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:
yueli
2026-09-28 10:52:25 +08:00
parent 956d4acb5f
commit 89db1d14d3
2 changed files with 122 additions and 5 deletions

View File

@ -14,6 +14,7 @@ from app.services.audit_export_service import (
build_audit_workbook, build_audit_workbook,
changes_summary, changes_summary,
format_target_display, format_target_display,
load_child_lookup,
load_material_context, load_material_context,
load_ref_maps, load_ref_maps,
sanitize_details, sanitize_details,
@ -222,6 +223,8 @@ def _serialize_logs(rows):
# 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。 # 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。
# 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。 # 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。
materials = load_material_context(rows) materials = load_material_context(rows)
# ★ 父子关系记录(BOM)的子件:批量解析,无 N+1。见 load_child_lookup。
children = load_child_lookup(rows)
out = [] out = []
for r in rows: for r in rows:
d = r.to_dict() d = r.to_dict()
@ -234,7 +237,8 @@ def _serialize_logs(rows):
# 保留原始 target_name(搜索仍按它匹配),展示用 target_display # 保留原始 target_name(搜索仍按它匹配),展示用 target_display
d['target_display'] = format_target_display(mat) d['target_display'] = format_target_display(mat)
d['summary'] = changes_summary( 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) out.append(d)
return out return out

View File

@ -553,6 +553,91 @@ def _material_ref_of(row, sku_map, stock_map):
return base_id, _display_code(row, sku) 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): def load_material_context(rows):
""" """
一批审计行 → {row.id: {'base_id','code','name','spec','unit'}}。 一批审计行 → {row.id: {'base_id','code','name','spec','unit'}}。
@ -964,7 +1049,16 @@ def _core_attrs(snap, material=None):
return parts 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 用。 一行日志的变更摘要(一句话),供台账 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)}" parts = [f"{label}:{fmt_value(old)}→{fmt_value(new)}"
for label, old, new in resolved] 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) source = url_source(row.url)
return f"{body}(来源:/{source})" if source else body 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) mod = module_display(row.module)
prefix = f"{verb}({mod}):" if mod else f"{verb}:" 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) _title, snap = snapshot_of_any(row)
if not isinstance(snap, dict): if not isinstance(snap, dict):
return '' 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) core = _core_attrs(snap, material)
if core: if core:
@ -1179,6 +1290,7 @@ def build_audit_workbook(rows, ref_maps=None, summary_rows=None, note=None):
if ref_maps is None: if ref_maps is None:
ref_maps = load_ref_maps(rows) ref_maps = load_ref_maps(rows)
materials = load_material_context(rows) materials = load_material_context(rows)
children = load_child_lookup(rows)
# ---------- 审计日志(台账)---------- # ---------- 审计日志(台账)----------
ledger = [] 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 display = format_target_display(mat) or None
ledger.append( ledger.append(
audit_head_values(r, display=display) 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 ''] r.ip_address or '']
) )
if changes is None: if changes is None: