From f33b3569c662487552c2429635d621e142870327 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 1 Aug 2026 13:38:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=A5=E9=94=80=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E5=AE=A1=E6=89=B9=E8=8A=82=E7=82=B9=E8=A6=86=E7=9B=96?= =?UTF-8?q?=EF=BC=8807-27-yang-jun-reimbursement-readiness=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/approval-workflow.md | 73 ++++-- client/src/api/reimbursements.ts | 2 +- .../pageObjects/ReimbursementDetailPage.ts | 4 +- .../e2e/pc-detail-mobile-visual-wave3.spec.ts | 3 + .../reimbursement-workflow-progress.spec.ts | 11 +- server/src/routes/reimbursements.js | 97 ++++++- .../unit/routes/reimbursements.detail.test.js | 241 +++++++++++++++++- 7 files changed, 386 insertions(+), 45 deletions(-) diff --git a/.trellis/spec/hyb-oa/approval-workflow.md b/.trellis/spec/hyb-oa/approval-workflow.md index 76554b3c..2de92646 100644 --- a/.trellis/spec/hyb-oa/approval-workflow.md +++ b/.trellis/spec/hyb-oa/approval-workflow.md @@ -109,13 +109,31 @@ if (!hasPermission(actor, "payment.batch.review") - Response: `processingProgress.stages[]` with `key`, `label`, `state`, nullable `actorName`, `occurredAt`, `result`, `comment`, `detail` and permission-gated `targetHref`. -- Stable stage keys: `submitted`, `manager_approval`, `finance_review`, - `payment_batch_review`, `payment_and_receipt`. +- Fixed stage keys: `submitted`, `payment_batch_review` and + `payment_and_receipt`. Each persisted workflow task contributes one unique + approval-stage key derived from task identity; clients treat keys as opaque + strings and never infer responsibility from their value. ### 3. Contracts -- Always return the five stages in the stable order above. UI labels describe - responsibilities and never expose step IDs, role codes or enum values. +- Always return submission first, then one stage per real workflow task in the + query's stable `created_at,id` order. Because the engine creates only the + current task, append not-yet-materialized future steps from the immutable + `workflow_steps_snapshot` after the current step, then return payment-batch + review and payment/receipt. The current two-approval production rule therefore + returns five stages from initial submission onward; configured extra or + repeated-role approvals increase the array length instead of being discarded, + merged or temporarily hidden. +- Never collect workflow tasks into a `Map` keyed by semantic responsibility: + two finance, manager or CEO tasks are distinct historical facts and later + tasks must not overwrite earlier ones. The UI renders `stages` with a plain + ordered `map` and contains no fixed-length or positional responsibility logic. +- Approval labels use a readable non-generic `step_name` first. Empty, + ordinal (`步骤2`, `审批节点1`, `step 3`), raw technical identifier or generic + (`审批`, `审核`, `复核`) names fall back through role mapping + (`manager -> 主管审批`, `finance|finance_mgr -> 财务复核`, + `ceo -> 负责人复核`) and finally `审批`. UI labels never expose step IDs, + role codes or sequence numbers. - Completed stages use the actual completer/actor and completion time. Current workflow stages use the actual pending assignee when available. Future stages keep `actorName=null`; never guess a person from today's role holders. @@ -131,8 +149,15 @@ if (!hasPermission(actor, "payment.batch.review") ### 4. Validation & Error Matrix -- No workflow instance -> submission/applicant state remains readable; approval - and payment stages stay pending. +- No workflow instance -> submission/applicant state remains readable and the + approval segment falls back to pending `主管审批` / `财务复核` placeholders. +- Workflow instance with no task yet -> build pending approval stages from its + immutable snapshot. Current task exists but later tasks do not -> append those + future snapshot stages with `actorName=null`; never infer future employees. +- A third unknown task -> one additional approval stage in its real position; + it must not disappear before the payment stages. +- Two tasks with the same role -> two unique ordered stages with their own + actor, state, time and comment; neither may overwrite the other. - Workflow approved + no batch -> payment-batch stage is current, future actor remains null. - Batch `draft|reviewed|executed|failed|cancelled` -> map to explicit current, @@ -148,36 +173,40 @@ if (!hasPermission(actor, "payment.batch.review") - Good: `财务复核=通过` followed by current `待财务创建付款批次`, so the owner can diagnose the exact responsibility without asking three people. -- Base: a newly submitted reimbursement shows known submission/current approval - and three future stages with no future names. -- Bad: display an expected checker before batch creation, label a shared batch - as `您的报销待复核`, or hide the whole timeline because no receipt exists. +- Base: a newly submitted reimbursement shows known submission, every real + configured approval task, and the two future payment stages with no guessed + future names. +- Bad: classify tasks into manager/finance keys and overwrite duplicates, skip + the third task, display an expected checker before batch creation, label a + shared batch as `您的报销待复核`, or hide the whole timeline because no receipt + exists. ### 6. Tests Required -- Route tests for no workflow, pending/completed/returned/rejected tasks, all - five batch statuses, receipt count zero/positive and paid-without-batch legacy - data. +- Route tests for no workflow, empty-task instance, a current task plus future + snapshot-only steps, pending/completed/returned/rejected tasks, a third + unclassified task, two same-role tasks, all five batch statuses, receipt count + zero/positive and paid-without-batch legacy data. - Assert future stages have null actors and ordinary applicants receive no batch link or batch-adjacent business data. -- PC/mobile tests assert five responsibility labels, no `第 N 步`, comments on - exception nodes, four-block order and no loss of earlier stages when a - downstream source is absent. +- PC/mobile tests assert variable-length ordered rendering, no `第 N 步` or raw + ordinal label, comments on exception nodes, four-block order and no loss of + earlier stages when a downstream source is absent. ### 7. Wrong vs Correct #### Wrong ```js -return workflowInstance ? buildTimeline(workflowInstance) : null; +for (const task of workflowTasks) { + stageByRole.set(classify(task), buildStage(task)); +} ``` #### Correct ```js -return buildProcessingProgress({ - reimbursement, - workflowInstance: workflowInstance ?? null, - paymentBatchItem: paymentBatchItem ?? null, -}); +const approvalStages = workflowTasks.map((task) => buildApprovalStage(task)); +approvalStages.push(...buildFutureSnapshotStages(workflowSnapshot, workflowTasks)); +return [submittedStage, ...approvalStages, batchReviewStage, paymentStage]; ``` diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 966da62e..c3119dad 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -113,7 +113,7 @@ export type ReimbursementWorkflowProgress = { }; export type ReimbursementProcessingStage = { - key: "submitted" | "manager_approval" | "finance_review" | "payment_batch_review" | "payment_and_receipt"; + key: string; label: string; state: "completed" | "current" | "pending" | "exception"; actorName: string | null; diff --git a/client/tests/e2e/helpers/pageObjects/ReimbursementDetailPage.ts b/client/tests/e2e/helpers/pageObjects/ReimbursementDetailPage.ts index 87275f58..28338656 100644 --- a/client/tests/e2e/helpers/pageObjects/ReimbursementDetailPage.ts +++ b/client/tests/e2e/helpers/pageObjects/ReimbursementDetailPage.ts @@ -79,6 +79,8 @@ export class ReimbursementDetailPageObject { } async uploadInvoicePdf(fileName: string, buffer: Buffer) { + const invoiceCards = this.invoices.locator(":scope > div"); + const invoiceCountBeforeUpload = await invoiceCards.count(); await this.invoiceAddBtn.click(); await expect(this.invoiceDialog).toBeVisible(); @@ -101,7 +103,7 @@ export class ReimbursementDetailPageObject { throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`); } - await expect(this.invoices).toContainText(fileName, { timeout: 90_000 }); + await expect(invoiceCards).toHaveCount(invoiceCountBeforeUpload + 1, { timeout: 90_000 }); await this.page.keyboard.press("Escape"); await expect(this.invoiceDialog).toHaveCount(0); diff --git a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts index 69e5eb42..c6fa4f54 100644 --- a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts +++ b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts @@ -153,6 +153,7 @@ const reimbursementDetail = { stages: [ { key: "submitted", label: "申请提交", state: "completed", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "已提交", comment: null, detail: null, targetHref: null }, { key: "manager_approval", label: "主管审批", state: "current", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "处理中", comment: null, detail: "待主管审批处理", targetHref: null }, + { key: "approval_extra-review", label: "负责人复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null }, { key: "finance_review", label: "财务复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null }, { key: "payment_batch_review", label: "付款批次复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务创建付款批次", targetHref: null }, { key: "payment_and_receipt", label: "付款与回单", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款批次复核后处理", targetHref: null }, @@ -1096,6 +1097,7 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1); await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1); await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0); + await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核"); await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款批次复核"); await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/); @@ -1107,6 +1109,7 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible(); await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0); await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name); + await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("负责人复核"); await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10"); await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1); await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1); diff --git a/client/tests/e2e/reimbursement-workflow-progress.spec.ts b/client/tests/e2e/reimbursement-workflow-progress.spec.ts index 9c4362e8..a7361900 100644 --- a/client/tests/e2e/reimbursement-workflow-progress.spec.ts +++ b/client/tests/e2e/reimbursement-workflow-progress.spec.ts @@ -6,8 +6,8 @@ import { openNavLink } from "./helpers/navigation"; async function firstEnabledExpenseTypeId(api: APIRequestContext) { const response = await api.get("/api/expense-types?enabled=true"); - const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean }> } }; - const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0]; + const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean; category?: string | null }> } }; + const item = payload.data?.items?.find((candidate) => candidate.enabled !== false && candidate.category !== "travel"); if (!response.ok() || !item?.id) { throw new Error(`无法读取费用类型:HTTP ${response.status()}`); } @@ -29,7 +29,7 @@ async function createReimbursementItem(api: APIRequestContext, reimbursementId: } } -test("E2E 报销处理进度:提交后详情页展示五阶段职责进度", async ({ page, reimbursementDetail }) => { +test("E2E 报销处理进度:提交后按真实 workflow 展示职责进度", async ({ page, reimbursementDetail }) => { const apiClient = await createAuthenticatedApiClient(EMPLOYEE); await login(page, EMPLOYEE); @@ -67,9 +67,10 @@ test("E2E 报销处理进度:提交后详情页展示五阶段职责进度", a await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 }); await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 }); + await expect(reimbursementDetail.workflowProgress.locator(":scope > li")).toHaveCount(4); await expect(reimbursementDetail.workflowProgress).toContainText("申请提交"); - await expect(reimbursementDetail.workflowProgress).toContainText("主管审批"); - await expect(reimbursementDetail.workflowProgress).toContainText("财务复核"); + await expect(reimbursementDetail.workflowProgress).toContainText("审批"); + await expect(reimbursementDetail.workflowProgress).not.toContainText("审批节点1"); await expect(reimbursementDetail.workflowProgress).toContainText("付款批次复核"); await expect(reimbursementDetail.workflowProgress).toContainText("付款与回单"); await expect(reimbursementDetail.workflowProgress).not.toContainText(/第 \d+ 步/); diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 8676fdfc..17933be6 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -341,16 +341,52 @@ function isoDateTimeOrNull(value) { return value?.toISOString?.() ?? null; } -function classifyApprovalTask(task, index) { +function isOrdinalApprovalLabel(value) { + const label = String(value ?? "").trim(); + if (!label) return true; + const ordinal = "(?:\\d+|[一二三四五六七八九十百]+)"; + return new RegExp(`^(?:第\\s*)?${ordinal}\\s*(?:步|步骤|节点)$`, "i").test(label) + || new RegExp(`^(?:审批)?(?:步骤?|节点|step)\\s*[-_#::]?\\s*${ordinal}$`, "i").test(label); +} + +function resolveApprovalStageLabel(task) { + const stepName = String(task?.step_name ?? "").trim(); + 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) { + return stepName; + } + const role = String(task?.assignee_role ?? "").toLowerCase(); - const text = `${task?.step_id ?? ""} ${task?.step_name ?? ""}`.toLowerCase(); - if (["finance", "finance_mgr"].includes(role) || /财务|finance/.test(text)) { - return "finance_review"; - } - if (["manager", "ceo"].includes(role) || /主管|经理|manager|ceo/.test(text)) { - return "manager_approval"; - } - return index === 0 ? "manager_approval" : index === 1 ? "finance_review" : null; + if (role === "manager") return "主管审批"; + if (["finance", "finance_mgr"].includes(role)) return "财务复核"; + if (role === "ceo") return "负责人复核"; + return "审批"; +} + +function resolveSnapshotApprovalRole(step) { + if (step?.assignee_rule?.type === "department_manager") return "manager"; + if (step?.assignee_rule?.type === "role") return step.assignee_rule.value ?? null; + return null; +} + +function buildPendingSnapshotApprovalStage(step, index) { + return { + key: `approval_pending_${index + 1}`, + label: resolveApprovalStageLabel({ + step_id: step?.id, + step_name: step?.name, + assignee_role: resolveSnapshotApprovalRole(step), + }), + state: "pending", + actorName: null, + occurredAt: null, + result: null, + comment: null, + detail: "待处理", + targetHref: null, + }; } function buildApprovalProcessingStage(task, fallback) { @@ -436,10 +472,34 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch }); } - for (const [index, task] of (workflowInstance?.workflow_tasks ?? []).entries()) { - const stageKey = classifyApprovalTask(task, index); - if (!stageKey || !stageByKey.has(stageKey)) continue; - stageByKey.set(stageKey, buildApprovalProcessingStage(task, stageByKey.get(stageKey))); + const approvalStages = []; + const workflowTasks = workflowInstance?.workflow_tasks ?? []; + for (const [index, task] of workflowTasks.entries()) { + const fallback = { + key: `approval_${task.id ?? index + 1}`, + label: resolveApprovalStageLabel(task), + state: "pending", + actorName: null, + occurredAt: null, + result: null, + comment: null, + detail: "待处理", + targetHref: null, + }; + approvalStages.push(buildApprovalProcessingStage(task, fallback)); + } + + if (workflowInstance) { + const snapshotSteps = getWorkflowStepsSnapshot(workflowInstance); + const currentStepIndex = snapshotSteps.findIndex((step) => step.id === workflowInstance.current_step_id); + const materializedStepIds = new Set(workflowTasks.map((task) => task.step_id).filter(Boolean)); + const pendingStartIndex = workflowTasks.length === 0 ? 0 : currentStepIndex + 1; + for (let index = Math.max(pendingStartIndex, 0); index < snapshotSteps.length; index += 1) { + const step = snapshotSteps[index]; + if (!materializedStepIds.has(step.id)) { + approvalStages.push(buildPendingSnapshotApprovalStage(step, index)); + } + } } const batch = paymentBatchItem?.payment_batch ?? null; @@ -516,7 +576,16 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch }); } - return { stages: PROCESSING_STAGE_DEFINITIONS.map(({ key }) => stageByKey.get(key)) }; + return { + stages: [ + stageByKey.get("submitted"), + ...(approvalStages.length > 0 + ? approvalStages + : [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]), + stageByKey.get("payment_batch_review"), + stageByKey.get("payment_and_receipt"), + ], + }; } function ensureApplicantOrAdmin(request, reimbursement) { diff --git a/server/tests/unit/routes/reimbursements.detail.test.js b/server/tests/unit/routes/reimbursements.detail.test.js index 07dc347d..3bdf0282 100644 --- a/server/tests/unit/routes/reimbursements.detail.test.js +++ b/server/tests/unit/routes/reimbursements.detail.test.js @@ -320,8 +320,8 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { }); expect(response.json().data.processingProgress.stages).toEqual([ expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }), - expect.objectContaining({ key: "manager_approval", state: "completed", actorName: "王经理", result: "通过" }), - expect.objectContaining({ key: "finance_review", state: "current", actorName: "财务专员", result: "处理中" }), + expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed", actorName: "王经理", result: "通过" }), + expect.objectContaining({ key: "approval_finance-task", label: "财务审批", state: "current", actorName: "财务专员", result: "处理中" }), expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务创建付款批次" }), expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }), ]); @@ -360,6 +360,243 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { await app.close(); }); + it("keeps a third approval task and uses the role label when its configured name is only an ordinal", async () => { + const { app } = await buildApp({ + latestWorkflowInstance: { + id: PENDING_INSTANCE_ID, + current_step_id: "ceo_approve", + current_status: "pending", + created_at: new Date("2026-04-10T10:00:00.000Z"), + started_at: new Date("2026-04-10T10:00:00.000Z"), + context_data: { + workflow_steps_snapshot: [ + { id: "manager_approve", name: "主管审批" }, + { id: "finance_approve", name: "财务复核" }, + { id: "ceo_approve", name: "步骤3" }, + ], + }, + workflow_definition: { + definition_json: { + steps: [ + { id: "manager_approve", name: "主管审批" }, + { id: "finance_approve", name: "财务复核" }, + { id: "ceo_approve", name: "步骤3" }, + ], + }, + }, + workflow_tasks: [ + { + id: "manager-task", + step_id: "manager_approve", + step_name: "主管审批", + assignee_role: "manager", + status: "completed", + outcome: "approved", + comment: null, + created_at: new Date("2026-04-10T10:00:00.000Z"), + completed_at: new Date("2026-04-10T11:00:00.000Z"), + assignee_employee: { name: "主管" }, + completer_employee: { name: "主管" }, + }, + { + id: "finance-task", + step_id: "finance_approve", + step_name: "财务复核", + assignee_role: "finance", + status: "completed", + outcome: "approved", + comment: null, + created_at: new Date("2026-04-10T11:00:00.000Z"), + completed_at: new Date("2026-04-10T12:00:00.000Z"), + assignee_employee: { name: "财务" }, + completer_employee: { name: "财务" }, + }, + { + id: "ceo-task", + step_id: "ceo_approve", + step_name: "步骤3", + assignee_role: "ceo", + status: "pending", + outcome: null, + comment: null, + created_at: new Date("2026-04-10T12:00: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.slice(1, 4)).toEqual([ + expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed" }), + expect.objectContaining({ key: "approval_finance-task", label: "财务复核", state: "completed" }), + expect.objectContaining({ key: "approval_ceo-task", label: "负责人复核", state: "current", actorName: "负责人" }), + ]); + + await app.close(); + }); + + it("appends future snapshot approvals that do not have workflow tasks yet", async () => { + const { app } = await buildApp({ + latestWorkflowInstance: { + id: PENDING_INSTANCE_ID, + current_step_id: "manager_approve", + current_status: "pending", + created_at: new Date("2026-04-10T10:00:00.000Z"), + started_at: new Date("2026-04-10T10:00:00.000Z"), + context_data: { + workflow_steps_snapshot: [ + { id: "manager_approve", name: "主管审批", type: "approval", assignee_rule: { type: "department_manager" } }, + { id: "finance_approve", name: "财务复核", type: "approval", assignee_rule: { type: "role", value: "finance" } }, + { id: "ceo_approve", name: "步骤3", type: "approval", assignee_rule: { type: "role", value: "ceo" } }, + ], + }, + workflow_definition: { definition_json: { steps: [] } }, + workflow_tasks: [{ + id: "manager-task", + step_id: "manager_approve", + step_name: "主管审批", + assignee_role: "manager", + status: "pending", + outcome: null, + comment: null, + created_at: new Date("2026-04-10T10:00: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.slice(1, 4)).toEqual([ + expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "current", actorName: "主管" }), + expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }), + expect.objectContaining({ key: "approval_pending_3", label: "负责人复核", state: "pending", actorName: null }), + ]); + + await app.close(); + }); + + it("keeps multiple tasks with the same role as separate ordered approval stages", async () => { + const { app } = await buildApp({ + latestWorkflowInstance: { + id: PENDING_INSTANCE_ID, + current_step_id: "finance_final", + current_status: "pending", + created_at: new Date("2026-04-10T10:00:00.000Z"), + started_at: new Date("2026-04-10T10:00:00.000Z"), + context_data: { + workflow_steps_snapshot: [ + { id: "finance_first", name: "财务初审" }, + { id: "finance_final", name: "财务终审" }, + ], + }, + workflow_definition: { + definition_json: { + steps: [ + { id: "finance_first", name: "财务初审" }, + { id: "finance_final", name: "财务终审" }, + ], + }, + }, + workflow_tasks: [ + { + id: "finance-first-task", + step_id: "finance_first", + step_name: "finance_first", + assignee_role: "finance", + status: "completed", + outcome: "approved", + comment: "初审通过", + created_at: new Date("2026-04-10T10:00:00.000Z"), + completed_at: new Date("2026-04-10T11:00:00.000Z"), + assignee_employee: { name: "初审人" }, + completer_employee: { name: "初审人" }, + }, + { + id: "finance-final-task", + step_id: "finance_final", + step_name: "财务终审", + assignee_role: "finance", + status: "pending", + outcome: null, + comment: null, + created_at: new Date("2026-04-10T11:00: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.slice(1, 3)).toEqual([ + expect.objectContaining({ key: "approval_finance-first-task", label: "财务复核", actorName: "初审人", state: "completed" }), + expect.objectContaining({ key: "approval_finance-final-task", label: "财务终审", actorName: "终审人", state: "current" }), + ]); + + await app.close(); + }); + + it("falls back to pending manager and finance placeholders when an instance has no tasks yet", async () => { + const { app } = await buildApp({ + latestWorkflowInstance: { + id: PENDING_INSTANCE_ID, + current_step_id: null, + current_status: "pending", + created_at: new Date("2026-04-10T10:00:00.000Z"), + started_at: new Date("2026-04-10T10:00:00.000Z"), + context_data: { + workflow_steps_snapshot: [ + { id: "manager_approve", name: "主管审批" }, + { id: "finance_approve", name: "财务复核" }, + ], + }, + workflow_definition: { + definition_json: { + steps: [ + { id: "manager_approve", name: "主管审批" }, + { id: "finance_approve", name: "财务复核" }, + ], + }, + }, + workflow_tasks: [], + }, + }); + + 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.slice(1, 3)).toEqual([ + expect.objectContaining({ key: "approval_pending_1", label: "主管审批", state: "pending", actorName: null }), + expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }), + ]); + + await app.close(); + }); + it.each([ ["draft", "current", "pending", "待所在付款批次复核通过"], ["reviewed", "completed", "current", "所在付款批次已复核,待执行付款"],