Files
ArchiTools/src/modules/parcel-sync/components/cf-api-base.ts
T
Claude VM 077ec401fb guard(epay): force legacy queue for paid CF orders — gis-api has no fulfiller yet
gis-api POST /enrichment/cf only inserts a pending row; no orchestrator
worker executes the ePay purchase, so pilot-flag orders silently never
complete. EPAY_ORDERING_VIA_GIS_AC=false routes all paid orders through
/api/ancpi/order and restores the connected+credits gating on the
per-parcel button. Flip the constant after the orchestrator ePay worker
ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:45:55 +03:00

343 lines
12 KiB
TypeScript

// Plan 003, Faza F — feature-flag glue for ePay/CF UI.
//
// Two backends with overlapping but distinct semantics:
//
// /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).
//
// 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";
// GUARD (2026-06-04): ePay ordering via gis-api is NOT live yet. gis-api's
// POST /enrichment/cf only inserts a pending CfExtract row — there is no
// orchestrator-side worker that executes the paid ePay purchase, so orders
// placed through /api/cf/order silently never complete (UI even shows
// "Extras CF valid"). Until that fulfiller ships and is verified, ALL paid
// ePay orders MUST go through the legacy local queue (/api/ancpi/order).
// Flip to true only after the orchestrator ePay worker is deployed.
export const EPAY_ORDERING_VIA_GIS_AC = false;
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;
siruta: string | null;
judetName: string;
uatName: string;
status: string;
epayStatus: string | null;
documentName: string | null;
documentDate: string | null;
minioPath: string | null;
expiresAt: string | null;
errorMessage: string | null;
version: number;
creditsUsed: number;
createdAt: string;
completedAt: string | null;
};
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,
siruta: null,
judetName: "",
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: null,
version: 1,
creditsUsed: 0,
createdAt: row.createdAt,
completedAt: row.completedAt ?? null,
};
}
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<CfExtractRow & { type?: string }>;
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.
// 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 }> {
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<string>();
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: params.limit ? dedup.slice(0, params.limit) : dedup,
total,
};
}
// 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<boolean> {
const checks: Array<Promise<boolean>> = [];
// 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) {
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 results = await Promise.all(checks);
return results.some(Boolean);
}
// Per-parcel order placement. The two endpoints take different body
// shapes; we normalize at the call site here.
export async function placeCfOrder(
useGisAc: boolean,
parcel: {
nrCadastral: string;
nrCF?: string | null;
siruta?: string | null;
judetName: string;
uatName: string;
gisFeatureId?: string;
},
): Promise<{ ok: boolean; error?: string }> {
// See EPAY_ORDERING_VIA_GIS_AC — gis-api can't fulfill paid orders yet,
// so the pilot flag alone must NOT route ordering away from the queue.
if (useGisAc && EPAY_ORDERING_VIA_GIS_AC) {
try {
const res = await fetch("/api/cf/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nrCadastral: parcel.nrCadastral,
siruta: parcel.siruta ?? undefined,
gisFeatureId: parcel.gisFeatureId,
}),
});
const data = (await res.json()) as { error?: string };
// 409 catalog_hit is a "soft success": the user already has (or shares
// access to) a fresh extract for this cadastral. The UI should reflect
// that as "Extras CF valid", same as if we just created one.
if (res.status === 409 && data?.error === "catalog_hit") {
return { ok: true };
}
if (!res.ok || data.error) {
return { ok: false, error: data.error ?? "Eroare comanda" };
}
return { ok: true };
} catch {
return { ok: false, error: "Eroare retea" };
}
}
// Legacy path — preserved verbatim from the existing component.
try {
const res = await fetch("/api/ancpi/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parcels: [
{
nrCadastral: parcel.nrCadastral,
nrCF: parcel.nrCF ?? null,
siruta: parcel.siruta ?? null,
judetIndex: 0,
judetName: parcel.judetName,
uatId: 0,
uatName: parcel.uatName,
},
],
}),
});
const data = (await res.json()) as { error?: string };
if (!res.ok || data.error) {
return { ok: false, error: data.error ?? "Eroare comanda" };
}
return { ok: true };
} catch {
return { ok: false, error: "Eroare retea" };
}
}
// 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}`;
}