077ec401fb
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>
343 lines
12 KiB
TypeScript
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}`;
|
|
}
|