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 行删除
@@ -2559,7 +2559,7 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("exports a reviewed CCB batch as BIFF8 and records the first export", async () => {
it.each([{}, { reason: "导入企业网银" }])("exports BIFF8 without requiring a reason and preserves legacy input: %j", async (payload) => {
const payerEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
`6217${"0".repeat(8)}1234`,
@@ -2603,14 +2603,28 @@ describe("payment batch reimbursement routes", () => {
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: { reason: "导入企业网银" },
payload,
});
expect(response.statusCode).toBe(200);
expect(response.rawPayload.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
expect(response.headers["cache-control"]).toBe("no-store");
expect(decodeURIComponent(response.headers["content-disposition"])).toContain("报销制单_");
expect(state.batch.payment_order_export_count).toBe(1);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED", extra: expect.objectContaining({ reason: "导入企业网银", exportCount: 1 }) }),
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED", extra: expect.objectContaining({ purpose: "reimbursement_bank_transfer", purposeSource: "system", exportCount: 1, ...payload }) }),
});
const firstAudit = tx.auditLog.create.mock.calls.find(([args]) => args.data.action === "PAYMENT_ORDER_EXPORTED")[0].data.extra;
if (!payload.reason) expect(firstAudit).not.toHaveProperty("reason");
const repeated = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: {},
});
expect(repeated.statusCode).toBe(200);
expect(state.batch.payment_order_export_count).toBe(2);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "PAYMENT_ORDER_REEXPORTED", extra: expect.objectContaining({ purposeSource: "system", exportCount: 2 }) }),
});
await app.close();
});
@@ -2713,6 +2727,31 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it.each([{ reason: "" }, { reason: "x" }, { purposeSource: "user" }])("rejects invalid optional download input: %j", async (payload) => {
const { app, tx } = await buildApp({
actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.manage", "payment.order.export"], roleCodes: ["finance"] },
});
const response = await app.inject({
method: "POST", url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" }, payload,
});
expect(response.statusCode).toBe(400);
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
await app.close();
});
it.each(["cancel", `items/${ITEM_ID}/payee-bank/reveal`])("still requires reasons for %s", async (action) => {
const { app } = await buildApp({
actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["*"], roleCodes: ["finance"] },
});
const response = await app.inject({
method: "POST", url: `/api/payment-batches/${BATCH_ID}/${action}`,
headers: { authorization: "Bearer test" }, payload: {},
});
expect(response.statusCode).toBe(400);
await app.close();
});
it.each(["draft", "failed", "cancelled", "executed"])(
"rejects direct payment-order export while the batch is %s",
async (status) => {
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { apiRequestBlob } from "../../../../client/src/api/http";
afterEach(() => vi.unstubAllGlobals());
describe("browser download Content-Disposition filenames", () => {
it.each([
["attachment; filename=legacy.xls; filename*=UTF-8''%E6%8A%A5%E9%94%80%E5%88%B6%E5%8D%95_PB-001.xls", "报销制单_PB-001.xls"],
["attachment; FILENAME*=utf-8'zh'%E6%8A%A5%E9%94%80.xls; filename=legacy.xls", "报销.xls"],
['attachment; filename="receipt.pdf"', "receipt.pdf"],
["attachment; filename*=UTF-8''%broken; filename=legacy.xls", "legacy.xls"],
["attachment; filename*=ISO-8859-1''abc; filename=legacy.xls", "legacy.xls"],
["attachment; filename*=UTF-8''; filename=legacy.xls", "legacy.xls"],
["attachment", null],
])("resolves %s", async (header, filename) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("synthetic", {
status: 200, headers: { "content-disposition": header },
})));
const response = await apiRequestBlob("/synthetic-download", { auth: false });
expect(response.filename).toBe(filename);
expect(await response.blob.text()).toBe("synthetic");
});
});