From 89db1d14d3c152530be2501a2cef45eeb081ce81 Mon Sep 17 00:00:00 2001 From: yueli Date: Mon, 28 Sep 2026 10:52:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20BOM=20=E6=91=98=E8=A6=81?= =?UTF-8?q?=E5=B8=A6=E5=87=BA=E5=AD=90=E4=BB=B6=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E6=8F=92=E5=85=A5=E7=9A=84=E5=88=B7=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: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。 --- inventory-backend/app/api/v1/audit.py | 6 +- .../app/services/audit_export_service.py | 121 +++++++++++++++++- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/inventory-backend/app/api/v1/audit.py b/inventory-backend/app/api/v1/audit.py index 34d4f5d..bb6c30e 100644 --- a/inventory-backend/app/api/v1/audit.py +++ b/inventory-backend/app/api/v1/audit.py @@ -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 diff --git a/inventory-backend/app/services/audit_export_service.py b/inventory-backend/app/services/audit_export_service.py index b757015..d609bb2 100644 --- a/inventory-backend/app/services/audit_export_service.py +++ b/inventory-backend/app/services/audit_export_service.py @@ -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: