补齐报销待组批财务入口 [09-04-reimbursement-state-gap-finance-view][R1]

这个提交包含在:
2026-09-05 09:14:13 +08:00
父节点 8e0af8235e
当前提交 5fe9f78bc9
共修改 9 个文件,包含 243 行新增和 67 行删除
+13 -1
查看文件
@@ -625,6 +625,7 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
- Builder: `buildPaymentProcessingSemantics({ status, createdAt, reviewedAt, executedAt, cancelledAt, cancelledReason, requiresBankReceipt, receiptCount, paymentOrderExportCount })`. - Builder: `buildPaymentProcessingSemantics({ status, createdAt, reviewedAt, executedAt, cancelledAt, cancelledReason, requiresBankReceipt, receiptCount, paymentOrderExportCount })`.
- Channel helpers: `resolveItemPaymentChannel(batch, item)` and `requiresBankReceipt(batch)`. - Channel helpers: `resolveItemPaymentChannel(batch, item)` and `requiresBankReceipt(batch)`.
- Payment detail: `GET /api/payment-batches/:id` returns `processingProgress.stages[]`; list responses do not return this field. - Payment detail: `GET /api/payment-batches/:id` returns `processingProgress.stages[]`; list responses do not return this field.
- Pending batching queue: `GET /api/payment-batches/candidates?q=&page=&pageSize=` is the only eligibility source; `/payment-batches?view=pending&q=<business-no>` is its refreshable UI entry.
- Stage states: `completed | current | pending | exception | terminated`. - Stage states: `completed | current | pending | exception | terminated`.
### 3. Contracts ### 3. Contracts
@@ -637,6 +638,8 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
- `failed` uses `exception`. A cancellation before review makes the review stage `exception` and payment stage `terminated`; a cancellation after review completes review and makes payment `exception`. `terminated` is a neutral stopped state, not pending, completed or failed. - `failed` uses `exception`. A cancellation before review makes the review stage `exception` and payment stage `terminated`; a cancellation after review completes review and makes payment `exception`. `terminated` is a neutral stopped state, not pending, completed or failed.
- `actorSource=creator|reviewer|executor|canceller` is an internal semantic reference. The shared service never queries employees; each response assembler resolves only a nullable actor name and never exposes actor IDs inside progress. - `actorSource=creator|reviewer|executor|canceller` is an internal semantic reference. The shared service never queries employees; each response assembler resolves only a nullable actor name and never exposes actor IDs inside progress.
- When an authorized reimbursement has entered a batch, its processing progress exposes at most one batch link. `draft` and cancellation before review attach it to `付款复核`; `reviewed|executed|failed` and cancellation after review attach it to `付款完成`. Unauthorized actors receive neither link. - When an authorized reimbursement has entered a batch, its processing progress exposes at most one batch link. `draft` and cancellation before review attach it to `付款复核`; `reviewed|executed|failed` and cancellation after review attach it to `付款完成`. Unauthorized actors receive neither link.
- An `approved` reimbursement without a batch is current at the reimbursement-only label `组建付款批次`; `付款复核` remains pending until a `draft` batch exists. The payment-batch detail keeps the shared `批次创建` label, so the employee projection does not rename the shared definition globally.
- Payment managers default to the URL-backed `待组批` view and may create from the existing candidate contract. Review-only actors default to `付款批次` and must neither render nor request candidates. Candidate eligibility is never reimplemented in React.
### 4. Validation & Error Matrix ### 4. Validation & Error Matrix
@@ -647,10 +650,12 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
- `reviewed + requiresBankReceipt=true + receiptCount>0` -> guide registration completion. - `reviewed + requiresBankReceipt=true + receiptCount>0` -> guide registration completion.
- `cancelled + reviewedAt=null` -> review `exception`, payment `terminated`; `cancelled + reviewedAt!=null` -> review `completed`, payment `exception`. - `cancelled + reviewedAt=null` -> review `exception`, payment `terminated`; `cancelled + reviewedAt!=null` -> review `completed`, payment `exception`.
- Missing actor row or timestamp -> corresponding name/time is null; the detail response still succeeds. - Missing actor row or timestamp -> corresponding name/time is null; the detail response still succeeds.
- `approved + no batch` -> `组建付款批次=current/待组批`, `付款复核=pending`; a created `draft` batch -> creation completed and review current.
### 5. Good/Base/Bad Cases ### 5. Good/Base/Bad Cases
- Good: both pages import the same semantic builder, while the reimbursement route selects two payment stages and the payment route returns all three. - Good: both pages import the same semantic builder, while the reimbursement route returns the three payment stages and locally labels creation as `组建付款批次`.
- Good: a payment manager follows a reimbursement deep link into one candidate list whose count, search and pagination all come from the server response and URL.
- Base: a cash batch without a receipt shows `等待财务登记付款完成` and exposes the permitted registration action. - Base: a cash batch without a receipt shows `等待财务登记付款完成` and exposes the permitted registration action.
- Bad: a route adds `if (batch.status === "reviewed")` to rewrite stage state/detail, or a client infers receipt requirements from `receiptCount===0`. - Bad: a route adds `if (batch.status === "reviewed")` to rewrite stage state/detail, or a client infers receipt requirements from `receiptCount===0`.
- Bad: a cancelled batch shows a later `待处理` node, or a cash batch tells finance to upload a bank receipt. - Bad: a cancelled batch shows a later `待处理` node, or a cash batch tells finance to upload a bank receipt.
@@ -661,6 +666,7 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
- A structural test extracts both route assembler functions and rejects route-local payment status matrices or duplicate `付款复核` / `付款完成` label definitions. - A structural test extracts both route assembler functions and rejects route-local payment status matrices or duplicate `付款复核` / `付款完成` label definitions.
- Route tests assert actor name/time projection, list/detail field separation and absence of snapshot, bank ciphertext and actor IDs inside progress. - Route tests assert actor name/time projection, list/detail field separation and absence of snapshot, bank ciphertext and actor IDs inside progress.
- Reimbursement route tests assert the five batch statuses, both cancellation positions and the no-permission case never produce duplicate batch links. - Reimbursement route tests assert the five batch statuses, both cancellation positions and the no-permission case never produce duplicate batch links.
- Browser tests assert manager/reviewer default URLs, review-only candidate request count `0`, one candidate DOM, server `total`, URL search/pagination, responsive overflow and create-to-detail navigation.
- PC/mobile E2E uses synthetic responses for all five states, verifies `terminated`, bank/non-bank copy, one visible primary action per viewport and upload-to-register in-place switching. - PC/mobile E2E uses synthetic responses for all five states, verifies `terminated`, bank/non-bank copy, one visible primary action per viewport and upload-to-register in-place switching.
- Reimbursement E2E asserts the four-block order and that submit, print, remind, withdraw and delete retain their state/permission, reason/confirmation, request and refresh contracts after action-panel migration. - Reimbursement E2E asserts the four-block order and that submit, print, remind, withdraw and delete retain their state/permission, reason/confirmation, request and refresh contracts after action-panel migration.
@@ -672,6 +678,9 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
if (batch.status === "reviewed" && receiptCount === 0) { if (batch.status === "reviewed" && receiptCount === 0) {
paymentStage.detail = "请上传银行回单"; paymentStage.detail = "请上传银行回单";
} }
// Wrong: infer candidates again in the client.
const pending = reimbursements.filter((item) => item.status === "approved" && !item.batchId);
``` ```
#### Correct #### Correct
@@ -683,4 +692,7 @@ const semantics = buildPaymentProcessingSemantics({
receiptCount: batch._count?.receipt_attachments ?? 0, receiptCount: batch._count?.receipt_attachments ?? 0,
paymentOrderExportCount: batch.payment_order_export_count ?? 0, paymentOrderExportCount: batch.payment_order_export_count ?? 0,
}); });
// Correct: the UI renders only the server candidate contract.
const candidates = await fetchPaymentBatchCandidates({ q, page, pageSize });
``` ```
+140 -35
查看文件
@@ -147,12 +147,10 @@ export function PaymentBatchesPage() {
const canRevealPayeeBank = const canRevealPayeeBank =
hasPermission(permissions, "employee.bank.read") && hasPermission(permissions, "employee.bank.read") &&
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode))); Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]); const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank"); const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
const receiptInputRef = useRef<HTMLInputElement | null>(null); const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showCreatePanel, setShowCreatePanel] = useState(false);
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{ const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
kind: "financeReview" | "financePayment"; kind: "financeReview" | "financePayment";
batchId: string; batchId: string;
@@ -181,18 +179,50 @@ export function PaymentBatchesPage() {
}; };
}, [receiptPreview?.objectUrl]); }, [receiptPreview?.objectUrl]);
const selectedBatchId = searchParams.get("id") ?? "";
const requestedView = searchParams.get("view");
const activeView = selectedBatchId
? "detail"
: canManageBatch && requestedView !== "batches"
? "pending"
: "batches";
const candidateSearch = activeView === "pending" ? (searchParams.get("q") ?? "") : "";
const parsedCandidatePage = Number(searchParams.get("page") ?? "1");
const candidatePage = activeView === "pending" && Number.isInteger(parsedCandidatePage) && parsedCandidatePage > 0
? parsedCandidatePage
: 1;
const candidatePageSize = 20;
useEffect(() => {
if (selectedBatchId) return;
const normalizedView = canManageBatch && requestedView !== "batches" ? "pending" : "batches";
if (requestedView === normalizedView) return;
const next = new URLSearchParams(searchParams);
next.set("view", normalizedView);
if (normalizedView === "batches") {
next.delete("q");
next.delete("page");
}
setSearchParams(next, { replace: true });
}, [canManageBatch, requestedView, searchParams, selectedBatchId, setSearchParams]);
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: ["payment-batches"], queryKey: ["payment-batches"],
queryFn: () => fetchPaymentBatches(), queryFn: () => fetchPaymentBatches(),
}); });
const candidatesQuery = useQuery({ const candidatesQuery = useQuery({
queryKey: ["payment-batch-candidates", candidateSearch], queryKey: ["payment-batch-candidates", candidateSearch, candidatePage, candidatePageSize],
enabled: showCreatePanel && canManageBatch, enabled: canManageBatch,
queryFn: () => fetchPaymentBatchCandidates({ q: candidateSearch, page: 1, pageSize: 50 }), queryFn: () => fetchPaymentBatchCandidates({
q: candidateSearch,
page: candidatePage,
pageSize: candidatePageSize,
}),
}); });
const selectedBatchId = searchParams.get("id") ?? "";
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]); const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
const candidateTotal = candidatesQuery.data?.total ?? 0;
const candidateTotalPages = Math.max(1, Math.ceil(candidateTotal / candidatePageSize));
const selectedBatchQuery = useQuery({ const selectedBatchQuery = useQuery({
queryKey: ["payment-batches", "detail", selectedBatchId], queryKey: ["payment-batches", "detail", selectedBatchId],
enabled: Boolean(selectedBatchId), enabled: Boolean(selectedBatchId),
@@ -279,9 +309,40 @@ export function PaymentBatchesPage() {
const returnToList = () => { const returnToList = () => {
const next = new URLSearchParams(searchParams); const next = new URLSearchParams(searchParams);
next.delete("id"); next.delete("id");
next.set("view", "batches");
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
}; };
const openView = (view: "pending" | "batches") => {
if (view === "batches") {
setSelectedCandidateIds([]);
setSelectedPaymentChannel("bank");
}
const next = new URLSearchParams(searchParams);
next.delete("id");
next.set("view", view);
next.delete("page");
if (view === "batches") next.delete("q");
setSearchParams(next);
};
const updateCandidateSearch = (value: string) => {
const next = new URLSearchParams(searchParams);
next.set("view", "pending");
if (value) next.set("q", value);
else next.delete("q");
next.delete("page");
setSearchParams(next, { replace: true });
};
const updateCandidatePage = (page: number) => {
const next = new URLSearchParams(searchParams);
next.set("view", "pending");
if (page > 1) next.set("page", String(page));
else next.delete("page");
setSearchParams(next);
};
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async () => mutationFn: async () =>
createPaymentBatch({ createPaymentBatch({
@@ -292,10 +353,12 @@ export function PaymentBatchesPage() {
onSuccess: async ({ item }) => { onSuccess: async ({ item }) => {
setError(""); setError("");
setSelectedCandidateIds([]); setSelectedCandidateIds([]);
setCandidateSearch("");
setSelectedPaymentChannel("bank"); setSelectedPaymentChannel("bank");
const next = new URLSearchParams(searchParams); const next = new URLSearchParams(searchParams);
next.set("id", item.id); next.set("id", item.id);
next.set("view", "batches");
next.delete("q");
next.delete("page");
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
await refresh(); await refresh();
toast.success("已提交付款复核,等待负责人处理"); toast.success("已提交付款复核,等待负责人处理");
@@ -571,39 +634,67 @@ export function PaymentBatchesPage() {
返回列表 返回列表
</Button> </Button>
) : null} ) : null}
{!selectedBatchId && canManageBatch ? (
<Button type="button" onClick={() => setShowCreatePanel(true)}>
新建批次
</Button>
) : null}
</HeaderActionGroup> </HeaderActionGroup>
} }
/> />
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null} {error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
{!selectedBatchId && showCreatePanel ? ( {!selectedBatchId ? (
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel"> <nav className="flex min-h-11 flex-wrap items-center gap-2 border-b border-zinc-200" aria-label="付款工作台视图">
<div> {canManageBatch ? (
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3> <button
</div> type="button"
{!canManageBatch ? ( className={`min-h-11 border-b-2 px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 ${activeView === "pending" ? "border-primary text-primary" : "border-transparent text-zinc-600 hover:text-zinc-900"}`}
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600"> aria-current={activeView === "pending" ? "page" : undefined}
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。 onClick={() => openView("pending")}
</div> >
待组批 {candidateTotal}
</button>
) : null} ) : null}
<button
type="button"
className={`min-h-11 border-b-2 px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 ${activeView === "batches" ? "border-primary text-primary" : "border-transparent text-zinc-600 hover:text-zinc-900"}`}
aria-current={activeView === "batches" ? "page" : undefined}
onClick={() => openView("batches")}
>
付款批次 {batches.length}
</button>
</nav>
) : null}
{!selectedBatchId && activeView === "pending" ? (
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">待组批报销</h3>
<p className="mt-1 text-sm text-zinc-500">选择报销后创建付款批次并提交复核。</p>
</div>
{selectedCandidateIds.length ? (
<span className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
已选择 {selectedCandidateIds.length} 项
</span>
) : null}
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>选择待付款报销</Label> <Label htmlFor="payment-batch-candidate-search">搜索待组批报销</Label>
<Input <Input
id="payment-batch-candidate-search"
data-testid="payment-batch-candidate-search" data-testid="payment-batch-candidate-search"
value={candidateSearch} value={candidateSearch}
disabled={!canManageBatch} onChange={(event) => updateCandidateSearch(event.target.value)}
onChange={(event) => setCandidateSearch(event.target.value)}
placeholder="按报销编号、人员或摘要搜索" placeholder="按报销编号、人员或摘要搜索"
/> />
<div className="max-h-72 space-y-2 overflow-y-auto rounded-lg border border-zinc-200 p-2" data-testid="payment-batch-candidates"> <div className="space-y-2 rounded-lg border border-zinc-200 p-2" data-testid="payment-batch-candidates">
{candidatesQuery.isLoading ? <div className="px-2 py-3 text-sm text-zinc-500">正在加载待付款报销...</div> : null} {candidatesQuery.isLoading ? <div className="px-2 py-6 text-center text-sm text-zinc-500">正在加载待组批报销...</div> : null}
{candidatesQuery.isError ? <div className="px-2 py-3 text-sm text-red-600">{resolveError(candidatesQuery.error)}</div> : null} {candidatesQuery.isError ? (
<div className="flex flex-wrap items-center justify-between gap-2 px-2 py-3 text-sm text-red-600" role="alert">
<span>{resolveError(candidatesQuery.error)}</span>
<Button type="button" size="sm" variant="secondary" disabled={candidatesQuery.isFetching} onClick={() => void candidatesQuery.refetch()}>
{candidatesQuery.isFetching ? "重新加载中..." : "重新加载"}
</Button>
</div>
) : null}
{(candidatesQuery.data?.items ?? []).map((candidate) => { {(candidatesQuery.data?.items ?? []).map((candidate) => {
const selected = selectedCandidateIds.includes(candidate.entityId); const selected = selectedCandidateIds.includes(candidate.entityId);
return ( return (
@@ -614,7 +705,7 @@ export function PaymentBatchesPage() {
> >
<input <input
type="checkbox" type="checkbox"
className="mt-1 h-4 w-4" className="mt-1 h-5 w-5"
checked={selected} checked={selected}
onChange={(event) => setSelectedCandidateIds((current) => onChange={(event) => setSelectedCandidateIds((current) =>
event.target.checked event.target.checked
@@ -636,9 +727,20 @@ export function PaymentBatchesPage() {
); );
})} })}
{!candidatesQuery.isLoading && !candidatesQuery.isError && !(candidatesQuery.data?.items ?? []).length ? ( {!candidatesQuery.isLoading && !candidatesQuery.isError && !(candidatesQuery.data?.items ?? []).length ? (
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div> <div className="px-2 py-6 text-center text-sm text-zinc-500">当前没有待组批报销</div>
) : null} ) : null}
</div> </div>
{!candidatesQuery.isError && candidateTotal > candidatePageSize ? (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-zinc-600" data-testid="payment-batch-candidate-pagination">
<span>第 {candidatePage} / {candidateTotalPages} 页,共 {candidateTotal} 项</span>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" disabled={candidatePage <= 1 || candidatesQuery.isFetching} onClick={() => updateCandidatePage(candidatePage - 1)}>上一页</Button>
<Button type="button" size="sm" variant="secondary" disabled={candidatePage >= candidateTotalPages || candidatesQuery.isFetching} onClick={() => updateCandidatePage(candidatePage + 1)}>下一页</Button>
</div>
</div>
) : null}
{selectedCandidateIds.length ? (
<>
<div className="max-w-sm space-y-2"> <div className="max-w-sm space-y-2">
<Label htmlFor="payment-batch-default-channel">付款渠道</Label> <Label htmlFor="payment-batch-default-channel">付款渠道</Label>
<select <select
@@ -646,7 +748,6 @@ export function PaymentBatchesPage() {
data-testid="payment-batch-default-channel" data-testid="payment-batch-default-channel"
className="select-std w-full" className="select-std w-full"
value={selectedPaymentChannel} value={selectedPaymentChannel}
disabled={!canManageBatch}
onChange={(event) => setSelectedPaymentChannel(event.target.value as PaymentChannel)} onChange={(event) => setSelectedPaymentChannel(event.target.value as PaymentChannel)}
> >
{PAYMENT_CHANNEL_OPTIONS.map((option) => ( {PAYMENT_CHANNEL_OPTIONS.map((option) => (
@@ -657,10 +758,14 @@ export function PaymentBatchesPage() {
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,按{paymentChannelLabel(selectedPaymentChannel)}提交负责人复核。 已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,按{paymentChannelLabel(selectedPaymentChannel)}提交负责人复核。
</div> </div>
</>
) : null}
</div> </div>
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}> {selectedCandidateIds.length ? (
{createMutation.isPending ? "提交中..." : "创建并提交复核"} <Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
</Button> {createMutation.isPending ? "提交中..." : "创建并提交复核"}
</Button>
) : null}
</div> </div>
) : null} ) : null}
@@ -995,7 +1100,7 @@ export function PaymentBatchesPage() {
) : ( ) : (
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div> <div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
) )
) : ( ) : activeView === "batches" ? (
<> <>
<BusinessListToolbar <BusinessListToolbar
actions={ actions={
@@ -1090,7 +1195,7 @@ export function PaymentBatchesPage() {
</tbody> </tbody>
</BusinessTable> </BusinessTable>
</> </>
)} ) : null}
<ActionConfirmDialog <ActionConfirmDialog
open={Boolean(confirmPaymentAction)} open={Boolean(confirmPaymentAction)}
onOpenChange={(open) => { onOpenChange={(open) => {
+12 -1
查看文件
@@ -160,6 +160,7 @@ export function ReimbursementDetailPage() {
const canUploadInvoice = hasPermission(permissions, "invoice.upload"); const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canManagePaymentBatch = hasPermission(permissions, "payment.batch.manage");
const reimbursementPrecheckQuery = useQuery({ const reimbursementPrecheckQuery = useQuery({
enabled: Boolean( enabled: Boolean(
@@ -956,7 +957,7 @@ export function ReimbursementDetailPage() {
return currentProcessingStage?.detail ?? "查看处理进度"; return currentProcessingStage?.detail ?? "查看处理进度";
} }
if (detail.status === "approved") { if (detail.status === "approved") {
return "等待财务处理"; return "等待财务组建付款批次";
} }
if (detail.status === "paid") { if (detail.status === "paid") {
return "查看付款结果"; return "查看付款结果";
@@ -1001,6 +1002,16 @@ export function ReimbursementDetailPage() {
); );
} }
if (detail.status === "approved" && canManagePaymentBatch) {
return (
<Button asChild data-testid="reimbursement-open-pending-payment-batches">
<Link to={`/payment-batches?view=pending&q=${encodeURIComponent(resolveReimbursementDisplayCode(detail))}`}>
去组建付款批次
</Link>
</Button>
);
}
return null; return null;
})(); })();
-2
查看文件
@@ -110,8 +110,6 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const reimbursement = await apiGetData<{ formNumber: string | null }>(page.request, `/api/reimbursements/${reimbursementId}`); const reimbursement = await apiGetData<{ formNumber: string | null }>(page.request, `/api/reimbursements/${reimbursementId}`);
const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId; const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId;
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "新建批次" }).click();
const createCard = page.getByTestId("payment-batch-create-panel"); const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard).toBeVisible(); await expect(createCard).toBeVisible();
const createBatchResponsePromise = page.waitForResponse((response) => { const createBatchResponsePromise = page.waitForResponse((response) => {
@@ -45,6 +45,8 @@ type MockScenario = {
businessDate: string | null; businessDate: string | null;
summary: string | null; summary: string | null;
}>; }>;
paymentCandidateTotal?: number;
onCandidateQuery?: (query: URLSearchParams) => void;
contracts?: SyntheticContract[]; contracts?: SyntheticContract[];
onCreatePaymentBatch?: (payload: unknown) => void; onCreatePaymentBatch?: (payload: unknown) => void;
}; };
@@ -156,7 +158,8 @@ function expectNoPageErrors(errors: string[]) {
async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: MockScenario = {}) { async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: MockScenario = {}) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request(); const request = route.request();
const pathName = new URL(request.url()).pathname; const requestUrl = new URL(request.url());
const pathName = requestUrl.pathname;
if (pathName === "/api/auth/capabilities") { if (pathName === "/api/auth/capabilities") {
await fulfillJson(route, { await fulfillJson(route, {
@@ -265,7 +268,13 @@ async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: Mo
} }
if (pathName === "/api/payment-batches/candidates") { if (pathName === "/api/payment-batches/candidates") {
const items = scenario.paymentCandidates ?? []; const items = scenario.paymentCandidates ?? [];
await fulfillJson(route, { items, page: 1, pageSize: 50, total: items.length }); scenario.onCandidateQuery?.(requestUrl.searchParams);
await fulfillJson(route, {
items,
page: Number(requestUrl.searchParams.get("page") ?? "1"),
pageSize: Number(requestUrl.searchParams.get("pageSize") ?? "20"),
total: scenario.paymentCandidateTotal ?? items.length,
});
return; return;
} }
if (pathName === "/api/payment-batches" && request.method() === "POST") { if (pathName === "/api/payment-batches" && request.method() === "POST") {
@@ -342,13 +351,18 @@ test("只有付款复核职责的经理可看应付账款但看不到导出", as
test("只有付款复核职责的经理看不到新建批次", async ({ page }) => { test("只有付款复核职责的经理看不到新建批次", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page); const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, REVIEWER_ACTOR); let candidateQueryCount = 0;
await mockSyntheticApis(page, REVIEWER_ACTOR, {
onCandidateQuery: () => { candidateQueryCount += 1; },
});
await login(page, REVIEWER_ACTOR); await login(page, REVIEWER_ACTOR);
await page.goto("/payment-batches"); await page.goto("/payment-batches?view=pending");
await expect(page).toHaveURL(/\/payment-batches$/); await expect(page).toHaveURL(/\/payment-batches\?view=batches$/);
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "新建批次", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: /待组批/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: /付款批次/ })).toBeVisible();
expect(candidateQueryCount).toBe(0);
expectNoPageErrors(pageErrors); expectNoPageErrors(pageErrors);
}); });
@@ -394,8 +408,11 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page); const pageErrors = capturePageErrors(page);
let createPayload: unknown = null; let createPayload: unknown = null;
const candidateQueries: string[] = [];
await mockSyntheticApis(page, FINANCE_ACTOR, { await mockSyntheticApis(page, FINANCE_ACTOR, {
paymentCandidates: [PAYMENT_CANDIDATE], paymentCandidates: [PAYMENT_CANDIDATE],
paymentCandidateTotal: 21,
onCandidateQuery: (query) => candidateQueries.push(query.toString()),
onCreatePaymentBatch: (payload) => { onCreatePaymentBatch: (payload) => {
createPayload = payload; createPayload = payload;
}, },
@@ -403,9 +420,15 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
await login(page, FINANCE_ACTOR); await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches$/); await expect(page).toHaveURL(/\/payment-batches\?view=pending$/);
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await page.getByRole("button", { name: "新建批次", exact: true }).click(); await expect(page.getByRole("button", { name: "待组批 21", exact: true })).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("payment-batch-candidates")).toHaveCount(1);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2);
await page.getByRole("button", { name: "下一页", exact: true }).click();
await expect(page).toHaveURL(/view=pending&page=2/);
await expect.poll(() => candidateQueries.some((query) => query.includes("page=2") && query.includes("pageSize=20"))).toBe(true);
await page.getByRole("button", { name: "上一页", exact: true }).click();
const candidateRow = page.getByTestId("payment-batch-candidate-row"); const candidateRow = page.getByTestId("payment-batch-candidate-row");
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo); await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo);
@@ -413,13 +436,17 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
await expect(candidateRow).toContainText("¥1,280.50"); await expect(candidateRow).toContainText("¥1,280.50");
await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0); await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0);
await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0); await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0);
await page.getByTestId("payment-batch-candidate-search").fill(PAYMENT_CANDIDATE.businessNo);
await expect(page).toHaveURL(new RegExp(`view=pending&q=${PAYMENT_CANDIDATE.businessNo}`));
await page.setViewportSize({ width: 375, height: 812 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2);
await candidateRow.getByRole("checkbox").check(); await candidateRow.getByRole("checkbox").check();
await page.getByTestId("payment-batch-default-channel").selectOption("cash"); await page.getByTestId("payment-batch-default-channel").selectOption("cash");
await expect(page.getByText("按现金提交负责人复核", { exact: false })).toBeVisible(); await expect(page.getByText("按现金提交负责人复核", { exact: false })).toBeVisible();
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click(); await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`)); await expect(page).toHaveURL(new RegExp(`/payment-batches\\?view=batches&id=${CREATED_PAYMENT_BATCH_ID}$`));
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByText("已提交付款复核,等待负责人处理", { exact: true })).toBeVisible(); await expect(page.getByText("已提交付款复核,等待负责人处理", { exact: true })).toBeVisible();
expect(createPayload).toEqual({ expect(createPayload).toEqual({
@@ -143,6 +143,20 @@ test("申请人进度与审批历史保持有意分工", () => {
expect(approvalAdapter).not.toContain("<ProcessingProgress"); expect(approvalAdapter).not.toContain("<ProcessingProgress");
}); });
test("待组批工作台保持单一候选源且报销详情使用可刷新深链", () => {
const paymentPage = source("src/pages/PaymentBatchesPage.tsx");
const reimbursementDetail = source("src/pages/ReimbursementDetailPage.tsx");
expect(paymentPage.match(/data-testid="payment-batch-candidates"/g)).toHaveLength(1);
expect(paymentPage.match(/fetchPaymentBatchCandidates\(/g)).toHaveLength(1);
expect(paymentPage).toContain('next.set("view", "pending")');
expect(paymentPage).toContain('next.set("page", String(page))');
expect(paymentPage).toContain("candidatesQuery.data?.total");
expect(reimbursementDetail).toContain('hasPermission(permissions, "payment.batch.manage")');
expect(reimbursementDetail).toContain("/payment-batches?view=pending&q=");
expect(reimbursementDetail).toContain("等待财务组建付款批次");
});
test("票据申报事实与系统金额证据状态分属正文和处理上下文", () => { test("票据申报事实与系统金额证据状态分属正文和处理上下文", () => {
const baseDetail = { const baseDetail = {
items: [{ items: [{
@@ -102,8 +102,6 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches/); await expect(page).toHaveURL(/\/payment-batches/);
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "新建批次" }).click();
const createCard = page.getByTestId("payment-batch-create-panel"); const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard).toBeVisible(); await expect(createCard).toBeVisible();
const createBatchResponsePromise = page.waitForResponse((response) => { const createBatchResponsePromise = page.waitForResponse((response) => {
+10 -3
查看文件
@@ -392,6 +392,7 @@ const PROCESSING_STAGE_DEFINITIONS = Object.freeze([
{ key: "submitted", label: "申请提交" }, { key: "submitted", label: "申请提交" },
{ key: "manager_approval", label: "主管审批" }, { key: "manager_approval", label: "主管审批" },
{ key: "finance_review", label: "财务复核" }, { key: "finance_review", label: "财务复核" },
PAYMENT_PROCESSING_STAGE_DEFINITIONS.created,
PAYMENT_PROCESSING_STAGE_DEFINITIONS.review, PAYMENT_PROCESSING_STAGE_DEFINITIONS.review,
PAYMENT_PROCESSING_STAGE_DEFINITIONS.payment, PAYMENT_PROCESSING_STAGE_DEFINITIONS.payment,
]); ]);
@@ -566,13 +567,16 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
const batchTargetHref = batch && canReadPaymentBatch const batchTargetHref = batch && canReadPaymentBatch
? `/payment-batches?id=${encodeURIComponent(batch.id)}` ? `/payment-batches?id=${encodeURIComponent(batch.id)}`
: null; : null;
const batchCreatedStage = stageByKey.get("payment_batch_created");
batchCreatedStage.label = "组建付款批次";
const batchReviewStage = stageByKey.get("payment_batch_review"); const batchReviewStage = stageByKey.get("payment_batch_review");
const paymentStage = stageByKey.get("payment_and_receipt"); const paymentStage = stageByKey.get("payment_and_receipt");
if (!batch) { if (!batch) {
Object.assign(batchReviewStage, reimbursement.status === "approved" Object.assign(batchCreatedStage, reimbursement.status === "approved"
? { state: "current", result: "待安排", detail: "待财务安排付款" } ? { state: "current", result: "待组批", detail: "待财务组建付款批次" }
: { detail: "待财务安排付款" }); : { detail: "待财务组建付款批次" });
batchReviewStage.detail = "待付款批次创建并提交复核";
paymentStage.detail = "待付款复核后由财务登记,非审批环节"; paymentStage.detail = "待付款复核后由财务登记,非审批环节";
} else { } else {
const semantics = buildPaymentProcessingSemantics({ const semantics = buildPaymentProcessingSemantics({
@@ -587,11 +591,13 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
paymentOrderExportCount: batch.payment_order_export_count ?? 0, paymentOrderExportCount: batch.payment_order_export_count ?? 0,
}); });
const actorIds = { const actorIds = {
creator: batch.created_by,
reviewer: batch.reviewed_by, reviewer: batch.reviewed_by,
executor: batch.executed_by, executor: batch.executed_by,
canceller: batch.cancelled_by, canceller: batch.cancelled_by,
}; };
for (const [target, source] of [ for (const [target, source] of [
[batchCreatedStage, semantics.created],
[batchReviewStage, semantics.review], [batchReviewStage, semantics.review],
[paymentStage, semantics.payment], [paymentStage, semantics.payment],
]) { ]) {
@@ -630,6 +636,7 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
...(approvalStages.length > 0 ...(approvalStages.length > 0
? approvalStages ? approvalStages
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]), : [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
stageByKey.get("payment_batch_created"),
stageByKey.get("payment_batch_review"), stageByKey.get("payment_batch_review"),
stageByKey.get("payment_and_receipt"), stageByKey.get("payment_and_receipt"),
], ],
@@ -444,7 +444,8 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }), expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }),
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed", actorName: "王经理", result: "通过" }), expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed", actorName: "王经理", result: "通过" }),
expect.objectContaining({ key: "approval_finance-task", label: "财务审批", state: "current", actorName: "财务专员", result: "处理中" }), expect.objectContaining({ key: "approval_finance-task", label: "财务审批", state: "current", actorName: "财务专员", result: "处理中" }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务安排付款" }), expect.objectContaining({ key: "payment_batch_created", label: "组建付款批次", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待付款批次创建并提交复核" }),
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }), expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]); ]);
@@ -516,6 +517,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
expect.objectContaining({ key: "submitted", state: "current", actorName: "张三", detail: "待申请人提交" }), expect.objectContaining({ key: "submitted", state: "current", actorName: "张三", detail: "待申请人提交" }),
expect.objectContaining({ key: "manager_approval", state: "pending", actorName: null }), expect.objectContaining({ key: "manager_approval", state: "pending", actorName: null }),
expect.objectContaining({ key: "finance_review", state: "pending", actorName: null }), expect.objectContaining({ key: "finance_review", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_batch_created", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_batch_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 }), expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]); ]);
@@ -856,9 +858,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const stages = response.json().data.processingProgress.stages; const stages = response.json().data.processingProgress.stages;
expect(stages[3]).toMatchObject({ state: reviewState, targetHref: null }); expect(stages[3]).toMatchObject({ key: "payment_batch_created", state: "completed", targetHref: null });
expect(stages[4]).toMatchObject({ state: paymentState, detail, targetHref: null }); expect(stages[4]).toMatchObject({ state: reviewState, targetHref: null });
if (status === "draft") expect(stages[3].actorName).toBe(null); expect(stages[5]).toMatchObject({ state: paymentState, detail, targetHref: null });
if (status === "draft") expect(stages[4].actorName).toBe(null);
await app.close(); await app.close();
}); });
@@ -895,7 +898,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
headers: { authorization: "Bearer test" }, headers: { authorization: "Bearer test" },
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[3]).toMatchObject({ expect(response.json().data.processingProgress.stages[4]).toMatchObject({
detail: "待非制单人复核付款", detail: "待非制单人复核付款",
actorName: null, actorName: null,
targetHref: "/payment-batches?id=batch-1", targetHref: "/payment-batches?id=batch-1",
@@ -906,12 +909,12 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
it("attaches the payment-batch link to exactly one status-owning stage", async () => { it("attaches the payment-batch link to exactly one status-owning stage", async () => {
const cases = [ const cases = [
{ status: "draft", reviewedAt: null, targetIndex: 3 }, { status: "draft", reviewedAt: null, targetIndex: 4 },
{ status: "reviewed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 }, { status: "reviewed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 },
{ status: "executed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 }, { status: "executed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 },
{ status: "failed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 }, { status: "failed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 },
{ status: "cancelled", reviewedAt: null, targetIndex: 3 }, { status: "cancelled", reviewedAt: null, targetIndex: 4 },
{ status: "cancelled", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 }, { status: "cancelled", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 },
]; ];
for (const item of cases) { for (const item of cases) {
@@ -967,10 +970,11 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[3]).toMatchObject({ expect(response.json().data.processingProgress.stages[3]).toMatchObject({
key: "payment_batch_created",
state: "current", state: "current",
actorName: null, actorName: null,
result: "待安排", result: "待组批",
detail: "待财务安排付款", detail: "待财务组建付款批次",
}); });
await app.close(); await app.close();
@@ -991,7 +995,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
headers: { authorization: "Bearer test" }, headers: { authorization: "Bearer test" },
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[4]).toMatchObject({ expect(response.json().data.processingProgress.stages[5]).toMatchObject({
state: "completed", state: "completed",
actorName: "实际付款人", actorName: "实际付款人",
occurredAt: "2026-04-12T08:00:00.000Z", occurredAt: "2026-04-12T08:00:00.000Z",