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

@ -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,
)
# 字段级脱敏

View File

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

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))

View File

@ -43,6 +43,44 @@
<el-form-item>
<el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<!-- 高级筛选对齐 material/list.vue 既有模式 -->
<el-popover
v-model:visible="advancedFilterVisible"
placement="bottom"
title="高级筛选"
width="600"
trigger="manual"
>
<template #reference>
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
高级筛选
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
</el-button>
</template>
<div class="advanced-filter">
<div
v-for="(condition, index) in advancedConditions"
:key="index"
class="condition-row"
>
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
</div>
<div style="margin-top:12px">
<el-button type="primary" link @click="addCondition">添加条件</el-button>
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
<el-button @click="resetAdvancedFilter">重置</el-button>
</div>
</div>
</el-popover>
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="success" @click="$router.push('/outbound/create')">新建出库</el-button>
</el-form-item>
</el-form>
@ -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<any[]>([])
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;