完善付款回单凭证核验与本地重检(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 行删除
+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]]),
)),
})),