|
|
|
@@ -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,
|
|
|
|
|
};
|