From 46aec0b9540f119eb9a6294107a793cd677b0f97 Mon Sep 17 00:00:00 2001 From: yueli Date: Mon, 28 Sep 2026 10:19:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=E6=8F=90=E5=8D=87=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E6=80=A7=20=E2=80=94=E2=80=94=20=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E8=A1=A5=E5=85=A8=E7=89=A9=E6=96=99=E5=90=8D?= =?UTF-8?q?=E3=80=81=E8=AF=A6=E6=83=85=E9=99=8D=E5=99=AA=E3=80=81=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=AD=97=E5=85=B8=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务方反馈两点:操作对象太干瘪、详情快照噪音太多。 1. 操作对象补全为「SKU - 物料名称 (规格型号)」 库里 target_name 大多只存了 SKU('0000002270'),入库类甚至存的是内部 标识('stock_buy ID:1667'),业务人员完全看不懂。 补全走两条路径,**正确性优先**: · 快照里的 base_id(CREATE/DELETE 的库存行)—— 权威,零歧义 · target_id 唯一命中一张股票表 —— 实测与上一条 1230/1230 完全一致 ★ 撞号一律放弃:target_id 同时命中 2 张股票表时解出的 base_id 24/27 是错的、命中 3 张时 44/44 全错。补一个**错的**物料名比不补更糟 —— 那是"看起来完全可信的错误答案",业务方会照着它去找不相干的物料。 实测 300 条撞号行 0 条被补,300 条唯一命中行全部补上。 target_name 原值保留(target_keyword 搜索仍按它匹配),新增 target_display 供展示。前端去掉灰显的 #target_id —— 业务人员不需要看数据库主键。 2. 详情快照降噪 · 前端过滤空值:null / '' / [] / {} / '-'。判据**严格**:false 和 0 不算空(`is_returned: false`、`quantity: 0` 是明确的业务事实, 用真值判断会把它们一起吃掉,那是在篡改数据)。 · 过滤纯技术字段:id / created_at / updated_at / target_id / module_name 及 pgvector 的 `*embedding`(单条可达数 KB)。清单由后端下发 (labels.hiddenFields),前端不硬编 —— 它会随新表增长,再存一份必然漂移。 embedding 类按后缀拦截,新表加向量列不必改代码。 · 变更对比表过滤「等于没改」的行(null ↔ 空串)。 3. 字段字典补齐:168 项 实测快照里出现过但字典没有的字段全部补上(buyer_email / currency / in_date / exchange_rate / dosage / loss_rate / child / parent / production_* / return_* / *_threshold 等 60+ 个)。 现在快照字段缺中文名的数量为 **0** —— 详情页不会再裸露英文。 4. 摘要优化 · changes_of 丢掉无意义变更(null ↔ 空串)。库里 98 条记录带这种变更, 写进摘要就是「备注:空→空」,纯噪音还挤占截断长度。 · CREATE/DELETE 摘要改为核心属性**按槽位取值**: 新增(入库):入库数量 10 件、库位 ZZTEST 而不是「新增:SKU、base、状态… 等 29 个字段」。 · 槽位只有数量与位置两个,**不含物料** —— 物料由「操作对象」列承担, 同一屏里再来一遍是重复。数量字段也按槽位只取一个: in_quantity / stock_quantity / available_quantity 值往往相同, 取三个会得到三个一样的数字。 · 数字去掉无意义的 .0(10.0 → 10);单位取自物料主数据, 纯数字的占位单位(库里有一批 unit='1')丢弃。 验证:33 + 16 项断言全过,含「补全的物料与快照 base_id 零冲突」「撞号行 一律不补」「快照字段缺中文名 0 个」「摘要无空→空」「导出口径与列表一致」; vue-tsc --noEmit 与 vite build 均 exit=0。 日报回归:三天附件 490.5K / 193.4K / 10.3K,6 列结构未变。 --- inventory-backend/app/api/v1/audit.py | 13 +- .../app/services/audit_export_service.py | 273 ++++++++++++++++-- inventory-backend/app/utils/audit_labels.py | 124 ++++++++ inventory-web/src/views/system/AuditLog.vue | 63 +++- 4 files changed, 436 insertions(+), 37 deletions(-) diff --git a/inventory-backend/app/api/v1/audit.py b/inventory-backend/app/api/v1/audit.py index 4cc931b..b12f59e 100644 --- a/inventory-backend/app/api/v1/audit.py +++ b/inventory-backend/app/api/v1/audit.py @@ -13,6 +13,8 @@ from app.services.audit_export_service import ( SYSTEM_USERNAME, build_audit_workbook, changes_summary, + format_target_display, + load_material_context, load_ref_maps, ) from app.utils.decorators import get_current_company_filter, permission_required @@ -214,11 +216,20 @@ def _serialize_logs(rows): if not rows: return [] ref_maps = load_ref_maps(rows) + # ★ 操作对象补全:库里 target_name 大多只存了 SKU('0000002270')甚至内部 + # 标识('stock_buy ID:1667'),业务人员看不懂。补成 + # 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。 + # 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。 + materials = load_material_context(rows) out = [] for r in rows: d = r.to_dict() d['action'] = canon_action(d.get('action')) - d['summary'] = changes_summary(r, ref_maps, limit=SUMMARY_LIMIT) + mat = materials.get(r.id) + # 保留原始 target_name(搜索仍按它匹配),展示用 target_display + d['target_display'] = format_target_display(mat) + d['summary'] = changes_summary( + r, ref_maps, limit=SUMMARY_LIMIT, material=mat) 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 6220c5a..b6814b3 100644 --- a/inventory-backend/app/services/audit_export_service.py +++ b/inventory-backend/app/services/audit_export_service.py @@ -41,6 +41,7 @@ from app.utils.audit_labels import ( enum_label, field_label, id_ref_of, + module_display, person_name_label, ) @@ -103,6 +104,38 @@ TEXT_VALUE_LIMIT = 40 # 基础类型转换 # ============================================================================= +def is_blank(val): + """ + 值是否「空」—— 用于过滤无意义的变更与快照字段。 + + ★ 判据必须**严格**:None / '' / [] / {} / '-' 才算空。 + **false 和 0 不算空** —— `is_returned: false`、`quantity: 0` 是明确的 + 业务事实,用 Python 的真值判断(`if not val`)会把它们一起吃掉, + 那是在悄悄篡改业务数据。 + """ + if val is None: + return True + if isinstance(val, str): + return val.strip() in ('', '-') + if isinstance(val, (list, dict)): + return len(val) == 0 + return False + + +def is_noop_change(old, new): + """ + 该变更是否「等于没改」。 + + ★ 典型来源:字段从 NULL 被写成空串(或反之)。库里实测有 98 条记录 + 带这种变更 —— 写进摘要就是「备注:空→空」,纯噪音,还会把真正有意义 + 的变更挤出截断长度之外。 + """ + if is_blank(old) and is_blank(new): + return True + # 空串与 NULL 在业务上是同一件事,两者之间的"变化"也不算改 + return text_of(old) == text_of(new) + + def as_int(value): """尽力转 int;转不了返回 None(bool 不算数 —— 它是 int 的子类)""" if isinstance(value, bool): @@ -312,6 +345,129 @@ REF_LOADERS = { } +# 「快照里带 base_id」= 这条记录就是关于某个物料的库存行 +_SNAPSHOT_BASE_ID_SOURCES = (SNAPSHOT_CREATED, SNAPSHOT_DELETED) + +# 库存三表 —— 入库数据分别落在采购/半成品/成品库存表里 +STOCK_TABLES = ('stock_buy', 'stock_semi', 'stock_product') + + +def _stock_row_lookup(target_ids): + """ + {target_id: (base_id, sku)} —— **仅在 target_id 恰好命中一张股票表**时给出。 + + ★ 为什么撞号必须放弃(这是本函数存在的全部理由): + 实测在库存类记录上,target_id「唯一命中」股票表时,解出的 base_id 与 + 快照里的 base_id **1230/1230 完全一致**;而一旦命中 2 张表就 24/27 错、 + 命中 3 张表就 44/44 全错。 + 补一个**错的**物料名比不补更糟 —— 那是"看起来完全可信的错误答案", + 业务方会照着它去找一个根本不相干的物料。 + + ★ 为什么按 id 去查三张表而不是按 module 定位: + 「入库管理」这个 module 名下混着三张表的记录,module 本身分辨不出是哪张; + 只能靠 id 命中情况反推,命中多张就是无法判定,直接放弃。 + """ + ids = set() + for t in target_ids: + iv = as_int(t) + if iv is not None: + ids.add(iv) + if not ids: + return {} + + from app.models.inbound.buy import StockBuy + from app.models.inbound.product import StockProduct + from app.models.inbound.semi import StockSemi + + hits = defaultdict(dict) # stock_id -> {表名: (base_id, sku)} + for name, model in (('buy', StockBuy), ('semi', StockSemi), ('product', StockProduct)): + for r in model.query.filter(model.id.in_(ids)).all(): + hits[r.id][name] = (getattr(r, 'base_id', None), getattr(r, 'sku', '') or '') + + return {sid: next(iter(tbls.values())) + for sid, tbls in hits.items() if len(tbls) == 1} + + +def load_material_context(rows): + """ + 一批审计行 → {row.id: {'sku','name','spec','unit','base_id'}}。 + + 只为「能无歧义定位到物料」的行给出结果: + 1. 快照里有 base_id(CREATE/DELETE 的库存行)—— 权威,零歧义; + 2. 否则 target_id 唯一命中一张股票表 —— 实测与 (1) 100% 一致。 + + 定位不到就**不放进去**,由调用方回落到原始 target_name —— + 宁可不补,也不能补错。 + """ + # 1) 先从快照取 base_id / sku + snap_ctx = {} # row_pk -> (base_id, sku) + target_ids = [] + for r in rows: + base_id, sku = None, '' + for which in _SNAPSHOT_BASE_ID_SOURCES: + snap = snapshot_of(r, which) + if not snap: + continue + base_id = as_int(snap.get('base_id')) or base_id + sku = sku or (snap.get('sku') or '') + if base_id: + snap_ctx[r.id] = (base_id, sku) + elif as_int(getattr(r, 'target_id', None)) is not None: + # 2) 没有快照 base_id 的(典型是 UPDATE),留给股票表兜底 + target_ids.append(r.target_id) + + stock_ctx = _stock_row_lookup(target_ids) + + # 汇总所有要查的 base_id,一次查完 + need_base = {b for b, _ in snap_ctx.values()} + need_base |= {b for b, _ in stock_ctx.values() if b} + if not need_base: + return {} + + bases = { + m.id: m for m in MaterialBase.query.filter(MaterialBase.id.in_(need_base)).all() + } + + out = {} + for r in rows: + ctx = snap_ctx.get(r.id) + if ctx is None: + ctx = stock_ctx.get(as_int(getattr(r, 'target_id', None))) + if not ctx: + continue + base_id, sku = ctx + b = bases.get(base_id) + if b is None: + continue + out[r.id] = { + 'base_id': base_id, + 'sku': (sku or '').strip(), + 'name': (b.name or '').strip(), + 'spec': (b.spec_model or '').strip(), + 'unit': (b.unit or '').strip(), + } + return out + + +def format_target_display(ctx, fallback=''): + """ + 物料上下文 → 「SKU - 物料名称 (规格型号)」。取不到就返回空串(调用方回落)。 + + ★ 缺哪段就省哪段,不留空括号/空横杠:材料名查不到时显示 + 「0000001180 - 」比只显示「0000001180」更让人困惑。 + """ + if not ctx: + return '' + head = ctx.get('sku') or '' + name, spec = ctx.get('name') or '', ctx.get('spec') or '' + if not (head or name): + return '' + tail = f"{name} ({spec})" if name and spec else (name or '') + if head and tail: + return f"{head} - {tail}" + return head or tail + + def _collect_ref(ref_ids, user_ids, module, key, value): """把一个候选值按类型塞进对应的收集集合(只收集,不查库)""" iv = as_int(value) @@ -375,10 +531,15 @@ def changes_of(row): if key in IGNORED_CHANGE_FIELDS: continue if isinstance(val, dict): - out.append((key, val.get('old'), val.get('new'))) + old, new = val.get('old'), val.get('new') else: # 兼容「非 {old,new} 结构」的历史写法:整值视为新值 - out.append((key, None, val)) + old, new = None, val + # ★ 等于没改的(NULL ↔ 空串)在这里就丢掉:它会污染摘要(「备注:空→空」) + # 并把真正有意义的变更挤出截断长度;在 Excel 里也白占一行。 + if is_noop_change(old, new): + continue + out.append((key, old, new)) return out @@ -512,7 +673,60 @@ def url_source(url): return truncate(_API_VERSION_RE.sub('', s).strip('/'), 40) -def changes_summary(row, ref_maps, limit=200, resolved=None): +# 摘要核心属性按**槽位**取:每个槽位只取第一个有值的字段。 +# +# ★ 为什么不是简单按字段列表顺序取前 3 个:库存快照里 in_quantity / +# stock_quantity / available_quantity 三个数量字段的值往往完全相同, +# 取前 3 个会得到「入库数量 10 件、总库存 10 件、可用库存 10 件」—— +# 三个数字一样,纯冗余,还把库位这类更该看到的信息挤掉了。 +# 按槽位(数量 / 位置 / 物料)各取一个,信息不重复。 +# +# ★ 为什么不再罗列字段名:实测一条入库快照有 29 个字段,罗列成 +# 「新增:SKU、base、状态、…等 29 个字段」信息量为零 —— 用户看完还是不知道 +# "到底新增了什么"。业务方真正想知道的是"多少数量、放在哪"。 +# ★ 只有两个槽位:数量与位置。**不含物料** —— 物料由「操作对象」列承担 +# (那一列已经是「SKU - 名称 (规格)」),摘要里再来一遍「SKU 0000002270」 +# 是同一屏内的重复,白白吃掉截断长度。用户要的也正是「10 件 (仓库: ZZTEST)」 +# 这种只讲数量和位置的形式。 +_SUMMARY_SLOTS = ( + ('数量', ('in_quantity', 'quantity', 'stock_quantity', 'available_quantity', + 'out_quantity', 'return_qty', 'dosage')), + ('位置', ('warehouse_location', 'warehouse_loc', 'location', 'return_location')), +) + +# 数量类字段 —— 摘要里带上单位(单位来自物料主数据,查不到就不带) +_QUANTITY_FIELDS = frozenset(_SUMMARY_SLOTS[0][1]) + + +def _fmt_number(val): + """数字去掉无意义的 .0(10.0 → 10);非数字原样返回。""" + if isinstance(val, bool): + return str(val) + if isinstance(val, float) and val.is_integer(): + return str(int(val)) + return text_of(val) + + +def _core_attrs(snap, material=None): + """快照 → ['入库数量 10 件', '库位 ZZTEST', …],每个槽位取一个。""" + parts = [] + for _slot, keys in _SUMMARY_SLOTS: + for key in keys: + if key not in snap or is_blank(snap[key]): + continue + text = truncate(_fmt_number(snap[key]), 30) + # ★ 单位只在物料主数据里确实有 unit 时才带 —— 不猜。 + # 但**纯数字的单位要丢掉**:库里有一批 unit 落成了 '1', + # 显示成「入库数量 48 1」比不带单位还难读。 + unit = (material or {}).get('unit') or '' + if key in _QUANTITY_FIELDS and unit and not unit.isdigit(): + text = f"{text} {unit}" + parts.append(f"{field_label(key)} {text}") + break # 该槽位已取到,不再看后面的候选字段 + return parts + + +def changes_summary(row, ref_maps, limit=200, resolved=None, material=None): """ 一行日志的变更摘要(一句话),供台账 sheet 用。 @@ -539,15 +753,24 @@ def changes_summary(row, ref_maps, limit=200, resolved=None): source = url_source(row.url) return f"{body}(来源:/{source})" if source else body - for which, verb in ((SNAPSHOT_CREATED, '新增'), (SNAPSHOT_DELETED, '删除前快照')): + verb = '新增' if canon_action(row.action) == 'CREATE' else '删除' + mod = module_display(row.module) + prefix = f"{verb}({mod}):" if mod else f"{verb}:" + + for which in (SNAPSHOT_CREATED, SNAPSHOT_DELETED): snap = snapshot_of(row, which) - if snap: - # ★ 跳过 id:它是主键、每条都有,排在最前面却零信息量, - # 白白挤掉一个真正有内容的字段(摘要只列前 8 个)。 - keys = [k for k in snap if k != 'id'] - names = [field_label(k) for k in keys[:8]] - more = '' if len(keys) <= 8 else f" 等 {len(keys)} 个字段" - return truncate(f"{verb}:{'、'.join(names)}{more}", limit) + if not snap: + continue + # 优先:核心属性的**值**(多少数量、放哪)—— 这才是用户想知道的 + core = _core_attrs(snap, material) + if core: + return truncate(prefix + '、'.join(core), limit) + # 回落:没有核心属性可提(如 BOM 记录),退化成字段名清单。 + # 跳过 id:主键每条都有,排最前却零信息量,白占位置。 + keys = [k for k in snap if k != 'id'] + names = [field_label(k) for k in keys[:8]] + more = '' if len(keys) <= 8 else f" 等 {len(keys)} 个字段" + return truncate(f"{prefix}{'、'.join(names)}{more}", limit) return '' @@ -649,14 +872,19 @@ def _timestamp(row): return row.created_at.strftime('%Y-%m-%d %H:%M:%S') if row.created_at else '' -def audit_head_values(row): - """审计页导出的前导列取值(与 AUDIT_HEAD 一一对应)""" +def audit_head_values(row, display=None): + """ + 审计页导出的前导列取值(与 AUDIT_HEAD 一一对应)。 + + display: 补全后的操作对象(「SKU - 名称 (规格)」)。不传则用库里的 + target_name —— 后者常常只是 SKU 或 'stock_buy ID:1667'。 + """ return [ _timestamp(row), operator_of(row), row.module or '', action_label(canon_action(row.action)), - row.target_name or '', + display or row.target_name or '', row.target_id or '', row.url or '', ] @@ -703,6 +931,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) # ---------- 审计日志(台账)---------- ledger = [] @@ -711,24 +940,24 @@ def build_audit_workbook(rows, ref_maps=None, summary_rows=None, note=None): # UPDATE 行的翻译只算一次,同时喂给台账摘要与明细长表(见 changes_summary) changes = (resolved_changes(r.module, changes_of(r), ref_maps) if canon_action(r.action) == 'UPDATE' else None) + mat = materials.get(r.id) + display = format_target_display(mat) or None ledger.append( - audit_head_values(r) - + [changes_summary(r, ref_maps, resolved=changes), r.ip_address or ''] + audit_head_values(r, display=display) + + [changes_summary(r, ref_maps, resolved=changes, material=mat), + r.ip_address or ''] ) if changes is None: continue + head6 = audit_head_values(r, display=display)[:6] if not changes: # ★ 改的全是噪声字段的记录不能凭空消失 —— 它确实发生过。 detail.append( - audit_head_values(r)[:6] - + ['(仅变更了图片/链接/更新时间等噪声字段)', '', ''] + head6 + ['(仅变更了图片/链接/更新时间等噪声字段)', '', ''] ) continue for label, old, new in changes: - detail.append( - audit_head_values(r)[:6] - + [label, cell(old), cell(new)] - ) + detail.append(head6 + [label, cell(old), cell(new)]) # ---------- 汇总 ---------- summary = list(summary_rows or []) diff --git a/inventory-backend/app/utils/audit_labels.py b/inventory-backend/app/utils/audit_labels.py index 346949c..fe37999 100644 --- a/inventory-backend/app/utils/audit_labels.py +++ b/inventory-backend/app/utils/audit_labels.py @@ -200,8 +200,130 @@ FIELD_LABELS = { 'target_code': '目标编码', 'user_agent': '浏览器标识', 'ip_address': 'IP地址', + 'visibility_level': '可见级别', + 'is_archived': '是否归档', + + # --- 采购 / 入库(stock_* 表)--- + # 这几个是实测缺失最多的:采购入库快照整表展开,缺一个就漏一排英文。 + 'buyer_email': '采购邮箱', + 'in_date': '入库日期', + 'currency': '币种', + 'exchange_rate': '汇率', + 'inspection_status': '质检状态', + 'inspection_report': '质检报告', + 'inspection_report_link': '质检报告链接', + 'quality_status': '质检状态', + 'quality_report_link': '质检报告链接', + 'arrival_photo': '到货照片', + 'product_photo': '成品照片', + 'global_print_id': '全局打印ID', + 'base': '基础物料', + 'detail_link': '详情链接', + 'original_link': '原始链接', + 'supplier_link': '供应商链接', + 'request_id': '申请ID', + 'purchase_request': '采购申请', + 'order_id': '订单ID', + 'is_ordered': '是否已下单', + 'sale_price': '销售价', + 'location': '库位', + 'common_name': '通用名', + 'material': '物料', + 'images': '图片', + + # --- 生产(半成品/成品)--- + 'work_order_code': '工单号', + 'bom_code': 'BOM编号', + 'production_manager': '生产负责人', + 'production_date': '生产日期', + 'production_start_time': '生产开始时间', + 'production_end_time': '生产结束时间', + 'production_time_range': '生产时间段', + 'raw_material_cost': '原材料成本', + 'manual_cost': '人工成本', + + # --- BOM --- + 'loss_rate': '损耗率', + 'dosage': '用量', + 'parent': '父件', + 'child': '子件', + 'version': '版本', + 'bom_remark': 'BOM备注', + 'child_bom_no': '子BOM编号', + 'child_bom_version': '子BOM版本', + + # --- 退回 / 借还 --- + 'return_signature': '归还签名', + 'return_location': '归还库位', + 'borrow_signature': '借出签名', + 'current_holder_id': '当前持有人ID', + 'current_holder_name': '当前持有人', + 'borrower_id': '借用人ID', + 'dispatch_operator': '发放操作人', + 'source_return_id': '来源退回ID', + + # --- 预警设置 --- + 'yellow_threshold': '黄色阈值', + 'red_threshold': '红色阈值', + 'yellow_emails': '黄色预警邮箱', + 'red_emails': '红色预警邮箱', + 'last_notified_at': '上次通知时间', + + # --- 审批 --- + 'approver_name': '审批人', + 'approval_status': '审批状态', + + # --- 盘点 / 扫码(长尾,实测仅个位数条记录,但不补就会在详情里裸露英文)--- + 'diff_qty': '差异数量', + 'stock_qty': '库存数量', + 'scan_time': '扫码时间', + 'session_id': '会话标识', + 'uuid': '唯一标识', + 'image_url': '图片地址', + 'user_id': '用户ID', + 'type': '类型', } +# ============================================================================= +# 6. 详情快照里**不展示**的纯技术字段 +# +# ★ 判据:这些字段对业务人员零信息量,且常常极端冗长。 +# 典型是 image_embeddings 表的 `embedding`(1536 维向量),一条快照里 +# 塞进去就是几 KB 的乱码;`target_id` / `module_name` 则是审计层的元数据, +# 被早期快照采集误带进了业务对象。 +# +# ★ 只收「纯技术」的。链接/图片**不收**:那是业务内容,用户点进去是有用的; +# 它们在"变更对比"里已经被 IGNORED_CHANGE_FIELDS 单独处理了。 +# +# ★ 这份清单放在后端而不是前端硬编:它会随新表增长,前端再存一份必然漂移。 +# 前端经 GET /audit/labels 的 hiddenFields 取。 +# ============================================================================= +HIDDEN_SNAPSHOT_FIELDS = frozenset({ + 'id', + 'created_at', + 'updated_at', + 'tenant_id', + 'target_id', # 审计层元数据,不是业务字段 + 'module_name', + 'embedding', # pgvector 向量列,单条可达数 KB + 'img_embedding', + 'arrival_image_embedding', + 'qc_report_image_embedding', + 'image_embedding', +}) + + +def is_hidden_snapshot_field(key): + """ + 该字段是否属于「不该在详情快照里展示」的纯技术字段。 + + ★ 除名单本身,还按**后缀**拦截 `*embedding`:向量列在各表命名不一 + (img_embedding / arrival_image_embedding / qc_report_image_embedding…), + 逐个登记必然漏。新表加向量列时不必再改这里。 + """ + k = str(key or '').strip().lower() + return k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding') + def field_label(key): """字段名 → 中文;未命中时原样返回字段名""" @@ -552,4 +674,6 @@ def labels_payload(): # 否则会「下拉显示『入库』、表格显示『库存管理』」。 # ★ 含英文历史值,前端不再自带一份 moduleMap。 'moduleDisplay': module_display_map(), + # 详情快照里不展示的纯技术字段(前端过滤用,见 HIDDEN_SNAPSHOT_FIELDS) + 'hiddenFields': sorted(HIDDEN_SNAPSHOT_FIELDS), } diff --git a/inventory-web/src/views/system/AuditLog.vue b/inventory-web/src/views/system/AuditLog.vue index 927206a..6aa7fe1 100644 --- a/inventory-web/src/views/system/AuditLog.vue +++ b/inventory-web/src/views/system/AuditLog.vue @@ -134,11 +134,14 @@ - - + + @@ -225,8 +228,7 @@ {{ actionLabel(currentDetail.action) }} - {{ currentDetail.target_name || '-' }} - #{{ currentDetail.target_id }} + {{ currentDetail.target_display || currentDetail.target_name || '-' }} {{ currentDetail.created_at }} {{ currentDetail.ip_address || '-' }} @@ -351,7 +353,8 @@ const statusMapByModule = ref>>({}) const defaultStatusMap = ref>({}) const enumFields = ref(['status']) const booleanFields = ref([]) -// 模块展示映射 {原始值: 展示名} 定义在下方「模块名展示」一节(后端下发) +// 详情快照里不展示的纯技术字段(后端下发,见「详情降噪」一节) +const hiddenFields = ref([]) // 字段中文名解析:命中映射取中文,否则原样返回 const fieldLabel = (key: string | number): string => { @@ -395,6 +398,7 @@ const loadLabels = async () => { enumFields.value = res.data.enumFields || ['status'] booleanFields.value = res.data.booleanFields || [] moduleDisplay.value = res.data.moduleDisplay || {} + hiddenFields.value = res.data.hiddenFields || [] } } catch (e) { console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e) @@ -458,24 +462,55 @@ const OBJECT_REPR_RE = /^<[A-Za-z_][\w.]*(\s+[^<>]*)?>$/ const isObjectRepr = (v: any): boolean => typeof v === 'string' && OBJECT_REPR_RE.test(v.trim()) +// ============================================================ +// 详情降噪 +// +// 目标:只给用户看「真正有值」的业务数据。 +// +// ★ 「空」的判据必须**严格**:null / '' / [] / {} / '-' 才算空。 +// **false 和 0 不算空** —— `is_returned: false`、`quantity: 0` 是明确的 +// 业务事实;用 JS 的真值判断(`if (!v)`)会把它们一起吃掉, +// 那是在悄悄篡改业务数据。 +// +// ★ 纯技术字段清单由**后端**下发(hiddenFields),前端不硬编: +// 它会随新表增长(尤其 pgvector 的 `*embedding` 列,单条几 KB), +// 前端再存一份必然漂移。这里只额外按后缀拦一层 embedding, +// 与后端 is_hidden_snapshot_field 的规则一致。 +// ============================================================ +const isBlankValue = (v: any): boolean => { + if (v === null || v === undefined) return true + if (typeof v === 'string') return ['', '-'].includes(v.trim()) + if (Array.isArray(v)) return v.length === 0 + if (typeof v === 'object') return Object.keys(v).length === 0 + return false +} + +const isHiddenField = (key: string): boolean => { + const k = String(key || '').trim().toLowerCase() + return hiddenFields.value.includes(k) || k.endsWith('embedding') +} + // UPDATE:字段对比行 +// 过滤「等于没改」的行(两侧都空)—— 后端 changes_of 也会滤,但详情走的是 +// 原始 details,这里必须再滤一次,否则会看到「备注:空 → 空」这种行。 const diffRows = computed(() => { const changes = currentDetail.value?.details?.changes if (!changes || typeof changes !== 'object') return [] - return Object.entries(changes).map(([key, v]: [string, any]) => ({ - key, - old: v?.old, - new: v?.new, - })) + return Object.entries(changes) + .filter(([key, v]: [string, any]) => + !isHiddenField(key) && !(isBlankValue(v?.old) && isBlankValue(v?.new))) + .map(([key, v]: [string, any]) => ({ key, old: v?.old, new: v?.new })) }) -// CREATE / DELETE:快照属性行(跳过对象 repr 脏值) +// CREATE / DELETE:快照属性行 +// 过滤:空值、纯技术字段、对象 repr 脏值(见 isObjectRepr) const snapshotRows = computed(() => { const d = currentDetail.value?.details || {} const snap = d.deleted_snapshot || d.created if (!snap || typeof snap !== 'object') return [] return Object.entries(snap) - .filter(([, v]) => !isObjectRepr(v)) + .filter(([key, v]) => + !isBlankValue(v) && !isHiddenField(key) && !isObjectRepr(v)) .map(([key, value]) => ({ key, value })) })