修复(08-28-reimbursement-review-routing-copy): 规范财务兜底文案

这个提交包含在:
2026-08-28 20:30:04 +08:00
父节点 1bb066ddb7
当前提交 be057e665d
共修改 12 个文件,包含 291 行新增和 8 行删除
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -0,0 +1,47 @@
# 设计:报销规范核查与审批文案优化
## 边界
本任务包含两个相互关联但可独立验证的输出:生产 4 笔报销的只读规范性报告,以及负责人本人财务兜底节点的用户文案修复。前者不写业务数据;后者只调整步骤命名与详情显示投影,不改审批规则、角色、权限或数据库结构。
## 数据流
### 报销核查
```text
生产只读数据 → 按提交人/日期冻结 4 个业务编号 → 最小字段核查
→ 对照报销/发票/附件事实源 → 脱敏逐笔结论
```
- 优先使用现有受控生产只读连接或已授权 API 身份。
- 查询结果只在进程内用于判断;任务文档只登记问题分类、业务编号和必要的非敏感摘要。
- 不打开或下载原始附件时,只把“需要财务查看原件”列为保留项,不推断真实性。
### 审批文案
```text
审批规则解析
→ buildRoleApprovalStep(保存步骤 ID、角色与业务步骤名)
→ workflow task / workflow_steps_snapshot(历史事实)
→ reimbursement processingProgress 显示投影
→ 当前处理 / 下一步 / 时间线
```
负责人本人兜底的技术原因继续由步骤 ID 后缀 `_owner_self_fallback`、分支原因和角色规则表达。业务步骤名改为按角色映射后的 `财务复核`,避免新记录继续污染用户入口。针对已经保存旧步骤名的历史记录,`resolveApprovalStageLabel` 将 `负责人本人申请兜底:<role>` 识别为内部标签,再按 `assignee_role` 映射为 `财务复核`。
`processingProgress` 当前阶段的 `detail` 不再拼接旧步骤名,而使用规范化后的 stage label,因此详情页现有 `nextStepText` 可直接显示 `待财务复核处理`。前端无需引入第二套文案映射。
## 兼容性
- 不回写历史工作流实例或任务。
- 普通的自定义可读节点名继续保留;只把明确的负责人本人兜底内部标签、序号、通用名和技术标识走角色映射。
- `finance` 与 `finance_mgr` 的显示均为 `财务复核`;审批资格仍按原角色执行。
- 报销审批历史如直接读取旧 `step_name`,需在同一服务端显示边界复用规范化函数,避免详情页不同区域一处修好、一处仍泄漏。
- 待办中心的历史任务若仍直接暴露旧名,本任务只做必要的共享显示归一化,不改变任务 API 的权限与动作模型。
## 风险与回滚
- 风险:过宽地判定“不可读节点名”会覆盖合法自定义名称。控制方式是只增加负责人本人兜底前缀的精确识别,并保留现有名称优先级测试。
- 风险:仅改生成名无法修复已经提交的 4 笔。控制方式是在读取投影层同时兼容历史名。
- 回滚:代码回退即可;无 schema、配置或数据回滚。
- 公网部署不在默认执行范围,必须另行明确批准。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -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`:聚焦回归。
- 如待办入口确需同步,优先复用已有显示函数或服务端投影,不新增页面级散落映射。
@@ -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 未改变审批资格、任务推进、历史快照持久化或生产数据。
@@ -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": {}
}
+4 -2
查看文件
@@ -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;
}
+6 -4
查看文件
@@ -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,
+14 -1
查看文件
@@ -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,
};
@@ -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: {
@@ -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 },
]),
);
});
});
+18
查看文件
@@ -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 () => {