A bad scan, the wrong PDFs or a duplicate upload used to leave a batch sitting in READY_FOR_REVIEW forever, because the only exits were confirm (posts to the books) or rejecting every page one at a time. Add a DISCARDED terminal status to both OCR domains and a single endpoint per domain that rejects every page still pending in one shot. Discarding is refused once anything has landed: statements once a page is POSTED, policies once a page is APPLIED. Those batches did real work and have to be settled page by page. - POST /statements/batches/:id/discard - POST /policy-ocr/batches/:id/discard - shared DiscardBatchCard on both review screens, gated the same way
527 lines
20 KiB
TypeScript
527 lines
20 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Inject,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import {
|
|
Prisma,
|
|
type ServiceKind,
|
|
type StatementDocumentStatus,
|
|
} from "@jorgecuadros/database";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { StorageService } from "../storage/storage.service";
|
|
import { BillingService } from "../billing/billing.service";
|
|
import type { UploadedFileLike } from "../storage/upload-file";
|
|
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
|
import { parseStatement } from "./parsers/statement-parser";
|
|
import { StatementMatcherService, scopedRefField } from "./statement-matcher.service";
|
|
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
|
|
|
/**
|
|
* Default ledger concept per service kind. The names are the legacy
|
|
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
|
|
* per confirm rather than hard-coded as ids, which differ per environment.
|
|
*/
|
|
const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
|
|
ELECTRIC: "ELECTRIC",
|
|
WATER: "WATER",
|
|
TELEPHONE: "TELEPHONE",
|
|
GAS: "GAS BUTANO",
|
|
PROPERTY_TAX: "PROPERTY TAXES",
|
|
FEDERAL_ZONE: "FEDERAL ZONE",
|
|
CABLE: "CABLE",
|
|
};
|
|
|
|
/** Statuses a document can still be worked on from. */
|
|
const OPEN: StatementDocumentStatus[] = ["NEEDS_REVIEW", "MATCHED", "CONFIRMED"];
|
|
|
|
@Injectable()
|
|
export class StatementsService {
|
|
private readonly logger = new Logger(StatementsService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly storage: StorageService,
|
|
private readonly billing: BillingService,
|
|
private readonly matcher: StatementMatcherService,
|
|
@Inject(OCR_PROVIDER) private readonly ocr: OcrProvider,
|
|
) {}
|
|
|
|
ocrAvailable(): Promise<boolean> {
|
|
return this.ocr.available();
|
|
}
|
|
|
|
/** Scans are stored as blobs, so no object storage means no intake. */
|
|
storageAvailable(): boolean {
|
|
return this.storage.available;
|
|
}
|
|
|
|
// --- ingest ---------------------------------------------------------------
|
|
|
|
/**
|
|
* Accept a batch of scanned PDFs and start processing.
|
|
*
|
|
* Processing is kicked off but deliberately not awaited: 300 pages of OCR is
|
|
* minutes of CPU, far past any sane HTTP timeout. The caller gets the batch
|
|
* id immediately and polls its status, which is also what lets the review
|
|
* queue show partial progress.
|
|
*/
|
|
async createBatch(
|
|
files: UploadedFileLike[],
|
|
serviceKind: ServiceKind,
|
|
uploadedById: string,
|
|
label?: string,
|
|
) {
|
|
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
|
|
if (!(await this.ocr.available())) {
|
|
throw new BadRequestException(
|
|
"El servidor no tiene OCR instalado; no se pueden procesar recibos.",
|
|
);
|
|
}
|
|
// Checked here rather than at the first `put`, which would only surface as
|
|
// a FAILED batch minutes later.
|
|
if (!this.storage.available) {
|
|
throw new BadRequestException(
|
|
"El almacenamiento de documentos no está configurado; no se pueden " +
|
|
"guardar los recibos escaneados.",
|
|
);
|
|
}
|
|
|
|
const batch = await this.prisma.statementBatch.create({
|
|
data: { serviceKind, uploadedById, label, fileCount: files.length },
|
|
});
|
|
|
|
// Buffers are held for the background pass; the request's own copies would
|
|
// otherwise be garbage once the response is sent.
|
|
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
|
void this.process(batch.id, copies, serviceKind).catch(async (err) => {
|
|
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`);
|
|
await this.prisma.statementBatch.update({
|
|
where: { id: batch.id },
|
|
data: { status: "FAILED", error: (err as Error).message },
|
|
});
|
|
});
|
|
|
|
return batch;
|
|
}
|
|
|
|
/** Render → OCR → parse → match, one document row per page. */
|
|
private async process(
|
|
batchId: string,
|
|
files: { buffer: Buffer; name?: string }[],
|
|
serviceKind: ServiceKind,
|
|
) {
|
|
await this.prisma.statementBatch.update({
|
|
where: { id: batchId },
|
|
data: { status: "PROCESSING" },
|
|
});
|
|
|
|
let pageNumber = 0;
|
|
for (const file of files) {
|
|
// The source PDF is kept as well as the page images: it is the artifact
|
|
// the office actually received, and the only way to re-run a corrected
|
|
// parser over the original later.
|
|
const sourceKey = `statement/${batchId}/source-${pageNumber + 1}.pdf`;
|
|
await this.storage.put(sourceKey, file.buffer, "application/pdf");
|
|
|
|
const pages = await this.ocr.renderPages(file.buffer);
|
|
// Page images are still rendered and stored for every file, text layer or
|
|
// not: the review screen shows the reviewer the page, and "what the
|
|
// parser read" is only checkable against a picture of the paper.
|
|
const textLayer = await this.ocr.textPages(file.buffer).catch(() => []);
|
|
|
|
for (const [index, image] of pages.entries()) {
|
|
pageNumber += 1;
|
|
const storageKey = `statement/${batchId}/page-${pageNumber}.png`;
|
|
await this.storage.put(storageKey, image, "image/png");
|
|
|
|
try {
|
|
const embedded = textLayer[index] ?? null;
|
|
const ocr = embedded ?? (await this.ocr.recognize(image));
|
|
const parsed = parseStatement(ocr);
|
|
if (embedded) {
|
|
parsed.notes.unshift("texto leído del PDF original, sin OCR");
|
|
}
|
|
const match = await this.matcher.match(parsed, serviceKind);
|
|
|
|
const notes = [...parsed.notes, match.note].filter(Boolean);
|
|
// A confident field match is only trusted when nothing contradicts
|
|
// it: a barcode that disagrees with the printed number means one of
|
|
// the two was misread, and which one is a judgement call.
|
|
const trusted = match.confident && parsed.crossChecked !== false;
|
|
|
|
await this.prisma.statementDocument.create({
|
|
data: {
|
|
batchId,
|
|
pageNumber,
|
|
storageKey,
|
|
status: trusted ? "MATCHED" : "NEEDS_REVIEW",
|
|
ocrRawText: ocr.text,
|
|
ocrConfidence: new Prisma.Decimal(ocr.confidence.toFixed(3)),
|
|
provider: parsed.provider,
|
|
extractedAccountRef: parsed.accountRef,
|
|
extractedAmount:
|
|
parsed.amount != null ? new Prisma.Decimal(parsed.amount) : null,
|
|
extractedPeriod: parsed.period,
|
|
extractedDueDate: parsed.dueDate,
|
|
extractedCadastralKey: parsed.cadastralKey,
|
|
matchedPropertyServiceId: match.propertyServiceId,
|
|
matchedCustomerId: match.customerId,
|
|
matchNote: notes.join("; ").slice(0, 190),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
// One unreadable page must not abandon the other 299.
|
|
await this.prisma.statementDocument.create({
|
|
data: {
|
|
batchId,
|
|
pageNumber,
|
|
storageKey,
|
|
status: "OCR_FAILED",
|
|
matchNote: (err as Error).message.slice(0, 190),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
await this.prisma.statementBatch.update({
|
|
where: { id: batchId },
|
|
data: { status: "READY_FOR_REVIEW" },
|
|
});
|
|
}
|
|
|
|
// --- reads ----------------------------------------------------------------
|
|
|
|
async listBatches(page: number, pageSize: number) {
|
|
const [total, items] = await this.prisma.$transaction([
|
|
this.prisma.statementBatch.count(),
|
|
this.prisma.statementBatch.findMany({
|
|
orderBy: { createdAt: "desc" },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
include: {
|
|
uploadedBy: { select: { name: true } },
|
|
_count: { select: { documents: true } },
|
|
},
|
|
}),
|
|
]);
|
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
|
}
|
|
|
|
async getBatch(id: string) {
|
|
const batch = await this.prisma.statementBatch.findUnique({
|
|
where: { id },
|
|
include: { uploadedBy: { select: { name: true } } },
|
|
});
|
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
|
|
|
const counts = await this.prisma.statementDocument.groupBy({
|
|
by: ["status"],
|
|
where: { batchId: id },
|
|
_count: { _all: true },
|
|
});
|
|
const totals = await this.prisma.statementDocument.aggregate({
|
|
where: { batchId: id, status: { in: OPEN } },
|
|
_sum: { extractedAmount: true },
|
|
});
|
|
|
|
return {
|
|
...batch,
|
|
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
|
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
|
|
};
|
|
}
|
|
|
|
async listDocuments(batchId: string, status?: StatementDocumentStatus) {
|
|
return this.prisma.statementDocument.findMany({
|
|
where: { batchId, ...(status ? { status } : {}) },
|
|
orderBy: { pageNumber: "asc" },
|
|
include: {
|
|
matchedCustomer: { select: { id: true, name: true } },
|
|
matchedPropertyService: {
|
|
select: {
|
|
id: true,
|
|
kind: true,
|
|
accountNumber: true,
|
|
meterNumber: true,
|
|
property: { select: { id: true, addressLine1: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/** The rendered page image, so a reviewer can read what the parser read. */
|
|
async pageImage(documentId: string) {
|
|
const doc = await this.prisma.statementDocument.findUnique({
|
|
where: { id: documentId },
|
|
select: { storageKey: true },
|
|
});
|
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
|
return this.storage.getStream(doc.storageKey);
|
|
}
|
|
|
|
// --- review ---------------------------------------------------------------
|
|
|
|
/** Staff correction of an extracted field or of the match itself. */
|
|
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
|
|
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
|
if (doc.status === "POSTED") {
|
|
throw new BadRequestException("Este documento ya fue registrado.");
|
|
}
|
|
|
|
// Changing the service implies its owner; deriving the customer here rather
|
|
// than trusting a client-supplied pair is what stops a page being posted to
|
|
// one customer's ledger against another customer's service.
|
|
let matchedCustomerId = doc.matchedCustomerId;
|
|
let matchedPropertyServiceId = dto.matchedPropertyServiceId ?? undefined;
|
|
|
|
if (dto.matchedPropertyServiceId) {
|
|
const svc = await this.prisma.propertyService.findUnique({
|
|
where: { id: dto.matchedPropertyServiceId },
|
|
select: { property: { select: { customerId: true } } },
|
|
});
|
|
if (!svc) throw new BadRequestException("Servicio no encontrado.");
|
|
matchedCustomerId = svc.property.customerId;
|
|
} else if (dto.matchedCustomerId) {
|
|
matchedCustomerId = dto.matchedCustomerId;
|
|
|
|
// A reviewer picks a *customer*, not one of their service rows. Without
|
|
// a service the posting still works, but the confirmed reference has
|
|
// nowhere to be written back, so the same account would land in review
|
|
// again next month — which is exactly the behaviour that is supposed to
|
|
// make gas (whose numbers the migration never populated) a one-time cost.
|
|
// So: if the batch's service kind resolves to exactly one of that
|
|
// customer's services that has no reference yet, attach it. Exactly one
|
|
// — with two candidates there is no way to tell which meter or line the
|
|
// bill belongs to, and guessing would write a real number onto the wrong
|
|
// service.
|
|
const batch = await this.prisma.statementBatch.findUnique({
|
|
where: { id: doc.batchId },
|
|
select: { serviceKind: true },
|
|
});
|
|
const field = batch && scopedRefField(batch.serviceKind);
|
|
if (batch && field) {
|
|
const blank = await this.prisma.propertyService.findMany({
|
|
where: {
|
|
kind: batch.serviceKind,
|
|
[field]: null,
|
|
property: { customerId: matchedCustomerId },
|
|
},
|
|
select: { id: true },
|
|
take: 2,
|
|
});
|
|
if (blank.length === 1) matchedPropertyServiceId = blank[0].id;
|
|
}
|
|
}
|
|
|
|
return this.prisma.statementDocument.update({
|
|
where: { id },
|
|
data: {
|
|
extractedAccountRef: dto.accountRef ?? undefined,
|
|
extractedAmount:
|
|
dto.amount != null ? new Prisma.Decimal(dto.amount) : undefined,
|
|
extractedPeriod: dto.period ?? undefined,
|
|
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
|
matchedPropertyServiceId,
|
|
matchedCustomerId,
|
|
status: dto.status ?? "MATCHED",
|
|
reviewedById,
|
|
reviewedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
async reject(id: string, reviewedById: string) {
|
|
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
|
if (doc.status === "POSTED") {
|
|
throw new BadRequestException("Este documento ya fue registrado.");
|
|
}
|
|
const updated = await this.prisma.statementDocument.update({
|
|
where: { id },
|
|
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
|
});
|
|
// Rejecting the last open page settles the batch just as posting it would
|
|
// — without this, a fully-rejected batch sat in READY_FOR_REVIEW forever
|
|
// because only confirmBatch() ever closed one.
|
|
await this.closeIfDone(doc.batchId);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Throw away a whole batch that is pending review: every page that has not
|
|
* been posted is marked REJECTED and the batch itself becomes DISCARDED.
|
|
*
|
|
* Refuses once any page is POSTED — those pages already wrote ledger rows
|
|
* against a check, and a "discarded" label on the batch would leave those
|
|
* charges unexplained. Reject the remaining pages individually instead.
|
|
*/
|
|
async discardBatch(batchId: string, reviewedById: string) {
|
|
const batch = await this.prisma.statementBatch.findUnique({
|
|
where: { id: batchId },
|
|
});
|
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
|
if (batch.status === "DISCARDED") {
|
|
throw new BadRequestException("Este lote ya fue descartado.");
|
|
}
|
|
|
|
const posted = await this.prisma.statementDocument.count({
|
|
where: { batchId, status: "POSTED" },
|
|
});
|
|
if (posted > 0) {
|
|
throw new BadRequestException(
|
|
`No se puede descartar: ${posted} página(s) ya se registraron en el estado de cuenta.`,
|
|
);
|
|
}
|
|
|
|
const { count } = await this.prisma.statementDocument.updateMany({
|
|
where: { batchId, status: { notIn: ["POSTED", "REJECTED"] } },
|
|
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
|
});
|
|
|
|
await this.prisma.statementBatch.update({
|
|
where: { id: batchId },
|
|
data: { status: "DISCARDED", completedAt: new Date() },
|
|
});
|
|
|
|
return { batchId, rejected: count };
|
|
}
|
|
|
|
// --- posting --------------------------------------------------------------
|
|
|
|
/**
|
|
* Post every confirmable document in a batch to the ledger.
|
|
*
|
|
* This goes through `BillingService.createBatch` — the same method the manual
|
|
* "Editor" screen uses — rather than writing `Transaction` rows directly, so
|
|
* OCR-sourced and hand-keyed receipts share one write path, one validation
|
|
* path and one audit trail. `source: "OCR"` and a per-line `captureRef` of
|
|
* the document id give the duplicate-post guard something to key on, so a
|
|
* batch confirmed twice cannot double-charge anyone.
|
|
*/
|
|
async confirmBatch(batchId: string, dto: ConfirmBatchDto, reviewedById: string) {
|
|
const batch = await this.prisma.statementBatch.findUnique({
|
|
where: { id: batchId },
|
|
});
|
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
|
|
|
const docs = await this.prisma.statementDocument.findMany({
|
|
where: {
|
|
batchId,
|
|
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
|
|
matchedCustomerId: { not: null },
|
|
},
|
|
orderBy: { pageNumber: "asc" },
|
|
});
|
|
if (!docs.length) {
|
|
throw new BadRequestException("No hay documentos listos para registrar.");
|
|
}
|
|
|
|
const missing = docs.filter((d) => d.extractedAmount == null);
|
|
if (missing.length) {
|
|
throw new BadRequestException(
|
|
`Falta el importe en ${missing.length} documento(s): página(s) ` +
|
|
missing.map((d) => d.pageNumber).join(", "),
|
|
);
|
|
}
|
|
|
|
const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind));
|
|
|
|
const result = await this.billing.createBatch(
|
|
{
|
|
domain: "UTILITY",
|
|
transactionDate: dto.transactionDate,
|
|
checkNumber: dto.checkNumber,
|
|
currency: dto.currency ?? "MXN",
|
|
typeId,
|
|
lines: docs.map((d) => ({
|
|
customerId: d.matchedCustomerId!,
|
|
// Charges are negative in this ledger: a negative amount is what the
|
|
// customer owes. The parser reads the printed (positive) figure, so
|
|
// the sign is applied here, at the single point where a statement
|
|
// becomes a ledger row.
|
|
amount: -Math.abs(Number(d.extractedAmount)),
|
|
reference: d.extractedAccountRef ?? undefined,
|
|
period: d.extractedPeriod ?? undefined,
|
|
outstanding: dto.outstanding ?? false,
|
|
})),
|
|
},
|
|
{ source: "OCR", refs: docs.map((d) => d.id) },
|
|
);
|
|
|
|
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
|
|
// the created rows zip straight back onto the documents that produced them.
|
|
await this.prisma.$transaction(
|
|
docs.map((d, i) =>
|
|
this.prisma.statementDocument.update({
|
|
where: { id: d.id },
|
|
data: {
|
|
status: "POSTED",
|
|
postedTransactionId: result.items[i].id,
|
|
reviewedById,
|
|
reviewedAt: new Date(),
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
// Teach the matcher. When a document was matched by clave catastral or by
|
|
// hand because the scoped field was blank, writing the reference back means
|
|
// next month's statement for the same account matches on its own — this is
|
|
// what turns gas (whose numbers the migration never populated) from a
|
|
// permanent review queue into a one-time cost.
|
|
await this.learnAccountRefs(docs, batch.serviceKind);
|
|
|
|
await this.closeIfDone(batchId);
|
|
|
|
return { posted: result.count, total: result.total, checkNumber: dto.checkNumber };
|
|
}
|
|
|
|
/** Write a confirmed reference onto a service that had none. */
|
|
private async learnAccountRefs(
|
|
docs: { matchedPropertyServiceId: string | null; extractedAccountRef: string | null }[],
|
|
kind: ServiceKind,
|
|
) {
|
|
const field = scopedRefField(kind);
|
|
if (!field) return;
|
|
for (const d of docs) {
|
|
if (!d.matchedPropertyServiceId || !d.extractedAccountRef) continue;
|
|
await this.prisma.propertyService.updateMany({
|
|
// Only fills a hole — never overwrites a number already on file, which
|
|
// would let one misread page rewrite good reference data.
|
|
where: { id: d.matchedPropertyServiceId, [field]: null },
|
|
data: { [field]: d.extractedAccountRef },
|
|
});
|
|
}
|
|
}
|
|
|
|
private async closeIfDone(batchId: string) {
|
|
const open = await this.prisma.statementDocument.count({
|
|
where: { batchId, status: { in: OPEN } },
|
|
});
|
|
if (open === 0) {
|
|
await this.prisma.statementBatch.updateMany({
|
|
// `updateMany` + a status filter so a discarded batch is never quietly
|
|
// relabelled COMPLETED by a late reject on one of its pages.
|
|
where: { id: batchId, status: { not: "DISCARDED" } },
|
|
data: { status: "COMPLETED", completedAt: new Date() },
|
|
});
|
|
}
|
|
}
|
|
|
|
private async conceptFor(kind: ServiceKind): Promise<string | undefined> {
|
|
const name = CONCEPT_BY_KIND[kind];
|
|
if (!name) return undefined;
|
|
const row = await this.prisma.typeTransaction.findFirst({
|
|
where: { nameEn: name },
|
|
select: { id: true },
|
|
});
|
|
return row?.id;
|
|
}
|
|
}
|