From 588e4344e7a290f5a68e4a570cd268141aefefee Mon Sep 17 00:00:00 2001 From: Claude VM Date: Sun, 24 May 2026 00:37:15 +0300 Subject: [PATCH] fix(cf): merge ePay + intern extracts into a single Extrase CF list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yesterday's pin to /api/ancpi exposed a real architecture split: there are two CfExtract stores with no overlap and the previous pilot routing only ever showed one at a time: architools_postgres.CfExtract → ePay paid orders (type=epay) gis_core.CfExtract via gis-api → CF intern (type=intern) The pin made today's 50198 ePay visible but hid the 51 historic intern rows; the pre-pin state was the opposite. Neither was right — users think of "my CF extracts" as one timeline regardless of source. Revert the pin and add client-side merge for pilot users (`useGisAc=true`): fetchCfOrdersList now fans out to both /api/ancpi/orders and /api/cf/orders in parallel, normalizes each row through a dedicated adapter (legacy or gisApi), dedupes by id, and sorts by createdAt descending. fetchCfHas- CompletedForCadastral checks both backends too (either a fresh intern or a recent ePay row means "you already have one"). CfExtractRecord grows a required `type: 'epay' | 'intern'` field; the existing rendering adds a small colored badge (sky=intern, emerald=ePay) next to the status pill so users can tell where each row came from at a glance. cfDownloadUrl is now type-aware — intern rows download via /api/cf/:id/pdf, ePay rows via /api/ancpi/download regardless of pilot flag, matching how each store keeps its files. Legacy (useGisAc, id) signature still works for the few call sites that don't have the full row in scope. No data was deleted yesterday; the 51 intern rows in gis_core stayed intact (verified via gis_superuser). The single edit was cancelling the stuck 354686 pending row from 2026-05-19. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../parcel-sync/components/cf-api-base.ts | 260 +++++++++++++----- .../parcel-sync/components/epay-tab.tsx | 39 ++- 2 files changed, 218 insertions(+), 81 deletions(-) diff --git a/src/modules/parcel-sync/components/cf-api-base.ts b/src/modules/parcel-sync/components/cf-api-base.ts index 2e69413..4d2f070 100644 --- a/src/modules/parcel-sync/components/cf-api-base.ts +++ b/src/modules/parcel-sync/components/cf-api-base.ts @@ -1,33 +1,29 @@ // Plan 003, Faza F — feature-flag glue for ePay/CF UI. // -// The legacy backend lives under `/api/ancpi/*` and is backed by the local -// Prisma CfExtract table. The new backend lives under `/api/cf/*` and is -// backed by gisApi.enrichment.cf.* (api.gis.ac, RLS-filtered). +// Two backends with overlapping but distinct semantics: // -// Pilot users (m.tarau@beletage.ro for now) carry `session.useGisAc === true` -// and we route their UI through the new endpoints. Everyone else keeps the -// legacy path unchanged. +// /api/ancpi/* → architools_postgres.CfExtract → ePay paid orders +// (`type=epay`, 1 credit each, written by the local ePay queue). +// /api/cf/* → gis-api → gis_core.CfExtract → CF intern downloads +// (`type=intern`, free copycf flow, plus historic non-ePay rows). // -// Response shapes differ between the two backends. Components keep the -// existing UI shape (CfExtractRecord) and call `adaptCfRow` to convert -// gisApi.CfExtractRow → CfExtractRecord on read. +// They are NOT the same dataset. A user's complete CF history is the +// union. Pilot users (`useGisAc=true`) merge both client-side so the +// Extrase CF tab shows one timeline. Non-pilot users see only the +// legacy ePay flow (their gis_core rows are typically empty anyway — +// the cf-intern path is wired through gis-api which only pilots use). import type { CfExtractRow } from "@/lib/gis-api-client"; -export function cfApiBase(_useGisAc: boolean): string { - // Temporarily pinned to legacy /api/ancpi until Faza H lands. - // Background: pilot users (useGisAc=true) were routed to /api/cf - // which proxies to gis-api → gis_core."CfExtract". But the ePay queue - // still writes only to architools_postgres."CfExtract" (legacy). Pilot - // users were missing their own fresh orders in the listing + catalog - // checks. Until the queue mirrors writes to gis-api, every caller - // uses the legacy endpoint so the read source matches the write source. - return "/api/ancpi"; +export function cfApiBase(useGisAc: boolean): string { + return useGisAc ? "/api/cf" : "/api/ancpi"; } // UI-side row shape (mirrors what epay-tab.tsx already expects). export type CfExtractRecord = { id: string; + /** epay | intern — drives the source badge + download URL routing. */ + type: "epay" | "intern"; orderId: string | null; nrCadastral: string; nrCF: string | null; @@ -47,12 +43,61 @@ export type CfExtractRecord = { completedAt: string | null; }; -// Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape. -// Fields not exposed by gis-api are filled with safe defaults so the UI -// keeps rendering without conditional branches. -export function adaptCfRow(row: CfExtractRow): CfExtractRecord { +type LegacyCfExtract = { + id: string; + type?: string | null; + orderId?: string | null; + nrCadastral: string; + nrCF?: string | null; + siruta?: string | null; + judetName?: string | null; + uatName?: string | null; + status: string; + epayStatus?: string | null; + documentName?: string | null; + documentDate?: string | null; + minioPath?: string | null; + expiresAt?: string | null; + errorMessage?: string | null; + version?: number | null; + creditsUsed?: number | null; + createdAt: string; + completedAt?: string | null; +}; + +function adaptLegacyRow(row: LegacyCfExtract): CfExtractRecord { return { id: row.id, + type: row.type === "intern" ? "intern" : "epay", + orderId: row.orderId ?? null, + nrCadastral: row.nrCadastral, + nrCF: row.nrCF ?? null, + siruta: row.siruta ?? null, + judetName: row.judetName ?? "", + uatName: row.uatName ?? "", + status: row.status, + epayStatus: row.epayStatus ?? null, + documentName: row.documentName ?? null, + documentDate: row.documentDate ?? null, + minioPath: row.minioPath ?? null, + expiresAt: row.expiresAt ?? null, + errorMessage: row.errorMessage ?? null, + version: row.version ?? 1, + creditsUsed: row.creditsUsed ?? 0, + createdAt: row.createdAt, + completedAt: row.completedAt ?? null, + }; +} + +// Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape. +// gis-api currently doesn't surface uatName/siruta/judetName on the list +// endpoint, so we leave them blank; the row type defaults to "intern" +// because gis_core's CfExtract is the cf-intern store (the cutover plan +// hasn't yet moved ePay writes here). +export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractRecord { + return { + id: row.id, + type: row.type === "epay" ? "epay" : "intern", orderId: row.orderId ?? null, nrCadastral: row.nrCadastral, nrCF: row.nrCF ?? null, @@ -73,67 +118,127 @@ export function adaptCfRow(row: CfExtractRow): CfExtractRecord { }; } +async function fetchLegacy( + params: { limit?: number; nrCadastral?: string; status?: string }, +): Promise<{ orders: CfExtractRecord[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + if (params.status) qs.set("status", params.status); + if (params.nrCadastral) qs.set("nrCadastral", params.nrCadastral); + const url = `/api/ancpi/orders${qs.toString() ? `?${qs.toString()}` : ""}`; + const res = await fetch(url); + const data = (await res.json()) as { orders?: LegacyCfExtract[]; total?: number }; + return { + orders: (data.orders ?? []).map(adaptLegacyRow), + total: data.total ?? 0, + }; +} + +async function fetchGisAc( + params: { limit?: number; status?: string }, +): Promise<{ orders: CfExtractRecord[]; total: number }> { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + if (params.status) qs.set("status", params.status); + const url = `/api/cf/orders${qs.toString() ? `?${qs.toString()}` : ""}`; + const res = await fetch(url); + const data = (await res.json()) as { + rows?: Array; + total?: number; + }; + const rows = data.rows ?? []; + return { + orders: rows.map(adaptCfRow), + total: data.total ?? rows.length, + }; +} + // Fetch the orders list and normalize the response into a single shape. -// Caller passes `useGisAc` (already read from useSession()). +// Pilot users (`useGisAc=true`) merge the two backends client-side so a +// single timeline shows ePay + intern history together. Sort newest- +// first; dedupe by id (in case the same record ever lands in both +// stores during the cutover migration). export async function fetchCfOrdersList( useGisAc: boolean, params: { limit?: number; nrCadastral?: string; status?: string } = {}, ): Promise<{ orders: CfExtractRecord[]; total: number }> { - const base = cfApiBase(useGisAc); - const qs = new URLSearchParams(); - if (params.limit !== undefined) qs.set("limit", String(params.limit)); - if (params.status) qs.set("status", params.status); - // Legacy endpoint supports ?nrCadastral filter; new endpoint does not. - // The new flow uses the catalog endpoint instead for the single-cadastral - // pre-check (see fetchCfHasCompletedForCadastral below). - if (!useGisAc && params.nrCadastral) - qs.set("nrCadastral", params.nrCadastral); - const url = `${base}/orders${qs.toString() ? `?${qs.toString()}` : ""}`; - const res = await fetch(url); - const data = (await res.json()) as - | { orders?: CfExtractRecord[]; total?: number } // legacy - | { rows?: CfExtractRow[]; total?: number }; // gis-ac - if (useGisAc) { - const rows = (data as { rows?: CfExtractRow[] }).rows ?? []; - return { - orders: rows.map(adaptCfRow), - total: (data as { total?: number }).total ?? rows.length, - }; + if (!useGisAc) { + return fetchLegacy(params); } + + // Pull more rows from each side than the caller asked for so that the + // merge+truncate keeps a representative window from both backends. + const fetchLimit = params.limit ? Math.min(params.limit * 2, 400) : undefined; + const fetchParams = { ...params, limit: fetchLimit }; + + const [legacy, gisac] = await Promise.allSettled([ + fetchLegacy(fetchParams), + fetchGisAc(fetchParams), + ]); + + const merged: CfExtractRecord[] = []; + if (legacy.status === "fulfilled") merged.push(...legacy.value.orders); + if (gisac.status === "fulfilled") merged.push(...gisac.value.orders); + + const seen = new Set(); + const dedup = merged.filter((r) => { + if (seen.has(r.id)) return false; + seen.add(r.id); + return true; + }); + + dedup.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); + + const total = + (legacy.status === "fulfilled" ? legacy.value.total : 0) + + (gisac.status === "fulfilled" ? gisac.value.total : 0); + return { - orders: (data as { orders?: CfExtractRecord[] }).orders ?? [], - total: (data as { total?: number }).total ?? 0, + orders: params.limit ? dedup.slice(0, params.limit) : dedup, + total, }; } -// Existence check used by the per-parcel order button. On the legacy -// backend this hits /api/ancpi/orders?nrCadastral=…&status=completed; on -// the gis-ac backend it uses the catalog endpoint (cheaper, RLS-safe). +// Existence check used by the per-parcel order button. We check both +// backends on pilot accounts — a fresh cf-intern in gis_core OR an ePay +// extract in architools_postgres both mean "you already have one". export async function fetchCfHasCompletedForCadastral( useGisAc: boolean, nrCadastral: string, ): Promise { + const checks: Array> = []; + // Always check legacy (ePay store) — every user can have ePay rows. + checks.push( + (async () => { + try { + const res = await fetch( + `/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrCadastral)}&status=completed&limit=1`, + ); + const data = (await res.json()) as { total?: number }; + return Boolean(data?.total && data.total > 0); + } catch { + return false; + } + })(), + ); if (useGisAc) { - try { - const res = await fetch( - `/api/cf/catalog?nrCadastral=${encodeURIComponent(nrCadastral)}`, - ); - if (!res.ok) return false; - const data = (await res.json()) as { isFresh?: boolean }; - return Boolean(data?.isFresh); - } catch { - return false; - } - } - try { - const res = await fetch( - `/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrCadastral)}&status=completed&limit=1`, + checks.push( + (async () => { + try { + const res = await fetch( + `/api/cf/catalog?nrCadastral=${encodeURIComponent(nrCadastral)}`, + ); + if (!res.ok) return false; + const data = (await res.json()) as { isFresh?: boolean }; + return Boolean(data?.isFresh); + } catch { + return false; + } + })(), ); - const data = (await res.json()) as { total?: number }; - return Boolean(data?.total && data.total > 0); - } catch { - return false; } + const results = await Promise.all(checks); + return results.some(Boolean); } // Per-parcel order placement. The two endpoints take different body @@ -204,8 +309,23 @@ export async function placeCfOrder( } } -// Per-row download URL. Legacy path returns a Minio-backed PDF; new path -// streams via /api/cf/[id]/pdf. -export function cfDownloadUrl(useGisAc: boolean, id: string): string { - return useGisAc ? `/api/cf/${encodeURIComponent(id)}/pdf` : `/api/ancpi/download?id=${id}`; +// Per-row download URL. Two call shapes: +// - cfDownloadUrl(row) — new, type-aware (preferred). +// Routes intern → /api/cf, ePay → /api/ancpi regardless of pilot flag. +// - cfDownloadUrl(useGisAc, id) — legacy, falls back to the +// old per-pilot split. Kept for unchanged call sites that don't +// have the full row. +export function cfDownloadUrl( + a: { id: string; type?: "epay" | "intern" } | boolean, + b?: string, +): string { + if (typeof a === "boolean") { + const id = b ?? ""; + return a + ? `/api/cf/${encodeURIComponent(id)}/pdf` + : `/api/ancpi/download?id=${id}`; + } + return a.type === "intern" + ? `/api/cf/${encodeURIComponent(a.id)}/pdf` + : `/api/ancpi/download?id=${a.id}`; } diff --git a/src/modules/parcel-sync/components/epay-tab.tsx b/src/modules/parcel-sync/components/epay-tab.tsx index 174292a..ccbe28f 100644 --- a/src/modules/parcel-sync/components/epay-tab.tsx +++ b/src/modules/parcel-sync/components/epay-tab.tsx @@ -670,15 +670,32 @@ export function EpayTab() { {/* Status */} - - {badge.label} - +
+ + {badge.label} + + + {order.type === "intern" ? "intern" : "ePay"} + +
{order.errorMessage && (

@@ -756,7 +773,7 @@ export function EpayTab() { asChild >