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:
@@ -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