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