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;