From 421c2d6e1e047d16c39f0dadbce9c28be90e426e Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 1 Aug 2026 12:37:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E6=95=9B=E6=8A=A5=E9=94=80=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=B9=B6=E5=B1=95=E7=A4=BA=E4=BA=94=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=EF=BC=8807-27-yang-jun-reimbursement-readine?= =?UTF-8?q?ss=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 | 88 +++++ client/src/api/reimbursements.ts | 17 + .../ReimbursementProcessingProgress.tsx | 82 ++++ .../ReimbursementTravelSummary.tsx | 16 +- .../pages/MobileReimbursementDetailPage.tsx | 86 ++--- client/src/pages/ReimbursementDetailPage.tsx | 349 ++++-------------- .../e2e/pc-detail-mobile-visual-wave3.spec.ts | 18 + .../r111-invoice-reimbursement-guard.spec.ts | 25 ++ .../reimbursement-workflow-progress.spec.ts | 10 +- server/src/routes/reimbursements.js | 269 +++++++++++++- .../unit/routes/reimbursements.detail.test.js | 232 +++++++++++- 11 files changed, 840 insertions(+), 352 deletions(-) create mode 100644 client/src/components/reimbursements/ReimbursementProcessingProgress.tsx diff --git a/.trellis/spec/hyb-oa/approval-workflow.md b/.trellis/spec/hyb-oa/approval-workflow.md index 9061820f..76554b3c 100644 --- a/.trellis/spec/hyb-oa/approval-workflow.md +++ b/.trellis/spec/hyb-oa/approval-workflow.md @@ -93,3 +93,91 @@ if (!hasPermission(actor, "payment.batch.review") throw forbidden(); } ``` + +## Scenario: Reimbursement End-To-End Processing Projection + +### 1. Scope / Trigger + +- Trigger: showing one reimbursement's progress across approval workflow, + payment-batch review, payment execution and receipt retention. +- This is a read-only projection for diagnosis. It must not merge the workflow + and payment state machines or create synthetic workflow tasks. + +### 2. Signatures + +- Detail API: `GET /api/reimbursements/:id`. +- 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`. + +### 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. +- 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. +- A reimbursement approved without a batch makes `payment_batch_review` the + current stage with `detail="待财务创建付款批次"`. A draft batch says + `所在付款批次待非制单人复核`; shared-batch wording must not imply that this + reimbursement alone is being reviewed again. +- Missing workflow, batch or receipt facts degrade only their stages. Earlier + known stages remain visible; missing downstream objects are normal, not 5xx. +- `targetHref` is returned only when the viewer already has one of the existing + payment-batch read permissions. The projection never returns batch peers, + aggregate amount, other materials or bank receipt contents. + +### 4. Validation & Error Matrix + +- No workflow instance -> submission/applicant state remains readable; approval + and payment stages stay pending. +- Workflow approved + no batch -> payment-batch stage is current, future actor + remains null. +- Batch `draft|reviewed|executed|failed|cancelled` -> map to explicit current, + completed or exception states without changing the batch. +- Executed payment + zero active receipts -> completed payment with + `付款已完成,回单待留存`; positive count -> `付款与回单已留存`. +- Viewer lacks batch-read permission -> `targetHref=null`; do not return 403 for + the reimbursement detail itself. +- Database/query failure -> existing 5xx behavior and server logging; do not + fabricate a completed or pending business fact. + +### 5. Good/Base/Bad Cases + +- 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. + +### 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. +- 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. + +### 7. Wrong vs Correct + +#### Wrong + +```js +return workflowInstance ? buildTimeline(workflowInstance) : null; +``` + +#### Correct + +```js +return buildProcessingProgress({ + reimbursement, + workflowInstance: workflowInstance ?? null, + paymentBatchItem: paymentBatchItem ?? null, +}); +``` diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index d41d6f11..966da62e 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -112,6 +112,22 @@ export type ReimbursementWorkflowProgress = { }>; }; +export type ReimbursementProcessingStage = { + key: "submitted" | "manager_approval" | "finance_review" | "payment_batch_review" | "payment_and_receipt"; + label: string; + state: "completed" | "current" | "pending" | "exception"; + actorName: string | null; + occurredAt: string | null; + result: string | null; + comment: string | null; + detail: string | null; + targetHref: string | null; +}; + +export type ReimbursementProcessingProgress = { + stages: ReimbursementProcessingStage[]; +}; + export type ReimbursementApprovalHistoryItem = { id: string; stepName: string | null; @@ -215,6 +231,7 @@ export type ReimbursementDetail = { } | null; } | null; workflowProgress: ReimbursementWorkflowProgress | null; + processingProgress: ReimbursementProcessingProgress; approvalHistory: ReimbursementApprovalHistoryItem[]; riskFlags: RiskFlag[]; items: ReimbursementItem[]; diff --git a/client/src/components/reimbursements/ReimbursementProcessingProgress.tsx b/client/src/components/reimbursements/ReimbursementProcessingProgress.tsx new file mode 100644 index 00000000..b3d113f5 --- /dev/null +++ b/client/src/components/reimbursements/ReimbursementProcessingProgress.tsx @@ -0,0 +1,82 @@ +import { AlertCircle, Check, Circle, Clock, ExternalLink } from "lucide-react"; +import { Link } from "react-router-dom"; +import type { ReimbursementProcessingProgress as ProcessingProgress } from "../../api/reimbursements"; +import { formatDisplayDateTime } from "../../lib/displayFormat"; +import { cn } from "../../lib/utils"; + +type Props = { + progress: ProcessingProgress; + mobile?: boolean; +}; + +const STATE_TEXT = { + completed: "已完成", + current: "处理中", + pending: "待处理", + exception: "需处理", +} as const; + +export function ReimbursementProcessingProgress({ progress, mobile = false }: Props) { + return ( +
    + {progress.stages.map((stage, index) => { + const expanded = stage.state === "current" || stage.state === "exception" || Boolean(stage.comment); + const Icon = stage.state === "completed" + ? Check + : stage.state === "current" + ? Clock + : stage.state === "exception" + ? AlertCircle + : Circle; + const facts = [stage.actorName, stage.occurredAt ? formatDisplayDateTime(stage.occurredAt) : null, stage.result] + .filter(Boolean); + + return ( +
  1. + {index < progress.stages.length - 1 ? ( +
  2. + ); + })} +
+ ); +} diff --git a/client/src/components/reimbursements/ReimbursementTravelSummary.tsx b/client/src/components/reimbursements/ReimbursementTravelSummary.tsx index e3027315..e9c9748e 100644 --- a/client/src/components/reimbursements/ReimbursementTravelSummary.tsx +++ b/client/src/components/reimbursements/ReimbursementTravelSummary.tsx @@ -7,10 +7,10 @@ import { isTravelReimbursement } from "../../lib/reimbursementTravel"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean }; +type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean; embedded?: boolean }; const emptyForm: ReimbursementTravelSummaryPayload = { travelPurpose: null, travelLocation: null, travelStartDate: null, travelEndDate: null }; -export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false }: Props) { +export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false, embedded = false }: Props) { const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const [form, setForm] = useState(emptyForm); @@ -22,7 +22,10 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable, travelStartDate: detail.travelSummary?.startDate ?? null, travelEndDate: detail.travelSummary?.endDate ?? null, }); - }, [detail.travelSummary]); + if (editable && !detail.travelSummary?.purpose?.trim()) setEditing(true); + else if (editable && !detail.travelSummary?.location?.trim()) setEditing(true); + else if (editable && (!detail.travelSummary?.startDate || !detail.travelSummary?.endDate)) setEditing(true); + }, [detail.travelSummary, editable]); const mutation = useMutation({ mutationFn: () => updateReimbursementTravelSummary(reimbursementId, form), onSuccess: async () => { @@ -34,10 +37,11 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable, }); if (!isTravel) return null; const summary = detail.travelSummary ?? { purpose: null, location: null, startDate: null, endDate: null }; + const Container = embedded ? "div" : "section"; return ( -
+
-

差旅概要

+

出差信息

{editable && !editing ? : null}
{editing ? ( @@ -61,6 +65,6 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
结束日期
{summary.endDate || "未填写"}
)} -
+ ); } diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index eb437b4b..3b294daa 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -40,9 +40,11 @@ import { AttachmentGovernanceBadges } from "../components/common/AttachmentGover import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog"; import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog"; import { ConfirmDialog } from "../components/common/ConfirmDialog"; +import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { MobileIcon } from "../components/mobile/MobileIcon"; import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader"; import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary"; +import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { Button } from "../components/ui/button"; import { @@ -132,20 +134,6 @@ function formatDate(value: string | null | undefined) { return value || "未填写"; } -function workflowActionLabel(action: string) { - const labels: Record = { - start: "提交", - approve: "通过", - reject: "驳回", - return: "退回", - transfer: "转审", - withdraw: "撤回", - remind: "催办", - overdue_remind: "超时提醒", - }; - return labels[action] ?? action; -} - function matchInvoiceSearch( invoice: { invoiceCode?: string | null; @@ -933,17 +921,40 @@ export function MobileReimbursementDetailPage() {
报销人
{detail.owner?.name ?? "-"}
+
+
部门
+
{detail.department?.name ?? "-"}
+
+
+
申请人
+
{detail.submitter?.name ?? "-"}
+
{detail.description ?

{detail.description}

: null} + + {detail.project || detail.linkedTravelRequest ? ( +
+ {detail.project ?

项目:{detail.project.projectNo} · {detail.project.name}

: null} + {detail.linkedTravelRequest ? ( + + 差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"} + + ) : null} +
+ ) : null} + {detail.riskFlags.length ? ( +
+ +
+ ) : null} - -

报销明细

@@ -1164,7 +1175,6 @@ export function MobileReimbursementDetailPage() { ) : null} {detail.invoices.length || (detail.attachments ?? []).length ? ( - canOpenInvoiceDialog || canManageAttachments || Boolean(materialPreviewError) ? (
{detail.invoices.map((invoice) => { @@ -1247,7 +1257,6 @@ export function MobileReimbursementDetailPage() { ))}
- ) : null ) : (
暂无报销材料 @@ -1255,14 +1264,10 @@ export function MobileReimbursementDetailPage() { )}
- {detail.status === "reviewing" || detail.approvalHistory.length ? ( -
+
-

审批

-

- 当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"} -

+

处理进度

{canRemindPendingWorkflow && pendingTaskId ? (
- - {detail.approvalHistory.length ? ( -
- {detail.approvalHistory.map((item) => ( -
-
-
{workflowActionLabel(item.action)}
-
{formatDisplayDateTime(item.createdAt)}
-
-
- {item.stepName ?? "-"} · {item.actorName || "系统"} -
- {item.comment ?

{item.comment}

: null} -
- ))} -
- ) : ( -

暂无审批记录。

- )} +
+ +
- ) : null} {showSecondaryActions ? (
diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 0631497f..0e7c669c 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -47,6 +47,7 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary"; +import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress"; import { StatusBadge } from "../components/common/StatusBadge"; import { Button } from "../components/ui/button"; import { @@ -58,7 +59,6 @@ import { } from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; -import { DetailSideMeta } from "../components/patterns/DetailSideMeta"; import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; @@ -98,42 +98,6 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) { return fallbackMessage; } -function workflowActionLabel(action: string) { - const labels: Record = { - start: "提交", - approve: "通过", - reject: "驳回", - return: "退回", - transfer: "转审", - withdraw: "撤回", - remind: "催办", - overdue_remind: "超时提醒", - }; - return labels[action] ?? action; -} - -function budgetControlModeLabel(controlMode: string | null | undefined) { - const labels: Record = { - block: "超额阻断", - warn: "超额提醒", - manual_review: "人工复核", - }; - return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理"; -} - -function materialCompletenessLabel(count: number, noun: string) { - return count > 0 ? `${count} ${noun}` : `缺${noun}`; -} - -function paymentChannelLabel(value: string | null | undefined) { - if (value === "bank") return "银行转账"; - if (value === "alipay") return "支付宝"; - if (value === "wechat") return "微信"; - if (value === "cash") return "现金"; - if (value === "other") return "其他"; - return value || "-"; -} - export function ReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const navigate = useNavigate(); @@ -792,16 +756,8 @@ export function ReimbursementDetailPage() { } const detail = detailQuery.data; - const payment = detail.payment ?? { - paymentVoucherNo: null, - paymentDate: null, - paymentChannel: null, - paidAt: null, - paidBy: null, - }; const displayCode = resolveReimbursementDisplayCode(detail); const budgetHint = detail.budgetHint; - const canPay = hasPermission(permissions, "expense.finance.pay"); const canEditItems = ["draft", "rejected"].includes(detail.status); const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? ""); const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null; @@ -992,21 +948,13 @@ export function ReimbursementDetailPage() { const itemCount = detail.items.length; const invoiceCount = detail.invoices.length; const hasBlockingPrecheck = blockStandardChecks.length > 0; - const currentHandlerText = (() => { - if (detail.status === "reviewing") { - return detail.pendingWorkflow?.task?.step_name ?? "当前审批节点"; - } - if (detail.status === "approved") { - return canPay ? "财务处理" : "等待财务"; - } - if (detail.status === "paid") { - return payment.paidBy?.name ?? "财务已处理"; - } - if (["draft", "rejected"].includes(detail.status)) { - return detail.owner?.name ?? "报销人"; - } - return "-"; - })(); + const processingProgress = detail.processingProgress ?? { stages: [] }; + const currentProcessingStage = processingProgress.stages.find((stage) => + stage.state === "current" || stage.state === "exception", + ); + const currentHandlerText = currentProcessingStage + ? `${currentProcessingStage.label}${currentProcessingStage.actorName ? ` · ${currentProcessingStage.actorName}` : ""}` + : detail.status === "paid" ? "已完成" : "待处理"; const nextStepText = (() => { if (["draft", "rejected"].includes(detail.status)) { if (!itemCount) { @@ -1021,9 +969,7 @@ export function ReimbursementDetailPage() { return "提交审批"; } if (detail.status === "reviewing") { - return detail.workflowProgress - ? `查看第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步` - : "查看审批进度"; + return currentProcessingStage?.detail ?? "查看处理进度"; } if (detail.status === "approved") { return "等待财务处理"; @@ -1033,9 +979,7 @@ export function ReimbursementDetailPage() { } return "查看记录"; })(); - const showRelatedRecords = canEditItems || Boolean(detail.project || detail.linkedTravelRequest); const showReviewHints = Boolean(budgetHint || detail.riskFlags.length); - const showApprovalRecords = Boolean(detail.workflowProgress || detail.approvalHistory.length); const primaryAction = (() => { if (["draft", "rejected"].includes(detail.status) && canEditItems) { if (!itemCount) { @@ -1085,7 +1029,7 @@ export function ReimbursementDetailPage() { if (detail.status === "approved") { return ( ); } @@ -1093,7 +1037,7 @@ export function ReimbursementDetailPage() { if (detail.status === "paid") { return ( ); } @@ -1155,7 +1099,7 @@ export function ReimbursementDetailPage() { )} /> -
+
@@ -1175,9 +1119,9 @@ export function ReimbursementDetailPage() {
{currentHandlerText}
-
材料
+
部门
- {materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")} + {detail.department?.name ?? "-"}
@@ -1200,6 +1144,42 @@ export function ReimbursementDetailPage() { {detail.description}
) : null} + + {canEditItems || detail.project || detail.linkedTravelRequest ? ( +
+
+ 关联事项 + {detail.linkedTravelRequest ? ( + + 差旅申请 {detail.linkedTravelRequest.requestNo ?? "未编号"} + + ) : null} +
+ {canEditItems ? ( + + ) : detail.project ? ( + + 项目 {detail.project.projectNo} · {detail.project.name} + + ) : null} +
+ ) : null}
{actionError ? ( @@ -1220,45 +1200,19 @@ export function ReimbursementDetailPage() { 请修改超标明细后提交。

) : null} + {showReviewHints ? ( +
+ {budgetHint ? ( +

+ 预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}% + {budgetHint.exceedsThreshold ? ",已达到预警阈值。" : "。"} +

+ ) : null} + {detail.riskFlags.length ? : null} +
+ ) : null}
- {["approved", "paid"].includes(detail.status) ? ( -
-
-

付款状态

- - {detail.status === "paid" ? "已付款" : "未付款"} - -
-
-
-
付款日期
-
{payment.paymentDate ?? "-"}
-
-
-
付款凭证号
-
{payment.paymentVoucherNo ?? "-"}
-
-
-
付款渠道
-
{paymentChannelLabel(payment.paymentChannel)}
-
-
-
操作人 / 时间
-
- {payment.paidBy?.name ?? "-"} · {formatDisplayDateTime(payment.paidAt)} -
-
-
-
- ) : null} - {showSecondaryDetail ? (
@@ -1291,12 +1245,6 @@ export function ReimbursementDetailPage() { ) : null} - -
@@ -1489,7 +1437,6 @@ export function ReimbursementDetailPage() {

) : null} - {canOpenInvoiceBindDialog || materialPreviewError ? (
{detail.invoices.map((invoice) => { const invoiceHint = resolveInvoiceNextStepText(invoice); @@ -1529,7 +1476,6 @@ export function ReimbursementDetailPage() { ); })}
- ) : null}
{canEditItems ? ( @@ -1575,7 +1521,7 @@ export function ReimbursementDetailPage() {
) : null} - {(detail.attachments ?? []).length && (canEditItems || Boolean(materialPreviewError)) ? ( + {(detail.attachments ?? []).length ? (
{(detail.attachments ?? []).map((attachment) => (
@@ -1617,179 +1563,14 @@ export function ReimbursementDetailPage() {
- {showRelatedRecords ? ( - -
-
-
-
-

关联项目

-
- {detail.project ? ( - - 查看项目 - - ) : null} -
- {canEditItems ? ( - - ) : ( -
- {detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"} -
- )} -
- - {detail.linkedTravelRequest ? ( -
-
-
-

- 关联差旅申请:{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"} -

-

{detail.linkedTravelRequest.purpose || "未填写出差目的"}

-
- - 查看申请 - -
-
-
-
日期范围
-
- {detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"} -
-
-
-
预估金额
-
¥{detail.linkedTravelRequest.estimatedAmount}
-
-
-
申请编号
-
- {detail.linkedTravelRequest.requestNo ?? "未生成申请编号"} -
-
-
-
- ) : null} + +
+
- ) : null} - - {showReviewHints ? ( - -
- {budgetHint ? ( -
-

预算提示

-
预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}
-
- 本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount} - ({budgetHint.nextUsagePercent.toFixed(2)}%) -
-
控制方式:{budgetControlModeLabel(budgetHint.controlMode)}
- {budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? ( -

- 当前金额将超出预算上限,提交时会被阻断。 -

- ) : budgetHint.exceedsThreshold ? ( -

- 当前金额已达到预算预警阈值,提交后会记录预算预警审计。 -

- ) : ( -

当前预算充足,可继续提交。

- )} -
- ) : null} - - {detail.riskFlags.length ? : null} -
-
- ) : null} - - {showApprovalRecords ? ( - -
- {detail.workflowProgress ? ( -
-
- 当前位于第 {detail.workflowProgress.currentStepIndex + 1} / {detail.workflowProgress.totalSteps} 步。 -
-
- {detail.workflowProgress.steps.map((step, index) => ( -
-
- - 第 {index + 1} 步 · {step.name} - - - {step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"} - -
-
- ))} -
-
- ) : null} - - {detail.approvalHistory.length ? ( -
-

审批记录

-
- {detail.approvalHistory.map((item) => ( -
-
- {workflowActionLabel(item.action)} - {formatDisplayDateTime(item.createdAt)} -
-
- {item.stepName ?? "-"} · {item.actorName || "系统"} -
- {item.comment ?
{item.comment}
: null} -
- ))} -
-
- ) : null} -
-
- ) : null}
- {detail.status === "approved" && canPay ? ( - - - - ) : null} - { await expect(page.getByText("预算与审核提示")).toHaveCount(0); await expect(page.getByTestId("reimbursement-attachments")).toBeVisible(); await expect(page.getByTestId("reimbursement-related-records")).toBeVisible(); + const pcSectionOrder = await page.locator("[data-testid='reimbursement-overview'], [data-testid='reimbursement-items'], [data-testid='reimbursement-materials'], [data-testid='reimbursement-workflow-progress']") + .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); + expect(pcSectionOrder).toEqual([ + "reimbursement-overview", + "reimbursement-items", + "reimbursement-materials", + "reimbursement-workflow-progress", + ]); await expect(page.getByText("ERP")).toHaveCount(0); await page.getByTestId("reimbursement-invoice-add").click(); @@ -768,6 +785,14 @@ test.describe("R111 国内报销发票与附件双端体验", () => { await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("电子火车票 · 12345679"); await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("无代码"); await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("未识别销售方"); + const mobileSectionOrder = await mobile.page.locator("[data-testid='mobile-reimbursement-detail-focus'], [data-testid='mobile-reimbursement-items'], [data-testid='mobile-reimbursement-materials'], [data-testid='mobile-reimbursement-processing-section']") + .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); + expect(mobileSectionOrder).toEqual([ + "mobile-reimbursement-detail-focus", + "mobile-reimbursement-items", + "mobile-reimbursement-materials", + "mobile-reimbursement-processing-section", + ]); const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first(); await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0); await expect(mobileItem).not.toContainText("电子火车票"); diff --git a/client/tests/e2e/reimbursement-workflow-progress.spec.ts b/client/tests/e2e/reimbursement-workflow-progress.spec.ts index e089c5ea..9c4362e8 100644 --- a/client/tests/e2e/reimbursement-workflow-progress.spec.ts +++ b/client/tests/e2e/reimbursement-workflow-progress.spec.ts @@ -29,7 +29,7 @@ async function createReimbursementItem(api: APIRequestContext, reimbursementId: } } -test("E2E 报销审批进度:提交后详情页展示当前步骤进度", async ({ page, reimbursementDetail }) => { +test("E2E 报销处理进度:提交后详情页展示五阶段职责进度", async ({ page, reimbursementDetail }) => { const apiClient = await createAuthenticatedApiClient(EMPLOYEE); await login(page, EMPLOYEE); @@ -67,8 +67,12 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 }); await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 }); - await expect(reimbursementDetail.workflowProgress).toContainText("当前节点"); - await expect(reimbursementDetail.workflowProgress).toContainText(/第 1 步|第 2 步|第 3 步/); + await expect(reimbursementDetail.workflowProgress).toContainText("申请提交"); + 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(/第 \d+ 步/); await logout(page, EMPLOYEE.name); } finally { diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 91bea744..8676fdfc 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -329,6 +329,196 @@ function buildApprovalHistory(workflowInstance, historyItems = []) { })); } +const PROCESSING_STAGE_DEFINITIONS = Object.freeze([ + { key: "submitted", label: "申请提交" }, + { key: "manager_approval", label: "主管审批" }, + { key: "finance_review", label: "财务复核" }, + { key: "payment_batch_review", label: "付款批次复核" }, + { key: "payment_and_receipt", label: "付款与回单" }, +]); + +function isoDateTimeOrNull(value) { + return value?.toISOString?.() ?? null; +} + +function classifyApprovalTask(task, index) { + 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; +} + +function buildApprovalProcessingStage(task, fallback) { + if (!task) { + return fallback; + } + + const actorName = task.completer_employee?.name ?? task.assignee_employee?.name ?? null; + if (task.outcome === "rejected" || task.status === "rejected") { + return { + ...fallback, + state: "exception", + actorName, + occurredAt: isoDateTimeOrNull(task.completed_at), + result: "驳回", + comment: task.comment ?? null, + detail: "申请已驳回,待申请人处理", + }; + } + if (task.outcome === "returned") { + return { + ...fallback, + state: "exception", + actorName, + occurredAt: isoDateTimeOrNull(task.completed_at), + result: "退回", + comment: task.comment ?? null, + detail: "申请已退回,待申请人补充后重新提交", + }; + } + if (task.status === "completed" || task.outcome === "approved") { + return { + ...fallback, + state: "completed", + actorName, + occurredAt: isoDateTimeOrNull(task.completed_at), + result: "通过", + comment: task.comment ?? null, + detail: null, + }; + } + if (task.status === "pending") { + return { + ...fallback, + state: "current", + actorName: task.assignee_employee?.name ?? null, + occurredAt: isoDateTimeOrNull(task.created_at), + result: "处理中", + comment: null, + detail: `待${fallback.label}处理`, + }; + } + return fallback; +} + +function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatchItem, paymentActorNames, canReadPaymentBatch }) { + const stageByKey = new Map(PROCESSING_STAGE_DEFINITIONS.map((stage) => [stage.key, { + ...stage, + state: "pending", + actorName: null, + occurredAt: null, + result: null, + comment: null, + detail: "待处理", + targetHref: null, + }])); + + const submittedStage = stageByKey.get("submitted"); + if (workflowInstance) { + Object.assign(submittedStage, { + state: "completed", + actorName: reimbursement.submitter?.name ?? reimbursement.owner?.name ?? null, + occurredAt: isoDateTimeOrNull(workflowInstance.started_at ?? workflowInstance.created_at), + result: "已提交", + detail: null, + }); + } else if (["draft", "rejected"].includes(reimbursement.status)) { + Object.assign(submittedStage, { + state: "current", + actorName: reimbursement.owner?.name ?? null, + result: "待提交", + detail: "待申请人提交", + }); + } + + 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 batch = paymentBatchItem?.payment_batch ?? null; + const batchTargetHref = batch && canReadPaymentBatch + ? `/payment-batches?id=${encodeURIComponent(batch.id)}` + : null; + const batchReviewStage = stageByKey.get("payment_batch_review"); + const paymentStage = stageByKey.get("payment_and_receipt"); + + if (!batch) { + Object.assign(batchReviewStage, reimbursement.status === "approved" + ? { state: "current", result: "待建批次", detail: "待财务创建付款批次" } + : { detail: "待财务创建付款批次" }); + paymentStage.detail = "待付款批次复核后处理"; + } else if (batch.status === "draft") { + Object.assign(batchReviewStage, { + state: "current", + result: "待复核", + detail: "所在付款批次待非制单人复核", + targetHref: batchTargetHref, + }); + Object.assign(paymentStage, { detail: "待所在付款批次复核通过", targetHref: batchTargetHref }); + } else if (["reviewed", "executed", "failed"].includes(batch.status)) { + Object.assign(batchReviewStage, { + state: "completed", + actorName: paymentActorNames.get(batch.reviewed_by) ?? null, + occurredAt: isoDateTimeOrNull(batch.reviewed_at), + result: "通过", + detail: batch.status === "reviewed" ? "所在付款批次已复核,待执行付款" : null, + targetHref: batchTargetHref, + }); + if (batch.status === "reviewed") { + Object.assign(paymentStage, { + state: "current", + result: "待付款", + detail: "所在付款批次已复核,待执行付款", + targetHref: batchTargetHref, + }); + } else if (batch.status === "failed") { + Object.assign(paymentStage, { + state: "exception", + actorName: paymentActorNames.get(batch.executed_by) ?? null, + occurredAt: isoDateTimeOrNull(batch.executed_at), + result: "付款失败", + detail: "所在付款批次执行失败,待财务重新处理", + targetHref: batchTargetHref, + }); + } + } else if (batch.status === "cancelled") { + Object.assign(batchReviewStage, { + state: "exception", + actorName: paymentActorNames.get(batch.cancelled_by) ?? null, + occurredAt: isoDateTimeOrNull(batch.cancelled_at), + result: "批次已取消", + comment: batch.cancelled_reason ?? null, + detail: "所在付款批次已取消,待财务重新创建", + targetHref: batchTargetHref, + }); + Object.assign(paymentStage, { + detail: "待财务重新创建付款批次", + targetHref: batchTargetHref, + }); + } + + if (batch?.status === "executed" || reimbursement.status === "paid") { + const receiptCount = batch?._count?.receipt_attachments ?? 0; + Object.assign(paymentStage, { + state: "completed", + actorName: reimbursement.paid_by_employee?.name ?? paymentActorNames.get(batch?.executed_by) ?? null, + occurredAt: isoDateTimeOrNull(reimbursement.paid_at ?? batch?.executed_at), + result: "已付款", + detail: receiptCount > 0 ? "付款与回单已留存" : "付款已完成,回单待留存", + targetHref: batchTargetHref, + }); + } + + return { stages: PROCESSING_STAGE_DEFINITIONS.map(({ key }) => stageByKey.get(key)) }; +} + function ensureApplicantOrAdmin(request, reimbursement) { if (request.employee?.roleCodes?.includes("admin")) { return; @@ -2255,6 +2445,9 @@ async function reimbursementRoutes(fastify) { select: { id: true, current_step_id: true, + current_status: true, + started_at: true, + completed_at: true, created_at: true, context_data: true, workflow_definition: { @@ -2262,6 +2455,23 @@ async function reimbursementRoutes(fastify) { definition_json: true, }, }, + workflow_tasks: { + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + step_id: true, + step_name: true, + assignee_id: true, + assignee_role: true, + status: true, + outcome: true, + comment: true, + created_at: true, + completed_at: true, + assignee_employee: { select: { name: true } }, + completer_employee: { select: { name: true } }, + }, + }, }, }); const workflowHistory = pendingInstance @@ -2297,6 +2507,48 @@ async function reimbursementRoutes(fastify) { }, }) : []; + const paymentBatchItem = await fastify.prisma.paymentBatchItem.findFirst({ + where: { + entity_id: reimbursementId, + payment_batch: { entity_type: "reimbursement" }, + }, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + select: { + id: true, + payment_batch: { + select: { + id: true, + status: true, + created_by: true, + reviewed_at: true, + reviewed_by: true, + executed_at: true, + executed_by: true, + cancelled_at: true, + cancelled_by: true, + cancelled_reason: true, + _count: { + select: { receipt_attachments: { where: { archived_at: null } } }, + }, + }, + }, + }, + }); + const paymentActorIds = Array.from(new Set([ + paymentBatchItem?.payment_batch?.created_by, + paymentBatchItem?.payment_batch?.reviewed_by, + paymentBatchItem?.payment_batch?.executed_by, + paymentBatchItem?.payment_batch?.cancelled_by, + ].filter(Boolean))); + const paymentActors = paymentActorIds.length + ? await fastify.prisma.employee.findMany({ + where: { id: { in: paymentActorIds } }, + select: { id: true, name: true }, + }) + : []; + const paymentActorNames = new Map(paymentActors.map((actor) => [actor.id, actor.name])); + const canReadPaymentBatch = ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] + .some((permissionCode) => fastify.hasPermission(request.employee, permissionCode)); const riskFlags = await fastify.prisma.entityRiskFlag.findMany({ where: { entity_type: "reimbursement", @@ -2306,10 +2558,6 @@ async function reimbursementRoutes(fastify) { orderBy: [{ created_at: "desc" }, { id: "desc" }], }); const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel"); - const travelItemDates = travelItems - .map((item) => item.expense_date) - .filter(Boolean) - .sort((left, right) => left.getTime() - right.getTime()); const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length); return sendData(reply, request, { @@ -2349,10 +2597,10 @@ async function reimbursementRoutes(fastify) { linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request), travelSummary: { isTravel: isTravelReimbursement, - purpose: reimbursement.travel_purpose ?? (isTravelReimbursement ? reimbursement.description : null), + purpose: reimbursement.travel_purpose ?? null, location: reimbursement.travel_location ?? null, - startDate: toDateOnlyString(reimbursement.travel_start_date ?? travelItemDates[0] ?? null), - endDate: toDateOnlyString(reimbursement.travel_end_date ?? travelItemDates.at(-1) ?? null), + startDate: toDateOnlyString(reimbursement.travel_start_date), + endDate: toDateOnlyString(reimbursement.travel_end_date), }, owner: reimbursement.owner, submitter: reimbursement.submitter, @@ -2397,6 +2645,13 @@ async function reimbursementRoutes(fastify) { } : null, workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null, + processingProgress: buildProcessingProgress({ + reimbursement, + workflowInstance: latestWorkflowInstance, + paymentBatchItem, + paymentActorNames, + canReadPaymentBatch, + }), approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows), riskFlags: riskFlags.map(buildPersistedRiskFlag), }); diff --git a/server/tests/unit/routes/reimbursements.detail.test.js b/server/tests/unit/routes/reimbursements.detail.test.js index c4788752..07dc347d 100644 --- a/server/tests/unit/routes/reimbursements.detail.test.js +++ b/server/tests/unit/routes/reimbursements.detail.test.js @@ -99,6 +99,8 @@ async function buildApp({ id: PENDING_INSTANCE_ID, current_step_id: "finance_approve", created_at: new Date("2026-04-10T10:00:00.000Z"), + started_at: new Date("2026-04-10T10:00:00.000Z"), + current_status: "pending", context_data: { workflow_steps_snapshot: [ { id: "manager_approve", name: "主管审批" }, @@ -113,6 +115,34 @@ async function buildApp({ ], }, }, + 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-10T12:00:00.000Z"), + assignee_employee: { name: "王经理" }, + completer_employee: { name: "王经理" }, + }, + { + id: "finance-task", + step_id: "finance_approve", + step_name: "财务审批", + assignee_role: "finance", + 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, + }, + ], }, workflowHistoryRows = [ { @@ -143,6 +173,8 @@ async function buildApp({ workflow_task: null, }, ], + paymentBatchItem = null, + paymentActors = [], } = {}) { const app = Fastify(); const prisma = { @@ -163,6 +195,12 @@ async function buildApp({ entityRiskFlag: { findMany: vi.fn(async () => []), }, + paymentBatchItem: { + findFirst: vi.fn(async () => paymentBatchItem), + }, + employee: { + findMany: vi.fn(async () => paymentActors), + }, budgetPool: { findMany: vi.fn(async () => []), }, @@ -240,10 +278,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { submittedAt: "2026-04-10T10:00:00.000Z", travelSummary: { isTravel: true, - purpose: "客户拜访报销", + purpose: null, location: null, - startDate: "2026-04-09", - endDate: "2026-04-09", + startDate: null, + endDate: null, }, invoices: [ { @@ -280,6 +318,13 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { currentStepIndex: 1, totalSteps: 2, }); + 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: "payment_batch_review", state: "pending", actorName: null, detail: "待财务创建付款批次" }), + expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }), + ]); await app.close(); }); @@ -304,6 +349,187 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { expect(response.statusCode).toBe(200); expect(response.json().data.submittedAt).toBe(null); expect(response.json().data.approvalHistory).toEqual([]); + expect(response.json().data.processingProgress.stages).toEqual([ + expect.objectContaining({ key: "submitted", state: "current", actorName: "张三", detail: "待申请人提交" }), + expect.objectContaining({ key: "manager_approval", state: "pending", actorName: null }), + expect.objectContaining({ key: "finance_review", state: "pending", actorName: null }), + expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null }), + expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }), + ]); + + await app.close(); + }); + + it.each([ + ["draft", "current", "pending", "待所在付款批次复核通过"], + ["reviewed", "completed", "current", "所在付款批次已复核,待执行付款"], + ["executed", "completed", "completed", "付款与回单已留存"], + ["failed", "completed", "exception", "所在付款批次执行失败,待财务重新处理"], + ["cancelled", "exception", "pending", "待财务重新创建付款批次"], + ])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => { + const paymentBatchItem = { + id: "batch-item-1", + payment_batch: { + id: "batch-1", + status, + created_by: "maker-1", + reviewed_at: status === "draft" || status === "cancelled" ? null : new Date("2026-04-11T08:00:00.000Z"), + reviewed_by: status === "draft" || status === "cancelled" ? null : "reviewer-1", + executed_at: ["executed", "failed"].includes(status) ? new Date("2026-04-12T08:00:00.000Z") : null, + executed_by: ["executed", "failed"].includes(status) ? "executor-1" : null, + cancelled_at: status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null, + cancelled_by: status === "cancelled" ? "reviewer-1" : null, + cancelled_reason: status === "cancelled" ? "批次资料需调整" : null, + _count: { receipt_attachments: status === "executed" ? 1 : 0 }, + }, + }; + const { app } = await buildApp({ + paymentBatchItem, + paymentActors: [ + { id: "reviewer-1", name: "实际复核人" }, + { id: "executor-1", name: "实际付款人" }, + ], + }); + + const response = await app.inject({ + method: "GET", + url: `/api/reimbursements/${REIMBURSEMENT_ID}`, + headers: { authorization: "Bearer test" }, + }); + expect(response.statusCode).toBe(200); + const stages = response.json().data.processingProgress.stages; + expect(stages[3]).toMatchObject({ state: reviewState, targetHref: null }); + expect(stages[4]).toMatchObject({ state: paymentState, detail, targetHref: null }); + if (status === "draft") expect(stages[3].actorName).toBe(null); + + await app.close(); + }); + + it("returns only a safe payment-batch link to users with existing batch-read permission", async () => { + const { app } = await buildApp({ + permissionCodes: ["expense.view_own", "payment.batch.review"], + paymentBatchItem: { + id: "batch-item-1", + payment_batch: { + id: "batch-1", + status: "draft", + created_by: "maker-1", + reviewed_at: null, + reviewed_by: null, + executed_at: null, + executed_by: null, + cancelled_at: null, + cancelled_by: null, + cancelled_reason: null, + _count: { receipt_attachments: 0 }, + }, + }, + }); + + 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[3]).toMatchObject({ + detail: "所在付款批次待非制单人复核", + actorName: null, + targetHref: "/payment-batches?id=batch-1", + }); + + await app.close(); + }); + + it("marks finance batch creation as the current responsibility after workflow approval", async () => { + const { app } = await buildApp({ + reimbursement: buildReimbursementRecord({ status: "approved", workflow_instances: [] }), + }); + 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[3]).toMatchObject({ + state: "current", + actorName: null, + result: "待建批次", + detail: "待财务创建付款批次", + }); + + await app.close(); + }); + + it("keeps a legacy paid reimbursement readable when no payment batch exists", async () => { + const { app } = await buildApp({ + reimbursement: buildReimbursementRecord({ + status: "paid", + paid_at: new Date("2026-04-12T08:00:00.000Z"), + paid_by_employee: { id: "finance-1", employee_no: "F001", name: "实际付款人" }, + workflow_instances: [], + }), + }); + 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[4]).toMatchObject({ + state: "completed", + actorName: "实际付款人", + occurredAt: "2026-04-12T08:00:00.000Z", + result: "已付款", + detail: "付款已完成,回单待留存", + }); + + await app.close(); + }); + + it.each([ + ["returned", "退回", "请补充材料"], + ["rejected", "驳回", "不符合报销要求"], + ])("expands a %s workflow outcome with the actual handler and comment", async (outcome, result, comment) => { + const status = outcome === "rejected" ? "rejected" : "completed"; + const { app } = await buildApp({ + latestWorkflowInstance: { + id: PENDING_INSTANCE_ID, + current_step_id: "manager_approve", + current_status: outcome === "rejected" ? "rejected" : "terminated", + created_at: new Date("2026-04-10T10:00:00.000Z"), + started_at: new Date("2026-04-10T10:00:00.000Z"), + completed_at: new Date("2026-04-10T11:00:00.000Z"), + context_data: { workflow_steps_snapshot: [{ id: "manager_approve", name: "主管审批" }] }, + workflow_definition: { definition_json: { steps: [{ id: "manager_approve", name: "主管审批" }] } }, + workflow_tasks: [{ + id: "manager-task", + step_id: "manager_approve", + step_name: "主管审批", + assignee_role: "manager", + status, + outcome, + 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: "王经理" }, + }], + }, + }); + + 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]).toMatchObject({ + state: "exception", + actorName: "王经理", + result, + comment, + }); await app.close(); });