feat(records): 报废记录接入高级筛选

复用 app/utils/advanced_filter.py,但报废有两处与出库/借还不同,需特殊处理:

一、scrap_request_no 可为 NULL(历史直接报废)
  出库/借还可直接对单号列做 IN,报废不行——「单号 IN」永远匹配不上 NULL 行,
  会把历史单整体漏掉。此处统一用「行级等价键」表达同单关系:
    有单号 → scrap_request_no 相等
    无单号 → 同一分钟(date_trunc) + 同一操作人(与 Python 侧分组逻辑完全一致)

二、物料名不能用单号传递结果
  物料名需三表联查,但联查结果若用「单号 IN」回接,同样会漏掉 NULL 单号行。
  改用 tuple_(source_table, stock_id).in_(pairs) 元组定位报废行。

三、否定操作符
  命中行 → 折算同单等价键谓词 → 否定时整体取反(~pred),实现整单排除。

前端 scrap/index.vue 新增「高级筛选」el-popover,与出库/借还版式一致。

验证(库中当前唯一报废单含 SKU 0000000272):
  sku contains 0000000272 → 1 单
  sku ne 0000000272       → 0 单(整单排除,符合预期)
  material_name not_contains 白板 → 0 单(该单物料名含白板,被排除)
  单号 ne(父级)          → 1 单(父级否定语义不变)
This commit is contained in:
yueli
2026-09-10 13:06:07 +08:00
parent fd0bfd3d9c
commit 1ef9ae4ad9
2 changed files with 201 additions and 1 deletions

View File

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

View File

@ -38,6 +38,43 @@
<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-form-item>
</el-form>
@ -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<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()
}
// 损失金额可见性:与后端 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;
}
</style>