收敛报销详情并展示五阶段进度(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -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,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户