收敛报销详情并展示五阶段进度(07-27-yang-jun-reimbursement-readiness)

这个提交包含在:
2026-08-01 12:37:11 +08:00
父节点 4cf73ce0d2
当前提交 421c2d6e1e
共修改 11 个文件,包含 840 行新增和 352 行删除
+88
查看文件
@@ -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,
});
```
+17
查看文件
@@ -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[];
@@ -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 (
<ol
className="space-y-0"
data-testid={mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress"}
aria-label="报销处理进度"
>
{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 (
<li className="relative flex gap-3" data-state={stage.state} key={stage.key}>
{index < progress.stages.length - 1 ? (
<span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-zinc-200" aria-hidden="true" />
) : null}
<span
className={cn(
"relative z-10 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full border bg-white",
stage.state === "completed" && "border-emerald-300 text-emerald-700",
stage.state === "current" && "border-blue-300 bg-blue-50 text-blue-700",
stage.state === "exception" && "border-red-300 bg-red-50 text-red-700",
stage.state === "pending" && "border-zinc-300 text-zinc-400",
)}
aria-hidden="true"
>
<Icon className="size-4" />
</span>
<div className={cn("min-w-0 flex-1 pb-5", mobile ? "pt-1" : "pt-1.5")}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="font-semibold text-zinc-900">{stage.label}</span>
{facts.length ? <span className="text-zinc-500">· {facts.join(" · ")}</span> : null}
{!stage.result ? <span className="text-xs text-zinc-500">· {STATE_TEXT[stage.state]}</span> : null}
</div>
{expanded && stage.detail ? <p className="mt-1 text-sm text-zinc-600">{stage.detail}</p> : null}
{stage.comment ? (
<blockquote className="mt-2 rounded-md border-l-2 border-zinc-300 bg-zinc-50 px-3 py-2 text-sm leading-5 text-zinc-700">
{stage.comment}
</blockquote>
) : null}
{stage.targetHref ? (
<Link
className="mt-2 inline-flex min-h-11 items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
to={stage.targetHref}
>
查看所在付款批次
<ExternalLink aria-hidden="true" className="size-4" />
</Link>
) : null}
</div>
</li>
);
})}
</ol>
);
}
@@ -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 (
<section className={mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
<Container className={embedded ? "mt-4 border-t border-zinc-200 pt-4" : mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">差旅概要</h2>
<h3 className="text-sm font-semibold text-zinc-900">出差信息</h3>
{editable && !editing ? <Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>编辑</Button> : null}
</div>
{editing ? (
@@ -61,6 +65,6 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
<div><dt className="text-xs text-zinc-500">结束日期</dt><dd className="mt-1 text-zinc-900">{summary.endDate || "未填写"}</dd></div>
</dl>
)}
</section>
</Container>
);
}
@@ -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<string, string> = {
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() {
<div className="text-xs text-zinc-500">报销人</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.owner?.name ?? "-"}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">部门</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.department?.name ?? "-"}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">申请人</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.submitter?.name ?? "-"}</div>
</div>
</div>
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEdit && isParticipant)}
mobile
embedded
/>
{detail.project || detail.linkedTravelRequest ? (
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm text-zinc-600">
{detail.project ? <p>项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
{detail.linkedTravelRequest ? (
<Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}>
差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}
</Link>
) : null}
</div>
) : null}
{detail.riskFlags.length ? (
<div className="mt-4 border-t border-zinc-200 pt-4">
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
</div>
) : null}
</section>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEdit && isParticipant)}
mobile
/>
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -1164,7 +1175,6 @@ export function MobileReimbursementDetailPage() {
) : null}
{detail.invoices.length || (detail.attachments ?? []).length ? (
canOpenInvoiceDialog || canManageAttachments || Boolean(materialPreviewError) ? (
<div className="mt-3 space-y-2">
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
{detail.invoices.map((invoice) => {
@@ -1247,7 +1257,6 @@ export function MobileReimbursementDetailPage() {
))}
</div>
</div>
) : null
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无报销材料
@@ -1255,14 +1264,10 @@ export function MobileReimbursementDetailPage() {
)}
</section>
{detail.status === "reviewing" || detail.approvalHistory.length ? (
<section className="mobile-card" data-testid="mobile-reimbursement-approval-history">
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">审批</h2>
<p className="mt-1 text-xs text-zinc-500">
当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
</p>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
@@ -1276,27 +1281,10 @@ export function MobileReimbursementDetailPage() {
</Button>
) : null}
</div>
{detail.approvalHistory.length ? (
<div className="mt-3 space-y-2">
{detail.approvalHistory.map((item) => (
<article key={item.id} className="mobile-list-card">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">{workflowActionLabel(item.action)}</div>
<div className="shrink-0 text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.comment}</p> : null}
</article>
))}
</div>
) : (
<p className="mt-3 text-sm text-zinc-500">暂无审批记录。</p>
)}
<div className="mt-3">
<ReimbursementProcessingProgress progress={detail.processingProgress ?? { stages: [] }} mobile />
</div>
</section>
) : null}
{showSecondaryActions ? (
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
+65 -284
查看文件
@@ -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<string, string> = {
start: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
remind: "催办",
overdue_remind: "超时提醒",
};
return labels[action] ?? action;
}
function budgetControlModeLabel(controlMode: string | null | undefined) {
const labels: Record<string, string> = {
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 (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-payment-panel">看付款状态</a>
<a href="#reimbursement-processing-progress-panel">看处理进度</a>
</Button>
);
}
@@ -1093,7 +1037,7 @@ export function ReimbursementDetailPage() {
if (detail.status === "paid") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-payment-panel">查看付款结果</a>
<a href="#reimbursement-processing-progress-panel">查看付款结果</a>
</Button>
);
}
@@ -1155,7 +1099,7 @@ export function ReimbursementDetailPage() {
)}
/>
<div className="detail-workspace-grid">
<div className="mx-auto w-full max-w-5xl space-y-5">
<div className="space-y-5">
<SectionPanel title="报销概况">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
@@ -1175,9 +1119,9 @@ export function ReimbursementDetailPage() {
<div className="detail-fact-card__value">{currentHandlerText}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">材料</div>
<div className="detail-fact-card__label">部门</div>
<div className="detail-fact-card__value">
{materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")}
{detail.department?.name ?? "-"}
</div>
</div>
<div className="detail-fact-card">
@@ -1200,6 +1144,42 @@ export function ReimbursementDetailPage() {
{detail.description}
</div>
) : null}
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEditItems && isReimbursementParticipant)}
embedded
/>
{canEditItems || detail.project || detail.linkedTravelRequest ? (
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm" data-testid="reimbursement-related-records">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="font-medium text-zinc-900">关联事项</span>
{detail.linkedTravelRequest ? (
<Link to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`} className="text-primary hover:underline">
差旅申请 {detail.linkedTravelRequest.requestNo ?? "未编号"}
</Link>
) : null}
</div>
{canEditItems ? (
<select
className="select-std mt-3 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
aria-label="关联项目"
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>{item.projectNo} · {item.name}</option>
))}
</select>
) : detail.project ? (
<Link className="mt-2 inline-block text-primary hover:underline" to={`/projects/${detail.project.id}`}>
项目 {detail.project.projectNo} · {detail.project.name}
</Link>
) : null}
</div>
) : null}
</div>
{actionError ? (
@@ -1220,45 +1200,19 @@ export function ReimbursementDetailPage() {
请修改超标明细后提交。
</p>
) : null}
{showReviewHints ? (
<div className="space-y-3" data-testid="reimbursement-review-hints">
{budgetHint ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%
{budgetHint.exceedsThreshold ? ",已达到预警阈值。" : "。"}
</p>
) : null}
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
</div>
) : null}
</div>
{["approved", "paid"].includes(detail.status) ? (
<div id="reimbursement-payment-panel" className="detail-command-panel mt-4" data-testid="reimbursement-payment-status">
<div className="flex items-center justify-between gap-3">
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
detail.status === "paid"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}`}
>
{detail.status === "paid" ? "已付款" : "未付款"}
</span>
</div>
<div className="detail-fact-grid mt-3 xl:grid-cols-4">
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款日期</div>
<div className="detail-fact-card__value">{payment.paymentDate ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款凭证号</div>
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款渠道</div>
<div className="detail-fact-card__value">{paymentChannelLabel(payment.paymentChannel)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">操作人 / 时间</div>
<div className="detail-fact-card__value">
{payment.paidBy?.name ?? "-"} · {formatDisplayDateTime(payment.paidAt)}
</div>
</div>
</div>
</div>
) : null}
{showSecondaryDetail ? (
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
<div className="detail-command-panel">
@@ -1291,12 +1245,6 @@ export function ReimbursementDetailPage() {
) : null}
</SectionPanel>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEditItems && isReimbursementParticipant)}
/>
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
@@ -1489,7 +1437,6 @@ export function ReimbursementDetailPage() {
</p>
) : null}
{canOpenInvoiceBindDialog || materialPreviewError ? (
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
@@ -1529,7 +1476,6 @@ export function ReimbursementDetailPage() {
);
})}
</div>
) : null}
<div className="space-y-4" data-testid="reimbursement-attachments">
{canEditItems ? (
@@ -1575,7 +1521,7 @@ export function ReimbursementDetailPage() {
</div>
) : null}
{(detail.attachments ?? []).length && (canEditItems || Boolean(materialPreviewError)) ? (
{(detail.attachments ?? []).length ? (
<div className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
@@ -1617,179 +1563,14 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
{showRelatedRecords ? (
<SectionPanel title="关联事项">
<div className="space-y-4" data-testid="reimbursement-related-records">
<div className="detail-command-panel">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
</div>
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
查看项目
</Link>
) : null}
</div>
{canEditItems ? (
<select
className="select-std mt-4 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}
</option>
))}
</select>
) : (
<div className="mt-3 text-sm text-zinc-600">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}
</div>
)}
</div>
{detail.linkedTravelRequest ? (
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
</h4>
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
</div>
<Link
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
className="text-sm text-primary hover:underline"
>
查看申请
</Link>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">日期范围</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">预估金额</div>
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">申请编号</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
</div>
</div>
</div>
</div>
) : null}
<SectionPanel title="处理进度">
<div id="reimbursement-processing-progress-panel">
<ReimbursementProcessingProgress progress={processingProgress} />
</div>
</SectionPanel>
) : null}
{showReviewHints ? (
<SectionPanel title="预算与审核提示">
<div className="space-y-4">
{budgetHint ? (
<div className="detail-command-panel text-sm" data-testid="reimbursement-budget-hint">
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
<div className="mt-3">预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
<div>
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%)
</div>
<div>控制方式:{budgetControlModeLabel(budgetHint.controlMode)}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断。
</p>
) : budgetHint.exceedsThreshold ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
</p>
) : (
<p className="mt-3 text-zinc-500">当前预算充足,可继续提交。</p>
)}
</div>
) : null}
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
</div>
</SectionPanel>
) : null}
{showApprovalRecords ? (
<SectionPanel title="审批与处理记录">
<div id="reimbursement-workflow-progress-panel" className="space-y-4">
{detail.workflowProgress ? (
<div className="detail-command-panel">
<div className="mb-3 text-sm text-zinc-500">
当前位于第 {detail.workflowProgress.currentStepIndex + 1} / {detail.workflowProgress.totalSteps} 步。
</div>
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "detail-timeline-item detail-timeline-item--current"
: step.status === "completed"
? "detail-timeline-item detail-timeline-item--done"
: "detail-timeline-item"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
</div>
))}
</div>
</div>
) : null}
{detail.approvalHistory.length ? (
<div className="detail-command-panel">
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
<div className="detail-timeline mt-3" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
</div>
))}
</div>
</div>
) : null}
</div>
</SectionPanel>
) : null}
</div>
<div className="space-y-5">
{detail.status === "approved" && canPay ? (
<DetailSideMeta title="付款">
<Button asChild variant="secondary" size="sm">
<Link to="/payment-batches">进入付款批次</Link>
</Button>
</DetailSideMeta>
) : null}
<ReimbursementActions
status={detail.status}
showSubmitAction={false}
@@ -149,6 +149,15 @@ const reimbursementDetail = {
{ id: "step-2", name: "财务复核", status: "pending" },
],
},
processingProgress: {
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: "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 },
],
},
approvalHistory: [
{
id: "history-wave3-1",
@@ -1084,6 +1093,11 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
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")).not.toContainText(/第 \d+ 步/);
await page.goto("/workflow/tasks/task-wave3");
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
@@ -1094,6 +1108,10 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
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-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);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款与回单");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
@@ -198,6 +198,15 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
processingProgress: {
stages: [
{ key: "submitted", label: "申请提交", state: "current", actorName: "测试员工", occurredAt: null, result: "待提交", comment: null, detail: "待申请人提交", targetHref: null },
{ key: "manager_approval", 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 },
],
},
approvalHistory: [],
riskFlags: [],
items: [
@@ -624,6 +633,14 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
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("电子火车票");
@@ -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 {
+262 -7
查看文件
@@ -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),
});
@@ -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();
});