{item.comment}
: null} -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 ( +
{stage.detail}
: null} + {stage.comment ? ( ++ {stage.comment} ++ ) : null} + {stage.targetHref ? ( + + 查看所在付款批次 + + + ) : null} +
{detail.description}
: null} +项目:{detail.project.projectNo} · {detail.project.name}
: null} + {detail.linkedTravelRequest ? ( + + 差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"} + + ) : null} +- 当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"} -
+{item.comment}
: null} -暂无审批记录。
- )} ++ 预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}% + {budgetHint.exceedsThreshold ? ",已达到预警阈值。" : "。"} +
+ ) : null} + {detail.riskFlags.length ?{detail.linkedTravelRequest.purpose || "未填写出差目的"}
-- 当前金额将超出预算上限,提交时会被阻断。 -
- ) : budgetHint.exceedsThreshold ? ( -- 当前金额已达到预算预警阈值,提交后会记录预算预警审计。 -
- ) : ( -当前预算充足,可继续提交。
- )} -