借款登记支持上传原借入回单识别预填(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增识别与保存接口:可读 PDF 本地解析,必要时有界调用现有回单 OCR;预填上下文加密绑定经办人、文件和 30 分钟
- 适配建行单位客户专用回单表格版式:按位置归行排序、竖排栏头、顶行无标签日期、大小写金额
- 拦截非最终、失败/处理中、多笔交易(含跨页无关联、编号冲突)、非人民币、公司支出和其他收款主体
- 金额、日期、编号整段校验;借款与原件同事务保存,失败回滚并清理对象;同一出借人原件唯一
- 保存绑定所选出借人版本;前端冻结核对时版本,迟到结果与切换账号不覆盖、不重发
- 测试:单测、隔离 HTTP/浏览器、变异;对账浏览器场景固定 UTC;五轮独立复核通过
- 无新增迁移

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-10 10:34:40 +08:00
共同撰写人 Claude Opus 5.5
父节点 45737ccb9a
当前提交 2063ff1e93
共修改 22 个文件,包含 2056 行新增和 47 行删除
@@ -0,0 +1,353 @@
const incoming = require("../../../src/services/loanIncomingReceipt");
const { syntheticReceiptText, syntheticBankReceiptPdf, syntheticCcbIncomingPdf } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs");
const config = { fieldEncryptionKey: Buffer.alloc(32, 9).toString("base64") };
const credit = (options = {}) => syntheticReceiptText({ direction: "credit", status: "booked", amount: "50,000.00",
reference: "SYN-IN-0001", bookedOn: "2025-03-14", extraLines: ["付款人名称 合成出借人", "收款人名称 合成公司"], ...options });
const parse = (...texts) => incoming.finalizeResult(incoming.parseIncomingTexts(texts));
describe("incoming receipt field extraction", () => {
test("reads amount, booking date, reference and parties from labelled lines", () => {
const result = parse(credit());
expect(result.blocks).toEqual([]);
expect(result.direction).toBe("credit");
expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001",
referenceKind: "voucher", currency: "CNY", payerName: "合成出借人", payeeName: "合成公司" });
expect(result.sources).toMatchObject({ amount: "pdf_text", receivedDate: "pdf_text", reference: "pdf_text" });
expect(result.fieldIssues).toEqual({});
});
test("a stable serial number is preferred over a voucher number, kept as text", () => {
const result = parse(credit({ extraLines: ["交易流水号 0012345678"] }));
expect(result.fields.reference).toBe("0012345678");
expect(result.fields.referenceKind).toBe("serial");
});
test("conflicting dates are left empty rather than choosing one", () => {
const result = parse(credit({ extraLines: ["交易日期 2025-03-15"] }));
expect(result.fields.receivedDate).toBeUndefined();
expect(result.fieldIssues.receivedDate).toBe("ambiguous");
});
test("print stamps and capital amounts are never read as business values", () => {
const result = parse(credit({ extraLines: ["打印日期 2026-10-09", "大写金额 伍万元整"] }));
expect(result.fields.receivedDate).toBe("2025-03-14");
expect(result.fields.amount).toBe("5000000");
});
test("pages repeating one transaction are merged; different transactions are rejected", () => {
expect(parse(credit(), credit()).blocks).toEqual([]);
const result = parse(credit(), credit({ amount: "20.00", reference: "SYN-IN-0002" }));
expect(result.blocks).toContain("multiple_transactions");
expect(result.fields.amount).toBeUndefined();
});
test.each([
[{ final: false }, "non_final"], [{ status: "failed" }, "transaction_failed"],
[{ status: "pending" }, "transaction_pending"], [{ currency: "USD" }, "non_cny"],
])("restricted document %j is blocked", (options, block) => {
expect(parse(credit(options)).blocks).toContain(block);
});
test("two receipts on one page are blocked", () => {
expect(parse(credit() + "\n" + credit({ amount: "1.00" })).blocks).toContain("multiple_transactions");
});
test("missing required fields are reported, not invented", () => {
const result = parse("中国建设银行单位客户专用回单\n贷\n摘要 借款");
expect(result.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" });
expect(result.fields).toEqual({});
});
});
describe("review counterexamples (2026-10-10)", () => {
test("same amount with different voucher numbers is several transactions, not a missing reference", () => {
const result = parse(credit({ reference: "SYNTHETIC-VOUCHER-A" }), credit({ reference: "SYNTHETIC-VOUCHER-B" }));
expect(result.blocks).toContain("multiple_transactions");
});
test("a provider reporting different serial numbers with one amount is several transactions", () => {
const result = incoming.parseIncomingTexts([""]);
incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-A" }],
[{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-B" }]]);
expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions");
});
test("one transaction repeated across pages remains allowed", () => {
expect(parse(credit(), credit()).blocks).toEqual([]);
});
test("a provider currency never clears a locally known one", () => {
const local = incoming.parseIncomingTexts([credit()]);
incoming.mergeProviderFields(local, [[{ Name: "币别", Value: "美元" }, { Name: "币种", Value: "USD" }]]);
expect(incoming.finalizeResult(local).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"]));
expect(local.fields.currency).toBe("CNY");
});
test("a locally known foreign currency blocks before any provider call", async () => {
const createClient = vi.fn();
const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked",
currency: "USD", bookedOn: "not-a-date" }), { config, createClient });
expect(createClient).not.toHaveBeenCalled();
expect(result.blocks).toContain("non_cny");
});
test("conflicting currencies within the document are a known contradiction", () => {
expect(parse(credit({ extraLines: ["币种 USD"] })).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"]));
});
test.each([["1,23.45"], ["1234.567"], ["50000.00USD"], ["1.2.3"], ["¥"]])("malformed amount %s is invalid, never truncated", (amount) => {
const result = parse(credit({ amount }));
expect(result.fields.amount).toBeUndefined();
expect(result.fieldIssues.amount).toBe("invalid");
});
test.each([["¥1,234.50元", "123450"], ["人民币1234.5", "123450"], ["0.01", "1"], ["999,999,999,999.99", "99999999999999"]])(
"well-formed amount %s is read whole", (amount, minor) => {
expect(parse(credit({ amount })).fields.amount).toBe(minor);
});
test("the provider deadline stops later pages, not just the wait", async () => {
let calls = 0;
const BankSlipOCR = vi.fn(() => { calls += 1; return new Promise((resolve) => setTimeout(() => resolve({ BankSlipInfos: [] }), 30)); });
const pass = await incoming.providerPass(config, Buffer.from("%PDF-synthetic"), 3, () => ({ BankSlipOCR }), { totalMs: 10 });
expect(pass.status).toBe("failed");
const atReturn = calls;
await new Promise((resolve) => setTimeout(resolve, 150));
expect(calls).toBe(atReturn);
expect(calls).toBe(1);
});
});
describe("CCB table layout (synthetic replica of the real borrow-in receipt)", () => {
const inspect = async (options) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient: vi.fn() });
test("reads every field from out-of-order table items without any provider call", async () => {
const createClient = vi.fn();
const result = await incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(), { config, createClient });
expect(createClient).not.toHaveBeenCalled();
expect(result).toMatchObject({ ocr: "not_needed", blocks: [], direction: "credit", documentKind: "bank_receipt", fieldIssues: {} });
expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "123456789AB12CDEFG3", referenceKind: "serial",
currency: "CNY", payerName: "合成甲", payerAccount: "6222000011112222", payeeName: "合成公司", payeeAccount: "33050161000000000001" });
});
test("a vertical copy note level with the amount row neither pollutes the amount nor becomes a column header", async () => {
const result = await inspect({ copyNote: "(收款人回单)(第一联)" });
expect(result.fields.amount).toBe("5000000");
expect(result.fields.payeeName).toBe("合成公司");
});
test("baseline jitter within one row still reads the row left to right", async () => {
expect((await inspect({ dateDy: 1 })).fields.receivedDate).toBe("2025-03-14");
expect((await inspect({ dateDy: -2 })).fields.receivedDate).toBe("2025-03-14");
});
test("two different unlabelled header dates are ambiguous", async () => {
const result = await inspect({ extraItems: [["2025年03月15日", 150, 785]] });
expect(result.fields.receivedDate).toBeUndefined();
expect(result.fieldIssues.receivedDate).toBe("ambiguous");
});
test("an impossible header date is invalid, not shifted", async () => {
const result = await inspect({ date: ["2025", "02", "30"] });
expect(result.fieldIssues.receivedDate).toBe("invalid");
});
test("parties are only read when the payer column is left of the payee column", async () => {
const swapped = await inspect({ headers: ["收款人", "付款人"] });
expect(swapped.fields.payerName).toBeUndefined();
expect(swapped.fields.payeeName).toBeUndefined();
expect(swapped.fields.amount).toBe("5000000");
});
test("a malformed lower-case amount is invalid even in the table layout", async () => {
const result = await inspect({ amount: "50,00.00" });
expect(result.fields.amount).toBeUndefined();
expect(result.fieldIssues.amount).toBe("invalid");
});
test("a non-final notice still blocks the table layout", async () => {
const result = await inspect({ extraItems: [["本回执不作为收、付款方交易的最终依据。", 25, 575]] });
expect(result.blocks).toContain("non_final");
});
});
describe("second review counterexamples (2026-10-10)", () => {
const inspect = async (options, createClient = vi.fn()) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient });
test.each([[["交易失败", 140, 650], ["交易状态", 37, 650]], [["处理中", 140, 650], ["交易状态", 37, 650]],
[["交易的最终依据。", 140, 575], ["本回执不作为收、付款方", 25, 575]]])(
"restrictions stored value-first are still read in reading order: %j", async (...extraItems) => {
const result = await inspect({ extraItems });
expect(result.blocks.some((b) => ["transaction_failed", "transaction_pending", "non_final"].includes(b))).toBe(true);
});
test("a labelled print date on the header row is never the booking date", async () => {
const result = await inspect({ date: null, extraItems: [["打印日期:2026年10月08日", 140, 785]] });
expect(result.fields.receivedDate).toBeUndefined();
expect(result.fieldIssues.receivedDate).toBe("missing");
});
test("a labelled print date beside the unlabelled booking date leaves the booking date", async () => {
expect((await inspect({ extraItems: [["打印日期:2026年10月08日", 450, 785]] })).fields.receivedDate).toBe("2025-03-14");
});
test.each([["2025-03-149"], ["2025-03-14/2025-04-14"]])("labelled date %s must be one whole date", (date) => {
expect(parse(credit({ bookedOn: date })).fieldIssues.receivedDate).toBe("invalid");
});
async function readablePlusScanned() {
const { PDFDocument } = require("pdf-lib");
const doc = await PDFDocument.create();
const source = await PDFDocument.load(await syntheticCcbIncomingPdf());
doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage();
return Buffer.from(await doc.save());
}
test("a scanned page of another transaction after a readable page is read and blocked", async () => {
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "9.00" }, { Name: "流水号", Value: "OTHER-2" }] }));
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
expect(BankSlipOCR).toHaveBeenCalledTimes(1);
expect(BankSlipOCR.mock.calls[0][0].PdfPageNumber).toBe(2);
expect(result.blocks).toContain("multiple_transactions");
});
test("an unverifiable extra page blocks when no provider is available", async () => {
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => null });
expect(result.blocks).toContain("pages_unverified");
});
test("a scanned repeat of the same transaction is accepted", async () => {
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" }, { Name: "流水号", Value: "123456789AB12CDEFG3" }] }));
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
expect(result.blocks).toEqual([]);
});
test.each([
["empty provider page", []],
["unparseable serial", [{ Name: "流水号", Value: "?" }]],
["unparseable amount", [{ Name: "金额", Value: "无法识别" }]],
["same amount without any identifier", [{ Name: "金额", Value: "50,000.00" }]],
])("a scanned page is not verified by %s", async (_label, fields) => {
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: fields }));
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
expect(result.blocks).toContain("pages_unverified");
});
test("a readable page with only the same amount and no identifier is not verified", async () => {
const { PDFDocument } = require("pdf-lib");
const doc = await PDFDocument.create();
for (const options of [{}, { serial: null }]) {
const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options));
doc.addPage((await doc.copyPages(source, [0]))[0]);
}
const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => null });
expect(result.blocks).toContain("pages_unverified");
});
test("a different secondary voucher number is another transaction even with the same serial", async () => {
const { PDFDocument } = require("pdf-lib");
const doc = await PDFDocument.create();
const source = await PDFDocument.load(await syntheticCcbIncomingPdf({ extraItems: [["凭证号 LOCAL-VOUCHER", 293, 702]] }));
doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage();
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" },
{ Name: "流水号", Value: "123456789AB12CDEFG3" }, { Name: "凭证号", Value: "OTHER-VOUCHER" }] }));
const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) });
expect(result.fields.referenceKind).toBe("serial");
expect(result.blocks).toContain("multiple_transactions");
});
async function twoCcbPages(first, second) {
const { PDFDocument } = require("pdf-lib");
const doc = await PDFDocument.create();
for (const options of [first, second]) {
const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options));
doc.addPage((await doc.copyPages(source, [0]))[0]);
}
return Buffer.from(await doc.save());
}
const voucherOnly = (voucher) => ({ serial: null, extraItems: [[`凭证号 ${voucher}`, 293, 702]] });
test("a serial on one page and an unrelated voucher on the other are not linked", async () => {
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A" }, voucherOnly("VOUCHER-B")), { config, createClient: () => null });
expect(result.blocks).toContain("pages_unverified");
});
test("the same text under different identifier kinds is not a link", async () => {
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SAME-0001" }, voucherOnly("SAME-0001")), { config, createClient: () => null });
expect(result.blocks).toContain("pages_unverified");
});
test("a page carrying both numbers links a later page that repeats one of them", async () => {
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A", extraItems: [["凭证号 VOUCHER-B", 293, 702]] },
voucherOnly("VOUCHER-B")), { config, createClient: () => null });
expect(result.blocks).toEqual([]);
});
test.each([["SERIAL-A/001"], ["SERIAL-A?"], ["A".repeat(70)]])("identifier %s is invalid as a whole, never cut to a prefix", (reference) => {
const result = parse(credit({ reference }));
expect(result.fields.reference).toBeUndefined();
expect(result.fieldIssues.reference).toBe("invalid");
});
test("same prefix with different suffixes cannot collapse into one identifier", async () => {
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIALA/001" }, { serial: "SERIALA/002" }), { config, createClient: () => null });
expect(result.blocks).toContain("pages_unverified");
expect(result.fields.reference).toBeUndefined();
});
test("a provider identifier with an invalid suffix does not verify a page", async () => {
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "流水号", Value: "123456789AB12CDEFG3?" }] }));
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
expect(result.blocks).toContain("pages_unverified");
});
test("a provider transaction different from the text layer is several transactions", () => {
const result = incoming.parseIncomingTexts([credit({ reference: "LOCAL-A", amount: "10.00" })]);
incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "20.00" }, { Name: "凭证号", Value: "OCR-B" }]]);
expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions");
});
});
describe("bounded provider fallback", () => {
const providerFields = [{ Name: "金额", Value: "1,234.50" }, { Name: "记账日期", Value: "2025年1月2日" },
{ Name: "流水号", Value: "OCR-77" }, { Name: "币别", Value: "人民币" }];
test("a readable PDF with all required fields makes no provider call", async () => {
const createClient = vi.fn();
const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked",
reference: "SYN-PDF-1", bookedOn: "2025-02-03", amount: "88.00" }), { config, createClient });
expect(createClient).not.toHaveBeenCalled();
expect(result.ocr).toBe("not_needed");
expect(result.fields).toMatchObject({ amount: "8800", receivedDate: "2025-02-03", reference: "SYN-PDF-1" });
});
test("an unreadable PDF uses each page at most once and only fills gaps", async () => {
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: providerFields }));
const { PDFDocument } = require("pdf-lib");
const doc = await PDFDocument.create(); doc.addPage(); doc.addPage();
const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) });
expect(BankSlipOCR).toHaveBeenCalledTimes(2);
expect(result.ocr).toBe("used");
expect(result.fields).toMatchObject({ amount: "123450", receivedDate: "2025-01-02", reference: "OCR-77" });
expect(result.sources.amount).toBe("ocr");
});
test("provider unavailable or failing leaves manual completion without retries", async () => {
const { PDFDocument } = require("pdf-lib");
const doc = await PDFDocument.create(); doc.addPage();
const pdf = Buffer.from(await doc.save());
expect((await incoming.inspectIncomingPdf(pdf, { config, createClient: () => null })).ocr).toBe("unavailable");
const BankSlipOCR = vi.fn(async () => { throw new Error("quota"); });
const failed = await incoming.inspectIncomingPdf(pdf, { config, createClient: () => ({ BankSlipOCR }) });
expect(BankSlipOCR).toHaveBeenCalledTimes(1);
expect(failed.ocr).toBe("failed");
expect(failed.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" });
});
test("provider text can add restrictions but never overwrite local values", () => {
const result = incoming.parseIncomingTexts([credit()]);
incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "1.00" }, { Name: "备注", Value: "本回执不作为收付款方交易的最终依据" }]]);
incoming.finalizeResult(result);
expect(result.blocks).toContain("non_final");
expect(result.fields.amount).toBeUndefined();
expect(result.fieldIssues.amount).toBe("ambiguous");
});
test.each(["not a pdf", ""])("rejects non-PDF input %j", async (text) => {
await expect(incoming.inspectIncomingPdf(Buffer.from(text), { config })).rejects.toMatchObject({ details: { reasonCode: "loan.incoming_receipt_invalid" } });
});
});
describe("sealed prefill context", () => {
const base = { v: 1, purpose: incoming.PURPOSE, parserVersion: incoming.PARSER_VERSION, actorId: "actor-1", fileSha256: "a".repeat(64),
issuedAt: 1000, expiresAt: 1000 + incoming.CONTEXT_TTL_MS, fields: {}, blocks: [] };
test("opens only for the same actor, file and lifetime", () => {
const token = incoming.sealContext(config, base);
expect(incoming.openContext(config, token, { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 }).actorId).toBe("actor-1");
for (const binding of [{ actorId: "actor-2", fileSha256: "a".repeat(64), now: 2000 },
{ actorId: "actor-1", fileSha256: "b".repeat(64), now: 2000 },
{ actorId: "actor-1", fileSha256: "a".repeat(64), now: base.expiresAt + 1 }]) {
expect(() => incoming.openContext(config, token, binding)).toThrow();
}
});
test("tampered, foreign-purpose or plain tokens are rejected", () => {
const token = incoming.sealContext(config, base);
const binding = { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 };
expect(() => incoming.openContext(config, `${token.slice(0, -4)}AAAA`, binding)).toThrow();
expect(() => incoming.openContext(config, incoming.sealContext(config, { ...base, purpose: "other" }), binding)).toThrow();
expect(() => incoming.openContext(config, Buffer.from(JSON.stringify(base)).toString("base64"), binding)).toThrow();
});
});
describe("save guards", () => {
const lender = { legal_entity: { name: "合成公司" } };
test("company payment out or money to another entity is not a borrow-in", () => {
expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成公司" } }, lender)).toThrow();
expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "另一公司" } }, lender)).toThrow();
expect(() => incoming.assertSavable({ blocks: ["non_final"], direction: "credit", fields: {} }, lender)).toThrow();
});
test("a credit to the company, or a lender's own debit slip, can be saved", () => {
expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "合成公司" } }, lender)).not.toThrow();
expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成出借人" } }, lender)).not.toThrow();
expect(() => incoming.assertSavable({ blocks: [], direction: null, fields: {} }, lender)).not.toThrow();
});
test("records corrected field names only", () => {
const context = { fields: { amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001" } };
expect(incoming.corrections(context, { originalPrincipal: "50000.00", receivedDate: "2025-03-15", originReference: "SYN-IN-0001" }))
.toEqual(["receivedDate"]);
});
});