feat(recibos): OCR capture for gas butano and municipal predial
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m8s

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:
2026-08-01 12:52:20 -07:00
co-authored by Claude Opus 5
parent 216309190c
commit d6501f1d74
12 changed files with 940 additions and 58 deletions
+52 -3
View File
@@ -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: