feat(audit): 提升可读性 —— 操作对象补全物料名、详情降噪、字段字典补齐
业务方反馈两点:操作对象太干瘪、详情快照噪音太多。
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 列结构未变。
This commit is contained in:
@ -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
|
||||
|
||||
|
||||
@ -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 [])
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -134,11 +134,14 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 强化操作对象:名称 + 主键,便于按 ID 追单条记录 -->
|
||||
<el-table-column label="操作对象" min-width="180" show-overflow-tooltip>
|
||||
<!--
|
||||
★ 操作对象只显示名称,**不显示数据库主键** —— 业务人员不需要它。
|
||||
★ 优先用后端补全的 target_display(「SKU - 物料名称 (规格型号)」);
|
||||
后端解不出物料的记录回落原始 target_name。
|
||||
-->
|
||||
<el-table-column label="操作对象" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.target_name || '-' }}</span>
|
||||
<span v-if="row.target_id" class="muted">#{{ row.target_id }}</span>
|
||||
<span>{{ row.target_display || row.target_name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@ -225,8 +228,7 @@
|
||||
<el-tag :type="getActionType(currentDetail.action)">{{ actionLabel(currentDetail.action) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作对象">
|
||||
{{ currentDetail.target_name || '-' }}
|
||||
<span v-if="currentDetail.target_id" class="muted">#{{ currentDetail.target_id }}</span>
|
||||
{{ currentDetail.target_display || currentDetail.target_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作时间" :span="2">{{ currentDetail.created_at }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">{{ currentDetail.ip_address || '-' }}</el-descriptions-item>
|
||||
@ -351,7 +353,8 @@ const statusMapByModule = ref<Record<string, Record<string, string>>>({})
|
||||
const defaultStatusMap = ref<Record<string, string>>({})
|
||||
const enumFields = ref<string[]>(['status'])
|
||||
const booleanFields = ref<string[]>([])
|
||||
// 模块展示映射 {原始值: 展示名} 定义在下方「模块名展示」一节(后端下发)
|
||||
// 详情快照里不展示的纯技术字段(后端下发,见「详情降噪」一节)
|
||||
const hiddenFields = ref<string[]>([])
|
||||
|
||||
// 字段中文名解析:命中映射取中文,否则原样返回
|
||||
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 }))
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user