feat(corporate-bank-account-payment-order): 基于原模板生成建行制单
这个提交包含在:
@@ -1,25 +1,8 @@
|
||||
const XLSX = require("xlsx");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const { decryptStringOrThrow } = require("./crypto");
|
||||
|
||||
const HEADERS = Object.freeze([
|
||||
"*序号",
|
||||
"*付款方客户账号",
|
||||
"*付款方账户名称",
|
||||
"*收款方行别代码\n(01-本行 02-国内他行)",
|
||||
"*收款方客户账号",
|
||||
"*收款方账户名称",
|
||||
"收款方开户行名称",
|
||||
"收款方联行号",
|
||||
"客户方流水号",
|
||||
"*金额",
|
||||
"*用途",
|
||||
"备注",
|
||||
"是否短信通知收款人(0-不通知,1-通知,默认为0-不通知。)",
|
||||
"收款人手机号码",
|
||||
"短信通知附加信息",
|
||||
]);
|
||||
|
||||
class PaymentOrderXlsError extends Error {
|
||||
constructor(code, details = {}) {
|
||||
super(code);
|
||||
@@ -55,10 +38,6 @@ function decryptRequired(config, value, code) {
|
||||
}
|
||||
}
|
||||
|
||||
function stringCell(value = "") {
|
||||
return { t: "s", v: String(value) };
|
||||
}
|
||||
|
||||
function buildPaymentOrderRows(config, batch) {
|
||||
const blockers = [];
|
||||
const addBlocker = (code, entityCode) => {
|
||||
@@ -86,6 +65,11 @@ function buildPaymentOrderRows(config, batch) {
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "payer_name_missing");
|
||||
}
|
||||
try {
|
||||
requiredText(payer.bankName, "payer_bank_name_missing");
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "payer_bank_name_missing");
|
||||
}
|
||||
}
|
||||
|
||||
const items = [...(batch.items ?? [])].sort((left, right) =>
|
||||
@@ -108,6 +92,7 @@ function buildPaymentOrderRows(config, batch) {
|
||||
}
|
||||
let payeeAccount = "";
|
||||
let payeeName = "";
|
||||
let payeeBankName = "";
|
||||
let entityCode = "";
|
||||
try {
|
||||
payeeAccount = decryptRequired(config, payee?.bankAccountEncrypted, "payee_account_unavailable");
|
||||
@@ -119,6 +104,11 @@ function buildPaymentOrderRows(config, batch) {
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "payee_name_missing", entityCodeHint);
|
||||
}
|
||||
try {
|
||||
payeeBankName = requiredText(payee?.bankName, "payee_bank_name_missing");
|
||||
} catch (error) {
|
||||
addBlocker(error.code ?? "payee_bank_name_missing", entityCodeHint);
|
||||
}
|
||||
try {
|
||||
entityCode = requiredText(item.entity_code, "entity_code_missing");
|
||||
} catch (error) {
|
||||
@@ -137,59 +127,90 @@ function buildPaymentOrderRows(config, batch) {
|
||||
addBlocker(error.code ?? "customer_serial_invalid", entityCodeHint);
|
||||
}
|
||||
if (Number.isFinite(amount) && amount > 0) totalAmount += amount;
|
||||
return { payee, payeeAccount, payeeName, entityCode, amount, serial };
|
||||
return { payeeAccount, payeeName, payeeBankName, entityCode, amount, serial };
|
||||
});
|
||||
|
||||
if (blockers.length) {
|
||||
throw new PaymentOrderXlsError(blockers[0].code, { blockers });
|
||||
}
|
||||
|
||||
const rows = preparedRows.map(({ payee, payeeAccount, payeeName, entityCode, amount, serial }, index) => {
|
||||
const rows = preparedRows.map(({ payeeAccount, payeeName, payeeBankName, entityCode, amount, serial }, index) => {
|
||||
return [
|
||||
stringCell(index + 1),
|
||||
stringCell(payerAccount),
|
||||
stringCell(payerName),
|
||||
stringCell("01"),
|
||||
stringCell(payeeAccount),
|
||||
stringCell(payeeName),
|
||||
stringCell(payee.bankName ?? "中国建设银行"),
|
||||
stringCell(""),
|
||||
stringCell(serial),
|
||||
{ t: "n", v: Number(amount.toFixed(2)), z: "0.00" },
|
||||
stringCell("报销款"),
|
||||
stringCell(entityCode),
|
||||
stringCell("0"),
|
||||
stringCell(""),
|
||||
stringCell(""),
|
||||
String(index + 1),
|
||||
payerAccount,
|
||||
payerName,
|
||||
"01",
|
||||
payeeAccount,
|
||||
payeeName,
|
||||
payeeBankName,
|
||||
"",
|
||||
serial,
|
||||
Number(amount.toFixed(2)),
|
||||
"报销款",
|
||||
entityCode,
|
||||
"0",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
});
|
||||
return { rows, totalAmount: totalAmount.toFixed(2), serials: [...serials] };
|
||||
}
|
||||
|
||||
function buildSheet(rows) {
|
||||
const sheet = {};
|
||||
const allRows = [HEADERS.map(stringCell), ...rows];
|
||||
for (let row = 0; row < allRows.length; row += 1) {
|
||||
for (let column = 0; column < HEADERS.length; column += 1) {
|
||||
sheet[XLSX.utils.encode_cell({ r: row, c: column })] = allRows[row][column] ?? stringCell("");
|
||||
}
|
||||
const PYTHON_ERROR_CODES = new Set([
|
||||
"runtime_dependency_unavailable",
|
||||
"template_unavailable",
|
||||
"template_hash_mismatch",
|
||||
"template_invalid",
|
||||
"template_structure_mismatch",
|
||||
"input_too_large",
|
||||
"input_invalid",
|
||||
"rows_invalid",
|
||||
"row_invalid",
|
||||
"amount_invalid",
|
||||
"text_cell_invalid",
|
||||
"fill_failed",
|
||||
"output_invalid",
|
||||
]);
|
||||
|
||||
function mapTemplateProcessError(result) {
|
||||
const stderr = Buffer.isBuffer(result?.stderr) ? result.stderr.toString("utf8") : String(result?.stderr ?? "");
|
||||
const matched = stderr.match(/^\[payment-order-template\]\[ERROR\] ([a-z_]+)\s*$/);
|
||||
const internalCode = matched && PYTHON_ERROR_CODES.has(matched[1]) ? matched[1] : "process_failed";
|
||||
if (internalCode === "runtime_dependency_unavailable" || result?.error?.code === "ENOENT") {
|
||||
return "payment_order_generator_unavailable";
|
||||
}
|
||||
sheet["!ref"] = XLSX.utils.encode_range({ r: 0, c: 0 }, { r: allRows.length - 1, c: HEADERS.length - 1 });
|
||||
return sheet;
|
||||
if (internalCode.startsWith("template_")) return "payment_order_template_unavailable";
|
||||
return "payment_order_generation_failed";
|
||||
}
|
||||
|
||||
function generatePaymentOrderXls(config, batch) {
|
||||
function generatePaymentOrderXls(config, batch, options = {}) {
|
||||
const { rows, totalAmount, serials } = buildPaymentOrderRows(config, batch);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, buildSheet(rows), "Sheet1");
|
||||
XLSX.utils.book_append_sheet(workbook, {}, "Sheet2");
|
||||
XLSX.utils.book_append_sheet(workbook, {}, "Sheet3");
|
||||
const buffer = XLSX.write(workbook, { bookType: "biff8", type: "buffer" });
|
||||
const pythonBin = options.pythonBin ?? process.env.PAYMENT_ORDER_PYTHON_BIN ?? "python3";
|
||||
const scriptPath = options.scriptPath ?? path.resolve(__dirname, "../scripts/fill-payment-order-template.py");
|
||||
const maxBuffer = options.maxBuffer ?? 16 * 1024 * 1024;
|
||||
const result = spawnSync(pythonBin, [scriptPath], {
|
||||
input: Buffer.from(JSON.stringify({ rows }), "utf8"),
|
||||
maxBuffer,
|
||||
timeout: options.timeout ?? 15_000,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
PATH: path.isAbsolute(pythonBin) ? path.dirname(pythonBin) : "/usr/local/bin:/usr/bin:/bin",
|
||||
LANG: "C.UTF-8",
|
||||
PYTHONDONTWRITEBYTECODE: "1",
|
||||
PYTHONNOUSERSITE: "1",
|
||||
},
|
||||
});
|
||||
if (result.status !== 0 || result.error || !Buffer.isBuffer(result.stdout)) {
|
||||
throw new PaymentOrderXlsError(mapTemplateProcessError(result));
|
||||
}
|
||||
const buffer = result.stdout;
|
||||
if (buffer.length > maxBuffer || buffer.subarray(0, 8).toString("hex") !== "d0cf11e0a1b11ae1") {
|
||||
throw new PaymentOrderXlsError("payment_order_generation_failed");
|
||||
}
|
||||
return { buffer, rowCount: rows.length, totalAmount, serials };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HEADERS,
|
||||
PaymentOrderXlsError,
|
||||
buildCustomerSerial,
|
||||
buildPaymentOrderRows,
|
||||
|
||||
在新工单中引用
屏蔽一个用户