fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -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 };
|
||||
在新工单中引用
屏蔽一个用户