fix(parcel-sync): paperCfNo bug, status filter, enrichment robustness

BUGS FIXED:
- paperCfNo does NOT exist in eTerra API — field is paperLbNo
  Renamed withPaperCf → withPaperLb everywhere (type, scan, UI)
- Area fields: only measuredArea and legalArea exist on immovable/list
  Removed phantom area/areaValue/suprafata checks from import filter

FILTERING TIGHTENED:
- Quality gate now requires status=1 (active) in eTerra
- Items with status≠1 are filtered out before import
- Quality breakdown adds: withActiveStatus, withLandbook counters
- Import attributes now store MEASURED_AREA, LEGAL_AREA, HAS_LANDBOOK
- workspace.nomenPk used instead of workspacePk for accuracy

ENRICHMENT ROBUSTNESS:
- Area fallback: when AREA_VALUE is missing (no-geom), enrichment
  now falls back to listItem.measuredArea/legalArea from immovable list
- Post-enrichment verification: logs 100% coverage or warns about gaps
- EnrichResult type extended with totalFeatures + unenrichedCount

UI UPDATES:
- Quality grid shows 6 stats: cadRef, CF/LB, paperCad, area, active, landbook
- Filter explanation updated: 'inactive sau fără date' instead of old text
This commit is contained in:
AI Assistant
2026-03-07 20:25:05 +02:00
parent af2631920f
commit f9594fff71
3 changed files with 132 additions and 65 deletions
@@ -394,8 +394,10 @@ export function ParcelSyncModule() {
qualityBreakdown: {
withCadRef: number;
withPaperCad: number;
withPaperCf: number;
withPaperLb: number;
withLandbook: number;
withArea: number;
withActiveStatus: number;
useful: number;
empty: number;
};
@@ -704,8 +706,10 @@ export function ParcelSyncModule() {
const emptyQuality = {
withCadRef: 0,
withPaperCad: 0,
withPaperCf: 0,
withPaperLb: 0,
withLandbook: 0,
withArea: 0,
withActiveStatus: 0,
useful: 0,
empty: 0,
};
@@ -745,8 +749,10 @@ export function ParcelSyncModule() {
qualityBreakdown: {
withCadRef: Number(qb.withCadRef ?? 0),
withPaperCad: Number(qb.withPaperCad ?? 0),
withPaperCf: Number(qb.withPaperCf ?? 0),
withPaperLb: Number(qb.withPaperLb ?? 0),
withLandbook: Number(qb.withLandbook ?? 0),
withArea: Number(qb.withArea ?? 0),
withActiveStatus: Number(qb.withActiveStatus ?? 0),
useful: Number(qb.useful ?? 0),
empty: Number(qb.empty ?? 0),
},
@@ -2663,9 +2669,9 @@ export function ParcelSyncModule() {
</span>
</span>
<span>
Cu nr. CF pe hârtie:{" "}
Cu nr. CF/LB:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withPaperCf.toLocaleString(
{noGeomScan.qualityBreakdown.withPaperLb.toLocaleString(
"ro-RO",
)}
</span>
@@ -2686,6 +2692,22 @@ export function ParcelSyncModule() {
)}
</span>
</span>
<span>
Active (status=1):{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withActiveStatus.toLocaleString(
"ro-RO",
)}
</span>
</span>
<span>
Cu carte funciară:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withLandbook.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>
@@ -2698,7 +2720,7 @@ export function ParcelSyncModule() {
</span>
{noGeomScan.qualityBreakdown.empty > 0 && (
<span>
Fără date identificare:{" "}
Filtrate (inactive/fără date):{" "}
<span className="font-semibold text-rose-600 dark:text-rose-400">
{noGeomScan.qualityBreakdown.empty.toLocaleString(
"ro-RO",
@@ -2712,7 +2734,7 @@ export function ParcelSyncModule() {
{includeNoGeom && (
<p className="text-[11px] text-muted-foreground ml-7">
{noGeomScan.qualityBreakdown.empty > 0
? `Din ${noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără geometrie, ~${noGeomScan.qualityBreakdown.useful.toLocaleString("ro-RO")} vor fi importate (cele cu identificare sau suprafață). ${noGeomScan.qualityBreakdown.empty.toLocaleString("ro-RO")} fără nicio dată de identificare vor fi filtrate.`
? `Din ${noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără geometrie, ~${noGeomScan.qualityBreakdown.useful.toLocaleString("ro-RO")} vor fi importate (active, cu identificare sau suprafață). ${noGeomScan.qualityBreakdown.empty.toLocaleString("ro-RO")} vor fi filtrate (inactive sau fără date).`
: "Vor fi importate în DB și incluse în CSV + Magic GPKG (coloana HAS_GEOMETRY=0/1)."}{" "}
În GPKG de bază apar doar cele cu geometrie.
</p>
@@ -20,6 +20,8 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export type EnrichResult = {
siruta: string;
enrichedCount: number;
totalFeatures?: number;
unenrichedCount?: number;
buildingCrossRefs: number;
status: "done" | "error";
error?: string;
@@ -545,8 +547,21 @@ export async function enrichFeatures(
const buildLegal = build.has ? (build.legal ? 1 : 0) : 0;
if (hasBuilding) buildingCrossRefs += 1;
const areaValue =
// Area: prefer GIS AREA_VALUE, fall back to measuredArea/legalArea from
// immovable list (important for no-geometry features where AREA_VALUE
// may have been stored from measuredArea at import time, or may be null).
let areaValue =
typeof attrs.AREA_VALUE === "number" ? attrs.AREA_VALUE : null;
if (areaValue == null && listItem) {
areaValue =
(typeof listItem.measuredArea === "number" &&
listItem.measuredArea > 0
? listItem.measuredArea
: null) ??
(typeof listItem.legalArea === "number" && listItem.legalArea > 0
? listItem.legalArea
: null);
}
const enrichment: FeatureEnrichment = {
NR_CAD: cadRefRaw,
@@ -585,6 +600,19 @@ export async function enrichFeatures(
}
}
// ── Post-enrichment verification ──
// Check that ALL features now have enrichment (no gaps)
const unenriched = terenuri.length - enrichedCount;
if (unenriched > 0) {
console.warn(
`[enrich] ${unenriched}/${terenuri.length} features remain unenriched for siruta=${siruta}`,
);
} else {
console.log(
`[enrich] ✓ 100% enrichment: ${enrichedCount}/${terenuri.length} features for siruta=${siruta}`,
);
}
push({
phase: "Îmbogățire completă",
status: "done",
@@ -596,6 +624,8 @@ export async function enrichFeatures(
return {
siruta,
enrichedCount,
totalFeatures: terenuri.length,
unenrichedCount: unenriched,
buildingCrossRefs,
status: "done",
};
@@ -97,13 +97,17 @@ export type NoGeomQuality = {
withCadRef: number;
/** Have paper cadastral number */
withPaperCad: number;
/** Have paper CF (carte funciară) number */
withPaperCf: number;
/** Have area > 0 */
/** Have paper LB / CF (carte funciară) number — field is paperLbNo in API */
withPaperLb: number;
/** Have hasLandbook=1 flag from eTerra */
withLandbook: number;
/** Have area > 0 (measuredArea or legalArea) */
withArea: number;
/** "Useful" = have cadRef OR (paperCad AND paperCf) */
/** status=1 (active) in eTerra */
withActiveStatus: number;
/** "Useful" = active AND has identification or area */
useful: number;
/** No cadRef, no paperCad, no paperCf — likely unusable */
/** Filtered out: inactive, or no identification AND no area */
empty: number;
};
@@ -121,8 +125,10 @@ export type NoGeomScanResult = {
immovablePk: number;
identifierDetails: string;
paperCadNo?: string;
paperCfNo?: string;
paperLbNo?: string;
status?: number;
hasLandbook?: number;
measuredArea?: number;
}>;
/** Total features already in local DB (geometry + no-geom) */
localDbTotal: number;
@@ -177,8 +183,10 @@ export async function scanNoGeometryParcels(
qualityBreakdown: {
withCadRef: 0,
withPaperCad: 0,
withPaperCf: 0,
withPaperLb: 0,
withLandbook: 0,
withArea: 0,
withActiveStatus: 0,
useful: 0,
empty: 0,
},
@@ -232,8 +240,11 @@ export async function scanNoGeometryParcels(
immovablePk: number;
identifierDetails: string;
paperCadNo?: string;
paperCfNo?: string;
paperLbNo?: string;
status?: number;
hasLandbook?: number;
measuredArea?: number;
legalArea?: number;
}> = [];
for (const item of allImmovables) {
@@ -251,8 +262,14 @@ export async function scanNoGeometryParcels(
immovablePk: immPk,
identifierDetails: String(item.identifierDetails ?? ""),
paperCadNo: item.paperCadNo ?? undefined,
paperCfNo: item.paperCfNo ?? undefined,
paperLbNo: item.paperLbNo ?? undefined,
status: typeof item.status === "number" ? item.status : undefined,
hasLandbook:
typeof item.hasLandbook === "number" ? item.hasLandbook : undefined,
measuredArea:
typeof item.measuredArea === "number" ? item.measuredArea : undefined,
legalArea:
typeof item.legalArea === "number" ? item.legalArea : undefined,
});
}
@@ -317,43 +334,33 @@ export async function scanNoGeometryParcels(
const matchedCount = allImmovables.length - noGeomItems.length;
// Quality analysis of no-geom items
// Build a quick lookup for area data from the immovable list (any area field)
const areaByPk = new Map<number, number>();
for (const item of allImmovables) {
const pk = Number(item.immovablePk ?? 0);
const areaVal = [
item.area,
item.measuredArea,
item.areaValue,
item.suprafata,
]
.map((v) => (typeof v === "number" && v > 0 ? v : null))
.find((v) => v != null);
if (pk > 0 && areaVal != null) {
areaByPk.set(pk, areaVal);
}
}
let qWithCadRef = 0;
let qWithPaperCad = 0;
let qWithPaperCf = 0;
let qWithPaperLb = 0;
let qWithLandbook = 0;
let qWithArea = 0;
let qWithActiveStatus = 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 hasPaperLb = !!item.paperLbNo?.trim();
const hasArea = areaByPk.has(item.immovablePk);
const hasArea =
(item.measuredArea != null && item.measuredArea > 0) ||
(item.legalArea != null && item.legalArea > 0);
const isActive = item.status === 1;
const hasLb = item.hasLandbook === 1;
if (hasCad) qWithCadRef++;
if (hasPaperCad) qWithPaperCad++;
if (hasPaperCf) qWithPaperCf++;
if (hasPaperLb) qWithPaperLb++;
if (hasLb) qWithLandbook++;
if (hasArea) qWithArea++;
// "Useful" = has any form of identification OR area
// Matches the import quality gate: !hasIdentification && !hasArea → filter out
const hasIdentification = hasCad || hasPaperCf || hasPaperLb || hasPaperCad;
if (hasIdentification || hasArea) qUseful++;
if (isActive) qWithActiveStatus++;
// "Useful" = ACTIVE + has any form of identification OR area
// Matches the import quality gate
const hasIdentification = hasCad || hasPaperLb || hasPaperCad;
if (isActive && (hasIdentification || hasArea)) qUseful++;
else qEmpty++;
}
@@ -365,8 +372,10 @@ export async function scanNoGeometryParcels(
qualityBreakdown: {
withCadRef: qWithCadRef,
withPaperCad: qWithPaperCad,
withPaperCf: qWithPaperCf,
withPaperLb: qWithPaperLb,
withLandbook: qWithLandbook,
withArea: qWithArea,
withActiveStatus: qWithActiveStatus,
useful: qUseful,
empty: qEmpty,
},
@@ -425,33 +434,34 @@ export async function syncNoGeometryParcels(
}
// 3. Filter: not yet in DB + quality gate
// Quality: Must have EITHER a valid cadRef/CF number AND area > 0.
// Items with no identification AND no area are noise — skip them.
// Quality: must be ACTIVE (status=1) AND have identification OR area.
// Items that are inactive, or have no identification AND no area = noise.
let filteredOut = 0;
const candidates = allImmovables.filter((item) => {
const cadRef = normalizeCadRef(item.identifierDetails ?? "");
const immPk = Number(item.immovablePk ?? 0);
// Already in DB? → skip
// Already in DB? → skip (not counted as filtered)
if (cadRef && existingCadRefs.has(cadRef)) return false;
if (immPk > 0 && existingObjIds.has(-immPk)) return false;
// Quality gate: must have CF/identification reference
// Quality gate 1: must be active (status=1)
const status = typeof item.status === "number" ? item.status : 1;
if (status !== 1) {
filteredOut++;
return false;
}
// Quality gate 2: must have identification OR area
const hasCadRef = !!cadRef;
const hasPaperCf = !!(item.paperCfNo ?? "").toString().trim();
const hasPaperLb = !!(item.paperLbNo ?? "").toString().trim();
const hasPaperCad = !!(item.paperCadNo ?? "").toString().trim();
const hasIdentification =
hasCadRef || hasPaperCf || hasPaperLb || hasPaperCad;
const hasIdentification = hasCadRef || hasPaperLb || hasPaperCad;
// Quality gate: must have area from any field
const areaFields = [
item.area,
item.measuredArea,
item.areaValue,
item.suprafata,
];
const hasArea = areaFields.some((v) => typeof v === "number" && v > 0);
// Area: measuredArea and legalArea are the only area fields on immovable/list
const hasArea =
(typeof item.measuredArea === "number" && item.measuredArea > 0) ||
(typeof item.legalArea === "number" && item.legalArea > 0);
if (!hasIdentification && !hasArea) {
filteredOut++;
@@ -494,26 +504,31 @@ export async function syncNoGeometryParcels(
}
const cadRef = String(item.identifierDetails ?? "").trim();
// Extract area from any available field
// Extract area — on immovable/list, the real fields are measuredArea and legalArea
const areaValue =
[item.area, item.measuredArea, item.areaValue, item.suprafata]
.map((v) => (typeof v === "number" && v > 0 ? v : null))
.find((v) => v != null) ?? null;
(typeof item.measuredArea === "number" && item.measuredArea > 0
? item.measuredArea
: null) ??
(typeof item.legalArea === "number" && item.legalArea > 0
? item.legalArea
: null);
const attributes: Record<string, unknown> = {
OBJECTID: -immPk,
IMMOVABLE_ID: immPk,
WORKSPACE_ID: item.workspacePk ?? wsPk,
WORKSPACE_ID: item.workspace?.nomenPk ?? wsPk,
APPLICATION_ID: item.applicationId ?? null,
NATIONAL_CADASTRAL_REFERENCE: cadRef,
AREA_VALUE: areaValue,
IS_ACTIVE: 1,
IS_ACTIVE: item.status === 1 ? 1 : 0,
ADMIN_UNIT_ID: Number(siruta),
PAPER_CAD_NO: item.paperCadNo ?? null,
PAPER_CF_NO: item.paperCfNo ?? null,
PAPER_LB_NO: item.paperLbNo ?? null,
HAS_LANDBOOK: item.hasLandbook ?? null,
TOP_NO: item.topNo ?? null,
IMMOVABLE_TYPE: item.immovableType ?? "P",
MEASURED_AREA: item.measuredArea ?? null,
LEGAL_AREA: item.legalArea ?? null,
NO_GEOMETRY_SOURCE: "ETERRA_IMMOVABLE_LIST",
};