feat(corporate-bank-account-payment-order): 基于原模板生成建行制单
这个提交包含在:
-13
@@ -41,7 +41,6 @@
|
||||
"prom-client": "^15.1.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
@@ -9351,18 +9350,6 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"prom-client": "^15.1.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
xlrd==2.0.2 --hash=sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9
|
||||
xlwt==1.3.0 --hash=sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e
|
||||
xlutils==2.0.0 --hash=sha256:b56640862c030e9d53104e7f1d750135fdfabf6bf55e425e5ac40fdee9dbaeb9
|
||||
@@ -1,42 +1,59 @@
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const { encryptStringOrThrow } = require("../src/services/crypto");
|
||||
const { generatePaymentOrderXls } = require("../src/services/paymentOrderXls");
|
||||
|
||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-xls-"));
|
||||
const outputPath = path.join(temporaryDirectory, "synthetic-order.xls");
|
||||
try {
|
||||
const fieldEncryptionKey = Buffer.alloc(32, 7).toString("base64");
|
||||
const config = { fieldEncryptionKey };
|
||||
const encrypt = (value) => encryptStringOrThrow(config, value);
|
||||
const generated = generatePaymentOrderXls(config, {
|
||||
batch_no: "PB-20260802-001",
|
||||
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountName: "合成测试公司", accountNumberEncrypted: encrypt("6217000000000012345") },
|
||||
items: [{ id: "1", entity_code: "BX-20260802-0001", amount: "1586.00", snapshot_json: { payee: { name: "合成员工", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000056789") } } }],
|
||||
});
|
||||
fs.writeFileSync(outputPath, generated.buffer);
|
||||
const check = spawnSync("python3", ["-c", [
|
||||
"import sys,xlrd",
|
||||
"book=xlrd.open_workbook(sys.argv[1])",
|
||||
"assert book.sheet_names()==['Sheet1','Sheet2','Sheet3']",
|
||||
"assert [(s.nrows,s.ncols) for s in book.sheets()]==[(2,15),(0,0),(0,0)]",
|
||||
"sheet=book.sheet_by_index(0)",
|
||||
"assert sheet.cell_type(1,1)==xlrd.XL_CELL_TEXT and sheet.cell_value(1,1)=='6217000000000012345'",
|
||||
"assert sheet.cell_type(1,3)==xlrd.XL_CELL_TEXT and sheet.cell_value(1,3)=='01'",
|
||||
"assert sheet.cell_type(1,4)==xlrd.XL_CELL_TEXT and sheet.cell_value(1,4)=='6222000000000056789'",
|
||||
"assert sheet.cell_type(1,8)==xlrd.XL_CELL_TEXT",
|
||||
"assert sheet.cell_type(1,9)==xlrd.XL_CELL_NUMBER and sheet.cell_value(1,9)==1586.0",
|
||||
"assert sheet.cell_value(1,10)=='报销款' and sheet.cell_value(1,5)=='合成员工'",
|
||||
].join(";"), outputPath], { encoding: "utf8" });
|
||||
if (check.status !== 0) {
|
||||
process.stderr.write(check.stderr || check.stdout || "xlrd roundtrip failed\n");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.stdout.write("[payment-order-xls][PASS] xlrd BIFF8 roundtrip verified\n");
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
const fieldEncryptionKey = Buffer.alloc(32, 7).toString("base64");
|
||||
const config = { fieldEncryptionKey };
|
||||
const encrypt = (value) => encryptStringOrThrow(config, value);
|
||||
const syntheticAccounts = [
|
||||
"6222000000000012345",
|
||||
"6222000000000023456",
|
||||
"6222000000000034567",
|
||||
"6222000000000045678",
|
||||
];
|
||||
|
||||
const generated = generatePaymentOrderXls(config, {
|
||||
batch_no: "PB-20260802-001",
|
||||
payment_account_snapshot_json: {
|
||||
bankInstitutionCode: "CCB",
|
||||
accountName: "合成测试公司",
|
||||
bankName: "中国建设银行",
|
||||
accountNumberEncrypted: encrypt("6217000000000012345"),
|
||||
},
|
||||
items: syntheticAccounts.map((account, index) => ({
|
||||
id: String(index + 1),
|
||||
entity_code: `BX-20260802-${String(index + 1).padStart(4, "0")}`,
|
||||
amount: `${index + 1}.20`,
|
||||
snapshot_json: {
|
||||
payee: {
|
||||
name: `合成员工${index + 1}`,
|
||||
bankName: "中国建设银行",
|
||||
bankInstitutionCode: "CCB",
|
||||
bankAccountEncrypted: encrypt(account),
|
||||
},
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const pythonBin = process.env.PAYMENT_ORDER_PYTHON_BIN ?? "python3";
|
||||
const verifier = spawnSync(pythonBin, [path.resolve(__dirname, "verify-payment-order-xls.py")], {
|
||||
input: generated.buffer,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: 15_000,
|
||||
env: {
|
||||
PATH: path.isAbsolute(pythonBin) ? path.dirname(pythonBin) : "/usr/local/bin:/usr/bin:/bin",
|
||||
LANG: "C.UTF-8",
|
||||
PYTHONDONTWRITEBYTECODE: "1",
|
||||
PYTHONNOUSERSITE: "1",
|
||||
},
|
||||
});
|
||||
|
||||
if (verifier.status !== 0 || verifier.error) {
|
||||
process.stderr.write(verifier.stderr || "[payment-order-xls][FAIL] verifier unavailable\n");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.stdout.write(verifier.stdout);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independently verify generated BIFF8 data and visual template contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
try:
|
||||
import xlrd
|
||||
except ImportError:
|
||||
sys.stderr.write("[payment-order-xls][FAIL] verifier dependency unavailable\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "src" / "assets" / "payment-order" / "ccb-payment-order-template.xls"
|
||||
EXPECTED_HEADERS = [
|
||||
"*序号",
|
||||
"*付款方客户账号",
|
||||
"*付款方账户名称",
|
||||
"*收款方行别代码\n(01-本行 02-国内他行)",
|
||||
"*收款方客户账号",
|
||||
"*收款方账户名称",
|
||||
"收款方开户行名称",
|
||||
"收款方联行号",
|
||||
"客户方流水号",
|
||||
"*金额",
|
||||
"*用途",
|
||||
"备注",
|
||||
"是否短信通知收款人(0-不通知,1-通知,默认为0-不通知。)",
|
||||
"收款人手机号码",
|
||||
"短信通知附加信息",
|
||||
]
|
||||
|
||||
|
||||
def visual_signature(book: xlrd.book.Book, sheet: xlrd.sheet.Sheet, row: int, column: int) -> tuple:
|
||||
xf = book.xf_list[sheet.cell_xf_index(row, column)]
|
||||
font = book.font_list[xf.font_index]
|
||||
alignment = xf.alignment
|
||||
background = xf.background
|
||||
border = xf.border
|
||||
return (
|
||||
font.name,
|
||||
font.height,
|
||||
font.bold,
|
||||
font.italic,
|
||||
font.underline_type,
|
||||
font.colour_index,
|
||||
alignment.hor_align,
|
||||
alignment.vert_align,
|
||||
alignment.text_wrapped,
|
||||
alignment.rotation,
|
||||
alignment.shrink_to_fit,
|
||||
background.fill_pattern,
|
||||
background.pattern_colour_index,
|
||||
background.background_colour_index,
|
||||
border.left_line_style,
|
||||
border.right_line_style,
|
||||
border.top_line_style,
|
||||
border.bottom_line_style,
|
||||
border.left_colour_index,
|
||||
border.right_colour_index,
|
||||
border.top_colour_index,
|
||||
border.bottom_colour_index,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
stage = "read"
|
||||
try:
|
||||
generated_bytes = sys.stdin.buffer.read(16 * 1024 * 1024 + 1)
|
||||
assert len(generated_bytes) <= 16 * 1024 * 1024
|
||||
template = xlrd.open_workbook(str(TEMPLATE_PATH), formatting_info=True)
|
||||
generated = xlrd.open_workbook(file_contents=generated_bytes, formatting_info=True)
|
||||
stage = "structure"
|
||||
assert generated.sheet_names() == ["Sheet1", "Sheet2", "Sheet3"]
|
||||
assert [(sheet.nrows, sheet.ncols) for sheet in generated.sheets()] == [(5, 15), (0, 0), (0, 0)]
|
||||
|
||||
source_sheet = template.sheet_by_index(0)
|
||||
sheet = generated.sheet_by_index(0)
|
||||
stage = "headers"
|
||||
assert sheet.row_values(0) == EXPECTED_HEADERS
|
||||
stage = "column_widths"
|
||||
stage = "explicit_column_widths"
|
||||
assert {column: info.width for column, info in sheet.colinfo_map.items()} == {
|
||||
column: info.width for column, info in source_sheet.colinfo_map.items()
|
||||
}
|
||||
stage = "header_height"
|
||||
assert sheet.rowinfo_map[0].height == source_sheet.rowinfo_map[0].height
|
||||
|
||||
for row in range(1, 5):
|
||||
stage = "data_row_height"
|
||||
assert sheet.rowinfo_map[row].height == source_sheet.rowinfo_map[1].height
|
||||
for column in range(15):
|
||||
stage = "visual_style"
|
||||
assert visual_signature(generated, sheet, row, column) == visual_signature(template, source_sheet, 1, column)
|
||||
|
||||
stage = "cell_types"
|
||||
for row in range(1, 5):
|
||||
for column in (0, 1, 2, 3, 4, 5, 6, 8, 10, 11, 12):
|
||||
stage = f"text_cell_{column}"
|
||||
assert sheet.cell_type(row, column) == xlrd.XL_CELL_TEXT
|
||||
for column in (7, 13, 14):
|
||||
stage = f"empty_cell_{column}"
|
||||
assert sheet.cell_type(row, column) in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK)
|
||||
assert sheet.cell_value(1, 1) == "6217000000000012345"
|
||||
assert sheet.cell_value(1, 3) == "01"
|
||||
assert sheet.cell_value(1, 4) == "6222000000000012345"
|
||||
assert sheet.cell_value(1, 5) == "合成员工1"
|
||||
assert sheet.cell_value(1, 10) == "报销款"
|
||||
assert sheet.cell_value(1, 12) == "0"
|
||||
for row in range(1, 5):
|
||||
assert sheet.cell_type(row, 9) == xlrd.XL_CELL_NUMBER
|
||||
stage = "amount_format"
|
||||
assert generated.format_map[generated.xf_list[sheet.cell_xf_index(row, 9)].format_key].format_str == "0.00"
|
||||
sys.stdout.write("[payment-order-xls][PASS] original BIFF8 template data and visual contracts verified\n")
|
||||
return 0
|
||||
except Exception:
|
||||
sys.stderr.write(f"[payment-order-xls][FAIL] BIFF8 template contract mismatch: {stage}\n")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
|
||||
@@ -2382,6 +2382,57 @@ describe("payment batch reimbursement routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not let an admin without a finance business role export a payment order", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["*"],
|
||||
roleCodes: ["admin"],
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "权限负向验证" },
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED" }),
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each(["draft", "failed", "cancelled", "executed"])(
|
||||
"rejects direct payment-order export while the batch is %s",
|
||||
async (status) => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "payment.order.export"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: { status },
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "状态门禁验证" },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("status_not_reviewed");
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }),
|
||||
});
|
||||
await app.close();
|
||||
},
|
||||
);
|
||||
|
||||
it("permanently rejects payment-order export for a legacy batch without frozen accounts", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
@@ -2399,11 +2450,72 @@ describe("payment batch reimbursement routes", () => {
|
||||
payload: { reason: "旧批次尝试" },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("payment_account_snapshot_missing");
|
||||
expect(response.json().error.details.reason).toBe("legacy_payment_account_snapshot_missing");
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects direct payment-order export when an item effective channel is not bank", async () => {
|
||||
const payerEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
`6217${"0".repeat(8)}1234`,
|
||||
);
|
||||
const payeeEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
);
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "payment.order.export"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: {
|
||||
status: "reviewed",
|
||||
default_payment_channel: "bank",
|
||||
payment_account_snapshot_json: {
|
||||
accountName: "合成测试公司",
|
||||
bankName: "中国建设银行",
|
||||
bankInstitutionCode: "CCB",
|
||||
accountNumberMasked: "6217********1234",
|
||||
accountNumberEncrypted: payerEncrypted,
|
||||
},
|
||||
items: buildPaymentBatch().items.map((item) => ({
|
||||
...item,
|
||||
payment_channel_override: "cash",
|
||||
snapshot_json: {
|
||||
payee: {
|
||||
name: "测试员工",
|
||||
bankName: "中国建设银行",
|
||||
bankInstitutionCode: "CCB",
|
||||
bankAccountMasked: "6222********5678",
|
||||
bankAccountEncrypted: payeeEncrypted,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "验证付款渠道门禁" },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||
reason: "bank_channel_required",
|
||||
blockers: expect.arrayContaining([
|
||||
{ code: "bank_channel_required", entityCode: "BX-20260429-0001" },
|
||||
]),
|
||||
}));
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }),
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("reveals the frozen payee account to finance only after a reason and audits the view", async () => {
|
||||
const payeeEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const XLSX = require("xlsx");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const {
|
||||
HEADERS,
|
||||
PaymentOrderXlsError,
|
||||
buildCustomerSerial,
|
||||
generatePaymentOrderXls,
|
||||
@@ -18,6 +19,7 @@ function buildBatch(overrides = {}) {
|
||||
payment_account_snapshot_json: {
|
||||
bankInstitutionCode: "CCB",
|
||||
accountName: "合成测试公司",
|
||||
bankName: "中国建设银行",
|
||||
accountNumberEncrypted: encrypt("6217000000000012345"),
|
||||
},
|
||||
items: [
|
||||
@@ -39,21 +41,12 @@ function buildBatch(overrides = {}) {
|
||||
}
|
||||
|
||||
describe("payment order BIFF8 generator", () => {
|
||||
it("writes the fixed workbook contract with typed string identifiers and stable ordering", () => {
|
||||
it("generates a BIFF8 workbook with stable rows, totals and customer serials", () => {
|
||||
const first = generatePaymentOrderXls(config, buildBatch());
|
||||
const second = generatePaymentOrderXls(config, buildBatch());
|
||||
const workbook = XLSX.read(first.buffer, { type: "buffer", cellNF: true });
|
||||
const sheet = workbook.Sheets.Sheet1;
|
||||
|
||||
expect(first.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
|
||||
expect(workbook.SheetNames).toEqual(["Sheet1", "Sheet2", "Sheet3"]);
|
||||
expect(XLSX.utils.sheet_to_json(sheet, { header: 1 })[0]).toEqual(HEADERS);
|
||||
expect(sheet.B2).toMatchObject({ t: "s", v: "6217000000000012345" });
|
||||
expect(sheet.D2).toMatchObject({ t: "s", v: "01" });
|
||||
expect(sheet.E2).toMatchObject({ t: "s", v: "6222000000000012345" });
|
||||
expect(sheet.I2).toMatchObject({ t: "s", v: "PB-20260802-002-001" });
|
||||
expect(sheet.J2).toMatchObject({ t: "n", v: 1.2 });
|
||||
expect(sheet.M2).toMatchObject({ t: "s", v: "0" });
|
||||
expect(first.rowCount).toBe(2);
|
||||
expect(first.serials).toEqual(second.serials);
|
||||
expect(first.totalAmount).toBe("3.20");
|
||||
});
|
||||
@@ -75,9 +68,107 @@ describe("payment order BIFF8 generator", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not invent a bank display name when the frozen snapshot is incomplete", () => {
|
||||
const batch = buildBatch();
|
||||
delete batch.items[0].snapshot_json.payee.bankName;
|
||||
expect(() => generatePaymentOrderXls(config, batch)).toThrow(expect.objectContaining({
|
||||
code: "payee_bank_name_missing",
|
||||
details: {
|
||||
blockers: expect.arrayContaining([
|
||||
{ code: "payee_bank_name_missing", entityCode: "BX-20260802-0002" },
|
||||
]),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("validates customer serial length and character set without truncation", () => {
|
||||
expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-001");
|
||||
expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError);
|
||||
expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError);
|
||||
});
|
||||
|
||||
it("fails closed when the fixed Python runtime is unavailable", () => {
|
||||
expect(() => generatePaymentOrderXls(config, buildBatch(), {
|
||||
pythonBin: "/definitely-missing/payment-order-python",
|
||||
})).toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" }));
|
||||
});
|
||||
|
||||
it("fails closed when the fixed Python dependencies are unavailable", () => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-dependency-test-"));
|
||||
try {
|
||||
const fakePython = path.join(temporaryRoot, "python");
|
||||
fs.writeFileSync(
|
||||
fakePython,
|
||||
"#!/bin/sh\nprintf '%s\\n' '[payment-order-template][ERROR] runtime_dependency_unavailable' >&2\nexit 70\n",
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython }))
|
||||
.toThrow(expect.objectContaining({ code: "payment_order_generator_unavailable" }));
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when the controlled template hash drifts", () => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-template-test-"));
|
||||
try {
|
||||
const scriptDirectory = path.join(temporaryRoot, "src", "scripts");
|
||||
const assetDirectory = path.join(temporaryRoot, "src", "assets", "payment-order");
|
||||
fs.mkdirSync(scriptDirectory, { recursive: true });
|
||||
fs.mkdirSync(assetDirectory, { recursive: true });
|
||||
const scriptPath = path.resolve(__dirname, "../../../src/scripts/fill-payment-order-template.py");
|
||||
const templatePath = path.resolve(__dirname, "../../../src/assets/payment-order/ccb-payment-order-template.xls");
|
||||
const copiedScriptPath = path.join(scriptDirectory, "fill-payment-order-template.py");
|
||||
const copiedTemplatePath = path.join(assetDirectory, "ccb-payment-order-template.xls");
|
||||
fs.copyFileSync(scriptPath, copiedScriptPath);
|
||||
const template = fs.readFileSync(templatePath);
|
||||
template[template.length - 1] ^= 1;
|
||||
fs.writeFileSync(copiedTemplatePath, template);
|
||||
|
||||
expect(() => generatePaymentOrderXls(config, buildBatch(), {
|
||||
scriptPath: copiedScriptPath,
|
||||
})).toThrow(expect.objectContaining({ code: "payment_order_template_unavailable" }));
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not copy child stderr or account payload into the exposed error", () => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-stderr-test-"));
|
||||
try {
|
||||
const fakePython = path.join(temporaryRoot, "python");
|
||||
fs.writeFileSync(fakePython, "#!/bin/sh\ntee /dev/stderr >/dev/null\nexit 70\n", { mode: 0o700 });
|
||||
let caught;
|
||||
try {
|
||||
generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PaymentOrderXlsError);
|
||||
expect(caught.code).toBe("payment_order_generation_failed");
|
||||
expect(JSON.stringify(caught)).not.toContain("6217000000000012345");
|
||||
expect(JSON.stringify(caught)).not.toContain("6222000000000012345");
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["timeout", "#!/bin/sh\nwhile :; do :; done\n", { timeout: 20 }],
|
||||
[
|
||||
"oversized output",
|
||||
"#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do printf X; i=$((i + 1)); done\n",
|
||||
{ maxBuffer: 1024 },
|
||||
],
|
||||
])("fails closed on child %s", (_label, script, options) => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-process-test-"));
|
||||
try {
|
||||
const fakePython = path.join(temporaryRoot, "python");
|
||||
fs.writeFileSync(fakePython, script, { mode: 0o700 });
|
||||
expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython, ...options }))
|
||||
.toThrow(expect.objectContaining({ code: "payment_order_generation_failed" }));
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户