feat: quality analysis for no-geom parcels + raport_calitate.txt

Scan phase:
- qualityBreakdown on NoGeomScanResult: withCadRef, withPaperCad,
  withPaperCf, withArea, useful vs empty counts
- UI scan card shows quality grid before deciding to export

Export phase:
- Comprehensive enrichment quality analysis: owners, CF, address,
  area, category, building — split by with-geom vs no-geom
- raport_calitate.txt in ZIP: human-readable Romanian report with
  per-category breakdowns and percentage stats
- export_report.json includes full qualityAnalysis object
- Progress completion note shows quality summary inline
This commit is contained in:
AI Assistant
2026-03-07 19:23:57 +02:00
parent 53914c7fc3
commit 681b52e816
3 changed files with 334 additions and 11 deletions
+181 -11
View File
@@ -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<string, unknown> | 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<string, unknown> = {
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);
@@ -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<string, unknown>;
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
</span>
</label>
{/* Quality breakdown of no-geom items */}
{scanDone && noGeomScan.noGeomCount > 0 && (
<div className="ml-7 p-2 rounded-md bg-muted/40 space-y-1">
<p className="text-[11px] font-medium text-muted-foreground">
Calitate date (din{" "}
{noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără
geometrie):
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-[11px] text-muted-foreground">
<span>
Cu nr. cadastral eTerra:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withCadRef.toLocaleString(
"ro-RO",
)}
</span>
</span>
<span>
Cu nr. CF pe hârtie:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withPaperCf.toLocaleString(
"ro-RO",
)}
</span>
</span>
<span>
Cu nr. cad. pe hârtie:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withPaperCad.toLocaleString(
"ro-RO",
)}
</span>
</span>
<span>
Cu suprafață:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withArea.toLocaleString(
"ro-RO",
)}
</span>
</span>
</div>
<div className="flex items-center gap-3 text-[11px] pt-0.5 border-t border-muted-foreground/10">
<span>
Utilizabile:{" "}
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{noGeomScan.qualityBreakdown.useful.toLocaleString(
"ro-RO",
)}
</span>
</span>
{noGeomScan.qualityBreakdown.empty > 0 && (
<span>
Fără date identificare:{" "}
<span className="font-semibold text-rose-600 dark:text-rose-400">
{noGeomScan.qualityBreakdown.empty.toLocaleString(
"ro-RO",
)}
</span>
</span>
)}
</div>
</div>
)}
{includeNoGeom && (
<p className="text-[11px] text-muted-foreground ml-7">
Vor fi importate în DB și incluse în CSV + Magic GPKG
@@ -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<number, number>();
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,