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