diff --git a/src/app/api/eterra/export-bundle/route.ts b/src/app/api/eterra/export-bundle/route.ts index b78ad72..0c4d5fb 100644 --- a/src/app/api/eterra/export-bundle/route.ts +++ b/src/app/api/eterra/export-bundle/route.ts @@ -540,6 +540,84 @@ export async function POST(req: Request) { zip.file("terenuri.gpkg", terenuriGpkg); zip.file("cladiri.gpkg", cladiriGpkg); + // ── Comprehensive quality analysis ── + const withGeomRecords = dbTerenuri.filter( + (r) => + (r as unknown as { geometrySource: string | null }).geometrySource !== + "NO_GEOMETRY", + ); + const noGeomRecords = dbTerenuri.filter( + (r) => + (r as unknown as { geometrySource: string | null }).geometrySource === + "NO_GEOMETRY", + ); + + const analyzeRecords = (records: typeof dbTerenuri) => { + let enriched = 0; + let withOwners = 0; + let withOldOwners = 0; + let withCF = 0; + let withAddress = 0; + let withArea = 0; + let withCategory = 0; + let withBuilding = 0; + let complete = 0; + let partial = 0; + let empty = 0; + + for (const r of records) { + const e = r.enrichment as Record | null; + if (!e) { + empty++; + continue; + } + enriched++; + const hasOwners = !!e.PROPRIETARI && e.PROPRIETARI !== "-"; + const hasOldOwners = + !!e.PROPRIETARI_VECHI && String(e.PROPRIETARI_VECHI).trim() !== ""; + const hasCF = !!e.NR_CF && e.NR_CF !== "-"; + const hasAddr = !!e.ADRESA && e.ADRESA !== "-"; + const hasArea = + e.SUPRAFATA_2D != null && + e.SUPRAFATA_2D !== "" && + Number(e.SUPRAFATA_2D) > 0; + const hasCat = !!e.CATEGORIE_FOLOSINTA && e.CATEGORIE_FOLOSINTA !== "-"; + const hasBuild = Number(e.HAS_BUILDING ?? 0) === 1; + + if (hasOwners) withOwners++; + if (hasOldOwners) withOldOwners++; + if (hasCF) withCF++; + if (hasAddr) withAddress++; + if (hasArea) withArea++; + if (hasCat) withCategory++; + if (hasBuild) withBuilding++; + + // "Complete" = has at least owners + CF + area + if (hasOwners && hasCF && hasArea) complete++; + else if (hasOwners || hasCF || hasAddr || hasArea || hasCat) partial++; + else empty++; + } + + return { + total: records.length, + enriched, + withOwners, + withOldOwners, + withCF, + withAddress, + withArea, + withCategory, + withBuilding, + complete, + partial, + empty, + }; + }; + + const qualityAll = analyzeRecords(dbTerenuri); + const qualityGeom = analyzeRecords(withGeomRecords); + const qualityNoGeom = analyzeRecords(noGeomRecords); + const report: Record = { siruta: validated.siruta, generatedAt: new Date().toISOString(), @@ -547,11 +625,7 @@ export async function POST(req: Request) { terenuri: { count: terenuriGeoFeatures.length, totalInDb: dbTerenuri.length, - noGeometryCount: dbTerenuri.filter( - (r) => - (r as unknown as { geometrySource: string | null }) - .geometrySource === "NO_GEOMETRY", - ).length, + noGeometryCount: noGeomRecords.length, }, cladiri: { count: cladiriGeoFeatures.length }, syncSkipped: { @@ -560,6 +634,11 @@ export async function POST(req: Request) { }, includeNoGeometry: hasNoGeom, noGeomImported, + qualityAnalysis: { + all: qualityAll, + withGeometry: qualityGeom, + noGeometry: qualityNoGeom, + }, }; if (validated.mode === "magic" && magicGpkg && csvContent) { @@ -570,6 +649,89 @@ export async function POST(req: Request) { hasBuildingCount, legalBuildingCount, }; + + // Generate human-readable quality report (Romanian) + const pct = (n: number, total: number) => + total > 0 ? `${((n / total) * 100).toFixed(1)}%` : "0%"; + const fmt = (n: number) => n.toLocaleString("ro-RO"); + + const lines: string[] = [ + `══════════════════════════════════════════════════════════`, + ` RAPORT CALITATE DATE — UAT SIRUTA ${validated.siruta}`, + ` Generat: ${new Date().toISOString().replace("T", " ").slice(0, 19)}`, + `══════════════════════════════════════════════════════════`, + ``, + `REZUMAT GENERAL`, + `─────────────────────────────────────────────────────────`, + ` Total parcele în baza de date: ${fmt(dbTerenuri.length)}`, + ` • Cu geometrie (contur GIS): ${fmt(withGeomRecords.length)}`, + ` • Fără geometrie (doar date): ${fmt(noGeomRecords.length)}`, + ` Clădiri: ${fmt(cladiriGeoFeatures.length)}`, + ``, + `CALITATE ÎMBOGĂȚIRE — TOATE PARCELELE (${fmt(qualityAll.total)})`, + `─────────────────────────────────────────────────────────`, + ` Îmbogățite: ${fmt(qualityAll.enriched)} (${pct(qualityAll.enriched, qualityAll.total)})`, + ` Cu proprietari: ${fmt(qualityAll.withOwners)} (${pct(qualityAll.withOwners, qualityAll.total)})`, + ` Cu prop. vechi: ${fmt(qualityAll.withOldOwners)} (${pct(qualityAll.withOldOwners, qualityAll.total)})`, + ` Cu nr. CF: ${fmt(qualityAll.withCF)} (${pct(qualityAll.withCF, qualityAll.total)})`, + ` Cu adresă: ${fmt(qualityAll.withAddress)} (${pct(qualityAll.withAddress, qualityAll.total)})`, + ` Cu suprafață: ${fmt(qualityAll.withArea)} (${pct(qualityAll.withArea, qualityAll.total)})`, + ` Cu categorie fol.: ${fmt(qualityAll.withCategory)} (${pct(qualityAll.withCategory, qualityAll.total)})`, + ` Cu clădire: ${fmt(qualityAll.withBuilding)} (${pct(qualityAll.withBuilding, qualityAll.total)})`, + ` ────────────────`, + ` Complete (prop+CF+sup): ${fmt(qualityAll.complete)} (${pct(qualityAll.complete, qualityAll.total)})`, + ` Parțiale: ${fmt(qualityAll.partial)} (${pct(qualityAll.partial, qualityAll.total)})`, + ` Goale (fără date): ${fmt(qualityAll.empty)} (${pct(qualityAll.empty, qualityAll.total)})`, + ``, + ]; + + if (withGeomRecords.length > 0) { + lines.push( + `PARCELE CU GEOMETRIE (${fmt(qualityGeom.total)})`, + `─────────────────────────────────────────────────────────`, + ` Îmbogățite: ${fmt(qualityGeom.enriched)} (${pct(qualityGeom.enriched, qualityGeom.total)})`, + ` Cu proprietari: ${fmt(qualityGeom.withOwners)} (${pct(qualityGeom.withOwners, qualityGeom.total)})`, + ` Cu nr. CF: ${fmt(qualityGeom.withCF)} (${pct(qualityGeom.withCF, qualityGeom.total)})`, + ` Cu adresă: ${fmt(qualityGeom.withAddress)} (${pct(qualityGeom.withAddress, qualityGeom.total)})`, + ` Cu suprafață: ${fmt(qualityGeom.withArea)} (${pct(qualityGeom.withArea, qualityGeom.total)})`, + ` Cu categorie fol.: ${fmt(qualityGeom.withCategory)} (${pct(qualityGeom.withCategory, qualityGeom.total)})`, + ` Complete: ${fmt(qualityGeom.complete)} (${pct(qualityGeom.complete, qualityGeom.total)})`, + ` Parțiale: ${fmt(qualityGeom.partial)} (${pct(qualityGeom.partial, qualityGeom.total)})`, + ` Goale: ${fmt(qualityGeom.empty)} (${pct(qualityGeom.empty, qualityGeom.total)})`, + ``, + ); + } + + if (noGeomRecords.length > 0) { + lines.push( + `PARCELE FĂRĂ GEOMETRIE (${fmt(qualityNoGeom.total)})`, + `─────────────────────────────────────────────────────────`, + ` Îmbogățite: ${fmt(qualityNoGeom.enriched)} (${pct(qualityNoGeom.enriched, qualityNoGeom.total)})`, + ` Cu proprietari: ${fmt(qualityNoGeom.withOwners)} (${pct(qualityNoGeom.withOwners, qualityNoGeom.total)})`, + ` Cu nr. CF: ${fmt(qualityNoGeom.withCF)} (${pct(qualityNoGeom.withCF, qualityNoGeom.total)})`, + ` Cu adresă: ${fmt(qualityNoGeom.withAddress)} (${pct(qualityNoGeom.withAddress, qualityNoGeom.total)})`, + ` Cu suprafață: ${fmt(qualityNoGeom.withArea)} (${pct(qualityNoGeom.withArea, qualityNoGeom.total)})`, + ` Cu categorie fol.: ${fmt(qualityNoGeom.withCategory)} (${pct(qualityNoGeom.withCategory, qualityNoGeom.total)})`, + ` Complete: ${fmt(qualityNoGeom.complete)} (${pct(qualityNoGeom.complete, qualityNoGeom.total)})`, + ` Parțiale: ${fmt(qualityNoGeom.partial)} (${pct(qualityNoGeom.partial, qualityNoGeom.total)})`, + ` Goale: ${fmt(qualityNoGeom.empty)} (${pct(qualityNoGeom.empty, qualityNoGeom.total)})`, + ``, + ); + } + + lines.push( + `NOTE`, + `─────────────────────────────────────────────────────────`, + ` • "Complete" = are proprietari + nr. CF + suprafață`, + ` • "Parțiale" = are cel puțin un câmp util`, + ` • "Goale" = niciun câmp de îmbogățire completat`, + ` • Parcelele fără geometrie provin din lista de imobile`, + ` eTerra și nu au contur desenat în layerul GIS.`, + ` • Datele sunt extrase din ANCPI eTerra la data raportului.`, + `══════════════════════════════════════════════════════════`, + ); + + zip.file("raport_calitate.txt", lines.join("\n")); } zip.file("export_report.json", JSON.stringify(report, null, 2)); @@ -580,11 +742,7 @@ export async function POST(req: Request) { finishPhase(); /* Done */ - const noGeomInDb = dbTerenuri.filter( - (r) => - (r as unknown as { geometrySource: string | null }).geometrySource === - "NO_GEOMETRY", - ).length; + const noGeomInDb = noGeomRecords.length; message = `Finalizat 100% · Terenuri ${terenuriGeoFeatures.length} · Clădiri ${cladiriGeoFeatures.length}`; if (noGeomInDb > 0) { message += ` · Fără geometrie ${noGeomInDb}`; @@ -592,9 +750,21 @@ export async function POST(req: Request) { if (!terenuriNeedsSync && !cladiriNeedsSync) { message += " (din cache local)"; } + // Quality summary in note (visible in UI progress card) + if (validated.mode === "magic") { + const qParts: string[] = []; + qParts.push(`Complete: ${qualityAll.complete}/${qualityAll.total}`); + if (qualityAll.partial > 0) + qParts.push(`parțiale: ${qualityAll.partial}`); + if (qualityAll.empty > 0) qParts.push(`goale: ${qualityAll.empty}`); + qParts.push(`prop: ${qualityAll.withOwners}`); + qParts.push(`CF: ${qualityAll.withCF}`); + qParts.push(`sup: ${qualityAll.withArea}`); + note = `Calitate: ${qParts.join(" · ")} — vezi raport_calitate.txt în ZIP`; + } status = "done"; phase = "Finalizat"; - note = undefined; + // note already set with quality summary above (or undefined for base mode) pushProgress(); scheduleClear(jobId); diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 17cbda6..9e24d8a 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -391,6 +391,14 @@ export function ParcelSyncModule() { withGeometry: number; remoteGisCount: number; noGeomCount: number; + qualityBreakdown: { + withCadRef: number; + withPaperCad: number; + withPaperCf: number; + withArea: number; + useful: number; + empty: number; + }; localDbTotal: number; localDbWithGeom: number; localDbNoGeom: number; @@ -693,11 +701,20 @@ export function ParcelSyncModule() { setNoGeomScanning(true); setNoGeomScan(null); setNoGeomScanSiruta(s); + const emptyQuality = { + withCadRef: 0, + withPaperCad: 0, + withPaperCf: 0, + withArea: 0, + useful: 0, + empty: 0, + }; const emptyResult = { totalImmovables: 0, withGeometry: 0, remoteGisCount: 0, noGeomCount: 0, + qualityBreakdown: emptyQuality, localDbTotal: 0, localDbWithGeom: 0, localDbNoGeom: 0, @@ -719,11 +736,20 @@ export function ParcelSyncModule() { console.warn("[no-geom-scan]", data.error); setNoGeomScan(emptyResult); } else { + const qb = (data.qualityBreakdown ?? {}) as Record; setNoGeomScan({ totalImmovables: Number(data.totalImmovables ?? 0), withGeometry: Number(data.withGeometry ?? 0), remoteGisCount: Number(data.remoteGisCount ?? 0), noGeomCount: Number(data.noGeomCount ?? 0), + qualityBreakdown: { + withCadRef: Number(qb.withCadRef ?? 0), + withPaperCad: Number(qb.withPaperCad ?? 0), + withPaperCf: Number(qb.withPaperCf ?? 0), + withArea: Number(qb.withArea ?? 0), + useful: Number(qb.useful ?? 0), + empty: Number(qb.empty ?? 0), + }, localDbTotal: Number(data.localDbTotal ?? 0), localDbWithGeom: Number(data.localDbWithGeom ?? 0), localDbNoGeom: Number(data.localDbNoGeom ?? 0), @@ -2612,6 +2638,70 @@ export function ParcelSyncModule() { Include și parcelele fără geometrie la export + {/* Quality breakdown of no-geom items */} + {scanDone && noGeomScan.noGeomCount > 0 && ( +
+

+ Calitate date (din{" "} + {noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără + geometrie): +

+
+ + Cu nr. cadastral eTerra:{" "} + + {noGeomScan.qualityBreakdown.withCadRef.toLocaleString( + "ro-RO", + )} + + + + Cu nr. CF pe hârtie:{" "} + + {noGeomScan.qualityBreakdown.withPaperCf.toLocaleString( + "ro-RO", + )} + + + + Cu nr. cad. pe hârtie:{" "} + + {noGeomScan.qualityBreakdown.withPaperCad.toLocaleString( + "ro-RO", + )} + + + + Cu suprafață:{" "} + + {noGeomScan.qualityBreakdown.withArea.toLocaleString( + "ro-RO", + )} + + +
+
+ + Utilizabile:{" "} + + {noGeomScan.qualityBreakdown.useful.toLocaleString( + "ro-RO", + )} + + + {noGeomScan.qualityBreakdown.empty > 0 && ( + + Fără date identificare:{" "} + + {noGeomScan.qualityBreakdown.empty.toLocaleString( + "ro-RO", + )} + + + )} +
+
+ )} {includeNoGeom && (

Vor fi importate în DB și incluse în CSV + Magic GPKG diff --git a/src/modules/parcel-sync/services/no-geom-sync.ts b/src/modules/parcel-sync/services/no-geom-sync.ts index 49fc81c..17066fd 100644 --- a/src/modules/parcel-sync/services/no-geom-sync.ts +++ b/src/modules/parcel-sync/services/no-geom-sync.ts @@ -91,6 +91,22 @@ const normalizeId = (value: unknown) => { const normalizeCadRef = (value: unknown) => normalizeId(value).replace(/\s+/g, "").toUpperCase(); +/** Quality breakdown of no-geometry immovables from scan */ +export type NoGeomQuality = { + /** Have electronic cadRef (identifierDetails non-empty) */ + withCadRef: number; + /** Have paper cadastral number */ + withPaperCad: number; + /** Have paper CF (carte funciară) number */ + withPaperCf: number; + /** Have area > 0 */ + withArea: number; + /** "Useful" = have cadRef OR (paperCad AND paperCf) */ + useful: number; + /** No cadRef, no paperCad, no paperCf — likely unusable */ + empty: number; +}; + export type NoGeomScanResult = { totalImmovables: number; /** Immovables that matched a remote GIS feature (have geometry) */ @@ -98,6 +114,8 @@ export type NoGeomScanResult = { /** Total features in the remote ArcGIS TERENURI_ACTIVE layer */ remoteGisCount: number; noGeomCount: number; + /** Quality breakdown of no-geometry items */ + qualityBreakdown: NoGeomQuality; /** Sample of immovable identifiers without geometry */ samples: Array<{ immovablePk: number; @@ -155,6 +173,14 @@ export async function scanNoGeometryParcels( withGeometry: 0, remoteGisCount: 0, noGeomCount: 0, + qualityBreakdown: { + withCadRef: 0, + withPaperCad: 0, + withPaperCf: 0, + withArea: 0, + useful: 0, + empty: 0, + }, samples: [], localDbTotal: 0, localDbWithGeom: 0, @@ -287,11 +313,48 @@ export async function scanNoGeometryParcels( // withGeometry = immovables that MATCHED a GIS feature (always adds up) const matchedCount = allImmovables.length - noGeomItems.length; + // Quality analysis of no-geom items + // Build a quick lookup for area data from the immovable list + const areaByPk = new Map(); + for (const item of allImmovables) { + const pk = Number(item.immovablePk ?? 0); + if (pk > 0 && typeof item.area === "number" && item.area > 0) { + areaByPk.set(pk, item.area); + } + } + + let qWithCadRef = 0; + let qWithPaperCad = 0; + let qWithPaperCf = 0; + let qWithArea = 0; + let qUseful = 0; + let qEmpty = 0; + for (const item of noGeomItems) { + const hasCad = !!item.identifierDetails?.trim(); + const hasPaperCad = !!item.paperCadNo?.trim(); + const hasPaperCf = !!item.paperCfNo?.trim(); + const hasArea = areaByPk.has(item.immovablePk); + if (hasCad) qWithCadRef++; + if (hasPaperCad) qWithPaperCad++; + if (hasPaperCf) qWithPaperCf++; + if (hasArea) qWithArea++; + if (hasCad || (hasPaperCad && hasPaperCf)) qUseful++; + else qEmpty++; + } + return { totalImmovables: allImmovables.length, withGeometry: matchedCount, remoteGisCount: remoteFeatures.length, noGeomCount: noGeomItems.length, + qualityBreakdown: { + withCadRef: qWithCadRef, + withPaperCad: qWithPaperCad, + withPaperCf: qWithPaperCf, + withArea: qWithArea, + useful: qUseful, + empty: qEmpty, + }, samples: noGeomItems.slice(0, 20), localDbTotal: localTotal, localDbWithGeom: localWithGeom,