""" 审计日志的中文化标签 —— **唯一来源**。 为什么要有这个模块 ------------------ 同一套「字段名 → 中文」映射原先在两个地方各存一份:前端 `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 # ============================================================================= # 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(), }