汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
300 行
14 KiB
JavaScript
300 行
14 KiB
JavaScript
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { spawnSync } = require("node:child_process");
|
|
|
|
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
|
const {
|
|
PaymentOrderXlsError,
|
|
buildCustomerSerial,
|
|
buildPaymentOrderRows,
|
|
generatePaymentOrderXls,
|
|
} = require("../../../src/services/paymentOrderXls");
|
|
|
|
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
|
|
const encrypt = (value) => encryptStringOrThrow(config, value);
|
|
|
|
function buildBatch(overrides = {}) {
|
|
return {
|
|
batch_no: "PB-20260802-002",
|
|
payment_account_snapshot_json: {
|
|
bankInstitutionCode: "CCB",
|
|
accountName: "合成测试公司",
|
|
bankName: "中国建设银行",
|
|
accountNumberEncrypted: encrypt("6217000000000012345"),
|
|
},
|
|
items: [
|
|
{
|
|
id: "b",
|
|
entity_code: "BX-20260802-0002",
|
|
amount: "2.00",
|
|
snapshot_json: { payee: { name: "乙", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000056789") } },
|
|
},
|
|
{
|
|
id: "a",
|
|
entity_code: "BX-20260802-0001",
|
|
amount: "1.20",
|
|
snapshot_json: { payee: { name: "甲", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000012345") } },
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("payment order BIFF8 generator", () => {
|
|
it("keeps reimbursement-purpose remarks and separate transfers for the same frozen payee account", () => {
|
|
const batch = buildBatch();
|
|
for (const item of batch.items) {
|
|
item.snapshot_json.payee.name = " 合成收款人 ";
|
|
item.snapshot_json.payee.bankAccountEncrypted = batch.items[0].snapshot_json.payee.bankAccountEncrypted;
|
|
item.snapshot_json.submitter = { name: "合成提交人" };
|
|
item.owner = { name: "已改名归属人" };
|
|
}
|
|
const result = buildPaymentOrderRows(config, batch);
|
|
expect(result.rows.map((row) => row.slice(10, 12))).toEqual([
|
|
["报销款", "报销款 BX-20260802-0001"],
|
|
["报销款", "报销款 BX-20260802-0002"],
|
|
]);
|
|
expect(result.rows.map((row) => row[5])).toEqual(["合成收款人", "合成收款人"]);
|
|
expect(result.rows[0][4]).toBe(result.rows[1][4]);
|
|
expect(result.rows.map((row) => row[9])).toEqual([1.2, 2]);
|
|
expect(result.serials).toEqual(["PB-20260802-002-R001", "PB-20260802-002-R002"]);
|
|
expect(result.totalAmount).toBe("3.20");
|
|
});
|
|
|
|
it("exports principal repayment as one transfer with decrypted account name and repayment purpose", () => {
|
|
const batch = buildBatch({ entity_type: "loan_repayment" });
|
|
batch.items = [batch.items[0]];
|
|
batch.items[0].entity_code = "HK-SYNTHETIC";
|
|
batch.items[0].snapshot_json.payee.name = "合***";
|
|
batch.items[0].snapshot_json.payee.accountNameEncrypted = encrypt("合成出借人");
|
|
const row = buildPaymentOrderRows(config, batch).rows[0];
|
|
expect(row[5]).toBe("合成出借人");
|
|
expect(row.slice(10, 12)).toEqual(["还款", "归还借款本金"]);
|
|
batch.items[0].snapshot_json.payee.accountNameEncrypted = "invalid";
|
|
expect(() => buildPaymentOrderRows(config, batch)).toThrow();
|
|
});
|
|
|
|
it("preserves complete names and document codes without truncation", () => {
|
|
const batch = buildBatch();
|
|
const name = "合成姓名".repeat(20);
|
|
const code = "BX-20260802-000123456789";
|
|
batch.items = [batch.items[0]];
|
|
batch.items[0].snapshot_json.payee.name = ` ${name} `;
|
|
batch.items[0].entity_code = ` ${code} `;
|
|
const row = buildPaymentOrderRows(config, batch).rows[0];
|
|
expect(row[5]).toBe(name);
|
|
expect(row[11]).toBe(`报销款 ${code}`);
|
|
});
|
|
|
|
it.each([
|
|
["payee_name_missing", (item) => { item.snapshot_json.payee.name = " "; }],
|
|
["entity_code_missing", (item) => { item.entity_code = " "; }],
|
|
])("rejects the entire export for %s", (code, invalidate) => {
|
|
const batch = buildBatch();
|
|
invalidate(batch.items[0]);
|
|
expect(() => buildPaymentOrderRows(config, batch)).toThrow(expect.objectContaining({ code }));
|
|
});
|
|
|
|
it("generates a BIFF8 workbook with stable rows, totals and customer serials", () => {
|
|
const first = generatePaymentOrderXls(config, buildBatch());
|
|
const second = generatePaymentOrderXls(config, buildBatch());
|
|
|
|
expect(first.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
|
|
expect(first.rowCount).toBe(2);
|
|
expect(first.serials).toEqual(second.serials);
|
|
expect(first.totalAmount).toBe("3.20");
|
|
});
|
|
|
|
it("round-trips the bank template with exact text accounts, headers and decimal amounts", () => {
|
|
const batch = buildBatch();
|
|
const payerAccount = "000123456789012345678";
|
|
const payeeAccount = "000987654321098765432";
|
|
batch.payment_account_snapshot_json.accountNumberEncrypted = encrypt(payerAccount);
|
|
batch.items[1].snapshot_json.payee.bankAccountEncrypted = encrypt(payeeAccount);
|
|
const generated = generatePaymentOrderXls(config, batch);
|
|
const templatePath = path.resolve(__dirname, "../../../src/assets/payment-order/ccb-payment-order-template.xls");
|
|
const readback = spawnSync(process.env.PAYMENT_ORDER_PYTHON_BIN ?? "python3", ["-c", [
|
|
"import json,sys,xlrd",
|
|
"book=xlrd.open_workbook(file_contents=sys.stdin.buffer.read(),formatting_info=True)",
|
|
"template=xlrd.open_workbook(sys.argv[1],formatting_info=True)",
|
|
"sheet=book.sheet_by_index(0)",
|
|
"source=template.sheet_by_index(0)",
|
|
"rows=[row for row in range(1,sheet.nrows) if sheet.cell_value(row,0) != '']",
|
|
"print(json.dumps({'biff':book.biff_version,'sheets':book.sheet_names(),'columns':sheet.ncols,'headerMatches':sheet.row_values(0)==source.row_values(0),'widthsMatch':all(sheet.computed_column_width(col)==source.computed_column_width(col) for col in range(15)),'rows':[sheet.row_values(row) for row in rows],'textTypes':[[sheet.cell_type(row,col) for col in [1,3,4,8,10,11]] for row in rows],'amountFormats':[book.format_map[book.xf_list[sheet.cell_xf_index(row,9)].format_key].format_str for row in rows]}))",
|
|
].join("\n"), templatePath], { input: generated.buffer, maxBuffer: 1024 * 1024 });
|
|
expect(readback.stderr.toString("utf8")).toBe("");
|
|
expect(readback.status).toBe(0);
|
|
const parsed = JSON.parse(readback.stdout.toString("utf8"));
|
|
expect(parsed).toMatchObject({
|
|
biff: 80,
|
|
sheets: ["Sheet1", "Sheet2", "Sheet3"],
|
|
columns: 15,
|
|
headerMatches: true,
|
|
widthsMatch: true,
|
|
textTypes: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
|
|
amountFormats: ["0.00", "0.00"],
|
|
});
|
|
expect(parsed.rows).toHaveLength(2);
|
|
expect(parsed.rows[0]).toEqual([
|
|
"1", payerAccount, "合成测试公司", "01", payeeAccount, "甲", "中国建设银行", "",
|
|
"PB-20260802-002-R001", 1.2, "报销款", "报销款 BX-20260802-0001", "0", "", "",
|
|
]);
|
|
expect(parsed.rows[1][9]).toBe(2);
|
|
expect(parsed.rows[1].slice(10, 12)).toEqual(["报销款", "报销款 BX-20260802-0002"]);
|
|
});
|
|
|
|
it("fails the whole export and reports every blocking payee issue", () => {
|
|
const batch = buildBatch();
|
|
batch.items[1].snapshot_json.payee.bankInstitutionCode = "OTHER";
|
|
batch.items[0].snapshot_json.payee.bankAccountEncrypted = null;
|
|
try {
|
|
generatePaymentOrderXls(config, batch);
|
|
throw new Error("expected payment order generation to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(PaymentOrderXlsError);
|
|
expect(error.code).toBe("payee_bank_not_supported");
|
|
expect(error.details.blockers).toEqual(expect.arrayContaining([
|
|
{ code: "payee_bank_not_supported", entityCode: "BX-20260802-0001" },
|
|
{ code: "payee_account_unavailable", entityCode: "BX-20260802-0002" },
|
|
]));
|
|
}
|
|
});
|
|
|
|
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-R001");
|
|
expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError);
|
|
expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError);
|
|
});
|
|
|
|
it("fails closed when the fixed Python runtime is unavailable", () => {
|
|
let caught;
|
|
try {
|
|
generatePaymentOrderXls(config, buildBatch(), {
|
|
pythonBin: "/definitely-missing/payment-order-python",
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toBeInstanceOf(PaymentOrderXlsError);
|
|
expect(caught).toMatchObject({ code: "payment_order_generator_unavailable" });
|
|
expect(caught.diagnostic).toContain("server/requirements/payment-order-biff8.txt");
|
|
expect(caught.diagnostic).toContain("PAYMENT_ORDER_PYTHON_BIN");
|
|
expect(JSON.stringify(caught)).not.toContain("payment-order-biff8.txt");
|
|
});
|
|
|
|
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 },
|
|
);
|
|
let caught;
|
|
try {
|
|
generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython });
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toMatchObject({ code: "payment_order_generator_unavailable" });
|
|
expect(caught.diagnostic).toContain("server/requirements/payment-order-biff8.txt");
|
|
expect(JSON.stringify(caught)).not.toContain("PAYMENT_ORDER_PYTHON_BIN");
|
|
} 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 });
|
|
}
|
|
});
|
|
});
|
|
|
|
it("keeps customer serial and business row identity with an annual PB and mixed source formats", () => {
|
|
const batch = buildBatch({ batch_no: "PB-2026-0001" });
|
|
batch.items[0].entity_code = "BX-202609-0001";
|
|
const original = structuredClone(batch);
|
|
const rows = buildPaymentOrderRows(config, batch);
|
|
expect(rows.serials).toEqual(["PB-2026-0001-R001", "PB-2026-0001-R002"]);
|
|
const reversed = buildPaymentOrderRows(config, { ...batch, items: [...batch.items].reverse() });
|
|
expect(reversed).toEqual(rows);
|
|
expect(batch).toEqual(original);
|
|
const workbook = generatePaymentOrderXls(config, batch);
|
|
expect(workbook.serials).toEqual(rows.serials);
|
|
expect(workbook.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
|
|
});
|