完善付款回单凭证核验与本地重检(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 行删除
+263
查看文件
@@ -35,6 +35,7 @@
"nodemailer": "^9.0.3",
"openid-client": "^6.8.2",
"pdf-lib": "1.17.1",
"pdfjs-dist": "6.4.299",
"pg": "^8.18.0",
"pg-boss": "^10.3.0",
"prisma": "^7.8.0",
@@ -2010,6 +2011,256 @@
"node": ">=8"
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.10",
"@napi-rs/canvas-darwin-arm64": "1.0.10",
"@napi-rs/canvas-darwin-x64": "1.0.10",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz",
"integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz",
"integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz",
"integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz",
"integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz",
"integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz",
"integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz",
"integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz",
"integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz",
"integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz",
"integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@nodable/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
@@ -7593,6 +7844,18 @@
"tslib": "^1.11.1"
}
},
"node_modules/pdfjs-dist": {
"version": "6.4.299",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
"integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.10"
}
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
+1
查看文件
@@ -79,6 +79,7 @@
"nodemailer": "^9.0.3",
"openid-client": "^6.8.2",
"pdf-lib": "1.17.1",
"pdfjs-dist": "6.4.299",
"pg": "^8.18.0",
"pg-boss": "^10.3.0",
"prisma": "^7.8.0",
+2
查看文件
@@ -803,6 +803,8 @@ const ERROR_REASONS = [
{"code":"payment_batch.receipt_transaction_reused","domain":"payment_batch","audience":"user","defaultMessage":"回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_manual_state_changed","domain":"payment_batch","audience":"user","defaultMessage":"回单或核验版本已变化,请刷新后重新查看当前状态。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_manual_verification_required","domain":"payment_batch","audience":"user","defaultMessage":"回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_document_check_required","domain":"payment_batch","audience":"user","defaultMessage":"这份回单尚未检查凭证性质,请在核验详情中更新凭证核验后重新核对。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_final_evidence_required","domain":"payment_batch","audience":"user","defaultMessage":"当前材料不能证明本笔付款已完成。请核对正式回单的实际付款或记账依据;非最终回执、入账方向不符或证据不明时不能登记付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_key_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_key_rotation_required","domain":"payment_batch","audience":"user","defaultMessage":"回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_snapshot_unsupported","domain":"payment_batch","audience":"user","defaultMessage":"付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。","recovery":"follow_guidance"},
+28 -1
查看文件
@@ -13,7 +13,7 @@ const { uploadTerminalEvidence, voidTerminalEvidence, proposeTerminalFact, revie
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { ERROR_REASONS } = require("../constants/errorReasons");
const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification");
const { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail } = require("../services/paymentReceiptManualPersistence");
const { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail, refreshReceiptDocument } = require("../services/paymentReceiptManualPersistence");
const { resolvePaymentBatchCreationReadiness, paymentReadinessError } = require("../services/paymentBatchReadiness");
const crypto = require("node:crypto");
const path = require("node:path");
@@ -113,6 +113,11 @@ const RECEIPT_MANUAL_SCHEMA = z.object({
fields: z.array(z.object({
Name: z.string().max(64), Value: z.string().max(256),
}).strict()).min(1).max(200).optional(),
paymentProof: z.object({
basis: z.enum(["explicit_success", "booked_debit"]), originalChecked: z.literal(true),
note: z.string().trim().min(5).max(500),
bookedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(),
}).strict().optional(),
}).strict()).min(1).max(20),
}).strict();
@@ -124,6 +129,12 @@ const RECEIPT_MANUAL_REVIEW_SCHEMA = z.object({
function mapManualReceiptError(error) {
if (!(error instanceof ReceiptVerificationError)) throw error;
if (error.code === "receipt_document_check_required") {
throw buildBusinessValidationError("payment_batch.receipt_document_check_required", {}, 409);
}
if (["receipt_document_not_final", "receipt_document_proof_required"].includes(error.code)) {
throw buildBusinessValidationError("payment_batch.receipt_final_evidence_required", {}, 409);
}
if (error.code === "receipt_key_unavailable") {
throw buildBusinessValidationError("payment_batch.receipt_key_unavailable", {}, 409);
}
@@ -2611,6 +2622,22 @@ async function paymentBatchesRoutes(fastify) {
}
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/document-check", { preHandler: authenticateBatch }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const body = parseBody(z.object({ expectedVersion: z.number().int().min(0).max(2147483646) }).strict(), request.body);
try {
const item = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
return refreshReceiptDocument(tx, fastify.config, request, { ...body, batchId, attachmentId });
}, { timeout: 10000 });
return sendData(reply, request, { item });
} catch (error) { mapManualReceiptError(error); }
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: authenticateBatch }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
@@ -0,0 +1,131 @@
const { Worker } = require("node:worker_threads");
const path = require("node:path");
const { isValidDateOnlyString, currentDateString } = require("../utils/date");
const DOCUMENT_VERSION = 1;
const NON_FINAL = /不(?:能)?(?:作为|用作).{0,32}(?:最终|记账|入账).{0,20}(?:依据|凭证)|(?:正式|最终)回单.{0,16}(?:另行|另|重新)(?:打印|下载)|仅(?:表示|代表).{0,12}(?:受理|提交)|仅供参考/u;
const SUCCESS = /(?:单据状态|交易状态)[::]?(?:交易|转账|付款|支付)成功/u;
const NEGATIVE = /(?:单据状态|交易状态)[::]?(?:交易失败|转账失败|付款失败|支付失败|已撤销|已冲正|已退汇|未成功)/u;
const PENDING = /(?:单据状态|交易状态)[::]?(?:已受理|受理成功|处理中|待处理)/u;
function unknownDocument(source = "unreadable") {
return { version: DOCUMENT_VERSION, kind: "unknown", source, direction: null,
explicitSuccess: false, issues: [] };
}
// This describes the document, not bank authenticity or a payment authorization.
// A recognizable title alone never establishes a completed payment.
function classifyReceiptText(text) {
if (typeof text !== "string" || text.length > 100000) return unknownDocument();
const compact = text.normalize("NFKC").replace(/\s/g, "");
const result = unknownDocument("pdf_text");
if (NON_FINAL.test(compact)) result.kind = "non_final";
const issues = [];
if (NEGATIVE.test(compact)) issues.push("document_transaction_failed");
if (PENDING.test(compact)) issues.push("document_transaction_pending");
const title = "中国建设银行单位客户专用回单";
const count = compact.split(title).length - 1;
if (count > 1) issues.push("document_multiple_receipts");
const readable = compact.length >= 50 && !/[\uFFFD\uE000-\uF8FF]/u.test(compact);
if (result.kind !== "non_final" && readable && count === 1 && compact.includes("金额") &&
/币[别种]/u.test(compact) && /(?:流水号|凭证号码?)/u.test(compact) && /记[账帐]/u.test(compact)) {
result.kind = "bank_receipt";
}
// Only interpret the standalone debit/credit cell of this known receipt type.
const directions = new Set(text.split(/\n/).map((s) => s.trim()).filter((s) => ["借", "贷", "借方", "贷方"].includes(s))
.map((s) => s.startsWith("借") ? "debit" : "credit"));
if (result.kind === "bank_receipt" && directions.size === 1) result.direction = [...directions][0];
if (directions.size > 1) issues.push("document_direction_ambiguous");
result.explicitSuccess = result.kind === "bank_receipt" && SUCCESS.test(compact);
result.issues = issues;
return result;
}
function withProviderRestrictions(document, fields) {
const result = safeDocument(document) ?? unknownDocument();
if (!Array.isArray(fields)) return result;
const text = fields.slice(0, 200).map((field) =>
`${typeof field?.Name === "string" ? field.Name : ""}:${typeof field?.Value === "string" ? field.Value : ""}`)
.join(" ").normalize("NFKC").replace(/\s/g, "").slice(0, 100000);
if (NON_FINAL.test(text)) result.kind = "non_final";
return result;
}
function safeDocument(document) {
if (document?.version !== DOCUMENT_VERSION || !["bank_receipt", "non_final", "unknown"].includes(document.kind) ||
!["pdf_text", "image", "unreadable", "manual"].includes(document.source) ||
![null, "debit", "credit"].includes(document.direction) ||
typeof document.explicitSuccess !== "boolean" || !Array.isArray(document.issues) ||
document.issues.some((issue) => !["document_transaction_failed", "document_transaction_pending",
"document_multiple_receipts", "document_direction_ambiguous"].includes(issue))) return null;
const result = { version: DOCUMENT_VERSION, kind: document.kind, source: document.source,
direction: document.direction, explicitSuccess: document.explicitSuccess, issues: [...new Set(document.issues)] };
if (document.source === "manual") {
const proof = safeManualProof(document.proof);
if (!proof) return null;
result.proof = proof;
}
return result;
}
function documentForPage(recognition, pageNumber) {
const page = recognition?.pages?.find((entry) => entry.pageNumber === pageNumber);
// An explicitly invalid or obsolete page result must not borrow a different
// successful inspection. The fallback is only for pages OCR never processed.
if (page && Object.hasOwn(page, "document")) return safeDocument(page.document);
return safeDocument(recognition?.documentPages?.find((entry) => entry.pageNumber === pageNumber)?.document);
}
function documentBlocksPayment(document) {
return document?.kind === "non_final" || document?.direction === "credit" || Boolean(document?.issues?.length);
}
function paymentDocumentReady(page, { allowManual = false } = {}) {
const document = safeDocument(page?.document);
if (!document || document.kind !== "bank_receipt" || documentBlocksPayment(document)) return false;
if (document.source === "manual") return allowManual &&
(document.proof.basis === "booked_debit" || (document.proof.basis === "explicit_success" && page.transactionStatus === "success"));
return document.source === "pdf_text" && document.explicitSuccess && page.transactionStatus === "success";
}
function safeManualProof(proof) {
if (!proof || proof.originalChecked !== true || !["explicit_success", "booked_debit"].includes(proof.basis) ||
typeof proof.note !== "string" || proof.note.trim().length < 5 || proof.note.length > 500) return null;
if (proof.basis === "booked_debit" && (typeof proof.bookedOn !== "string" || !isValidDateOnlyString(proof.bookedOn) ||
proof.bookedOn > currentDateString("Asia/Shanghai"))) return null;
if (proof.basis === "explicit_success" && proof.bookedOn !== undefined && proof.bookedOn !== null) return null;
return { basis: proof.basis, originalChecked: true, note: proof.note.trim(),
...(proof.basis === "booked_debit" ? { bookedOn: proof.bookedOn } : {}) };
}
async function inspectReceiptDocument({ buffer, mimeType, pagesExpected }) {
if (!Number.isInteger(pagesExpected) || pagesExpected < 1 || pagesExpected > 20) return [];
const unknown = () => Array.from({ length: pagesExpected }, (_, index) => ({
pageNumber: index + 1, document: unknownDocument(mimeType === "application/pdf" ? "unreadable" : "image"),
}));
if (mimeType !== "application/pdf" || !Buffer.isBuffer(buffer) || !buffer.length || buffer.length > 5 * 1024 * 1024) return unknown();
// Bound hostile PDF parsing independently of the request/event loop. No URLs,
// external fonts, OCR calls, raw text logging or persisted extraction payloads.
return new Promise((resolve) => {
const worker = new Worker(path.join(__dirname, "paymentReceiptDocumentWorker.js"), {
workerData: { bytes: new Uint8Array(buffer), pagesExpected },
resourceLimits: { maxOldGenerationSizeMb: 128 }, stdout: true, stderr: true,
});
worker.stdout.resume(); worker.stderr.resume();
let settled = false;
const finish = (pages) => {
if (settled) return;
settled = true; clearTimeout(timer); void worker.terminate(); resolve(pages);
};
const timer = setTimeout(() => finish(unknown()), 5000);
worker.once("message", (message) => {
if (!Array.isArray(message?.texts) || message.texts.length !== pagesExpected) return finish(unknown());
finish(message.texts.map((text, index) => ({ pageNumber: index + 1, document: classifyReceiptText(text) })));
});
worker.once("error", () => finish(unknown()));
worker.once("exit", () => finish(unknown()));
});
}
module.exports = { DOCUMENT_VERSION, unknownDocument, classifyReceiptText, withProviderRestrictions,
safeDocument, safeManualProof, documentForPage, documentBlocksPayment, paymentDocumentReady, inspectReceiptDocument };
@@ -0,0 +1,34 @@
const { parentPort, workerData } = require("node:worker_threads");
(async () => {
let task;
try {
const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
task = getDocument({ data: workerData.bytes, verbosity: 0, stopAtErrors: true,
isEvalSupported: false, useSystemFonts: false, disableFontFace: true, useWorkerFetch: false, useWasm: false });
const pdf = await task.promise;
if (pdf.numPages !== workerData.pagesExpected) throw new Error("page_count");
const texts = [];
for (let number = 1; number <= pdf.numPages; number++) {
const page = await pdf.getPage(number);
const content = await page.getTextContent();
if (content.items.length > 20000) throw new Error("text_limit");
let y = null;
const pieces = [];
for (const item of content.items) {
if (typeof item.str !== "string") continue;
const nextY = Math.round(item.transform?.[5] ?? 0);
if (y !== null && Math.abs(nextY - y) > 2) pieces.push("\n");
pieces.push(item.str, item.hasEOL ? "\n" : " "); y = nextY;
}
const text = pieces.join("");
if (text.length > 100000) throw new Error("text_limit");
texts.push(text); page.cleanup();
}
parentPort.postMessage({ texts });
} catch {
parentPort.postMessage({ failed: true });
} finally {
if (task) await task.destroy().catch(() => {});
}
})().catch(() => parentPort.postMessage({ failed: true }));
@@ -1,4 +1,4 @@
const { ReceiptVerificationError, batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
const { ReceiptVerificationError, batchCandidates, transactionFingerprint, storeReceiptRecognition } = require("./paymentReceiptVerification");
const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("./paymentReceiptManualReview");
const { encryptStringOrThrow, decryptStringOrThrow } = require("./crypto");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
@@ -7,6 +7,10 @@ const { normalizeBankSlip } = require("./paymentReceiptMatching");
const { maskBankAccount } = require("./crypto");
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
const loanApprovalPolicy = require("./loanApprovalPolicy");
const { safeManualProof, safeDocument, documentForPage, inspectReceiptDocument } = require("./paymentReceiptDocument");
const { createHash } = require("node:crypto");
const { readFileBuffer } = require("./storage");
const { PDFDocument } = require("pdf-lib");
function requireFact(condition, code) {
if (!condition) throw new ReceiptVerificationError(code);
@@ -128,8 +132,10 @@ function safePages(pages) {
requireFact(Array.isArray(page?.fields) && page.fields.length > 0 && page.fields.length <= 200 &&
page.fields.every((field) => typeof field?.Name === "string" && field.Name.length <= 64 &&
typeof field.Value === "string" && field.Value.length <= 256), "receipt_manual_fields_invalid");
const proof = page.paymentProof === undefined ? undefined : safeManualProof(page.paymentProof);
requireFact(proof !== null, "receipt_document_proof_required");
return { pageNumber: page.pageNumber, itemId: page.itemId,
fields: page.fields.map(({ Name, Value }) => ({ Name, Value })) };
fields: page.fields.map(({ Name, Value }) => ({ Name, Value })), ...(proof ? { paymentProof: proof } : {}) };
});
}
@@ -144,7 +150,7 @@ function resolvePageFields(pages, original) {
const values = {
小写金额: amount, 币别: source?.currency ?? "", 付款人账号: source?.payerAccount ?? "",
收款人账号: source?.payeeAccount ?? "", 凭证号: source?.reference ?? "",
单据状态: source?.transactionStatus === "success" ? "交易成功" : "",
单据状态: source?.transactionStatus === "success" ? source.transactionStatusRaw ?? "" : "",
};
if (page.fields !== undefined) {
requireFact(Array.isArray(page.fields) && page.fields.length <= 7 &&
@@ -154,7 +160,8 @@ function resolvePageFields(pages, original) {
for (const field of page.fields) values[field.Name] = field.Value;
}
return { pageNumber: page.pageNumber, itemId: page.itemId,
fields: Object.entries(values).map(([Name, Value]) => ({ Name, Value })) };
fields: Object.entries(values).map(([Name, Value]) => ({ Name, Value })),
...(page.paymentProof !== undefined ? { paymentProof: page.paymentProof } : {}) };
});
}
@@ -164,7 +171,13 @@ function pageSummary(page) {
return { pageNumber: page.pageNumber, amount, currency: page.currency ?? null,
payerAccountMasked: page.payerAccount ? maskBankAccount(page.payerAccount) : null,
payeeAccountMasked: page.payeeAccount ? maskBankAccount(page.payeeAccount) : null,
transactionStatus: page.transactionStatus ?? null, reference: page.reference ?? null };
transactionStatus: page.transactionStatus ?? null, reference: page.reference ?? null,
document: safeDocument(page.document) };
}
function inspectionComplete(recognition) {
return Number.isInteger(recognition?.pagesExpected) && recognition.pagesExpected > 0 && recognition.pagesExpected <= 20 &&
Array.from({ length: recognition.pagesExpected }, (_, i) => documentForPage(recognition, i + 1)).every(Boolean);
}
async function readManualReceiptDetail(tx, config, { batchId, attachmentId, reviewer, hasReviewPermission }) {
@@ -179,11 +192,13 @@ async function readManualReceiptDetail(tx, config, { batchId, attachmentId, revi
where: { attachment_id: attachmentId }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
if (!original) return { version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
pagesExpected: 0, pages: [], proposal: null };
documentInspectionRequired: true, pagesExpected: 0, pages: [], proposal: null };
requireFact(original.attachment_id === attachmentId && original.source_sha256 === attachment.file_sha256 &&
original.source_version + (proposal ? 1 : 0) === attachment.receipt_ocr_version &&
(!proposal || proposal.source_sha256 === attachment.file_sha256), "receipt_source_changed");
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
requireFact(Number.isInteger(recognition.pagesExpected) && recognition.pagesExpected >= 0 &&
recognition.pagesExpected <= 20 && Array.isArray(recognition.pages), "receipt_pages_unresolved");
const payload = proposal ? JSON.parse(decryptStringOrThrow(config, proposal.payload_encrypted)) : null;
const batch = proposal ? await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } }) : null;
let canReview = false;
@@ -204,17 +219,81 @@ async function readManualReceiptDetail(tx, config, { batchId, attachmentId, revi
}
return {
canReview,
documentInspectionRequired: !inspectionComplete(recognition),
version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
pagesExpected: recognition.pagesExpected, pages: recognition.pages.map(pageSummary),
pagesExpected: recognition.pagesExpected, pages: Array.from({ length: recognition.pagesExpected }, (_, index) => {
const pageNumber = index + 1;
return pageSummary({ ...recognition.pages.find((page) => page.pageNumber === pageNumber),
pageNumber, document: documentForPage(recognition, pageNumber) });
}),
proposal: proposal ? { id: proposal.id, status: proposal.status, proposedBy: proposal.proposed_by,
approvalMode: batch?.entity_type === "loan_repayment" ? (await loanApprovalPolicy.repaymentFor(tx, batch)).approval_mode : "independent",
reviewedBy: proposal.reviewed_by, reason: payload.reason, changedFields: payload.changedFields,
pages: payload.pages.map((page) => ({
...pageSummary({ pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) }), itemId: page.itemId,
})) } : null,
pages: payload.pages.map((page) => {
const proof = safeManualProof(page.paymentProof);
return { ...pageSummary({ pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields),
...(proof?.basis === "booked_debit" ? { transactionStatus: "success" } : {}),
document: proof ? { version: 1, kind: "bank_receipt", source: "manual", direction: "debit",
explicitSuccess: proof.basis === "explicit_success", issues: [], proof } : documentForPage(recognition, page.pageNumber),
}), itemId: page.itemId };
}) } : null,
};
}
async function refreshReceiptDocument(tx, config, request, input) {
const { batch, attachment } = await lockContext(tx, input);
await loanApprovalPolicy.assertMaker(tx, batch, request.employee.id);
requireFact(attachment.receipt_ocr_version < 2147483647 &&
["legacy", "matched", "needs_review", "ocr_failed", "manual_rejected", "manual_approved", "association_ready", "pending_review"]
.includes(attachment.receipt_ocr_state), "receipt_manual_not_available");
requireFact(await tx.paymentReceiptClaim.count({ where: { verification: { attachment_id: attachment.id } } }) === 0,
"receipt_transaction_reused");
const proposal = await tx.paymentReceiptManualProposal.findUnique({
where: { attachment_id_source_version: { attachment_id: attachment.id, source_version: attachment.receipt_ocr_version } },
include: { original_verification: true },
});
const original = proposal?.original_verification ?? await tx.paymentReceiptVerification.findFirst({
where: { attachment_id: attachment.id }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
if (original) requireFact(original.source_sha256 === attachment.file_sha256 &&
original.source_version + (proposal ? 1 : 0) === attachment.receipt_ocr_version, "receipt_source_changed");
const recognition = original ? JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted)) :
{ status: "ocr_failed", pagesExpected: 0, pagesProcessed: 0, pages: [], reason: "receipt_not_recognized" };
requireFact(!inspectionComplete(recognition), "receipt_manual_not_available");
requireFact(attachment.file_size > 0 && attachment.file_size <= 5 * 1024 * 1024, "receipt_unavailable");
let buffer;
try { buffer = await readFileBuffer({ config, storagePath: attachment.storage_path }); }
catch { throw new ReceiptVerificationError("receipt_unavailable"); }
requireFact(buffer.length === attachment.file_size && createHash("sha256").update(buffer).digest("hex") === attachment.file_sha256,
"receipt_source_changed");
let pagesExpected;
try {
if (attachment.mime_type === "application/pdf") pagesExpected = (await PDFDocument.load(buffer)).getPageCount();
else {
requireFact(["image/png", "image/jpeg", "image/webp"].includes(attachment.mime_type), "receipt_unavailable");
const meta = await require("sharp")(buffer, { limitInputPixels: 40000000 }).metadata();
requireFact((meta.pages ?? 1) === 1, "receipt_unavailable"); pagesExpected = 1;
}
} catch { throw new ReceiptVerificationError("receipt_unavailable"); }
requireFact(pagesExpected > 0 && pagesExpected <= 20 && (!recognition.pagesExpected || recognition.pagesExpected === pagesExpected),
"receipt_pages_unresolved");
const documentPages = await inspectReceiptDocument({ buffer, mimeType: attachment.mime_type, pagesExpected });
requireFact(Array.isArray(recognition.pages), "receipt_pages_unresolved");
const current = { ...recognition, pagesExpected, documentPages,
pages: recognition.pages.map((page) => ({ ...page, document: documentPages[page.pageNumber - 1]?.document })) };
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: {
receipt_ocr_version: { increment: 1 }, receipt_ocr_state: "needs_review", receipt_ocr_started_at: null,
} });
const verification = await storeReceiptRecognition(tx, config, { batchId: batch.id, attachmentId: attachment.id,
sourceSha256: attachment.file_sha256, expectedVersion: attachment.receipt_ocr_version + 1, recognition: current });
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: { receipt_ocr_state: verification.status } });
await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id,
action: "PAYMENT_RECEIPT_DOCUMENT_CHECKED", targetType: "payment_batch", targetId: batch.id,
extra: { attachmentId: attachment.id, verificationId: verification.id, version: attachment.receipt_ocr_version + 1,
documentVersion: 1, cloudOcrCalled: false } }) });
return { id: attachment.id, status: verification.status, version: attachment.receipt_ocr_version + 1 };
}
function summarize(proposal) {
return { id: proposal.id, status: proposal.status, version: proposal.source_version };
}
@@ -310,4 +389,4 @@ async function reviewManualReceiptProposal(tx, config, request, input, hasReview
return summarize(reviewed);
}
module.exports = { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail, loadApprovedManualReceiptEvidence };
module.exports = { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail, loadApprovedManualReceiptEvidence, refreshReceiptDocument };
@@ -1,5 +1,6 @@
const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching");
const { ReceiptVerificationError, transactionFingerprint } = require("./paymentReceiptVerification");
const { documentForPage, documentBlocksPayment, paymentDocumentReady, safeManualProof } = require("./paymentReceiptDocument");
const FIELDS = ["amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference"];
const BLOCKED_ISSUES = ["transaction_failed", "transaction_pending"];
@@ -55,12 +56,34 @@ function buildManualReceiptProposal(config, { original, pages, candidates }) {
requireFact(!previous || (!["failed", "pending"].includes(previous.transactionStatus) &&
!(previous.issues ?? []).some((issue) => BLOCKED_ISSUES.includes(issue))),
"receipt_transaction_not_successful");
const document = documentForPage(original, page.pageNumber);
requireFact(document, "receipt_document_check_required");
requireFact(!documentBlocksPayment(document), "receipt_document_not_final");
const normalized = { pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) };
normalized.document = document;
if (page.paymentProof !== undefined) {
const proof = safeManualProof(page.paymentProof);
requireFact(proof, "receipt_document_proof_required");
if (proof.basis === "booked_debit") {
// Booked debit is a separate bank fact, not invented OCR status wording.
requireFact(normalized.transactionStatus === null && normalized.issues.includes("transactionStatus_unresolved") &&
!page.fields.some((field) => ["单据状态", "交易状态"].includes(field.Name?.trim()) && field.Value?.trim()),
"receipt_document_proof_required");
normalized.transactionStatus = "success";
normalized.transactionStatusRaw = null;
normalized.issues = normalized.issues.filter((issue) => issue !== "transactionStatus_unresolved");
} else requireFact(normalized.transactionStatus === "success", "receipt_document_proof_required");
normalized.document = { version: document.version, kind: "bank_receipt", source: "manual", direction: "debit",
explicitSuccess: proof.basis === "explicit_success", issues: [], proof };
requiresIndependentReview = true;
}
requireFact(paymentDocumentReady(normalized, { allowManual: true }), "receipt_document_proof_required");
const candidate = candidates.find((entry) => entry.id === page.itemId);
requireFact(candidate, "receipt_item_unavailable");
const match = matchBankSlip(normalized, [candidate]);
requireFact(match.status === "matched", "receipt_manual_match_required");
const changed = FIELDS.filter((field) => previous?.[field] !== normalized[field]);
if (page.paymentProof !== undefined) changed.push("paymentProof");
if (changed.length || previous?.issues?.length) requiresIndependentReview = true;
const { fingerprint } = transactionFingerprint(config, normalized);
requireFact(!fingerprints.has(fingerprint), "receipt_transaction_reused");
+11 -1
查看文件
@@ -1,6 +1,7 @@
const { PDFDocument } = require("pdf-lib");
const sharp = require("sharp");
const { normalizeBankSlip } = require("./paymentReceiptMatching");
const { inspectReceiptDocument, withProviderRestrictions } = require("./paymentReceiptDocument");
function createReceiptOcrClient(config) {
if (config?.ocrProvider && config.ocrProvider !== "tencent") return null;
@@ -44,6 +45,7 @@ async function recognizePaymentReceipt({ buffer, mimeType, config, createClient
}
const encoded = payload.toString("base64");
if (encoded.length > 7 * 1024 * 1024) return { ...result, reason: "receipt_encoded_size_limit" };
result.documentPages = await inspectReceiptDocument({ buffer, mimeType, pagesExpected: result.pagesExpected });
let client;
try {
client = createClient(config);
@@ -54,7 +56,15 @@ async function recognizePaymentReceipt({ buffer, mimeType, config, createClient
const response = await client.BankSlipOCR({
ImageBase64: encoded, IsPdf: isPdf, ...(isPdf ? { PdfPageNumber: pageNumber } : {}),
});
result.pages.push({ pageNumber, ...normalizeBankSlip(response?.BankSlipInfos) });
const document = withProviderRestrictions(result.documentPages[pageNumber - 1]?.document, response?.BankSlipInfos);
result.documentPages[pageNumber - 1] = { pageNumber, document };
const normalized = normalizeBankSlip(response?.BankSlipInfos);
if ((document.explicitSuccess && ["failed", "pending"].includes(normalized.transactionStatus)) ||
(document.issues.includes("document_transaction_failed") && normalized.transactionStatus !== "failed") ||
(document.issues.includes("document_transaction_pending") && normalized.transactionStatus !== "pending")) {
normalized.issues = [...new Set([...normalized.issues, "transactionStatus_ambiguous"])];
}
result.pages.push({ pageNumber, ...normalized, document });
result.pagesProcessed += 1;
}
} catch {
@@ -1,6 +1,7 @@
const { createHmac, createHash } = require("node:crypto");
const { encryptStringOrThrow, decryptStringOrThrow, resolveFieldEncryptionKey } = require("./crypto");
const { matchBankSlip } = require("./paymentReceiptMatching");
const { documentForPage, safeDocument, paymentDocumentReady } = require("./paymentReceiptDocument");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { resolveItemPaymentChannel } = require("./paymentProcessingProgress");
@@ -53,7 +54,8 @@ function buildVerificationData(config, batch, attachment, recognition) {
requireFact(attachment.payment_batch_id === batch.id && !attachment.archived_at &&
attachment.governance_status === "approved", "receipt_unavailable");
const candidates = batchCandidates(config, batch);
const pages = recognition.pages ?? [];
const pages = (recognition.pages ?? []).map((page) => ({ ...page,
document: documentForPage(recognition, page.pageNumber) }));
const complete = recognition.status === "recognized" && recognition.pagesExpected > 0 &&
recognition.pagesExpected <= 20 && recognition.pagesProcessed === recognition.pagesExpected &&
pages.length === recognition.pagesExpected &&
@@ -61,6 +63,7 @@ function buildVerificationData(config, batch, attachment, recognition) {
const matches = [];
if (complete) {
for (const page of pages) {
if (!paymentDocumentReady(page)) break;
const match = matchBankSlip(page, candidates);
if (match.status !== "matched") break;
matches.push({ page_number: page.pageNumber, item_id: match.itemId, ...transactionFingerprint(config, page) });
@@ -77,8 +80,10 @@ function buildVerificationData(config, batch, attachment, recognition) {
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
status: recognition.status, pagesExpected: recognition.pagesExpected,
pagesProcessed: recognition.pagesProcessed, reason: recognition.reason,
...(Array.isArray(recognition.documentPages) ? { documentPages: recognition.documentPages.slice(0, 20).map((page) =>
({ pageNumber: page.pageNumber, document: safeDocument(page.document) })) } : {}),
pages: pages.map((page) => Object.fromEntries(
["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "transactionStatusRaw", "reference", "issues"]
["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "transactionStatusRaw", "reference", "issues", "document"]
.map((key) => [key, page[key]]),
)),
})),
@@ -803,6 +803,8 @@
"payment_batch.source_status_invalid": "来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。",
"payment_batch.already_paid_conflict": "来源单据已有不同的付款记录,请核实已有记录,勿重复付款。",
"payment_batch.source_amount_changed": "来源单据金额已变化,请核对单据后重新安排付款。",
"payment_batch.receipt_document_check_required": "这份回单尚未检查凭证性质,请在核验详情中更新凭证核验后重新核对。",
"payment_batch.receipt_final_evidence_required": "当前材料不能证明本笔付款已完成。请核对正式回单的实际付款或记账依据;非最终回执、入账方向不符或证据不明时不能登记付款。",
"payment_batch.settled_transaction_required": "尚未找到已结算的交易,请确认结算记录后再登记付款。",
"payment_batch.receipt_security_failed": "付款回单未通过安全检查,请联系管理员核实并重新上传有效回单。",
"payment_batch.receipt_field_invalid": "请通过回单上传入口重新选择文件。",
@@ -535,6 +535,7 @@ function buildPrisma(options = {}) {
小写金额: String(item.amount), 币别: item.currency_code, 付款人账号: payerAccount,
收款人账号: payeeAccount, 单据状态: "交易成功", 凭证号: `SYN-${item.id}`,
}).map(([Name, Value]) => ({ Name, Value }))),
document: require("../../../../scripts/qa/synthetic-bank-receipt.cjs").syntheticDocumentEvidence(),
}));
const data = buildVerificationData(config, state.batch, attachment, {
status: "recognized", pagesExpected: pages.length, pagesProcessed: pages.length, pages,
@@ -573,7 +574,8 @@ describe("manual receipt verification routes", () => {
source_version: 0, source_sha256: attachment.file_sha256,
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
status: "needs_review", pagesExpected: 1, pagesProcessed: 1,
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), amount: null, issues: ["amount_unresolved"] }],
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), amount: null, issues: ["amount_unresolved"],
document: require("../../../../scripts/qa/synthetic-bank-receipt.cjs").syntheticDocumentEvidence() }],
})) };
let stored;
tx.paymentReceiptVerification = { findFirst: vi.fn(async () => original) };
@@ -644,6 +646,26 @@ describe("manual receipt verification routes", () => {
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each([0, -1, 21, 1000000000])("bounds historical page metadata while allowing local reinspection: %s", async (pagesExpected) => {
const { app, tx, state, tempDir } = await buildApp();
setupEvidence(tx, state);
const original = await tx.paymentReceiptVerification.findFirst();
original.normalized_encrypted = encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, JSON.stringify({
status: "ocr_failed", pagesExpected, pagesProcessed: 0, pages: [],
}));
state.receiptAttachments[0].receipt_ocr_state = "ocr_failed";
try {
const response = await app.inject({ method: "GET", url, headers });
expect(response.statusCode).toBe(pagesExpected === 0 ? 200 : 409);
if (pagesExpected === 0) expect(response.json().data.item).toMatchObject({
documentInspectionRequired: true, pagesExpected: 0, pages: [], proposal: null,
});
expect(tx.paymentReceiptManualProposal.create).not.toHaveBeenCalled();
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each(["foreign", "voided", "stale"])("does not expose details for %s evidence", async (stateKind) => {
const { app, tx, state, tempDir } = await buildApp();
setupEvidence(tx, state);
@@ -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);