const path = require("node:path"); const { spawnSync } = require("node:child_process"); const { decryptStringOrThrow } = require("./crypto"); class PaymentOrderXlsError extends Error { constructor(code, details = {}, diagnostic = null) { super(code); this.name = "PaymentOrderXlsError"; this.code = code; this.details = details; if (diagnostic) { Object.defineProperty(this, "diagnostic", { value: diagnostic, enumerable: false, }); } } } const PAYMENT_ORDER_RUNTIME_DIAGNOSTIC = "BIFF8 运行时不可用:请安装 server/requirements/payment-order-biff8.txt 中的固定依赖,并通过 PAYMENT_ORDER_PYTHON_BIN 指定 Python 可执行文件。"; function buildCustomerSerial(batchNo, rowNumber) { const serial = `${String(batchNo).trim()}-R${String(rowNumber).padStart(3, "0")}`; if (serial.length > 32) { throw new PaymentOrderXlsError("customer_serial_too_long"); } if (!/^[A-Z0-9-]+$/.test(serial)) { throw new PaymentOrderXlsError("customer_serial_invalid_characters"); } return serial; } function requiredText(value, code) { const normalized = typeof value === "string" ? value.trim() : ""; if (!normalized) throw new PaymentOrderXlsError(code); return normalized; } function decryptRequired(config, value, code) { try { return requiredText(value ? decryptStringOrThrow(config, value) : "", code); } catch (error) { if (error instanceof PaymentOrderXlsError) throw error; throw new PaymentOrderXlsError(code); } } function buildPaymentOrderRows(config, batch) { const blockers = []; const addBlocker = (code, entityCode) => { blockers.push({ code, ...(entityCode ? { entityCode: String(entityCode) } : {}) }); }; const payer = batch?.payment_account_snapshot_json; if (!payer || typeof payer !== "object" || Array.isArray(payer)) { addBlocker("payment_account_snapshot_missing"); } if (payer && !payer.bankInstitutionCode) { addBlocker("payer_bank_institution_missing"); } else if (payer && payer.bankInstitutionCode !== "CCB") { addBlocker("payer_bank_not_supported"); } let payerAccount = ""; let payerName = ""; if (payer) { try { payerAccount = decryptRequired(config, payer.accountNumberEncrypted, "payer_account_unavailable"); } catch (error) { addBlocker(error.code ?? "payer_account_unavailable"); } try { payerName = requiredText(payer.accountName, "payer_name_missing"); } 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) => String(left.entity_code).localeCompare(String(right.entity_code), "zh-CN") || String(left.id).localeCompare(String(right.id)), ); if (!items.length) addBlocker("items_missing"); const serials = new Set(); let totalAmount = 0; const preparedRows = items.map((item, index) => { const payee = item.snapshot_json?.payee; const entityCodeHint = typeof item.entity_code === "string" && item.entity_code.trim() ? item.entity_code.trim() : undefined; if (!payee?.bankInstitutionCode) { addBlocker("payee_bank_institution_missing", entityCodeHint); } else if (payee.bankInstitutionCode !== "CCB") { addBlocker("payee_bank_not_supported", entityCodeHint); } let payeeAccount = ""; let payeeName = ""; let payeeBankName = ""; let entityCode = ""; try { payeeAccount = decryptRequired(config, payee?.bankAccountEncrypted, "payee_account_unavailable"); } catch (error) { addBlocker(error.code ?? "payee_account_unavailable", entityCodeHint); } try { payeeName = batch.entity_type === "loan_repayment" ? decryptRequired(config, payee?.accountNameEncrypted, "payee_name_missing") : requiredText(payee?.name, "payee_name_missing"); } 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) { addBlocker(error.code ?? "entity_code_missing"); } const amount = Number(item.amount); if (!Number.isFinite(amount) || amount <= 0) { addBlocker("amount_invalid", entityCodeHint); } let serial = ""; try { serial = buildCustomerSerial(batch.batch_no, index + 1); if (serials.has(serial)) addBlocker("customer_serial_duplicate", entityCodeHint); serials.add(serial); } catch (error) { addBlocker(error.code ?? "customer_serial_invalid", entityCodeHint); } if (Number.isFinite(amount) && amount > 0) totalAmount += amount; return { payeeAccount, payeeName, payeeBankName, entityCode, amount, serial }; }); if (blockers.length) { throw new PaymentOrderXlsError(blockers[0].code, { blockers }); } const rows = preparedRows.map(({ payeeAccount, payeeName, payeeBankName, entityCode, amount, serial }, index) => { return [ String(index + 1), payerAccount, payerName, "01", payeeAccount, payeeName, payeeBankName, "", serial, Number(amount.toFixed(2)), batch.entity_type === "loan_repayment" ? "还款" : "报销款", batch.entity_type === "loan_repayment" ? "归还借款本金" : `报销款 ${entityCode}`, "0", "", "", ]; }); return { rows, totalAmount: totalAmount.toFixed(2), serials: [...serials] }; } 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 { code: "payment_order_generator_unavailable", diagnostic: PAYMENT_ORDER_RUNTIME_DIAGNOSTIC, }; } if (internalCode.startsWith("template_")) return { code: "payment_order_template_unavailable" }; return { code: "payment_order_generation_failed" }; } function generatePaymentOrderXls(config, batch, options = {}) { const { rows, totalAmount, serials } = buildPaymentOrderRows(config, batch); 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)) { const mappedError = mapTemplateProcessError(result); throw new PaymentOrderXlsError(mappedError.code, {}, mappedError.diagnostic); } 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 = { PaymentOrderXlsError, buildCustomerSerial, buildPaymentOrderRows, generatePaymentOrderXls, };