feat(corporate-bank-account-payment-order): 基于原模板生成建行制单

这个提交包含在:
2026-08-03 01:18:10 +08:00
父节点 91564d6774
当前提交 23c3768c46
共修改 19 个文件,包含 713 行新增和 152 行删除
二进制文件未显示。
+9 -6
查看文件
@@ -718,12 +718,12 @@ function paymentOrderExportBlockers(batch) {
const add = (code, entityCode) => blockers.push({ code, ...(entityCode ? { entityCode } : {}) });
if (batch.status !== "reviewed") add("status_not_reviewed");
if (batch.entity_type !== "reimbursement") add("reimbursement_only");
if (batch.default_payment_channel !== "bank") add("bank_channel_required");
if (!batch.payment_account_snapshot_json) {
add("legacy_payment_account_snapshot_missing");
} else {
const payer = readPaymentAccountSnapshot(batch.payment_account_snapshot_json);
if (!payer) add("payment_account_snapshot_invalid");
if (!batch.payment_account_snapshot_json.accountNumberEncrypted) add("payer_account_unavailable");
if (payer && !payer.bankInstitutionCode) add("payer_bank_institution_missing");
else if (payer && payer.bankInstitutionCode !== "CCB") add("payer_bank_not_supported");
}
@@ -731,9 +731,14 @@ function paymentOrderExportBlockers(batch) {
for (const item of batch.items ?? []) {
const entityCode = normalizeSnapshotText(item.entity_code) ?? undefined;
const payee = item.snapshot_json?.payee;
if (resolveItemPaymentChannel(batch, item) !== "bank") add("bank_channel_required", entityCode);
if (!payee?.bankAccountEncrypted) add("legacy_payee_snapshot_missing", entityCode);
if (!payee?.bankInstitutionCode) add("payee_bank_institution_missing", entityCode);
else if (payee.bankInstitutionCode !== "CCB") add("payee_bank_not_supported", entityCode);
const safePayee = readSnapshotPayee(item.snapshot_json);
if (!safePayee?.name) add("payee_name_missing", entityCode);
if (!safePayee?.bankName) add("payee_bank_name_missing", entityCode);
if (!safePayee?.bankAccountMasked) add("payee_account_masked_missing", entityCode);
}
return blockers;
}
@@ -1581,11 +1586,9 @@ async function paymentBatchesRoutes(fastify) {
result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (batch.status !== "reviewed") {
throw buildValidationError({ status: "payment_order_requires_reviewed_batch" }, 409);
}
if (batch.entity_type !== "reimbursement") {
throw buildValidationError({ entityType: "payment_order_reimbursement_only" }, 409);
const blockers = paymentOrderExportBlockers(batch);
if (blockers.length) {
throw new PaymentOrderXlsError(blockers[0].code, { blockers });
}
const generated = generatePaymentOrderXls(fastify.config, batch);
const exportCount = Number(batch.payment_order_export_count ?? 0) + 1;
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Fill the fixed blank CCB BIFF8 template without exposing row data."""
from __future__ import annotations
import copy
import hashlib
import io
import json
import math
from pathlib import Path
import sys
try:
import xlrd
from xlutils.filter import process, XLRDReader, XLWTWriter
except ImportError:
sys.stderr.write("[payment-order-template][ERROR] runtime_dependency_unavailable\n")
raise SystemExit(70)
TEMPLATE_SHA256 = "72e83ab6b7cd71263ca00196746de896e02a02390bab5c896dabd76421e40c0e"
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "assets" / "payment-order" / "ccb-payment-order-template.xls"
EXPECTED_SHEETS = ["Sheet1", "Sheet2", "Sheet3"]
COLUMN_COUNT = 15
TEMPLATE_DATA_ROW = 1
MAX_INPUT_BYTES = 2 * 1024 * 1024
MAX_ROWS = 5000
class TemplateFillError(Exception):
def __init__(self, code: str):
super().__init__(code)
self.code = code
def read_payload() -> dict:
payload_bytes = sys.stdin.buffer.read(MAX_INPUT_BYTES + 1)
if len(payload_bytes) > MAX_INPUT_BYTES:
raise TemplateFillError("input_too_large")
try:
payload = json.loads(payload_bytes.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise TemplateFillError("input_invalid") from None
if not isinstance(payload, dict) or set(payload) != {"rows"}:
raise TemplateFillError("input_invalid")
rows = payload.get("rows")
if not isinstance(rows, list) or not rows or len(rows) > MAX_ROWS:
raise TemplateFillError("rows_invalid")
for row in rows:
if not isinstance(row, list) or len(row) != COLUMN_COUNT:
raise TemplateFillError("row_invalid")
for column, value in enumerate(row):
if column == 9:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
raise TemplateFillError("amount_invalid")
elif not isinstance(value, str):
raise TemplateFillError("text_cell_invalid")
return payload
def read_template() -> tuple[bytes, xlrd.book.Book]:
try:
template_bytes = TEMPLATE_PATH.read_bytes()
except OSError:
raise TemplateFillError("template_unavailable") from None
if hashlib.sha256(template_bytes).hexdigest() != TEMPLATE_SHA256:
raise TemplateFillError("template_hash_mismatch")
try:
book = xlrd.open_workbook(file_contents=template_bytes, formatting_info=True)
except Exception:
raise TemplateFillError("template_invalid") from None
if book.sheet_names() != EXPECTED_SHEETS:
raise TemplateFillError("template_structure_mismatch")
sheet = book.sheet_by_index(0)
if sheet.nrows < 4 or sheet.ncols != COLUMN_COUNT:
raise TemplateFillError("template_structure_mismatch")
return template_bytes, book
def fill_workbook(rows: list[list[object]], source_book: xlrd.book.Book) -> bytes:
writer = XLWTWriter()
try:
process(XLRDReader(source_book, "ccb-payment-order-template.xls"), writer)
target_book = writer.output[0][1]
source_sheet = source_book.sheet_by_index(0)
target_sheet = target_book.get_sheet(0)
target_sheet._cell_overwrite_ok = True
source_row_info = source_sheet.rowinfo_map.get(TEMPLATE_DATA_ROW)
source_row_height = source_row_info.height if source_row_info else 735
source_row_height_mismatch = source_row_info.height_mismatch if source_row_info else False
for row_offset, values in enumerate(rows):
target_row_index = row_offset + 1
target_row = target_sheet.row(target_row_index)
target_row.height = source_row_height
target_row.height_mismatch = source_row_height_mismatch
for column, value in enumerate(values):
style_index = source_sheet.cell_xf_index(TEMPLATE_DATA_ROW, column)
style = writer.style_list[style_index]
if column == 9:
style = copy.deepcopy(style)
style.num_format_str = "0.00"
target_sheet.write(target_row_index, column, value, style)
output = io.BytesIO()
target_book.save(output)
return output.getvalue()
except TemplateFillError:
raise
except Exception:
raise TemplateFillError("fill_failed") from None
def main() -> int:
try:
payload = read_payload()
_, source_book = read_template()
output = fill_workbook(payload["rows"], source_book)
if not output.startswith(bytes.fromhex("d0cf11e0a1b11ae1")):
raise TemplateFillError("output_invalid")
sys.stdout.buffer.write(output)
return 0
except TemplateFillError as error:
sys.stderr.write(f"[payment-order-template][ERROR] {error.code}\n")
return 70
if __name__ == "__main__":
raise SystemExit(main())
+77 -56
查看文件
@@ -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,