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
This commit is contained in:
@@ -143,6 +143,18 @@ async pageImage(
|
|||||||
return doc;
|
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")
|
@Post("batches/:id/confirm")
|
||||||
@RequireAbility("policy:ocr-review")
|
@RequireAbility("policy:ocr-review")
|
||||||
async confirm(
|
async confirm(
|
||||||
|
|||||||
@@ -364,10 +364,55 @@ export class PolicyOcrService {
|
|||||||
if (doc.status === "POSTED") {
|
if (doc.status === "POSTED") {
|
||||||
throw new BadRequestException("Este documento ya fue aplicado.");
|
throw new BadRequestException("Este documento ya fue aplicado.");
|
||||||
}
|
}
|
||||||
return this.prisma.policyOcrDocument.update({
|
const updated = await this.prisma.policyOcrDocument.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
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 --------------------------------------------------------------
|
// --- confirm --------------------------------------------------------------
|
||||||
@@ -533,8 +578,10 @@ export class PolicyOcrService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (open === 0) {
|
if (open === 0) {
|
||||||
await this.prisma.policyOcrBatch.update({
|
await this.prisma.policyOcrBatch.updateMany({
|
||||||
where: { id: batchId },
|
// `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() },
|
data: { status: "COMPLETED", completedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,18 @@ export class StatementsController {
|
|||||||
return doc;
|
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 every matched document in the batch, against one check. */
|
||||||
@Post("batches/:id/confirm")
|
@Post("batches/:id/confirm")
|
||||||
@RequireAbility("statement:review")
|
@RequireAbility("statement:review")
|
||||||
|
|||||||
@@ -342,10 +342,54 @@ export class StatementsService {
|
|||||||
if (doc.status === "POSTED") {
|
if (doc.status === "POSTED") {
|
||||||
throw new BadRequestException("Este documento ya fue registrado.");
|
throw new BadRequestException("Este documento ya fue registrado.");
|
||||||
}
|
}
|
||||||
return this.prisma.statementDocument.update({
|
const updated = await this.prisma.statementDocument.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
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 --------------------------------------------------------------
|
// --- posting --------------------------------------------------------------
|
||||||
@@ -461,8 +505,10 @@ export class StatementsService {
|
|||||||
where: { batchId, status: { in: OPEN } },
|
where: { batchId, status: { in: OPEN } },
|
||||||
});
|
});
|
||||||
if (open === 0) {
|
if (open === 0) {
|
||||||
await this.prisma.statementBatch.update({
|
await this.prisma.statementBatch.updateMany({
|
||||||
where: { id: batchId },
|
// `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() },
|
data: { status: "COMPLETED", completedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
||||||
import {
|
import {
|
||||||
confirmStatementBatch,
|
confirmStatementBatch,
|
||||||
|
discardStatementBatch,
|
||||||
getStatementBatch,
|
getStatementBatch,
|
||||||
listStatementDocuments,
|
listStatementDocuments,
|
||||||
rejectStatementDocument,
|
rejectStatementDocument,
|
||||||
@@ -66,6 +68,7 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [discarding, setDiscarding] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -108,9 +111,31 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
||||||
).length;
|
).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 <div className="state-box">Cargando…</div>;
|
if (loading) return <div className="state-box">Cargando…</div>;
|
||||||
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
@@ -146,6 +171,15 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canDiscard && (
|
||||||
|
<DiscardBatchCard
|
||||||
|
busy={discarding}
|
||||||
|
onDiscard={discard}
|
||||||
|
pageCount={docs.length}
|
||||||
|
what="recibo"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
{sorted.map((doc) => (
|
{sorted.map((doc) => (
|
||||||
<DocumentRow
|
<DocumentRow
|
||||||
@@ -166,6 +200,7 @@ const STATUS_LABEL_BATCH: Record<string, string> = {
|
|||||||
READY_FOR_REVIEW: "Listo para revisar",
|
READY_FOR_REVIEW: "Listo para revisar",
|
||||||
COMPLETED: "Registrado",
|
COMPLETED: "Registrado",
|
||||||
FAILED: "Falló",
|
FAILED: "Falló",
|
||||||
|
DISCARDED: "Descartado",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<section className="card" style={{ padding: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||||
|
Descartar lote
|
||||||
|
</h2>
|
||||||
|
{armed ? (
|
||||||
|
<>
|
||||||
|
<p className="page-sub" style={{ marginBottom: 12 }}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="inline-form" style={{ gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onDiscard}
|
||||||
|
>
|
||||||
|
{busy ? "Descartando…" : "Sí, descartar el lote"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setArmed(false)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="page-sub" style={{ marginBottom: 12 }}>
|
||||||
|
Si el lote quedó mal (escaneo ilegible, PDFs equivocados, subida
|
||||||
|
duplicada), descártelo para sacarlo de la cola de revisión.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setArmed(true)}
|
||||||
|
>
|
||||||
|
Descartar lote
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ const STATUS_LABEL: Record<PolicyOcrBatchStatus, string> = {
|
|||||||
READY_FOR_REVIEW: "Listo para revisar",
|
READY_FOR_REVIEW: "Listo para revisar",
|
||||||
COMPLETED: "Aplicado",
|
COMPLETED: "Aplicado",
|
||||||
FAILED: "Falló",
|
FAILED: "Falló",
|
||||||
|
DISCARDED: "Descartado",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PolicyOcrIntake() {
|
export function PolicyOcrIntake() {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
||||||
import {
|
import {
|
||||||
confirmPolicyOcrBatch,
|
confirmPolicyOcrBatch,
|
||||||
|
discardPolicyOcrBatch,
|
||||||
getPolicyOcrBatch,
|
getPolicyOcrBatch,
|
||||||
listCustomers,
|
listCustomers,
|
||||||
listPolicyOcrDocuments,
|
listPolicyOcrDocuments,
|
||||||
@@ -23,6 +25,8 @@ import type {
|
|||||||
PolicyOcrReviewInput,
|
PolicyOcrReviewInput,
|
||||||
} from "@/lib/types";
|
} 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<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
PENDING_OCR: "Pendiente",
|
PENDING_OCR: "Pendiente",
|
||||||
OCR_FAILED: "Falló OCR",
|
OCR_FAILED: "Falló OCR",
|
||||||
@@ -31,6 +35,12 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
CONFIRMED: "Confirmado",
|
CONFIRMED: "Confirmado",
|
||||||
POSTED: "Aplicado",
|
POSTED: "Aplicado",
|
||||||
REJECTED: "Rechazado",
|
REJECTED: "Rechazado",
|
||||||
|
UPLOADED: "Recibido",
|
||||||
|
PROCESSING: "Procesando…",
|
||||||
|
READY_FOR_REVIEW: "Listo para revisar",
|
||||||
|
COMPLETED: "Aplicado",
|
||||||
|
FAILED: "Falló",
|
||||||
|
DISCARDED: "Descartado",
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPEN_FIRST = [
|
const OPEN_FIRST = [
|
||||||
@@ -54,6 +64,7 @@ export function PolicyOcrReview({ id }: { id: string }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [discarding, setDiscarding] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
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 <div className="state-box">Cargando…</div>;
|
if (loading) return <div className="state-box">Cargando…</div>;
|
||||||
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
@@ -175,6 +210,15 @@ export function PolicyOcrReview({ id }: { id: string }) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canDiscard && (
|
||||||
|
<DiscardBatchCard
|
||||||
|
busy={discarding}
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
pageCount={docs.length}
|
||||||
|
what="póliza"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
{sorted.map((doc) => (
|
{sorted.map((doc) => (
|
||||||
<DocumentRow
|
<DocumentRow
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const STATUS_LABEL: Record<StatementBatchStatus, string> = {
|
|||||||
READY_FOR_REVIEW: "Listo para revisar",
|
READY_FOR_REVIEW: "Listo para revisar",
|
||||||
COMPLETED: "Registrado",
|
COMPLETED: "Registrado",
|
||||||
FAILED: "Falló",
|
FAILED: "Falló",
|
||||||
|
DISCARDED: "Descartado",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatementIntake() {
|
export function StatementIntake() {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
CreateBankInput,
|
CreateBankInput,
|
||||||
CreateBankMovementInput,
|
CreateBankMovementInput,
|
||||||
CreateMovementInput,
|
CreateMovementInput,
|
||||||
|
DiscardBatchResult,
|
||||||
UpdateBankAccountInput,
|
UpdateBankAccountInput,
|
||||||
ResolveOutstandingInput,
|
ResolveOutstandingInput,
|
||||||
ReviewDocumentInput,
|
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<DiscardBatchResult> {
|
||||||
|
return apiFetch(`/statements/batches/${batchId}/discard`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
/** The rendered page image. A plain <img src> — the cookie rides along. */
|
/** The rendered page image. A plain <img src> — the cookie rides along. */
|
||||||
export function statementPageUrl(documentId: string): string {
|
export function statementPageUrl(documentId: string): string {
|
||||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
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<DiscardBatchResult> {
|
||||||
|
return apiFetch(`/policy-ocr/batches/${batchId}/discard`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL for the source PDF of a parsed policy document. The endpoint returns
|
* 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
|
* the original upload (one PDF = one parsed policy), not a rendered page
|
||||||
|
|||||||
@@ -1217,7 +1217,9 @@ export type StatementBatchStatus =
|
|||||||
| "PROCESSING"
|
| "PROCESSING"
|
||||||
| "READY_FOR_REVIEW"
|
| "READY_FOR_REVIEW"
|
||||||
| "COMPLETED"
|
| "COMPLETED"
|
||||||
| "FAILED";
|
| "FAILED"
|
||||||
|
/** Abandoned by staff before anything was posted. */
|
||||||
|
| "DISCARDED";
|
||||||
|
|
||||||
export type StatementDocumentStatus =
|
export type StatementDocumentStatus =
|
||||||
| "PENDING_OCR"
|
| "PENDING_OCR"
|
||||||
@@ -1296,6 +1298,12 @@ export interface ConfirmBatchResult {
|
|||||||
checkNumber: string;
|
checkNumber: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared by both OCR domains: how many pages the discard rejected. */
|
||||||
|
export interface DiscardBatchResult {
|
||||||
|
batchId: string;
|
||||||
|
rejected: number;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------ Policy OCR intake (GMX) */
|
/* ------------------------------------------ Policy OCR intake (GMX) */
|
||||||
|
|
||||||
export type PolicyOcrBatchStatus =
|
export type PolicyOcrBatchStatus =
|
||||||
@@ -1303,7 +1311,9 @@ export type PolicyOcrBatchStatus =
|
|||||||
| "PROCESSING"
|
| "PROCESSING"
|
||||||
| "READY_FOR_REVIEW"
|
| "READY_FOR_REVIEW"
|
||||||
| "COMPLETED"
|
| "COMPLETED"
|
||||||
| "FAILED";
|
| "FAILED"
|
||||||
|
/** Abandoned by staff before anything was applied. */
|
||||||
|
| "DISCARDED";
|
||||||
|
|
||||||
export type PolicyOcrDocumentStatus =
|
export type PolicyOcrDocumentStatus =
|
||||||
| "PENDING_OCR"
|
| "PENDING_OCR"
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -399,6 +399,10 @@ enum PolicyOcrBatchStatus {
|
|||||||
READY_FOR_REVIEW
|
READY_FOR_REVIEW
|
||||||
COMPLETED
|
COMPLETED
|
||||||
FAILED
|
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).
|
/// One parsed policy page — one Policy → one Customer (after staff confirms).
|
||||||
@@ -573,6 +577,10 @@ enum StatementBatchStatus {
|
|||||||
READY_FOR_REVIEW
|
READY_FOR_REVIEW
|
||||||
COMPLETED
|
COMPLETED
|
||||||
FAILED
|
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 {
|
enum StatementDocumentStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user