feat(recibos): OCR capture for gas butano and municipal predial
Adds four parsers to the statement intake — GAS TIJUANA plus one per municipality, because Tijuana, Rosarito and Ensenada issue three completely different predial documents — and a text-layer fast path for the born-digital invoices the gas company sends. Measured against a new corpus of 14 documents / 29 pages: provider read on 29/29, amount on 26/29, and 21/29 auto-matched against the dev database (22/29 identified). The eight review cases are all legitimate. Five things the corpus forced: - Not every statement is a scan. The gas invoices are born-digital CFDIs whose text layer is exact; rasterising them only loses information (one sample turned `MEDIDOR: VM01014426` into `ar (LTR): 014420`). The new `OcrProvider.textPages` reads the embedded layer via `pdftotext -bbox-layout` — same poppler package as `pdftoppm`, so no new dependency — and OCR stays the fallback for real scans. Poppler's own `<line>` grouping follows text flow rather than the page, so words are regrouped by vertical position; without that, a two-column header leaves every label separated from the value printed beside it. - The clave catastral is not two letters and six digits. Position three is a letter in 15 of the 932 stored claves, and digitising the whole tail mapped a real `MMB01041` to a nonexistent `MM801041`. - Tijuana predial prints no clave at all. Its only identifier is an 8-digit municipal account carried in a 32-digit payment barcode, which the legacy database never held, so it goes in `meterNumber` alongside gas — `accountNumber` holds `DATMEX.predial`, which is not a per-property key and must not be overwritten. Those pages start cold and are taught by the first confirm. - On Rosarito and Ensenada the clave is the primary key, not a fallback: those receipts print nothing else, so a unique hit auto-matches. On a utility bill that merely happens to print one it stays a review hint. - A misread `$` is the dangerous failure. An Ensenada receipt for $2,203.00 OCR'd as `82,203.00`, which would post a charge 37x too large and look ordinary in the ledger. Predial amounts now require a literal `$` and a page that cannot produce one goes to review. The scoped match field is now one exported function rather than three copies of `kind === "GAS" ? ... : ...`, since the lookup, the blank-service fill and the confirm write-back have to agree or a reference gets learned into a column nothing searches. First tests in this package: 23 specs over the parsers and the text-layer reader, every fixture a verbatim OCR excerpt from a real receipt. Adds the jest config they need and a build tsconfig so they stay out of dist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -134,7 +134,8 @@ single-movement form.
|
||||
> **BUILT — 2026-08-01.** Implemented and verified end to end against real
|
||||
> scanned statements. `apps/api/src/statements/` holds the module: a swappable
|
||||
> `OcrProvider` seam with a self-hosted Tesseract implementation, per-provider
|
||||
> parsers for CFE / CESPT / Telnor, a scoped matcher, and a review queue that
|
||||
> parsers for CFE / CESPT / Telnor / gas / predial, a scoped matcher, and a
|
||||
> review queue that
|
||||
> posts through `BillingService.createBatch` with `source: "OCR"`. Web:
|
||||
> the "Captura automática (OCR)" tab of the Captura screen (upload + batch
|
||||
> list) and `/recibos/:id` (review queue with the page image beside the
|
||||
@@ -207,6 +208,54 @@ single-movement form.
|
||||
> each bill (`9`, `405`, `406`); Tesseract read `405` as `205`. Handwriting is
|
||||
> a review hint at best and is deliberately not an input to matching.
|
||||
|
||||
> **EXTENDED — gas and predial, 2026-08-01.** A second corpus (14 documents,
|
||||
> 29 pages: five municipal predial batches and ten gas invoices) added four
|
||||
> parsers — `GAS TIJUANA` plus one per municipality, because Tijuana, Rosarito
|
||||
> and Ensenada issue three completely different documents. End to end against
|
||||
> the dev database that is **21/29 auto-matched, 22/29 identified**, with the
|
||||
> provider read on 29/29 and an amount on 26/29.
|
||||
>
|
||||
> The eight review cases are all legitimate: five Tijuana pages whose municipal
|
||||
> account is not yet on file (see below), one clave not in the book, one page
|
||||
> too poorly scanned to read a clave at all, and one gas account shared by two
|
||||
> services. Excluding the structural Tijuana case, that is 21/24.
|
||||
>
|
||||
> **Five things this corpus proved:**
|
||||
>
|
||||
> 1. **Not every statement is a scan.** The gas company sends born-digital CFDI
|
||||
> invoices whose text layer is exact. Rasterising and re-recognising those
|
||||
> can only lose information — one sample turned `MEDIDOR: VM01014426` into
|
||||
> `ar (LTR): 014420` — so `OcrProvider.textPages` reads the embedded layer
|
||||
> first (`pdftotext -bbox-layout`, same poppler package as `pdftoppm`) and
|
||||
> OCR stays the fallback for real scans. Page images are still rendered and
|
||||
> stored either way, because the reviewer needs to see the paper.
|
||||
> 2. **The clave catastral is not two letters and six digits.** Positions four
|
||||
> through eight are digits in all 932 stored claves, but the third is a
|
||||
> letter in fifteen of them (`MMB01041`, `CGH52121`). Digitising the whole
|
||||
> tail maps that `B` to an `8` and produces a key matching no property.
|
||||
> 3. **Tijuana predial prints no clave catastral at all.** Its only identifier
|
||||
> is an 8-digit municipal account, carried in a 32-digit payment barcode
|
||||
> (`account(8) + DDMMYY + amount(9) + folio(9)`) that the legacy database
|
||||
> never held. It goes in `PROPERTY_TAX.meterNumber` — the same column gas
|
||||
> uses, and for the same reason: `accountNumber` holds `DATMEX.predial`,
|
||||
> which is not a per-property key and overwriting it would destroy the only
|
||||
> link back to the original records. So Tijuana pages start cold and are
|
||||
> taught by the first confirm, exactly like gas.
|
||||
> 4. **On Rosarito and Ensenada the clave is the primary key, not a fallback.**
|
||||
> Those receipts print nothing else, so a unique clave hit there is a real
|
||||
> match and auto-matches; on a utility bill that merely happens to print one
|
||||
> it stays a review hint, as before.
|
||||
> 5. **A misread `$` is the dangerous failure, not a missing one.** An Ensenada
|
||||
> receipt for `$2,203.00` OCR'd as `82,203.00` — the dollar sign read as an
|
||||
> 8, which would post a charge 37× too large and look entirely ordinary in
|
||||
> the ledger. Every predial amount therefore requires a literal `$`, and a
|
||||
> page that cannot produce one reports no amount and goes to review. Two of
|
||||
> the 29 pages take that path, which is the correct outcome for both.
|
||||
>
|
||||
> Regression cover for all of the above lives in
|
||||
> `statement-parser.spec.ts` and `tesseract.provider.spec.ts`; every fixture in
|
||||
> them is a verbatim OCR excerpt from a real receipt.
|
||||
|
||||
### Motivation (from the meeting)
|
||||
|
||||
Each utility company (CFE, water, phone, gas...) sends 300+ individual
|
||||
@@ -330,8 +379,8 @@ Per the meeting notes' own field list:
|
||||
| Agua — Número de cuenta | `WATER` | `accountNumber` | `AGUA` | ✅ populated today |
|
||||
| Zona Fed — Número de Zona Federal | `FEDERAL_ZONE` | `accountNumber` | `ZFED` | ✅ populated today |
|
||||
| Tel — Número de teléfono | `TELEPHONE` *(new)* | `accountNumber` | `Property.phone1/2/3` (currently on `Property`, not `PropertyService`) | ⚠️ schema gap — see below |
|
||||
| Impuesto — Clave Catastral | `PROPERTY_TAX` | `accountNumber` | migrated from `PREDIAL`, **not** `CLAVE` | ⚠️ needs verification — see below |
|
||||
| Gas — Número de medidor | `GAS` | `meterNumber` | not populated — folded into free-text `notes` today | ⚠️ data gap — see below |
|
||||
| Impuesto — Clave Catastral | `PROPERTY_TAX` | `Property.cadastralKey`, plus `meterNumber` for Tijuana's municipal account | `CLAVE`; `PREDIAL` is left on `accountNumber` and never matched against | ✅ built — see the 2026-08-01 extension note |
|
||||
| Gas — Número de medidor | `GAS` | `meterNumber` | not populated — folded into free-text `notes` today | ✅ 160/334 recovered from `notes` |
|
||||
|
||||
Confidence rule of thumb once a field is confirmed populated, tune after
|
||||
seeing real statements:
|
||||
|
||||
Reference in New Issue
Block a user