diff --git a/src/app/api/ancpi/download-zip/route.ts b/src/app/api/ancpi/download-zip/route.ts index 6de7c12..e4fb03e 100644 --- a/src/app/api/ancpi/download-zip/route.ts +++ b/src/app/api/ancpi/download-zip/route.ts @@ -59,6 +59,7 @@ export async function GET(req: Request) { minioPath: true, documentDate: true, completedAt: true, + status: true, }, }); @@ -71,7 +72,9 @@ export async function GET(req: Request) { for (let i = 0; i < ids.length; i++) { const id = ids[i]!; const extract = extractMap.get(id); - if (!extract?.minioPath) continue; + // Skip rows without a file, and "review" rows (PDF present but the + // CF↔doc match is unverified — must not land in a "valid extracts" zip). + if (!extract?.minioPath || extract.status === "review") continue; const dateForName = extract.documentDate ?? extract.completedAt ?? new Date(); const d = new Date(dateForName); diff --git a/src/app/api/ancpi/recover/route.ts b/src/app/api/ancpi/recover/route.ts index 1f908e2..fd77770 100644 --- a/src/app/api/ancpi/recover/route.ts +++ b/src/app/api/ancpi/recover/route.ts @@ -15,7 +15,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; -import { recoverBatch } from "@/modules/parcel-sync/services/epay-queue"; +import { recoverBatch, getQueueStatus } from "@/modules/parcel-sync/services/epay-queue"; import { requireCfAccess } from "@/core/auth/cf-access"; export const runtime = "nodejs"; @@ -85,6 +85,22 @@ export async function GET(req: Request) { ); } + // Cross-guard against the live batch queue: while a batch is processing, + // its rows sit at (orderId:null, status in cart/ordering/...) — exactly the + // orphan window the WHERE below matches. Recovering then would re-stamp a + // live batch's rows with THIS order's id (wrong PDF, status corruption). + // A genuinely-crashed batch leaves __epayQueueProcessing=false (reset on + // restart), so blocking here only defers against an actively-running queue. + if (getQueueStatus().processing) { + return NextResponse.json( + { + error: + "O comandă ePay este în curs de procesare. Așteaptă finalizarea înainte de recuperare.", + }, + { status: 409 }, + ); + } + // Candidate rows: anything already tagged with this order that isn't // terminal, PLUS recent orphaned rows (orderId:null) in a recoverable // state — the operator asserts they belong to this order. diff --git a/src/modules/geoportal/v2/cf-order-modal.tsx b/src/modules/geoportal/v2/cf-order-modal.tsx index 4859c6e..98adc49 100644 --- a/src/modules/geoportal/v2/cf-order-modal.tsx +++ b/src/modules/geoportal/v2/cf-order-modal.tsx @@ -254,6 +254,23 @@ export function CfOrderModal({ const row = (data.orders ?? []).find((o) => o.id === id); if (row) { const s = (row.status || "").toLowerCase(); + // "review" rows DO have minioPath + documentName but the PDF + // may belong to another parcel (ambiguous CF↔doc match) — must + // be checked BEFORE the minioPath/documentName completion + // fallback, or it would short-circuit to "done" and hand the + // operator an unverified extract. + if (s === "review") { + setError( + "Comanda necesită verificare manuală (potrivire ambiguă document↔parcelă). Verifică în ParcelSync → ePay înainte de a folosi extrasul.", + ); + setPhase("error"); + return; + } + if (s === "failed" || s === "error") { + setError("Comanda a eșuat la ANCPI."); + setPhase("error"); + return; + } if ( s === "completed" || s === "done" || @@ -264,11 +281,6 @@ export function CfOrderModal({ setPhase("done"); return; } - if (s === "failed" || s === "error") { - setError("Comanda a eșuat la ANCPI."); - setPhase("error"); - return; - } } } } catch { diff --git a/src/modules/parcel-sync/components/epay-tab.tsx b/src/modules/parcel-sync/components/epay-tab.tsx index d2fb970..8063402 100644 --- a/src/modules/parcel-sync/components/epay-tab.tsx +++ b/src/modules/parcel-sync/components/epay-tab.tsx @@ -328,17 +328,35 @@ export function EpayTab() { * order, no new charge. For rows that failed at the download/poll * stage (the order exists at ANCPI but we never stored the PDF). -- */ const [retryingId, setRetryingId] = useState(null); + const [retryNotice, setRetryNotice] = useState(null); const handleRetryDownload = async (order: CfExtractRecord) => { setRetryingId(order.id); + setRetryNotice(null); try { const res = await fetch( `/api/ancpi/recover?extractId=${encodeURIComponent(order.id)}`, ); - // 409 → the row has no orderId yet (never reached ANCPI); nothing to - // recover by row. Other errors surface on the next refresh. - await res.json().catch(() => ({})); + const data = (await res.json().catch(() => ({}))) as { + error?: string; + completed?: number; + attempted?: number; + }; + if (!res.ok) { + // 409 (queue busy / no orderId yet), 404, 500 — tell the user. + setRetryNotice(data.error ?? `Reîncercare eșuată (${res.status}).`); + } else if ((data.completed ?? 0) > 0) { + setRetryNotice( + `Recuperat: ${data.completed}/${data.attempted ?? data.completed} extrase.`, + ); + } else { + setRetryNotice( + "Nimic de recuperat — comanda nu există la ANCPI sau e deja finalizată.", + ); + } void fetchOrders(true); void fetchEpayStatus(); + } catch { + setRetryNotice("Eroare rețea la reîncercare."); } finally { setRetryingId(null); } @@ -911,6 +929,20 @@ export function EpayTab() { )} + {/* -- Retry notice ------------------------------------------- */} + {retryNotice && ( +
+ {retryNotice} + +
+ )} + {/* -- Active orders indicator -------------------------------- */} {hasActive && (
diff --git a/src/modules/parcel-sync/services/epay-queue.ts b/src/modules/parcel-sync/services/epay-queue.ts index 1433562..1477592 100644 --- a/src/modules/parcel-sync/services/epay-queue.ts +++ b/src/modules/parcel-sync/services/epay-queue.ts @@ -16,7 +16,7 @@ import { prisma } from "@/core/storage/prisma"; import { EpayClient } from "./epay-client"; import { getEpayCredentials, updateEpayCredits } from "./epay-session-store"; -import { storeCfExtract } from "./epay-storage"; +import { storeCfExtract, getNextFileIndex } from "./epay-storage"; import type { CfExtractCreateInput } from "./epay-types"; /* ------------------------------------------------------------------ */ @@ -310,8 +310,10 @@ async function processBatch( // bail the moment ANCPI reports more rows than we put in. // cartCount tracks the rows actually in the cart (incremented on add, // decremented only on a CONFIRMED delete) so the invariant stays correct - // even if a cleanup delete fails. + // even if a cleanup delete fails. cartDirty trips when a metadata-less + // row could not be confirmed-deleted → we must not submit. let cartCount = 0; + let cartDirty = false; for (let idx = 0; idx < items.length; idx++) { const item = items[idx]!; const { extractId, input } = item; @@ -384,17 +386,42 @@ async function processBatch( await updateStatus(extractId, "failed", { errorMessage: "Salvarea metadatelor în ePay a eșuat.", }); - // Remove this metadata-less row from the cart so it can't be - // checked out and charged. Only decrement cartCount if ANCPI - // confirmed the delete — otherwise the row is still there and the - // invariant must keep counting it. + item.basketRowId = undefined; // exclude from validItems regardless + // Remove this metadata-less row from the cart so it can't be checked + // out and charged. Only drop it from tracking + decrement cartCount + // if ANCPI CONFIRMED the delete; otherwise the row is still in the + // cart, must stay in cleanup tracking, and the cart is now "dirty". const deleted = await client.deleteCartItem(basketRowId, idx); - if (deleted) cartCount--; - ourBasketIdsForCleanup.pop(); - item.basketRowId = undefined; + if (deleted) { + cartCount--; + ourBasketIdsForCleanup.pop(); + } else { + // Undeletable metadata-less row → submitting now would check it out + // and charge for it. Refuse to submit this batch. + cartDirty = true; + } } } + // A metadata-less row we couldn't remove is still in the global cart; + // submitOrder would check out the WHOLE cart and charge for it. Abort + // (best-effort wipe) instead of submitting a cart we can't guarantee + // clean. The catch below would NOT fire on the success path, so this + // explicit guard is what stops the unintended charge. + if (cartDirty) { + console.error( + "[epay-queue] Cart has an undeletable metadata-less row — aborting before submit.", + ); + await client.deleteCartItems(ourBasketIdsForCleanup); + for (const id of extractIds) { + await updateStatus(id, "failed", { + errorMessage: + "Coș ePay nu a putut fi curățat (ștergere neconfirmată) — comanda a fost oprită pentru a evita o plată fără metadate. Reîncearcă.", + }); + } + return null; + } + // Filter to only items that had successful metadata saves const validItems = items.filter((i) => i.basketRowId !== undefined); if (validItems.length === 0) { @@ -506,8 +533,14 @@ async function finalizeOrder( item: QueueItem; doc: (typeof downloadableDocs)[number]; matchedByIndex: boolean; + index: number; }; const plans: Plan[] = []; + // Per-cadastral file index, pre-allocated SEQUENTIALLY so two parallel + // tasks for the same cadastral never collide on the MinIO scan (which is + // a read-modify-write). Seed each distinct cadastral from MinIO once, + // then hand out 1,2,… within this batch. + const nextIndexByCad = new Map(); for (let i = 0; i < validItems.length; i++) { const item = validItems[i]!; const nrCF = item.input.nrCF ?? item.input.nrCadastral; @@ -532,12 +565,19 @@ async function finalizeOrder( }); continue; } - plans.push({ item, doc, matchedByIndex }); + + const cad = item.input.nrCadastral; + let next = nextIndexByCad.get(cad); + if (next === undefined) next = await getNextFileIndex(cad); + nextIndexByCad.set(cad, next + 1); + + plans.push({ item, doc, matchedByIndex, index: next }); } // Step 6: download + store in parallel (bounded). Each task is fully - // self-contained so a failure on one row doesn't abort the others. - await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async ({ item, doc, matchedByIndex }) => { + // self-contained so a failure on one row doesn't abort the others. The + // file index is pre-allocated above, so parallel stores never overwrite. + await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async ({ item, doc, matchedByIndex, index: fileIndex }) => { try { await updateStatus(item.extractId, "downloading", { idDocument: doc.idDocument, @@ -559,6 +599,7 @@ async function finalizeOrder( stare: finalStatus.status, produs: "EXI_ONLINE", }, + fileIndex, ); if (!doc.dataDocument) { diff --git a/src/modules/parcel-sync/services/epay-storage.ts b/src/modules/parcel-sync/services/epay-storage.ts index 2225969..6bc134d 100644 --- a/src/modules/parcel-sync/services/epay-storage.ts +++ b/src/modules/parcel-sync/services/epay-storage.ts @@ -83,15 +83,25 @@ export function buildFileName( /** * Store a CF extract PDF in MinIO. * Returns the MinIO path and file index. + * + * Pass `explicitIndex` to skip the MinIO scan and use a caller-allocated + * index. Required when storing concurrently (parallel downloads): the scan + * is an unsynchronised read-modify-write, so two tasks for the same + * cadastral would both compute index 1 and the second putObject would + * silently overwrite the first. The caller pre-allocates distinct indices. */ export async function storeCfExtract( pdfBuffer: Buffer, nrCadastral: string, metadata: Record, + explicitIndex?: number, ): Promise<{ path: string; fileName: string; index: number }> { await ensureAncpiBucket(); - const index = await getNextFileIndex(nrCadastral); + const index = + explicitIndex !== undefined + ? explicitIndex + : await getNextFileIndex(nrCadastral); const fileName = buildFileName(index, nrCadastral, new Date()); // Store in subfolder per cadastral number const path = `parcele/${nrCadastral}/${fileName}`;