完善付款回单凭证核验与本地重检(09-17-company-loan-principal-repayment)

区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。

正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。

本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。

本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。

限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
这个提交包含在:
2026-10-09 06:18:09 +08:00
父节点 ce11dea976
当前提交 5a7a13524e
共修改 31 个文件,包含 1325 行新增和 49 行删除
@@ -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) });