fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-15 07:21:55 +08:00
父节点 229867f5fd
当前提交 8d08d46d16
共修改 33 个文件,包含 3691 行新增和 11 行删除
@@ -0,0 +1,105 @@
const FIELD_ALIASES = {
amount: ["小写金额", "交易金额", "转账金额", "付款金额", "金额"],
currency: ["币别", "币种"],
payerAccount: ["付款人账号", "付款账号"],
payeeAccount: ["收款人账号", "收款账号"],
transactionStatus: ["单据状态", "交易状态"],
reference: ["流水号", "交易流水号", "凭证号", "凭证号码"],
};
function normalizeText(value) {
return typeof value === "string" ? value.normalize("NFKC").trim() : "";
}
function parseReceiptMinorUnits(value) {
const text = normalizeText(value).replace(/^[¥¥]\s*/, "").replace(/\s*元$/, "");
if (!/^(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d{1,2})?$/.test(text)) return null;
const [whole, fraction = ""] = text.replace(/,/g, "").split(".");
if (whole.length > 12) return null;
return (BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"))).toString();
}
function normalizeAccount(value) {
const text = normalizeText(value).replace(/\s/g, "");
return /^\d{6,40}$/.test(text) ? text : null;
}
function normalizeCurrency(value) {
const text = normalizeText(value).toUpperCase();
if (["人民币", "人民币元", "RMB", "CNY"].includes(text)) return "CNY";
return /^[A-Z]{3}$/.test(text) ? text : null;
}
function normalizeStatus(value) {
const text = normalizeText(value);
if (["交易成功", "转账成功", "付款成功", "支付成功"].includes(text)) return "success";
if (["交易失败", "转账失败", "付款失败", "支付失败", "已撤销", "已冲正", "已退汇"].includes(text)) return "failed";
if (["已受理", "受理成功", "处理中", "待处理"].includes(text)) return "pending";
return null;
}
function normalizeBankSlip(fields) {
const result = {
amount: null, currency: null, payerAccount: null, payeeAccount: null,
transactionStatus: null, reference: null, issues: [],
};
if (!Array.isArray(fields) || fields.length === 0 || fields.length > 200) {
result.issues.push("response_invalid");
return result;
}
const parsers = {
amount: parseReceiptMinorUnits, currency: normalizeCurrency,
payerAccount: normalizeAccount, payeeAccount: normalizeAccount,
transactionStatus: normalizeStatus, reference: (value) => normalizeText(value) || null,
};
for (const [key, aliases] of Object.entries(FIELD_ALIASES)) {
let entries = fields.filter((field) => aliases.includes(normalizeText(field?.Name)) && normalizeText(field?.Value));
if (key === "reference" && entries.length) {
const preferred = aliases.find((alias) => entries.some((field) => normalizeText(field.Name) === alias));
entries = entries.filter((field) => normalizeText(field.Name) === preferred);
}
const names = entries.map((field) => normalizeText(field.Name));
const values = entries.map((field) => parsers[key](field.Value));
if (key === "transactionStatus") {
if (values.includes("failed")) result.issues.push("transaction_failed");
if (values.includes("pending")) result.issues.push("transaction_pending");
}
if (new Set(names).size !== names.length || new Set(values).size > 1) {
result.issues.push(`${key}_ambiguous`);
} else if (values.length && values[0] !== null) {
result[key] = values[0];
} else {
result.issues.push(`${key}_unresolved`);
}
}
return result;
}
function matchBankSlip(receipt, candidates) {
const finish = (status, reasons, itemId = null) => ({ status, itemId, reasons, bankVerified: false });
if (receipt.transactionStatus === "failed") return finish("mismatch", ["transaction_failed"]);
const unresolved = [...receipt.issues];
if (receipt.transactionStatus !== "success") unresolved.push("transaction_not_confirmed");
if (unresolved.length) return finish("needs_review", [...new Set(unresolved)]);
if (!Array.isArray(candidates) || !candidates.length) return finish("needs_review", ["candidates_missing"]);
const comparisons = candidates.map((candidate) => {
const expected = {
amount: parseReceiptMinorUnits(candidate.amount), currency: normalizeCurrency(candidate.currency),
payerAccount: normalizeAccount(candidate.payerAccount), payeeAccount: normalizeAccount(candidate.payeeAccount),
};
const invalid = !candidate.id || Object.values(expected).some((value) => value === null);
const reasons = [];
if (expected.amount !== receipt.amount) reasons.push("amount_mismatch");
if (expected.currency !== receipt.currency) reasons.push("currency_mismatch");
if (expected.payerAccount !== receipt.payerAccount) reasons.push("payer_account_mismatch");
if (expected.payeeAccount !== receipt.payeeAccount) reasons.push("payee_account_mismatch");
return { id: candidate.id, invalid, reasons };
});
if (comparisons.some((comparison) => comparison.invalid)) return finish("needs_review", ["candidate_invalid"]);
const matches = comparisons.filter((comparison) => comparison.reasons.length === 0);
if (matches.length === 1) return finish("matched", [], matches[0].id);
if (matches.length > 1) return finish("needs_review", ["multiple_candidates"]);
return finish("mismatch", [...new Set(comparisons.flatMap((comparison) => comparison.reasons))]);
}
module.exports = { normalizeBankSlip, matchBankSlip, parseReceiptMinorUnits };