feat(recibos): OCR capture for zona federal (ZOFEMAT Tijuana)
Adds the ZONA FEDERAL TIJUANA parser to the statement intake, measured against 8 pages of real "Zona Federal Marítimo Terrestre" receipts — the federal maritime-zone occupancy fee the municipality bills on beachfront lots. Provider read on 8/8, amount on 8/8 (each verified against the paper), concession clave on 6/8, period on 8/8, deadline on 2/8. Four things the corpus forced: - Tijuana bills predial and zona federal from the same treasury: same header, same Paseo del Centenario address, same ATB-541201 RFC. Every predial discriminator matches a zona federal page too, so whichever rule is asked first wins it. The only words exclusive to this layout are "Marítimo Terrestre", so its brand rule is asked ahead of all three predial ones — and its structural rule, anchored on the stub's "Derechos de ocupación", ahead of theirs. - FEDERAL_ZONE.accountNumber is an amount, not a reference. It holds DATMEX.zfed, whose 77 values include 246.06, 2369.09, 22653.94 and a negative -1679, while the concession claves these receipts are keyed by appear nowhere in the database. Matching on that column could never hit — and because every row already has a value, the `[field]: null` guards on learnAccountRefs and on the review blank-service fill would never fire either, so every page would return to the queue every bimester forever. The clave moves to meterNumber, joining gas and Tijuana predial, and the first confirm teaches the match. - The payable figure is not the printed subtotal. The municipality rounds to whole pesos and prints the difference on its own "Ajuste Ley Hacienda Mpal" line (-$0.05 against a 591.05 subtotal, $0.21 against 2,872.79). The "Total a pagar" box carrying the rounded figure sits on a grey fill and OCR'd on 1 of 8 pages; the SubTotal row read on 8 of 8. So the amount is the rounded subtotal, cross-checked against the printed box wherever it survives — where it did, it agreed. - The clave is 2 digits, a letter and 3 digits (12-T -012), not the cadastral shape, and the letter is kept as printed: toDigits maps D to 0, which turns a real 14-D -014 into 140014. It is printed twice, which rescued a page whose heading was struck through by the office's own highlighter — the failure mode behind both missing claves. Deriving the deadline from the bimester is deliberately not attempted: it is the 17th of the month after the bimester closes on a current bill, but four of these eight are late (a $1,000 Multa) and print a recalculated date, so a derived date would be wrong on exactly the pages a human most wants to see. Re-ran the earlier corpora (25 pages: predial Tijuana/Rosarito/Ensenada, CFE, CESPT, Telnor) through detection to confirm the new rules steal nothing — all 25 still read as their original provider, including the five Tijuana predial pages that share the RFC. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,8 @@ import type { OcrPage, OcrWord } from "../ocr/ocr.provider";
|
||||
export interface ParsedStatement {
|
||||
/**
|
||||
* "CFE" | "CESPT" | "TELNOR" | "GAS TIJUANA" | "PREDIAL TIJUANA" |
|
||||
* "PREDIAL ROSARITO" | "PREDIAL ENSENADA", or null when no parser claimed
|
||||
* the page.
|
||||
* "PREDIAL ROSARITO" | "PREDIAL ENSENADA" | "ZONA FEDERAL TIJUANA", or null
|
||||
* when no parser claimed the page.
|
||||
*/
|
||||
provider: string | null;
|
||||
serviceKind: ServiceKind | null;
|
||||
@@ -190,6 +190,15 @@ const BRAND: [string, RegExp][] = [
|
||||
["CESPT", /CESPT|COMISI[OÓ]N ESTATAL DE SERVICIOS/i],
|
||||
["TELNOR", /TELNOR|TELEFONOS DEL NOROESTE/i],
|
||||
["GAS TIJUANA", /COMPA[ÑN][IÍ]?A\s*DE\s*GAS\s*DE\s*TIJUANA|bajagas/i],
|
||||
// Ahead of the predial rules on purpose. Tijuana's zona federal receipt is
|
||||
// issued by the same treasury and carries the same header — "Ayuntamiento de
|
||||
// Tijuana", the same address, the same `ATB-541201` RFC — so every predial
|
||||
// discriminator matches it too, and whichever rule is asked first wins the
|
||||
// page. What only the zona federal layout says is "Marítimo Terrestre", which
|
||||
// survived OCR on all eight sample pages even where the heading above it came
|
||||
// back as "Zona Ledera) Maritimo Terrestre" and the printed concession clave
|
||||
// was lost under a highlighter mark.
|
||||
["ZONA FEDERAL TIJUANA", /ZOFEMAT|Mar[ií]timo\s*Terrestre|ocupaci[oó]n\s*de\s*Zona\s*Federal/i],
|
||||
// The municipal RFCs are the single most reliable discriminator on a predial
|
||||
// receipt: they are printed in a clean monospaced run on every layout, they
|
||||
// never change, and they say which of the three city treasuries issued the
|
||||
@@ -209,6 +218,11 @@ const BRAND: [string, RegExp][] = [
|
||||
* utility ones, so it is the safe first question to ask.
|
||||
*/
|
||||
const LAYOUT: [string, RegExp][] = [
|
||||
// Same reasoning as the brand pass, one rule earlier: the concept line
|
||||
// "Derechos de ocupación de Zona Federal Marítimo Terrestre" is printed on
|
||||
// the stub of every zona federal page and on no other layout, and it read
|
||||
// cleanly on 8 of 8 samples — including the two whose heading did not.
|
||||
["ZONA FEDERAL TIJUANA", /Derechos\s*de\s*ocupaci[oó]n/i],
|
||||
["PREDIAL TIJUANA", /IMPUESTO\s*PREDIAL[\s\S]*?(?:CERTIFICACION\s*DE\s*CAJA|PASEO\s*DEL\s*CENTENARIO|PAGA\s*TU\s*PREDIAL)/i],
|
||||
["PREDIAL ENSENADA", /(?:IMPUESTO\s*PREDIAL[\s\S]*?TRANSPENINSULAR)|(?:IMPRESION\s*MAQUINA\s*REGISTRADORA)/i],
|
||||
["PREDIAL ROSARITO", /IMPUESTO\s*PREDIAL/i],
|
||||
@@ -701,6 +715,132 @@ function parsePredialEnsenada(page: OcrPage): ParsedStatement {
|
||||
};
|
||||
}
|
||||
|
||||
// --- ZONA FEDERAL (ZOFEMAT, Tijuana) ----------------------------------------
|
||||
|
||||
/**
|
||||
* Normalise the concession clave the zona federal receipt is keyed by.
|
||||
*
|
||||
* It is printed grouped and loosely spaced — `12-T -012`, `14-A-119`,
|
||||
* `14-K -031` — and is a different shape from the cadastral key entirely: two
|
||||
* digits, one letter, three digits. The letter is kept as printed rather than
|
||||
* digitised, for the same reason `normalizeCadastralKey` keeps its third
|
||||
* character: `toDigits` maps `B` to `8` and `D` to `0`, and a real `14-D -014`
|
||||
* run through it becomes `140014`, which is not a clave at all.
|
||||
*
|
||||
* Stored without separators, because nothing on file holds this value yet (see
|
||||
* `parseZonaFederal`) so the canonical form is ours to pick, and a bare run
|
||||
* cannot be broken by the hyphen the scan renders as a dash, a minus or
|
||||
* nothing.
|
||||
*/
|
||||
export function normalizeZofematKey(raw: string): string | null {
|
||||
const m = raw.match(/^([0-9OIlSBD]{2})\s*-\s*([A-Za-z])\s*-?\s*([0-9OIlSBD]{3})$/);
|
||||
if (!m) return null;
|
||||
const zone = toDigits(m[1]);
|
||||
const lot = toDigits(m[3]);
|
||||
if (zone.length !== 2 || lot.length !== 3) return null;
|
||||
return `${zone}${m[2].toUpperCase()}${lot}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bimester the receipt prints as `2026-2 / 2026-2`, rendered in the
|
||||
* vocabulary the ledger already speaks.
|
||||
*
|
||||
* All 258 legacy FEDERAL ZONE transactions carry a period of `JAN/FEB`,
|
||||
* `MAR/APR`, `MAY/JUN` or `NOV/DEC`, and their payment dates confirm the
|
||||
* ordering — JAN/FEB was paid in March, MAR/APR in May, MAY/JUN in July,
|
||||
* NOV/DEC in January, i.e. always the month after the bimester closes. The
|
||||
* receipts agree: the two `2026-3` samples fall due 17/07/2026 with no
|
||||
* surcharge, which is bimester three, May and June. Writing `2026-3` instead
|
||||
* would leave the OCR-posted rows unsearchable alongside every hand-keyed one.
|
||||
*/
|
||||
const BIMESTERS = ["JAN/FEB", "MAR/APR", "MAY/JUN", "JUL/AUG", "SEP/OCT", "NOV/DEC"];
|
||||
|
||||
/**
|
||||
* Tijuana's "Zona Federal Marítimo Terrestre" — the federal maritime-zone
|
||||
* occupancy fee, billed by the municipality for beachfront lots.
|
||||
*
|
||||
* Nothing on file identifies these. `PropertyService.accountNumber` for
|
||||
* FEDERAL_ZONE holds DATMEX.zfed, which is not a reference at all but an
|
||||
* amount: its 77 values include `246.06`, `2369.09`, `22653.94` and a negative
|
||||
* `-1679`, and the concession claves these receipts are keyed by appear nowhere
|
||||
* in the database. So the clave goes to `meterNumber` (see `scopedRefField`),
|
||||
* every page starts cold, and the first confirm teaches the match — the same
|
||||
* arrangement Tijuana predial needed, for the same reason.
|
||||
*
|
||||
* The amount is taken from the SubTotal row rather than the "Total a pagar"
|
||||
* box, which is printed on a grey fill and OCR'd on only 1 of 8 sample pages
|
||||
* while the SubTotal row read on 8 of 8. The two differ by design: the
|
||||
* municipality rounds to whole pesos and prints the difference on its own
|
||||
* "Ajuste Ley Hacienda Mpal" line — `-$0.05` against a 591.05 subtotal, `$0.21`
|
||||
* against 2,872.79 — so the payable figure is the rounded subtotal, and where
|
||||
* the printed box did read, it agreed.
|
||||
*/
|
||||
function parseZonaFederal(page: OcrPage): ParsedStatement {
|
||||
const text = page.text;
|
||||
const notes: string[] = [];
|
||||
|
||||
// Printed twice, once on the receipt and once on the stub below it, which is
|
||||
// a free second reading: on one sample the heading was struck through by the
|
||||
// office's own highlighter and only the stub survived.
|
||||
const claves = [
|
||||
...new Set(
|
||||
allMatches(text, /Clave\s*[:;.]?\s*([0-9OIlSBD]{2}\s*-\s*[A-Za-z]\s*-?\s*[0-9OIlSBD]{3})/i)
|
||||
.map(normalizeZofematKey)
|
||||
.filter((k): k is string => k != null),
|
||||
),
|
||||
];
|
||||
|
||||
const accountRef = claves[0] ?? null;
|
||||
let crossChecked: boolean | null = null;
|
||||
|
||||
const subtotalRow = text.split("\n").find((l) => /SubTotal/i.test(l)) ?? "";
|
||||
const figures = allMatches(subtotalRow, /(\d[\d,]*\.\d{2})/);
|
||||
// Impuesto, Actualización, Recargo, Multa, Importe — the payable one is last.
|
||||
const importe = figures.length ? money(figures[figures.length - 1]) : null;
|
||||
const rounded = importe != null ? Math.round(importe) : null;
|
||||
const printed = money(
|
||||
firstMatch(text, [/Total\s*a\s*pagar\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i]),
|
||||
);
|
||||
|
||||
if (printed != null && rounded != null) {
|
||||
crossChecked = Math.abs(printed - rounded) < 0.5;
|
||||
if (!crossChecked) {
|
||||
notes.push(
|
||||
`el total impreso (${printed}) no coincide con el subtotal redondeado (${rounded})`,
|
||||
);
|
||||
}
|
||||
} else if (rounded != null) {
|
||||
notes.push("importe tomado del subtotal, redondeado al peso");
|
||||
} else if (printed == null) {
|
||||
notes.push("no se pudo leer el importe con certeza; capturarlo a mano");
|
||||
}
|
||||
|
||||
// A clave read two different ways means one of the two readings is wrong and
|
||||
// there is no third to break the tie, so the page goes to a human even if the
|
||||
// money cross-checked.
|
||||
if (claves.length > 1) {
|
||||
crossChecked = false;
|
||||
notes.push(`la clave se leyó de ${claves.length} formas distintas (${claves.join(", ")})`);
|
||||
}
|
||||
if (!accountRef) notes.push("no se pudo leer la clave de la concesión");
|
||||
|
||||
const bimester = text.match(/\b(20\d{2})\s*-\s*([1-6])\s*\/\s*20\d{2}\s*-\s*[1-6]/);
|
||||
|
||||
return {
|
||||
provider: "ZONA FEDERAL TIJUANA",
|
||||
serviceKind: "FEDERAL_ZONE",
|
||||
accountRef,
|
||||
cadastralKey: null,
|
||||
amount: printed ?? rounded,
|
||||
dueDate: parseDate(
|
||||
firstMatch(text, [/Vencimiento\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]),
|
||||
),
|
||||
period: bimester ? BIMESTERS[+bimester[2] - 1] : null,
|
||||
crossChecked,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
const PARSERS: Record<string, (page: OcrPage) => ParsedStatement> = {
|
||||
CFE: parseCfe,
|
||||
CESPT: parseCespt,
|
||||
@@ -709,6 +849,7 @@ const PARSERS: Record<string, (page: OcrPage) => ParsedStatement> = {
|
||||
"PREDIAL TIJUANA": parsePredialTijuana,
|
||||
"PREDIAL ROSARITO": parsePredialRosarito,
|
||||
"PREDIAL ENSENADA": parsePredialEnsenada,
|
||||
"ZONA FEDERAL TIJUANA": parseZonaFederal,
|
||||
};
|
||||
|
||||
const EMPTY: ParsedStatement = {
|
||||
|
||||
Reference in New Issue
Block a user