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. */ /** * Which `PropertyService` column a given kind's statements actually print. * * Exported because the same answer governs three places that must agree: the * lookup here, the blank-service fill on review, and the write-back on confirm. * When they disagree, a reference gets learned into a column nothing searches, * and the same page returns to the review queue every month forever. * * `meterNumber` is doing double duty for the three kinds whose printed * reference DATMEX never held in `accountNumber`: * - GAS, where the number lived in free-text notes, * - PROPERTY_TAX, where `accountNumber` holds DATMEX.predial — a 3-4 digit * office file number that is neither unique nor printed on any statement. * The Tijuana municipal receipt prints an 8-digit account and no clave * catastral at all, so it needs a column of its own; overwriting the legacy * predial numbers to make room would destroy the only link back to the * original records, and * - FEDERAL_ZONE, where `accountNumber` holds DATMEX.zfed, which is not a * reference of any kind but a peso amount: 3 of its 77 values carry cents * (`246.06`, `2369.09`, `22653.94`) and one is negative. Searching it for * the concession clave the receipt prints would never hit, and — worse — * because every row already has a value, the `[field]: null` guards in * `learnAccountRefs` and the blank-service fill would never fire either, so * the same page would return to the review queue every bimester forever. */ export function scopedRefField( 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 "CABLE": return "accountNumber"; case "GAS": // bajagas "Cuenta" -> recovered from notes into meterNumber case "PROPERTY_TAX": // Tijuana's 8-digit municipal account case "FEDERAL_ZONE": // ZOFEMAT concession clave, e.g. `12T012` return "meterNumber"; default: return null; } } @Injectable() export class StatementMatcherService { constructor(private readonly prisma: PrismaService) {} async match(parsed: ParsedStatement, expectedKind: ServiceKind): Promise { 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 = scopedRefField(kind); if (field && parsed.accountRef) { const hit = await this.byServiceField(kind, field, parsed.accountRef); if (hit) return hit; } // 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. On the Rosarito and Ensenada predial layouts it is not a rescue at // all but the only identifier the receipt carries, so a unique hit there is // as good as any account-number match and is treated as one. if (parsed.cadastralKey) { const primary = kind === "PROPERTY_TAX" && !parsed.accountRef; const hit = await this.byCadastralKey(kind, parsed.cadastralKey, primary); if (hit) return hit; } if (!field && !parsed.cadastralKey) { return this.unmatched(`no hay campo de búsqueda definido para ${kind}`); } if (!parsed.accountRef && !parsed.cadastralKey) { return this.unmatched( kind === "PROPERTY_TAX" ? "no se leyó ni la clave catastral ni la cuenta municipal" : "no se pudo leer la referencia de la cuenta", ); } return this.unmatched( parsed.accountRef ? `no se encontró ningún servicio de ${kind} con la referencia ${parsed.accountRef}` : `no se encontró ninguna propiedad con la clave catastral ${parsed.cadastralKey}`, ); } private async byServiceField( kind: ServiceKind, field: "accountNumber" | "meterNumber", ref: string, ): Promise { 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, /** True when the clave is the identifier the statement was issued against. */ primary: boolean, ): Promise { 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, }; } // When the clave is the *secondary* key — a utility bill that also happens // to print it — the page is left for review, because the clave was not the // number the statement was issued against and confirming is what teaches // the matcher the account number for next month. When it is the primary key // (Rosarito and Ensenada predial, which print nothing else), a unique hit // is a real match and there is no second number to learn. return { propertyServiceId: candidates[0].propertyServiceId ?? null, customerId: candidates[0].customerId, note: primary ? `coincidencia exacta por clave catastral ${key}` : `identificado por clave catastral ${key}; confirme para registrar también el número de cuenta`, // A clave with no service row of the right kind behind it still needs a // human: there is nothing to attach the posting to. confident: primary && candidates[0].propertyServiceId != null, candidates, }; } private unmatched(note: string): MatchResult { return { propertyServiceId: null, customerId: null, note, confident: false, candidates: [], }; } }