From 3125b52057ae8e2594d036637c8984d9e3e36bed Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Sun, 2 Aug 2026 01:59:40 -0700 Subject: [PATCH] feat(ocr): discard abandoned capture batches 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 --- .../src/policy-ocr/policy-ocr.controller.ts | 12 +++ apps/api/src/policy-ocr/policy-ocr.service.ts | 53 +++++++++++- .../src/statements/statements.controller.ts | 12 +++ apps/api/src/statements/statements.service.ts | 52 +++++++++++- apps/web/src/app/recibos/[id]/page.tsx | 35 ++++++++ apps/web/src/components/DiscardBatchCard.tsx | 80 +++++++++++++++++++ apps/web/src/components/PolicyOcrIntake.tsx | 1 + apps/web/src/components/PolicyOcrReview.tsx | 44 ++++++++++ apps/web/src/components/StatementIntake.tsx | 1 + apps/web/src/lib/api.ts | 11 +++ apps/web/src/lib/types.ts | 14 +++- .../migration.sql | 12 +++ packages/database/prisma/schema.prisma | 8 ++ 13 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/DiscardBatchCard.tsx create mode 100644 packages/database/prisma/migrations/20260801120000_ocr_batch_discarded/migration.sql diff --git a/apps/api/src/policy-ocr/policy-ocr.controller.ts b/apps/api/src/policy-ocr/policy-ocr.controller.ts index 504d089..3c1c9ec 100644 --- a/apps/api/src/policy-ocr/policy-ocr.controller.ts +++ b/apps/api/src/policy-ocr/policy-ocr.controller.ts @@ -143,6 +143,18 @@ async pageImage( return doc; } + /** Abandon a batch pending review — rejects every unapplied page. */ + @Post("batches/:id/discard") + @RequireAbility("policy:ocr-review") + async discard(@Param("id") id: string, @Req() req: Request) { + const result = await this.policyOcr.discardBatch(id, this.actingId(req)); + void this.audit.log(this.actingId(req), "policyOcr.batch.discard", { + batchId: id, + rejected: result.rejected, + }); + return result; + } + @Post("batches/:id/confirm") @RequireAbility("policy:ocr-review") async confirm( diff --git a/apps/api/src/policy-ocr/policy-ocr.service.ts b/apps/api/src/policy-ocr/policy-ocr.service.ts index edb6122..6a4718e 100644 --- a/apps/api/src/policy-ocr/policy-ocr.service.ts +++ b/apps/api/src/policy-ocr/policy-ocr.service.ts @@ -364,10 +364,55 @@ export class PolicyOcrService { if (doc.status === "POSTED") { throw new BadRequestException("Este documento ya fue aplicado."); } - return this.prisma.policyOcrDocument.update({ + const updated = await this.prisma.policyOcrDocument.update({ where: { id }, data: { status: "REJECTED", reviewedById, reviewedAt: new Date() }, }); + // Rejecting the last open page settles the batch just as confirming 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 applied is marked REJECTED and the batch itself becomes DISCARDED. + * + * Refuses once any page is POSTED — a partly-applied batch has already + * written Policy (and possibly Transaction) rows, and hiding the paperwork + * behind a "discarded" label would leave those rows unexplained. Reject the + * remaining pages individually instead. + */ + async discardBatch(batchId: string, reviewedById: string) { + const batch = await this.prisma.policyOcrBatch.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.policyOcrDocument.count({ + where: { batchId, status: "POSTED" }, + }); + if (posted > 0) { + throw new BadRequestException( + `No se puede descartar: ${posted} página(s) ya se aplicaron a una póliza.`, + ); + } + + const { count } = await this.prisma.policyOcrDocument.updateMany({ + where: { batchId, status: { notIn: ["POSTED", "REJECTED"] } }, + data: { status: "REJECTED", reviewedById, reviewedAt: new Date() }, + }); + + await this.prisma.policyOcrBatch.update({ + where: { id: batchId }, + data: { status: "DISCARDED", completedAt: new Date() }, + }); + + return { batchId, rejected: count }; } // --- confirm -------------------------------------------------------------- @@ -533,8 +578,10 @@ export class PolicyOcrService { }, }); if (open === 0) { - await this.prisma.policyOcrBatch.update({ - where: { id: batchId }, + await this.prisma.policyOcrBatch.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() }, }); } diff --git a/apps/api/src/statements/statements.controller.ts b/apps/api/src/statements/statements.controller.ts index abb0f28..3838457 100644 --- a/apps/api/src/statements/statements.controller.ts +++ b/apps/api/src/statements/statements.controller.ts @@ -142,6 +142,18 @@ export class StatementsController { return doc; } + /** Abandon a batch pending review — rejects every unposted page. */ + @Post("batches/:id/discard") + @RequireAbility("statement:review") + async discard(@Param("id") id: string, @Req() req: Request) { + const result = await this.statements.discardBatch(id, this.actingId(req)); + void this.audit.log(this.actingId(req), "statement.batch.discard", { + batchId: id, + rejected: result.rejected, + }); + return result; + } + /** Post every matched document in the batch, against one check. */ @Post("batches/:id/confirm") @RequireAbility("statement:review") diff --git a/apps/api/src/statements/statements.service.ts b/apps/api/src/statements/statements.service.ts index 3fb9463..865d543 100644 --- a/apps/api/src/statements/statements.service.ts +++ b/apps/api/src/statements/statements.service.ts @@ -342,10 +342,54 @@ export class StatementsService { if (doc.status === "POSTED") { throw new BadRequestException("Este documento ya fue registrado."); } - return this.prisma.statementDocument.update({ + 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 -------------------------------------------------------------- @@ -461,8 +505,10 @@ export class StatementsService { where: { batchId, status: { in: OPEN } }, }); if (open === 0) { - await this.prisma.statementBatch.update({ - where: { id: batchId }, + 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() }, }); } diff --git a/apps/web/src/app/recibos/[id]/page.tsx b/apps/web/src/app/recibos/[id]/page.tsx index 8983ef5..fefb77b 100644 --- a/apps/web/src/app/recibos/[id]/page.tsx +++ b/apps/web/src/app/recibos/[id]/page.tsx @@ -4,8 +4,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/AppShell"; import { CustomerPicker } from "@/components/CustomerPicker"; +import { DiscardBatchCard } from "@/components/DiscardBatchCard"; import { confirmStatementBatch, + discardStatementBatch, getStatementBatch, listStatementDocuments, rejectStatementDocument, @@ -66,6 +68,7 @@ function BatchReview({ id }: { id: string }) { const [docs, setDocs] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [discarding, setDiscarding] = useState(false); const load = useCallback(async () => { try { @@ -108,9 +111,31 @@ function BatchReview({ id }: { id: string }) { (d) => d.status === "MATCHED" && d.matchedCustomer, ).length; + async function discard() { + setDiscarding(true); + setError(null); + try { + await discardStatementBatch(id); + await load(); + } catch (e) { + setError((e as Error)?.message ?? "No se pudo descartar el lote."); + } finally { + setDiscarding(false); + } + } + if (loading) return
Cargando…
; if (!batch) return
{error ?? "No encontrado."}
; + const postedCount = batch.byStatus.POSTED ?? 0; + // Discarding is only offered while the batch can still be abandoned whole: + // nothing posted to the ledger yet, and not already settled. + const canDiscard = + canReview && + batch.status !== "DISCARDED" && + batch.status !== "COMPLETED" && + postedCount === 0; + return (
@@ -146,6 +171,15 @@ function BatchReview({ id }: { id: string }) { /> )} + {canDiscard && ( + + )} +
{sorted.map((doc) => ( = { READY_FOR_REVIEW: "Listo para revisar", COMPLETED: "Registrado", FAILED: "Falló", + DISCARDED: "Descartado", }; /** diff --git a/apps/web/src/components/DiscardBatchCard.tsx b/apps/web/src/components/DiscardBatchCard.tsx new file mode 100644 index 0000000..cf294ac --- /dev/null +++ b/apps/web/src/components/DiscardBatchCard.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; + +/** + * "Throw this batch away" control, shared by both OCR review queues + * (recibos and pólizas). + * + * Confirmation is a two-step inline swap rather than `window.confirm`: the + * dialog would block the page, and an accidental discard is not undoable from + * the UI — the reviewer should read what they are about to lose, not dismiss + * a modal reflexively. + * + * The card is only rendered when the batch is still discardable; the API + * refuses again on its own (a page posted between render and click). + */ +export function DiscardBatchCard({ + busy, + onDiscard, + pageCount, + what, +}: { + busy: boolean; + onDiscard: () => void; + pageCount: number; + /** Singular noun for what a page becomes — "recibo" / "póliza". */ + what: string; +}) { + const [armed, setArmed] = useState(false); + + return ( +
+

+ Descartar lote +

+ {armed ? ( + <> +

+ Se descartarán las {pageCount} página(s) de este lote y no se + creará ninguna {what}. Esto no se puede deshacer desde aquí; para + volver a intentarlo hay que subir los PDFs otra vez. +

+
+ + +
+ + ) : ( + <> +

+ Si el lote quedó mal (escaneo ilegible, PDFs equivocados, subida + duplicada), descártelo para sacarlo de la cola de revisión. +

+ + + )} +
+ ); +} diff --git a/apps/web/src/components/PolicyOcrIntake.tsx b/apps/web/src/components/PolicyOcrIntake.tsx index 216a6a7..6c491c6 100644 --- a/apps/web/src/components/PolicyOcrIntake.tsx +++ b/apps/web/src/components/PolicyOcrIntake.tsx @@ -30,6 +30,7 @@ const STATUS_LABEL: Record = { READY_FOR_REVIEW: "Listo para revisar", COMPLETED: "Aplicado", FAILED: "Falló", + DISCARDED: "Descartado", }; export function PolicyOcrIntake() { diff --git a/apps/web/src/components/PolicyOcrReview.tsx b/apps/web/src/components/PolicyOcrReview.tsx index 8f6f5f6..50be8cc 100644 --- a/apps/web/src/components/PolicyOcrReview.tsx +++ b/apps/web/src/components/PolicyOcrReview.tsx @@ -3,8 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { CustomerPicker } from "@/components/CustomerPicker"; +import { DiscardBatchCard } from "@/components/DiscardBatchCard"; import { confirmPolicyOcrBatch, + discardPolicyOcrBatch, getPolicyOcrBatch, listCustomers, listPolicyOcrDocuments, @@ -23,6 +25,8 @@ import type { PolicyOcrReviewInput, } from "@/lib/types"; +/** Document and batch statuses share this map — the two enums have no + * overlapping members, and the header renders a batch status through it. */ const STATUS_LABEL: Record = { PENDING_OCR: "Pendiente", OCR_FAILED: "Falló OCR", @@ -31,6 +35,12 @@ const STATUS_LABEL: Record = { CONFIRMED: "Confirmado", POSTED: "Aplicado", REJECTED: "Rechazado", + UPLOADED: "Recibido", + PROCESSING: "Procesando…", + READY_FOR_REVIEW: "Listo para revisar", + COMPLETED: "Aplicado", + FAILED: "Falló", + DISCARDED: "Descartado", }; const OPEN_FIRST = [ @@ -54,6 +64,7 @@ export function PolicyOcrReview({ id }: { id: string }) { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); + const [discarding, setDiscarding] = useState(false); const load = useCallback(async () => { try { @@ -129,9 +140,33 @@ export function PolicyOcrReview({ id }: { id: string }) { } } + async function onDiscard() { + if (!batch) return; + setDiscarding(true); + setError(null); + try { + await discardPolicyOcrBatch(batch.id); + setEdits({}); + await load(); + } catch (e) { + setError((e as Error)?.message ?? "No se pudo descartar el lote."); + } finally { + setDiscarding(false); + } + } + if (loading) return
Cargando…
; if (!batch) return
{error ?? "No encontrado."}
; + const appliedCount = docs.filter((d) => d.status === "POSTED").length; + // Discarding is only offered while the batch can still be abandoned whole: + // nothing applied yet, and not already discarded. + const canDiscard = + canReview && + batch.status !== "DISCARDED" && + batch.status !== "COMPLETED" && + appliedCount === 0; + return (
@@ -175,6 +210,15 @@ export function PolicyOcrReview({ id }: { id: string }) {
)} + {canDiscard && ( + + )} +
{sorted.map((doc) => ( = { READY_FOR_REVIEW: "Listo para revisar", COMPLETED: "Registrado", FAILED: "Falló", + DISCARDED: "Descartado", }; export function StatementIntake() { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 17f6ba6..864d6c2 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -27,6 +27,7 @@ import type { CreateBankInput, CreateBankMovementInput, CreateMovementInput, + DiscardBatchResult, UpdateBankAccountInput, ResolveOutstandingInput, ReviewDocumentInput, @@ -1079,6 +1080,11 @@ export function confirmStatementBatch( }); } +/** Abandon a batch pending review; rejects every page that is not posted. */ +export function discardStatementBatch(batchId: string): Promise { + return apiFetch(`/statements/batches/${batchId}/discard`, { method: "POST" }); +} + /** The rendered page image. A plain — the cookie rides along. */ export function statementPageUrl(documentId: string): string { return `${API_ORIGIN}/statements/documents/${documentId}/page`; @@ -1168,6 +1174,11 @@ export function confirmPolicyOcrBatch( }); } +/** Abandon a batch pending review; rejects every page that is not applied. */ +export function discardPolicyOcrBatch(batchId: string): Promise { + return apiFetch(`/policy-ocr/batches/${batchId}/discard`, { method: "POST" }); +} + /** * URL for the source PDF of a parsed policy document. The endpoint returns * the original upload (one PDF = one parsed policy), not a rendered page diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 4f1b205..39eda04 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -1217,7 +1217,9 @@ export type StatementBatchStatus = | "PROCESSING" | "READY_FOR_REVIEW" | "COMPLETED" - | "FAILED"; + | "FAILED" + /** Abandoned by staff before anything was posted. */ + | "DISCARDED"; export type StatementDocumentStatus = | "PENDING_OCR" @@ -1296,6 +1298,12 @@ export interface ConfirmBatchResult { checkNumber: string; } +/** Shared by both OCR domains: how many pages the discard rejected. */ +export interface DiscardBatchResult { + batchId: string; + rejected: number; +} + /* ------------------------------------------ Policy OCR intake (GMX) */ export type PolicyOcrBatchStatus = @@ -1303,7 +1311,9 @@ export type PolicyOcrBatchStatus = | "PROCESSING" | "READY_FOR_REVIEW" | "COMPLETED" - | "FAILED"; + | "FAILED" + /** Abandoned by staff before anything was applied. */ + | "DISCARDED"; export type PolicyOcrDocumentStatus = | "PENDING_OCR" diff --git a/packages/database/prisma/migrations/20260801120000_ocr_batch_discarded/migration.sql b/packages/database/prisma/migrations/20260801120000_ocr_batch_discarded/migration.sql new file mode 100644 index 0000000..c0eab41 --- /dev/null +++ b/packages/database/prisma/migrations/20260801120000_ocr_batch_discarded/migration.sql @@ -0,0 +1,12 @@ +-- Add DISCARDED to both OCR batch status enums. Staff can now abandon a +-- pending review queue outright instead of leaving it stuck in +-- READY_FOR_REVIEW forever (rejecting every page never closed the batch). +-- Purely additive: no existing row changes value. + +-- AlterTable +ALTER TABLE `policy_ocr_batches` + MODIFY `status` ENUM('UPLOADED', 'PROCESSING', 'READY_FOR_REVIEW', 'COMPLETED', 'FAILED', 'DISCARDED') NOT NULL DEFAULT 'UPLOADED'; + +-- AlterTable +ALTER TABLE `statement_batches` + MODIFY `status` ENUM('UPLOADED', 'PROCESSING', 'READY_FOR_REVIEW', 'COMPLETED', 'FAILED', 'DISCARDED') NOT NULL DEFAULT 'UPLOADED'; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index cb3d947..8716ae8 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -399,6 +399,10 @@ enum PolicyOcrBatchStatus { READY_FOR_REVIEW COMPLETED FAILED + /// Abandoned by staff before anything was applied — a bad scan, the wrong + /// PDFs, a duplicate upload. Distinct from COMPLETED so the queue can tell + /// "we did the work" from "we threw it away". + DISCARDED } /// One parsed policy page — one Policy → one Customer (after staff confirms). @@ -573,6 +577,10 @@ enum StatementBatchStatus { READY_FOR_REVIEW COMPLETED FAILED + /// Abandoned by staff before anything was posted — a bad scan, the wrong + /// PDFs, a duplicate upload. Distinct from COMPLETED so the queue can tell + /// "we did the work" from "we threw it away". + DISCARDED } enum StatementDocumentStatus {