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:
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user