fix(parcel-sync): robust workspace resolution with direct nomen lookup

- Add fetchNomenByPk() to EterraClient  single nomen entry lookup
- resolveWorkspace() now tries fast path first: direct nomen lookup for
  SIRUTA  walk parentNomenPk chain to find COUNTY (1-3 calls vs 42+)
- Falls back to full county scan only if direct lookup fails
- Search route: DB lookup as middle fallback between workspacePk and resolve
- Debug logging to trace workspace resolution on production
- Fix: try all possible UAT identifier fields (nomenPk, siruta, code, pk)
This commit is contained in:
AI Assistant
2026-03-06 21:09:22 +02:00
parent ec5a866673
commit 1b72d641cd
3 changed files with 118 additions and 38 deletions
+80 -6
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,6 +31,17 @@ globalRef.__eterraWorkspaceCache = workspaceCache;
* until we find one whose nomenPk matches the SIRUTA. * until we find one whose nomenPk matches the SIRUTA.
* Results are cached globally (survives hot-reload). * Results are cached globally (survives hot-reload).
*/ */
/**
* Resolve eTerra workspace nomenPk for a given SIRUTA.
*
* Strategy (fast path first):
* 1. Check in-memory cache
* 2. Direct nomen lookup: GET /api/adm/nomen/{siruta} → read parentNomenPk
* then GET parent to check if it's a COUNTY → that's the workspace
* 3. Full scan: fetch all counties → their UATs (slow fallback, ~42 calls)
*
* Results are cached globally.
*/
async function resolveWorkspace( async function resolveWorkspace(
client: EterraClient, client: EterraClient,
siruta: string, siruta: string,
@@ -37,6 +49,40 @@ async function resolveWorkspace(
const cached = workspaceCache.get(siruta); const cached = workspaceCache.get(siruta);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
// Fast path: direct nomen lookup
try {
const nomen = await client.fetchNomenByPk(siruta);
console.log("[resolveWorkspace] direct nomen lookup for", siruta, "→", JSON.stringify(nomen).slice(0, 500));
if (nomen) {
// Walk parent chain to find COUNTY
const parentPk = nomen?.parentNomenPk ?? nomen?.parentPk ?? nomen?.parent?.nomenPk;
if (parentPk) {
const parent = await client.fetchNomenByPk(parentPk);
console.log("[resolveWorkspace] parent nomen:", JSON.stringify(parent).slice(0, 500));
const parentType = String(parent?.nomenType ?? parent?.type ?? "").toUpperCase();
if (parentType === "COUNTY" || parentType === "JUDET") {
const countyPk = Number(parent?.nomenPk ?? parentPk);
workspaceCache.set(siruta, countyPk);
return countyPk;
}
// Maybe grandparent is the county (UAT → commune → county?)
const grandparentPk = parent?.parentNomenPk ?? parent?.parentPk;
if (grandparentPk) {
const grandparent = await client.fetchNomenByPk(grandparentPk);
const gpType = String(grandparent?.nomenType ?? grandparent?.type ?? "").toUpperCase();
if (gpType === "COUNTY" || gpType === "JUDET") {
const countyPk = Number(grandparent?.nomenPk ?? grandparentPk);
workspaceCache.set(siruta, countyPk);
return countyPk;
}
}
}
}
} catch {
// Direct lookup failed — continue to full scan
}
// Slow fallback: iterate all counties and their UATs
try { try {
const counties = await client.fetchCounties(); const counties = await client.fetchCounties();
for (const county of counties) { for (const county of counties) {
@@ -45,12 +91,17 @@ async function resolveWorkspace(
try { try {
const uats = await client.fetchAdminUnitsByCounty(countyPk); const uats = await client.fetchAdminUnitsByCounty(countyPk);
for (const uat of uats) { for (const uat of uats) {
const uatPk = String(uat?.nomenPk ?? uat?.pk ?? ""); // Try all possible identifier fields
if (uatPk) { const candidates = [
workspaceCache.set(uatPk, Number(countyPk)); uat?.nomenPk,
uat?.siruta,
uat?.code,
uat?.pk,
].filter(Boolean).map(String);
for (const c of candidates) {
workspaceCache.set(c, Number(countyPk));
} }
} }
// Check if our SIRUTA is now resolved
const resolved = workspaceCache.get(siruta); const resolved = workspaceCache.get(siruta);
if (resolved !== undefined) return resolved; if (resolved !== undefined) return resolved;
} catch { } catch {
@@ -58,7 +109,7 @@ async function resolveWorkspace(
} }
} }
} catch { } catch {
// fallback: can't fetch counties // Can't fetch counties
} }
return null; return null;
} }
@@ -187,10 +238,33 @@ export async function POST(req: Request) {
const client = await EterraClient.create(username, password); const client = await EterraClient.create(username, password);
// Use provided workspacePk — or fall back to resolution // Use provided workspacePk — or look up from DB — or resolve from eTerra
let workspaceId = body.workspacePk ?? null; let workspaceId = body.workspacePk ?? null;
console.log(
"[search] siruta:",
siruta,
"body.workspacePk:",
body.workspacePk,
"workspaceId:",
workspaceId,
);
if (!workspaceId || !Number.isFinite(workspaceId)) { if (!workspaceId || !Number.isFinite(workspaceId)) {
// Try DB lookup first (cheap)
try {
const dbUat = await prisma.gisUat.findUnique({
where: { siruta },
select: { workspacePk: true },
});
console.log("[search] DB lookup result:", dbUat);
if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk;
} catch (e) {
console.log("[search] DB lookup error:", e);
}
}
if (!workspaceId || !Number.isFinite(workspaceId)) {
console.log("[search] falling back to resolveWorkspace...");
workspaceId = await resolveWorkspace(client, siruta); workspaceId = await resolveWorkspace(client, siruta);
console.log("[search] resolveWorkspace result:", workspaceId);
} }
if (!workspaceId) { if (!workspaceId) {
return NextResponse.json( return NextResponse.json(
@@ -328,21 +328,17 @@ export function ParcelSyncModule() {
// Load UATs from local DB (fast — no eTerra needed) // Load UATs from local DB (fast — no eTerra needed)
fetch("/api/eterra/uats") fetch("/api/eterra/uats")
.then((res) => res.json()) .then((res) => res.json())
.then( .then((data: { uats?: UatEntry[] }) => {
(data: { if (data.uats && data.uats.length > 0) {
uats?: UatEntry[]; setUatData(data.uats);
}) => { } else {
if (data.uats && data.uats.length > 0) { // DB empty — fall back to static uat.json (no county/workspace)
setUatData(data.uats); fetch("/uat.json")
} else { .then((res) => res.json())
// DB empty — fall back to static uat.json (no county/workspace) .then((fallback: UatEntry[]) => setUatData(fallback))
fetch("/uat.json") .catch(() => {});
.then((res) => res.json()) }
.then((fallback: UatEntry[]) => setUatData(fallback)) })
.catch(() => {});
}
},
)
.catch(() => { .catch(() => {
// API failed — fall back to static uat.json // API failed — fall back to static uat.json
fetch("/uat.json") fetch("/uat.json")
@@ -372,23 +368,19 @@ export function ParcelSyncModule() {
// POST triggers sync check — only does full fetch if data changed // POST triggers sync check — only does full fetch if data changed
fetch("/api/eterra/uats", { method: "POST" }) fetch("/api/eterra/uats", { method: "POST" })
.then((res) => res.json()) .then((res) => res.json())
.then( .then((data: { synced?: boolean }) => {
(data: { synced?: boolean }) => { if (data.synced) {
if (data.synced) { // Data changed — reload from DB
// Data changed — reload from DB fetch("/api/eterra/uats")
fetch("/api/eterra/uats") .then((res) => res.json())
.then((res) => res.json()) .then((fresh: { uats?: UatEntry[] }) => {
.then( if (fresh.uats && fresh.uats.length > 0) {
(fresh: { uats?: UatEntry[] }) => { setUatData(fresh.uats);
if (fresh.uats && fresh.uats.length > 0) { }
setUatData(fresh.uats); })
} .catch(() => {});
}, }
) })
.catch(() => {});
}
},
)
.catch(() => {}); .catch(() => {});
}, [session.connected]); }, [session.connected]);
@@ -560,6 +560,20 @@ export class EterraClient {
return this.getRawJson(url); return this.getRawJson(url);
} }
/**
* Fetch a single nomenclature entry by nomenPk.
* Returns { nomenPk, name, parentNomenPk, nomenType, ... } or null
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchNomenByPk(nomenPk: string | number): Promise<any> {
const url = `${BASE_URL}/api/adm/nomen/${nomenPk}`;
try {
return await this.getRawJson(url);
} catch {
return null;
}
}
/** /**
* Fetch administrative units (UATs) under a county workspace. * Fetch administrative units (UATs) under a county workspace.
* Returns array of { nomenPk, name, parentNomenPk, ... } * Returns array of { nomenPk, name, parentNomenPk, ... }