借款登记支持上传原借入回单识别预填(09-17-company-loan-principal-repayment)
- 新增识别与保存接口:可读 PDF 本地解析,必要时有界调用现有回单 OCR;预填上下文加密绑定经办人、文件和 30 分钟 - 适配建行单位客户专用回单表格版式:按位置归行排序、竖排栏头、顶行无标签日期、大小写金额 - 拦截非最终、失败/处理中、多笔交易(含跨页无关联、编号冲突)、非人民币、公司支出和其他收款主体 - 金额、日期、编号整段校验;借款与原件同事务保存,失败回滚并清理对象;同一出借人原件唯一 - 保存绑定所选出借人版本;前端冻结核对时版本,迟到结果与切换账号不覆盖、不重发 - 测试:单测、隔离 HTTP/浏览器、变异;对账浏览器场景固定 UTC;五轮独立复核通过 - 无新增迁移 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -1,4 +1,8 @@
|
||||
const ERROR_REASONS = [
|
||||
{"code":"loan.incoming_receipt_invalid","domain":"loan","audience":"user","defaultMessage":"请上传 5MB 以内、不超过 20 页的单份 PDF 原借入回单;文件损坏或无法安全读取时请改用手工登记。","recovery":"follow_guidance"},
|
||||
{"code":"loan.incoming_receipt_blocked","domain":"loan","audience":"user","defaultMessage":"该回单不能作为本笔借入原件:可能是非最终回执、失败或处理中、公司支出、多笔交易、非人民币或收款主体不符。请更换正式入账回单或改用手工登记。","recovery":"follow_guidance"},
|
||||
{"code":"loan.incoming_receipt_context_invalid","domain":"loan","audience":"user","defaultMessage":"识别结果已过期,或与当前账号、所选文件不一致,请重新上传并识别。","recovery":"follow_guidance"},
|
||||
{"code":"loan.incoming_receipt_duplicate","domain":"loan","audience":"user","defaultMessage":"该原件已用于本出借人的其他借款登记,不能重复登记;请核对已有借款。","recovery":"follow_guidance"},
|
||||
{"code":"loan.employee_source_incomplete","domain":"loan","audience":"user","defaultMessage":"员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。","recovery":"follow_guidance"},
|
||||
{"code":"loan.employee_source_changed","domain":"loan","audience":"user","defaultMessage":"员工资料已变化,请重新加载并核对后再保存。","recovery":"follow_guidance"},
|
||||
{"code":"loan.employee_lender_conflict","domain":"loan","audience":"user","defaultMessage":"该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。","recovery":"follow_guidance"},
|
||||
|
||||
@@ -15,6 +15,7 @@ const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = requ
|
||||
const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy");
|
||||
const approvalPolicy = require("../services/loanApprovalPolicy");
|
||||
const employeeLenders = require("../services/loanLenderEmployee");
|
||||
const incomingReceipts = require("../services/loanIncomingReceipt");
|
||||
|
||||
const UUID = z.string().uuid();
|
||||
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString);
|
||||
@@ -29,12 +30,17 @@ const ACCOUNT = z.object({
|
||||
const LENDER = ACCOUNT.extend({ requestKey: UUID, legalEntityId: UUID, name: z.string().trim().min(1).max(120) }).strict();
|
||||
const IDENTITY = z.object({ expectedVersion: VERSION, employeeId: UUID.nullable(), externalPersonConfirmed: z.boolean(), reason: TEXT }).strict()
|
||||
.refine((v) => v.employeeId ? !v.externalPersonConfirmed : v.externalPersonConfirmed);
|
||||
const LOAN = z.object({
|
||||
const LOAN_FIELDS = {
|
||||
requestKey: UUID, lenderId: UUID, originReference: z.string().trim().min(1).max(200),
|
||||
originalPrincipal: MONEY, openingOutstandingPrincipal: MONEY,
|
||||
receivedDate: DATE, balanceAsOf: DATE, historicalEntry: z.boolean(),
|
||||
}).strict().refine((v) => new Prisma.Decimal(v.originalPrincipal).gt(0) &&
|
||||
new Prisma.Decimal(v.openingOutstandingPrincipal).lte(v.originalPrincipal) && v.receivedDate <= v.balanceAsOf);
|
||||
};
|
||||
const loanAmountsValid = (v) => new Prisma.Decimal(v.originalPrincipal).gt(0) &&
|
||||
new Prisma.Decimal(v.openingOutstandingPrincipal).lte(v.originalPrincipal) && v.receivedDate <= v.balanceAsOf;
|
||||
const LOAN = z.object(LOAN_FIELDS).strict().refine(loanAmountsValid);
|
||||
const INCOMING_SAVE = z.object({ ...LOAN_FIELDS, context: z.string().min(16).max(20000),
|
||||
expectedLenderIdentityVersion: VERSION, expectedLenderAccountVersion: VERSION,
|
||||
originalChecked: z.literal(true), natureConfirmed: z.literal(true) }).strict().refine(loanAmountsValid);
|
||||
const CONFIRM = z.object({ expectedVersion: VERSION, accountingConfirmationRef: z.string().trim().min(2).max(200) }).strict();
|
||||
const EVIDENCE_KIND = z.enum(["agreement", "incoming", "accounting_confirmation", "adjustment"]);
|
||||
const LIST = z.object({ lenderId: UUID.optional(), page: z.coerce.number().int().min(1).default(1),
|
||||
@@ -68,6 +74,38 @@ async function validateEvidenceFile(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// One PDF original, optionally preceded by one JSON field named "payload".
|
||||
async function readIncomingParts(request, { withPayload }) {
|
||||
let payload = null; let file = null;
|
||||
try {
|
||||
for await (const part of request.parts({ preservePath: true, limits: {
|
||||
files: 1, fields: withPayload ? 1 : 0, parts: withPayload ? 2 : 1, fileSize: incomingReceipts.MAX_BYTES, fieldSize: 32 * 1024,
|
||||
} })) {
|
||||
if (part.type === "field") {
|
||||
if (!withPayload || part.fieldname !== "payload" || payload !== null || file) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
try { payload = JSON.parse(String(part.value)); } catch { throw buildBusinessValidationError("loan.invalid_input", {}, 400); }
|
||||
continue;
|
||||
}
|
||||
if (part.fieldname !== "file" || file) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400);
|
||||
const buffer = await boundedBuffer(part.file);
|
||||
if (buffer.length > incomingReceipts.MAX_BYTES) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400);
|
||||
let validated;
|
||||
try { validated = await validateAndNormalizePurchaseAttachment({ buffer, fileName: part.filename, declaredMimeType: part.mimetype, maxBytes: incomingReceipts.MAX_BYTES }); }
|
||||
catch (error) {
|
||||
if (error instanceof PurchaseAttachmentPolicyError) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400);
|
||||
throw error;
|
||||
}
|
||||
if (validated.mimeType !== "application/pdf") throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400);
|
||||
file = validated;
|
||||
}
|
||||
} catch (error) {
|
||||
if (String(error.code ?? "").startsWith("FST_")) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400);
|
||||
throw error;
|
||||
}
|
||||
if (!file || (withPayload && payload === null)) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400);
|
||||
return { payload, file };
|
||||
}
|
||||
|
||||
async function companyLoansRoutes(fastify) {
|
||||
const auth = { preHandler: fastify.authenticate };
|
||||
const isFinance = (request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) &&
|
||||
@@ -293,6 +331,37 @@ async function companyLoansRoutes(fastify) {
|
||||
return sendData(reply, request, mapLoan(fastify.config, item));
|
||||
});
|
||||
|
||||
// Recognition proposes values only; nothing is stored. The sealed context
|
||||
// binds the result to this finance user, this exact file and 30 minutes.
|
||||
fastify.post("/company-loans/incoming-receipt/recognize", auth, async (request, reply) => {
|
||||
ensureLoanManager(fastify, request);
|
||||
const { file } = await readIncomingParts(request, { withPayload: false });
|
||||
const result = await incomingReceipts.recognize(fastify.prisma, fastify.config, request, file.buffer);
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return sendData(reply, request, result);
|
||||
});
|
||||
|
||||
fastify.post("/company-loans/from-incoming-receipt", auth, async (request, reply) => {
|
||||
ensureLoanManager(fastify, request);
|
||||
const { payload, file } = await readIncomingParts(request, { withPayload: true });
|
||||
const body = parse(INCOMING_SAVE, payload);
|
||||
const storagePath = await writeBuffer({ config: fastify.config, key: `company-loan-evidence/incoming/${randomUUID()}${file.extension}`,
|
||||
buffer: file.buffer, contentType: file.mimeType });
|
||||
let created = false;
|
||||
try {
|
||||
const result = await incomingReceipts.saveFromReceipt(fastify.prisma, fastify.config, request, {
|
||||
body, file, storagePath, timeZone: fastify.config.timeZone ?? "Asia/Shanghai" });
|
||||
created = result.created;
|
||||
return sendData(reply, request, { item: mapLoan(fastify.config, result.loan), created: result.created });
|
||||
} finally {
|
||||
if (!created) {
|
||||
try { await deleteObject({ config: fastify.config, storagePath }); } catch {
|
||||
request.log.error({ scope: "loan_incoming_receipt" }, "loan incoming original cleanup failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/company-loans/:id/confirm", auth, async (request, reply) => {
|
||||
ensureLoanManager(fastify, request);
|
||||
const id = parse(UUID, request.params.id); const body = parse(CONFIRM, request.body);
|
||||
@@ -314,7 +383,8 @@ async function companyLoansRoutes(fastify) {
|
||||
fastify.post("/company-loans/:id/evidences/:kind", auth, async (request, reply) => {
|
||||
ensureLoanManager(fastify, request);
|
||||
const id = parse(UUID, request.params.id); const kind = parse(EVIDENCE_KIND, request.params.kind);
|
||||
if (!await fastify.prisma.companyLoan.findUnique({ where: { id }, select: { id: true } })) throw loanNotFound();
|
||||
const target = await fastify.prisma.companyLoan.findUnique({ where: { id }, select: { id: true, lender_id: true } });
|
||||
if (!target) throw loanNotFound();
|
||||
const files = [];
|
||||
try {
|
||||
for await (const part of request.parts({ preservePath: true, limits: { files: 1, fields: 0, parts: 1, fileSize: MAX_BYTES } })) {
|
||||
@@ -333,7 +403,14 @@ async function companyLoansRoutes(fastify) {
|
||||
buffer: file.buffer, contentType: file.mimeType });
|
||||
try {
|
||||
const item = await fastify.prisma.$transaction(async (tx) => {
|
||||
await lockLoan(tx, id);
|
||||
// Same direction as repayment creation: lender, then loan. The incoming
|
||||
// original is unique per lender across this and the receipt entry.
|
||||
if (kind === "incoming") await lockLender(tx, target.lender_id);
|
||||
const loan = await lockLoan(tx, id);
|
||||
if (kind === "incoming") {
|
||||
if (loan.lender_id !== target.lender_id) throw loanNotFound();
|
||||
await incomingReceipts.assertIncomingUnused(tx, loan.lender_id, file.fileSha256, id);
|
||||
}
|
||||
const result = await tx.loanEvidence.create({ data: {
|
||||
loan_id: id, kind, original_name: file.originalName, storage_path: storagePath,
|
||||
mime_type: file.mimeType, file_size: file.fileSize, file_sha256: file.fileSha256,
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const { Prisma } = require("@prisma/client");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { isValidDateOnlyString, parseDateOnlyString, currentDateString } = require("../utils/date");
|
||||
const { encryptStringOrThrow, decryptStringOrThrow, maskBankAccount } = require("./crypto");
|
||||
const { extractReceiptTexts, classifyReceiptText } = require("./paymentReceiptDocument");
|
||||
const { parseReceiptMinorUnits, normalizeCurrency } = require("./paymentReceiptMatching");
|
||||
const { createReceiptOcrClient } = require("./paymentReceiptOcr");
|
||||
const { resolveNumberingContext, issueBusinessNumber } = require("./businessNumbering");
|
||||
const {
|
||||
LENDER_INCLUDE, LOAN_INCLUDE, auditLoan, lockLender, validateLenderReferences, readLenderAccount,
|
||||
} = require("./companyLoans");
|
||||
|
||||
// Prefill for registering an original borrow-in (company receives money).
|
||||
// Recognition never persists business data; it only proposes values for finance
|
||||
// to check. Payment-receipt rules (paymentDocumentReady) are deliberately not
|
||||
// reused: a company credit is the valid direction here.
|
||||
const PARSER_VERSION = 2;
|
||||
const PURPOSE = "loan_incoming_prefill";
|
||||
const CONTEXT_TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_PAGES = 20;
|
||||
const OCR_TOTAL_MS = 20000;
|
||||
const REQUIRED = ["amount", "receivedDate", "reference"];
|
||||
|
||||
const LABELS = {
|
||||
amount: ["(小写)", "小写金额", "交易金额", "入账金额", "转账金额", "金额"],
|
||||
receivedDate: ["记账日期", "交易日期", "入账日期", "记账时间", "交易时间"],
|
||||
serial: ["交易流水号", "流水号"],
|
||||
voucher: ["凭证号码", "凭证号"],
|
||||
currency: ["币别", "币种"],
|
||||
payerName: ["付款人名称", "付款人户名", "付款方名称", "付款方户名"],
|
||||
payerAccount: ["付款人账号", "付款方账号", "付款账号"],
|
||||
payeeName: ["收款人名称", "收款人户名", "收款方名称", "收款方户名"],
|
||||
payeeAccount: ["收款人账号", "收款方账号", "收款账号"],
|
||||
};
|
||||
// Labels that terminate a value but are never read (print/export stamps etc.).
|
||||
const STOP_LABELS = ["打印日期", "打印时间", "打印次数", "大写金额", "(大写)", "大写", "摘要", "用途", "附言", "备注", "单据状态", "交易状态",
|
||||
"开户行", "回单编号", "验证码", "柜员", "网点", "复核", "经办"];
|
||||
const ALL_LABELS = [...new Set([...Object.values(LABELS).flat(), ...STOP_LABELS])].sort((a, b) => b.length - a.length);
|
||||
const LABEL_RE = new RegExp(ALL_LABELS.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "gu");
|
||||
|
||||
function invalidFile() { return buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400); }
|
||||
function contextInvalid() { return buildBusinessValidationError("loan.incoming_receipt_context_invalid", {}, 409); }
|
||||
function blocked() { return buildBusinessValidationError("loan.incoming_receipt_blocked", {}, 409); }
|
||||
|
||||
function compact(value) { return String(value ?? "").normalize("NFKC").replace(/\s+/g, ""); }
|
||||
|
||||
// The whole value must be one date, optionally followed by a time of day.
|
||||
function parseDate(value) {
|
||||
const text = compact(value).replace(/^[::]+/u, "");
|
||||
const match = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?(?:\d{1,2}:\d{2}(?::\d{2})?)?$/u) ??
|
||||
text.match(/^(\d{4})(\d{2})(\d{2})$/u);
|
||||
if (!match) return null;
|
||||
const date = `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`;
|
||||
return isValidDateOnlyString(date) ? date : null;
|
||||
}
|
||||
|
||||
// The whole value must be one well-formed RMB amount. A malformed separator or
|
||||
// a third decimal is invalid, never truncated into a different valid amount.
|
||||
function parseAmount(value) {
|
||||
const match = compact(value).match(/^(?:人民币|CNY|RMB)?[¥¥]?((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?)元?$/u);
|
||||
return match ? parseReceiptMinorUnits(match[1]) : null;
|
||||
}
|
||||
|
||||
// The whole value must be one identifier; an unknown suffix or an over-long
|
||||
// value is invalid rather than cut down to a different, shorter number.
|
||||
function parseReference(value) {
|
||||
const match = compact(value).match(/^[::]?([A-Za-z0-9][A-Za-z0-9-]{3,63})$/u);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseAccount(value) {
|
||||
const match = compact(value).match(/^[::]?(\d{6,40})/u);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// Only the leading currency word is read ("人民币2025年…" on CCB header rows).
|
||||
const CURRENCY_WORDS = [["人民币", "CNY"], ["美元", "USD"], ["港币", "HKD"], ["港元", "HKD"], ["欧元", "EUR"], ["日元", "JPY"], ["英镑", "GBP"]];
|
||||
function parseCurrency(value) {
|
||||
const text = compact(value).replace(/^[::]+/u, "");
|
||||
const word = CURRENCY_WORDS.find(([name]) => text.startsWith(name));
|
||||
if (word) return word[1];
|
||||
const code = text.match(/^([A-Z]{3})(?![A-Z])/u)?.[1];
|
||||
return code ? normalizeCurrency(code === "RMB" ? "CNY" : code) : null;
|
||||
}
|
||||
|
||||
function parseName(value) {
|
||||
const text = compact(value).replace(/^[::]+/u, "");
|
||||
return text && text.length <= 120 && !/^\d+$/.test(text) ? text : null;
|
||||
}
|
||||
|
||||
const PARSERS = {
|
||||
amount: parseAmount, receivedDate: parseDate, serial: parseReference, voucher: parseReference,
|
||||
currency: parseCurrency,
|
||||
payerName: parseName, payerAccount: parseAccount, payeeName: parseName, payeeAccount: parseAccount,
|
||||
};
|
||||
const LABEL_FIELD = new Map(Object.entries(LABELS).flatMap(([field, labels]) => labels.map((label) => [label, field])));
|
||||
|
||||
// Read "label value" pairs from one text line. A value runs until the next known
|
||||
// label; a label without a value on the same line is ignored (no guessing from
|
||||
// arbitrary numbers or neighbouring cells).
|
||||
function lineValues(line) {
|
||||
const text = compact(line);
|
||||
const hits = [...text.matchAll(LABEL_RE)];
|
||||
const values = [];
|
||||
hits.forEach((hit, index) => {
|
||||
const field = LABEL_FIELD.get(hit[0]);
|
||||
if (!field) return;
|
||||
const raw = text.slice(hit.index + hit[0].length, hits[index + 1]?.index ?? text.length).replace(/^[::]+/u, "");
|
||||
const value = raw ? PARSERS[field](raw) : null;
|
||||
// A present but unreadable amount/date is recorded as invalid, not missing.
|
||||
if (value) values.push([field, value]);
|
||||
else if (raw && ["amount", "receivedDate", "serial", "voucher"].includes(field)) values.push([field, INVALID]);
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
const INVALID = Symbol("invalid");
|
||||
function collect(entries) {
|
||||
const found = { invalid: new Set() };
|
||||
for (const [field, value] of entries) {
|
||||
if (value === INVALID) found.invalid.add(field);
|
||||
else (found[field] ??= new Set()).add(value);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// Facts that make the file unusable for this entry. Distinct amounts or
|
||||
// distinct serial/voucher numbers mean more than one transaction; any foreign
|
||||
// or conflicting currency is a known contradiction. Neither can be cleared by
|
||||
// typing one value into the form.
|
||||
function addLimits(result, found) {
|
||||
const push = (block) => { if (!result.blocks.includes(block)) result.blocks.push(block); };
|
||||
if ((found.amount?.size ?? 0) > 1 || (found.serial?.size ?? 0) > 1 || (found.voucher?.size ?? 0) > 1) push("multiple_transactions");
|
||||
const currencies = [...(found.currency ?? [])];
|
||||
if (currencies.some((c) => c !== "CNY")) push("non_cny");
|
||||
if (currencies.length > 1) push("currency_conflict");
|
||||
}
|
||||
|
||||
function markInvalid(result, found) {
|
||||
for (const raw of found.invalid ?? []) {
|
||||
const key = ["serial", "voucher"].includes(raw) ? "reference" : raw;
|
||||
if (result.fields[key] === undefined && !result.fieldIssues[key]) result.fieldIssues[key] = "invalid";
|
||||
else if (result.fields[key] !== undefined) { result.fieldIssues[key] = "ambiguous"; delete result.fields[key]; delete result.sources[key]; }
|
||||
}
|
||||
}
|
||||
|
||||
function pageIdentifiers(entries) {
|
||||
const ids = { serial: new Set(), voucher: new Set() };
|
||||
for (const [key, value] of entries) if (ids[key] && typeof value === "string") ids[key].add(value);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Pages are linked when they share an identifier of the same kind and value;
|
||||
// a serial and a voucher number printed on one page belong together. Every page
|
||||
// must be reachable from the others, otherwise it is not shown to be the same
|
||||
// transaction. Returns the 1-based numbers of pages outside that one group.
|
||||
function unlinkedPages(pageIds) {
|
||||
const n = pageIds.length;
|
||||
if (n === 0) return [];
|
||||
const start = pageIds.findIndex((ids) => ids.serial.size || ids.voucher.size);
|
||||
if (start < 0) return pageIds.map((_, i) => i + 1);
|
||||
const linked = new Set([start]); const queue = [start];
|
||||
while (queue.length) {
|
||||
const i = queue.shift();
|
||||
pageIds.forEach((other, j) => {
|
||||
if (linked.has(j)) return;
|
||||
const shares = ["serial", "voucher"].some((kind) => [...other[kind]].some((v) => pageIds[i][kind].has(v)));
|
||||
if (shares) { linked.add(j); queue.push(j); }
|
||||
});
|
||||
}
|
||||
return pageIds.map((_, i) => (linked.has(i) ? null : i + 1)).filter(Boolean);
|
||||
}
|
||||
|
||||
function emptyResult() {
|
||||
return { fields: {}, fieldIssues: {}, sources: {}, blocks: [], warnings: [], direction: null, documentKind: "unknown",
|
||||
uncoveredPages: [], pageIds: [], identifiers: { serial: [], voucher: [] } };
|
||||
}
|
||||
|
||||
function settleField(result, key, values, source) {
|
||||
if (!values?.size) return;
|
||||
if (values.size > 1) { result.fieldIssues[key] = "ambiguous"; delete result.fields[key]; return; }
|
||||
const [value] = values;
|
||||
if (result.fields[key] !== undefined && result.fields[key] !== value) {
|
||||
result.fieldIssues[key] = "ambiguous"; delete result.fields[key]; delete result.sources[key]; return;
|
||||
}
|
||||
if (result.fieldIssues[key] === "ambiguous") return;
|
||||
result.fields[key] = value; result.sources[key] = result.sources[key] ?? source;
|
||||
}
|
||||
|
||||
function settleReference(result, found, source) {
|
||||
// Serial numbers and voucher numbers are different identifiers; prefer the
|
||||
// serial only when it is unique, otherwise an unambiguous voucher number.
|
||||
for (const kind of ["serial", "voucher"]) {
|
||||
const values = found[kind];
|
||||
if (!values?.size) continue;
|
||||
if (values.size > 1) { result.fieldIssues.reference = "ambiguous"; return; }
|
||||
settleField(result, "reference", values, source);
|
||||
if (result.fields.reference) result.fields.referenceKind = result.fields.referenceKind ?? kind;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Local text: classify each page with the shared document rules, then read the
|
||||
// whitelisted fields. Several pages may repeat one transaction; distinct values
|
||||
// mean several transactions and the file is rejected for this path.
|
||||
// Table receipts emit text out of reading order. Rows are grouped by baseline
|
||||
// and read left to right; field values are taken only from these rows.
|
||||
// Vertical text: three or more single characters stacked tightly at one x.
|
||||
function verticalColumns(items) {
|
||||
const columns = new Map();
|
||||
for (const item of items ?? []) {
|
||||
if ([...item.s.trim()].length !== 1) continue;
|
||||
const key = [...columns.keys()].find((x) => Math.abs(x - item.x) <= 2) ?? item.x;
|
||||
(columns.get(key) ?? columns.set(key, []).get(key)).push(item);
|
||||
}
|
||||
const result = [];
|
||||
for (const [x, list] of columns) {
|
||||
list.sort((a, b) => b.y - a.y);
|
||||
let run = [list[0]];
|
||||
const flush = () => { if (run.length >= 3) result.push({ x, items: run, text: run.map((i) => i.s.trim()).join("") }); };
|
||||
for (const item of list.slice(1)) {
|
||||
if (run.at(-1).y - item.y <= 11) run.push(item); else { flush(); run = [item]; }
|
||||
}
|
||||
flush();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function layoutRows(items, skip = new Set()) {
|
||||
const rows = [];
|
||||
for (const item of [...(items ?? [])].filter((i) => !skip.has(i)).sort((a, b) => b.y - a.y || a.x - b.x)) {
|
||||
const row = rows.find((r) => Math.abs(r.y - item.y) <= 2);
|
||||
if (row) row.items.push(item); else rows.push({ y: item.y, items: [item] });
|
||||
}
|
||||
for (const row of rows) { row.items.sort((a, b) => a.x - b.x); row.text = row.items.map((i) => i.s).join(" "); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
// CCB "单位客户专用回单": an unlabelled booking date on the header row (with 币别
|
||||
// and 流水号), and payer/payee columns headed by stacked "付款人"/"收款人".
|
||||
function ccbTableEntries(items, rows) {
|
||||
const entries = [];
|
||||
for (const row of rows) {
|
||||
const text = compact(row.text);
|
||||
if (!/流水号|币别/u.test(text)) continue;
|
||||
const hits = [...text.matchAll(LABEL_RE)];
|
||||
for (const match of text.matchAll(/(\d{4})年(\d{1,2})月(\d{1,2})日/gu)) {
|
||||
// A date immediately after a labelled date/time (打印日期 etc.) belongs to
|
||||
// that label; only an unlabelled date on the header row is the booking date.
|
||||
const label = hits.filter((h) => h.index + h[0].length <= match.index).at(-1);
|
||||
if (label && /日期|时间/u.test(label[0]) && /^[::]*$/u.test(text.slice(label.index + label[0].length, match.index))) continue;
|
||||
const date = `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`;
|
||||
entries.push(["receivedDate", isValidDateOnlyString(date) ? date : INVALID]);
|
||||
}
|
||||
}
|
||||
// A column header must be exactly the word; vertical notes such as
|
||||
// "…收款人回单" elsewhere on the page are not headers.
|
||||
const vertical = verticalColumns(items);
|
||||
const payerX = vertical.filter((c) => c.text === "付款人").map((c) => c.x);
|
||||
const payeeX = vertical.filter((c) => c.text === "收款人").map((c) => c.x);
|
||||
if (payerX.length !== 1 || payeeX.length !== 1 || payerX[0] >= payeeX[0]) return entries;
|
||||
for (const row of rows) {
|
||||
const sides = { payer: [], payee: [] };
|
||||
for (const item of row.items) {
|
||||
if (Math.abs(item.x - payerX[0]) <= 2 || Math.abs(item.x - payeeX[0]) <= 2) continue;
|
||||
sides[item.x < payeeX[0] ? "payer" : "payee"].push(item.s);
|
||||
}
|
||||
for (const [side, parts] of Object.entries(sides)) {
|
||||
const text = compact(parts.join(""));
|
||||
const name = text.match(/^(?:全称|户名)(.+)$/u)?.[1];
|
||||
const account = text.match(/^账号(\d{6,40})$/u)?.[1];
|
||||
if (name && parseName(name)) entries.push([`${side}Name`, parseName(name)]);
|
||||
if (account) entries.push([`${side}Account`, account]);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseIncomingTexts(texts, layouts = null) {
|
||||
const result = emptyResult();
|
||||
const documents = texts.map((text) => classifyReceiptText(text));
|
||||
const pageRows = layouts ? layouts.map((items) => layoutRows(items, new Set(verticalColumns(items).flatMap((c) => c.items)))) : null;
|
||||
// Restrictions are read from both the stored order and the rebuilt reading
|
||||
// order, so reordering PDF objects cannot hide a failure or non-final notice.
|
||||
const readingOrder = pageRows ? pageRows.map((rows) => classifyReceiptText(rows.map((r) => r.text).join("\n"))) : [];
|
||||
const restrictions = [...documents, ...readingOrder];
|
||||
if (restrictions.some((d) => d.kind === "non_final")) result.blocks.push("non_final");
|
||||
if (restrictions.some((d) => d.issues.includes("document_transaction_failed"))) result.blocks.push("transaction_failed");
|
||||
if (restrictions.some((d) => d.issues.includes("document_transaction_pending"))) result.blocks.push("transaction_pending");
|
||||
if (restrictions.some((d) => d.issues.includes("document_multiple_receipts"))) result.blocks.push("multiple_transactions");
|
||||
const directions = new Set(documents.map((d) => d.direction).filter(Boolean));
|
||||
if (directions.size > 1 || documents.some((d) => d.issues.includes("document_direction_ambiguous"))) result.warnings.push("direction_ambiguous");
|
||||
else if (directions.size === 1) result.direction = [...directions][0];
|
||||
result.documentKind = documents.every((d) => d.kind === "bank_receipt") ? "bank_receipt" : "unknown";
|
||||
const perPage = layouts
|
||||
? layouts.map((items, i) => [...pageRows[i].flatMap((r) => lineValues(r.text)), ...ccbTableEntries(items, pageRows[i])])
|
||||
: texts.map((text) => text.split(/\n/).flatMap(lineValues));
|
||||
// A page without its own amount or reference is not shown to be the same
|
||||
// transaction (e.g. a scanned page after a readable one).
|
||||
// Typed identifiers per page; pages are tied to one transaction only through
|
||||
// shared identifiers (see linkPages). An amount alone never links pages.
|
||||
result.pageIds = perPage.map((entries) => pageIdentifiers(entries));
|
||||
result.uncoveredPages = unlinkedPages(result.pageIds);
|
||||
const found = collect(perPage.flat());
|
||||
// Every identifier read locally is kept for cross-source comparison, not
|
||||
// only the one chosen for the form.
|
||||
result.identifiers = { serial: [...(found.serial ?? [])], voucher: [...(found.voucher ?? [])] };
|
||||
addLimits(result, found);
|
||||
for (const key of ["amount", "receivedDate", "currency", "payerName", "payerAccount", "payeeName", "payeeAccount"]) {
|
||||
settleField(result, key, found[key], "pdf_text");
|
||||
}
|
||||
settleReference(result, found, "pdf_text");
|
||||
markInvalid(result, found);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Provider fields come back as [{Name, Value}]; only exact whitelisted names
|
||||
// are read and they only fill gaps. Provider text can add, never lift, limits.
|
||||
function mergeProviderFields(result, pages) {
|
||||
const entries = [];
|
||||
for (const fields of pages) {
|
||||
if (!Array.isArray(fields)) continue;
|
||||
for (const field of fields.slice(0, 200)) {
|
||||
const name = compact(field?.Name); const key = LABEL_FIELD.get(name);
|
||||
if (key && typeof field?.Value === "string" && field.Value.trim()) {
|
||||
const value = PARSERS[key](field.Value);
|
||||
if (value) entries.push([key, value]);
|
||||
else if (["amount", "receivedDate"].includes(key)) entries.push([key, INVALID]);
|
||||
}
|
||||
}
|
||||
const document = classifyReceiptText(fields.map((f) => `${f?.Name ?? ""}:${f?.Value ?? ""}`).join("\n"));
|
||||
if (document.kind === "non_final" && !result.blocks.includes("non_final")) result.blocks.push("non_final");
|
||||
if (document.issues.includes("document_transaction_failed") && !result.blocks.includes("transaction_failed")) result.blocks.push("transaction_failed");
|
||||
if (document.issues.includes("document_transaction_pending") && !result.blocks.includes("transaction_pending")) result.blocks.push("transaction_pending");
|
||||
}
|
||||
const found = collect(entries);
|
||||
addLimits(result, found);
|
||||
// The provider reading a different amount or serial/voucher than the text
|
||||
// layer means the file holds another transaction, not a typo to fill in.
|
||||
const differs = (key, local) => local !== undefined && found[key]?.size && !(found[key].size === 1 && found[key].has(local));
|
||||
const otherId = (kind) => (result.identifiers?.[kind]?.length ?? 0) > 0 &&
|
||||
[...(found[kind] ?? [])].some((value) => !result.identifiers[kind].includes(value));
|
||||
if ((differs("amount", result.fields.amount) || otherId("serial") || otherId("voucher")) &&
|
||||
!result.blocks.includes("multiple_transactions")) result.blocks.push("multiple_transactions");
|
||||
// A provider currency that differs from a locally read one is a known
|
||||
// contradiction; it never clears the local currency into "unknown".
|
||||
if (result.fields.currency && found.currency?.size && !(found.currency.size === 1 && found.currency.has(result.fields.currency)) &&
|
||||
!result.blocks.includes("currency_conflict")) result.blocks.push("currency_conflict");
|
||||
for (const key of ["amount", "receivedDate", "payerName", "payerAccount", "payeeName", "payeeAccount"]) {
|
||||
if (result.fields[key] === undefined && result.fieldIssues[key] !== "ambiguous") settleField(result, key, found[key], "ocr");
|
||||
else if (found[key]?.size && result.fields[key] !== undefined && !found[key].has(result.fields[key])) {
|
||||
result.fieldIssues[key] = "ambiguous"; delete result.fields[key]; delete result.sources[key];
|
||||
}
|
||||
}
|
||||
if (result.fields.currency === undefined && result.fieldIssues.currency !== "ambiguous") settleField(result, "currency", found.currency, "ocr");
|
||||
if (result.fields.reference === undefined && result.fieldIssues.reference !== "ambiguous") settleReference(result, found, "ocr");
|
||||
markInvalid(result, found);
|
||||
}
|
||||
|
||||
function finalizeResult(result) {
|
||||
if (result.fields.currency && result.fields.currency !== "CNY") result.blocks.push("non_cny");
|
||||
for (const key of REQUIRED) if (result.fields[key] === undefined && !result.fieldIssues[key]) result.fieldIssues[key] = "missing";
|
||||
result.blocks = [...new Set(result.blocks)];
|
||||
result.warnings = [...new Set(result.warnings)];
|
||||
return result;
|
||||
}
|
||||
|
||||
// One bounded provider pass per recognition request: each page at most once, a
|
||||
// total deadline, and no retries. Unavailability leaves local values for manual
|
||||
// completion.
|
||||
async function providerPass(config, buffer, pageCount, createClient, { totalMs = OCR_TOTAL_MS, pages: wanted = null } = {}) {
|
||||
let client;
|
||||
try { client = createClient(config); } catch { return { status: "unavailable" }; }
|
||||
if (!client || typeof client.BankSlipOCR !== "function") return { status: "unavailable" };
|
||||
const encoded = buffer.toString("base64");
|
||||
if (encoded.length > 7 * 1024 * 1024) return { status: "unavailable" };
|
||||
// The deadline stops further pages, not just the wait: once expired, no page
|
||||
// is sent even if an earlier in-flight call completes later.
|
||||
const deadline = Date.now() + totalMs;
|
||||
let expired = false;
|
||||
const pages = (async () => {
|
||||
const out = [];
|
||||
for (const pageNumber of wanted ?? Array.from({ length: pageCount }, (_, i) => i + 1)) {
|
||||
if (expired || Date.now() >= deadline) throw new Error("deadline");
|
||||
const response = await client.BankSlipOCR({ ImageBase64: encoded, IsPdf: true, PdfPageNumber: pageNumber });
|
||||
out.push(response?.BankSlipInfos ?? []);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
pages.catch(() => {});
|
||||
let timer;
|
||||
try {
|
||||
const result = await Promise.race([pages, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), Math.max(0, deadline - Date.now())); })]);
|
||||
return { status: "used", pages: result };
|
||||
} catch { expired = true; return { status: "failed" }; }
|
||||
finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
async function inspectIncomingPdf(buffer, { config, createClient = createReceiptOcrClient } = {}) {
|
||||
if (!Buffer.isBuffer(buffer) || !buffer.length || buffer.length > MAX_BYTES || buffer.subarray(0, 5).toString() !== "%PDF-") throw invalidFile();
|
||||
let pageCount;
|
||||
try { pageCount = (await PDFDocument.load(buffer)).getPageCount(); } catch { throw invalidFile(); }
|
||||
if (pageCount < 1 || pageCount > MAX_PAGES) throw invalidFile();
|
||||
const extracted = await extractReceiptTexts({ buffer, pagesExpected: pageCount, layout: true });
|
||||
const texts = extracted?.texts ?? null;
|
||||
const result = texts ? parseIncomingTexts(texts, extracted.layouts) : { ...emptyResult(),
|
||||
uncoveredPages: Array.from({ length: pageCount }, (_, i) => i + 1),
|
||||
pageIds: Array.from({ length: pageCount }, () => ({ serial: new Set(), voucher: new Set() })) };
|
||||
let ocr = "not_needed";
|
||||
const missing = REQUIRED.some((key) => result.fields[key] === undefined && result.fieldIssues[key] !== "ambiguous");
|
||||
if (!result.blocks.length && (missing || result.uncoveredPages.length)) {
|
||||
// Uncovered pages are read individually; otherwise every page once.
|
||||
const pages = result.uncoveredPages.length && !missing ? result.uncoveredPages : null;
|
||||
const pass = await providerPass(config, buffer, pageCount, createClient, { pages });
|
||||
ocr = pass.status;
|
||||
if (pass.status === "used") {
|
||||
mergeProviderFields(result, pass.pages);
|
||||
const read = pages ?? Array.from({ length: pageCount }, (_, i) => i + 1);
|
||||
// Provider identifiers join each page's own set under the same validity
|
||||
// and linking rules as the text layer.
|
||||
read.forEach((n, index) => {
|
||||
const entries = (pass.pages[index] ?? []).slice(0, 200).map((f) => {
|
||||
const key = LABEL_FIELD.get(compact(f?.Name));
|
||||
return ["serial", "voucher"].includes(key) && typeof f?.Value === "string" ? [key, PARSERS[key](f.Value)] : null;
|
||||
}).filter((entry) => entry && entry[1]);
|
||||
const ids = pageIdentifiers(entries);
|
||||
for (const kind of ["serial", "voucher"]) for (const v of ids[kind]) result.pageIds[n - 1][kind].add(v);
|
||||
});
|
||||
result.uncoveredPages = unlinkedPages(result.pageIds);
|
||||
}
|
||||
}
|
||||
// Several pages that cannot all be tied to the one transaction are refused;
|
||||
// a single unreadable page can still be completed by hand.
|
||||
if (pageCount > 1 && result.uncoveredPages.length) result.blocks.push("pages_unverified");
|
||||
if (!texts) result.warnings.push("text_unavailable");
|
||||
return { ...finalizeResult(result), ocr, pageCount };
|
||||
}
|
||||
|
||||
function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); }
|
||||
|
||||
function minorToMoney(minor) {
|
||||
const value = BigInt(minor);
|
||||
return `${value / 100n}.${String(value % 100n).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function sealContext(config, payload) {
|
||||
return encryptStringOrThrow(config, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function openContext(config, token, { actorId, fileSha256, now = Date.now() }) {
|
||||
let payload;
|
||||
try { payload = JSON.parse(decryptStringOrThrow(config, token)); } catch { throw contextInvalid(); }
|
||||
if (payload?.purpose !== PURPOSE || payload.v !== 1 || payload.parserVersion !== PARSER_VERSION ||
|
||||
payload.actorId !== actorId || payload.fileSha256 !== fileSha256 ||
|
||||
!Number.isFinite(payload.expiresAt) || payload.expiresAt < now || payload.issuedAt > now + 60000) throw contextInvalid();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function suggestParties(db, config, fields) {
|
||||
const suggestions = { legalEntityId: null, lenderId: null };
|
||||
const warnings = [];
|
||||
if (fields.payeeName) {
|
||||
const companies = await db.legalEntity.findMany({ where: { name: fields.payeeName, enabled: true, archived_at: null, currency_code: "CNY" },
|
||||
select: { id: true }, take: 2 });
|
||||
if (companies.length === 1) suggestions.legalEntityId = companies[0].id;
|
||||
else if (companies.length > 1) warnings.push("company_not_unique");
|
||||
}
|
||||
if (fields.payerName) {
|
||||
const lenders = await db.loanLender.findMany({ where: { name: fields.payerName, archived_at: null,
|
||||
...(suggestions.legalEntityId ? { legal_entity_id: suggestions.legalEntityId } : {}) }, include: LENDER_INCLUDE, take: 2 });
|
||||
if (lenders.length === 1) {
|
||||
suggestions.lenderId = lenders[0].id;
|
||||
if (fields.payerAccount) {
|
||||
try { if (readLenderAccount(config, lenders[0]).bankAccount !== fields.payerAccount) warnings.push("payer_account_differs"); }
|
||||
catch { warnings.push("payer_account_differs"); }
|
||||
}
|
||||
} else if (lenders.length > 1) warnings.push("lender_not_unique");
|
||||
}
|
||||
return { suggestions, warnings };
|
||||
}
|
||||
|
||||
async function recognize(db, config, request, buffer, options = {}) {
|
||||
const fileSha256 = sha256(buffer);
|
||||
const result = await inspectIncomingPdf(buffer, { config, ...options });
|
||||
const parties = await suggestParties(db, config, result.fields);
|
||||
const now = Date.now();
|
||||
const context = sealContext(config, {
|
||||
v: 1, purpose: PURPOSE, parserVersion: PARSER_VERSION, actorId: request.employee.id, fileSha256,
|
||||
issuedAt: now, expiresAt: now + CONTEXT_TTL_MS,
|
||||
fields: result.fields, fieldIssues: result.fieldIssues, sources: result.sources,
|
||||
blocks: result.blocks, direction: result.direction,
|
||||
});
|
||||
const f = result.fields;
|
||||
return {
|
||||
context, expiresAt: new Date(now + CONTEXT_TTL_MS).toISOString(), ocr: result.ocr, pageCount: result.pageCount,
|
||||
documentKind: result.documentKind, direction: result.direction, blocks: result.blocks,
|
||||
warnings: [...new Set([...result.warnings, ...parties.warnings])], fieldIssues: result.fieldIssues, sources: result.sources,
|
||||
fields: {
|
||||
originalPrincipal: f.amount ? minorToMoney(f.amount) : null, receivedDate: f.receivedDate ?? null,
|
||||
originReference: f.reference ?? null, referenceKind: f.referenceKind ?? null, currency: f.currency ?? null,
|
||||
payerName: f.payerName ?? null, payerAccountMasked: f.payerAccount ? maskBankAccount(f.payerAccount) : null,
|
||||
payeeName: f.payeeName ?? null, payeeAccountMasked: f.payeeAccount ? maskBankAccount(f.payeeAccount) : null,
|
||||
},
|
||||
suggestions: parties.suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
// Corrections are recorded by field name only; values stay in the business row.
|
||||
function corrections(context, body) {
|
||||
const f = context.fields ?? {};
|
||||
const changed = [];
|
||||
if (f.amount && !new Prisma.Decimal(minorToMoney(f.amount)).eq(body.originalPrincipal)) changed.push("originalPrincipal");
|
||||
if (f.receivedDate && f.receivedDate !== body.receivedDate) changed.push("receivedDate");
|
||||
if (f.reference && f.reference !== body.originReference) changed.push("originReference");
|
||||
return changed;
|
||||
}
|
||||
|
||||
function assertSavable(context, lender) {
|
||||
if (context.blocks?.length) throw blocked();
|
||||
const company = lender.legal_entity?.name ? compact(lender.legal_entity.name) : null;
|
||||
const f = context.fields ?? {};
|
||||
// Company money going out, or money arriving at another entity, is not a
|
||||
// borrow-in of this company. A personal debit slip from the lender is fine.
|
||||
if (context.direction === "debit" && company && f.payerName && compact(f.payerName) === company) throw blocked();
|
||||
if (f.payeeName && company && compact(f.payeeName) !== company) throw blocked();
|
||||
}
|
||||
|
||||
function sameRegistration(loan, body, fileSha256) {
|
||||
return loan.lender_id === body.lenderId && loan.origin_reference === body.originReference &&
|
||||
new Prisma.Decimal(loan.original_principal).eq(body.originalPrincipal) &&
|
||||
new Prisma.Decimal(loan.opening_outstanding_principal).eq(body.openingOutstandingPrincipal) &&
|
||||
loan.received_date.toISOString().slice(0, 10) === body.receivedDate &&
|
||||
loan.balance_as_of.toISOString().slice(0, 10) === body.balanceAsOf && loan.historical_entry === body.historicalEntry &&
|
||||
(loan.evidences ?? []).some((e) => e.kind === "incoming" && e.file_sha256 === fileSha256 && !e.archived_at);
|
||||
}
|
||||
|
||||
async function assertIncomingUnused(tx, lenderId, fileSha256, exceptLoanId = null) {
|
||||
const existing = await tx.loanEvidence.findFirst({ where: { kind: "incoming", file_sha256: fileSha256, archived_at: null,
|
||||
loan: { lender_id: lenderId, ...(exceptLoanId ? { id: { not: exceptLoanId } } : {}) } }, select: { id: true } });
|
||||
if (existing) throw buildBusinessValidationError("loan.incoming_receipt_duplicate", {}, 409);
|
||||
}
|
||||
|
||||
// The file is written before the short transaction; the loan, its incoming
|
||||
// original and both audits commit together or not at all. The caller deletes
|
||||
// the new object whenever this does not return a freshly created record.
|
||||
async function saveFromReceipt(prisma, config, request, { body, file, storagePath, timeZone }) {
|
||||
if (body.balanceAsOf > currentDateString(timeZone)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
const actor = request.employee.id;
|
||||
const lookup = (db) => db.companyLoan.findUnique({ where: { created_by_request_key: { created_by: actor, request_key: body.requestKey } }, include: LOAN_INCLUDE });
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const lender = await lockLender(tx, body.lenderId);
|
||||
const previous = await lookup(tx);
|
||||
if (previous) {
|
||||
if (!sameRegistration(previous, body, file.fileSha256)) throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
|
||||
return { loan: previous, created: false };
|
||||
}
|
||||
// A lost response may be retried with the same key after the context expired.
|
||||
const context = openContext(config, body.context, { actorId: actor, fileSha256: file.fileSha256 });
|
||||
if (lender.archived_at) throw buildBusinessValidationError("loan.not_found", {}, 404);
|
||||
// The lender finance checked in the form must be the one saved against.
|
||||
if (lender.identity_version !== body.expectedLenderIdentityVersion || lender.account_version !== body.expectedLenderAccountVersion) {
|
||||
throw buildBusinessValidationError("loan.version_changed", {}, 409);
|
||||
}
|
||||
await validateLenderReferences(tx, { legalEntityId: lender.legal_entity_id, bankInstitutionId: lender.bank_institution_id });
|
||||
assertSavable(context, lender);
|
||||
await assertIncomingUnused(tx, lender.id, file.fileSha256);
|
||||
const id = randomUUID();
|
||||
const numbering = await resolveNumberingContext(tx);
|
||||
const loanNo = numbering.version === 2
|
||||
? (await issueBusinessNumber(tx, { family: "company_loan", ...numbering, timeZone: config.appTimezone })).documentNumber
|
||||
: `JK-${id}`;
|
||||
await tx.companyLoan.create({ data: {
|
||||
id, loan_no: loanNo, origin_reference: body.originReference,
|
||||
legal_entity_id: lender.legal_entity_id, lender_id: lender.id,
|
||||
original_principal: body.originalPrincipal, opening_outstanding_principal: body.openingOutstandingPrincipal,
|
||||
received_date: parseDateOnlyString(body.receivedDate), balance_as_of: parseDateOnlyString(body.balanceAsOf),
|
||||
historical_entry: body.historicalEntry, created_by: actor, updated_by: actor, request_key: body.requestKey,
|
||||
} });
|
||||
const evidence = await tx.loanEvidence.create({ data: {
|
||||
loan_id: id, kind: "incoming", original_name: file.originalName, storage_path: storagePath,
|
||||
mime_type: file.mimeType, file_size: file.fileSize, file_sha256: file.fileSha256,
|
||||
governance_status: "approved", uploaded_by: actor,
|
||||
} });
|
||||
await auditLoan(tx, request, "COMPANY_LOAN_REGISTERED", id, { historicalEntry: body.historicalEntry, source: "incoming_receipt",
|
||||
parserVersion: PARSER_VERSION, fileSha256: file.fileSha256, fieldSources: context.sources ?? {},
|
||||
correctedFields: corrections(context, body) });
|
||||
await auditLoan(tx, request, "COMPANY_LOAN_EVIDENCE_UPLOADED", id, { evidenceId: evidence.id, kind: "incoming" });
|
||||
return { loan: await tx.companyLoan.findUniqueOrThrow({ where: { id }, include: LOAN_INCLUDE }), created: true };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code !== "P2002") throw error;
|
||||
const existing = await lookup(prisma);
|
||||
if (existing && sameRegistration(existing, body, file.fileSha256)) return { loan: existing, created: false };
|
||||
throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
providerPass, PARSER_VERSION, PURPOSE, CONTEXT_TTL_MS, MAX_BYTES,
|
||||
parseIncomingTexts, mergeProviderFields, finalizeResult, inspectIncomingPdf, recognize,
|
||||
openContext, sealContext, saveFromReceipt, assertIncomingUnused, corrections, assertSavable, sha256,
|
||||
};
|
||||
@@ -98,34 +98,46 @@ function safeManualProof(proof) {
|
||||
...(proof.basis === "booked_debit" ? { bookedOn: proof.bookedOn } : {}) };
|
||||
}
|
||||
|
||||
// Bound hostile PDF parsing independently of the request/event loop. No URLs,
|
||||
// external fonts, OCR calls, raw text logging or persisted extraction payloads.
|
||||
// Returns one text per page, or null when the document cannot be read in bounds.
|
||||
function extractReceiptTexts({ buffer, pagesExpected, layout = false }) {
|
||||
if (!Number.isInteger(pagesExpected) || pagesExpected < 1 || pagesExpected > 20 ||
|
||||
!Buffer.isBuffer(buffer) || !buffer.length || buffer.length > 5 * 1024 * 1024) return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(path.join(__dirname, "paymentReceiptDocumentWorker.js"), {
|
||||
workerData: { bytes: new Uint8Array(buffer), pagesExpected, layout },
|
||||
resourceLimits: { maxOldGenerationSizeMb: 128 }, stdout: true, stderr: true,
|
||||
});
|
||||
worker.stdout.resume(); worker.stderr.resume();
|
||||
let settled = false;
|
||||
const finish = (texts) => {
|
||||
if (settled) return;
|
||||
settled = true; clearTimeout(timer); void worker.terminate(); resolve(texts);
|
||||
};
|
||||
const timer = setTimeout(() => finish(null), 5000);
|
||||
worker.once("message", (message) => {
|
||||
const valid = Array.isArray(message?.texts) && message.texts.length === pagesExpected &&
|
||||
message.texts.every((text) => typeof text === "string") &&
|
||||
(!layout || (Array.isArray(message.layouts) && message.layouts.length === pagesExpected));
|
||||
// With layout, callers get { texts, layouts }; the default stays a text array.
|
||||
finish(valid ? (layout ? { texts: message.texts, layouts: message.layouts } : message.texts) : null);
|
||||
});
|
||||
worker.once("error", () => finish(null));
|
||||
worker.once("exit", () => finish(null));
|
||||
});
|
||||
}
|
||||
|
||||
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()));
|
||||
});
|
||||
if (mimeType !== "application/pdf") return unknown();
|
||||
const texts = await extractReceiptTexts({ buffer, pagesExpected });
|
||||
if (!texts) return unknown();
|
||||
return texts.map((text, index) => ({ pageNumber: index + 1, document: classifyReceiptText(text) }));
|
||||
}
|
||||
|
||||
module.exports = { DOCUMENT_VERSION, unknownDocument, classifyReceiptText, withProviderRestrictions,
|
||||
safeDocument, safeManualProof, documentForPage, documentBlocksPayment, paymentDocumentReady, inspectReceiptDocument };
|
||||
safeDocument, safeManualProof, documentForPage, documentBlocksPayment, paymentDocumentReady, inspectReceiptDocument, extractReceiptTexts };
|
||||
|
||||
@@ -8,24 +8,26 @@ const { parentPort, workerData } = require("node:worker_threads");
|
||||
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 = [];
|
||||
const texts = []; const layouts = [];
|
||||
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 = [];
|
||||
const pieces = []; const layout = [];
|
||||
for (const item of content.items) {
|
||||
if (typeof item.str !== "string") continue;
|
||||
const nextY = Math.round(item.transform?.[5] ?? 0);
|
||||
// Positions only on request: table receipts need row/column order.
|
||||
if (workerData.layout && item.str.trim()) layout.push({ s: item.str, x: Math.round(item.transform?.[4] ?? 0), y: nextY });
|
||||
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();
|
||||
texts.push(text); layouts.push(layout); page.cleanup();
|
||||
}
|
||||
parentPort.postMessage({ texts });
|
||||
parentPort.postMessage(workerData.layout ? { texts, layouts } : { texts });
|
||||
} catch {
|
||||
parentPort.postMessage({ failed: true });
|
||||
} finally {
|
||||
|
||||
@@ -111,4 +111,4 @@ function matchBankSlip(receipt, candidates) {
|
||||
return finish("mismatch", [...new Set(comparisons.flatMap((comparison) => comparison.reasons))]);
|
||||
}
|
||||
|
||||
module.exports = { normalizeBankSlip, matchBankSlip, parseReceiptMinorUnits };
|
||||
module.exports = { normalizeBankSlip, matchBankSlip, parseReceiptMinorUnits, normalizeCurrency };
|
||||
|
||||
@@ -73,4 +73,4 @@ async function recognizePaymentReceipt({ buffer, mimeType, config, createClient
|
||||
return { ...result, status: result.pages.every((page) => page.issues.length === 0) ? "recognized" : "needs_review" };
|
||||
}
|
||||
|
||||
module.exports = { recognizePaymentReceipt };
|
||||
module.exports = { recognizePaymentReceipt, createReceiptOcrClient };
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"loan.incoming_receipt_invalid": "请上传 5MB 以内、不超过 20 页的单份 PDF 原借入回单;文件损坏或无法安全读取时请改用手工登记。",
|
||||
"loan.incoming_receipt_blocked": "该回单不能作为本笔借入原件:可能是非最终回执、失败或处理中、公司支出、多笔交易、非人民币或收款主体不符。请更换正式入账回单或改用手工登记。",
|
||||
"loan.incoming_receipt_context_invalid": "识别结果已过期,或与当前账号、所选文件不一致,请重新上传并识别。",
|
||||
"loan.incoming_receipt_duplicate": "该原件已用于本出借人的其他借款登记,不能重复登记;请核对已有借款。",
|
||||
"loan.employee_source_incomplete": "员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。",
|
||||
"loan.employee_source_changed": "员工资料已变化,请重新加载并核对后再保存。",
|
||||
"loan.employee_lender_conflict": "该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。",
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
const incoming = require("../../../src/services/loanIncomingReceipt");
|
||||
const { syntheticReceiptText, syntheticBankReceiptPdf, syntheticCcbIncomingPdf } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs");
|
||||
|
||||
const config = { fieldEncryptionKey: Buffer.alloc(32, 9).toString("base64") };
|
||||
const credit = (options = {}) => syntheticReceiptText({ direction: "credit", status: "booked", amount: "50,000.00",
|
||||
reference: "SYN-IN-0001", bookedOn: "2025-03-14", extraLines: ["付款人名称 合成出借人", "收款人名称 合成公司"], ...options });
|
||||
const parse = (...texts) => incoming.finalizeResult(incoming.parseIncomingTexts(texts));
|
||||
|
||||
describe("incoming receipt field extraction", () => {
|
||||
test("reads amount, booking date, reference and parties from labelled lines", () => {
|
||||
const result = parse(credit());
|
||||
expect(result.blocks).toEqual([]);
|
||||
expect(result.direction).toBe("credit");
|
||||
expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001",
|
||||
referenceKind: "voucher", currency: "CNY", payerName: "合成出借人", payeeName: "合成公司" });
|
||||
expect(result.sources).toMatchObject({ amount: "pdf_text", receivedDate: "pdf_text", reference: "pdf_text" });
|
||||
expect(result.fieldIssues).toEqual({});
|
||||
});
|
||||
test("a stable serial number is preferred over a voucher number, kept as text", () => {
|
||||
const result = parse(credit({ extraLines: ["交易流水号 0012345678"] }));
|
||||
expect(result.fields.reference).toBe("0012345678");
|
||||
expect(result.fields.referenceKind).toBe("serial");
|
||||
});
|
||||
test("conflicting dates are left empty rather than choosing one", () => {
|
||||
const result = parse(credit({ extraLines: ["交易日期 2025-03-15"] }));
|
||||
expect(result.fields.receivedDate).toBeUndefined();
|
||||
expect(result.fieldIssues.receivedDate).toBe("ambiguous");
|
||||
});
|
||||
test("print stamps and capital amounts are never read as business values", () => {
|
||||
const result = parse(credit({ extraLines: ["打印日期 2026-10-09", "大写金额 伍万元整"] }));
|
||||
expect(result.fields.receivedDate).toBe("2025-03-14");
|
||||
expect(result.fields.amount).toBe("5000000");
|
||||
});
|
||||
test("pages repeating one transaction are merged; different transactions are rejected", () => {
|
||||
expect(parse(credit(), credit()).blocks).toEqual([]);
|
||||
const result = parse(credit(), credit({ amount: "20.00", reference: "SYN-IN-0002" }));
|
||||
expect(result.blocks).toContain("multiple_transactions");
|
||||
expect(result.fields.amount).toBeUndefined();
|
||||
});
|
||||
test.each([
|
||||
[{ final: false }, "non_final"], [{ status: "failed" }, "transaction_failed"],
|
||||
[{ status: "pending" }, "transaction_pending"], [{ currency: "USD" }, "non_cny"],
|
||||
])("restricted document %j is blocked", (options, block) => {
|
||||
expect(parse(credit(options)).blocks).toContain(block);
|
||||
});
|
||||
test("two receipts on one page are blocked", () => {
|
||||
expect(parse(credit() + "\n" + credit({ amount: "1.00" })).blocks).toContain("multiple_transactions");
|
||||
});
|
||||
test("missing required fields are reported, not invented", () => {
|
||||
const result = parse("中国建设银行单位客户专用回单\n贷\n摘要 借款");
|
||||
expect(result.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" });
|
||||
expect(result.fields).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("review counterexamples (2026-10-10)", () => {
|
||||
test("same amount with different voucher numbers is several transactions, not a missing reference", () => {
|
||||
const result = parse(credit({ reference: "SYNTHETIC-VOUCHER-A" }), credit({ reference: "SYNTHETIC-VOUCHER-B" }));
|
||||
expect(result.blocks).toContain("multiple_transactions");
|
||||
});
|
||||
test("a provider reporting different serial numbers with one amount is several transactions", () => {
|
||||
const result = incoming.parseIncomingTexts([""]);
|
||||
incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-A" }],
|
||||
[{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-B" }]]);
|
||||
expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions");
|
||||
});
|
||||
test("one transaction repeated across pages remains allowed", () => {
|
||||
expect(parse(credit(), credit()).blocks).toEqual([]);
|
||||
});
|
||||
test("a provider currency never clears a locally known one", () => {
|
||||
const local = incoming.parseIncomingTexts([credit()]);
|
||||
incoming.mergeProviderFields(local, [[{ Name: "币别", Value: "美元" }, { Name: "币种", Value: "USD" }]]);
|
||||
expect(incoming.finalizeResult(local).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"]));
|
||||
expect(local.fields.currency).toBe("CNY");
|
||||
});
|
||||
test("a locally known foreign currency blocks before any provider call", async () => {
|
||||
const createClient = vi.fn();
|
||||
const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked",
|
||||
currency: "USD", bookedOn: "not-a-date" }), { config, createClient });
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result.blocks).toContain("non_cny");
|
||||
});
|
||||
test("conflicting currencies within the document are a known contradiction", () => {
|
||||
expect(parse(credit({ extraLines: ["币种 USD"] })).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"]));
|
||||
});
|
||||
test.each([["1,23.45"], ["1234.567"], ["50000.00USD"], ["1.2.3"], ["¥"]])("malformed amount %s is invalid, never truncated", (amount) => {
|
||||
const result = parse(credit({ amount }));
|
||||
expect(result.fields.amount).toBeUndefined();
|
||||
expect(result.fieldIssues.amount).toBe("invalid");
|
||||
});
|
||||
test.each([["¥1,234.50元", "123450"], ["人民币1234.5", "123450"], ["0.01", "1"], ["999,999,999,999.99", "99999999999999"]])(
|
||||
"well-formed amount %s is read whole", (amount, minor) => {
|
||||
expect(parse(credit({ amount })).fields.amount).toBe(minor);
|
||||
});
|
||||
test("the provider deadline stops later pages, not just the wait", async () => {
|
||||
let calls = 0;
|
||||
const BankSlipOCR = vi.fn(() => { calls += 1; return new Promise((resolve) => setTimeout(() => resolve({ BankSlipInfos: [] }), 30)); });
|
||||
const pass = await incoming.providerPass(config, Buffer.from("%PDF-synthetic"), 3, () => ({ BankSlipOCR }), { totalMs: 10 });
|
||||
expect(pass.status).toBe("failed");
|
||||
const atReturn = calls;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(calls).toBe(atReturn);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CCB table layout (synthetic replica of the real borrow-in receipt)", () => {
|
||||
const inspect = async (options) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient: vi.fn() });
|
||||
test("reads every field from out-of-order table items without any provider call", async () => {
|
||||
const createClient = vi.fn();
|
||||
const result = await incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(), { config, createClient });
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ ocr: "not_needed", blocks: [], direction: "credit", documentKind: "bank_receipt", fieldIssues: {} });
|
||||
expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "123456789AB12CDEFG3", referenceKind: "serial",
|
||||
currency: "CNY", payerName: "合成甲", payerAccount: "6222000011112222", payeeName: "合成公司", payeeAccount: "33050161000000000001" });
|
||||
});
|
||||
test("a vertical copy note level with the amount row neither pollutes the amount nor becomes a column header", async () => {
|
||||
const result = await inspect({ copyNote: "(收款人回单)(第一联)" });
|
||||
expect(result.fields.amount).toBe("5000000");
|
||||
expect(result.fields.payeeName).toBe("合成公司");
|
||||
});
|
||||
test("baseline jitter within one row still reads the row left to right", async () => {
|
||||
expect((await inspect({ dateDy: 1 })).fields.receivedDate).toBe("2025-03-14");
|
||||
expect((await inspect({ dateDy: -2 })).fields.receivedDate).toBe("2025-03-14");
|
||||
});
|
||||
test("two different unlabelled header dates are ambiguous", async () => {
|
||||
const result = await inspect({ extraItems: [["2025年03月15日", 150, 785]] });
|
||||
expect(result.fields.receivedDate).toBeUndefined();
|
||||
expect(result.fieldIssues.receivedDate).toBe("ambiguous");
|
||||
});
|
||||
test("an impossible header date is invalid, not shifted", async () => {
|
||||
const result = await inspect({ date: ["2025", "02", "30"] });
|
||||
expect(result.fieldIssues.receivedDate).toBe("invalid");
|
||||
});
|
||||
test("parties are only read when the payer column is left of the payee column", async () => {
|
||||
const swapped = await inspect({ headers: ["收款人", "付款人"] });
|
||||
expect(swapped.fields.payerName).toBeUndefined();
|
||||
expect(swapped.fields.payeeName).toBeUndefined();
|
||||
expect(swapped.fields.amount).toBe("5000000");
|
||||
});
|
||||
test("a malformed lower-case amount is invalid even in the table layout", async () => {
|
||||
const result = await inspect({ amount: "50,00.00" });
|
||||
expect(result.fields.amount).toBeUndefined();
|
||||
expect(result.fieldIssues.amount).toBe("invalid");
|
||||
});
|
||||
test("a non-final notice still blocks the table layout", async () => {
|
||||
const result = await inspect({ extraItems: [["本回执不作为收、付款方交易的最终依据。", 25, 575]] });
|
||||
expect(result.blocks).toContain("non_final");
|
||||
});
|
||||
});
|
||||
|
||||
describe("second review counterexamples (2026-10-10)", () => {
|
||||
const inspect = async (options, createClient = vi.fn()) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient });
|
||||
test.each([[["交易失败", 140, 650], ["交易状态", 37, 650]], [["处理中", 140, 650], ["交易状态", 37, 650]],
|
||||
[["交易的最终依据。", 140, 575], ["本回执不作为收、付款方", 25, 575]]])(
|
||||
"restrictions stored value-first are still read in reading order: %j", async (...extraItems) => {
|
||||
const result = await inspect({ extraItems });
|
||||
expect(result.blocks.some((b) => ["transaction_failed", "transaction_pending", "non_final"].includes(b))).toBe(true);
|
||||
});
|
||||
test("a labelled print date on the header row is never the booking date", async () => {
|
||||
const result = await inspect({ date: null, extraItems: [["打印日期:2026年10月08日", 140, 785]] });
|
||||
expect(result.fields.receivedDate).toBeUndefined();
|
||||
expect(result.fieldIssues.receivedDate).toBe("missing");
|
||||
});
|
||||
test("a labelled print date beside the unlabelled booking date leaves the booking date", async () => {
|
||||
expect((await inspect({ extraItems: [["打印日期:2026年10月08日", 450, 785]] })).fields.receivedDate).toBe("2025-03-14");
|
||||
});
|
||||
test.each([["2025-03-149"], ["2025-03-14/2025-04-14"]])("labelled date %s must be one whole date", (date) => {
|
||||
expect(parse(credit({ bookedOn: date })).fieldIssues.receivedDate).toBe("invalid");
|
||||
});
|
||||
async function readablePlusScanned() {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create();
|
||||
const source = await PDFDocument.load(await syntheticCcbIncomingPdf());
|
||||
doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage();
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
test("a scanned page of another transaction after a readable page is read and blocked", async () => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "9.00" }, { Name: "流水号", Value: "OTHER-2" }] }));
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(BankSlipOCR).toHaveBeenCalledTimes(1);
|
||||
expect(BankSlipOCR.mock.calls[0][0].PdfPageNumber).toBe(2);
|
||||
expect(result.blocks).toContain("multiple_transactions");
|
||||
});
|
||||
test("an unverifiable extra page blocks when no provider is available", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a scanned repeat of the same transaction is accepted", async () => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" }, { Name: "流水号", Value: "123456789AB12CDEFG3" }] }));
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(result.blocks).toEqual([]);
|
||||
});
|
||||
test.each([
|
||||
["empty provider page", []],
|
||||
["unparseable serial", [{ Name: "流水号", Value: "?" }]],
|
||||
["unparseable amount", [{ Name: "金额", Value: "无法识别" }]],
|
||||
["same amount without any identifier", [{ Name: "金额", Value: "50,000.00" }]],
|
||||
])("a scanned page is not verified by %s", async (_label, fields) => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: fields }));
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a readable page with only the same amount and no identifier is not verified", async () => {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create();
|
||||
for (const options of [{}, { serial: null }]) {
|
||||
const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options));
|
||||
doc.addPage((await doc.copyPages(source, [0]))[0]);
|
||||
}
|
||||
const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a different secondary voucher number is another transaction even with the same serial", async () => {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create();
|
||||
const source = await PDFDocument.load(await syntheticCcbIncomingPdf({ extraItems: [["凭证号 LOCAL-VOUCHER", 293, 702]] }));
|
||||
doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage();
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" },
|
||||
{ Name: "流水号", Value: "123456789AB12CDEFG3" }, { Name: "凭证号", Value: "OTHER-VOUCHER" }] }));
|
||||
const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(result.fields.referenceKind).toBe("serial");
|
||||
expect(result.blocks).toContain("multiple_transactions");
|
||||
});
|
||||
async function twoCcbPages(first, second) {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create();
|
||||
for (const options of [first, second]) {
|
||||
const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options));
|
||||
doc.addPage((await doc.copyPages(source, [0]))[0]);
|
||||
}
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
const voucherOnly = (voucher) => ({ serial: null, extraItems: [[`凭证号 ${voucher}`, 293, 702]] });
|
||||
test("a serial on one page and an unrelated voucher on the other are not linked", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A" }, voucherOnly("VOUCHER-B")), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("the same text under different identifier kinds is not a link", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SAME-0001" }, voucherOnly("SAME-0001")), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a page carrying both numbers links a later page that repeats one of them", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A", extraItems: [["凭证号 VOUCHER-B", 293, 702]] },
|
||||
voucherOnly("VOUCHER-B")), { config, createClient: () => null });
|
||||
expect(result.blocks).toEqual([]);
|
||||
});
|
||||
test.each([["SERIAL-A/001"], ["SERIAL-A?"], ["A".repeat(70)]])("identifier %s is invalid as a whole, never cut to a prefix", (reference) => {
|
||||
const result = parse(credit({ reference }));
|
||||
expect(result.fields.reference).toBeUndefined();
|
||||
expect(result.fieldIssues.reference).toBe("invalid");
|
||||
});
|
||||
test("same prefix with different suffixes cannot collapse into one identifier", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIALA/001" }, { serial: "SERIALA/002" }), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
expect(result.fields.reference).toBeUndefined();
|
||||
});
|
||||
test("a provider identifier with an invalid suffix does not verify a page", async () => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "流水号", Value: "123456789AB12CDEFG3?" }] }));
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a provider transaction different from the text layer is several transactions", () => {
|
||||
const result = incoming.parseIncomingTexts([credit({ reference: "LOCAL-A", amount: "10.00" })]);
|
||||
incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "20.00" }, { Name: "凭证号", Value: "OCR-B" }]]);
|
||||
expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded provider fallback", () => {
|
||||
const providerFields = [{ Name: "金额", Value: "1,234.50" }, { Name: "记账日期", Value: "2025年1月2日" },
|
||||
{ Name: "流水号", Value: "OCR-77" }, { Name: "币别", Value: "人民币" }];
|
||||
test("a readable PDF with all required fields makes no provider call", async () => {
|
||||
const createClient = vi.fn();
|
||||
const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked",
|
||||
reference: "SYN-PDF-1", bookedOn: "2025-02-03", amount: "88.00" }), { config, createClient });
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result.ocr).toBe("not_needed");
|
||||
expect(result.fields).toMatchObject({ amount: "8800", receivedDate: "2025-02-03", reference: "SYN-PDF-1" });
|
||||
});
|
||||
test("an unreadable PDF uses each page at most once and only fills gaps", async () => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: providerFields }));
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create(); doc.addPage(); doc.addPage();
|
||||
const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(BankSlipOCR).toHaveBeenCalledTimes(2);
|
||||
expect(result.ocr).toBe("used");
|
||||
expect(result.fields).toMatchObject({ amount: "123450", receivedDate: "2025-01-02", reference: "OCR-77" });
|
||||
expect(result.sources.amount).toBe("ocr");
|
||||
});
|
||||
test("provider unavailable or failing leaves manual completion without retries", async () => {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create(); doc.addPage();
|
||||
const pdf = Buffer.from(await doc.save());
|
||||
expect((await incoming.inspectIncomingPdf(pdf, { config, createClient: () => null })).ocr).toBe("unavailable");
|
||||
const BankSlipOCR = vi.fn(async () => { throw new Error("quota"); });
|
||||
const failed = await incoming.inspectIncomingPdf(pdf, { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(BankSlipOCR).toHaveBeenCalledTimes(1);
|
||||
expect(failed.ocr).toBe("failed");
|
||||
expect(failed.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" });
|
||||
});
|
||||
test("provider text can add restrictions but never overwrite local values", () => {
|
||||
const result = incoming.parseIncomingTexts([credit()]);
|
||||
incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "1.00" }, { Name: "备注", Value: "本回执不作为收付款方交易的最终依据" }]]);
|
||||
incoming.finalizeResult(result);
|
||||
expect(result.blocks).toContain("non_final");
|
||||
expect(result.fields.amount).toBeUndefined();
|
||||
expect(result.fieldIssues.amount).toBe("ambiguous");
|
||||
});
|
||||
test.each(["not a pdf", ""])("rejects non-PDF input %j", async (text) => {
|
||||
await expect(incoming.inspectIncomingPdf(Buffer.from(text), { config })).rejects.toMatchObject({ details: { reasonCode: "loan.incoming_receipt_invalid" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sealed prefill context", () => {
|
||||
const base = { v: 1, purpose: incoming.PURPOSE, parserVersion: incoming.PARSER_VERSION, actorId: "actor-1", fileSha256: "a".repeat(64),
|
||||
issuedAt: 1000, expiresAt: 1000 + incoming.CONTEXT_TTL_MS, fields: {}, blocks: [] };
|
||||
test("opens only for the same actor, file and lifetime", () => {
|
||||
const token = incoming.sealContext(config, base);
|
||||
expect(incoming.openContext(config, token, { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 }).actorId).toBe("actor-1");
|
||||
for (const binding of [{ actorId: "actor-2", fileSha256: "a".repeat(64), now: 2000 },
|
||||
{ actorId: "actor-1", fileSha256: "b".repeat(64), now: 2000 },
|
||||
{ actorId: "actor-1", fileSha256: "a".repeat(64), now: base.expiresAt + 1 }]) {
|
||||
expect(() => incoming.openContext(config, token, binding)).toThrow();
|
||||
}
|
||||
});
|
||||
test("tampered, foreign-purpose or plain tokens are rejected", () => {
|
||||
const token = incoming.sealContext(config, base);
|
||||
const binding = { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 };
|
||||
expect(() => incoming.openContext(config, `${token.slice(0, -4)}AAAA`, binding)).toThrow();
|
||||
expect(() => incoming.openContext(config, incoming.sealContext(config, { ...base, purpose: "other" }), binding)).toThrow();
|
||||
expect(() => incoming.openContext(config, Buffer.from(JSON.stringify(base)).toString("base64"), binding)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("save guards", () => {
|
||||
const lender = { legal_entity: { name: "合成公司" } };
|
||||
test("company payment out or money to another entity is not a borrow-in", () => {
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成公司" } }, lender)).toThrow();
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "另一公司" } }, lender)).toThrow();
|
||||
expect(() => incoming.assertSavable({ blocks: ["non_final"], direction: "credit", fields: {} }, lender)).toThrow();
|
||||
});
|
||||
test("a credit to the company, or a lender's own debit slip, can be saved", () => {
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "合成公司" } }, lender)).not.toThrow();
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成出借人" } }, lender)).not.toThrow();
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: null, fields: {} }, lender)).not.toThrow();
|
||||
});
|
||||
test("records corrected field names only", () => {
|
||||
const context = { fields: { amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001" } };
|
||||
expect(incoming.corrections(context, { originalPrincipal: "50000.00", receivedDate: "2025-03-15", originReference: "SYN-IN-0001" }))
|
||||
.toEqual(["receivedDate"]);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户