完善付款回单凭证核验与本地重检(09-17-company-loan-principal-repayment)
区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。 正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。 本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。 本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。 限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
这个提交包含在:
@@ -0,0 +1,74 @@
|
||||
const { classifyReceiptText, inspectReceiptDocument, safeManualProof, paymentDocumentReady,
|
||||
withProviderRestrictions, documentForPage } = require("../../../src/services/paymentReceiptDocument");
|
||||
const { recognizePaymentReceipt } = require("../../../src/services/paymentReceiptOcr");
|
||||
const { buildVerificationData } = require("../../../src/services/paymentReceiptVerification");
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const { syntheticReceiptText, syntheticBankReceiptPdf } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs");
|
||||
|
||||
describe("receipt documentary evidence", () => {
|
||||
test.each([
|
||||
"本回执不作为收、付款方交易的最终依据,正式回单请在交易成功后另打印。",
|
||||
"此凭证仅表示银行受理。", "该回执不能作为入账凭证", "本单仅供参考",
|
||||
])("a restriction overrides even a formal title and success field: %s", (notice) => {
|
||||
expect(classifyReceiptText(`${syntheticReceiptText()}\n${notice}`).kind).toBe("non_final");
|
||||
});
|
||||
test("a title by itself is not sufficient evidence", () => {
|
||||
expect(classifyReceiptText("中国建设银行单位客户专用回单").kind).toBe("unknown");
|
||||
});
|
||||
test("multiple transactions on one page are not a single receipt", () => {
|
||||
expect(classifyReceiptText(syntheticReceiptText() + syntheticReceiptText()).issues).toContain("document_multiple_receipts");
|
||||
});
|
||||
test("provider restriction fields cannot disappear during normalization", () => {
|
||||
const document = withProviderRestrictions(classifyReceiptText(syntheticReceiptText()), [
|
||||
{ Name: "注意事项", Value: "本回执不作为收付款方交易的最终依据" },
|
||||
]);
|
||||
expect(document.kind).toBe("non_final");
|
||||
});
|
||||
test.each(["2026-02-30", "2026-13-01", "", "20261001", "2999-01-01"])("rejects invalid or future booking date %s", (bookedOn) => {
|
||||
expect(safeManualProof({ basis: "booked_debit", originalChecked: true, note: "合成记账依据完整", bookedOn })).toBeNull();
|
||||
});
|
||||
test("obsolete page inspection cannot fall back to a different successful result", () => {
|
||||
const document = classifyReceiptText(syntheticReceiptText());
|
||||
const documentPages = [{ pageNumber: 1, document }];
|
||||
expect(documentForPage({ pages: [], documentPages }, 1)).toEqual(document);
|
||||
expect(documentForPage({ pages: [{ pageNumber: 1, document: { ...document, version: 0 } }], documentPages }, 1)).toBeNull();
|
||||
expect(documentForPage({ pages: [{ pageNumber: 1, document: null }], documentPages }, 1)).toBeNull();
|
||||
});
|
||||
test("even an approved-looking manual document cannot enter automatic verification", () => {
|
||||
const page = { transactionStatus: "success", document: { ...classifyReceiptText(syntheticReceiptText()), source: "manual",
|
||||
proof: { basis: "explicit_success", originalChecked: true, note: "合成人工付款依据" } } };
|
||||
expect(paymentDocumentReady(page)).toBe(false);
|
||||
expect(paymentDocumentReady(page, { allowManual: true })).toBe(true);
|
||||
});
|
||||
test.each([{ final: false, kind: "non_final" }, { status: "booked", kind: "bank_receipt" },
|
||||
{ direction: "credit", kind: "bank_receipt" }])("parses actual synthetic PDF text %j", async ({ kind, ...options }) => {
|
||||
const pages = await inspectReceiptDocument({ buffer: await syntheticBankReceiptPdf(options), mimeType: "application/pdf", pagesExpected: 1 });
|
||||
expect(pages[0].document.kind).toBe(kind);
|
||||
expect(pages[0].document.source).toBe("pdf_text");
|
||||
if (options.direction) expect(pages[0].document.direction).toBe(options.direction);
|
||||
});
|
||||
test("unreadable data remains unknown instead of assuming a missing disclaimer", async () => {
|
||||
const pages = await inspectReceiptDocument({ buffer: Buffer.from("%PDF-invalid"), mimeType: "application/pdf", pagesExpected: 1 });
|
||||
expect(pages[0].document.kind).toBe("unknown");
|
||||
});
|
||||
test("non-final PDF passes field recognition but cannot become matched payment evidence", async () => {
|
||||
const fields = Object.entries({ 小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||
收款人账号: "000033334444", 凭证号: "SYN-DOCUMENT", 单据状态: "交易成功" }).map(([Name, Value]) => ({ Name, Value }));
|
||||
const recognition = await recognizePaymentReceipt({ buffer: await syntheticBankReceiptPdf({ final: false }), mimeType: "application/pdf",
|
||||
createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: fields }) }) });
|
||||
expect(recognition.status).toBe("recognized");
|
||||
const config = { fieldEncryptionKey: Buffer.alloc(32, 6).toString("base64") };
|
||||
const batch = { id: "batch", entity_type: "reimbursement",
|
||||
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, "000011112222") },
|
||||
items: [{ id: "item", status: "pending", amount: "10.00", currency_code: "CNY",
|
||||
snapshot_json: { payee: { bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, "000033334444") } } }] };
|
||||
const data = buildVerificationData(config, batch, { id: "attachment", payment_batch_id: "batch", governance_status: "approved",
|
||||
file_sha256: "f".repeat(64), receipt_ocr_version: 1 }, recognition);
|
||||
expect(data.status).toBe("needs_review"); expect(data.matches).toBeUndefined();
|
||||
});
|
||||
test("contradictory native success and OCR failure cannot establish strict bank failure", async () => {
|
||||
const recognition = await recognizePaymentReceipt({ buffer: await syntheticBankReceiptPdf(), mimeType: "application/pdf",
|
||||
createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: [{ Name: "交易状态", Value: "交易失败" }] }) }) });
|
||||
expect(recognition.pages[0].issues).toContain("transactionStatus_ambiguous");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
||||
const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("../../../src/services/paymentReceiptManualReview");
|
||||
const { syntheticDocumentEvidence } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs");
|
||||
|
||||
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
||||
|
||||
@@ -50,7 +51,8 @@ function fixture() {
|
||||
}).map(([Name, Value]) => ({ Name, Value }));
|
||||
return {
|
||||
original: { status: "recognized", pagesExpected: 1, pagesProcessed: 1,
|
||||
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields) }] },
|
||||
documentPages: [{ pageNumber: 1, document: syntheticDocumentEvidence() }],
|
||||
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), document: syntheticDocumentEvidence() }] },
|
||||
pages: [{ pageNumber: 1, itemId: "item-2", fields }],
|
||||
candidates: ["item-1", "item-2"].map((id) => ({
|
||||
id, amount: "10.00", currency: "CNY", payerAccount: "000011112222", payeeAccount: "000033334444",
|
||||
@@ -59,6 +61,41 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("manual receipt proposal is evidence preparation, not authorization", () => {
|
||||
test("booked debit uses actual bank booking facts without inventing success wording", () => {
|
||||
const input = fixture();
|
||||
input.original.pages[0].document = syntheticDocumentEvidence({ status: "booked" });
|
||||
input.original.pages[0].transactionStatus = null;
|
||||
input.original.pages[0].transactionStatusRaw = null;
|
||||
input.pages[0].fields.find((field) => field.Name === "单据状态").Value = "";
|
||||
input.pages[0].paymentProof = { basis: "booked_debit", bookedOn: "2026-10-01", originalChecked: true,
|
||||
note: "合成正式回单以公司付款账户记载借方支出" };
|
||||
const result = buildManualReceiptProposal(config, input);
|
||||
expect(result.requiresIndependentReview).toBe(true);
|
||||
expect(result.recognition.pages[0]).toMatchObject({ transactionStatus: "success", transactionStatusRaw: null,
|
||||
document: { source: "manual", proof: { basis: "booked_debit", bookedOn: "2026-10-01" } } });
|
||||
expect(result.changedFields[0].fields).toContain("paymentProof");
|
||||
});
|
||||
|
||||
test.each([{ kind: "non_final" }, { direction: "credit" }, { issues: ["document_transaction_pending"] },
|
||||
{ issues: ["document_multiple_receipts"] }])("manual declarations cannot overwrite documentary restrictions %j", (change) => {
|
||||
const input = fixture();
|
||||
input.original.pages[0].document = { ...syntheticDocumentEvidence(), ...change };
|
||||
input.pages[0].paymentProof = { basis: "explicit_success", originalChecked: true, note: "合成强行确认不能覆盖已知限制" };
|
||||
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_document_not_final");
|
||||
});
|
||||
|
||||
test("unknown original needs an explicit, reviewed final-document declaration", () => {
|
||||
const input = fixture();
|
||||
input.original.pages[0].document = { ...syntheticDocumentEvidence(), kind: "unknown", explicitSuccess: false };
|
||||
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_document_proof_required");
|
||||
input.pages[0].paymentProof = { basis: "explicit_success", originalChecked: true, note: "合成原件为正式付款回单,已检查全部页及说明" };
|
||||
expect(buildManualReceiptProposal(config, input).requiresIndependentReview).toBe(true);
|
||||
});
|
||||
|
||||
test("legacy records require local reinspection before a manual proposal", () => {
|
||||
const input = fixture(); delete input.original.documentPages; delete input.original.pages[0].document;
|
||||
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_document_check_required");
|
||||
});
|
||||
test("explicit same-amount association needs no field reentry review", () => {
|
||||
const input = fixture();
|
||||
const before = JSON.stringify(input);
|
||||
@@ -83,7 +120,8 @@ describe("manual receipt proposal is evidence preparation, not authorization", (
|
||||
|
||||
test("OCR failure requires independent review even with fully matching manual fields", () => {
|
||||
const input = fixture();
|
||||
input.original = { status: "ocr_failed", pagesExpected: 1, pagesProcessed: 0, pages: [] };
|
||||
input.original = { status: "ocr_failed", pagesExpected: 1, pagesProcessed: 0, pages: [],
|
||||
documentPages: [{ pageNumber: 1, document: syntheticDocumentEvidence() }] };
|
||||
expect(buildManualReceiptProposal(config, input).requiresIndependentReview).toBe(true);
|
||||
});
|
||||
|
||||
@@ -107,7 +145,8 @@ describe("manual receipt proposal is evidence preparation, not authorization", (
|
||||
const input = fixture();
|
||||
input.pages[0].fields.find((field) => field.Name === name).Value = value;
|
||||
input.reason = "测试理由不能免除核验";
|
||||
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_manual_match_required");
|
||||
expect(() => buildManualReceiptProposal(config, input)).toThrow(name === "单据状态" ?
|
||||
"receipt_document_proof_required" : "receipt_manual_match_required");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -132,6 +171,7 @@ describe("manual receipt proposal is evidence preparation, not authorization", (
|
||||
test("duplicate transaction on two pages cannot pay two items", () => {
|
||||
const input = fixture();
|
||||
input.original.pagesExpected = 2;
|
||||
input.original.documentPages.push({ pageNumber: 2, document: syntheticDocumentEvidence() });
|
||||
input.pages.push({ ...input.pages[0], pageNumber: 2, itemId: "item-1" });
|
||||
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_reused");
|
||||
});
|
||||
@@ -139,6 +179,7 @@ describe("manual receipt proposal is evidence preparation, not authorization", (
|
||||
test("two transactions cannot be assigned to one reimbursement", () => {
|
||||
const input = fixture();
|
||||
input.original.pagesExpected = 2;
|
||||
input.original.documentPages.push({ pageNumber: 2, document: syntheticDocumentEvidence() });
|
||||
input.pages.push({ ...input.pages[0], pageNumber: 2,
|
||||
fields: input.pages[0].fields.map((field) => field.Name === "凭证号" ?
|
||||
{ ...field, Value: "SYN-SECOND" } : field) });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { encryptStringOrThrow, decryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
||||
const { buildVerificationData, claimVerifiedReceipts, transactionFingerprint, canRetryReceiptRecognition } = require("../../../src/services/paymentReceiptVerification");
|
||||
const { syntheticDocumentEvidence } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs");
|
||||
|
||||
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
||||
|
||||
@@ -8,7 +9,7 @@ function fixture() {
|
||||
const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({
|
||||
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-REF",
|
||||
}).map(([Name, Value]) => ({ Name, Value }))) };
|
||||
}).map(([Name, Value]) => ({ Name, Value }))), document: syntheticDocumentEvidence() };
|
||||
const batch = {
|
||||
id: "batch", entity_type: "reimbursement",
|
||||
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, page.payerAccount) },
|
||||
@@ -31,6 +32,23 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("receipt evidence and transaction claims", () => {
|
||||
test.each([null, { version: 0 }, { ...syntheticDocumentEvidence(), kind: "non_final" },
|
||||
{ ...syntheticDocumentEvidence(), direction: "credit" }, { ...syntheticDocumentEvidence(), explicitSuccess: false }])(
|
||||
"field matches alone cannot prove final payment (%j)", (document) => {
|
||||
const { batch, attachment, recognition } = fixture();
|
||||
recognition.pages[0].document = document;
|
||||
const data = buildVerificationData(config, batch, attachment, recognition);
|
||||
expect(data.status).toBe("needs_review");
|
||||
expect(data.matches).toBeUndefined();
|
||||
});
|
||||
|
||||
test("final claim rebuild rejects an old matched record with no document basis", async () => {
|
||||
const { tx, batch, verification, recognition } = fixture();
|
||||
delete recognition.pages[0].document;
|
||||
verification.normalized_encrypted = encryptStringOrThrow(config, JSON.stringify(recognition));
|
||||
await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_match_changed" });
|
||||
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||
});
|
||||
test("bank transactions already used for supplements or returns cannot be claimed again", async () => {
|
||||
const { tx, batch } = fixture();
|
||||
tx.loanBankEventClaim.count.mockResolvedValue(1);
|
||||
|
||||
在新工单中引用
屏蔽一个用户