fix: 优化报销制单下载与付款进度(09-08-reimbursement-payment-closeout)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-10 16:39:01 +08:00
父节点 73fcd5a65a
当前提交 fe025e65cf
共修改 15 个文件,包含 364 行新增和 42 行删除
+9
查看文件
@@ -165,6 +165,15 @@ function parseFilenameFromContentDisposition(headerValue: string | null): string
}
const parts = headerValue.split(";").map((part) => part.trim());
const encodedPart = parts.find((part) => part.toLowerCase().startsWith("filename*="));
const encodedValue = encodedPart?.slice("filename*=".length).trim().replace(/^"|"$/g, "");
const utf8Value = encodedValue?.match(/^UTF-8'[^']*'(.*)$/i);
if (utf8Value) {
try {
const decoded = decodeURIComponent(utf8Value[1]);
if (decoded) return decoded;
} catch {}
}
const filenamePart = parts.find((part) => part.toLowerCase().startsWith("filename="));
if (!filenamePart) {
return null;
+2 -2
查看文件
@@ -215,11 +215,11 @@ export async function cancelPaymentBatch(batchId: string, reason: string) {
});
}
export async function downloadPaymentOrder(batchId: string, reason: string): Promise<ApiBlobResponse> {
export async function downloadPaymentOrder(batchId: string): Promise<ApiBlobResponse> {
return apiRequestBlob(`/api/payment-batches/${batchId}/payment-order`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reason }),
body: JSON.stringify({}),
});
}
+20 -20
查看文件
@@ -561,10 +561,10 @@ export function PaymentBatchesPage() {
});
const paymentOrderMutation = useMutation({
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) =>
downloadPaymentOrder(batchId, reason),
onSuccess: async (response) => {
downloadBlob(response.blob, response.filename ?? "payment-order.xls");
mutationFn: ({ batchId }: { batchId: string; batchNo: string }) =>
downloadPaymentOrder(batchId),
onSuccess: async (response, variables) => {
downloadBlob(response.blob, response.filename ?? `报销制单_${variables.batchNo.replace(/[^A-Za-z0-9-]/g, "_")}.xls`);
setReasonAction(null);
setActionReason("");
await refresh();
@@ -864,15 +864,6 @@ export function PaymentBatchesPage() {
) : null}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-processing-panel">
<h3 className="text-base font-semibold text-zinc-900">处理进度</h3>
<ProcessingProgress
progress={selectedBatch.processingProgress}
ariaLabel="付款批次处理进度"
testId="payment-batch-processing-progress"
/>
</div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
<h3 className="text-base font-semibold text-zinc-900">
{selectedBatch.entityType === "reimbursement" ? "本批次报销单" : "本批次差旅订单"}
@@ -1080,8 +1071,9 @@ export function PaymentBatchesPage() {
</div>
</BusinessDetailMain>
<BusinessDetailAside className="hidden lg:block">
<BusinessDetailAside>
<BusinessDetailActionPanel
className="lg:static"
status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />}
primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined}
moreActions={paymentActionModel.more.length ? (
@@ -1102,6 +1094,14 @@ export function PaymentBatchesPage() {
)}
testId="payment-batch-action-panel"
/>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-processing-panel">
<h3 className="text-base font-semibold text-zinc-900">处理进度</h3>
<ProcessingProgress
progress={selectedBatch.processingProgress}
ariaLabel="付款批次处理进度"
testId="payment-batch-processing-progress"
/>
</div>
</BusinessDetailAside>
</BusinessDetailWorkspace>
@@ -1316,20 +1316,20 @@ export function PaymentBatchesPage() {
</DialogHeader>
{reasonAction?.kind === "payeeBank" && revealedPayeeAccount ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedPayeeAccount}</div>
) : <div className="mt-4 space-y-2">
) : reasonAction?.kind !== "paymentOrder" ? <div className="mt-4 space-y-2">
<Textarea
value={actionReason}
onChange={(event) => setActionReason(event.target.value)}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "paymentOrder" ? "制单下载原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
rows={4}
maxLength={500}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "paymentOrder" ? "说明本次制单用途" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
/>
<div className="flex items-center justify-between text-xs text-zinc-500">
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
<span>{actionReason.trim().length}/500</span>
</div>
</div>}
</div> : null}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => {
setReasonAction(null);
@@ -1341,7 +1341,7 @@ export function PaymentBatchesPage() {
type="button"
variant={reasonAction?.kind === "paymentOrder" ? "default" : "destructive"}
disabled={
actionReason.trim().length < 2 ||
(reasonAction?.kind !== "paymentOrder" && actionReason.trim().length < 2) ||
cancelMutation.isPending ||
voidReceiptMutation.isPending ||
paymentOrderMutation.isPending ||
@@ -1353,7 +1353,7 @@ export function PaymentBatchesPage() {
if (reasonAction.kind === "cancel") {
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "paymentOrder") {
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, reason });
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, batchNo: reasonAction.batchNo });
} else if (reasonAction.kind === "payeeBank") {
revealPayeeBankMutation.mutate({ batchId: reasonAction.batchId, itemId: reasonAction.itemId, reason });
} else {
@@ -559,7 +559,7 @@ async function mockSystemActionApis(
}
if (!options.paymentOrderFile) throw new Error("合成制单文件未配置");
paymentOrderExportCount += 1;
await route.fulfill({ status: 200, contentType: "application/vnd.ms-excel", headers: { "content-disposition": "attachment; filename=payment-order-PAY-ACTION-001.xls" }, body: options.paymentOrderFile });
await route.fulfill({ status: 200, contentType: "application/vnd.ms-excel", headers: { "content-disposition": `attachment; filename=payment-order-PAY-ACTION-001.xls; filename*=UTF-8''${encodeURIComponent("报销制单_PAY-ACTION-001.xls")}` }, body: options.paymentOrderFile });
return;
}
@@ -838,18 +838,29 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel");
await expect(actions.getByTestId("payment-batch-action-downloadOrder")).toBeVisible();
const sideActions = page.getByTestId("payment-batch-action-panel");
const progress = page.getByTestId("payment-batch-processing-panel");
await expect(progress).toBeVisible();
expect(await progress.evaluate((element) => Boolean(element.previousElementSibling?.querySelector('[data-testid="payment-batch-action-panel"]')))).toBe(true);
const actionBounds = await sideActions.boundingBox();
const progressBounds = await progress.boundingBox();
expect(actionBounds).not.toBeNull();
expect(progressBounds).not.toBeNull();
expect(progressBounds!.y).toBeGreaterThanOrEqual(actionBounds!.y + actionBounds!.height);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath("before-payment-order.png"), fullPage: true });
await actions.getByTestId("payment-batch-action-downloadOrder").click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "确认下载" })).toBeDisabled();
await expect(dialog.getByRole("button", { name: "确认下载" })).toBeEnabled();
await expect(dialog.getByRole("textbox")).toHaveCount(0);
await dialog.getByRole("button", { name: "返回", exact: true }).click();
expect(calls.paymentOrderReasons).toEqual([]);
await actions.getByTestId("payment-batch-action-downloadOrder").click();
await dialog.getByRole("textbox", { name: "制单下载原因" }).fill("合成网银制单验证");
await expect(dialog).toContainText("文件包含完整银行账号");
const firstDownload = page.waitForEvent("download");
await dialog.getByRole("button", { name: "确认下载" }).click();
const downloaded = await firstDownload;
expect(downloaded.suggestedFilename()).toBe("payment-order-PAY-ACTION-001.xls");
expect(downloaded.suggestedFilename()).toBe("报销制单_PAY-ACTION-001.xls");
const downloadedPath = await downloaded.path();
if (!downloadedPath) throw new Error("未收到合成制单文件");
expect(readFileSync(downloadedPath).equals(paymentOrderFile)).toBe(true);
@@ -862,12 +873,12 @@ test.describe("R7E 全站高风险操作二次确认", () => {
if (mobile) await page.getByRole("menuitem", { name: "重新下载制单" }).click();
else await actions.getByTestId("payment-batch-action-redownloadOrder").click();
await expect(dialog).toContainText("请勿重复提交");
await dialog.getByRole("textbox", { name: "制单下载原因" }).fill("合成文件重新下载验证");
await expect(dialog.getByRole("textbox")).toHaveCount(0);
const secondDownload = page.waitForEvent("download");
await dialog.getByRole("button", { name: "确认下载" }).click();
await secondDownload;
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("已生成 2 次");
expect(calls.paymentOrderReasons).toEqual(["合成网银制单验证", "合成文件重新下载验证"]);
expect(calls.paymentOrderReasons).toEqual([undefined, undefined]);
expect(calls.executePaymentBatch).toBe(0);
expect(calls.uploadPaymentReceipt).toBe(0);
});
@@ -880,7 +891,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await page.getByTestId("payment-batch-action-panel").getByTestId("payment-batch-action-downloadOrder").click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox", { name: "制单下载原因" }).fill("合成制单失败验证");
await expect(dialog.getByRole("textbox")).toHaveCount(0);
await dialog.getByRole("button", { name: "确认下载" }).click();
await expect.poll(() => calls.paymentOrderReasons.length).toBe(1);
await expect(dialog.getByRole("button", { name: "确认下载" })).toBeEnabled();
@@ -906,9 +917,9 @@ test.describe("R7E 全站高风险操作二次确认", () => {
].join(",")).evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(sectionOrder).toEqual([
"payment-batch-detail-panel",
"payment-batch-processing-panel",
"payment-batch-items-panel",
"payment-batch-receipt-panel",
"payment-batch-processing-panel",
]);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("¥88.00");
@@ -921,7 +932,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
};
});
expect(responsiveContract.gridColumns.split(" ").filter(Boolean)).toHaveLength(2);
expect(responsiveContract.actionPosition).toBe("sticky");
expect(responsiveContract.actionPosition).toBe("static");
});
test("付款详情展示费用明细并支持回单在线查看,下载仍要求用途", async ({ page }) => {