feat(corporate-bank-account-payment-order): 基于原模板生成建行制单
这个提交包含在:
@@ -2382,6 +2382,57 @@ describe("payment batch reimbursement routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not let an admin without a finance business role export a payment order", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["*"],
|
||||
roleCodes: ["admin"],
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "权限负向验证" },
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED" }),
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each(["draft", "failed", "cancelled", "executed"])(
|
||||
"rejects direct payment-order export while the batch is %s",
|
||||
async (status) => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "payment.order.export"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: { status },
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "状态门禁验证" },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("status_not_reviewed");
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }),
|
||||
});
|
||||
await app.close();
|
||||
},
|
||||
);
|
||||
|
||||
it("permanently rejects payment-order export for a legacy batch without frozen accounts", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
@@ -2399,11 +2450,72 @@ describe("payment batch reimbursement routes", () => {
|
||||
payload: { reason: "旧批次尝试" },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("payment_account_snapshot_missing");
|
||||
expect(response.json().error.details.reason).toBe("legacy_payment_account_snapshot_missing");
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects direct payment-order export when an item effective channel is not bank", async () => {
|
||||
const payerEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
`6217${"0".repeat(8)}1234`,
|
||||
);
|
||||
const payeeEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
);
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "payment.order.export"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: {
|
||||
status: "reviewed",
|
||||
default_payment_channel: "bank",
|
||||
payment_account_snapshot_json: {
|
||||
accountName: "合成测试公司",
|
||||
bankName: "中国建设银行",
|
||||
bankInstitutionCode: "CCB",
|
||||
accountNumberMasked: "6217********1234",
|
||||
accountNumberEncrypted: payerEncrypted,
|
||||
},
|
||||
items: buildPaymentBatch().items.map((item) => ({
|
||||
...item,
|
||||
payment_channel_override: "cash",
|
||||
snapshot_json: {
|
||||
payee: {
|
||||
name: "测试员工",
|
||||
bankName: "中国建设银行",
|
||||
bankInstitutionCode: "CCB",
|
||||
bankAccountMasked: "6222********5678",
|
||||
bankAccountEncrypted: payeeEncrypted,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "验证付款渠道门禁" },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||
reason: "bank_channel_required",
|
||||
blockers: expect.arrayContaining([
|
||||
{ code: "bank_channel_required", entityCode: "BX-20260429-0001" },
|
||||
]),
|
||||
}));
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }),
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("reveals the frozen payee account to finance only after a reason and audits the view", async () => {
|
||||
const payeeEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const XLSX = require("xlsx");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const {
|
||||
HEADERS,
|
||||
PaymentOrderXlsError,
|
||||
buildCustomerSerial,
|
||||
generatePaymentOrderXls,
|
||||
@@ -18,6 +19,7 @@ function buildBatch(overrides = {}) {
|
||||
payment_account_snapshot_json: {
|
||||
bankInstitutionCode: "CCB",
|
||||
accountName: "合成测试公司",
|
||||
bankName: "中国建设银行",
|
||||
accountNumberEncrypted: encrypt("6217000000000012345"),
|
||||
},
|
||||
items: [
|
||||
@@ -39,21 +41,12 @@ function buildBatch(overrides = {}) {
|
||||
}
|
||||
|
||||
describe("payment order BIFF8 generator", () => {
|
||||
it("writes the fixed workbook contract with typed string identifiers and stable ordering", () => {
|
||||
it("generates a BIFF8 workbook with stable rows, totals and customer serials", () => {
|
||||
const first = generatePaymentOrderXls(config, buildBatch());
|
||||
const second = generatePaymentOrderXls(config, buildBatch());
|
||||
const workbook = XLSX.read(first.buffer, { type: "buffer", cellNF: true });
|
||||
const sheet = workbook.Sheets.Sheet1;
|
||||
|
||||
expect(first.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
|
||||
expect(workbook.SheetNames).toEqual(["Sheet1", "Sheet2", "Sheet3"]);
|
||||
expect(XLSX.utils.sheet_to_json(sheet, { header: 1 })[0]).toEqual(HEADERS);
|
||||
expect(sheet.B2).toMatchObject({ t: "s", v: "6217000000000012345" });
|
||||
expect(sheet.D2).toMatchObject({ t: "s", v: "01" });
|
||||
expect(sheet.E2).toMatchObject({ t: "s", v: "6222000000000012345" });
|
||||
expect(sheet.I2).toMatchObject({ t: "s", v: "PB-20260802-002-001" });
|
||||
expect(sheet.J2).toMatchObject({ t: "n", v: 1.2 });
|
||||
expect(sheet.M2).toMatchObject({ t: "s", v: "0" });
|
||||
expect(first.rowCount).toBe(2);
|
||||
expect(first.serials).toEqual(second.serials);
|
||||
expect(first.totalAmount).toBe("3.20");
|
||||
});
|
||||
@@ -75,9 +68,107 @@ describe("payment order BIFF8 generator", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not invent a bank display name when the frozen snapshot is incomplete", () => {
|
||||
const batch = buildBatch();
|
||||
delete batch.items[0].snapshot_json.payee.bankName;
|
||||
expect(() => generatePaymentOrderXls(config, batch)).toThrow(expect.objectContaining({
|
||||
code: "payee_bank_name_missing",
|
||||
details: {
|
||||
blockers: expect.arrayContaining([
|
||||
{ code: "payee_bank_name_missing", entityCode: "BX-20260802-0002" },
|
||||
]),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("validates customer serial length and character set without truncation", () => {
|
||||
expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-001");
|
||||
expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError);
|
||||
expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError);
|
||||
});
|
||||
|
||||
it("fails closed when the fixed Python runtime is unavailable", () => {
|
||||
expect(() => generatePaymentOrderXls(config, buildBatch(), {
|
||||
pythonBin: "/definitely-missing/payment-order-python",
|
||||
})).toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" }));
|
||||
});
|
||||
|
||||
it("fails closed when the fixed Python dependencies are unavailable", () => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-dependency-test-"));
|
||||
try {
|
||||
const fakePython = path.join(temporaryRoot, "python");
|
||||
fs.writeFileSync(
|
||||
fakePython,
|
||||
"#!/bin/sh\nprintf '%s\\n' '[payment-order-template][ERROR] runtime_dependency_unavailable' >&2\nexit 70\n",
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython }))
|
||||
.toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" }));
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when the controlled template hash drifts", () => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-template-test-"));
|
||||
try {
|
||||
const scriptDirectory = path.join(temporaryRoot, "src", "scripts");
|
||||
const assetDirectory = path.join(temporaryRoot, "src", "assets", "payment-order");
|
||||
fs.mkdirSync(scriptDirectory, { recursive: true });
|
||||
fs.mkdirSync(assetDirectory, { recursive: true });
|
||||
const scriptPath = path.resolve(__dirname, "../../../src/scripts/fill-payment-order-template.py");
|
||||
const templatePath = path.resolve(__dirname, "../../../src/assets/payment-order/ccb-payment-order-template.xls");
|
||||
const copiedScriptPath = path.join(scriptDirectory, "fill-payment-order-template.py");
|
||||
const copiedTemplatePath = path.join(assetDirectory, "ccb-payment-order-template.xls");
|
||||
fs.copyFileSync(scriptPath, copiedScriptPath);
|
||||
const template = fs.readFileSync(templatePath);
|
||||
template[template.length - 1] ^= 1;
|
||||
fs.writeFileSync(copiedTemplatePath, template);
|
||||
|
||||
expect(() => generatePaymentOrderXls(config, buildBatch(), {
|
||||
scriptPath: copiedScriptPath,
|
||||
})).toThrow(expect.objectContaining({ code: "payment_order_template_unavailable" }));
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not copy child stderr or account payload into the exposed error", () => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-stderr-test-"));
|
||||
try {
|
||||
const fakePython = path.join(temporaryRoot, "python");
|
||||
fs.writeFileSync(fakePython, "#!/bin/sh\ntee /dev/stderr >/dev/null\nexit 70\n", { mode: 0o700 });
|
||||
let caught;
|
||||
try {
|
||||
generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PaymentOrderXlsError);
|
||||
expect(caught.code).toBe("payment_order_generation_failed");
|
||||
expect(JSON.stringify(caught)).not.toContain("6217000000000012345");
|
||||
expect(JSON.stringify(caught)).not.toContain("6222000000000012345");
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["timeout", "#!/bin/sh\nwhile :; do :; done\n", { timeout: 20 }],
|
||||
[
|
||||
"oversized output",
|
||||
"#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do printf X; i=$((i + 1)); done\n",
|
||||
{ maxBuffer: 1024 },
|
||||
],
|
||||
])("fails closed on child %s", (_label, script, options) => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-process-test-"));
|
||||
try {
|
||||
const fakePython = path.join(temporaryRoot, "python");
|
||||
fs.writeFileSync(fakePython, script, { mode: 0o700 });
|
||||
expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython, ...options }))
|
||||
.toThrow(expect.objectContaining({ code: "payment_order_generation_failed" }));
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户