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