文件
hyb-oa/server/src/services/paymentOrderXls.js
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

234 行
8.1 KiB
JavaScript

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,
};