收敛报销详情并展示五阶段进度(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -93,3 +93,91 @@ if (!hasPermission(actor, "payment.batch.review")
|
|||||||
throw forbidden();
|
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 = {
|
export type ReimbursementApprovalHistoryItem = {
|
||||||
id: string;
|
id: string;
|
||||||
stepName: string | null;
|
stepName: string | null;
|
||||||
@@ -215,6 +231,7 @@ export type ReimbursementDetail = {
|
|||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
workflowProgress: ReimbursementWorkflowProgress | null;
|
workflowProgress: ReimbursementWorkflowProgress | null;
|
||||||
|
processingProgress: ReimbursementProcessingProgress;
|
||||||
approvalHistory: ReimbursementApprovalHistoryItem[];
|
approvalHistory: ReimbursementApprovalHistoryItem[];
|
||||||
riskFlags: RiskFlag[];
|
riskFlags: RiskFlag[];
|
||||||
items: ReimbursementItem[];
|
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 { Button } from "../ui/button";
|
||||||
import { Input } from "../ui/input";
|
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 };
|
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 queryClient = useQueryClient();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
@@ -22,7 +22,10 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
|
|||||||
travelStartDate: detail.travelSummary?.startDate ?? null,
|
travelStartDate: detail.travelSummary?.startDate ?? null,
|
||||||
travelEndDate: detail.travelSummary?.endDate ?? 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({
|
const mutation = useMutation({
|
||||||
mutationFn: () => updateReimbursementTravelSummary(reimbursementId, form),
|
mutationFn: () => updateReimbursementTravelSummary(reimbursementId, form),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -34,10 +37,11 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
|
|||||||
});
|
});
|
||||||
if (!isTravel) return null;
|
if (!isTravel) return null;
|
||||||
const summary = detail.travelSummary ?? { purpose: null, location: null, startDate: null, endDate: null };
|
const summary = detail.travelSummary ?? { purpose: null, location: null, startDate: null, endDate: null };
|
||||||
|
const Container = embedded ? "div" : "section";
|
||||||
return (
|
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">
|
<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}
|
{editable && !editing ? <Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>编辑</Button> : null}
|
||||||
</div>
|
</div>
|
||||||
{editing ? (
|
{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>
|
<div><dt className="text-xs text-zinc-500">结束日期</dt><dd className="mt-1 text-zinc-900">{summary.endDate || "未填写"}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ import { AttachmentGovernanceBadges } from "../components/common/AttachmentGover
|
|||||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||||
|
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||||
|
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -132,20 +134,6 @@ function formatDate(value: string | null | undefined) {
|
|||||||
return value || "未填写";
|
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(
|
function matchInvoiceSearch(
|
||||||
invoice: {
|
invoice: {
|
||||||
invoiceCode?: string | null;
|
invoiceCode?: string | null;
|
||||||
@@ -933,17 +921,40 @@ export function MobileReimbursementDetailPage() {
|
|||||||
<div className="text-xs text-zinc-500">报销人</div>
|
<div className="text-xs text-zinc-500">报销人</div>
|
||||||
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.owner?.name ?? "-"}</div>
|
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.owner?.name ?? "-"}</div>
|
||||||
</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>
|
</div>
|
||||||
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
{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>
|
</section>
|
||||||
|
|
||||||
<ReimbursementTravelSummary
|
|
||||||
detail={detail}
|
|
||||||
reimbursementId={reimbursementId}
|
|
||||||
editable={Boolean(canEdit && isParticipant)}
|
|
||||||
mobile
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
||||||
@@ -1164,7 +1175,6 @@ export function MobileReimbursementDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||||
canOpenInvoiceDialog || canManageAttachments || Boolean(materialPreviewError) ? (
|
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
|
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
|
||||||
{detail.invoices.map((invoice) => {
|
{detail.invoices.map((invoice) => {
|
||||||
@@ -1247,7 +1257,6 @@ export function MobileReimbursementDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
<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>
|
</section>
|
||||||
|
|
||||||
{detail.status === "reviewing" || detail.approvalHistory.length ? (
|
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
|
||||||
<section className="mobile-card" data-testid="mobile-reimbursement-approval-history">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-zinc-900">审批</h2>
|
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{canRemindPendingWorkflow && pendingTaskId ? (
|
{canRemindPendingWorkflow && pendingTaskId ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -1276,27 +1281,10 @@ export function MobileReimbursementDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
{detail.approvalHistory.length ? (
|
<ReimbursementProcessingProgress progress={detail.processingProgress ?? { stages: [] }} mobile />
|
||||||
<div className="mt-3 space-y-2">
|
</div>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showSecondaryActions ? (
|
{showSecondaryActions ? (
|
||||||
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
|
<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 { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||||
|
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -58,7 +59,6 @@ import {
|
|||||||
} from "../components/ui/dialog";
|
} from "../components/ui/dialog";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
|
||||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||||
@@ -98,42 +98,6 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
|
|||||||
return fallbackMessage;
|
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() {
|
export function ReimbursementDetailPage() {
|
||||||
const { reimbursementId = "" } = useParams();
|
const { reimbursementId = "" } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -792,16 +756,8 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
const payment = detail.payment ?? {
|
|
||||||
paymentVoucherNo: null,
|
|
||||||
paymentDate: null,
|
|
||||||
paymentChannel: null,
|
|
||||||
paidAt: null,
|
|
||||||
paidBy: null,
|
|
||||||
};
|
|
||||||
const displayCode = resolveReimbursementDisplayCode(detail);
|
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||||
const budgetHint = detail.budgetHint;
|
const budgetHint = detail.budgetHint;
|
||||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
|
||||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||||
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
|
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
|
||||||
const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null;
|
const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null;
|
||||||
@@ -992,21 +948,13 @@ export function ReimbursementDetailPage() {
|
|||||||
const itemCount = detail.items.length;
|
const itemCount = detail.items.length;
|
||||||
const invoiceCount = detail.invoices.length;
|
const invoiceCount = detail.invoices.length;
|
||||||
const hasBlockingPrecheck = blockStandardChecks.length > 0;
|
const hasBlockingPrecheck = blockStandardChecks.length > 0;
|
||||||
const currentHandlerText = (() => {
|
const processingProgress = detail.processingProgress ?? { stages: [] };
|
||||||
if (detail.status === "reviewing") {
|
const currentProcessingStage = processingProgress.stages.find((stage) =>
|
||||||
return detail.pendingWorkflow?.task?.step_name ?? "当前审批节点";
|
stage.state === "current" || stage.state === "exception",
|
||||||
}
|
);
|
||||||
if (detail.status === "approved") {
|
const currentHandlerText = currentProcessingStage
|
||||||
return canPay ? "财务处理" : "等待财务";
|
? `${currentProcessingStage.label}${currentProcessingStage.actorName ? ` · ${currentProcessingStage.actorName}` : ""}`
|
||||||
}
|
: detail.status === "paid" ? "已完成" : "待处理";
|
||||||
if (detail.status === "paid") {
|
|
||||||
return payment.paidBy?.name ?? "财务已处理";
|
|
||||||
}
|
|
||||||
if (["draft", "rejected"].includes(detail.status)) {
|
|
||||||
return detail.owner?.name ?? "报销人";
|
|
||||||
}
|
|
||||||
return "-";
|
|
||||||
})();
|
|
||||||
const nextStepText = (() => {
|
const nextStepText = (() => {
|
||||||
if (["draft", "rejected"].includes(detail.status)) {
|
if (["draft", "rejected"].includes(detail.status)) {
|
||||||
if (!itemCount) {
|
if (!itemCount) {
|
||||||
@@ -1021,9 +969,7 @@ export function ReimbursementDetailPage() {
|
|||||||
return "提交审批";
|
return "提交审批";
|
||||||
}
|
}
|
||||||
if (detail.status === "reviewing") {
|
if (detail.status === "reviewing") {
|
||||||
return detail.workflowProgress
|
return currentProcessingStage?.detail ?? "查看处理进度";
|
||||||
? `查看第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步`
|
|
||||||
: "查看审批进度";
|
|
||||||
}
|
}
|
||||||
if (detail.status === "approved") {
|
if (detail.status === "approved") {
|
||||||
return "等待财务处理";
|
return "等待财务处理";
|
||||||
@@ -1033,9 +979,7 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
return "查看记录";
|
return "查看记录";
|
||||||
})();
|
})();
|
||||||
const showRelatedRecords = canEditItems || Boolean(detail.project || detail.linkedTravelRequest);
|
|
||||||
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
|
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
|
||||||
const showApprovalRecords = Boolean(detail.workflowProgress || detail.approvalHistory.length);
|
|
||||||
const primaryAction = (() => {
|
const primaryAction = (() => {
|
||||||
if (["draft", "rejected"].includes(detail.status) && canEditItems) {
|
if (["draft", "rejected"].includes(detail.status) && canEditItems) {
|
||||||
if (!itemCount) {
|
if (!itemCount) {
|
||||||
@@ -1085,7 +1029,7 @@ export function ReimbursementDetailPage() {
|
|||||||
if (detail.status === "approved") {
|
if (detail.status === "approved") {
|
||||||
return (
|
return (
|
||||||
<Button asChild data-testid="reimbursement-primary-action">
|
<Button asChild data-testid="reimbursement-primary-action">
|
||||||
<a href="#reimbursement-payment-panel">看付款状态</a>
|
<a href="#reimbursement-processing-progress-panel">看处理进度</a>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1093,7 +1037,7 @@ export function ReimbursementDetailPage() {
|
|||||||
if (detail.status === "paid") {
|
if (detail.status === "paid") {
|
||||||
return (
|
return (
|
||||||
<Button asChild data-testid="reimbursement-primary-action">
|
<Button asChild data-testid="reimbursement-primary-action">
|
||||||
<a href="#reimbursement-payment-panel">查看付款结果</a>
|
<a href="#reimbursement-processing-progress-panel">查看付款结果</a>
|
||||||
</Button>
|
</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">
|
<div className="space-y-5">
|
||||||
<SectionPanel title="报销概况">
|
<SectionPanel title="报销概况">
|
||||||
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
|
<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 className="detail-fact-card__value">{currentHandlerText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-fact-card">
|
<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">
|
<div className="detail-fact-card__value">
|
||||||
{materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")}
|
{detail.department?.name ?? "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-fact-card">
|
<div className="detail-fact-card">
|
||||||
@@ -1200,6 +1144,42 @@ export function ReimbursementDetailPage() {
|
|||||||
{detail.description}
|
{detail.description}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
|
|
||||||
{actionError ? (
|
{actionError ? (
|
||||||
@@ -1220,45 +1200,19 @@ export function ReimbursementDetailPage() {
|
|||||||
请修改超标明细后提交。
|
请修改超标明细后提交。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</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 ? (
|
{showSecondaryDetail ? (
|
||||||
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
|
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
|
||||||
<div className="detail-command-panel">
|
<div className="detail-command-panel">
|
||||||
@@ -1291,12 +1245,6 @@ export function ReimbursementDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<ReimbursementTravelSummary
|
|
||||||
detail={detail}
|
|
||||||
reimbursementId={reimbursementId}
|
|
||||||
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SectionPanel title="报销明细">
|
<SectionPanel title="报销明细">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-4" data-testid="reimbursement-items">
|
<div className="space-y-4" data-testid="reimbursement-items">
|
||||||
@@ -1489,7 +1437,6 @@ export function ReimbursementDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canOpenInvoiceBindDialog || materialPreviewError ? (
|
|
||||||
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
|
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
|
||||||
{detail.invoices.map((invoice) => {
|
{detail.invoices.map((invoice) => {
|
||||||
const invoiceHint = resolveInvoiceNextStepText(invoice);
|
const invoiceHint = resolveInvoiceNextStepText(invoice);
|
||||||
@@ -1529,7 +1476,6 @@ export function ReimbursementDetailPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="space-y-4" data-testid="reimbursement-attachments">
|
<div className="space-y-4" data-testid="reimbursement-attachments">
|
||||||
{canEditItems ? (
|
{canEditItems ? (
|
||||||
@@ -1575,7 +1521,7 @@ export function ReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(detail.attachments ?? []).length && (canEditItems || Boolean(materialPreviewError)) ? (
|
{(detail.attachments ?? []).length ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(detail.attachments ?? []).map((attachment) => (
|
{(detail.attachments ?? []).map((attachment) => (
|
||||||
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
|
<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>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
{showRelatedRecords ? (
|
<SectionPanel title="处理进度">
|
||||||
<SectionPanel title="关联事项">
|
<div id="reimbursement-processing-progress-panel">
|
||||||
<div className="space-y-4" data-testid="reimbursement-related-records">
|
<ReimbursementProcessingProgress progress={processingProgress} />
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<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
|
<ReimbursementActions
|
||||||
status={detail.status}
|
status={detail.status}
|
||||||
showSubmitAction={false}
|
showSubmitAction={false}
|
||||||
|
|||||||
@@ -149,6 +149,15 @@ const reimbursementDetail = {
|
|||||||
{ id: "step-2", name: "财务复核", status: "pending" },
|
{ 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: [
|
approvalHistory: [
|
||||||
{
|
{
|
||||||
id: "history-wave3-1",
|
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.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-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 page.goto("/workflow/tasks/task-wave3");
|
||||||
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
|
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.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
|
||||||
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
|
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-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.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
|
||||||
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
|
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
|
|||||||
@@ -198,6 +198,15 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
|
|||||||
budgetHint: null,
|
budgetHint: null,
|
||||||
pendingWorkflow: null,
|
pendingWorkflow: null,
|
||||||
workflowProgress: 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: [],
|
approvalHistory: [],
|
||||||
riskFlags: [],
|
riskFlags: [],
|
||||||
items: [
|
items: [
|
||||||
@@ -624,6 +633,14 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await expect(page.getByText("预算与审核提示")).toHaveCount(0);
|
await expect(page.getByText("预算与审核提示")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
|
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
|
||||||
await expect(page.getByTestId("reimbursement-related-records")).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 expect(page.getByText("ERP")).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByTestId("reimbursement-invoice-add").click();
|
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")).toContainText("电子火车票 · 12345679");
|
||||||
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("无代码");
|
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("无代码");
|
||||||
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();
|
const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
|
||||||
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
|
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
|
||||||
await expect(mobileItem).not.toContainText("电子火车票");
|
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);
|
const apiClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
@@ -67,8 +67,12 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn
|
|||||||
|
|
||||||
await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
|
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(reimbursementDetail.workflowProgress).toContainText("当前节点");
|
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).not.toContainText(/第 \d+ 步/);
|
||||||
|
|
||||||
await logout(page, EMPLOYEE.name);
|
await logout(page, EMPLOYEE.name);
|
||||||
} finally {
|
} 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) {
|
function ensureApplicantOrAdmin(request, reimbursement) {
|
||||||
if (request.employee?.roleCodes?.includes("admin")) {
|
if (request.employee?.roleCodes?.includes("admin")) {
|
||||||
return;
|
return;
|
||||||
@@ -2255,6 +2445,9 @@ async function reimbursementRoutes(fastify) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
current_step_id: true,
|
current_step_id: true,
|
||||||
|
current_status: true,
|
||||||
|
started_at: true,
|
||||||
|
completed_at: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
context_data: true,
|
context_data: true,
|
||||||
workflow_definition: {
|
workflow_definition: {
|
||||||
@@ -2262,6 +2455,23 @@ async function reimbursementRoutes(fastify) {
|
|||||||
definition_json: true,
|
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
|
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({
|
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
|
||||||
where: {
|
where: {
|
||||||
entity_type: "reimbursement",
|
entity_type: "reimbursement",
|
||||||
@@ -2306,10 +2558,6 @@ async function reimbursementRoutes(fastify) {
|
|||||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
});
|
});
|
||||||
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
|
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);
|
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
@@ -2349,10 +2597,10 @@ async function reimbursementRoutes(fastify) {
|
|||||||
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
||||||
travelSummary: {
|
travelSummary: {
|
||||||
isTravel: isTravelReimbursement,
|
isTravel: isTravelReimbursement,
|
||||||
purpose: reimbursement.travel_purpose ?? (isTravelReimbursement ? reimbursement.description : null),
|
purpose: reimbursement.travel_purpose ?? null,
|
||||||
location: reimbursement.travel_location ?? null,
|
location: reimbursement.travel_location ?? null,
|
||||||
startDate: toDateOnlyString(reimbursement.travel_start_date ?? travelItemDates[0] ?? null),
|
startDate: toDateOnlyString(reimbursement.travel_start_date),
|
||||||
endDate: toDateOnlyString(reimbursement.travel_end_date ?? travelItemDates.at(-1) ?? null),
|
endDate: toDateOnlyString(reimbursement.travel_end_date),
|
||||||
},
|
},
|
||||||
owner: reimbursement.owner,
|
owner: reimbursement.owner,
|
||||||
submitter: reimbursement.submitter,
|
submitter: reimbursement.submitter,
|
||||||
@@ -2397,6 +2645,13 @@ async function reimbursementRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null,
|
workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null,
|
||||||
|
processingProgress: buildProcessingProgress({
|
||||||
|
reimbursement,
|
||||||
|
workflowInstance: latestWorkflowInstance,
|
||||||
|
paymentBatchItem,
|
||||||
|
paymentActorNames,
|
||||||
|
canReadPaymentBatch,
|
||||||
|
}),
|
||||||
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
|
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
|
||||||
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ async function buildApp({
|
|||||||
id: PENDING_INSTANCE_ID,
|
id: PENDING_INSTANCE_ID,
|
||||||
current_step_id: "finance_approve",
|
current_step_id: "finance_approve",
|
||||||
created_at: new Date("2026-04-10T10:00:00.000Z"),
|
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: {
|
context_data: {
|
||||||
workflow_steps_snapshot: [
|
workflow_steps_snapshot: [
|
||||||
{ id: "manager_approve", name: "主管审批" },
|
{ 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 = [
|
workflowHistoryRows = [
|
||||||
{
|
{
|
||||||
@@ -143,6 +173,8 @@ async function buildApp({
|
|||||||
workflow_task: null,
|
workflow_task: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
paymentBatchItem = null,
|
||||||
|
paymentActors = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
const prisma = {
|
const prisma = {
|
||||||
@@ -163,6 +195,12 @@ async function buildApp({
|
|||||||
entityRiskFlag: {
|
entityRiskFlag: {
|
||||||
findMany: vi.fn(async () => []),
|
findMany: vi.fn(async () => []),
|
||||||
},
|
},
|
||||||
|
paymentBatchItem: {
|
||||||
|
findFirst: vi.fn(async () => paymentBatchItem),
|
||||||
|
},
|
||||||
|
employee: {
|
||||||
|
findMany: vi.fn(async () => paymentActors),
|
||||||
|
},
|
||||||
budgetPool: {
|
budgetPool: {
|
||||||
findMany: vi.fn(async () => []),
|
findMany: vi.fn(async () => []),
|
||||||
},
|
},
|
||||||
@@ -240,10 +278,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
|||||||
submittedAt: "2026-04-10T10:00:00.000Z",
|
submittedAt: "2026-04-10T10:00:00.000Z",
|
||||||
travelSummary: {
|
travelSummary: {
|
||||||
isTravel: true,
|
isTravel: true,
|
||||||
purpose: "客户拜访报销",
|
purpose: null,
|
||||||
location: null,
|
location: null,
|
||||||
startDate: "2026-04-09",
|
startDate: null,
|
||||||
endDate: "2026-04-09",
|
endDate: null,
|
||||||
},
|
},
|
||||||
invoices: [
|
invoices: [
|
||||||
{
|
{
|
||||||
@@ -280,6 +318,13 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
|||||||
currentStepIndex: 1,
|
currentStepIndex: 1,
|
||||||
totalSteps: 2,
|
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();
|
await app.close();
|
||||||
});
|
});
|
||||||
@@ -304,6 +349,187 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json().data.submittedAt).toBe(null);
|
expect(response.json().data.submittedAt).toBe(null);
|
||||||
expect(response.json().data.approvalHistory).toEqual([]);
|
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();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户