feat(records): 高级筛选引擎 + 出库记录接入

一、新增共享工具 app/utils/advanced_filter.py
  系统内已有该模式(material/list.vue、stock/inbound/buy.vue),
  沿用其既有约定:参数名 advancedFilters、值为 JSON 字符串、
  操作符 eq/ne/contains/not_contains/ge/le。

  · parse_advanced_filters() 解析并规整,坏输入退化为空列表不影响主查询
  · build_predicate() 单条件 → SQLAlchemy 谓词,未登记字段返回 None 杜绝列注入
  · build_material_name_select() 物料名三表联查(buy/semi/product JOIN material_base)

二、★ 父子关系处理(本次核心)
  记录接口返回的是**按单号分组的订单**,而用户筛选字段多落在**明细行**上。
  若直接 .filter(TransOutbound.sku.ilike(...)),会在 GROUP BY 前收窄明细范围,
  展开行里的兄弟明细会凭空消失。正确做法是先求「含匹配明细的单号集合」
  再让主查询按单号 IN 过滤。

  实测对照(单 OUT-20260811-1519-0003,21 条明细):
    按其中一条 SKU 筛选 → 子查询法保住全部 21 条;直接 filter 只剩 1 条。

三、★ 否定操作符语义(NOT IN)
  子级字段的 ne / not_contains 不能直接用 SQL != / NOT LIKE —— 那表达的是
  「本单存在某条不等于 X 的明细」,多明细单几乎必然成立,等于筛选失效。
  用户意图是**整单排除**,故 apply_child_condition() 统一:
    肯定 → order_no     IN (含匹配明细的单号)
    否定 → order_no NOT IN (含匹配明细的单号)
  两者子查询完全一致(都用肯定形式谓词),仅外层取反。
  父级字段(单号/操作人)仍走标准 SQL 谓词,语义无歧义。

四、出库记录接入(前端弹窗 + 后端接线)

验证:
  eq 0000000002 → 1 单;material_name contains 白板 → 16 单
  sku ne 0000000002 → 394 = 395-1,含该 SKU 的单被整体排除
  material_name not_contains 白板 → 379 = 395-16
This commit is contained in:
yueli
2026-09-10 13:05:59 +08:00
parent c35ec9a659
commit e437b3cece
4 changed files with 407 additions and 4 deletions

View File

@ -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→eqnot_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))