feat(audit): BOM 摘要带出子件,消除批量插入的刷屏
问题:BOM 一次新建会批量插入几十条子件关联记录,同一秒出现几十行
target_name 与 summary 完全相同的日志。实测同一 bom_no 同一秒最多 58 条,
整页 200 条只折叠出 16 种摘要。UPDATE 更严重 —— 批量归档时一种摘要
(「是否启用:是→否;是否归档:否→是」)重复了 135 次。
改动:changes_summary 增加子件分支,load_child_lookup 解析子件。
两条解析路径:
1. 快照里的 child_id —— CREATE/DELETE(实测 BOM CREATE 覆盖 100%)
2. 否则若 module 是 BOM,用 target_id 反查 bom_table —— UPDATE
(实测 584/634 可解)
★ 为什么必须按 module 门控,不能靠「target_id 命中 bom_table」这个特征:
后者实测**大量误命中** —— 入库管理 3655 条、系统管理的 /permissions/assign
1415 条、image_embeddings 1187 条、出库管理 726 条,它们的 target_id 都会
撞上某条 bom_table 记录。照着补子件名就是把毫不相干的零件安到别的记录上。
而 module 由**表名**推得(audit_listener: bom_table → 'BOM管理'),
按 module 判定等价于按表判定,是可靠的。
★ 子件名直接可用,不必退回 #ID:child_id 已在 load_ref_maps 的批量解析
范围内,且 load_child_lookup 自身也只做一次 MaterialBase 批量查
(实测 200 行 1 次查询、20 行 1 次查询,与行数无关,无 N+1)。
摘要格式:
新增(BOM管理):添加子件 SF-9000 机加工配件(用量 1)
删除(BOM管理):移除子件 四代一体-侧面壳(用量 1)
子件 9-36V输入5V输出隔离模块15W:是否启用:是→否;是否归档:否→是(来源:/bom/archive)
用量只在快照里有;UPDATE 行拿不到就不显示 —— 宁可不显示,也不去猜。
效果(实测):
· 同一秒 58 条 → 58 种不同摘要
· 整页 200 条 CREATE:改造前 16 种 → 改造后 76 种
· UPDATE:改造前 300 条折叠成 5 种(最多重复 135)→ 去重种类显著提升
· 非 BOM 记录零污染(系统管理/入库管理/出库管理 各 200 条,含「子件」0 条)
验证:16 + 18 项断言全过;日报三天附件 490.5K / 193.4K / 10.3K 与 6 列结构未变;
导出口径在 4 组筛选下与列表一致;权限与凭据过滤未松动;
字典 182 个键零缺失;全库 0 条退化到原始 JSON。vue-tsc 与 vite build exit=0。
This commit is contained in:
@ -14,6 +14,7 @@ from app.services.audit_export_service import (
|
||||
build_audit_workbook,
|
||||
changes_summary,
|
||||
format_target_display,
|
||||
load_child_lookup,
|
||||
load_material_context,
|
||||
load_ref_maps,
|
||||
sanitize_details,
|
||||
@ -222,6 +223,8 @@ def _serialize_logs(rows):
|
||||
# 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。
|
||||
# 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。
|
||||
materials = load_material_context(rows)
|
||||
# ★ 父子关系记录(BOM)的子件:批量解析,无 N+1。见 load_child_lookup。
|
||||
children = load_child_lookup(rows)
|
||||
out = []
|
||||
for r in rows:
|
||||
d = r.to_dict()
|
||||
@ -234,7 +237,8 @@ def _serialize_logs(rows):
|
||||
# 保留原始 target_name(搜索仍按它匹配),展示用 target_display
|
||||
d['target_display'] = format_target_display(mat)
|
||||
d['summary'] = changes_summary(
|
||||
r, ref_maps, limit=SUMMARY_LIMIT, material=mat)
|
||||
r, ref_maps, limit=SUMMARY_LIMIT, material=mat,
|
||||
child=children.get(r.id))
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
@ -553,6 +553,91 @@ def _material_ref_of(row, sku_map, stock_map):
|
||||
return base_id, _display_code(row, sku)
|
||||
|
||||
|
||||
# BOM 是唯一一张「父件—子件」关系表:一行 = 父件用 N 个某子件。
|
||||
#
|
||||
# ★ 为什么必须按 module 判定,不能靠「target_id 命中 bom_table」这个特征:
|
||||
# 后者实测**大量误命中** —— 入库管理 3655 条、系统管理的 /permissions/assign
|
||||
# 1415 条、image_embeddings 1187 条、出库管理 726 条,它们的 target_id 都会
|
||||
# 撞上某条 bom_table 记录。照着补子件名就是把毫不相干的零件安到别的记录上。
|
||||
#
|
||||
# 而 module 是由**表名**推得的(audit_listener._get_module_name:
|
||||
# bom_table → 'BOM管理'),按 module 判定等价于按表判定,是可靠的。
|
||||
_BOM_MODULE = 'BOM管理'
|
||||
|
||||
|
||||
def _child_id_from_snapshot(row):
|
||||
"""CREATE/DELETE 快照里的 child_id;UPDATE 没有快照,返回 None"""
|
||||
for which in (SNAPSHOT_CREATED, SNAPSHOT_DELETED):
|
||||
cid = as_int(snapshot_of(row, which).get('child_id'))
|
||||
if cid:
|
||||
return cid
|
||||
return None
|
||||
|
||||
|
||||
def load_child_lookup(rows):
|
||||
"""
|
||||
一批审计行 → {row.id: {'id','name','dosage'}} —— 父子关系里的**子件**。
|
||||
|
||||
父子关系记录(BOM)批量插入时,同一秒会有几十条 target_name 完全相同的
|
||||
日志,光看父件分不出哪条对应哪个子件。这里把子件解出来供摘要差异化。
|
||||
|
||||
两条路径:
|
||||
1. 快照里的 child_id —— CREATE/DELETE(实测 BOM CREATE 覆盖率 100%)
|
||||
2. 否则若 module 是 BOM,用 target_id 反查 bom_table —— UPDATE
|
||||
(实测 584/634 可解)
|
||||
|
||||
★ 子件名称**批量**解析(一次 MaterialBase 查询),不逐行查库:
|
||||
BOM 一次插入几十条,逐行反查就是 N+1。
|
||||
|
||||
★ 用量只在快照里有;UPDATE 行拿不到就不显示 —— 宁可不显示,也不去猜。
|
||||
"""
|
||||
if not rows:
|
||||
return {}
|
||||
|
||||
picked = {} # row_pk -> (child_id, dosage)
|
||||
need_lookup = [] # 无快照 child_id 的 BOM 行,留给 bom_table 兜底
|
||||
for r in rows:
|
||||
if (r.module or '') != _BOM_MODULE:
|
||||
continue
|
||||
cid = _child_id_from_snapshot(r)
|
||||
if cid:
|
||||
dosage = None
|
||||
for which in (SNAPSHOT_CREATED, SNAPSHOT_DELETED):
|
||||
snap = snapshot_of(r, which)
|
||||
if snap.get('dosage') is not None:
|
||||
dosage = snap.get('dosage')
|
||||
break
|
||||
picked[r.id] = (cid, dosage)
|
||||
else:
|
||||
tid = as_int(getattr(r, 'target_id', None))
|
||||
if tid is not None:
|
||||
need_lookup.append((r.id, tid))
|
||||
|
||||
if need_lookup:
|
||||
from app.models.bom import BomTable
|
||||
|
||||
ids = {tid for _, tid in need_lookup}
|
||||
found = {
|
||||
b.id: b.child_id
|
||||
for b in BomTable.query.filter(BomTable.id.in_(ids)).all()
|
||||
}
|
||||
for rid, tid in need_lookup:
|
||||
cid = as_int(found.get(tid))
|
||||
if cid:
|
||||
picked[rid] = (cid, None)
|
||||
|
||||
if not picked:
|
||||
return {}
|
||||
|
||||
child_ids = {cid for cid, _ in picked.values()}
|
||||
names = load_material_name_map(child_ids)
|
||||
|
||||
return {
|
||||
rid: {'id': cid, 'name': names.get(cid, ''), 'dosage': dosage}
|
||||
for rid, (cid, dosage) in picked.items()
|
||||
}
|
||||
|
||||
|
||||
def load_material_context(rows):
|
||||
"""
|
||||
一批审计行 → {row.id: {'base_id','code','name','spec','unit'}}。
|
||||
@ -964,7 +1049,16 @@ def _core_attrs(snap, material=None):
|
||||
return parts
|
||||
|
||||
|
||||
def changes_summary(row, ref_maps, limit=200, resolved=None, material=None):
|
||||
def _child_text(child):
|
||||
"""子件的展示名:优先物资名,退化成 #ID;两者都没有返回空串"""
|
||||
if not child:
|
||||
return ''
|
||||
return (child.get('name') or '').strip() or (
|
||||
f"#{child['id']}" if child.get('id') else '')
|
||||
|
||||
|
||||
def changes_summary(row, ref_maps, limit=200, resolved=None, material=None,
|
||||
child=None):
|
||||
"""
|
||||
一行日志的变更摘要(一句话),供台账 sheet 用。
|
||||
|
||||
@ -984,14 +1078,22 @@ def changes_summary(row, ref_maps, limit=200, resolved=None, material=None):
|
||||
# 里很难看,也和日报正文的排版不一致。
|
||||
parts = [f"{label}:{fmt_value(old)}→{fmt_value(new)}"
|
||||
for label, old, new in resolved]
|
||||
body = ';'.join(parts)
|
||||
# ★ BOM 的 UPDATE 行必须带出子件:一次批量归档会同时改几十行,
|
||||
# 光看"是否启用 是→否"完全分不出改的是哪个子件(实测一种摘要
|
||||
# 重复 135 次)。子件名放在最前面,扫一眼就能区分。
|
||||
ct = _child_text(child)
|
||||
if ct:
|
||||
body = f"子件 {ct}:{body}"
|
||||
# ★ 来源**拼在截断之后**:先截变更内容、再追加来源,保证来源这条关键
|
||||
# 信息永远不会被截掉。反过来(整个串一起截)会把来源切没,
|
||||
# 而"哪触发的"正是这条摘要存在的理由。
|
||||
body = truncate(';'.join(parts), limit)
|
||||
body = truncate(body, limit)
|
||||
source = url_source(row.url)
|
||||
return f"{body}(来源:/{source})" if source else body
|
||||
|
||||
verb = '新增' if canon_action(row.action) == 'CREATE' else '删除'
|
||||
is_create = canon_action(row.action) == 'CREATE'
|
||||
verb = '新增' if is_create else '删除'
|
||||
mod = module_display(row.module)
|
||||
prefix = f"{verb}({mod}):" if mod else f"{verb}:"
|
||||
|
||||
@ -1001,6 +1103,15 @@ def changes_summary(row, ref_maps, limit=200, resolved=None, material=None):
|
||||
_title, snap = snapshot_of_any(row)
|
||||
if not isinstance(snap, dict):
|
||||
return ''
|
||||
# ★ 父子关系记录(BOM)优先走子件分支:一次新建会插入几十条,
|
||||
# 子件不同但父件相同,不把子件写出来就是几十行一模一样的日志。
|
||||
# 实测同一秒同一 bom_no 最多 58 条。
|
||||
ct = _child_text(child)
|
||||
if ct:
|
||||
body = f"{'添加' if is_create else '移除'}子件 {ct}"
|
||||
if (child or {}).get('dosage') is not None:
|
||||
body += f"(用量 {_fmt_number(child['dosage'])})"
|
||||
return truncate(prefix + body, limit)
|
||||
# 优先:核心属性的**值**(多少数量、放哪)—— 这才是用户想知道的
|
||||
core = _core_attrs(snap, material)
|
||||
if core:
|
||||
@ -1179,6 +1290,7 @@ def build_audit_workbook(rows, ref_maps=None, summary_rows=None, note=None):
|
||||
if ref_maps is None:
|
||||
ref_maps = load_ref_maps(rows)
|
||||
materials = load_material_context(rows)
|
||||
children = load_child_lookup(rows)
|
||||
|
||||
# ---------- 审计日志(台账)----------
|
||||
ledger = []
|
||||
@ -1191,7 +1303,8 @@ def build_audit_workbook(rows, ref_maps=None, summary_rows=None, note=None):
|
||||
display = format_target_display(mat) or None
|
||||
ledger.append(
|
||||
audit_head_values(r, display=display)
|
||||
+ [changes_summary(r, ref_maps, resolved=changes, material=mat),
|
||||
+ [changes_summary(r, ref_maps, resolved=changes, material=mat,
|
||||
child=children.get(r.id)),
|
||||
r.ip_address or '']
|
||||
)
|
||||
if changes is None:
|
||||
|
||||
Reference in New Issue
Block a user