S1(迁移 20261007010000) - 借款导出逐次记录;存量批次保留已知导出次数为历史汇总,不视为真实导出,存在即拦截 - 银行提交登记(只能撤回一次)与提交清单确认;独立复核,复核后任何登记、撤回、导出即失效 - 确认失败只计未作废附件;作废附件无法证明为明确失败或确属他笔付款即阻断 - S3 前临时拦截:多次导出、多次提交、清单未复核或历史汇总均不能确认失败 - 确认失败改按付款后规则;确认人不得兼任导出、提交登记、清单确认 S2(迁移 20261007020000) - 存在同金额候选时须有已复核、覆盖提交清单的失败归属;结算与失败归属分槽 失败回单识别口径(用户同意并入) - 新增原始状态措辞字段(多字段原文不一致即为空);失败事实严格判定:单页完整、 仅“交易失败/转账失败/付款失败/支付失败”、唯一问题为交易失败、字段齐全; 已撤销、已冲正、已退汇不接受;顶层识别状态与成功匹配不变 - 测试改走真实识别与归一化链,仅替换云端 OCR 响应 验证:借款 HTTP 41 组、浏览器 7 组、76 迁移;server 4463;共享回单测试 237; 三轮同模型只读复核的阻断项均已修复。 注意:界面未完成,不能单独部署;真实回单待验。未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
115 行
5.8 KiB
JavaScript
115 行
5.8 KiB
JavaScript
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, transactionStatusRaw: 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];
|
|
// The exact bank wording is kept: several distinct bank states share the
|
|
// normalized "failed", but only some prove that no money left the account.
|
|
// Several bank wordings normalize to the same value (e.g. 交易失败 and 已撤销
|
|
// both become "failed"), so the raw wording is kept only when every status
|
|
// field carries the identical text; otherwise it stays null (fail closed).
|
|
if (key === "transactionStatus") {
|
|
const raws = new Set(entries.map((field) => normalizeText(field.Value)));
|
|
result.transactionStatusRaw = raws.size === 1 ? [...raws][0] : null;
|
|
}
|
|
} 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 };
|