问题: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 的归档/取消归档操作,业务数据已精确还原为原状)。
审计记录未删除 —— 删审计要单独决策。
815 lines
33 KiB
Python
815 lines
33 KiB
Python
"""
|
||
审计日志的中文化标签 —— **唯一来源**。
|
||
|
||
为什么要有这个模块
|
||
------------------
|
||
同一套「字段名 → 中文」映射原先在两个地方各存一份:前端
|
||
`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],
|
||
}
|