From ad2d27cd72aa8e1284f52f8624a04b11cd692758 Mon Sep 17 00:00:00 2001 From: yueli Date: Wed, 23 Sep 2026 17:16:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(audit):=20=E5=AE=A1=E8=AE=A1=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=98=A0=E5=B0=84=E6=94=B6=E6=95=9B=E4=B8=BA=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=94=AF=E4=B8=80=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:同一套「字段名 → 中文」映射存了三份手工同步的副本 —— 前端 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。 --- inventory-backend/app/api/v1/audit.py | 53 +-- inventory-backend/app/utils/audit_labels.py | 399 ++++++++++++++++++++ inventory-web/src/api/audit.ts | 9 + inventory-web/src/views/system/AuditLog.vue | 195 ++++------ 4 files changed, 498 insertions(+), 158 deletions(-) create mode 100644 inventory-backend/app/utils/audit_labels.py diff --git a/inventory-backend/app/api/v1/audit.py b/inventory-backend/app/api/v1/audit.py index 2317e0c..e91245a 100644 --- a/inventory-backend/app/api/v1/audit.py +++ b/inventory-backend/app/api/v1/audit.py @@ -12,35 +12,20 @@ audit_bp = Blueprint('audit', __name__) # ============================================================================= -# 操作类型归一化 +# 操作类型归一化 / 中文化标签 # -# 问题背景:历史数据里 action 有两套写法 —— 早期装饰器(已废弃)写入中文 -# (新增/修改/删除/批量删除…),现行监听器写入大写英文(CREATE/UPDATE/DELETE)。 -# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个选项, -# 而表格里二者又都显示为「新增」(actionMap 做了映射),用户无法分辨。 +# ★ 映射表已统一收敛到 app/utils/audit_labels.py(**唯一来源**)。 +# 本模块与日报服务共用同一份,前端经 GET /audit/labels 拉取同一份 —— +# 改一处即可,不会再出现"两边手工同步、改一边漏一边"的漂移。 # -# 后果:用户选了看得懂的中文项,只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。 -# -# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名, -# 历史数据无需迁移即可被正确检索。 +# 历史问题(迁移前):前端 AuditLog.vue 自带一份 fieldMap,后端这里自带一份 +# ACTION_ALIASES,两份副本各自演化。 # ============================================================================= -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()) +from app.utils.audit_labels import ( # noqa: E402 + ACTION_ALIASES, + canon_action, + labels_payload, +) @audit_bp.route('/logs', methods=['GET']) @@ -193,3 +178,19 @@ def get_modules(): except Exception as e: current_app.logger.error(f"获取模块列表失败: {str(e)}") return jsonify({'code': 500, 'msg': str(e)}), 500 + + +@audit_bp.route('/labels', methods=['GET']) +@jwt_required() +def get_labels(): + """ + 下发操作类型与字段名的中文映射。 + + ★ 后端是**唯一来源**,前端不再自带副本 —— 否则两份手工同步的映射必然漂移。 + 前端拉到之前(或拉取失败时)对未命中字段原样显示字段名, + 是可控的降级,不会让页面崩掉。 + + 免权限码:纯静态标签,不含任何业务数据;审计页本身已由 + @permission_required('system_audit') 把关(见 /logs)。 + """ + return jsonify({'code': 200, 'data': labels_payload()}), 200 diff --git a/inventory-backend/app/utils/audit_labels.py b/inventory-backend/app/utils/audit_labels.py new file mode 100644 index 0000000..6243619 --- /dev/null +++ b/inventory-backend/app/utils/audit_labels.py @@ -0,0 +1,399 @@ +""" +审计日志的中文化标签 —— **唯一来源**。 + +为什么要有这个模块 +------------------ +同一套「字段名 → 中文」映射原先在两个地方各存一份:前端 +`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), + } diff --git a/inventory-web/src/api/audit.ts b/inventory-web/src/api/audit.ts index 4955f24..2921cda 100644 --- a/inventory-web/src/api/audit.ts +++ b/inventory-web/src/api/audit.ts @@ -24,3 +24,12 @@ export function getAuditModules() { method: 'get' }) } + +// 获取操作类型 / 字段名的中文映射 +// ★ 后端为唯一来源,前端不再自带副本(见 views/system/AuditLog.vue 的说明) +export function getAuditLabels() { + return request({ + url: '/audit/labels', + method: 'get' + }) +} diff --git a/inventory-web/src/views/system/AuditLog.vue b/inventory-web/src/views/system/AuditLog.vue index 8b8c998..f5ca6b2 100644 --- a/inventory-web/src/views/system/AuditLog.vue +++ b/inventory-web/src/views/system/AuditLog.vue @@ -155,12 +155,12 @@ @@ -179,7 +179,7 @@ :label="fieldLabel(key)" :span="isLongValue(value) ? 2 : 1" > - {{ formatValue(value) }} + {{ formatValue(value, String(key)) }} @@ -197,7 +197,7 @@ :label="fieldLabel(key)" :span="isLongValue(value) ? 2 : 1" > - {{ formatValue(value) }} + {{ formatValue(value, String(key)) }} @@ -222,7 +222,7 @@