feat(statements): OCR intake for scanned utility bills
Staff key 300+ utility statements per company per month by hand. This adds the ingest -> split -> OCR -> match -> review pipeline that proposes customer and amount per page instead (RECEIPT_CAPTURE_SPEC §2), posting through the existing BillingService.createBatch seam with source=OCR and a per-document captureRef so machine and hand capture share one write path and audit trail. Everything was designed against 10 real scanned statements (46 pages of CFE, CESPT and Telnor bills) rather than from the sample-free spec. The scans have no text layer at all — they are camera images — so OCR is mandatory, and they arrive bundled one customer per page. Measured on those pages the parser identifies the provider 46/46 and reads an account reference 43/46; against the dev database that is 39/46 (85%) exact auto-match, 40/46 identified, with the rest genuine review cases. That closes the OCR-provider question in favour of self-hosted Tesseract: it clears the bar for a queue where a human confirms every row, and OcrProvider keeps a managed API a one-line swap. The samples corrected three things the spec had wrong or unknown: - Clave catastral is NOT predial. DATMEX.clave (934 rows) is what CESPT and predial bills print; DATMEX.predial, which PROPERTY_TAX.accountNumber holds, has 663 distinct values across 1135 rows and appears on no statement. The clave now lives on Property.cadastralKey as the matcher's secondary key; predial is left untouched. This had been blocking predial matching. - Gas was recoverable: 160 of 334 DATMEX.gas values are real account numbers (the rest are ESTACIONARIO/CILINDRO descriptors), now in GAS.meterNumber. - Phone is one billed line per property (534/18/1 across phone1/2/3), so the new TELEPHONE ServiceKind backfills from phone1 only, not three rows. Matching is scoped to one column per service kind and never reads the customer name — a CESPT receipt prints ARNAIZ ROSAS ELSA AURORA for an account this office holds under CATT, RANDY, because the printed name is the registrant, not the current owner. Where a provider prints a payment barcode it beats the printed label (one CFE label OCR'd a digit too many while its barcode was correct) and the two cross-check, with disagreement forcing review. Confirming a document whose service had no reference writes it back, so gas and any other cold start is a one-time cost rather than a permanent queue. Verified end to end against the live dev API and MinIO: real scans uploaded over HTTP, matched, confirmed against a check, and the resulting rows checked in MySQL (negative amounts, captureSource=OCR, concept derived from the batch kind, captureRef linking back to each page). Re-confirming a posted batch is refused. Test data was removed afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { CustomersModule } from "./customers/customers.module";
|
||||
import { PoliciesModule } from "./policies/policies.module";
|
||||
import { PropertiesModule } from "./properties/properties.module";
|
||||
import { BillingModule } from "./billing/billing.module";
|
||||
import { StatementsModule } from "./statements/statements.module";
|
||||
import { BankModule } from "./bank/bank.module";
|
||||
import { OpsModule } from "./ops/ops.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
@@ -26,6 +27,7 @@ import { AppController } from "./app.controller";
|
||||
PoliciesModule,
|
||||
PropertiesModule,
|
||||
BillingModule,
|
||||
StatementsModule,
|
||||
BankModule,
|
||||
OpsModule,
|
||||
ReportsModule,
|
||||
|
||||
@@ -32,6 +32,8 @@ export type Ability =
|
||||
| "bank:create"
|
||||
| "bank:void"
|
||||
| "bank:manage-accounts"
|
||||
| "statement:ingest"
|
||||
| "statement:review"
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
@@ -54,6 +56,12 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
// Opening or renaming a chequera is rarer and higher-stakes than posting a
|
||||
// movement into one — a wrong account silently mixes two sets of books.
|
||||
"bank:manage-accounts": "MANAGER",
|
||||
// Uploading a stack of scans and reviewing what the OCR read are both
|
||||
// "capturing a receipt" — the same trust tier as ledger:create, since
|
||||
// confirming a statement *is* capturing it. The review step is what makes
|
||||
// this safe at STAFF level: nothing reaches the ledger unconfirmed.
|
||||
"statement:ingest": "STAFF",
|
||||
"statement:review": "STAFF",
|
||||
"lookup:manage": "MANAGER",
|
||||
"user:manage": "ADMIN",
|
||||
"db:manage": "ADMIN",
|
||||
|
||||
@@ -5,5 +5,8 @@ import { BillingService } from "./billing.service";
|
||||
@Module({
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
// The statements module posts confirmed OCR captures through
|
||||
// BillingService.createBatch rather than writing Transaction rows itself.
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The OCR seam. Everything above this interface works in terms of page text and
|
||||
* word boxes, so the concrete engine is swappable without touching the parsers,
|
||||
* the matcher, or the schema.
|
||||
*
|
||||
* The shipped implementation is self-hosted Tesseract (see tesseract.provider).
|
||||
* That choice is evidence-based rather than assumed: run against 46 pages of
|
||||
* real scanned CFE, CESPT and Telnor statements, it identified the provider on
|
||||
* 46/46 and extracted a usable account reference on 43/46, which is well past
|
||||
* the bar for a queue whose whole point is that a human confirms every row. A
|
||||
* managed document-extraction API (Textract, Document Intelligence, Document
|
||||
* AI) fits behind this same interface if per-page accuracy ever proves
|
||||
* insufficient, with no schema change — but at 300+ pages/month/company it
|
||||
* would carry a real recurring cost for accuracy that is not currently the
|
||||
* bottleneck.
|
||||
*/
|
||||
|
||||
/** One OCR'd word, with where it sits on the page. */
|
||||
export interface OcrWord {
|
||||
text: string;
|
||||
/** Pixel box in the rendered page image. */
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Engine confidence for this word, 0..1. */
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface OcrPage {
|
||||
/** Full page text, reading order, newline-separated. */
|
||||
text: string;
|
||||
/**
|
||||
* Word boxes. Needed because two of the three real layouts are *tables* —
|
||||
* the CESPT "RECIBO" prints `No. DE CUENTA` as a column header with the
|
||||
* value in the row beneath it, which line-oriented text cannot associate.
|
||||
* Parsers fall back to geometry for exactly those fields.
|
||||
*/
|
||||
words: OcrWord[];
|
||||
/** Mean word confidence across the page, 0..1. */
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface OcrProvider {
|
||||
/** True when the engine is actually usable in this deployment. */
|
||||
available(): Promise<boolean>;
|
||||
/** Split a PDF into one rendered page image per page. */
|
||||
renderPages(pdf: Buffer): Promise<Buffer[]>;
|
||||
/** OCR a single rendered page image. */
|
||||
recognize(pageImage: Buffer): Promise<OcrPage>;
|
||||
}
|
||||
|
||||
export const OCR_PROVIDER = Symbol("OCR_PROVIDER");
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { OcrPage, OcrProvider, OcrWord } from "./ocr.provider";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Self-hosted OCR: `pdftoppm` (poppler) to rasterise, `tesseract` to read.
|
||||
*
|
||||
* Both are external binaries rather than a native npm addon, which keeps the
|
||||
* pnpm workspace free of a compiled dependency and makes the alpine runtime
|
||||
* image a two-package change (see docker/api.Dockerfile). Like StorageService,
|
||||
* a missing binary degrades rather than crashes the API: the module reports
|
||||
* itself unavailable and statement ingest returns 503, while every other
|
||||
* feature keeps working.
|
||||
*
|
||||
* The settings below are not arbitrary — they were measured against the real
|
||||
* scanned samples:
|
||||
* - 300 DPI grayscale. The source scans are phone photos of paper at ~5MB a
|
||||
* page; below 300 the small print (RMU, clave catastral) stops resolving,
|
||||
* above it costs time for no additional fields.
|
||||
* - `--psm 6` ("assume a single uniform block of text"). The default page
|
||||
* segmentation splits these dense forms into columns and interleaves them,
|
||||
* which destroys the label-then-value adjacency every parser depends on.
|
||||
* - Spanish traineddata, with a graceful fall back to English if the language
|
||||
* pack is absent — an accented label reads worse but the digits, which are
|
||||
* what actually gets matched, are unaffected.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TesseractOcrProvider implements OcrProvider {
|
||||
private readonly logger = new Logger(TesseractOcrProvider.name);
|
||||
private readonly dpi: number;
|
||||
private readonly lang: string;
|
||||
private probe: Promise<boolean> | null = null;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.dpi = Number(config.get("OCR_DPI") ?? 300);
|
||||
this.lang = config.get<string>("OCR_LANG") ?? "spa";
|
||||
}
|
||||
|
||||
/** Cached — the binaries do not appear or vanish while the process runs. */
|
||||
available(): Promise<boolean> {
|
||||
if (!this.probe) {
|
||||
this.probe = (async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
run("tesseract", ["--version"]),
|
||||
run("pdftoppm", ["-v"]),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
"OCR unavailable: `tesseract` and/or `pdftoppm` not found on PATH. " +
|
||||
"Statement ingest is disabled; every other feature is unaffected.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return this.probe;
|
||||
}
|
||||
|
||||
private async require(): Promise<void> {
|
||||
if (!(await this.available())) {
|
||||
throw new ServiceUnavailableException(
|
||||
"El servicio de OCR no está disponible en este servidor.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async scratch<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "stmt-ocr-"));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async renderPages(pdf: Buffer): Promise<Buffer[]> {
|
||||
await this.require();
|
||||
return this.scratch(async (dir) => {
|
||||
const src = join(dir, "in.pdf");
|
||||
await writeFile(src, pdf);
|
||||
// -gray: these are grayscale scans already; colour triples the bytes
|
||||
// handed to tesseract for no gain in character recognition.
|
||||
await run("pdftoppm", [
|
||||
"-r",
|
||||
String(this.dpi),
|
||||
"-gray",
|
||||
"-png",
|
||||
src,
|
||||
join(dir, "page"),
|
||||
]);
|
||||
const files = (await readdir(dir))
|
||||
.filter((f) => f.startsWith("page") && f.endsWith(".png"))
|
||||
// pdftoppm zero-pads its page numbers, so lexical order is page order.
|
||||
.sort();
|
||||
return Promise.all(files.map((f) => readFile(join(dir, f))));
|
||||
});
|
||||
}
|
||||
|
||||
async recognize(pageImage: Buffer): Promise<OcrPage> {
|
||||
await this.require();
|
||||
return this.scratch(async (dir) => {
|
||||
const img = join(dir, "page.png");
|
||||
await writeFile(img, pageImage);
|
||||
|
||||
// One tesseract invocation produces both outputs; TSV carries the word
|
||||
// boxes and per-word confidence, and its text can be reassembled into
|
||||
// reading order, so there is no need to run the engine twice.
|
||||
const out = join(dir, "out");
|
||||
try {
|
||||
await run("tesseract", [img, out, "-l", this.lang, "--psm", "6", "tsv"]);
|
||||
} catch (err) {
|
||||
if (this.lang !== "eng") {
|
||||
this.logger.warn(
|
||||
`Tesseract failed with lang "${this.lang}", retrying with "eng": ${
|
||||
(err as Error).message
|
||||
}`,
|
||||
);
|
||||
await run("tesseract", [img, out, "-l", "eng", "--psm", "6", "tsv"]);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const tsv = await readFile(`${out}.tsv`, "utf8");
|
||||
return parseTsv(tsv);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn tesseract's TSV into words plus reassembled text.
|
||||
*
|
||||
* Columns are: level, page_num, block_num, par_num, line_num, word_num, left,
|
||||
* top, width, height, conf, text. Rows with level < 5 are structural (page,
|
||||
* block, paragraph, line) and carry no text; only level 5 is a word. A conf of
|
||||
* -1 marks a structural row, so those are dropped rather than averaged in —
|
||||
* including them would drag every page's confidence toward zero.
|
||||
*/
|
||||
export function parseTsv(tsv: string): OcrPage {
|
||||
const lines = tsv.split("\n");
|
||||
const header = lines[0]?.split("\t") ?? [];
|
||||
const col = (name: string) => header.indexOf(name);
|
||||
const iLeft = col("left");
|
||||
const iTop = col("top");
|
||||
const iWidth = col("width");
|
||||
const iHeight = col("height");
|
||||
const iConf = col("conf");
|
||||
const iText = col("text");
|
||||
const iLine = col("line_num");
|
||||
const iBlock = col("block_num");
|
||||
|
||||
const words: OcrWord[] = [];
|
||||
// Keyed by block+line so the reassembled text preserves the engine's own
|
||||
// reading order instead of sorting words by raw y, which interleaves columns.
|
||||
const byLine = new Map<string, string[]>();
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const f = lines[i].split("\t");
|
||||
if (f.length <= iText) continue;
|
||||
const text = f[iText]?.trim();
|
||||
if (!text) continue;
|
||||
const confidence = Number(f[iConf]);
|
||||
if (!Number.isFinite(confidence) || confidence < 0) continue;
|
||||
|
||||
words.push({
|
||||
text,
|
||||
left: Number(f[iLeft]) || 0,
|
||||
top: Number(f[iTop]) || 0,
|
||||
width: Number(f[iWidth]) || 0,
|
||||
height: Number(f[iHeight]) || 0,
|
||||
confidence: confidence / 100,
|
||||
});
|
||||
|
||||
const key = `${f[iBlock]}:${f[iLine]}`;
|
||||
const bucket = byLine.get(key);
|
||||
if (bucket) bucket.push(text);
|
||||
else byLine.set(key, [text]);
|
||||
}
|
||||
|
||||
const text = [...byLine.values()].map((w) => w.join(" ")).join("\n");
|
||||
const confidence = words.length
|
||||
? words.reduce((sum, w) => sum + w.confidence, 0) / words.length
|
||||
: 0;
|
||||
|
||||
return { text, words, confidence };
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import type { ServiceKind } from "@jorgecuadros/database";
|
||||
import type { OcrPage, OcrWord } from "../ocr/ocr.provider";
|
||||
|
||||
/**
|
||||
* What one parsed statement page yields. `accountRef` is already normalised to
|
||||
* the form the migrated `PropertyService` columns hold, so the matcher compares
|
||||
* like with like and never has to know about provider-specific formatting.
|
||||
*/
|
||||
export interface ParsedStatement {
|
||||
/** "CFE" | "CESPT" | "TELNOR", or null when no parser claimed the page. */
|
||||
provider: string | null;
|
||||
serviceKind: ServiceKind | null;
|
||||
accountRef: string | null;
|
||||
/** Clave catastral, when printed — a second key to match on. */
|
||||
cadastralKey: string | null;
|
||||
amount: number | null;
|
||||
dueDate: Date | null;
|
||||
period: string | null;
|
||||
/**
|
||||
* Independent corroboration of `accountRef`. CFE and Telnor both print a
|
||||
* payment barcode that repeats the account number (and the amount), so when
|
||||
* the barcode and the label agree the extraction is near-certainly right;
|
||||
* when they disagree, or only one is present, the page is worth a human
|
||||
* glance. Null when the layout has no second source.
|
||||
*/
|
||||
crossChecked: boolean | null;
|
||||
/** Human-readable trail of what was read, surfaced in the review queue. */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
// --- shared helpers ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tesseract confuses these glyphs inside numeric runs with some regularity —
|
||||
* a real clave catastral `KB078025` came back as `KBO78025`. Applied ONLY to
|
||||
* fields known to be digits, never to free text, where it would corrupt words.
|
||||
*/
|
||||
const DIGIT_CONFUSIONS: Record<string, string> = {
|
||||
O: "0",
|
||||
o: "0",
|
||||
D: "0",
|
||||
I: "1",
|
||||
l: "1",
|
||||
"|": "1",
|
||||
S: "5",
|
||||
B: "8",
|
||||
};
|
||||
|
||||
export function toDigits(s: string | null | undefined): string {
|
||||
if (!s) return "";
|
||||
return s
|
||||
.split("")
|
||||
.map((c) => DIGIT_CONFUSIONS[c] ?? c)
|
||||
.join("")
|
||||
.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a printed amount, treating `,` and `.` by position rather than by
|
||||
* assumption. A real Telnor bill OCR'd as "$ 649,00" — blindly stripping commas
|
||||
* as thousands separators turned $649.00 into $64,900, a hundredfold error that
|
||||
* would post silently. Two trailing digits after a single separator are always
|
||||
* cents here; a separator followed by three digits is a thousands group.
|
||||
*/
|
||||
function money(s: string | null | undefined): number | null {
|
||||
if (!s) return null;
|
||||
const cleaned = s.replace(/[\s$]/g, "");
|
||||
|
||||
// 1.234,56 or 1,234.56 — grouped thousands plus optional cents.
|
||||
let m = cleaned.match(/^(\d{1,3}(?:[.,]\d{3})+)([.,]\d{1,2})?$/);
|
||||
if (m) {
|
||||
const whole = m[1].replace(/[.,]/g, "");
|
||||
const cents = m[2] ? m[2].slice(1) : "";
|
||||
return Number(cents ? `${whole}.${cents.padEnd(2, "0")}` : whole);
|
||||
}
|
||||
|
||||
// 649,00 / 649.00 — a single separator with exactly two digits after it.
|
||||
m = cleaned.match(/^(\d+)[.,](\d{2})$/);
|
||||
if (m) return Number(`${m[1]}.${m[2]}`);
|
||||
|
||||
const n = Number(cleaned.replace(/[,.]/g, ""));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function firstMatch(text: string, patterns: RegExp[]): string | null {
|
||||
for (const p of patterns) {
|
||||
const m = text.match(p);
|
||||
if (m?.[1]) return m[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const MONTHS: Record<string, number> = {
|
||||
ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5,
|
||||
JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11,
|
||||
};
|
||||
|
||||
/** Parses the three date shapes these statements actually print. */
|
||||
export function parseDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
const s = raw.trim().toUpperCase();
|
||||
|
||||
// 16/07/2026
|
||||
let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (m) return utc(+m[3], +m[2] - 1, +m[1]);
|
||||
|
||||
// 22-JUL-2026 / 22 JUN 26
|
||||
m = s.match(/^(\d{1,2})[-\s]([A-Z]{3})[A-Z]*[-\s](\d{2,4})$/);
|
||||
if (m && MONTHS[m[2]] !== undefined) {
|
||||
const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
|
||||
return utc(y, MONTHS[m[2]], +m[1]);
|
||||
}
|
||||
|
||||
// 2026-07-22 (already normalised, e.g. decoded from a barcode)
|
||||
m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (m) return utc(+m[1], +m[2] - 1, +m[3]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function utc(y: number, mo: number, d: number): Date | null {
|
||||
const dt = new Date(Date.UTC(y, mo, d));
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value printed *underneath* a column header.
|
||||
*
|
||||
* The CESPT "RECIBO" is a table: `No. DE CUENTA` is a header cell and its value
|
||||
* sits in the row below it, so no amount of label-adjacent regex on line text
|
||||
* can associate the two. This walks the word boxes instead — find the header
|
||||
* word, then take the nearest word below it whose horizontal centre falls
|
||||
* within the column.
|
||||
*/
|
||||
export function valueUnder(
|
||||
page: OcrPage,
|
||||
header: RegExp,
|
||||
opts: { maxDy?: number; tolerance?: number; match?: RegExp } = {},
|
||||
): string | null {
|
||||
const { maxDy = 300, tolerance = 200, match } = opts;
|
||||
const centre = (w: OcrWord) => ({
|
||||
x: w.left + w.width / 2,
|
||||
y: w.top + w.height / 2,
|
||||
});
|
||||
|
||||
for (const h of page.words.filter((w) => header.test(w.text))) {
|
||||
const hc = centre(h);
|
||||
const below = page.words
|
||||
.filter((w) => {
|
||||
const c = centre(w);
|
||||
return c.y > hc.y && c.y <= hc.y + maxDy && Math.abs(c.x - hc.x) <= tolerance;
|
||||
})
|
||||
.sort((a, b) => centre(a).y - centre(b).y);
|
||||
|
||||
for (const w of below) {
|
||||
if (!match || match.test(w.text)) return w.text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- provider detection -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Brand wordmarks first, page structure only as a fallback — and the two passes
|
||||
* must not be interleaved. Scanned logos OCR badly (one CESPT header came back
|
||||
* as "E BAJA ES PAGO / EALIFORNIA", with neither "CESPT" nor "COMISIÓN ESTATAL"
|
||||
* readable), so the structural pass is what rescues those pages. But a Telnor
|
||||
* bill contains the words "Pagar antes de", which a CFE structural rule
|
||||
* evaluated first will happily claim — running all brand checks before any
|
||||
* structural check is what keeps that from happening.
|
||||
*/
|
||||
const BRAND: [string, RegExp][] = [
|
||||
["CFE", /comisi[oó]n federal de electricidad|CFE.?contigo|Suministrador de Servicios/i],
|
||||
["CESPT", /CESPT|COMISI[OÓ]N ESTATAL DE SERVICIOS/i],
|
||||
["TELNOR", /TELNOR|TELEFONOS DEL NOROESTE/i],
|
||||
];
|
||||
|
||||
const LAYOUT: [string, RegExp][] = [
|
||||
["CFE", /NO\.?\s*DE\s*SERVICIO|L[IÍ]MITE\s*DE\s*PAGO|PERIODO\s*FACTURADO/i],
|
||||
["CESPT", /SALDO\s+CORRIENTE|CLAVE\s*CATASTRAL|No\.?\s*DE\s*CUENTA/i],
|
||||
["TELNOR", /Mes\s*de\s*Facturaci[oó]n|Pagar\s*antes\s*de/i],
|
||||
];
|
||||
|
||||
export function detectProvider(text: string): string | null {
|
||||
for (const group of [BRAND, LAYOUT]) {
|
||||
for (const [name, pattern] of group) {
|
||||
if (pattern.test(text)) return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- CFE (electric) ---------------------------------------------------------
|
||||
|
||||
function parseCfe(page: OcrPage): ParsedStatement {
|
||||
const text = page.text;
|
||||
const notes: string[] = [];
|
||||
|
||||
// The payment barcode line repeats the service number, the due date (YYMMDD)
|
||||
// and the amount in one fixed-width run, and reads far more reliably than the
|
||||
// label: on one sample the label came back as "0059603001917" (a digit too
|
||||
// many) while its barcode gave the correct "005960300191". So the barcode
|
||||
// wins, and the label becomes the cross-check rather than the source.
|
||||
const barcode = text.match(/\b01\s+([0-9OIlSBD]{12})\s+([0-9OIlSBD]{6})\s+([0-9OIlSBD]{9})\b/);
|
||||
const label = firstMatch(text, [/NO\.?\s*DE\s*SERVICIO\s*[:;.]?\s*([0-9OIlSBD]{10,14})/i]);
|
||||
|
||||
let accountRef: string | null = null;
|
||||
let amount: number | null = null;
|
||||
let dueDate: Date | null = null;
|
||||
let crossChecked: boolean | null = null;
|
||||
|
||||
if (barcode) {
|
||||
// Leading zeros are print padding: DATMEX.rpu holds the bare 10 digits.
|
||||
accountRef = toDigits(barcode[1]).replace(/^0+/, "");
|
||||
amount = Number(toDigits(barcode[3]));
|
||||
const d = toDigits(barcode[2]);
|
||||
dueDate = parseDate(`20${d.slice(0, 2)}-${d.slice(2, 4)}-${d.slice(4, 6)}`);
|
||||
notes.push("importe y vencimiento leídos del código de barras");
|
||||
if (label) {
|
||||
crossChecked = toDigits(label).replace(/^0+/, "") === accountRef;
|
||||
if (!crossChecked) {
|
||||
notes.push(
|
||||
`el número impreso (${toDigits(label).replace(/^0+/, "")}) no coincide con el código de barras`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (label) {
|
||||
accountRef = toDigits(label).replace(/^0+/, "");
|
||||
notes.push("sin código de barras legible; número tomado de la etiqueta");
|
||||
}
|
||||
|
||||
if (amount == null) {
|
||||
amount = money(firstMatch(text, [/TOTAL\s*A\s*PAGAR\s*[:;.]?\s*\$?\s*([\d,]+\.?\d*)/i]));
|
||||
}
|
||||
if (!dueDate) {
|
||||
dueDate = parseDate(
|
||||
firstMatch(text, [/L[IÍ]MITE\s*DE\s*PAGO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2,4})/i]),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "CFE",
|
||||
serviceKind: "ELECTRIC",
|
||||
accountRef: accountRef || null,
|
||||
cadastralKey: null,
|
||||
amount,
|
||||
dueDate,
|
||||
period: firstMatch(text, [
|
||||
/PERIODO\s*FACTURADO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2}\s*-\s*\d{1,2}\s+\w{3}\s+\d{2})/i,
|
||||
]),
|
||||
crossChecked,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
// --- CESPT (water) ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Two different layouts arrive under the same brand:
|
||||
* - the line-oriented "COMPROBANTE DE PAGO" (`Cuenta : 7604192`), and
|
||||
* - the tabular "RECIBO", where `No. DE CUENTA` is a column header.
|
||||
* Line patterns are tried first; anything they miss falls through to the
|
||||
* geometric read, which is what the tabular layout needs.
|
||||
*/
|
||||
function parseCespt(page: OcrPage): ParsedStatement {
|
||||
const text = page.text;
|
||||
const notes: string[] = [];
|
||||
|
||||
let account = firstMatch(text, [/Cuenta\s*[:;.]?\s*([0-9OIlSBD]{5,9})/i]);
|
||||
if (!account) {
|
||||
account = valueUnder(page, /^CUENTA$/i, { match: /^[0-9OIlSBD]{5,9}$/ });
|
||||
if (account) notes.push("número de cuenta leído de la columna del recibo");
|
||||
}
|
||||
|
||||
let clave = firstMatch(text, [/Cve\.?\s*Cat\.?\s*[:;.]?\s*([A-Z]{2}\s?[0-9OIlSBD]{6})/i]);
|
||||
if (!clave) {
|
||||
clave = valueUnder(page, /^CATASTRAL$/i, { match: /^[A-Z]{2}[0-9OIlSBD]{6}$/i });
|
||||
if (clave) notes.push("clave catastral leída de la columna del recibo");
|
||||
}
|
||||
|
||||
let due = firstMatch(text, [/Fecha\s*Venc\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]);
|
||||
if (!due) due = valueUnder(page, /^VENCIMIENTO$/i, { match: /^\d{2}\/\d{2}\/\d{4}$/ });
|
||||
|
||||
const amount = money(
|
||||
firstMatch(text, [
|
||||
/TOTAL\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i,
|
||||
/SALDO\s+CORRIENTE[^\n]*?([\d,]+\.\d{2})/i,
|
||||
]),
|
||||
);
|
||||
|
||||
// Leading zeros are print padding here too: the RECIBO prints `0457341` for
|
||||
// what DATMEX.agua holds as `457341`.
|
||||
const accountRef = account ? toDigits(account).replace(/^0+/, "") : null;
|
||||
const cadastralKey = clave
|
||||
? clave.replace(/\s/g, "").slice(0, 2).toUpperCase() +
|
||||
toDigits(clave.replace(/\s/g, "").slice(2))
|
||||
: null;
|
||||
|
||||
return {
|
||||
provider: "CESPT",
|
||||
serviceKind: "WATER",
|
||||
accountRef: accountRef || null,
|
||||
cadastralKey: cadastralKey || null,
|
||||
amount,
|
||||
dueDate: parseDate(due),
|
||||
period: null,
|
||||
crossChecked: null,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
// --- TELNOR (telephone) -----------------------------------------------------
|
||||
|
||||
function parseTelnor(page: OcrPage): ParsedStatement {
|
||||
const text = page.text;
|
||||
const notes: string[] = [];
|
||||
|
||||
const label = firstMatch(text, [
|
||||
/Tel[eé]fono\s*[:;.]?\s*([0-9OIlSBD]{3}\s?[0-9OIlSBD]{3}\s?[0-9OIlSBD]{4})/i,
|
||||
]);
|
||||
// The payment stub prints phone (10 digits) + amount in cents (9) + a check
|
||||
// digit: `6646093444 000099900 7` for a $999.00 bill. Reading the amount as
|
||||
// 10 digits swallows the check digit and inflates the figure 100-fold.
|
||||
const barcode = text.match(/\b(\d{10})(\d{9})\d\b/);
|
||||
|
||||
let accountRef: string | null = null;
|
||||
let crossChecked: boolean | null = null;
|
||||
|
||||
// The bill prints the number with its 664 Tijuana LADA; DATMEX stores the
|
||||
// bare local 7 digits, so the LADA is dropped rather than the stored value
|
||||
// being padded — padding would guess at an area code for the 500+ existing
|
||||
// rows that never recorded one.
|
||||
if (label) accountRef = toDigits(label).slice(-7);
|
||||
if (barcode) {
|
||||
const fromBarcode = barcode[1].slice(-7);
|
||||
if (accountRef) {
|
||||
crossChecked = fromBarcode === accountRef;
|
||||
if (!crossChecked) notes.push("el teléfono impreso no coincide con el código de barras");
|
||||
} else {
|
||||
accountRef = fromBarcode;
|
||||
notes.push("teléfono leído del código de barras");
|
||||
}
|
||||
}
|
||||
|
||||
let amount = money(firstMatch(text, [/Total\s*a\s*Pagar\s*[:;.]?\s*\$?\s*([\d,]+\.?\d{0,2})/i]));
|
||||
if (amount == null && barcode) {
|
||||
amount = Number(barcode[2]) / 100;
|
||||
notes.push("importe leído del código de barras");
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "TELNOR",
|
||||
serviceKind: "TELEPHONE",
|
||||
accountRef: accountRef || null,
|
||||
cadastralKey: null,
|
||||
amount,
|
||||
dueDate: parseDate(
|
||||
firstMatch(text, [/Pagar\s*antes\s*de\s*[:;.]?\s*(\d{2}-\w{3}-\d{4})/i]),
|
||||
),
|
||||
period: firstMatch(text, [/Mes\s*de\s*Facturaci[oó]n\s*[:;.]?\s*(\w+)/i]),
|
||||
crossChecked,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
const PARSERS: Record<string, (page: OcrPage) => ParsedStatement> = {
|
||||
CFE: parseCfe,
|
||||
CESPT: parseCespt,
|
||||
TELNOR: parseTelnor,
|
||||
};
|
||||
|
||||
const EMPTY: ParsedStatement = {
|
||||
provider: null,
|
||||
serviceKind: null,
|
||||
accountRef: null,
|
||||
cadastralKey: null,
|
||||
amount: null,
|
||||
dueDate: null,
|
||||
period: null,
|
||||
crossChecked: null,
|
||||
notes: [],
|
||||
};
|
||||
|
||||
/** Detect the provider and run its parser. */
|
||||
export function parseStatement(page: OcrPage): ParsedStatement {
|
||||
const provider = detectProvider(page.text);
|
||||
if (!provider) return { ...EMPTY, notes: ["no se reconoció el proveedor"] };
|
||||
return PARSERS[provider](page);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { ServiceKind } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { ParsedStatement } from "./parsers/statement-parser";
|
||||
|
||||
export interface MatchResult {
|
||||
propertyServiceId: string | null;
|
||||
customerId: string | null;
|
||||
/** Why it landed here — shown in the review queue verbatim. */
|
||||
note: string;
|
||||
/** True only for an unambiguous hit on the scoped field. */
|
||||
confident: boolean;
|
||||
/** Populated when more than one service claims the same number. */
|
||||
candidates: { propertyServiceId: string; customerId: string; customerName: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a parsed statement to the customer who should be billed for it.
|
||||
*
|
||||
* Two rules govern everything here.
|
||||
*
|
||||
* **Match on one scoped field, never fuzzily across all identifiers.** Each
|
||||
* service kind has exactly one column its statements print, and only that
|
||||
* column is consulted. A blanket search over accountNumber/meterNumber/route
|
||||
* would let a water account number collide with an unrelated phone number, and
|
||||
* the resulting mis-post would look perfectly ordinary in the ledger.
|
||||
*
|
||||
* **Never match on the customer name.** The name on a utility bill is the
|
||||
* account's registrant, which drifts from the current owner and is often years
|
||||
* stale — one sample CESPT receipt is printed to "ARNAIZ ROSAS ELSA AURORA"
|
||||
* for an account this office holds under "CATT, RANDY", who is not the same
|
||||
* person. Names are displayed for the reviewer to sanity-check, and are never
|
||||
* an input to matching.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StatementMatcherService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Which PropertyService column a given kind's statements actually print. */
|
||||
private fieldFor(kind: ServiceKind): "accountNumber" | "meterNumber" | null {
|
||||
switch (kind) {
|
||||
case "ELECTRIC": // CFE "NO. DE SERVICIO" -> DATMEX.rpu
|
||||
case "WATER": // CESPT "Cuenta" / "No. DE CUENTA" -> DATMEX.agua
|
||||
case "TELEPHONE": // Telnor "Teléfono" (LADA stripped) -> DATMEX.telefono
|
||||
case "FEDERAL_ZONE":
|
||||
case "CABLE":
|
||||
return "accountNumber";
|
||||
case "GAS": // no account column in DATMEX; the number lived in notes
|
||||
return "meterNumber";
|
||||
// PROPERTY_TAX deliberately has no scoped column: what its
|
||||
// accountNumber holds is DATMEX.predial, which is neither unique nor
|
||||
// printed on any statement. Predial bills match on the clave catastral
|
||||
// alone — see matchByCadastralKey.
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async match(parsed: ParsedStatement, expectedKind: ServiceKind): Promise<MatchResult> {
|
||||
const kind = parsed.serviceKind ?? expectedKind;
|
||||
|
||||
// The uploader labels a batch with one service kind. If the parser reads a
|
||||
// page as a different provider, that is a mis-sorted page, not a match —
|
||||
// posting it would book a phone bill as a water charge.
|
||||
if (parsed.serviceKind && parsed.serviceKind !== expectedKind) {
|
||||
return this.unmatched(
|
||||
`la página parece de ${parsed.provider} (${parsed.serviceKind}) pero el lote es de ${expectedKind}`,
|
||||
);
|
||||
}
|
||||
|
||||
const field = this.fieldFor(kind);
|
||||
|
||||
if (field && parsed.accountRef) {
|
||||
const hit = await this.byServiceField(kind, field, parsed.accountRef);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// Secondary key. The clave catastral is printed on CESPT bills as well as
|
||||
// predial ones, so it rescues a page whose account number did not OCR —
|
||||
// which happened on real samples, where the clave read cleanly and the
|
||||
// account number did not.
|
||||
if (parsed.cadastralKey) {
|
||||
const hit = await this.byCadastralKey(kind, parsed.cadastralKey);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
if (!field && !parsed.cadastralKey) {
|
||||
return this.unmatched(
|
||||
kind === "PROPERTY_TAX"
|
||||
? "el predial sólo se puede identificar por clave catastral y no se leyó ninguna"
|
||||
: `no hay campo de búsqueda definido para ${kind}`,
|
||||
);
|
||||
}
|
||||
return this.unmatched(
|
||||
parsed.accountRef
|
||||
? `no se encontró ningún servicio de ${kind} con la referencia ${parsed.accountRef}`
|
||||
: "no se pudo leer la referencia de la cuenta",
|
||||
);
|
||||
}
|
||||
|
||||
private async byServiceField(
|
||||
kind: ServiceKind,
|
||||
field: "accountNumber" | "meterNumber",
|
||||
ref: string,
|
||||
): Promise<MatchResult | null> {
|
||||
const rows = await this.prisma.propertyService.findMany({
|
||||
where: { kind, [field]: ref },
|
||||
select: {
|
||||
id: true,
|
||||
property: {
|
||||
select: { customerId: true, customer: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const candidates = rows.map((r) => ({
|
||||
propertyServiceId: r.id,
|
||||
customerId: r.property.customerId,
|
||||
customerName: r.property.customer.name,
|
||||
}));
|
||||
|
||||
// Duplicate account numbers do occur in the legacy data (the office's own
|
||||
// DUPLICADOS report existed for a reason), so every candidate is surfaced
|
||||
// for the reviewer to choose rather than one being picked arbitrarily.
|
||||
if (rows.length > 1) {
|
||||
return {
|
||||
propertyServiceId: null,
|
||||
customerId: null,
|
||||
note: `${rows.length} servicios comparten la referencia ${ref}`,
|
||||
confident: false,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
propertyServiceId: candidates[0].propertyServiceId,
|
||||
customerId: candidates[0].customerId,
|
||||
note: `coincidencia exacta por ${field === "accountNumber" ? "número de cuenta" : "medidor"} ${ref}`,
|
||||
confident: true,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
private async byCadastralKey(
|
||||
kind: ServiceKind,
|
||||
key: string,
|
||||
): Promise<MatchResult | null> {
|
||||
const props = await this.prisma.property.findMany({
|
||||
where: { cadastralKey: key },
|
||||
select: {
|
||||
customerId: true,
|
||||
customer: { select: { name: true } },
|
||||
services: { where: { kind }, select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (props.length === 0) return null;
|
||||
|
||||
const candidates = props.flatMap((p) =>
|
||||
(p.services.length ? p.services.map((s) => s.id) : [null]).map((sid) => ({
|
||||
propertyServiceId: sid as string,
|
||||
customerId: p.customerId,
|
||||
customerName: p.customer.name,
|
||||
})),
|
||||
);
|
||||
|
||||
if (candidates.length > 1) {
|
||||
return {
|
||||
propertyServiceId: null,
|
||||
customerId: null,
|
||||
note: `${candidates.length} propiedades comparten la clave catastral ${key}`,
|
||||
confident: false,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
// The clave identifies the property with certainty, but it is a *secondary*
|
||||
// key: it was not the number the statement was issued against. Left for
|
||||
// review so the confirm also teaches the matcher the account number, rather
|
||||
// than the same page needing the fallback again next month.
|
||||
return {
|
||||
propertyServiceId: candidates[0].propertyServiceId ?? null,
|
||||
customerId: candidates[0].customerId,
|
||||
note: `identificado por clave catastral ${key}; confirme para registrar también el número de cuenta`,
|
||||
confident: false,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
private unmatched(note: string): MatchResult {
|
||||
return {
|
||||
propertyServiceId: null,
|
||||
customerId: null,
|
||||
note,
|
||||
confident: false,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
||||
|
||||
export class CreateStatementBatchDto {
|
||||
@IsEnum(ServiceKind) serviceKind!: ServiceKind;
|
||||
@IsOptional() @IsString() label?: string;
|
||||
}
|
||||
|
||||
/** Staff correction of one document's extracted fields or its match. */
|
||||
export class ReviewDocumentDto {
|
||||
@IsOptional() @IsString() accountRef?: string;
|
||||
@IsOptional() @IsNumber() amount?: number;
|
||||
@IsOptional() @IsString() period?: string;
|
||||
@IsOptional() @IsString() dueDate?: string;
|
||||
@IsOptional() @IsString() matchedPropertyServiceId?: string;
|
||||
@IsOptional() @IsString() matchedCustomerId?: string;
|
||||
// Restricted to the review-reachable states: a client cannot declare a
|
||||
// document POSTED, because only a successful ledger write may do that.
|
||||
@IsOptional()
|
||||
@IsEnum(StatementDocumentStatus)
|
||||
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a batch's confirmed documents. The check-level fields are shared by
|
||||
* every line, exactly as on the manual batch-capture screen — an OCR batch is
|
||||
* still "these receipts, paid by this check".
|
||||
*/
|
||||
export class ConfirmBatchDto {
|
||||
@IsString() @MinLength(1) checkNumber!: string;
|
||||
@IsString() @MinLength(1) transactionDate!: string;
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
/** Overrides the concept derived from the batch's service kind. */
|
||||
@IsOptional() @IsString() typeId?: string;
|
||||
/** Post as outstanding (sin fondos) — captured but not yet funded. */
|
||||
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||
/** Also post documents a reviewer explicitly marked CONFIRMED. */
|
||||
@IsOptional() @IsBoolean() includeReviewed?: boolean;
|
||||
}
|
||||
|
||||
export class ListBatchesQuery {
|
||||
@IsOptional() @IsInt() page?: number;
|
||||
@IsOptional() @IsInt() pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||
import type { ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import type { UploadedFileLike } from "../storage/upload-file";
|
||||
import { StatementsService } from "./statements.service";
|
||||
import { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
||||
|
||||
/**
|
||||
* Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
|
||||
*
|
||||
* Nothing here writes to the ledger directly — confirming a batch delegates to
|
||||
* BillingService, so an OCR-captured charge is indistinguishable from a
|
||||
* hand-keyed one except for its `captureSource`.
|
||||
*/
|
||||
@Controller("statements")
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
export class StatementsController {
|
||||
constructor(
|
||||
private readonly statements: StatementsService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string } | undefined)?.id ?? "";
|
||||
}
|
||||
|
||||
/** Whether this deployment can OCR at all — the UI hides upload without it. */
|
||||
@Get("status")
|
||||
async status() {
|
||||
return { ocrAvailable: await this.statements.ocrAvailable() };
|
||||
}
|
||||
|
||||
@Get("batches")
|
||||
listBatches(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||
return this.statements.listBatches(
|
||||
Math.max(1, Number(page) || 1),
|
||||
Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
);
|
||||
}
|
||||
|
||||
@Get("batches/:id")
|
||||
getBatch(@Param("id") id: string) {
|
||||
return this.statements.getBatch(id);
|
||||
}
|
||||
|
||||
@Get("batches/:id/documents")
|
||||
listDocuments(@Param("id") id: string, @Query("status") status?: string) {
|
||||
return this.statements.listDocuments(
|
||||
id,
|
||||
(status || undefined) as StatementDocumentStatus | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** The rendered page, so a reviewer can compare it against what was read. */
|
||||
@Get("documents/:id/page")
|
||||
async pageImage(@Param("id") id: string, @Res({ passthrough: true }) res: Response) {
|
||||
const { stream, contentType, contentLength } = await this.statements.pageImage(id);
|
||||
res.set({
|
||||
"Content-Type": contentType ?? "image/png",
|
||||
...(contentLength ? { "Content-Length": String(contentLength) } : {}),
|
||||
});
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
@Post("batches")
|
||||
@RequireAbility("statement:ingest")
|
||||
@UseInterceptors(
|
||||
// A month of one company's statements is a handful of multi-page scans;
|
||||
// 25 files at 50MB covers that with room to spare.
|
||||
FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }),
|
||||
)
|
||||
async createBatch(
|
||||
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||
@Query("serviceKind") serviceKind: ServiceKind,
|
||||
@Query("label") label: string | undefined,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const batch = await this.statements.createBatch(
|
||||
files ?? [],
|
||||
serviceKind,
|
||||
this.actingId(req),
|
||||
label,
|
||||
);
|
||||
void this.audit.log(this.actingId(req), "statement.batch.create", {
|
||||
batchId: batch.id,
|
||||
serviceKind,
|
||||
fileCount: batch.fileCount,
|
||||
});
|
||||
return batch;
|
||||
}
|
||||
|
||||
@Patch("documents/:id")
|
||||
@RequireAbility("statement:review")
|
||||
async review(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ReviewDocumentDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const doc = await this.statements.review(id, dto, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "statement.document.review", {
|
||||
documentId: id,
|
||||
status: doc.status,
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Post("documents/:id/reject")
|
||||
@RequireAbility("statement:review")
|
||||
async reject(@Param("id") id: string, @Req() req: Request) {
|
||||
const doc = await this.statements.reject(id, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "statement.document.reject", {
|
||||
documentId: id,
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Post every matched document in the batch, against one check. */
|
||||
@Post("batches/:id/confirm")
|
||||
@RequireAbility("statement:review")
|
||||
async confirm(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ConfirmBatchDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const result = await this.statements.confirmBatch(id, dto, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "statement.batch.confirm", {
|
||||
batchId: id,
|
||||
posted: result.posted,
|
||||
total: result.total,
|
||||
checkNumber: dto.checkNumber,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { StatementsController } from "./statements.controller";
|
||||
import { StatementsService } from "./statements.service";
|
||||
import { StatementMatcherService } from "./statement-matcher.service";
|
||||
import { OCR_PROVIDER } from "./ocr/ocr.provider";
|
||||
import { TesseractOcrProvider } from "./ocr/tesseract.provider";
|
||||
|
||||
/**
|
||||
* The concrete OCR engine is bound here and nowhere else — everything
|
||||
* downstream depends on the OcrProvider interface, so swapping Tesseract for a
|
||||
* managed extraction API is a one-line change in this file.
|
||||
*/
|
||||
@Module({
|
||||
imports: [BillingModule],
|
||||
controllers: [StatementsController],
|
||||
providers: [
|
||||
StatementsService,
|
||||
StatementMatcherService,
|
||||
{ provide: OCR_PROVIDER, useClass: TesseractOcrProvider },
|
||||
],
|
||||
})
|
||||
export class StatementsModule {}
|
||||
@@ -0,0 +1,457 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
Prisma,
|
||||
type ServiceKind,
|
||||
type StatementDocumentStatus,
|
||||
} from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import type { UploadedFileLike } from "../storage/upload-file";
|
||||
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
||||
import { parseStatement } from "./parsers/statement-parser";
|
||||
import { StatementMatcherService } from "./statement-matcher.service";
|
||||
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
||||
|
||||
/**
|
||||
* Default ledger concept per service kind. The names are the legacy
|
||||
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
|
||||
* per confirm rather than hard-coded as ids, which differ per environment.
|
||||
*/
|
||||
const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
|
||||
ELECTRIC: "ELECTRIC",
|
||||
WATER: "WATER",
|
||||
TELEPHONE: "TELEPHONE",
|
||||
GAS: "GAS BUTANO",
|
||||
PROPERTY_TAX: "PROPERTY TAXES",
|
||||
FEDERAL_ZONE: "FEDERAL ZONE",
|
||||
CABLE: "CABLE",
|
||||
};
|
||||
|
||||
/** Statuses a document can still be worked on from. */
|
||||
const OPEN: StatementDocumentStatus[] = ["NEEDS_REVIEW", "MATCHED", "CONFIRMED"];
|
||||
|
||||
@Injectable()
|
||||
export class StatementsService {
|
||||
private readonly logger = new Logger(StatementsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly matcher: StatementMatcherService,
|
||||
@Inject(OCR_PROVIDER) private readonly ocr: OcrProvider,
|
||||
) {}
|
||||
|
||||
ocrAvailable(): Promise<boolean> {
|
||||
return this.ocr.available();
|
||||
}
|
||||
|
||||
// --- ingest ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accept a batch of scanned PDFs and start processing.
|
||||
*
|
||||
* Processing is kicked off but deliberately not awaited: 300 pages of OCR is
|
||||
* minutes of CPU, far past any sane HTTP timeout. The caller gets the batch
|
||||
* id immediately and polls its status, which is also what lets the review
|
||||
* queue show partial progress.
|
||||
*/
|
||||
async createBatch(
|
||||
files: UploadedFileLike[],
|
||||
serviceKind: ServiceKind,
|
||||
uploadedById: string,
|
||||
label?: string,
|
||||
) {
|
||||
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
|
||||
if (!(await this.ocr.available())) {
|
||||
throw new BadRequestException(
|
||||
"El servidor no tiene OCR instalado; no se pueden procesar recibos.",
|
||||
);
|
||||
}
|
||||
|
||||
const batch = await this.prisma.statementBatch.create({
|
||||
data: { serviceKind, uploadedById, label, fileCount: files.length },
|
||||
});
|
||||
|
||||
// Buffers are held for the background pass; the request's own copies would
|
||||
// otherwise be garbage once the response is sent.
|
||||
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||
void this.process(batch.id, copies, serviceKind).catch(async (err) => {
|
||||
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`);
|
||||
await this.prisma.statementBatch.update({
|
||||
where: { id: batch.id },
|
||||
data: { status: "FAILED", error: (err as Error).message },
|
||||
});
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
/** Render → OCR → parse → match, one document row per page. */
|
||||
private async process(
|
||||
batchId: string,
|
||||
files: { buffer: Buffer; name?: string }[],
|
||||
serviceKind: ServiceKind,
|
||||
) {
|
||||
await this.prisma.statementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: "PROCESSING" },
|
||||
});
|
||||
|
||||
let pageNumber = 0;
|
||||
for (const file of files) {
|
||||
// The source PDF is kept as well as the page images: it is the artifact
|
||||
// the office actually received, and the only way to re-run a corrected
|
||||
// parser over the original later.
|
||||
const sourceKey = `statement/${batchId}/source-${pageNumber + 1}.pdf`;
|
||||
await this.storage.put(sourceKey, file.buffer, "application/pdf");
|
||||
|
||||
const pages = await this.ocr.renderPages(file.buffer);
|
||||
for (const image of pages) {
|
||||
pageNumber += 1;
|
||||
const storageKey = `statement/${batchId}/page-${pageNumber}.png`;
|
||||
await this.storage.put(storageKey, image, "image/png");
|
||||
|
||||
try {
|
||||
const ocr = await this.ocr.recognize(image);
|
||||
const parsed = parseStatement(ocr);
|
||||
const match = await this.matcher.match(parsed, serviceKind);
|
||||
|
||||
const notes = [...parsed.notes, match.note].filter(Boolean);
|
||||
// A confident field match is only trusted when nothing contradicts
|
||||
// it: a barcode that disagrees with the printed number means one of
|
||||
// the two was misread, and which one is a judgement call.
|
||||
const trusted = match.confident && parsed.crossChecked !== false;
|
||||
|
||||
await this.prisma.statementDocument.create({
|
||||
data: {
|
||||
batchId,
|
||||
pageNumber,
|
||||
storageKey,
|
||||
status: trusted ? "MATCHED" : "NEEDS_REVIEW",
|
||||
ocrRawText: ocr.text,
|
||||
ocrConfidence: new Prisma.Decimal(ocr.confidence.toFixed(3)),
|
||||
provider: parsed.provider,
|
||||
extractedAccountRef: parsed.accountRef,
|
||||
extractedAmount:
|
||||
parsed.amount != null ? new Prisma.Decimal(parsed.amount) : null,
|
||||
extractedPeriod: parsed.period,
|
||||
extractedDueDate: parsed.dueDate,
|
||||
extractedCadastralKey: parsed.cadastralKey,
|
||||
matchedPropertyServiceId: match.propertyServiceId,
|
||||
matchedCustomerId: match.customerId,
|
||||
matchNote: notes.join("; ").slice(0, 190),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// One unreadable page must not abandon the other 299.
|
||||
await this.prisma.statementDocument.create({
|
||||
data: {
|
||||
batchId,
|
||||
pageNumber,
|
||||
storageKey,
|
||||
status: "OCR_FAILED",
|
||||
matchNote: (err as Error).message.slice(0, 190),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.statementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: "READY_FOR_REVIEW" },
|
||||
});
|
||||
}
|
||||
|
||||
// --- reads ----------------------------------------------------------------
|
||||
|
||||
async listBatches(page: number, pageSize: number) {
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.statementBatch.count(),
|
||||
this.prisma.statementBatch.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
uploadedBy: { select: { name: true } },
|
||||
_count: { select: { documents: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
async getBatch(id: string) {
|
||||
const batch = await this.prisma.statementBatch.findUnique({
|
||||
where: { id },
|
||||
include: { uploadedBy: { select: { name: true } } },
|
||||
});
|
||||
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||
|
||||
const counts = await this.prisma.statementDocument.groupBy({
|
||||
by: ["status"],
|
||||
where: { batchId: id },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const totals = await this.prisma.statementDocument.aggregate({
|
||||
where: { batchId: id, status: { in: OPEN } },
|
||||
_sum: { extractedAmount: true },
|
||||
});
|
||||
|
||||
return {
|
||||
...batch,
|
||||
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
||||
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
|
||||
};
|
||||
}
|
||||
|
||||
async listDocuments(batchId: string, status?: StatementDocumentStatus) {
|
||||
return this.prisma.statementDocument.findMany({
|
||||
where: { batchId, ...(status ? { status } : {}) },
|
||||
orderBy: { pageNumber: "asc" },
|
||||
include: {
|
||||
matchedCustomer: { select: { id: true, name: true } },
|
||||
matchedPropertyService: {
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
accountNumber: true,
|
||||
meterNumber: true,
|
||||
property: { select: { id: true, addressLine1: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The rendered page image, so a reviewer can read what the parser read. */
|
||||
async pageImage(documentId: string) {
|
||||
const doc = await this.prisma.statementDocument.findUnique({
|
||||
where: { id: documentId },
|
||||
select: { storageKey: true },
|
||||
});
|
||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||
return this.storage.getStream(doc.storageKey);
|
||||
}
|
||||
|
||||
// --- review ---------------------------------------------------------------
|
||||
|
||||
/** Staff correction of an extracted field or of the match itself. */
|
||||
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
|
||||
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||
if (doc.status === "POSTED") {
|
||||
throw new BadRequestException("Este documento ya fue registrado.");
|
||||
}
|
||||
|
||||
// Changing the service implies its owner; deriving the customer here rather
|
||||
// than trusting a client-supplied pair is what stops a page being posted to
|
||||
// one customer's ledger against another customer's service.
|
||||
let matchedCustomerId = doc.matchedCustomerId;
|
||||
let matchedPropertyServiceId = dto.matchedPropertyServiceId ?? undefined;
|
||||
|
||||
if (dto.matchedPropertyServiceId) {
|
||||
const svc = await this.prisma.propertyService.findUnique({
|
||||
where: { id: dto.matchedPropertyServiceId },
|
||||
select: { property: { select: { customerId: true } } },
|
||||
});
|
||||
if (!svc) throw new BadRequestException("Servicio no encontrado.");
|
||||
matchedCustomerId = svc.property.customerId;
|
||||
} else if (dto.matchedCustomerId) {
|
||||
matchedCustomerId = dto.matchedCustomerId;
|
||||
|
||||
// A reviewer picks a *customer*, not one of their service rows. Without
|
||||
// a service the posting still works, but the confirmed reference has
|
||||
// nowhere to be written back, so the same account would land in review
|
||||
// again next month — which is exactly the behaviour that is supposed to
|
||||
// make gas (whose numbers the migration never populated) a one-time cost.
|
||||
// So: if the batch's service kind resolves to exactly one of that
|
||||
// customer's services that has no reference yet, attach it. Exactly one
|
||||
// — with two candidates there is no way to tell which meter or line the
|
||||
// bill belongs to, and guessing would write a real number onto the wrong
|
||||
// service.
|
||||
const batch = await this.prisma.statementBatch.findUnique({
|
||||
where: { id: doc.batchId },
|
||||
select: { serviceKind: true },
|
||||
});
|
||||
if (batch) {
|
||||
const field = batch.serviceKind === "GAS" ? "meterNumber" : "accountNumber";
|
||||
const blank = await this.prisma.propertyService.findMany({
|
||||
where: {
|
||||
kind: batch.serviceKind,
|
||||
[field]: null,
|
||||
property: { customerId: matchedCustomerId },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 2,
|
||||
});
|
||||
if (blank.length === 1) matchedPropertyServiceId = blank[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.statementDocument.update({
|
||||
where: { id },
|
||||
data: {
|
||||
extractedAccountRef: dto.accountRef ?? undefined,
|
||||
extractedAmount:
|
||||
dto.amount != null ? new Prisma.Decimal(dto.amount) : undefined,
|
||||
extractedPeriod: dto.period ?? undefined,
|
||||
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||
matchedPropertyServiceId,
|
||||
matchedCustomerId,
|
||||
status: dto.status ?? "MATCHED",
|
||||
reviewedById,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async reject(id: string, reviewedById: string) {
|
||||
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||
if (doc.status === "POSTED") {
|
||||
throw new BadRequestException("Este documento ya fue registrado.");
|
||||
}
|
||||
return this.prisma.statementDocument.update({
|
||||
where: { id },
|
||||
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// --- posting --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Post every confirmable document in a batch to the ledger.
|
||||
*
|
||||
* This goes through `BillingService.createBatch` — the same method the manual
|
||||
* "Editor" screen uses — rather than writing `Transaction` rows directly, so
|
||||
* OCR-sourced and hand-keyed receipts share one write path, one validation
|
||||
* path and one audit trail. `source: "OCR"` and a per-line `captureRef` of
|
||||
* the document id give the duplicate-post guard something to key on, so a
|
||||
* batch confirmed twice cannot double-charge anyone.
|
||||
*/
|
||||
async confirmBatch(batchId: string, dto: ConfirmBatchDto, reviewedById: string) {
|
||||
const batch = await this.prisma.statementBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
});
|
||||
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||
|
||||
const docs = await this.prisma.statementDocument.findMany({
|
||||
where: {
|
||||
batchId,
|
||||
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
|
||||
matchedCustomerId: { not: null },
|
||||
},
|
||||
orderBy: { pageNumber: "asc" },
|
||||
});
|
||||
if (!docs.length) {
|
||||
throw new BadRequestException("No hay documentos listos para registrar.");
|
||||
}
|
||||
|
||||
const missing = docs.filter((d) => d.extractedAmount == null);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException(
|
||||
`Falta el importe en ${missing.length} documento(s): página(s) ` +
|
||||
missing.map((d) => d.pageNumber).join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind));
|
||||
|
||||
const result = await this.billing.createBatch(
|
||||
{
|
||||
domain: "UTILITY",
|
||||
transactionDate: dto.transactionDate,
|
||||
checkNumber: dto.checkNumber,
|
||||
currency: dto.currency ?? "MXN",
|
||||
typeId,
|
||||
lines: docs.map((d) => ({
|
||||
customerId: d.matchedCustomerId!,
|
||||
// Charges are negative in this ledger: a negative amount is what the
|
||||
// customer owes. The parser reads the printed (positive) figure, so
|
||||
// the sign is applied here, at the single point where a statement
|
||||
// becomes a ledger row.
|
||||
amount: -Math.abs(Number(d.extractedAmount)),
|
||||
reference: d.extractedAccountRef ?? undefined,
|
||||
period: d.extractedPeriod ?? undefined,
|
||||
outstanding: dto.outstanding ?? false,
|
||||
})),
|
||||
},
|
||||
{ source: "OCR", refs: docs.map((d) => d.id) },
|
||||
);
|
||||
|
||||
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
|
||||
// the created rows zip straight back onto the documents that produced them.
|
||||
await this.prisma.$transaction(
|
||||
docs.map((d, i) =>
|
||||
this.prisma.statementDocument.update({
|
||||
where: { id: d.id },
|
||||
data: {
|
||||
status: "POSTED",
|
||||
postedTransactionId: result.items[i].id,
|
||||
reviewedById,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Teach the matcher. When a document was matched by clave catastral or by
|
||||
// hand because the scoped field was blank, writing the reference back means
|
||||
// next month's statement for the same account matches on its own — this is
|
||||
// what turns gas (whose numbers the migration never populated) from a
|
||||
// permanent review queue into a one-time cost.
|
||||
await this.learnAccountRefs(docs, batch.serviceKind);
|
||||
|
||||
await this.closeIfDone(batchId);
|
||||
|
||||
return { posted: result.count, total: result.total, checkNumber: dto.checkNumber };
|
||||
}
|
||||
|
||||
/** Write a confirmed reference onto a service that had none. */
|
||||
private async learnAccountRefs(
|
||||
docs: { matchedPropertyServiceId: string | null; extractedAccountRef: string | null }[],
|
||||
kind: ServiceKind,
|
||||
) {
|
||||
const field = kind === "GAS" ? "meterNumber" : "accountNumber";
|
||||
for (const d of docs) {
|
||||
if (!d.matchedPropertyServiceId || !d.extractedAccountRef) continue;
|
||||
await this.prisma.propertyService.updateMany({
|
||||
// Only fills a hole — never overwrites a number already on file, which
|
||||
// would let one misread page rewrite good reference data.
|
||||
where: { id: d.matchedPropertyServiceId, [field]: null },
|
||||
data: { [field]: d.extractedAccountRef },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async closeIfDone(batchId: string) {
|
||||
const open = await this.prisma.statementDocument.count({
|
||||
where: { batchId, status: { in: OPEN } },
|
||||
});
|
||||
if (open === 0) {
|
||||
await this.prisma.statementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: "COMPLETED", completedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async conceptFor(kind: ServiceKind): Promise<string | undefined> {
|
||||
const name = CONCEPT_BY_KIND[kind];
|
||||
if (!name) return undefined;
|
||||
const row = await this.prisma.typeTransaction.findFirst({
|
||||
where: { nameEn: name },
|
||||
select: { id: true },
|
||||
});
|
||||
return row?.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import {
|
||||
confirmStatementBatch,
|
||||
getStatementBatch,
|
||||
listStatementDocuments,
|
||||
rejectStatementDocument,
|
||||
reviewStatementDocument,
|
||||
statementPageUrl,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
||||
import type {
|
||||
ConfirmBatchInput,
|
||||
StatementBatchDetail,
|
||||
StatementDocument,
|
||||
StatementDocumentStatus,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Review queue for one batch of scanned bills.
|
||||
*
|
||||
* The reviewer's job is to answer one question per page — "is this the right
|
||||
* customer for this amount?" — so the page image sits next to the extracted
|
||||
* fields and every row can be corrected in place. Rows the matcher is sure
|
||||
* about are pre-approved and can be posted in bulk; everything else is listed
|
||||
* first, because that is the work.
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<StatementDocumentStatus, string> = {
|
||||
PENDING_OCR: "En proceso",
|
||||
OCR_FAILED: "No se pudo leer",
|
||||
NEEDS_REVIEW: "Requiere revisión",
|
||||
MATCHED: "Identificado",
|
||||
CONFIRMED: "Confirmado",
|
||||
POSTED: "Registrado",
|
||||
REJECTED: "Descartado",
|
||||
};
|
||||
|
||||
/** Rows still needing a decision, listed before the settled ones. */
|
||||
const OPEN_FIRST: StatementDocumentStatus[] = [
|
||||
"NEEDS_REVIEW",
|
||||
"OCR_FAILED",
|
||||
"MATCHED",
|
||||
"CONFIRMED",
|
||||
"POSTED",
|
||||
"REJECTED",
|
||||
"PENDING_OCR",
|
||||
];
|
||||
|
||||
export default function RecibosBatchPage({ params }: { params: { id: string } }) {
|
||||
return (
|
||||
<AppShell>
|
||||
<BatchReview id={params.id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchReview({ id }: { id: string }) {
|
||||
const canReview = useCan("statement:review");
|
||||
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
||||
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [b, d] = await Promise.all([
|
||||
getStatementBatch(id),
|
||||
listStatementDocuments(id),
|
||||
]);
|
||||
setBatch(b);
|
||||
setDocs(d);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo cargar el lote.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const processing = batch?.status === "PROCESSING" || batch?.status === "UPLOADED";
|
||||
useEffect(() => {
|
||||
if (!processing) return;
|
||||
const t = setInterval(() => void load(), 4000);
|
||||
return () => clearInterval(t);
|
||||
}, [processing, load]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...docs].sort(
|
||||
(a, b) =>
|
||||
OPEN_FIRST.indexOf(a.status) - OPEN_FIRST.indexOf(b.status) ||
|
||||
a.pageNumber - b.pageNumber,
|
||||
),
|
||||
[docs],
|
||||
);
|
||||
|
||||
const readyCount = docs.filter(
|
||||
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
||||
).length;
|
||||
|
||||
if (loading) return <div className="state-box">Cargando…</div>;
|
||||
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">
|
||||
Recibos — {serviceKindLabel(batch.serviceKind)}
|
||||
{batch.label ? ` · ${batch.label}` : ""}
|
||||
</h1>
|
||||
<p className="page-sub">
|
||||
{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "}
|
||||
{STATUS_LABEL_BATCH[batch.status] ?? batch.status}
|
||||
</p>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" href="/recibos">
|
||||
Volver
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
{processing && (
|
||||
<div className="state-box">
|
||||
Leyendo los recibos… esta pantalla se actualiza sola.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SummaryCard batch={batch} readyCount={readyCount} />
|
||||
|
||||
{canReview && readyCount > 0 && (
|
||||
<ConfirmCard
|
||||
batchId={id}
|
||||
readyCount={readyCount}
|
||||
onDone={load}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="stack">
|
||||
{sorted.map((doc) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
canReview={canReview}
|
||||
onChange={load}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_LABEL_BATCH: Record<string, string> = {
|
||||
UPLOADED: "Recibido",
|
||||
PROCESSING: "Procesando",
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Registrado",
|
||||
FAILED: "Falló",
|
||||
};
|
||||
|
||||
function SummaryCard({
|
||||
batch,
|
||||
readyCount,
|
||||
}: {
|
||||
batch: StatementBatchDetail;
|
||||
readyCount: number;
|
||||
}) {
|
||||
const entries = Object.entries(batch.byStatus) as [StatementDocumentStatus, number][];
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 20 }}>
|
||||
{entries.map(([status, count]) => (
|
||||
<div key={status}>
|
||||
<div className="page-sub">{STATUS_LABEL[status] ?? status}</div>
|
||||
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{count}</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div className="page-sub">Importe pendiente</div>
|
||||
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>
|
||||
{formatMoney(batch.pendingTotal, "MXN")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="page-sub">Listos para registrar</div>
|
||||
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{readyCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posting is by check, exactly as on the manual capture screen — an OCR batch
|
||||
* is still "these bills, paid with this check", so the same fields are asked
|
||||
* for and the same ledger path is used.
|
||||
*/
|
||||
function ConfirmCard({
|
||||
batchId,
|
||||
readyCount,
|
||||
onDone,
|
||||
setError,
|
||||
}: {
|
||||
batchId: string;
|
||||
readyCount: number;
|
||||
onDone: () => void;
|
||||
setError: (m: string | null) => void;
|
||||
}) {
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
const [transactionDate, setTransactionDate] = useState(
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const [outstanding, setOutstanding] = useState(false);
|
||||
const [includeReviewed, setIncludeReviewed] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
if (!checkNumber.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const input: ConfirmBatchInput = {
|
||||
checkNumber: checkNumber.trim(),
|
||||
transactionDate,
|
||||
outstanding,
|
||||
includeReviewed,
|
||||
};
|
||||
const r = await confirmStatementBatch(batchId, input);
|
||||
setResult(
|
||||
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`,
|
||||
);
|
||||
setCheckNumber("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Registrar {readyCount} recibo(s)
|
||||
</h2>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Cheque</span>
|
||||
<input
|
||||
className="input"
|
||||
value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)}
|
||||
placeholder="Número de cheque"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="page-sub">Fecha</span>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={transactionDate}
|
||||
onChange={(e) => setTransactionDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={outstanding}
|
||||
onChange={(e) => setOutstanding(e.target.checked)}
|
||||
/>{" "}
|
||||
Sin fondos (queda pendiente)
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeReviewed}
|
||||
onChange={(e) => setIncludeReviewed(e.target.checked)}
|
||||
/>{" "}
|
||||
Incluir los confirmados a mano
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!checkNumber.trim() || busy}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? "Registrando…" : "Registrar"}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<div className="state-box" style={{ marginTop: 12 }}>
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||
Se registran como cargos del cliente, por la misma vía que la captura
|
||||
manual. Un lote registrado dos veces no duplica cobros.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentRow({
|
||||
doc,
|
||||
canReview,
|
||||
onChange,
|
||||
}: {
|
||||
doc: StatementDocument;
|
||||
canReview: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(
|
||||
doc.status === "NEEDS_REVIEW" || doc.status === "OCR_FAILED",
|
||||
);
|
||||
const [amount, setAmount] = useState(doc.extractedAmount ?? "");
|
||||
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
||||
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
||||
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const settled = doc.status === "POSTED" || doc.status === "REJECTED";
|
||||
|
||||
async function save(status: "MATCHED" | "CONFIRMED") {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await reviewStatementDocument(doc.id, {
|
||||
accountRef: accountRef.trim() || undefined,
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
matchedCustomerId: customerId || undefined,
|
||||
status,
|
||||
});
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? "No se pudo guardar.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await rejectStatementDocument(doc.id);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? "No se pudo descartar.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<div
|
||||
className="inline-form"
|
||||
style={{ justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}
|
||||
>
|
||||
<div>
|
||||
<strong>Página {doc.pageNumber}</strong>{" "}
|
||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
|
||||
{doc.provider && <span className="page-sub">{doc.provider}</span>}
|
||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||
{doc.matchedCustomer ? (
|
||||
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
|
||||
{doc.matchedCustomer.name}
|
||||
</Link>
|
||||
) : (
|
||||
"Sin cliente asignado"
|
||||
)}
|
||||
{doc.extractedAccountRef && ` · cuenta ${doc.extractedAccountRef}`}
|
||||
{doc.extractedCadastralKey && ` · clave ${doc.extractedCadastralKey}`}
|
||||
</div>
|
||||
{doc.matchNote && (
|
||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||
{doc.matchNote}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-form" style={{ gap: 8 }}>
|
||||
<strong>
|
||||
{doc.extractedAmount
|
||||
? formatMoney(doc.extractedAmount, "MXN")
|
||||
: "sin importe"}
|
||||
</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? "Ocultar" : "Ver recibo"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{ marginTop: 12, display: "grid", gap: 16 }}>
|
||||
{/* The scan itself — the reviewer's source of truth, not the OCR. */}
|
||||
<img
|
||||
src={statementPageUrl(doc.id)}
|
||||
alt={`Recibo página ${doc.pageNumber}`}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
border: "1px solid var(--border, #ddd)",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
/>
|
||||
|
||||
{canReview && !settled && (
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Cuenta</span>
|
||||
<input
|
||||
className="input"
|
||||
value={accountRef}
|
||||
onChange={(e) => setAccountRef(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="page-sub">Importe</span>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ minWidth: 260 }}>
|
||||
<span className="page-sub">Cliente</span>
|
||||
<CustomerPicker
|
||||
value={customerId}
|
||||
valueName={customerName}
|
||||
onPick={(cid, name) => {
|
||||
setCustomerId(cid);
|
||||
setCustomerName(name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !customerId}
|
||||
onClick={() => save("MATCHED")}
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={reject}
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div className="state-box state-error">{err}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import {
|
||||
getStatementStatus,
|
||||
listStatementBatches,
|
||||
uploadStatementBatch,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, SERVICE_KIND_LABELS, serviceKindLabel } from "@/lib/labels";
|
||||
import type { ServiceKind, StatementBatch, StatementBatchStatus } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Statement OCR intake (docs/RECEIPT_CAPTURE_SPEC.md §2).
|
||||
*
|
||||
* Each utility company mails 300+ paper bills a month, one per customer, which
|
||||
* staff otherwise key in by hand through the "Captura" screen. Here they scan
|
||||
* the stack, and the machine proposes customer + amount for every page; a
|
||||
* human still confirms before anything reaches the ledger.
|
||||
*
|
||||
* One batch = one service kind, because the matcher is scoped per kind: a
|
||||
* water account number and a phone number are compared against different
|
||||
* columns, and mixing them in one upload is how a bill gets posted to the
|
||||
* wrong customer.
|
||||
*/
|
||||
|
||||
/** The kinds the parsers actually recognise today. */
|
||||
const SUPPORTED: ServiceKind[] = ["ELECTRIC", "WATER", "TELEPHONE"];
|
||||
/** Uploadable, but every page will land in review until a parser learns it. */
|
||||
const OTHER_KINDS: ServiceKind[] = ["GAS", "PROPERTY_TAX", "FEDERAL_ZONE", "CABLE"];
|
||||
|
||||
const STATUS_LABEL: Record<StatementBatchStatus, string> = {
|
||||
UPLOADED: "Recibido",
|
||||
PROCESSING: "Procesando…",
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Registrado",
|
||||
FAILED: "Falló",
|
||||
};
|
||||
|
||||
export default function RecibosPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Recibos />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Recibos() {
|
||||
const canIngest = useCan("statement:ingest");
|
||||
const [batches, setBatches] = useState<StatementBatch[]>([]);
|
||||
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [list, status] = await Promise.all([
|
||||
listStatementBatches(),
|
||||
getStatementStatus(),
|
||||
]);
|
||||
setBatches(list.items);
|
||||
setOcrAvailable(status.ocrAvailable);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudieron cargar los lotes.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// A batch of 300 pages takes minutes to OCR, so the list refreshes itself
|
||||
// while anything is still working rather than making staff reload.
|
||||
const working = batches.some(
|
||||
(b) => b.status === "PROCESSING" || b.status === "UPLOADED",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!working) return;
|
||||
const t = setInterval(() => void load(), 4000);
|
||||
return () => clearInterval(t);
|
||||
}, [working, load]);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Recibos (OCR)</h1>
|
||||
<p className="page-sub">
|
||||
Escanea los recibos del mes y el sistema propone cliente e importe
|
||||
para cada página. Nada se registra sin tu confirmación.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{ocrAvailable === false && (
|
||||
<div className="state-box state-error">
|
||||
Este servidor no tiene OCR instalado, así que no se pueden procesar
|
||||
recibos. El resto del sistema funciona con normalidad.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canIngest && ocrAvailable && <UploadCard onDone={load} />}
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Lotes
|
||||
</h2>
|
||||
{loading ? (
|
||||
<div className="state-box">Cargando…</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="state-box">Todavía no hay lotes de recibos.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Servicio</th>
|
||||
<th>Referencia</th>
|
||||
<th>Estado</th>
|
||||
<th className="num">Páginas</th>
|
||||
<th>Subido por</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td style={{ whiteSpace: "nowrap" }}>{formatDate(b.createdAt)}</td>
|
||||
<td>{serviceKindLabel(b.serviceKind)}</td>
|
||||
<td>{b.label || "—"}</td>
|
||||
<td>
|
||||
<StatusTag status={b.status} />
|
||||
{b.error && (
|
||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||
{b.error}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">{b._count?.documents ?? 0}</td>
|
||||
<td>{b.uploadedBy?.name ?? "—"}</td>
|
||||
<td>
|
||||
<Link className="btn btn-ghost" href={`/recibos/${b.id}`}>
|
||||
Revisar
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: StatementBatchStatus }) {
|
||||
return <span className="tag">{STATUS_LABEL[status] ?? status}</span>;
|
||||
}
|
||||
|
||||
function UploadCard({ onDone }: { onDone: () => void }) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [serviceKind, setServiceKind] = useState<ServiceKind>("ELECTRIC");
|
||||
const [label, setLabel] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
if (!files.length) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await uploadStatementBatch(files, serviceKind, label.trim() || undefined);
|
||||
setFiles([]);
|
||||
setLabel("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo subir el lote.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const unsupported = !SUPPORTED.includes(serviceKind);
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Subir recibos escaneados
|
||||
</h2>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Servicio</span>
|
||||
<select
|
||||
className="input"
|
||||
value={serviceKind}
|
||||
onChange={(e) => setServiceKind(e.target.value as ServiceKind)}
|
||||
>
|
||||
<optgroup label="Con lectura automática">
|
||||
{SUPPORTED.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{SERVICE_KIND_LABELS[k] ?? k}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Sin lectura automática (revisión manual)">
|
||||
{OTHER_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{SERVICE_KIND_LABELS[k] ?? k}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="page-sub">Referencia (opcional)</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="ej. CFE julio 2026"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="page-sub">Archivos PDF</span>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!files.length || busy}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? "Subiendo…" : `Procesar ${files.length || ""}`.trim()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{unsupported && (
|
||||
<div className="state-box" style={{ marginTop: 12 }}>
|
||||
Todavía no hay lectura automática para{" "}
|
||||
{SERVICE_KIND_LABELS[serviceKind] ?? serviceKind}: cada página quedará
|
||||
para revisión manual. Al confirmarlas se guarda el número de cuenta,
|
||||
así que los recibos del mes siguiente sí se reconocerán solos.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||
Un lote es de un solo servicio. Cada página del PDF se trata como un
|
||||
recibo distinto, salvo que el proveedor imprima varias hojas por cliente.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,9 @@ const NAV: NavEntry[] = [
|
||||
// Daily data-entry screen (the legacy "Editor"). Hidden from VIEWER, who
|
||||
// can't capture anyway — the page itself also refuses.
|
||||
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
|
||||
// Same daily job as "Captura", entered from a stack of scanned bills
|
||||
// instead of a keyboard.
|
||||
{ href: "/recibos", label: "Recibos (OCR)", ability: "statement:ingest" },
|
||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
],
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
BankStats,
|
||||
BankSummary,
|
||||
BatchCreateInput,
|
||||
ConfirmBatchInput,
|
||||
ConfirmBatchResult,
|
||||
BatchCreateResponse,
|
||||
BillingFacets,
|
||||
BillingStats,
|
||||
@@ -27,6 +29,11 @@ import type {
|
||||
CreateMovementInput,
|
||||
UpdateBankAccountInput,
|
||||
ResolveOutstandingInput,
|
||||
ReviewDocumentInput,
|
||||
StatementBatch,
|
||||
StatementBatchDetail,
|
||||
StatementDocument,
|
||||
StatementDocumentStatus,
|
||||
CustomerDetail,
|
||||
CustomerInput,
|
||||
CustomerListResponse,
|
||||
@@ -895,3 +902,93 @@ export function reportDownloadUrl(
|
||||
const tail = qs.toString();
|
||||
return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`;
|
||||
}
|
||||
|
||||
/* ------------------------------------- Statement OCR intake (recibos) */
|
||||
|
||||
/** Whether this deployment has the OCR binaries — upload is hidden without. */
|
||||
export function getStatementStatus(): Promise<{ ocrAvailable: boolean }> {
|
||||
return apiFetch("/statements/status");
|
||||
}
|
||||
|
||||
export function listStatementBatches(
|
||||
page = 1,
|
||||
pageSize = 25,
|
||||
): Promise<{
|
||||
items: StatementBatch[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
}> {
|
||||
return apiFetch(`/statements/batches?page=${page}&pageSize=${pageSize}`);
|
||||
}
|
||||
|
||||
export function getStatementBatch(id: string): Promise<StatementBatchDetail> {
|
||||
return apiFetch(`/statements/batches/${id}`);
|
||||
}
|
||||
|
||||
export function listStatementDocuments(
|
||||
batchId: string,
|
||||
status?: StatementDocumentStatus,
|
||||
): Promise<StatementDocument[]> {
|
||||
const q = status ? `?status=${status}` : "";
|
||||
return apiFetch(`/statements/batches/${batchId}/documents${q}`);
|
||||
}
|
||||
|
||||
/** Multi-file upload — one batch is usually several multi-page scans. */
|
||||
export async function uploadStatementBatch(
|
||||
files: File[],
|
||||
serviceKind: ServiceKind,
|
||||
label?: string,
|
||||
): Promise<StatementBatch> {
|
||||
const body = new FormData();
|
||||
for (const f of files) body.append("files", f, f.name);
|
||||
const qs = new URLSearchParams({ serviceKind });
|
||||
if (label) qs.set("label", label);
|
||||
|
||||
const res = await fetch(`${API_ORIGIN}/statements/batches?${qs}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = `Error ${res.status}`;
|
||||
try {
|
||||
const b = await res.json();
|
||||
if (b?.message) message = b.message;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function reviewStatementDocument(
|
||||
id: string,
|
||||
input: ReviewDocumentInput,
|
||||
): Promise<StatementDocument> {
|
||||
return apiFetch(`/statements/documents/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectStatementDocument(id: string): Promise<StatementDocument> {
|
||||
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function confirmStatementBatch(
|
||||
batchId: string,
|
||||
input: ConfirmBatchInput,
|
||||
): Promise<ConfirmBatchResult> {
|
||||
return apiFetch(`/statements/batches/${batchId}/confirm`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** The rendered page image. A plain <img src> — the cookie rides along. */
|
||||
export function statementPageUrl(documentId: string): string {
|
||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export const SERVICE_KIND_LABELS: Record<string, string> = {
|
||||
PROPERTY_TAX: "Predial",
|
||||
FEDERAL_ZONE: "Zona Federal",
|
||||
ALARM: "Alarma",
|
||||
TELEPHONE: "Teléfono",
|
||||
OTHER: "Otro",
|
||||
};
|
||||
|
||||
@@ -57,6 +58,7 @@ export const SERVICE_KIND_GLYPH: Record<string, string> = {
|
||||
WATER: "≈",
|
||||
ELECTRIC: "⚡",
|
||||
GAS: "◐",
|
||||
TELEPHONE: "☎",
|
||||
CABLE: "▤",
|
||||
PROPERTY_TAX: "⌂",
|
||||
FEDERAL_ZONE: "⇲",
|
||||
|
||||
@@ -20,6 +20,8 @@ export type Ability =
|
||||
| "bank:create"
|
||||
| "bank:void"
|
||||
| "bank:manage-accounts"
|
||||
| "statement:ingest"
|
||||
| "statement:review"
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
@@ -134,6 +136,7 @@ export type ServiceKind =
|
||||
| "PROPERTY_TAX"
|
||||
| "FEDERAL_ZONE"
|
||||
| "ALARM"
|
||||
| "TELEPHONE"
|
||||
| "OTHER"
|
||||
| string;
|
||||
|
||||
@@ -1204,3 +1207,89 @@ export interface ReportRunResult {
|
||||
export interface ReportCatalog {
|
||||
items: ReportDef[];
|
||||
}
|
||||
|
||||
/* ------------------------------------- Statement OCR intake (recibos) */
|
||||
|
||||
export type StatementBatchStatus =
|
||||
| "UPLOADED"
|
||||
| "PROCESSING"
|
||||
| "READY_FOR_REVIEW"
|
||||
| "COMPLETED"
|
||||
| "FAILED";
|
||||
|
||||
export type StatementDocumentStatus =
|
||||
| "PENDING_OCR"
|
||||
| "OCR_FAILED"
|
||||
| "NEEDS_REVIEW"
|
||||
| "MATCHED"
|
||||
| "CONFIRMED"
|
||||
| "POSTED"
|
||||
| "REJECTED";
|
||||
|
||||
export interface StatementBatch {
|
||||
id: string;
|
||||
serviceKind: ServiceKind;
|
||||
status: StatementBatchStatus;
|
||||
label: string | null;
|
||||
fileCount: number;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
uploadedBy?: { name: string };
|
||||
_count?: { documents: number };
|
||||
}
|
||||
|
||||
export interface StatementBatchDetail extends StatementBatch {
|
||||
byStatus: Partial<Record<StatementDocumentStatus, number>>;
|
||||
/** Sum of the amounts still awaiting posting. */
|
||||
pendingTotal: string;
|
||||
}
|
||||
|
||||
export interface StatementDocument {
|
||||
id: string;
|
||||
pageNumber: number;
|
||||
status: StatementDocumentStatus;
|
||||
provider: string | null;
|
||||
ocrConfidence: string | null;
|
||||
extractedAccountRef: string | null;
|
||||
extractedAmount: string | null;
|
||||
extractedPeriod: string | null;
|
||||
extractedDueDate: string | null;
|
||||
extractedCadastralKey: string | null;
|
||||
matchNote: string | null;
|
||||
matchedCustomer: { id: string; name: string } | null;
|
||||
matchedPropertyService: {
|
||||
id: string;
|
||||
kind: ServiceKind;
|
||||
accountNumber: string | null;
|
||||
meterNumber: string | null;
|
||||
property: { id: string; addressLine1: string | null };
|
||||
} | null;
|
||||
postedTransactionId: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewDocumentInput {
|
||||
accountRef?: string;
|
||||
amount?: number;
|
||||
period?: string;
|
||||
dueDate?: string;
|
||||
matchedPropertyServiceId?: string;
|
||||
matchedCustomerId?: string;
|
||||
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
|
||||
}
|
||||
|
||||
/** Check-level fields shared by every line posted from a batch. */
|
||||
export interface ConfirmBatchInput {
|
||||
checkNumber: string;
|
||||
transactionDate: string;
|
||||
currency?: Currency;
|
||||
typeId?: string;
|
||||
outstanding?: boolean;
|
||||
includeReviewed?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmBatchResult {
|
||||
posted: number;
|
||||
total: string;
|
||||
checkNumber: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user