fix: 优化报销制单下载与付款进度(09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -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;
|
||||
|
||||
@@ -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({}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户