汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
234 行
8.1 KiB
JavaScript
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,
|
|
};
|