完善付款回单凭证核验与本地重检(09-17-company-loan-principal-repayment)
区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。 正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。 本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。 本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。 限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
这个提交包含在:
+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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户