Files
KCGL/inventory-backend/app/utils/audit_labels.py
yueli 6a9e41f53c feat(audit-ui): 审计页 UI 重构 —— 干净模块下拉、变更摘要、抽屉详情、触发来源
后端:GET /audit/modules 与 /logs 的 modules 改为下发最终选项 [{value,label}]
  · 历史命名(入库管理/采购入库/成品入库/库存管理)折叠进「入库」一项且
    **不再单独列出**,下拉里看不到历史包袱。展开仍只由后端 expand_modules
    负责 —— 前端若自己再拼一份成员表,将来后端加成员会静默失效(本项目
    已经踩过两次这种漂移)。
  · 英文历史 module(image_embeddings 2719 条 / purchase_request 75 条 /
    sys_element 6 条)在这里翻成中文 label,value 保留英文原值否则筛选
    匹配不上。映射表 MODULE_LABELS 从**前端** AuditLog.vue 的 moduleMap
    收进 audit_labels.py —— 那份硬编副本历史上已经漂移过一次。
  · /audit/labels 新增 moduleDisplay {原始值 → 展示名},同时覆盖聚合成员与
    英文历史值:只覆盖后者会出现「下拉显示『入库』、表格显示『库存管理』」,
    用户会以为筛选没生效。

后端:新增「触发来源」
  · 摘要追加 (来源:/outbound/request) 形式的 URL 尾段(去掉 /api/vN 前缀)。
    解决一个具体误解:业务方看到「入库/库存管理」里有大量非库管人员的
    UPDATE,以为越权改库存,实际是出库/借还/盘点等单据流转触发的自动扣减。
    实测该类 UPDATE 的来源:outbound 1582、outbound/request 424、
    borrow/dispatch 102、stocktake/update-quantity 95 —— 光看"谁改的"永远
    解释不清,必须能看出"哪个流程触发的"。
  · 来源拼在**截断之后**,保证这条关键信息永远不会被截掉。
  · 列表与导出共用 changes_summary,故 Excel 台账同样带来源。

后端:其余
  · 新增 target_keyword(ilike 同时匹配 target_id / target_name),
    放进共用的 _build_audit_query,导出自动支持。
  · action 在响应里归一化为 CREATE/UPDATE/DELETE(复用 audit_labels
    .canon_action,覆盖批量删除/分配/归还等全部历史别名)。库里还有 1573 条
    中文 action,归一后前端不必再为每种历史写法兜底。
  · 新增 summary 字段。不算在模型 @property 上:摘要要把 user_id/base_id
    翻成人名/物料名,需要查库,模型属性里发查询就是 N+1;改为整页一次
    load_ref_maps 批量解析。
  · 过滤对象 repr 脏值(<MaterialBase 3030>)。快照收集早期漏跳关系属性,
    库里留了 1529 条这种值 —— 不是业务数据,真正的值在对应 _id 字段里。
    判据收窄到「<类名 空格 内容>」,避免误伤备注里的「<急件>」。

前端 AuditLog.vue
  · 模块下拉改平铺 [{value,label}],删除硬编的 moduleMap
  · 新增「操作对象」模糊搜索
  · 合并「操作人」「姓名」两列(判据 username==='system' —— 响应里没有
    operator_type 字段,那是请求参数名,照搬会永远不显示系统标签)
  · 新增「变更摘要」列;操作对象显示为「名称 #id」
  · 操作时间改为恒显示北京时间,不再按浏览器时区换算,与数据库/日报/导出一致
  · 详情弹窗改 el-drawer,按 action 分支渲染:UPDATE 出对比表,
    CREATE/DELETE 出快照属性表,并跳过对象 repr
  · 抽屉顶部高亮展示触发来源(METHOD + URL 告警条)

验证:后端 18 + 22 + 10 项断言全过(模块选项无历史名且「入库」=四值之和、
英文 value 仍可筛选、url_source 边界、来源不被截断、导出口径与列表一致、
权限 401/403/200 矩阵);vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K/193.5K/10.3K,6 列结构与改动前一致。
2026-09-28 10:10:05 +08:00

556 lines
22 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
# =============================================================================
# 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(),
}