Files
KCGL/inventory-backend/app/utils/audit_labels.py
yueli ad2d27cd72 refactor(audit): 审计中文映射收敛为后端唯一来源
问题:同一套「字段名 → 中文」映射存了三份手工同步的副本 ——
前端 AuditLog.vue 的 fieldMap(约 95 个字段)、后端 api/v1/audit.py 的
ACTION_ALIASES,另有若干内联的 status 映射散落在 service 层。
改一边漏一边就会漂移,页面上冒出英文列名或对不上的中文。

改动:
  · 新增 app/utils/audit_labels.py 作为**唯一来源**,统一提供
    操作类型归一化(canon_action)、字段名中文化(field_label)、
    码值中文化(enum_label / bool_label / person_name_label)、
    ID 指向登记(id_ref_of)。
  · api/v1/audit.py 改为从该模块 import,删掉本地副本。
  · 新增 GET /audit/labels 下发映射(静态标签,免权限码;审计页本身
    已由 system_audit 把关)。
  · 前端 AuditLog.vue 删除本地 95 行 fieldMap,改从接口拉取;
    拉取失败时未命中项原样显示,是可控降级。

★ 码值必须**按模块**翻译:同名 status 在出库/借还/报废里含义完全不同
  (出库的 3 是「已出库」,报废的 3 是「已执行(已报废)」),
  不区分模块会把报废单显示成已出库。借还模块下还混着字符串状态
  (borrowed/returned),因其两张表共用 status 列名。
  故映射按 (模块, 字段) 匹配,未登记再退回字段名。

★ ID 字段同理:parent_id 在「系统管理」里是菜单的上级菜单,在「BOM管理」
  里是物料节点 —— 指向完全不同的表。

验证:前端 vue-tsc 改前改后均为 565 个错误、去除行号后**报错集合完全相同**,
未引入新错误;GET /audit/labels 实测返回 200。
2026-09-23 17:16:20 +08:00

400 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
审计日志的中文化标签 —— **唯一来源**。
为什么要有这个模块
------------------
同一套「字段名 → 中文」映射原先在两个地方各存一份:前端
`views/system/AuditLog.vue` 的 fieldMap,以及后端 `api/v1/audit.py` 里的
ACTION_ALIASES。两份手工同步的副本必然漂移 —— 改一边漏一边,页面上就会
冒出英文列名或对不上的中文。
现在统一收在这里:
· 前端经 `GET /api/v1/audit/labels` 拉取,不再自带副本;
· 后端 API 层与日报服务直接 import,不重复定义。
★ 加新字段时**只改本文件**。
三层映射
--------
1. ACTION_ALIASES / canon_action —— 操作类型归一化。
历史数据里 action 有两套写法:早期装饰器写中文(新增/修改/批量删除…),
现行监听器写大写英文(CREATE/UPDATE/DELETE)。归一到规范值后,
筛选与统计才不会漏掉历史数据。
2. ACTION_LABELS —— 规范值 → 中文显示名。
3. FIELD_LABELS —— 数据库列名 → 中文列名。未命中时由 field_label()
兜底为原字段名(宁可显示英文,也不要显示空白)。
"""
# =============================================================================
# 1. 操作类型归一化
# =============================================================================
# 问题背景(沿用 api/v1/audit.py 的原始注释):
# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个
# 选项,而表格里二者又都显示为「新增」,用户无法分辨。用户选了看得懂的中文项,
# 只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。
#
# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名,
# 历史数据无需迁移即可被正确检索。
ACTION_ALIASES = {
'CREATE': ('CREATE', 'create', 'INSERT', 'insert', '新增', '批量生成'),
'UPDATE': ('UPDATE', 'update', '修改', '分配', '归还'),
'DELETE': ('DELETE', 'delete', '删除', '批量删除'),
}
# 反向索引:任意别名 → 规范值
_ALIAS_TO_CANON = {
alias: canon
for canon, aliases in ACTION_ALIASES.items()
for alias in aliases
}
def canon_action(action):
"""把任意写法的 action 归一化为规范值;无法识别时原样返回"""
return _ALIAS_TO_CANON.get((action or '').strip(), (action or '').strip())
# =============================================================================
# 2. 操作类型显示名
# =============================================================================
ACTION_LABELS = {
'CREATE': '新增',
'UPDATE': '修改',
'DELETE': '删除',
'EXPORT': '导出',
'IMPORT': '导入',
'LOGIN': '登录',
'LOGOUT': '登出',
}
def action_label(action):
"""操作类型 → 中文;先归一化再查表,未命中时返回归一化后的原值"""
canon = canon_action(action)
return ACTION_LABELS.get(canon, canon)
# =============================================================================
# 3. 字段名中文化映射
#
# ★ 覆盖范围:审批单 / 流水 / 库存 / 主数据 / 系统管理 五类核心业务表。
# 未命中的字段由 field_label() 兜底为「原字段名」,不会再出现大面积英文列名。
# ★ 用法:变更对比、新增详情、删除快照三个区块统一经 field_label() 取值。
# (改造前仅"变更对比"区用了映射,另外两区直接渲染原始 key,
# 这是"详情里一堆英文列名"的直接原因。)
# =============================================================================
FIELD_LABELS = {
# --- 通用 ---
'id': 'ID',
'name': '名称',
'title': '标题',
'remark': '备注',
'reason': '原因',
'reason_category': '原因分类',
'status': '状态',
'created_at': '创建时间',
'updated_at': '更新时间',
'is_active': '是否启用',
'is_enabled': '是否启用',
'company_name': '所属公司',
'operator_name': '操作人',
'operator': '操作人',
# --- 物料主数据 ---
'material_name': '物料名称',
'spec_model': '规格型号',
'category': '类别',
'material_type': '物料类型',
'unit': '单位',
'base_id': '物料ID',
'sku': 'SKU',
'batch_number': '批次号',
'serial_number': '序列号',
'barcode': '条码',
'warehouse_location': '库位',
'warehouse_loc': '库位',
'reference_price': '参考价格',
'is_approval_required': '是否需审批',
'is_inspection_required': '是否需质检',
# --- 库存数量 ---
'in_quantity': '入库数量',
'stock_quantity': '总库存',
'available_quantity': '可用库存',
'out_quantity': '出库数量',
'quantity': '数量',
'pre_tax_unit_price': '不含税单价',
'post_tax_unit_price': '含税单价',
'unit_price': '单价',
'total_price': '总价',
'tax_rate': '税率',
'supplier_name': '供应商',
'buyer_name': '采购员',
# --- 审批单 ---
'request_no': '申请单号',
'applicant_id': '申请人ID',
'allowed_approvers': '允许审批人',
'actual_approver_id': '实际审批人ID',
'approved_at': '审批时间',
'reject_reason': '驳回原因',
'items_json': '物料明细',
'outbound_type': '出库类型',
'consumer_name': '领用人/客户',
'borrower_name': '借用人',
'executed_at': '执行时间',
'executor_name': '执行人',
# --- 流水 ---
'outbound_no': '出库单号',
'outbound_time': '出库时间',
'borrow_no': '借出单号',
'borrow_time': '借出时间',
'expected_return_time': '预计归还时间',
'return_time': '归还时间',
'return_operator': '归还操作人',
'returned_quantity': '已归还数量',
'is_returned': '是否已归还',
'scrap_request_no': '报废申请单号',
'operation_time': '操作时间',
'source_table': '来源表',
'source_ref': '外部单号',
'stock_id': '库存ID',
'cost_at_scrap': '报废成本',
'total_loss': '损失金额',
# --- 逆向物流(退不良/在管不良品)---
'remaining_qty': '在管数量',
'restocked_qty': '累计已回库',
'scrapped_qty': '累计已报废',
'return_qty': '退回数量',
'return_type': '退回类型',
'return_id': '退回流水ID',
'outbound_id': '出库记录ID',
'signature_path': '签名',
'reissue_qty': '补发数量',
# --- 采购 / BOM ---
'purchase_date': '采购日期',
'requester_id': '申请人ID',
'approver_id': '审批人ID',
'bom_no': 'BOM编号',
'bom_version': 'BOM版本',
'parent_id': '父级ID',
'child_id': '子级ID',
# --- 系统管理 ---
'username': '用户名',
'display_name': '显示名',
'email': '邮箱',
'role': '角色',
'department': '部门',
'password_hash': '密码哈希',
'code': '编码',
'path': '路径',
'sort_order': '排序',
'is_visible': '是否可见',
'menu_code': '菜单编码',
'element_type': '元素类型',
'role_code': '角色编码',
'target_code': '目标编码',
'user_agent': '浏览器标识',
'ip_address': 'IP地址',
}
def field_label(key):
"""字段名 → 中文;未命中时原样返回字段名"""
k = str(key)
return FIELD_LABELS.get(k, k)
# =============================================================================
# 4. 枚举值中文化 —— ★ 必须按模块区分
#
# 同一个字段名在不同模块里是**不同的东西**:
# · 出库管理.status 的 3 表示「已完成(已出库)」
# · 借还管理.status 的 3 表示「已完成(已借出)」
# · 报废管理.status 的 3 表示「已执行(已报废)」
# · 借还管理.status 还可能是字符串 'borrowed'/'returned'(那是 trans_borrow,
# 不是审批单 —— 这个模块下两张表共用了 status 这个列名)
# · 退回管理.status 本来就是中文(待处理/已报废),无需翻译
#
# 不区分模块就会出现「报废单显示成已出库」这种错。
# =============================================================================
# 审批单通用取值(出库/借还/报废/采购四条审批流共用同一套编码)
_APPROVAL_STATUS = {
0: '待审批',
1: '已通过',
2: '已驳回',
3: '已完成',
4: '已撤回',
}
STATUS_LABELS_BY_MODULE = {
# 出库审批流(OutboundApproval)
'出库管理': {**_APPROVAL_STATUS, 3: '已完成(已出库)', 4: '已完结'},
# 借还模块下有两张表共用 status:审批单(数字)与 trans_borrow(字符串)
'借还管理': {
**_APPROVAL_STATUS,
3: '已完成(已借出)',
4: '已撤回',
'borrowed': '借出中',
'returned': '已归还',
'scrapped': '已报废',
},
# 报废审批流(ScrapApproval):4 是「已撤回」,不是「已完结」
'报废管理': {**_APPROVAL_STATUS, 3: '已执行(已报废)'},
# 采购申请 / 采购单(PurchaseRequest)
'采购管理': {**_APPROVAL_STATUS, 3: '已完成', 4: '已完结'},
'purchase_request': {**_APPROVAL_STATUS, 3: '已完成', 4: '已完结'},
}
# 未登记的模块退回通用编码 —— 比显示裸数字强,也比乱猜安全
DEFAULT_STATUS_LABELS = _APPROVAL_STATUS
# 走枚举翻译的字段(目前只有 status;将来有别的码值列在此追加)
ENUM_FIELDS = frozenset({'status'})
# 布尔列 → 是/否
BOOLEAN_FIELDS = frozenset({
'is_enabled', 'is_active', 'is_visible', 'is_archived',
'is_returned', 'is_approval_required', 'is_inspection_required',
})
# 引用 sys_user.id 的列 —— 值需解析成姓名才有意义
USER_ID_FIELDS = frozenset({
'actual_approver_id', 'approver_id', 'applicant_id',
'requester_id', 'user_id', 'returner_id',
'from_user_id', 'to_user_id',
})
# 引用 material_base.id 的列 —— 值需解析成物料名才有意义
MATERIAL_ID_FIELDS = frozenset({'base_id'})
# ---------------------------------------------------------------------------
# ID 字段指向什么实体
#
# ★ 同名 ID 在不同模块指向**完全不同的表** —— 这是必须按模块区分的原因:
# parent_id 在「系统管理」里是菜单的上级菜单(sys_menu),
# 在「BOM管理」里却是材质/物料节点(material_base)。
# 故 (模块, 字段) 优先,未命中再退回按字段名匹配。
#
# ★ 登记原则:**只登记能真正查到实体的**。查不到的 ID 宁可显示原值,
# 也不要硬编一个可能错的中文。
# ---------------------------------------------------------------------------
ID_REF_BY_MODULE = {
('系统管理', 'parent_id'): 'menu',
('系统管理', 'menu_id'): 'menu',
}
ID_REF_BY_FIELD = {
'base_id': 'material',
'parent_id': 'material', # BOM 结构里的父节点(非「系统管理」模块时)
'child_id': 'material',
'return_id': 'return_ledger',
}
def id_ref_of(module, field):
"""该 (模块, 字段) 的 ID 指向什么实体;未登记返回 None。"""
mod = (module or '').strip()
hit = ID_REF_BY_MODULE.get((mod, field))
if hit:
return hit
return ID_REF_BY_FIELD.get(field)
def enum_label(module, field, value):
"""
把枚举码值翻成中文。返回 None 表示「不适用/未登记」,由调用方原样显示。
★ 只对数值型/已登记的值做翻译,**不猜** —— 未登记的模块用通用表兜底,
通用表也查不到就返回 None(显示原值),绝不硬编一个可能错的中文。
"""
if field not in ENUM_FIELDS:
return None
mapping = STATUS_LABELS_BY_MODULE.get((module or '').strip(), DEFAULT_STATUS_LABELS)
if isinstance(value, bool): # bool 是 int 的子类,先挡掉
return None
if isinstance(value, (int, float)) and float(value).is_integer():
return mapping.get(int(value))
if isinstance(value, str):
return mapping.get(value.strip())
return None
def bool_label(value):
"""布尔值 → 是/否;非布尔返回 None"""
if isinstance(value, bool):
return '是' if value else '否'
return None
# 人名类**字符串**字段:库里存法不统一('杜邢宸/duxingchen'),需规整成「名(账号)」。
#
# ★★ 必须按字段名限定,**绝不能**对所有含 '/' 的字符串做规整:
# 规格型号的值就长这样('Det0001/Det0001'),一刀切会把它变成
# 'Det0001(Det0001)' —— 那是静默篡改业务数据,比不翻译危险得多。
PERSON_NAME_FIELDS = frozenset({
'executor_name', 'operator_name', 'operator',
'return_operator', 'approver_name', 'applicant_name',
'borrower_name', 'dispatch_operator', 'purchaser',
'production_manager', 'from_user_name', 'to_user_name',
})
def person_name_label(value):
"""
'杜邢宸/duxingchen' → '杜邢宸(duxingchen)'。
不是字符串、空串、或不含 '/' 时返回 None(由调用方原样显示)——
只做规整不做兜底,避免把空值变成 '-' 之类的占位符。
"""
if not isinstance(value, str):
return None
s = value.strip()
if not s or '/' not in s:
return None
name, _, acct = s.partition('/')
name, acct = name.strip(), acct.strip()
if name and acct:
return f"{name}({acct})"
return None
def _stringify_keys(mapping):
"""
把映射的键统一转成字符串。
★ 两重必要性:
1. JSON 对象的键本来就只能是字符串;
2. Flask 的 jsonify 默认开启 sort_keys,而「借还管理」的状态表里
int 键(0-4)与 str 键('borrowed')混用 —— 排序时会直接抛
TypeError: '<' not supported between instances of 'str' and 'int'。
实测这个错会让接口 500,且被全局错误处理器吞掉、日志里看不到堆栈。
前端取值时用 String(value) 归一化,正好对上字符串键。
"""
return {str(k): v for k, v in mapping.items()}
def labels_payload():
"""
下发给前端的合并映射。
前端替换数据来源即可,取用方式不用改。除字段名外还带上**码值**映射,
这样审计页的详情弹窗也不必再显示「状态: 1 → 3」。
"""
return {
'action': ACTION_LABELS,
'field': FIELD_LABELS,
'status': {
mod: _stringify_keys(m) for mod, m in STATUS_LABELS_BY_MODULE.items()
},
'defaultStatus': _stringify_keys(DEFAULT_STATUS_LABELS),
'booleanFields': sorted(BOOLEAN_FIELDS),
'enumFields': sorted(ENUM_FIELDS),
}