diff --git a/.trellis/tasks/08-28-reimbursement-review-routing-copy/check.jsonl b/.trellis/tasks/08-28-reimbursement-review-routing-copy/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-28-reimbursement-review-routing-copy/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-28-reimbursement-review-routing-copy/design.md b/.trellis/tasks/08-28-reimbursement-review-routing-copy/design.md new file mode 100644 index 00000000..db8956ab --- /dev/null +++ b/.trellis/tasks/08-28-reimbursement-review-routing-copy/design.md @@ -0,0 +1,47 @@ +# 设计:报销规范核查与审批文案优化 + +## 边界 + +本任务包含两个相互关联但可独立验证的输出:生产 4 笔报销的只读规范性报告,以及负责人本人财务兜底节点的用户文案修复。前者不写业务数据;后者只调整步骤命名与详情显示投影,不改审批规则、角色、权限或数据库结构。 + +## 数据流 + +### 报销核查 + +```text +生产只读数据 → 按提交人/日期冻结 4 个业务编号 → 最小字段核查 +→ 对照报销/发票/附件事实源 → 脱敏逐笔结论 +``` + +- 优先使用现有受控生产只读连接或已授权 API 身份。 +- 查询结果只在进程内用于判断;任务文档只登记问题分类、业务编号和必要的非敏感摘要。 +- 不打开或下载原始附件时,只把“需要财务查看原件”列为保留项,不推断真实性。 + +### 审批文案 + +```text +审批规则解析 +→ buildRoleApprovalStep(保存步骤 ID、角色与业务步骤名) +→ workflow task / workflow_steps_snapshot(历史事实) +→ reimbursement processingProgress 显示投影 +→ 当前处理 / 下一步 / 时间线 +``` + +负责人本人兜底的技术原因继续由步骤 ID 后缀 `_owner_self_fallback`、分支原因和角色规则表达。业务步骤名改为按角色映射后的 `财务复核`,避免新记录继续污染用户入口。针对已经保存旧步骤名的历史记录,`resolveApprovalStageLabel` 将 `负责人本人申请兜底:` 识别为内部标签,再按 `assignee_role` 映射为 `财务复核`。 + +`processingProgress` 当前阶段的 `detail` 不再拼接旧步骤名,而使用规范化后的 stage label,因此详情页现有 `nextStepText` 可直接显示 `待财务复核处理`。前端无需引入第二套文案映射。 + +## 兼容性 + +- 不回写历史工作流实例或任务。 +- 普通的自定义可读节点名继续保留;只把明确的负责人本人兜底内部标签、序号、通用名和技术标识走角色映射。 +- `finance` 与 `finance_mgr` 的显示均为 `财务复核`;审批资格仍按原角色执行。 +- 报销审批历史如直接读取旧 `step_name`,需在同一服务端显示边界复用规范化函数,避免详情页不同区域一处修好、一处仍泄漏。 +- 待办中心的历史任务若仍直接暴露旧名,本任务只做必要的共享显示归一化,不改变任务 API 的权限与动作模型。 + +## 风险与回滚 + +- 风险:过宽地判定“不可读节点名”会覆盖合法自定义名称。控制方式是只增加负责人本人兜底前缀的精确识别,并保留现有名称优先级测试。 +- 风险:仅改生成名无法修复已经提交的 4 笔。控制方式是在读取投影层同时兼容历史名。 +- 回滚:代码回退即可;无 schema、配置或数据回滚。 +- 公网部署不在默认执行范围,必须另行明确批准。 diff --git a/.trellis/tasks/08-28-reimbursement-review-routing-copy/implement.jsonl b/.trellis/tasks/08-28-reimbursement-review-routing-copy/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-28-reimbursement-review-routing-copy/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-28-reimbursement-review-routing-copy/implement.md b/.trellis/tasks/08-28-reimbursement-review-routing-copy/implement.md new file mode 100644 index 00000000..279d1570 --- /dev/null +++ b/.trellis/tasks/08-28-reimbursement-review-routing-copy/implement.md @@ -0,0 +1,44 @@ +# 实施清单:报销规范核查与审批文案优化 + +## 1. 只读核查 + +- [x] 确认受控生产只读访问方式,不打印或持久化凭据。 +- [x] 按 `majianyong`、提交日期 `2026-08-28` 查询并冻结 4 个业务编号;数量漂移则停止。 +- [x] 读取最小核查字段,逐笔完成事由、项目、明细、日期金额、发票对应和补充材料检查。 +- [x] 形成脱敏结论与建议,不执行任何报销、附件、审批、付款或企微写操作。 + +## 2. 文案实现 + +- [x] 修改负责人本人兜底步骤的业务名称,保留内部步骤 ID 和审批路由原因。 +- [x] 扩展报销处理进度的显示归一化,兼容 4 笔已保存的旧快照和任务名。 +- [x] 让当前阶段 detail 使用规范化的节点标签,得到 `待财务复核处理`。 +- [x] 检查审批历史、PC 待办和移动待办的直接消费路径,只修复会继续暴露相同内部标签的位置。 + +## 3. 测试与复核 + +- [x] 补充 workflow 和 reimbursement detail 聚焦单测,覆盖新旧步骤名及普通链路。 +- [x] 运行后端语法、Prisma、聚焦 Vitest。 +- [x] 运行前端 typecheck/build 与聚焦 PC 报销详情 Playwright。 +- [x] 主会话进行只读 diff 复核,确认自审、权限、状态机、任务推进、历史快照和审批规则未改变。 +- [x] 运行流程、文档、Trellis 治理和 `git diff --check`。 + +## 4. 交付边界 + +- [x] 汇总 4 笔脱敏核查结论、代码变化和验证结果。 +- [x] 如需公网部署,先向用户单独申请批准;部署前不执行生产写入,部署后通过 `/ready`、构建号和公网只读检查后才能通知测试。 + +## 脱敏核查结论 + +- `BX-20260828-0014`:硬件类 5 张发票合并为 1 条明细,发票日期跨 5—7 月但明细只填一个日期;金额超过 2 万元且无订单、验收或资产信息附件。建议撤回后按实际设备/采购批次拆分,补用途、使用人/保管人、订单验收和资产候选说明。 +- `BX-20260828-0016`:4 张外部技术服务发票合并为 1 条笼统明细,发票日期跨 5—6 月且均未关联具体明细,无服务范围、服务期间、成果或订单/合同摘要材料。建议撤回后按服务事项或期间拆分并补业务依据。 +- `BX-20260828-0017`:2 张办公用品发票未关联明细,发票合计与申报金额相差 1,949.81 元;虽有 2 张图片附件,但材料类型和名称不能说明是支付、订单、验收或差额说明。建议撤回核对申报金额,逐票关联并明确差额和附件用途。 +- `BX-20260828-0018`:5 张办公用品发票金额合计与申报一致,但均未关联明细;明细日期晚于全部发票日期,事由和说明仅写“办公用品采购”,无订单/物品清单或用途材料。建议撤回后按购买内容/日期拆分并逐票关联。 + +本轮仅执行生产只读 SQL;未打开或下载发票原件和附件,未写生产数据库、审批、付款或企业微信。 + +## 关键文件与回滚点 + +- `server/src/utils/workflow.js`:新工作流步骤业务名;回退单个命名分支即可。 +- `server/src/routes/reimbursements.js`:历史显示投影;回退标签识别与 detail 生成即可。 +- `server/tests/unit/utils/workflow.test.js`、`server/tests/unit/routes/reimbursements.detail.test.js`:聚焦回归。 +- 如待办入口确需同步,优先复用已有显示函数或服务端投影,不新增页面级散落映射。 diff --git a/.trellis/tasks/08-28-reimbursement-review-routing-copy/prd.md b/.trellis/tasks/08-28-reimbursement-review-routing-copy/prd.md new file mode 100644 index 00000000..2016c083 --- /dev/null +++ b/.trellis/tasks/08-28-reimbursement-review-routing-copy/prd.md @@ -0,0 +1,71 @@ +# 报销规范核查与审批文案优化 + +## Goal + +核查 `majianyong` 于 2026-08-28 提交的 4 笔报销是否满足当前报销、发票和附件规范,并将“负责人本人申请兜底:finance”这类内部审批路由说明替换为用户能直接理解的财务处理文案,同时保持禁止自审和历史审批事实不变。 + +## Background + +- 用户反馈 `majianyong` 当前提交了 4 笔填写不够规范的报销,并提供了其中 `BX-20260828-0018` 的详情截图。 +- 截图可确认该单为办公用品报销:整单事由与唯一明细说明均为泛化的“办公用品采购”,未关联项目;5 张发票自动归入 1 条总额明细,材料对应区提示“待关联发票或补充无票说明”。 +- 当前正式组织口径是 `majianyong` 为公司负责人 `ceo`、不配置直属主管且不得自审;负责人本人提交报销时由唯一 active 非本人 `finance` 处理。 +- 后端在生成负责人本人兜底步骤时把内部原因和角色代码拼为 `负责人本人申请兜底:finance`,报销详情的处理进度投影把它当作可读节点名,因而在“下一步”“当前处理”和时间线中直接展示。 +- 现有稳定规约已经规定审批投影不得暴露角色代码或技术标识,`finance|finance_mgr` 应显示为“财务复核”。 + +## Scope + +- 生产 4 笔报销的最小字段只读核查与脱敏结论。 +- 负责人本人财务兜底步骤的新记录业务命名。 +- 报销详情、PC/移动审批任务等用户入口对历史兜底步骤名的只读显示投影。 +- 聚焦后端回归、前端构建与报销详情页面验证;不包含公网部署。 + +## Requirements + +### R1 生产报销只读核查 + +- 只读定位 `majianyong` 在 2026-08-28 提交的 4 笔报销;若实际结果不恰好为 4 笔,应停止逐笔结论并报告范围漂移。 +- 仅读取核查所需的最小字段:业务编号、状态、报销日期、事由、项目关联、费用类型、明细日期/金额/说明、发票与明细关联状态、补充附件类型/说明、材料完整性提示和审批节点摘要。 +- 按当前事实源检查:事由和明细说明是否能说明购买/支出内容及业务用途;费用类型和日期是否准确;项目性费用是否关联项目;多张发票是否合理拆分或能与明细对应;发票是否逐条关联;支付截图、订单或情况说明等补充材料是否足以支撑实付与用途。 +- 输出逐笔脱敏问题清单,区分“应退回补正”“建议补充”“仅需财务核对”;不得在任务文件、仓库或聊天中记录完整发票号码、附件原文、原始 payload、账号、凭据或存储路径。 +- 本任务不替用户编辑、撤回、审批或付款,不写生产数据库,不写企业微信。 + +### R2 用户可见审批文案 + +- `owner_self_fallback` 继续作为内部路由/审计语义,仍须保持 active 非本人财务处理和禁止自审。 +- 报销详情中该节点统一显示为 `财务复核`;“当前处理”按现有结构显示 `财务复核 · <实际处理人姓名>`,不显示内部角色代码;“下一步”显示 `待财务复核处理`。 +- 处理进度时间线对当前和历史负责人本人兜底快照均显示 `财务复核`,不得要求迁移或重写历史 `workflow_steps_snapshot`。 +- 新提交报销生成的业务步骤名使用可读文案,内部兜底原因保留在稳定步骤 ID/路由逻辑中,不能因文案调整丢失自审保护或审批人解析依据。 +- 聚焦核对待办/移动审批等直接使用任务步骤名的用户入口;如它们会继续暴露该内部文案,应复用同一服务端显示语义或确保新任务不再生成内部文案。不得进行通用工作流 UI 重构。 + +## Acceptance Criteria + +- [x] 生产只读结果恰好覆盖 `majianyong` 于 2026-08-28 提交的 4 笔报销,并形成不含敏感原文的逐笔规范性结论。 +- [x] 每笔结论至少覆盖事由、项目、明细拆分/说明、日期与金额、发票对应、补充材料六类检查,并给出明确处理建议。 +- [x] `BX-20260828-0018` 不再把 5 张发票仅以泛化“办公用品采购”视为充分说明;结论明确指出其明细对应和用途证据缺口。 +- [x] 负责人本人申请仍只由 active 非本人 `finance` 处理,不产生自审、不改变普通员工审批链。 +- [x] 报销详情的“当前处理”“下一步”和处理进度不再显示 `负责人本人申请兜底`、`finance` 或其他内部步骤标识,分别使用“财务复核 · 处理人”“待财务复核处理”和“财务复核”。 +- [x] 已存在的历史兜底快照无需数据迁移即可得到同样的用户可读投影;新提交记录不再生成内部原因作为用户可见步骤名。 +- [x] 后端聚焦测试覆盖历史步骤名、新步骤名、普通财务节点、非财务节点与禁止自审回归;相关前端类型检查、构建和报销详情 Playwright 通过。 +- [x] 审批/workflow 变更完成后进行一次只读代码复核,确认权限、状态机、快照和审批规则语义未扩大。 +- [x] 未经另行批准不部署公网;部署并通过公网只读检查前,不告知用户线上可测试。 + +## Out of Scope + +- 修改、撤回、驳回、通过这 4 笔真实报销,或替员工补录材料。 +- 修改审批角色、负责人直属主管、审批金额档、生产审批规则或历史快照。 +- 执行真实付款、付款批次操作、附件下载、企业微信写入或数据库迁移。 +- 新增通用 BPM、重构全部业务域审批文案或改变现有页面布局。 + +## Risks and Constraints + +- 生产数据可能已发生状态变化;只读查询必须冻结查询时间并以业务编号为结论锚点。 +- 发票 PDF 和支付证明需要人工看原件才能判断真实性;本轮自动/字段核查只能判断材料结构与对应关系,不能替代财务验真。 +- 历史快照是审批事实,文案修复必须在显示投影层兼容,不得回写历史数据。 + +## Test Plan + +- 聚焦后端:`cd server && npx vitest run tests/unit/utils/workflow.test.js tests/unit/routes/reimbursements.detail.test.js`。 +- 基础后端:`cd server && npm run prisma:validate`,修改的 JavaScript 文件执行 `node --check`。 +- 前端:`cd client && npm run typecheck && npm run build`;运行覆盖 PC 报销详情的聚焦 Playwright。 +- 流程治理:`node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-trellis-governance.mjs`、`git diff --check`。 +- 人工只读复核:检查 diff 未改变审批资格、任务推进、历史快照持久化或生产数据。 diff --git a/.trellis/tasks/08-28-reimbursement-review-routing-copy/task.json b/.trellis/tasks/08-28-reimbursement-review-routing-copy/task.json new file mode 100644 index 00000000..b90b7db6 --- /dev/null +++ b/.trellis/tasks/08-28-reimbursement-review-routing-copy/task.json @@ -0,0 +1,26 @@ +{ + "id": "reimbursement-review-routing-copy", + "name": "reimbursement-review-routing-copy", + "title": "报销规范核查与审批文案优化", + "description": "只读核查 majianyong 的 4 笔报销规范性,并修正负责人本人财务兜底节点的用户可见文案。", + "status": "in_progress", + "dev_type": null, + "scope": "server/workflow and reimbursement display projection; production reimbursement read-only review", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-28", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 986c0726..4e969553 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -57,6 +57,7 @@ const { buildApprovalWorkflowSnapshot, getWorkflowStepsSnapshot, resolveActionableStep, + resolveWorkflowStepDisplayName, toMoneyString, } = require("../utils/workflow"); @@ -355,11 +356,12 @@ function isOrdinalApprovalLabel(value) { } function resolveApprovalStageLabel(task) { - const stepName = String(task?.step_name ?? "").trim(); + const stepName = resolveWorkflowStepDisplayName(task); const stepId = String(task?.step_id ?? "").trim(); const isTechnicalIdentifier = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+$/i.test(stepName); const isGenericLabel = ["审批", "审核", "复核"].includes(stepName); - if (stepName && stepName !== stepId && !isOrdinalApprovalLabel(stepName) && !isTechnicalIdentifier && !isGenericLabel) { + if (stepName && stepName !== stepId && !isOrdinalApprovalLabel(stepName) + && !isTechnicalIdentifier && !isGenericLabel) { return stepName; } diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index dfec9b2d..e8757da9 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -8,6 +8,7 @@ const { getWorkflowStepsSnapshot, isBusinessApprovalRoleCode, resolveActionableStep, + resolveWorkflowStepDisplayName, toMoneyString, } = require("../utils/workflow"); const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave"); @@ -336,6 +337,7 @@ function isoOrNull(value) { function mapTaskSummary(task, allowedActions = []) { const businessSummary = buildTaskBusinessSummary(task); + const stepDisplayName = resolveWorkflowStepDisplayName(task); const workflowInstance = task.workflow_instance ? { entity_type: task.workflow_instance.entity_type, @@ -348,7 +350,7 @@ function mapTaskSummary(task, allowedActions = []) { id: task.id, instance_id: task.instance_id, step_id: task.step_id, - step_name: task.step_name, + step_name: stepDisplayName, assignee_id: task.assignee_id, assignee_role: task.assignee_role, status: task.status, @@ -359,7 +361,7 @@ function mapTaskSummary(task, allowedActions = []) { instanceId: task.instance_id, stepId: task.step_id, - stepName: task.step_name, + stepName: stepDisplayName, assigneeId: task.assignee_id, assigneeRole: task.assignee_role, createdAt: isoOrNull(task.created_at), @@ -531,7 +533,7 @@ function buildWorkflowTaskSummaryPayload(tasks, scope = "mine", now = new Date() } incrementSummaryBucket(byEntityType, task.workflow_instance?.entity_type ?? "unknown", task, now); - incrementSummaryBucket(byStep, task.step_name ?? task.step_id ?? "unknown", task, now); + incrementSummaryBucket(byStep, resolveWorkflowStepDisplayName(task) || task.step_id || "unknown", task, now); incrementSummaryBucket(byAssigneeRole, task.assignee_role ?? (task.assignee_id ? "指定审批人" : "未分配"), task, now); } @@ -2433,7 +2435,7 @@ async function workflowRoutes(fastify) { id: task.id, instanceId: task.instance_id, stepId: task.step_id, - stepName: task.step_name, + stepName: resolveWorkflowStepDisplayName(task), assigneeId: task.assignee_id, assigneeRole: task.assignee_role, status: task.status, diff --git a/server/src/utils/workflow.js b/server/src/utils/workflow.js index 9876e565..f2ba4d1a 100644 --- a/server/src/utils/workflow.js +++ b/server/src/utils/workflow.js @@ -107,13 +107,25 @@ function getRoleCodeFromStep(step) { return rule?.type === "role" && typeof rule.value === "string" ? rule.value : null; } +function resolveWorkflowStepDisplayName(step) { + const stepName = String(step?.step_name ?? step?.stepName ?? step?.name ?? "").trim(); + const assigneeRole = String(step?.assignee_role ?? step?.assigneeRole ?? "").toLowerCase(); + const isLegacyOwnerFallback = /^负责人本人申请兜底\s*[::]/u.test(stepName); + + if (isLegacyOwnerFallback && ["finance", "finance_mgr"].includes(assigneeRole)) { + return "财务复核"; + } + + return stepName; +} + function buildRoleApprovalStep(roleCode, index, sourceStep, reason) { const suffix = reason === "owner_self_fallback" ? "_owner_self_fallback" : ""; return { id: `approval_${index + 1}_${roleCode}${suffix}`, name: reason === "owner_self_fallback" - ? `负责人本人申请兜底:${roleCode}` + ? "财务复核" : sourceStep?.name ?? `审批节点${index + 1}`, type: sourceStep?.type ?? "approval", assignee_rule: { type: "role", value: roleCode }, @@ -565,5 +577,6 @@ module.exports = { resolveActionableStep, resolveAssigneeFromRule, resolveDepartmentManagerAssignee, + resolveWorkflowStepDisplayName, toMoneyString, }; diff --git a/server/tests/unit/routes/reimbursements.detail.test.js b/server/tests/unit/routes/reimbursements.detail.test.js index 8cde61b5..5336fe4f 100644 --- a/server/tests/unit/routes/reimbursements.detail.test.js +++ b/server/tests/unit/routes/reimbursements.detail.test.js @@ -443,6 +443,59 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { await app.close(); }); + it("projects legacy owner-self fallback task names as finance review", async () => { + const { app } = await buildApp({ + latestWorkflowInstance: { + id: PENDING_INSTANCE_ID, + current_step_id: "approval_1_finance_owner_self_fallback", + current_status: "pending", + created_at: new Date("2026-08-28T11:08:00.000Z"), + started_at: new Date("2026-08-28T11:08:00.000Z"), + context_data: { + workflow_steps_snapshot: [{ + id: "approval_1_finance_owner_self_fallback", + name: "负责人本人申请兜底:finance", + type: "approval", + assignee_rule: { type: "role", value: "finance" }, + }], + }, + workflow_definition: { definition_json: { steps: [] } }, + workflow_tasks: [{ + id: "owner-fallback-task", + step_id: "approval_1_finance_owner_self_fallback", + step_name: "负责人本人申请兜底:finance", + assignee_role: "finance", + status: "pending", + outcome: null, + comment: null, + created_at: new Date("2026-08-28T11:08:00.000Z"), + completed_at: null, + assignee_employee: { name: "财务人员" }, + completer_employee: null, + }], + }, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/reimbursements/${REIMBURSEMENT_ID}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.processingProgress.stages[1]).toEqual( + expect.objectContaining({ + key: "approval_owner-fallback-task", + label: "财务复核", + state: "current", + actorName: "财务人员", + detail: "待财务复核处理", + }), + ); + + await app.close(); + }); + it("appends future snapshot approvals that do not have workflow tasks yet", async () => { const { app } = await buildApp({ latestWorkflowInstance: { diff --git a/server/tests/unit/routes/workflow.travel.test.js b/server/tests/unit/routes/workflow.travel.test.js index 49389102..f6660f52 100644 --- a/server/tests/unit/routes/workflow.travel.test.js +++ b/server/tests/unit/routes/workflow.travel.test.js @@ -122,7 +122,7 @@ describe("workflow travel helpers", () => { { id: "task-2", status: "pending", - step_name: "财务审批", + step_name: "负责人本人申请兜底:finance", assignee_role: "finance", created_at: new Date("2026-05-12T07:00:00.000Z"), due_date: new Date("2026-05-12T07:30:00.000Z"), @@ -148,5 +148,10 @@ describe("workflow travel helpers", () => { { key: "purchase_request", pendingTotal: 1, overdueTotal: 1 }, ]), ); + expect(summary.byStep).toEqual( + expect.arrayContaining([ + { key: "财务复核", pendingTotal: 1, overdueTotal: 1 }, + ]), + ); }); }); diff --git a/server/tests/unit/utils/workflow.test.js b/server/tests/unit/utils/workflow.test.js index 362d3b66..705ac1c3 100644 --- a/server/tests/unit/utils/workflow.test.js +++ b/server/tests/unit/utils/workflow.test.js @@ -114,6 +114,22 @@ describe("workflow assignee resolution", () => { ); }); + describe("resolveWorkflowStepDisplayName", () => { + it("hides the internal role code in legacy owner fallback tasks", () => { + expect(workflowUtils.resolveWorkflowStepDisplayName({ + step_name: "负责人本人申请兜底:finance", + assignee_role: "finance", + })).toBe("财务复核"); + }); + + it("keeps normal readable workflow names unchanged", () => { + expect(workflowUtils.resolveWorkflowStepDisplayName({ + step_name: "高额财务终审", + assignee_role: "finance", + })).toBe("高额财务终审"); + }); + }); + describe("buildApprovalWorkflowSnapshot", () => { it("keeps ordinary employee approval snapshots unchanged", async () => { const tx = buildTx({ ownerRoles: ["employee"], managerId: MANAGER_ID }); @@ -153,6 +169,7 @@ describe("workflow assignee resolution", () => { { type: "role", value: "finance" }, ]); expect(steps[0].id).toContain("owner_self_fallback"); + expect(steps[0].name).toBe("财务复核"); const resolved = await resolveActionableStep({ tx, @@ -184,6 +201,7 @@ describe("workflow assignee resolution", () => { { type: "role", value: "finance" }, ]); expect(steps[0].id).toContain("owner_self_fallback"); + expect(steps[0].name).toBe("财务复核"); }); it("removes CEO self node and duplicate fallback role for large CEO owner applications", async () => {