Files
KCGL/inventory-backend/app/utils/audit_labels.py
yueli 0b87a72a41 feat(audit): 级联操作聚合 —— 一次业务点击只产生一条审计
问题:BOM 归档/启停这类接口一次请求改动整组数据,ORM 监听器逐行写审计,
审计页被刷屏。**实测问题范围远大于 BOM**(同一秒、同接口、同操作人的条数):

    /api/v1/inbound/stock/draft/start-new             924 条
    /api/v1/inbound/stock/stocktake/generate-missing  898 条
    /api/v1/permissions/assign                        381 条
    /api/v1/outbound                                  110 条
    /api/v1/bom/save                                   83 条
    /api/v1/bom/archive                                58 条

全库共 311 次「单请求 ≥20 条」的突发,累计 4 万余行。

实现:URL 白名单 + 同事务合并(方案 A 的收敛版)
  · AGGREGATE_PATH_MARKERS 按**路径段前缀**匹配(不是子串):
    /api/v1/bom_draft_x 不会命中 bom,/api/v1/somebom/thing 也不会。
  · 命中白名单的变更在 flush 期间累加到 session.info,由 Session
    after_flush 钩子合并成**一条** AuditLog。
  · details = 各行的**深度公共子集** + aggregate{count, targets}。

★ 为什么用白名单而不是全局默认合并:合并会改变审计的**语义粒度**
  (日报里「修改 354 条」可能变成几十条),全局改会让业务方以为日志坏了。
  白名单的失败模式也更安全 —— 新接口忘了登记只是"仍然刷屏",
  而不会把两个不相干的业务动作错误合并(错误合并 = 把 A 的改动记到 B 头上,
  是审计里最危险的一类错)。

★ 只取公共子集,不用某一行的值代表整批 —— 那是编造。BOM 的级联批次各行
  改动完全一致(实测 /bom/archive 400 条只有 3 种签名、349 条同一个),
  故合并**无损**;各行不一致时 changes 只留共同字段,其余由 targets 交代
  "动过哪些对象"。

★★ 钩子必须挂 after_flush,不能挂 before_commit —— 踩过的坑:
   Session.commit() 的顺序是 before_commit → flush → after_flush → COMMIT,
   而累加发生在 flush **期间**。挂 before_commit 时钩子跑在累加之前,
   缓冲还是空的;等 flush 填满后没人再写 —— 结果是**审计整批丢失**
   (实测归档请求产出 0 条日志,业务却已提交,正是最危险的"改了但没记录")。
   首版就是这个错,靠真实请求打 /bom/archive 数日志条数才抓出来。

★ 为什么不用 after_request/teardown:那跑在业务事务之外,业务回滚也会留下
  一条"成功"的审计 —— 假账。after_flush 仍属同一事务,聚合日志与业务改动
  同生共死(实测回滚后不留日志)。

消费端:
  · changes_summary 优先识别 aggregate,读作
    「批量更新 58 条;是否启用:是→否;是否归档:否→是(来源:/bom/archive)」
  · SNAPSHOT_VIEWS 增加 aggregate,抽屉据此渲染「变更次数 + 受影响对象表」
  · 前端把「变更对比」与「快照/汇总」从二选一改为各自独立渲染 ——
    聚合日志两者都有,原先的 v-else 会让汇总块显示不出来

验证(26 + 18 项断言全过):
  · 真实请求 POST /api/v1/bom/archive 打一个 58 行的 BOM:
    归档前 0 条 → 归档后**恰好 1 条**,count=58、targets 58 条、
    共同变更无损、业务改动同时生效(58/58 行已归档)
  · 回滚后不留下聚合日志(59996 → 59996)
  · 路径匹配 15 个边界(含 bom_draft_x / somebom 两个反向用例)
  · 非白名单接口行为不变;日报附件 490.5K/193.4K/92.3K 与 6 列结构未变;
    导出口径与列表一致;权限与凭据过滤未松动;字典 181 键零缺失
  · vue-tsc 与 vite build exit=0

⚠️ 仅对**改动之后**的请求生效,历史 4 万行突发数据不变。
⚠️ 验证过程在库里留下 4 条真实审计记录(id 60278/60279 等,均是本人对
   SF-9000-9 V2.2 的归档/取消归档操作,业务数据已精确还原为原状)。
   审计记录未删除 —— 删审计要单独决策。
2026-09-28 11:07:10 +08:00

815 lines
33 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地址',
'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': '类型',
# --- 快照里的数组字段 ---
# ★ 这些在详情抽屉里会**直接当表格标题**用(见前端的分层渲染),
# 漏一个就是在表头上裸露英文。
'items': '物料明细',
'rules': '规则',
'children': '子项',
'arrival_photo': '到货照片',
'generalImage': '通用图片',
'generalManual': '通用手册',
'permissions': '权限',
'signature_path': '签名',
# --- payload 型快照的字段 ---
# ★ 来源:接口层手工记录的请求体整包(借库/出库/采购入库/用户管理/预警设置)。
# 它的命名与 ORM 快照**不一致**,同一概念有两种写法:
# qty_stock / stock_quantity —— 都是"库存数量"
# commonName / common_name —— 都是"通用名"
# companyName / company_name —— 都是"所属公司"
# 这是历史遗留,改数据不可逆,故两种写法都登记。
'qty_stock': '库存数量',
'qty_available': '可用数量',
'qty_inbound': '入库数量',
'inventoryCount': '库存数量',
'availableCount': '可用数量',
'pending_quantity': '待处理数量',
'inbound_date': '入库日期',
'cn_name': '中文名',
'commonName': '通用名',
'companyName': '所属公司',
'spec': '规格型号',
'price': '价格',
'purchaser': '采购员',
'purchaser_email': '采购邮箱',
'print_copies': '打印份数',
'global_print_id_str': '全局打印ID(文本)',
'source_link': '来源链接',
'unit_total_cost': '单位总成本',
'current_location': '当前位置',
'isEnabled': '是否启用',
'isInspectionRequired': '是否需质检',
'visibilityLevel': '可见级别',
'warningEnabled': '是否启用预警',
'warningStatus': '预警状态',
'warningRed': '红色预警',
'warningYellow': '黄色预警',
'manual_link': '说明书链接',
'manual_link_remark': '说明书链接备注',
'product_image': '产品图片',
'product_image_remark': '产品图片备注',
'purchase_link': '采购链接',
# --- 级联聚合日志的汇总字段 ---
# ★ 这些是 audit_listener 写聚合日志时自造的元字段(见其 AGGREGATE_PATH_MARKERS),
# 不是任何业务表上的列。名字都很通用(count/targets),
# 目前业务快照里没有同名键;将来若出现,要改成按 details 结构区分而非按字段名。
'count': '变更次数',
'targets': '受影响对象',
'targets_total': '对象总数',
'targets_truncated': '对象清单已截断',
# 早期版本的聚合载荷里带过 action(与日志自身的 action 重复,已不再写入),
# 但历史行里还在,留个标签免得它在详情里裸露英文
'action': '操作类型',
}
# =============================================================================
# 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',
'password', # ★ 见下方凭据关键词的说明
'password_hash',
})
# 凭据类字段的**关键词**拦截。
#
# ★★ 这不是洁癖,是实测出来的洞:`用户管理/新增` 的 payload 快照里存着
# **明文密码**(27 条,实测 pw_len 6/8/11,形如 '1234…'、以用户名开头…)——
# 写入路径把请求体整包记进了审计,而请求体里是哈希前的原始密码。
# 这些内容会直接显示在详情抽屉里,并随导出进 Excel。
#
# ★ 用关键词而不是逐个登记:今天漏的是 payload 里的 password,
# 明天可能是 reset_token、api_secret。凡是键名沾这些词的都不展示 ——
# 新表加了凭据字段也自动被挡住。
_CREDENTIAL_KEYWORDS = ('password', 'passwd', 'secret', 'token', 'private_key')
# =============================================================================
# 7. 快照(details)的键与展示标题
#
# ★ 历史上有**三种**互不相同的存法,由不同时期的写入路径产生:
# {'created': {...}} —— ORM 监听器的 INSERT 快照
# {'deleted_snapshot': {...}} —— ORM 监听器的 DELETE 快照
# {'payload': {...}} —— 接口层手工记录的业务数据整包
# (借库/出库/采购入库等,内部还嵌 items 数组)
# `changes` 是第四种,但语义是"变更对比"而非快照,不走这套渲染。
#
# ★ 键名与标题都收在这里、由 GET /audit/labels 下发:前端不该硬编这三行 ——
# 将来再加第四种存法,只改这里即可(本项目已多次栽在"前端存一份副本"上)。
#
# ★ 顺序即优先级:一条记录同时有多个键时取第一个能用的。
# =============================================================================
SNAPSHOT_CREATED = 'created'
SNAPSHOT_DELETED = 'deleted_snapshot'
SNAPSHOT_PAYLOAD = 'payload'
CHANGES_KEY = 'changes'
# 聚合键:级联接口的批量变更被合并成一条日志时,受影响对象清单存在这里
# (见 audit_listener 的级联聚合)。它不是"快照",但走同一套分层渲染,
# 故一并列在 VIEWS 里 —— 抽屉里会把它渲染成一张对象清单表。
SNAPSHOT_AGGREGATE = 'aggregate'
SNAPSHOT_VIEWS = (
(SNAPSHOT_CREATED, '新增数据快照'),
(SNAPSHOT_DELETED, '删除前数据快照'),
(SNAPSHOT_PAYLOAD, '业务数据'),
(SNAPSHOT_AGGREGATE, '批量汇总'),
)
def is_hidden_credential_field(key):
"""
该字段是否属于**凭据类**(密码/令牌/密钥)—— 这类字段不仅要"不显示",
还要在接口出口**整键剥掉**(见 audit_export_service.sanitize_details)。
★ 单列一个函数而不是复用 is_hidden_snapshot_field:两者处置方式不同。
技术字段(id/embedding)只是不该展示;凭据字段是**泄漏**,
必须连响应体里都不能有。混在一起会让"要不要剥掉"的语义变含糊。
"""
k = str(key or '').strip().lower()
return any(w in k for w in _CREDENTIAL_KEYWORDS)
def is_hidden_snapshot_field(key):
"""
该字段是否属于「不该在详情快照里展示」的字段。
三类:
· 名单内的纯技术字段(id / 时间戳 / 审计元数据)
· `*embedding` 向量列 —— 按**后缀**拦截:各表命名不一
(img_embedding / arrival_image_embedding / qc_report_image_embedding…),
逐个登记必然漏。新表加向量列不必再改这里。
· 凭据类字段 —— 见 is_hidden_credential_field
★ 前端同一套规则再判一次(AuditLog.vue 的 isHiddenField):
后端名单管"点名"的,关键词两端各自判,语义一致。
"""
k = str(key or '').strip().lower()
if k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding'):
return True
return is_hidden_credential_field(k)
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
# =============================================================================
# 5. 模块聚合别名 —— 让一个业务概念跨越历史上的口径变更
#
# ★ 背景:审计日志的 module 是**监听器写死的字符串**,而监听器换过一次。
# `app/utils/audit_events.py`(旧的全局监听器)产出「入库管理」;
# 现行的 `app/core/audit_listener.py` 把入库三表
# stock_buy / stock_semi / stock_product 统一归为「库存管理」
# (见 audit_listener.py 的 _get_module_name),不再产出「入库管理」。
#
# 实测的分界点是 2026-09-10:
# 入库管理 16118 条 2026-04-22 ~ 2026-09-10
# 库存管理 1371 条 2026-09-10 ~ 至今
#
# 后果:只按单个 module 值筛选「入库」,会正好在 9-10 那天断掉 ——
# 选「入库管理」看不到 9-10 之后的,选「库存管理」看不到之前的。
# 报表读起来像是"入库记录突然没了",极难排查。
#
# ★ 解法只在**查询期**做别名展开,不迁移历史数据:改数据不可逆,
# 而聚合查询是无损的。新增值只需往元组里追加。
#
# ★ 与 STATUS_LABELS_BY_MODULE 一样,这份表是**唯一来源**:
# 前端经 GET /audit/labels 取(前端 AuditLog.vue 自带的 moduleMap
# 已经是手工同步的副本,不能再塞第二份进去)。
# =============================================================================
MODULE_GROUPS = {
'入库': ('入库管理', '采购入库', '成品入库', '库存管理'),
}
# 历史遗留的**英文** module 值 → 中文展示名。
#
# ★ 来源:早期全局监听器(app/utils/audit_events.py,现已停用)没有表白名单,
# 系统表、草稿表、向量表都会被审计,而 _infer_module_name 对未登记的类名
# 直接回退成**表名**,于是这些英文值混进了 module 列。
# 实测存量:image_embeddings 2719 条、purchase_request 75 条、sys_element 6 条;
# 表里其余的(stock_buy 等)当前没有数据,但换个环境可能出现,一并留着。
#
# ★ 这份表原本硬编在前端 AuditLog.vue 的 moduleMap 里,**已经漂移过一次**:
# 前端加了表名后端不知道、后端加了前端不显示。收进本文件后,前端经
# GET /audit/labels 的 moduleDisplay 取,和字段名/状态码一样只有一份。
MODULE_LABELS = {
'image_embeddings': '图像特征(历史)',
'purchase_request': '采购申请',
'sys_element': '系统元素',
'sys_menu': '系统菜单',
'sys_user': '系统用户',
'sys_role': '系统角色',
'sys_role_permission': '角色权限',
'sys_warehouse_location': '库位设置',
'material_base': '物料主数据',
'material_warning_settings': '物料预警设置',
'stock_buy': '采购库存',
'stock_semi': '半成品库存',
'stock_product': '成品库存',
'stock_adjustment': '库存调整',
'trans_outbound': '出库流水',
'trans_borrow': '借还流水',
'trans_scrap': '报废流水',
'trans_repair': '维修单',
'bom_table': 'BOM配方',
'bom_draft_table': 'BOM草稿',
'stocktake_draft': '盘点草稿',
}
def module_display(module):
"""
单个 module 值 → 展示名。
三级解析,顺序不能换:
1. 聚合成员 → 聚合名(「入库管理」→「入库」)
2. 英文历史值 → 中文(「image_embeddings」→「图像特征(历史)」)
3. 其余原样(现行监听器写的就是中文,无需翻译)
★ 第 1 级必须在第 2 级之前:聚合名优先,否则「库存管理」会被
MODULE_LABELS 之类的表抢先命中而显示成别的。
"""
m = (module or '').strip()
for group, members in MODULE_GROUPS.items():
if m in members:
return group
return MODULE_LABELS.get(m, m)
def module_display_map():
"""
完整展示映射 {原始值: 展示名},下发给前端。
★ 必须把**聚合成员**也算进来(不只是 MODULE_LABELS):表格与详情里拿到
的是原始 module 值,不映射的话会出现「下拉显示『入库』、表格显示
『库存管理』」——用户会以为筛选没生效。
"""
out = dict(MODULE_LABELS)
for group, members in MODULE_GROUPS.items():
for m in members:
out[m] = group
return out
def module_options(raw_modules):
"""
库里的原始 module 值 → 前端「模块」下拉的最终选项 [{value, label}]。
★ 为什么由后端算,而不是前端拉原始值自己拼:
分组规则(哪些历史值属于同一个业务概念)由 MODULE_GROUPS 管。前端再算
一遍就是第二份副本 —— 将来往组里加成员,前端会**静默失效且没有任何
报错**。这是本项目反复踩过的坑(见本文件开头、以及前端 AuditLog.vue
那个已经漂移过一次的 moduleMap)。
★ 已归入聚合项的历史值**不再单独列出**:`入库管理`/`采购入库`/`成品入库`
与 `库存管理` 是被改名的同一批数据。同时列出会让用户以为它们是不同的
东西,选了历史值又只看得到断裂前的一半 —— 那正是 2026-09-10 的口径
断裂在界面上的表现。
代价:无法只查 `库存管理`(09-10 之后的 1371 条)。这是有意取舍 ——
那几个名字本身就是历史包袱,把选择权从用户手里拿走比让他选错更安全。
"""
grouped = {m for members in MODULE_GROUPS.values() for m in members}
# 聚合项置顶,其余按名称排序(DISTINCT 返回顺序不保证,排序后位置稳定)
opts = [{'value': name, 'label': name} for name in MODULE_GROUPS]
# ★ label 走 module_display,英文历史值在这里就变成中文 ——
# value 保持原始字符串,否则筛选匹配不上(后端按 module 列的原值过滤)
opts += [{'value': m, 'label': module_display(m)}
for m in sorted(set(raw_modules or [])) if m and m not in grouped]
return opts
def expand_modules(values):
"""
筛选值 → 实际要匹配的 module 列表。
接受三种输入并混合使用:
· 聚合名('入库(全部)')→ 展开为其全部成员值
· 真实 module 值('入库管理')→ 原样保留
· 未登记的任意值 → 原样保留(不猜、不丢弃,查不到就是查不到)
返回**去重后**的列表 —— 用户同时选了「入库(全部)」与「入库管理」时,
展开会出现重复值,虽然 .in_() 去重与否结果相同,但重复值会让
SQL 参数列表无谓变长。
"""
out = []
for v in values:
v = (v or '').strip()
if not v:
continue
out.extend(MODULE_GROUPS.get(v, (v,)))
# dict.fromkeys 去重且保序(Python 3.7+ 的 dict 有序)
return list(dict.fromkeys(out))
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),
# 模块聚合规则及其成员。前端**不需要**它来渲染下拉(下拉的选项由
# /audit/modules 下发),保留是为了让"规则"本身可被查看/调试。
'moduleGroups': {k: list(v) for k, v in MODULE_GROUPS.items()},
# {原始 module 值: 展示名} —— 前端用它把表格/详情里的原始值翻成中文,
# 否则会「下拉显示『入库』、表格显示『库存管理』」。
# ★ 含英文历史值,前端不再自带一份 moduleMap。
'moduleDisplay': module_display_map(),
# 详情快照里不展示的纯技术字段(前端过滤用,见 HIDDEN_SNAPSHOT_FIELDS)
'hiddenFields': sorted(HIDDEN_SNAPSHOT_FIELDS),
# 快照键 → 展示标题,按优先级排列(见 SNAPSHOT_VIEWS)。
# 前端据此把 created / deleted_snapshot / payload 三种存法抹平,
# 不硬编键名。
'snapshotViews': [{'key': k, 'title': t} for k, t in SNAPSHOT_VIEWS],
}