diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py
index 3294858..681c6cb 100644
--- a/inventory-backend/app/api/v1/scrap.py
+++ b/inventory-backend/app/api/v1/scrap.py
@@ -161,6 +161,10 @@ def get_scrap_records():
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
+ # ★ 高级筛选:JSON 字符串 → 条件列表
+ from app.utils.advanced_filter import parse_advanced_filters
+ advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
+
try:
result = ScrapService.query_records(
page=page,
@@ -170,6 +174,7 @@ def get_scrap_records():
end_date=end_date,
keyword=keyword,
search_type=search_type,
+ advanced_filters=advanced_filters,
)
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
@@ -444,7 +449,7 @@ class ScrapService:
@staticmethod
def query_records(page=1, page_size=50, sku='', start_date='', end_date='',
- keyword='', search_type='all'):
+ keyword='', search_type='all', advanced_filters=None):
"""
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
@@ -469,6 +474,110 @@ class ScrapService:
elif keyword and search_type == 'sku':
query = query.filter(TransScrap.sku.ilike(f'%{keyword}%'))
+ # ====================================================================
+ # ★ 高级筛选
+ #
+ # 报废流水在 Python 侧按「单号 / (时间+操作人) 虚拟单号」分组,
+ # 且历史数据 scrap_request_no 为 NULL,无法像出库/借还那样直接对
+ # 单号列做 IN。因此这里统一用**行级等价键**表达父子关系:
+ #
+ # 1) 先用子级条件求出「命中行」;
+ # 2) 把命中行折算成"同单等价键"谓词;
+ # 3) 肯定操作符 → 放行同单全部行(OR),
+ # 否定操作符 → 整单排除(NOT ...)。
+ #
+ # 等价键:有单号 → scrap_request_no 相等;
+ # 无单号 → 同一分钟 + 同一操作人(与分组逻辑完全一致)。
+ # ====================================================================
+ if advanced_filters:
+ from app.utils.advanced_filter import (
+ build_predicate, is_negative, invert_condition,
+ )
+ from sqlalchemy import or_, and_, tuple_, func as _func
+
+ parent_map = {
+ 'no': TransScrap.scrap_request_no,
+ 'operator': TransScrap.operator_name,
+ }
+ child_map = {'sku': TransScrap.sku}
+
+ def _order_key_pred(rows):
+ """把若干命中行折算成「同单」谓词(OR 连接)"""
+ keys = []
+ for h in rows:
+ if h.scrap_request_no:
+ keys.append(and_(
+ TransScrap.scrap_request_no.isnot(None),
+ TransScrap.scrap_request_no == h.scrap_request_no,
+ ))
+ else:
+ keys.append(and_(
+ TransScrap.scrap_request_no.is_(None),
+ _func.date_trunc('minute', TransScrap.operation_time)
+ == _func.date_trunc('minute', h.operation_time),
+ TransScrap.operator_name == h.operator_name,
+ ))
+ return or_(*keys) if keys else None
+
+ def _matched_rows(cond):
+ """按肯定形式求出「命中该子级条件的报废行」"""
+ probe = invert_condition(cond)
+ field = probe.get('field')
+ if field == 'material_name':
+ # 物料名不在流水表:先在三张库存表求出 (source_table, stock_id)
+ # 命中集合,再用元组 IN 定位报废行。
+ # ★ 用元组 IN 而非"单号 IN":后者对 scrap_request_no 为 NULL 的
+ # 历史行永远匹配不上,会把历史数据整体漏掉。
+ from app.models.base import MaterialBase
+ name_col = MaterialBase.name
+ name_pred = (name_col == probe.get('value')
+ if probe.get('operator') == 'eq'
+ else name_col.ilike(f"%{probe.get('value')}%"))
+ pairs = []
+ for SM, sv in [(StockBuy, 'stock_buy'), (StockSemi, 'stock_semi'),
+ (StockProduct, 'stock_product')]:
+ for r in (SM.query.join(MaterialBase, SM.base_id == MaterialBase.id)
+ .filter(name_pred).all()):
+ pairs.append((sv, r.id))
+ if not pairs:
+ return None
+ return TransScrap.query.filter(
+ tuple_(TransScrap.source_table, TransScrap.stock_id).in_(pairs)
+ ).all()
+
+ p = build_predicate(probe, child_map)
+ if p is None:
+ return None
+ return TransScrap.query.filter(p).all()
+
+ for cond in advanced_filters:
+ field = cond.get('field')
+
+ # --- 父级字段:标准 SQL 谓词,否定操作符语义无歧义 ---
+ if field in parent_map:
+ p = build_predicate(cond, parent_map)
+ if p is not None:
+ query = query.filter(p)
+ continue
+
+ if field not in child_map and field != 'material_name':
+ continue
+
+ # --- 子级字段:正/负操作符语义分派 ---
+ negative = is_negative(cond)
+ rows = _matched_rows(cond)
+
+ if not rows:
+ # 没有命中行:肯定 → 结果为空;否定 → 无需排除任何单
+ if not negative:
+ query = query.filter(db.false())
+ continue
+
+ key_pred = _order_key_pred(rows)
+ if key_pred is None:
+ continue
+ query = query.filter(~key_pred if negative else key_pred)
+
# 【行级数据隔离】基于 JWT 多租户公司过滤
# 通过 stock 表或 trans_repair 关联到 MaterialBase
company_limit = get_current_company_filter()
diff --git a/inventory-web/src/views/operation/scrap/index.vue b/inventory-web/src/views/operation/scrap/index.vue
index 61df95b..18c7f04 100644
--- a/inventory-web/src/views/operation/scrap/index.vue
+++ b/inventory-web/src/views/operation/scrap/index.vue
@@ -38,6 +38,43 @@
查询
重置
+
+
+
+
+
+ 高级筛选
+
+
+
+
+
+
+
+
+
+
+
+
+ 删除
+
+
+ 添加条件
+ 应用筛选
+ 重置
+
+
+
@@ -159,8 +196,50 @@ const listQuery = reactive({
keyword: '',
search_type: 'all',
dateRange: [] as string[],
+ advancedFilters: [] as any[],
})
+// --- ★ 高级筛选 ---
+const advancedFilterVisible = ref(false)
+const advancedConditions = ref([{ field: '', operator: '', value: '' }])
+const appliedConditions = ref([]) // 已生效的条件(用于按钮角标)
+const fieldOptions = [
+ { value: 'no', label: '单号' },
+ { value: 'sku', label: 'SKU' },
+ { value: 'material_name', label: '物料名称' },
+ { value: 'operator', label: '操作人' },
+]
+const operatorOptions = [
+ { value: 'contains', label: '包含' },
+ { value: 'eq', label: '等于' },
+ { value: 'not_contains', label: '不包含' },
+ { value: 'ne', label: '不等于' },
+]
+
+const addCondition = () => {
+ advancedConditions.value.push({ field: '', operator: '', value: '' })
+}
+const removeCondition = (index: number) => {
+ advancedConditions.value.splice(index, 1)
+}
+const applyAdvancedFilter = () => {
+ // 过滤掉不完整的条件行
+ const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
+ listQuery.advancedFilters = valid
+ appliedConditions.value = valid
+ advancedFilterVisible.value = false
+ listQuery.page = 1
+ fetchData()
+}
+const resetAdvancedFilter = () => {
+ advancedConditions.value = [{ field: '', operator: '', value: '' }]
+ listQuery.advancedFilters = []
+ appliedConditions.value = []
+ advancedFilterVisible.value = false
+ listQuery.page = 1
+ fetchData()
+}
+
// 损失金额可见性:与后端 scrap_list:loss_amount 权限一致
const canViewLoss = computed(() =>
userStore.role === 'SUPER_ADMIN' || userStore.hasPermission('scrap_list:loss_amount')
@@ -174,6 +253,8 @@ const fetchData = async () => {
pageSize: listQuery.pageSize,
keyword: listQuery.keyword,
search_type: listQuery.search_type,
+ // ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
+ advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
}
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
params.start_date = listQuery.dateRange[0]
@@ -212,6 +293,9 @@ const resetFilter = () => {
listQuery.keyword = ''
listQuery.search_type = 'all'
listQuery.dateRange = []
+ listQuery.advancedFilters = []
+ advancedConditions.value = [{ field: '', operator: '', value: '' }]
+ appliedConditions.value = []
listQuery.page = 1
fetchData()
}
@@ -257,4 +341,11 @@ onBeforeUnmount(() => {
margin-bottom: 0;
margin-right: 12px;
}
+
+/* 高级筛选条件行 */
+.condition-row {
+ display: flex;
+ align-items: center;
+ margin-bottom: 10px;
+}