fix(parcel-sync): populate county data during login, not via PATCH
Root cause: PATCH endpoint created a new EterraClient which tried to re-login with expired session → 401. Now county refresh runs immediately after successful login in the session route, using the same authenticated client (fire-and-forget). Component reloads UAT data 5s after connection to pick up fresh county info. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* County refresh — populates GisUat.county from eTerra nomenclature.
|
||||
*
|
||||
* Called with an already-authenticated EterraClient (fire-and-forget
|
||||
* after login), so there's no session expiry risk.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. fetchCounties() → build countyMap (nomenPk → county name)
|
||||
* 2. Phase 1: UATs with workspacePk → instant lookup
|
||||
* 3. Phase 2: enumerate counties → fetchAdminUnitsByCounty() → match by name
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import type { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
|
||||
function normalizeName(s: string): string {
|
||||
return s
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toUpperCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
function titleCase(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractName(entry: any): string {
|
||||
if (!entry || typeof entry !== "object") return "";
|
||||
for (const key of ["name", "nomenName", "label", "denumire", "NAME"]) {
|
||||
const val = entry[key];
|
||||
if (typeof val === "string" && val.trim()) return val.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractCode(entry: any): string {
|
||||
if (!entry || typeof entry !== "object") return "";
|
||||
for (const key of [
|
||||
"code",
|
||||
"sirutaCode",
|
||||
"siruta",
|
||||
"externalCode",
|
||||
"cod",
|
||||
]) {
|
||||
const val = entry[key];
|
||||
if (val != null) {
|
||||
const s = String(val).trim();
|
||||
if (s && /^\d+$/.test(s)) return s;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function unwrapArray(data: any): any[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === "object") {
|
||||
if (Array.isArray(data.content)) return data.content;
|
||||
if (Array.isArray(data.data)) return data.data;
|
||||
if (Array.isArray(data.items)) return data.items;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function refreshCountyData(client: EterraClient): Promise<void> {
|
||||
// Check if refresh is needed
|
||||
const [total, withCounty] = await Promise.all([
|
||||
prisma.gisUat.count(),
|
||||
prisma.gisUat.count({ where: { county: { not: null } } }),
|
||||
]);
|
||||
|
||||
if (total === 0) return;
|
||||
if (withCounty > total * 0.5) {
|
||||
console.log(
|
||||
`[county-refresh] ${withCounty}/${total} already have county, skipping.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[county-refresh] Starting: ${withCounty}/${total} have county.`,
|
||||
);
|
||||
|
||||
// 1. Fetch counties
|
||||
const rawCounties = await client.fetchCounties();
|
||||
const counties = unwrapArray(rawCounties);
|
||||
const countyMap = new Map<number, string>();
|
||||
for (const c of counties) {
|
||||
const pk = Number(c?.nomenPk ?? 0);
|
||||
const name = extractName(c);
|
||||
if (pk > 0 && name) countyMap.set(pk, titleCase(name));
|
||||
}
|
||||
|
||||
if (countyMap.size === 0) {
|
||||
console.error(
|
||||
"[county-refresh] fetchCounties returned 0 counties. Raw sample:",
|
||||
JSON.stringify(rawCounties).slice(0, 500),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[county-refresh] Got ${countyMap.size} counties.`);
|
||||
|
||||
// 2. Load all UATs needing county
|
||||
const allUats = await prisma.gisUat.findMany({
|
||||
where: { county: null },
|
||||
select: { siruta: true, name: true, workspacePk: true },
|
||||
});
|
||||
|
||||
// Phase 1: UATs with workspacePk
|
||||
let phase1 = 0;
|
||||
const needsCounty: Array<{ siruta: string; name: string }> = [];
|
||||
|
||||
for (const uat of allUats) {
|
||||
if (uat.workspacePk && uat.workspacePk > 0) {
|
||||
const county = countyMap.get(uat.workspacePk);
|
||||
if (county) {
|
||||
await prisma.gisUat.update({
|
||||
where: { siruta: uat.siruta },
|
||||
data: { county },
|
||||
});
|
||||
phase1++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
needsCounty.push({ siruta: uat.siruta, name: uat.name });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[county-refresh] Phase 1: ${phase1} via workspacePk. ${needsCounty.length} remaining.`,
|
||||
);
|
||||
|
||||
// Phase 2: enumerate counties → match by code or name
|
||||
const nameToSirutas = new Map<string, string[]>();
|
||||
const sirutaSet = new Set<string>();
|
||||
for (const u of needsCounty) {
|
||||
sirutaSet.add(u.siruta);
|
||||
const key = normalizeName(u.name);
|
||||
const arr = nameToSirutas.get(key);
|
||||
if (arr) arr.push(u.siruta);
|
||||
else nameToSirutas.set(key, [u.siruta]);
|
||||
}
|
||||
|
||||
let phase2 = 0;
|
||||
let codeMatches = 0;
|
||||
let nameMatches = 0;
|
||||
const matched = new Set<string>();
|
||||
|
||||
for (const [countyPk, countyName] of countyMap) {
|
||||
if (matched.size >= needsCounty.length) break;
|
||||
|
||||
try {
|
||||
const rawUats = await client.fetchAdminUnitsByCounty(countyPk);
|
||||
const uats = unwrapArray(rawUats);
|
||||
|
||||
// Log first response for debugging
|
||||
if (phase2 === 0 && codeMatches === 0 && nameMatches === 0 && uats.length > 0) {
|
||||
console.log(
|
||||
`[county-refresh] Sample from ${countyName} (${uats.length} UATs):`,
|
||||
JSON.stringify(uats[0]).slice(0, 500),
|
||||
);
|
||||
}
|
||||
|
||||
for (const uat of uats) {
|
||||
// Strategy A: match by code
|
||||
const code = extractCode(uat);
|
||||
if (code && sirutaSet.has(code) && !matched.has(code)) {
|
||||
matched.add(code);
|
||||
await prisma.gisUat.update({
|
||||
where: { siruta: code },
|
||||
data: { county: countyName, workspacePk: countyPk },
|
||||
});
|
||||
phase2++;
|
||||
codeMatches++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strategy B: match by name
|
||||
const eterraName = extractName(uat);
|
||||
if (!eterraName) continue;
|
||||
const key = normalizeName(eterraName);
|
||||
const sirutas = nameToSirutas.get(key);
|
||||
if (!sirutas || sirutas.length === 0) continue;
|
||||
|
||||
const siruta = sirutas.find((s) => !matched.has(s));
|
||||
if (!siruta) continue;
|
||||
|
||||
matched.add(siruta);
|
||||
await prisma.gisUat.update({
|
||||
where: { siruta },
|
||||
data: { county: countyName, workspacePk: countyPk },
|
||||
});
|
||||
phase2++;
|
||||
nameMatches++;
|
||||
|
||||
if (sirutas.every((s) => matched.has(s))) {
|
||||
nameToSirutas.delete(key);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[county-refresh] County ${countyName} failed:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[county-refresh] Done: ${phase1 + phase2} updated ` +
|
||||
`(phase1=${phase1}, phase2=${phase2}: ${codeMatches} code, ${nameMatches} name). ` +
|
||||
`Unmatched: ${needsCounty.length - phase2}.`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user