diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 755ca88..7c2da9b 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -175,6 +175,12 @@ def get_outbound_list(): search_type = request.args.get('search_type', 'all') company = request.args.get('company', '') + # ★ 高级筛选:JSON 字符串 → 条件列表(解析失败退化为空,不影响主查询) + from app.utils.advanced_filter import parse_advanced_filters + advanced_filters = parse_advanced_filters( + request.args.get('advancedFilters', '') + ) + # ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;管理者看全部 consumer_name = None if not is_privileged_viewer(): @@ -189,7 +195,8 @@ def get_outbound_list(): # ★ [修改] 调用分组查询服务,支持搜索类型 result = OutboundService.get_grouped_list( page, limit, keyword, search_type=search_type, - company=company, consumer_name=consumer_name + company=company, consumer_name=consumer_name, + advanced_filters=advanced_filters, ) # 字段级脱敏 diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index 6d07532..d4cfa73 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -327,12 +327,13 @@ class OutboundService: raise e @staticmethod - def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None, consumer_name=None): + def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None, consumer_name=None, advanced_filters=None): """ 查询出库记录(按出库单号分组),包含详细物品信息 支持跨表搜索:单号、领用人、SKU、物料名称、规格型号 search_type: all, no, name, sku, material_name, spec_model company: 可选的公司过滤参数 + advanced_filters: 高级筛选条件列表 [{'field','operator','value'}, ...] """ # 日期补全:解决零点截断问题 if end_date and len(str(end_date).strip()) == 10: @@ -536,6 +537,51 @@ class OutboundService: if keyword_conditions is not None: stmt = stmt.filter(keyword_conditions) + # ==================================================================== + # ★ 高级筛选:父级字段直接过滤,子级字段(SKU/物料名称)走 + # 「命中单号子查询 → 按单号 IN」的 EXISTS 语义。 + # + # 绝不能写成 stmt.filter(TransOutbound.sku.ilike(...)): + # 那样会在 GROUP BY 前收窄明细范围,展开行里的兄弟明细会丢失。 + # ==================================================================== + if advanced_filters: + from app.utils.advanced_filter import ( + build_predicate, apply_child_condition, + ) + + # 父级字段:单号/操作人本身就在流水表上,直接 filter(否定操作符 + # 走标准 SQL != / NOT LIKE 即可,语义无歧义) + parent_field_map = { + 'no': TransOutbound.outbound_no, + 'operator': TransOutbound.operator_name, + 'consumer_name': TransOutbound.consumer_name, + 'outbound_type': TransOutbound.outbound_type, + } + # 子级字段:SKU 直接列 + 物料名称(需三表联查) + child_field_map = {'sku': TransOutbound.sku} + material_stock_models = [ + (StockBuy, 'stock_buy'), + (StockSemi, 'stock_semi'), + (StockProduct, 'stock_product'), + ] + + for cond in advanced_filters: + field = cond.get('field') + + if field in parent_field_map: + p = build_predicate(cond, parent_field_map) + if p is not None: + stmt = stmt.filter(p) + continue + + if field in child_field_map or field == 'material_name': + # ★ 正/负操作符语义分派:否定 → 整单排除(NOT IN) + stmt = apply_child_condition( + stmt, TransOutbound.outbound_no, TransOutbound, cond, + child_field_map, material_stock_models, + ) + continue + if start_date and end_date: stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date)) diff --git a/inventory-backend/app/utils/advanced_filter.py b/inventory-backend/app/utils/advanced_filter.py new file mode 100644 index 0000000..2ab9b53 --- /dev/null +++ b/inventory-backend/app/utils/advanced_filter.py @@ -0,0 +1,259 @@ +""" +高级筛选(Advanced Filters)共享解析与谓词构建工具。 + +设计背景 +-------- +记录类接口(出库 / 借还 / 报废)对外返回的是**按单号分组的订单**(父), +而用户筛选用的字段往往落在**明细行**上(子),例如「SKU 包含 123」。 + +若直接在分组查询上加 .filter(TransOutbound.sku.ilike('%123%')),分组范围会 +被一起收窄——GROUP BY 之后只剩匹配的那条明细,展开行里的其它兄弟明细会 +凭空消失。正确做法是:先求出「含匹配明细的单号集合」,再让主查询按该集合 +过滤,即 IN / EXISTS 子查询语义。 + +本模块只负责保证三个模块的操作符语义不漂移: + 1. parse_advanced_filters() 解析并规整前端传来的 JSON 字符串 + 2. build_predicate() 把单个条件翻译成 SQLAlchemy 谓词 + 3. build_material_name_subquery() 物料名称的三表联查子查询(三模块共用) + +字段 → 列的映射由各调用方提供(allowed_fields),因为三个模块的操作人/单号 +列名不同。 +""" +import json +import logging + +logger = logging.getLogger(__name__) + +# 与前端 operatorOptions 保持一致的受支持操作符 +SUPPORTED_OPERATORS = { + 'eq', 'ne', 'contains', 'not_contains', 'ge', 'le', +} + +# ★ 否定操作符 → 其等价的肯定形式。 +# 子级字段(SKU/物料名称)的否定语义必须借「肯定形式求反」来实现, +# 不能直接用 SQL 的 != / NOT LIKE,原因见 apply_child_condition 的说明。 +NEGATIVE_OPERATORS = { + 'ne': 'eq', + 'not_contains': 'contains', +} + + +def invert_condition(condition): + """ + 把否定条件翻转为等价的肯定条件(ne→eq,not_contains→contains)。 + + 非否定操作符原样返回。 + """ + op = condition.get('operator') + if op in NEGATIVE_OPERATORS: + flipped = dict(condition) + flipped['operator'] = NEGATIVE_OPERATORS[op] + return flipped + return dict(condition) + + +def is_negative(condition): + return condition.get('operator') in NEGATIVE_OPERATORS + + +def parse_advanced_filters(raw): + """ + 解析 advancedFilters 查询参数。 + + 前端传的是 JSON.stringify 后的数组字符串;同时兼容已被解析为 list 的情况 + (测试或内部调用)。任何解析失败都退化为空列表(不影响主查询), + 并记 warning 便于排查。 + """ + if not raw: + return [] + + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (ValueError, TypeError) as e: + logger.warning(f"[advanced_filter] JSON 解析失败,已忽略: {e}") + return [] + + if not isinstance(raw, list): + logger.warning(f"[advanced_filter] 期望 list,实际 {type(raw).__name__},已忽略") + return [] + + parsed = [] + for cond in raw: + if not isinstance(cond, dict): + continue + field = str(cond.get('field') or '').strip() + operator = str(cond.get('operator') or '').strip() + value = cond.get('value') + + # 字段与操作符必须有效;值允许为 0 / '0',故用 is None 判定而非真值 + if not field or operator not in SUPPORTED_OPERATORS or value is None: + continue + if isinstance(value, str) and value.strip() == '': + continue + + parsed.append({'field': field, 'operator': operator, 'value': value}) + + return parsed + + +def build_predicate(condition, allowed_fields): + """ + 把单个条件翻译成 SQLAlchemy 谓词。 + + allowed_fields: {field_name: Column} —— 调用方给出的白名单映射, + 未登记的字段返回 None(杜绝任意列注入)。 + + 数值型操作符 ge/le 在值无法转 float 时返回 None 跳过该条件, + 与既有 buy_service 的处理保持一致,避免整条查询 500。 + """ + field = condition.get('field') + operator = condition.get('operator') + value = condition.get('value') + + column = allowed_fields.get(field) + if column is None: + return None + + if operator == 'eq': + return column == value + if operator == 'ne': + return column != value + if operator == 'contains': + return column.ilike(f'%{value}%') + if operator == 'not_contains': + return ~column.ilike(f'%{value}%') + if operator == 'ge': + try: + return column >= float(value) + except (ValueError, TypeError): + return None + if operator == 'le': + try: + return column <= float(value) + except (ValueError, TypeError): + return None + return None + + +def build_material_name_select(model, order_no_column, stock_models, keyword, + operator='contains'): + """ + 构造「物料名称命中」的单号查询(三表联查,出库/借还/报废共用)。 + + material_name 不在流水表上,需经 stock_buy / stock_semi / stock_product + 三张库存表 JOIN material_base 才能取到。任一来源命中即算命中,故三路 + union 后再交给调用方做 order_no_column.in_(...)。 + + 这是"子级字段 → 父级订单"的核心:调用方对**单号**做 IN 过滤, + 而不是对明细行做 filter,从而保住同单的其它兄弟明细。 + + 参数 + ---- + model : 流水模型(TransOutbound / TransBorrow / TransScrap) + order_no_column: 该模型上的单号列 + stock_models : [(StockModel, source_table_value), ...] + keyword : 匹配值 + operator : 'contains'(默认)或 'eq' + + 返回 + ---- + 可直接用于 order_no_column.in_(...) 的 select,或 None(无有效来源/无关键词)。 + """ + if not keyword: + return None + if not stock_models: + return None + + from sqlalchemy import and_ + from app.models.base import MaterialBase + + matched = MaterialBase.name == keyword if operator == 'eq' \ + else MaterialBase.name.ilike(f'%{keyword}%') + + selects = [] + for StockModel, source_value in stock_models: + s = ( + db_session_query(model, order_no_column) + .join(StockModel, and_(model.stock_id == StockModel.id, + model.source_table == source_value)) + .join(MaterialBase, StockModel.base_id == MaterialBase.id) + .filter(matched) + ) + selects.append(s) + + combined = selects[0] + for extra in selects[1:]: + combined = combined.union(extra) + return combined + + +def db_session_query(model, *columns): + """按需构造 Query(延迟导入 db,避免模块级导入环)""" + from app.extensions import db + return db.session.query(*columns) + + +def apply_child_condition(stmt, order_no_column, model, condition, + child_field_map, material_stock_models=None): + """ + ★ 把**子级**条件应用到主查询上,正/负操作符语义有别。 + + 为什么否定操作符不能直接用 SQL 的 != / NOT LIKE + --------------------------------------------------- + 订单是父、明细是子。若对明细行写 `sku != 'X'`,得到的是 + 「本单存在某条 SKU 不等于 X 的明细」——多明细单几乎必然成立, + 于是「SKU 不等于 X」会返回几乎全部订单,与用户直觉相悖。 + + 用户点「不等于」时的真实意图是:**整单排除**——只要单内任意一条明细 + 命中 X,整张单就不该出现。即 NOT EXISTS / NOT IN 语义。 + + 实现方式 + -------- + 肯定操作符: order_no IN (含匹配明细的单号) + 否定操作符: order_no NOT IN (含匹配明细的单号) + + 两者的子查询完全一致(都用肯定形式的谓词),只是外层取反, + 因此 ne / not_contains 只需翻转为 eq / contains 后走同一条路径。 + + 参数 + ---- + stmt : 主查询(已 GROUP BY 或待过滤的 Query) + order_no_column : 单号列 + model : 流水模型(用于物料名称的三表联查) + condition : 单个高级筛选条件 + child_field_map : {field: Column} 明细级直接字段白名单(如 sku) + material_stock_models : [(StockModel, source_table_value), ...], + 提供后可支持 material_name 字段 + + 返回 + ---- + 过滤后的 stmt;条件不可用(字段未登记/值为非法类型)时原样返回。 + """ + from app.extensions import db + + negative = is_negative(condition) + probe = invert_condition(condition) # 一律以肯定形式构造子查询 + field = probe.get('field') + + inner = None + if field == 'material_name': + if not material_stock_models: + return stmt + sel = build_material_name_select( + model, order_no_column, material_stock_models, + probe.get('value'), operator=probe.get('operator', 'contains'), + ) + if sel is None: + return stmt + inner = sel + else: + p = build_predicate(probe, child_field_map) + if p is None: + return stmt + sub = db.session.query(order_no_column).filter(p).distinct().subquery() + inner = sub.select().with_only_columns(sub.c[0]) + + # ★ 否定 → 整单排除(NOT IN);肯定 → 整单命中(IN) + return stmt.filter(~order_no_column.in_(inner) if negative + else order_no_column.in_(inner)) diff --git a/inventory-web/src/views/outbound/index.vue b/inventory-web/src/views/outbound/index.vue index 66a369f..9f09a7e 100644 --- a/inventory-web/src/views/outbound/index.vue +++ b/inventory-web/src/views/outbound/index.vue @@ -43,6 +43,44 @@ 查询 重置 + + + + +
+
+ + + + + + + + 删除 +
+
+ 添加条件 + 应用筛选 + 重置 +
+
+
+ 新建出库
@@ -230,16 +268,59 @@ const listQuery = reactive({ keyword: '', search_type: 'all', dateRange: [], - company: '' as string + company: '' 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() +} + const fetchData = async () => { loading.value = true try { const params = { ...listQuery, start_date: listQuery.dateRange && listQuery.dateRange[0] ? listQuery.dateRange[0] : null, - end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null + end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null, + // ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串 + advancedFilters: JSON.stringify(listQuery.advancedFilters || []), } const res = await getOutboundList(params) @@ -269,6 +350,9 @@ const resetFilter = () => { listQuery.keyword = '' listQuery.search_type = 'all' listQuery.dateRange = [] + listQuery.advancedFilters = [] + advancedConditions.value = [{ field: '', operator: '', value: '' }] + appliedConditions.value = [] listQuery.page = 1 fetchData() } @@ -322,6 +406,13 @@ onBeforeUnmount(() => { margin-right: 12px; } +/* 高级筛选条件行 */ +.condition-row { + display: flex; + align-items: center; + margin-bottom: 10px; +} + .signature-cell { display: flex; justify-content: center;