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; +}