From ed3c1285288e0e352a45313e2026d28f2eae0692 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 19:06:22 +0300 Subject: [PATCH 1/2] feat(nspd): TIER 4 opportunity layers + red lines (#94 PR2 of 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NSPDClient: QUARTER_OPPORTUNITY_LAYERS (auction/scheme/free/future/oopt) + QuarterDump.opportunity field - nspd_sync: harvest_quarter accepts include_opportunity, denorm cols has_auction_parcels + opportunity_count in UPSERT - quarter_dump_lookup: - _get_opportunity_parcels (sort by distance, early-exit on count=0) - _get_red_lines (query existing dump.red_lines core path, layer='red_lines') - SQL 89_*: has_auction_parcels + opportunity_count + partial index - Pydantic: OpportunityParcel + RedLine schemas - /analyze: nspd_opportunity_parcels + nspd_red_lines fields - Frontend: NspdOpportunityBlock + NspdRedLinesBlock + LandTab integration - Tests: 28 total (11 PR1 + 17 PR2), all pass Red lines uses existing core harvest path (layer 879243 already in dump.red_lines from PR1+core) — no duplicate harvest, no red_lines_count_v2 column needed. Part of #94 --- backend/app/api/v1/parcels.py | 8 + backend/app/schemas/parcel.py | 39 +++ backend/app/services/scrapers/nspd_client.py | 32 ++- .../site_finder/quarter_dump_lookup.py | 263 +++++++++++++++++- backend/app/workers/tasks/nspd_sync.py | 39 ++- .../services/test_quarter_dump_lookup.py | 198 ++++++++++++- ...89_nspd_quarter_dumps_opportunity_flag.sql | 43 +++ .../src/components/site-finder/LandTab.tsx | 30 +- .../site-finder/NspdOpportunityBlock.tsx | 162 +++++++++++ .../site-finder/NspdRedLinesBlock.tsx | 136 +++++++++ frontend/src/types/nspd.ts | 22 ++ frontend/src/types/site-finder.ts | 3 + 12 files changed, 966 insertions(+), 9 deletions(-) create mode 100644 data/sql/89_nspd_quarter_dumps_opportunity_flag.sql create mode 100644 frontend/src/components/site-finder/NspdOpportunityBlock.tsx create mode 100644 frontend/src/components/site-finder/NspdRedLinesBlock.tsx diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 5dd72961..ba80c31c 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -21,9 +21,11 @@ from app.schemas.parcel import ( CompetitorsRequest, CompetitorsResponse, ConnectionPointsResponse, + OpportunityParcel, ParcelDetail, ParcelSearchRequest, ParcelSearchResponse, + RedLine, RiskZone, ) from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf @@ -2065,11 +2067,17 @@ def analyze_parcel( # nspd_zouit_overlaps: ЗОУИТ пересечения (G3) # nspd_engineering_nearby: инженерные сооружения в 200м (I3) # nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни + # nspd_opportunity_parcels: TIER 4 opportunity ЗУ (#94 PR2) + # nspd_red_lines: TIER 4 красные линии застройки (#94 PR2, #54 Generative) # nspd_dump: freshness metadata — available, stale, harvest_triggered "nspd_zoning": nspd_dump_data["nspd_zoning"], "nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"], "nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"], "nspd_risk_zones": [RiskZone(**rz) for rz in nspd_dump_data.get("nspd_risk_zones", [])], + "nspd_opportunity_parcels": [ + OpportunityParcel(**op) for op in nspd_dump_data.get("nspd_opportunity_parcels", []) + ], + "nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])], "nspd_dump": nspd_dump_data["nspd_dump"], # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner "gate_verdict": compute_gate_verdict( diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 48990eff..6f92375e 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -80,6 +80,45 @@ class RiskZone(BaseModel): intersection_area_sqm: float | None # площадь пересечения с участком, м² +# ── NSPD Opportunity + Red Lines schemas (issue #94 TIER 4) ────────────────── + + +class OpportunityParcel(BaseModel): + """TIER 4: opportunity ЗУ вблизи участка (auction/scheme/free/future/oopt). + + Поля: + layer: тип opportunity — "auction_parcels" | "scheme_parcels" | + "free_parcels" | "future_parcels" | "oopt" + cad_num: кадастровый номер ЗУ (если доступен в NSPD properties) + distance_m: расстояние от centroid анализируемого участка до ЗУ, м + geom_wkt: WKT геометрии ЗУ в EPSG:4326 + """ + + layer: str + cad_num: str | None + distance_m: float | None + geom_wkt: str | None + + +class RedLine(BaseModel): + """TIER 4: красная линия застройки (NSPD layer 879243). + + Поля: + geom_wkt: WKT геометрии линии в EPSG:4326 + intersection_area_sqm: площадь пересечения с участком, м² (null если только nearby) + distance_m: расстояние от ближайшей точки линии до участка, м + (null если линия пересекает участок) + + UI-семантика: + intersection_area_sqm != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT) + distance_m != null → красная линия рядом (warning) + """ + + geom_wkt: str | None + intersection_area_sqm: float | None # null если только nearby, не intersect + distance_m: float | None # null если intersect + + # ── Parcel search/detail schemas ────────────────────────────────────────────── diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index da78fdf0..e2bd760a 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -199,6 +199,9 @@ class QuarterDump: engineering_structures: list[NSPDFeature] zouit: dict[str, list[NSPDFeature]] # {"okn": [...], "engineering": [...], ...} risks: dict[str, list[NSPDFeature]] # {"flooding": [...], "landslide": [...], ...} + # TIER 4 opportunity layers (issue #94 PR2). + # {"auction_parcels": [...], "scheme_parcels": [...], "free_parcels": [...], ...} + opportunity: dict[str, list[NSPDFeature]] # tuple, не list — frozen dataclass + immutable contents (audit/debug snapshot) layers_fetched: tuple[str, ...] bbox_3857: tuple[float, float, float, float] | None # bbox квартала @@ -215,6 +218,7 @@ class QuarterDump: + len(self.engineering_structures) + sum(len(v) for v in self.zouit.values()) + sum(len(v) for v in self.risks.values()) + + sum(len(v) for v in self.opportunity.values()) ) @@ -489,6 +493,16 @@ class NSPDClient: "clutter": "risk_clutter", "burns": "risk_burns", } + # TIER 4 — Opportunity layers (issue #94 PR2). + # short_name → LAYERS dict key (for get_features_in_bbox lookup). + # Features stored in features_json с layer = "opportunity_". + QUARTER_OPPORTUNITY_LAYERS: dict[str, str] = { # noqa: RUF012 + "auction_parcels": "auction_parcels", # 37299 — аукционные ЗУ + "scheme_parcels": "scheme_parcels", # 37294 — схемы расположения ЗУ + "free_parcels": "free_parcels", # 37298 — свободные ЗУ + "future_parcels": "future_parcels", # 36473 — планируемые ЗУ + "oopt": "protected_areas", # 875845 — ООПТ + } def search_by_quarter( self, @@ -496,6 +510,7 @@ class NSPDClient: *, include_zouit: bool = True, include_risks: bool = False, + include_opportunity: bool = False, ) -> QuarterDump: """Harvest всех NSPD-данных для квартала: 1 vacuum, N layers. @@ -517,13 +532,17 @@ class NSPDClient: include_zouit: Включать TIER 2 ЗОУИТ layers (G3). Default True. include_risks: Включать TIER 3 risk zones. Default False (rate-limit budget; для отдельного D-N risk score можно включить). + include_opportunity: Включать TIER 4 opportunity layers (auction_parcels, + scheme_parcels, free_parcels, future_parcels, oopt). Default False. + +5 HTTP запросов при включении. Returns: QuarterDump с per-layer feature lists. Если NSPD пуст / quarter не найден — `quarter=None`, `bbox_3857=None`, все feature lists пустые (no bulk-fetch без bounds — нет смысла). При этом dict- - поля `zouit` / `risks` всё равно populated с пустыми lists для - каждого включённого short_name (структура контракта стабильна). + поля `zouit` / `risks` / `opportunity` всё равно populated с пустыми + lists для каждого включённого short_name + (структура контракта стабильна). `layers_fetched` в этом случае содержит только `('search',)`. Raises: @@ -532,7 +551,7 @@ class NSPDClient: операция атомарна (failure → exception). Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors, - E1 #51 parcels backfill, #96 ЕГРН помещения. + E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity. """ # 1. Quarter geometry через REST search quarter_search = self.search_by_cad(quarter_cad, thematic_id=2) @@ -577,6 +596,12 @@ class NSPDClient: for short_name, layer_key in self.QUARTER_RISK_LAYERS.items(): risks[short_name] = _fetch_layer(f"risk_{short_name}", layer_key) + # 6. Opportunity layers (TIER 4, issue #94 PR2) + opportunity: dict[str, list[NSPDFeature]] = {} + if include_opportunity: + for short_name, layer_key in self.QUARTER_OPPORTUNITY_LAYERS.items(): + opportunity[short_name] = _fetch_layer(f"opportunity_{short_name}", layer_key) + return QuarterDump( quarter_cad=quarter_cad, quarter=quarter_feat, @@ -587,6 +612,7 @@ class NSPDClient: engineering_structures=engineering_structures, zouit=zouit, risks=risks, + opportunity=opportunity, layers_fetched=tuple(layers_fetched), bbox_3857=bbox, fetched_at_utc=_dt.datetime.now(_dt.UTC).isoformat(), diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index d44d9136..56222c18 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -39,6 +39,8 @@ EMPTY_DUMP_RESULT: dict[str, Any] = { "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], "nspd_risk_zones": [], + "nspd_opportunity_parcels": [], + "nspd_red_lines": [], "nspd_dump": { "available": False, "fetched_at_utc": None, @@ -65,6 +67,8 @@ def make_empty_result( "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], "nspd_risk_zones": [], + "nspd_opportunity_parcels": [], + "nspd_red_lines": [], "nspd_dump": { "available": False, "fetched_at_utc": fetched_at_utc, @@ -128,7 +132,9 @@ def get_quarter_dump_data( territorial_zones_count, zouit_count, engineering_count, - risks_count + risks_count, + COALESCE(opportunity_count, 0) AS opportunity_count, + COALESCE(red_lines_count, 0) AS red_lines_count FROM nspd_quarter_dumps WHERE quarter_cad = :q """ @@ -155,6 +161,8 @@ def get_quarter_dump_data( zouit_count: int = row[5] or 0 engineering_count: int = row[6] or 0 risks_count: int = row[7] or 0 + opportunity_count: int = row[8] or 0 + red_lines_count: int = row[9] or 0 is_stale = (now - fetched_at) > max_age has_error = harvest_error is not None @@ -185,6 +193,8 @@ def get_quarter_dump_data( "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], "nspd_risk_zones": [], + "nspd_opportunity_parcels": [], + "nspd_red_lines": [], "nspd_dump": dump_meta, } @@ -193,17 +203,23 @@ def get_quarter_dump_data( "zouit_count": zouit_count, "engineering_count": engineering_count, "risks_count": risks_count, + "opportunity_count": opportunity_count, + "red_lines_count": red_lines_count, } nspd_zoning = _get_zoning(db, quarter, parcel_wkt, layer_counts) nspd_zouit = _get_zouit_overlaps(db, quarter, parcel_wkt, layer_counts) nspd_engineering = _get_engineering_nearby(db, quarter, parcel_wkt, layer_counts) nspd_risk_zones = _get_risk_zones(db, quarter, parcel_wkt, layer_counts) + nspd_opportunity = _get_opportunity_parcels(db, quarter, parcel_wkt, layer_counts) + nspd_red_lines = _get_red_lines(db, quarter, parcel_wkt, layer_counts) return { "nspd_zoning": nspd_zoning, "nspd_zouit_overlaps": nspd_zouit, "nspd_engineering_nearby": nspd_engineering, "nspd_risk_zones": nspd_risk_zones, + "nspd_opportunity_parcels": nspd_opportunity, + "nspd_red_lines": nspd_red_lines, "nspd_dump": dump_meta, } @@ -549,6 +565,241 @@ def _get_risk_zones( return [] +# ── Opportunity parcels (issue #94 TIER 4) ─────────────────────────────────── + +# Human-readable type labels for opportunity layer short_names. +_OPPORTUNITY_TYPE_LABELS: dict[str, str] = { + "auction_parcels": "auction_parcels", + "scheme_parcels": "scheme_parcels", + "free_parcels": "free_parcels", + "future_parcels": "future_parcels", + "oopt": "oopt", +} + +# Radius (m) for opportunity parcel proximity search from parcel centroid. +_OPPORTUNITY_RADIUS_M = 500 + + +def _get_opportunity_parcels( + db: Session, + quarter: str, + parcel_wkt: str, + layer_counts: dict[str, int] | None = None, +) -> list[dict[str, Any]]: + """TIER 4: opportunity parcels вблизи участка (auction, scheme, free, future, oopt). + + Возвращает список ближайших opportunity ЗУ с полями: + layer, cad_num, distance_m, geom_wkt. + + layer_counts — денормализованные счётчики. Если opportunity_count == 0 — + пропускаем heavy jsonb_array_elements scan (early-exit pattern как у risks). + """ + if layer_counts is not None and layer_counts.get("opportunity_count", 1) == 0: + return [] + try: + rows = db.execute( + text( + """ + SELECT feat.value->>'layer' AS layer, + feat.value->'properties' AS props, + ST_AsText( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ) + ) AS geom_wkt, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' LIKE 'opportunity_%' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_DWithin( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + :radius_m + ) + ORDER BY distance_m ASC + LIMIT 30 + """ + ), + {"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M}, + ).fetchall() + + result: list[dict[str, Any]] = [] + for r in rows: + raw_layer: str = r[0] or "" + props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {} + geom_wkt_val: str | None = r[2] + distance_m = float(r[3]) if r[3] is not None else None + + # Strip "opportunity_" prefix to get short_name → type label + short_name = raw_layer.removeprefix("opportunity_") + layer_type = _OPPORTUNITY_TYPE_LABELS.get(short_name, short_name) + + cad_num = props.get("cad_num") or props.get("cadastral_number") + + result.append( + { + "layer": layer_type, + "cad_num": cad_num, + "distance_m": round(distance_m, 1) if distance_m is not None else None, + "geom_wkt": geom_wkt_val, + } + ) + return result + except Exception as e: + logger.warning("nspd opportunity parcels query failed for quarter=%s: %s", quarter, e) + return [] + + +# Radius (m) for red lines proximity search — beyond intersect check. +_RED_LINES_NEARBY_M = 200 + + +def _get_red_lines( + db: Session, + quarter: str, + parcel_wkt: str, + layer_counts: dict[str, int] | None = None, +) -> list[dict[str, Any]]: + """TIER 4: красные линии застройки (layer 879243). + + Возвращает red lines пересекающие участок ИЛИ ближайшие (до 200м) с полями: + geom_wkt, intersection_area_sqm, distance_m. + + Логика: + - intersecting: intersection_area_sqm > 0, distance_m = None + - nearby only: intersection_area_sqm = None, distance_m = расстояние + + layer_counts — денормализованные счётчики. Если red_lines_count == 0 — + пропускаем heavy jsonb_array_elements scan. + """ + if layer_counts is not None and layer_counts.get("red_lines_count", 1) == 0: + return [] + try: + rows = db.execute( + text( + """ + SELECT ST_AsText( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ) + ) AS geom_wkt, + ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ), + ST_GeomFromText(:wkt, 4326) + ) AS does_intersect, + ST_Area( + ST_Intersection( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) + ) AS intersection_area_sqm, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' = 'red_lines' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_DWithin( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography, + :nearby_m + ) + ORDER BY distance_m ASC + LIMIT 50 + """ + ), + {"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M}, + ).fetchall() + + result: list[dict[str, Any]] = [] + for r in rows: + geom_wkt_val: str | None = r[0] + does_intersect: bool = bool(r[1]) if r[1] is not None else False + raw_area: Any = r[2] + raw_dist: Any = r[3] + + intersection_area_sqm: float | None = None + if does_intersect and raw_area is not None: + try: + val = float(raw_area) + intersection_area_sqm = round(val, 1) if not math.isnan(val) else None + except (TypeError, ValueError): + pass + + distance_m: float | None = None + if not does_intersect and raw_dist is not None: + try: + distance_m = round(float(raw_dist), 1) + except (TypeError, ValueError): + pass + + result.append( + { + "geom_wkt": geom_wkt_val, + "intersection_area_sqm": intersection_area_sqm, + "distance_m": distance_m, + } + ) + return result + except Exception as e: + logger.warning("nspd red lines query failed for quarter=%s: %s", quarter, e) + return [] + + # ── Connection-points lookup (issue #115) ──────────────────────────────────── @@ -863,7 +1114,15 @@ def _trigger_harvest(quarter: str) -> bool: try: from app.workers.tasks.nspd_sync import harvest_quarter - harvest_quarter.apply_async(args=[quarter], kwargs={"region_code": 66}) + harvest_quarter.apply_async( + args=[quarter], + kwargs={ + "region_code": 66, + "include_zouit": True, + "include_risks": True, + "include_opportunity": True, + }, + ) logger.info("quarter dump harvest triggered for quarter=%s", quarter) return True except Exception as e: diff --git a/backend/app/workers/tasks/nspd_sync.py b/backend/app/workers/tasks/nspd_sync.py index f4eec507..f87dcefc 100644 --- a/backend/app/workers/tasks/nspd_sync.py +++ b/backend/app/workers/tasks/nspd_sync.py @@ -82,6 +82,12 @@ def _build_features_json(dump: QuarterDump) -> list[dict[str, Any]]: for feat in features: out.append(_feat_to_dict(layer_name, feat)) + # TIER 4 opportunity groups — keys: auction_parcels, scheme_parcels, ... + for short_name, features in dump.opportunity.items(): + layer_name = f"opportunity_{short_name}" + for feat in features: + out.append(_feat_to_dict(layer_name, feat)) + return out @@ -95,6 +101,16 @@ def _build_risks_count(dump: QuarterDump) -> int: return sum(len(v) for v in dump.risks.values()) +def _build_opportunity_count(dump: QuarterDump) -> int: + """Сумма features по всем TIER 4 opportunity слоям (issue #94 PR2).""" + return sum(len(v) for v in dump.opportunity.values()) + + +def _build_has_auction_parcels(dump: QuarterDump) -> bool: + """True если квартал содержит >= 1 feature auction_parcels (layer 37299).""" + return len(dump.opportunity.get("auction_parcels", [])) > 0 + + # ── UPSERT helper ───────────────────────────────────────────────────────────── _UPSERT_SQL = text( @@ -103,6 +119,7 @@ _UPSERT_SQL = text( quarter_cad, quarter_geom, bbox_3857, parcels_count, buildings_count, territorial_zones_count, red_lines_count, engineering_count, zouit_count, risks_count, total_features, + has_auction_parcels, opportunity_count, features_json, layers_fetched, fetched_at_utc, harvest_duration_ms, harvest_error, region_code ) VALUES ( @@ -115,6 +132,7 @@ _UPSERT_SQL = text( END, :parcels_count, :buildings_count, :territorial_zones_count, :red_lines_count, :engineering_count, :zouit_count, :risks_count, :total_features, + :has_auction_parcels, :opportunity_count, CAST(:features_json AS jsonb), CAST(:layers_fetched AS text[]), CAST(:fetched_at_utc AS timestamptz), @@ -133,6 +151,8 @@ _UPSERT_SQL = text( zouit_count = EXCLUDED.zouit_count, risks_count = EXCLUDED.risks_count, total_features = EXCLUDED.total_features, + has_auction_parcels = EXCLUDED.has_auction_parcels, + opportunity_count = EXCLUDED.opportunity_count, features_json = EXCLUDED.features_json, layers_fetched = EXCLUDED.layers_fetched, fetched_at_utc = EXCLUDED.fetched_at_utc, @@ -178,6 +198,8 @@ def _upsert_dump( "engineering_count": len(dump.engineering_structures), "zouit_count": _build_zouit_count(dump), "risks_count": _build_risks_count(dump), + "has_auction_parcels": _build_has_auction_parcels(dump), + "opportunity_count": _build_opportunity_count(dump), "total_features": dump.total_features, "features_json": json.dumps(features_json or [], ensure_ascii=False), "layers_fetched": list(dump.layers_fetched), @@ -202,6 +224,8 @@ def _upsert_dump( "engineering_count": 0, "zouit_count": 0, "risks_count": 0, + "has_auction_parcels": False, + "opportunity_count": 0, "total_features": 0, "features_json": "[]", "layers_fetched": [], @@ -239,20 +263,26 @@ def harvest_quarter( region_code: int = 66, include_zouit: bool = True, include_risks: bool = False, + include_opportunity: bool = False, ) -> dict[str, Any]: """Single-quarter harvest. NSPDClient.search_by_quarter → UPSERT nspd_quarter_dumps. Идемпотентен: повторный вызов обновляет строку (ON CONFLICT DO UPDATE). WAF 403/429 → autoretry с exponential backoff (max 3 попытки). Другие исключения → запись harvest_error в строку, return error dict (не raise). + + Args: + include_opportunity: Фетчить TIER 4 opportunity layers (+5 HTTP запросов). """ t0 = time.monotonic() logger.info( - "harvest_quarter start: cad=%s region=%d include_zouit=%s include_risks=%s", + "harvest_quarter start: cad=%s region=%d include_zouit=%s " + "include_risks=%s include_opportunity=%s", quarter_cad, region_code, include_zouit, include_risks, + include_opportunity, ) client = NSPDClient() @@ -264,6 +294,7 @@ def harvest_quarter( quarter_cad, include_zouit=include_zouit, include_risks=include_risks, + include_opportunity=include_opportunity, ) features_json = _build_features_json(dump) duration_ms = int((time.monotonic() - t0) * 1000) @@ -391,7 +422,11 @@ def harvest_stale_quarters( try: harvest_quarter.apply_async( args=[cad, region_code], - kwargs={"include_zouit": True, "include_risks": True}, + kwargs={ + "include_zouit": True, + "include_risks": True, + "include_opportunity": True, + }, ) enqueued += 1 except Exception as e: diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 244b7a9e..044d1c30 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -1,9 +1,12 @@ -"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction. +"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction + TIER 4. Покрывает: - _extract_features_by_layer: filter by prefix - _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect, no-risks-in-dump +- _get_opportunity_parcels: auction/scheme/free/future/oopt layers, distance sort, empty +- _get_red_lines: intersecting, nearby-only, empty, early-exit +- EMPTY_DUMP_RESULT / make_empty_result: new keys присутствуют Не требует реального PostgreSQL — мокает db.execute(). """ @@ -15,7 +18,10 @@ from unittest.mock import MagicMock import pytest from app.services.site_finder.quarter_dump_lookup import ( + EMPTY_DUMP_RESULT, _extract_features_by_layer, + _get_opportunity_parcels, + _get_red_lines, _get_risk_zones, derive_quarter_cad, make_empty_result, @@ -183,6 +189,196 @@ def test_make_empty_result_has_risk_zones_key() -> None: assert result["nspd_risk_zones"] == [] +# ── _get_opportunity_parcels ───────────────────────────────────────────────── + + +def test_get_opportunity_parcels_finds_auction() -> None: + """3 features (1 auction + 2 other types) — все возвращаются, auction в нужном layer.""" + rows: list[tuple[Any, ...]] = [ + # (layer, props, geom_wkt, distance_m) + ( + "opportunity_auction_parcels", + {"cad_num": "66:41:0204016:101"}, + "POLYGON((0 0,1 0,1 1,0 0))", + 50.0, + ), + ( + "opportunity_scheme_parcels", + {"cadastral_number": "66:41:0204016:102"}, + "POLYGON((1 1,2 1,2 2,1 1))", + 150.0, + ), + ( + "opportunity_oopt", + {}, + "POLYGON((2 2,3 2,3 3,2 2))", + 300.0, + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 3 + + auction = next(r for r in result if r["layer"] == "auction_parcels") + assert auction["cad_num"] == "66:41:0204016:101" + assert auction["distance_m"] == 50.0 + + scheme = next(r for r in result if r["layer"] == "scheme_parcels") + assert scheme["cad_num"] == "66:41:0204016:102" + + oopt = next(r for r in result if r["layer"] == "oopt") + assert oopt["cad_num"] is None + assert oopt["distance_m"] == 300.0 + + +def test_get_opportunity_parcels_distance_sort() -> None: + """Результаты отсортированы по distance_m ASC (SQL ORDER BY передаётся DB).""" + rows: list[tuple[Any, ...]] = [ + # Порядок: ближайший первым (SQL уже сортирует — тест проверяет что порядок сохраняется) + ( + "opportunity_free_parcels", + {"cad_num": "66:41:0204016:10"}, + "POINT(0 0)", + 10.0, + ), + ( + "opportunity_auction_parcels", + {"cad_num": "66:41:0204016:20"}, + "POINT(1 1)", + 200.0, + ), + ( + "opportunity_future_parcels", + {}, + "POINT(2 2)", + 450.0, + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 3 + # Порядок из DB (mock возвращает в порядке rows) — ближайший первый + assert result[0]["distance_m"] == 10.0 + assert result[1]["distance_m"] == 200.0 + assert result[2]["distance_m"] == 450.0 + + +def test_get_opportunity_parcels_empty() -> None: + """Нет opportunity features — пустой список.""" + db = _make_mock_db_with_rows([]) + result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +def test_get_opportunity_parcels_early_exit() -> None: + """opportunity_count == 0 → early exit, db.execute не вызывается.""" + db = MagicMock() + layer_counts = {"opportunity_count": 0} + result = _get_opportunity_parcels( + db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts + ) + assert result == [] + db.execute.assert_not_called() + + +def test_get_opportunity_parcels_db_exception_returns_empty() -> None: + """DB exception → пустой список (не propagate).""" + db = MagicMock() + db.execute.side_effect = Exception("connection lost") + result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +# ── _get_red_lines ──────────────────────────────────────────────────────────── + + +def test_get_red_lines_intersects() -> None: + """Red line intersecting parcel → intersection_area_sqm filled, distance_m=None.""" + rows: list[tuple[Any, ...]] = [ + # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + ( + "LINESTRING(0 0, 1 1)", + True, + 500.0, + 0.0, # intersecting → distance_m becomes None in result + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 1 + r = result[0] + assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)" + assert r["intersection_area_sqm"] == 500.0 + assert r["distance_m"] is None # null when intersecting + + +def test_get_red_lines_nearby_only() -> None: + """Red line nearby only (не intersect) → distance_m filled, intersection_area_sqm=None.""" + rows: list[tuple[Any, ...]] = [ + # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + ( + "LINESTRING(10 10, 20 20)", + False, + 0.0, # нет пересечения + 85.5, + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 1 + r = result[0] + assert r["intersection_area_sqm"] is None + assert r["distance_m"] == 85.5 + + +def test_get_red_lines_empty() -> None: + """Нет red lines features — пустой список.""" + db = _make_mock_db_with_rows([]) + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +def test_get_red_lines_early_exit() -> None: + """red_lines_count == 0 → early exit, db.execute не вызывается.""" + db = MagicMock() + layer_counts = {"red_lines_count": 0} + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts) + assert result == [] + db.execute.assert_not_called() + + +# ── EMPTY_DUMP_RESULT / make_empty_result includes new TIER 4 keys ──────────── + + +def test_empty_dump_result_has_opportunity_key() -> None: + """EMPTY_DUMP_RESULT содержит nspd_opportunity_parcels: [].""" + assert "nspd_opportunity_parcels" in EMPTY_DUMP_RESULT + assert EMPTY_DUMP_RESULT["nspd_opportunity_parcels"] == [] + + +def test_empty_dump_result_has_red_lines_key() -> None: + """EMPTY_DUMP_RESULT содержит nspd_red_lines: [].""" + assert "nspd_red_lines" in EMPTY_DUMP_RESULT + assert EMPTY_DUMP_RESULT["nspd_red_lines"] == [] + + +def test_make_empty_result_has_tier4_keys() -> None: + """make_empty_result() возвращает nspd_opportunity_parcels и nspd_red_lines.""" + result = make_empty_result() + assert "nspd_opportunity_parcels" in result + assert result["nspd_opportunity_parcels"] == [] + assert "nspd_red_lines" in result + assert result["nspd_red_lines"] == [] + + # ── derive_quarter_cad (smoke tests) ────────────────────────────────────────── diff --git a/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql b/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql new file mode 100644 index 00000000..cc56e5e8 --- /dev/null +++ b/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql @@ -0,0 +1,43 @@ +-- 89_nspd_quarter_dumps_opportunity_flag.sql +-- Context : TIER 4 opportunity denorm columns for nspd_quarter_dumps. +-- Enables fast map filter "find quarters with auction parcels" without +-- unpacking features_json JSONB on every query. +-- Part of issue #94 PR 2 (TIER 4 opportunity layers). +-- Dependencies: 88_nspd_quarter_dumps.sql (table must exist) +-- Deploy order: after 88_nspd_quarter_dumps.sql +-- Idempotent: yes — ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS +-- Related: +-- - backend/app/services/scrapers/nspd_client.py :: LAYERS dict (TIER 4) +-- - backend/app/services/site_finder/quarter_dump_lookup.py :: _get_opportunity_parcels +-- - backend/app/workers/tasks/nspd_sync.py :: _build_opportunity_count, _build_has_auction_parcels +-- Note: red_lines (layer 879243) is a TIER 1 core layer — always harvested, +-- counted in red_lines_count (88_*.sql). No separate v2 column needed. + +BEGIN; + +-- Denorm columns for fast filter on map (find quarters with opportunity) +ALTER TABLE nspd_quarter_dumps + ADD COLUMN IF NOT EXISTS has_auction_parcels BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS opportunity_count INTEGER DEFAULT 0; + +COMMENT ON COLUMN nspd_quarter_dumps.has_auction_parcels IS + 'TRUE если квартал содержит >= 1 feature слоя auction_parcels (NSPD layer 37299). ' + 'Заполняется при harvest (include_opportunity=True). Используется для быстрого ' + 'поиска кварталов с аукционными ЗУ на карте. (#94 PR2)'; + +COMMENT ON COLUMN nspd_quarter_dumps.opportunity_count IS + 'Сумма features TIER 4 opportunity layers: ' + 'auction_parcels (37299) + scheme_parcels (37294) + free_parcels (37298) + ' + 'future_parcels (36473) + protected_areas/oopt (875845). ' + 'Заполняется при harvest (include_opportunity=True). (#94 PR2)'; + +-- Partial index for quick "find quarters with auction parcels" map filter +CREATE INDEX IF NOT EXISTS idx_nspd_quarter_dumps_auction + ON nspd_quarter_dumps (quarter_cad) + WHERE has_auction_parcels = TRUE; + +COMMENT ON INDEX idx_nspd_quarter_dumps_auction IS + 'Partial index: быстрый поиск кварталов с аукционными ЗУ для UI-фильтра карты. ' + 'Малая кардинальность (только TRUE rows) — очень компактный. (#94 PR2)'; + +COMMIT; diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 601b1ebd..8a9bd016 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -11,6 +11,8 @@ import { NspdZoningBlock } from "./NspdZoningBlock"; import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock"; import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock"; import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock"; +import { NspdOpportunityBlock } from "./NspdOpportunityBlock"; +import { NspdRedLinesBlock } from "./NspdRedLinesBlock"; import { NspdFreshnessBadge } from "./NspdFreshnessBadge"; interface Props { @@ -26,7 +28,11 @@ export function LandTab({ data }: Props) { data.nspd_zoning !== undefined || data.nspd_zouit_overlaps !== undefined || data.nspd_engineering_nearby !== undefined || - data.nspd_risk_zones !== undefined; + data.nspd_risk_zones !== undefined || + (data.nspd_opportunity_parcels !== undefined && + (data.nspd_opportunity_parcels?.length ?? 0) > 0) || + (data.nspd_red_lines !== undefined && + (data.nspd_red_lines?.length ?? 0) > 0); return (
@@ -70,6 +76,28 @@ export function LandTab({ data }: Props) {
)} + {/* Issue #94 PR2 TIER 4 — Opportunity parcels */} + {(data.nspd_opportunity_parcels?.length ?? 0) > 0 && ( +
+ + Возможности рядом (НСПД) + + +
+ )} + + {/* Issue #94 PR2 TIER 4 — Red lines */} + {(data.nspd_red_lines?.length ?? 0) > 0 && ( +
+ + Красные линии застройки (НСПД) + + +
+ )} + {/* Issue #94 TIER 3 — Риск-зоны НСПД */} {data.nspd_risk_zones !== undefined && (
diff --git a/frontend/src/components/site-finder/NspdOpportunityBlock.tsx b/frontend/src/components/site-finder/NspdOpportunityBlock.tsx new file mode 100644 index 00000000..d946a483 --- /dev/null +++ b/frontend/src/components/site-finder/NspdOpportunityBlock.tsx @@ -0,0 +1,162 @@ +"use client"; + +import type { OpportunityParcel } from "@/types/nspd"; + +interface Props { + opportunityParcels: OpportunityParcel[] | null | undefined; +} + +type LayerKey = OpportunityParcel["layer"]; + +const LAYER_CONFIG: Record< + LayerKey, + { label: string; bg: string; badgeBg: string; color: string } +> = { + auction_parcels: { + label: "На аукционе", + bg: "#fff7ed", + badgeBg: "#fed7aa", + color: "#c2410c", + }, + scheme_parcels: { + label: "Схема расположения", + bg: "#eff6ff", + badgeBg: "#bfdbfe", + color: "#1d4ed8", + }, + free_parcels: { + label: "Свободный от прав", + bg: "#f0fdf4", + badgeBg: "#bbf7d0", + color: "#15803d", + }, + future_parcels: { + label: "Планируемый (проект межевания)", + bg: "#f9fafb", + badgeBg: "#e5e7eb", + color: "#374151", + }, + oopt: { + label: "ООПТ", + bg: "#faf5ff", + badgeBg: "#e9d5ff", + color: "#7e22ce", + }, +}; + +function formatDistance(m: number | null): string { + if (m === null) return ""; + if (m < 1000) return `${Math.round(m)} м`; + return `${(m / 1000).toFixed(1)} км`; +} + +function buildNspdViewerUrl(cadNum: string): string { + return `https://nspd.gov.ru/map?cadastralNumber=${encodeURIComponent(cadNum)}`; +} + +export function NspdOpportunityBlock({ opportunityParcels }: Props) { + const parcels = opportunityParcels ?? []; + + if (parcels.length === 0) { + return null; + } + + // Group by layer type + const grouped = parcels.reduce>( + (acc, p) => { + const key = p.layer as LayerKey; + if (!acc[key]) acc[key] = []; + acc[key].push(p); + return acc; + }, + {} as Record, + ); + + // Stable display order: auction first (highest priority) + const layerOrder: LayerKey[] = [ + "auction_parcels", + "free_parcels", + "scheme_parcels", + "future_parcels", + "oopt", + ]; + + return ( +
+ {layerOrder.map((layerKey) => { + const items = grouped[layerKey]; + if (!items || items.length === 0) return null; + const cfg = LAYER_CONFIG[layerKey]; + + return ( +
+
1 ? 8 : 0, + }} + > + + {cfg.label} + + + {items.length} уч. + +
+ + {items.map((p, idx) => ( +
+ {p.cad_num ? ( + + {p.cad_num} + + ) : ( + + )} + {p.distance_m !== null && ( + + {formatDistance(p.distance_m)} + + )} +
+ ))} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/site-finder/NspdRedLinesBlock.tsx b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx new file mode 100644 index 00000000..e6628c6e --- /dev/null +++ b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx @@ -0,0 +1,136 @@ +"use client"; + +import type { RedLine } from "@/types/nspd"; + +interface Props { + redLines: RedLine[] | null | undefined; +} + +function formatArea(sqm: number | null): string | null { + if (sqm === null) return null; + if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`; + return `${Math.round(sqm).toLocaleString("ru-RU")} м²`; +} + +function formatDistance(m: number | null): string | null { + if (m === null) return null; + if (m < 1000) return `${Math.round(m)} м`; + return `${(m / 1000).toFixed(1)} км`; +} + +export function NspdRedLinesBlock({ redLines }: Props) { + const lines = redLines ?? []; + + if (lines.length === 0) { + return null; + } + + const intersecting = lines.filter((l) => l.intersection_area_sqm !== null); + const nearbyOnly = lines.filter((l) => l.intersection_area_sqm === null); + const hasIntersect = intersecting.length > 0; + + return ( +
+ {/* Alert banner when red lines intersect the parcel */} + {hasIntersect && ( +
+ + ПЕРЕСЕЧЕНИЕ + +
+
+ Красные линии пересекают участок — отступы могут быть критичны +
+
+ {intersecting.map((l, idx) => { + const areaStr = formatArea(l.intersection_area_sqm); + return ( +
+ Линия {idx + 1} + {areaStr ? `: пересечение ${areaStr}` : ""} +
+ ); + })} +
+
+
+ )} + + {/* Nearby-only red lines */} + {nearbyOnly.length > 0 && ( +
+ + РЯДОМ + +
+
+ {nearbyOnly.length} кр. лини{nearbyOnly.length === 1 ? "я" : "и"}{" "} + вблизи участка +
+
+ {nearbyOnly.map((l, idx) => { + const distStr = formatDistance(l.distance_m); + return ( +
+ Линия {idx + 1} + {distStr ? `: ${distStr} от границы` : ""} +
+ ); + })} +
+
+
+ )} + + {/* Summary line */} +
+ Итого: {lines.length} красн. лини{lines.length === 1 ? "я" : "и"} + {hasIntersect + ? `, из них ${intersecting.length} пересека${intersecting.length === 1 ? "ет" : "ют"} участок` + : ""} +
+
+ ); +} diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index 024f5313..7f8cdae6 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -41,6 +41,28 @@ export interface RiskZone { intersection_area_sqm: number | null; } +// Opportunity parcels (issue #94 TIER 4) + +export interface OpportunityParcel { + layer: + | "auction_parcels" + | "scheme_parcels" + | "free_parcels" + | "future_parcels" + | "oopt"; + cad_num: string | null; + distance_m: number | null; + geom_wkt: string | null; +} + +// Red lines (issue #94 TIER 4 + #54 Generative foundation) + +export interface RedLine { + geom_wkt: string | null; + intersection_area_sqm: number | null; // null if only nearby, not intersecting + distance_m: number | null; // null if intersecting +} + // Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points) export interface EngineeringStructure { diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 3d66b542..cb4f469f 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -384,6 +384,9 @@ export interface ParcelAnalysis { nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[]; nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[]; nspd_risk_zones?: import("./nspd").RiskZone[]; + // Issue #94 PR2 — TIER 4 opportunity layers + red lines + nspd_opportunity_parcels?: import("./nspd").OpportunityParcel[]; + nspd_red_lines?: import("./nspd").RedLine[]; nspd_dump?: import("./nspd").NspdDumpMeta | null; } From fdb54834e5b1fe76c4ecd719902a70238c9e5c6f Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 19:33:14 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(nspd):=20rename=20migration=2089?= =?UTF-8?q?=E2=86=9298,=20fix=20red=20lines=20ST=5FArea=E2=86=92ST=5FLengt?= =?UTF-8?q?h=20(#220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data/sql/98_*: rename from 89 to avoid collision with existing 89_drop_dead_brin - _get_red_lines: ST_Length(ST_Intersection(planar)::geography) instead of ST_Area(ST_Intersection(::geography, ::geography)) — fixes PostGIS 3.4 tolerance error and returns correct non-zero length for LINESTRING intersections - Rename intersection_area_sqm → intersection_length_m across schema, TS types, frontend component, and tests; add test_get_red_lines_db_exception_returns_empty Addresses review-bot feedback on PR #220. --- backend/app/schemas/parcel.py | 7 ++-- .../site_finder/quarter_dump_lookup.py | 32 +++++++++++-------- .../services/test_quarter_dump_lookup.py | 22 +++++++++---- ...8_nspd_quarter_dumps_opportunity_flag.sql} | 2 +- .../site-finder/NspdRedLinesBlock.tsx | 16 +++++----- frontend/src/types/nspd.ts | 2 +- 6 files changed, 47 insertions(+), 34 deletions(-) rename data/sql/{89_nspd_quarter_dumps_opportunity_flag.sql => 98_nspd_quarter_dumps_opportunity_flag.sql} (98%) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 6f92375e..8d210704 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -105,17 +105,18 @@ class RedLine(BaseModel): Поля: geom_wkt: WKT геометрии линии в EPSG:4326 - intersection_area_sqm: площадь пересечения с участком, м² (null если только nearby) + intersection_length_m: длина пересечения линии с участком, м + (null если только nearby, не intersect) distance_m: расстояние от ближайшей точки линии до участка, м (null если линия пересекает участок) UI-семантика: - intersection_area_sqm != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT) + intersection_length_m != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT) distance_m != null → красная линия рядом (warning) """ geom_wkt: str | None - intersection_area_sqm: float | None # null если только nearby, не intersect + intersection_length_m: float | None # null если только nearby, не intersect distance_m: float | None # null если intersect diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 56222c18..b6b17e06 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -685,11 +685,15 @@ def _get_red_lines( """TIER 4: красные линии застройки (layer 879243). Возвращает red lines пересекающие участок ИЛИ ближайшие (до 200м) с полями: - geom_wkt, intersection_area_sqm, distance_m. + geom_wkt, intersection_length_m, distance_m. Логика: - - intersecting: intersection_area_sqm > 0, distance_m = None - - nearby only: intersection_area_sqm = None, distance_m = расстояние + - intersecting: intersection_length_m >= 0, distance_m = None + - nearby only: intersection_length_m = None, distance_m = расстояние + + ST_Intersection выполняется в planar EPSG:4326, затем результат кастуется + в ::geography для ST_Length — это избегает PostGIS 3.4 tolerance bug + (ST_Intersection geography × geography на LINESTRING бросает transform error). layer_counts — денормализованные счётчики. Если red_lines_count == 0 — пропускаем heavy jsonb_array_elements scan. @@ -719,7 +723,7 @@ def _get_red_lines( ), ST_GeomFromText(:wkt, 4326) ) AS does_intersect, - ST_Area( + ST_Length( ST_Intersection( ST_Transform( ST_SetSRID( @@ -727,10 +731,10 @@ def _get_red_lines( 3857 ), 4326 - )::geography, - ST_GeomFromText(:wkt, 4326)::geography - ) - ) AS intersection_area_sqm, + ), + ST_GeomFromText(:wkt, 4326) + )::geography + ) AS intersection_length_m, ST_Distance( ST_Transform( ST_SetSRID( @@ -769,14 +773,14 @@ def _get_red_lines( for r in rows: geom_wkt_val: str | None = r[0] does_intersect: bool = bool(r[1]) if r[1] is not None else False - raw_area: Any = r[2] + raw_length: Any = r[2] raw_dist: Any = r[3] - intersection_area_sqm: float | None = None - if does_intersect and raw_area is not None: + intersection_length_m: float | None = None + if does_intersect and raw_length is not None: try: - val = float(raw_area) - intersection_area_sqm = round(val, 1) if not math.isnan(val) else None + val = float(raw_length) + intersection_length_m = round(val, 1) if not math.isnan(val) else None except (TypeError, ValueError): pass @@ -790,7 +794,7 @@ def _get_red_lines( result.append( { "geom_wkt": geom_wkt_val, - "intersection_area_sqm": intersection_area_sqm, + "intersection_length_m": intersection_length_m, "distance_m": distance_m, } ) diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 044d1c30..3b23a011 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -297,13 +297,13 @@ def test_get_opportunity_parcels_db_exception_returns_empty() -> None: def test_get_red_lines_intersects() -> None: - """Red line intersecting parcel → intersection_area_sqm filled, distance_m=None.""" + """Red line intersecting parcel → intersection_length_m filled, distance_m=None.""" rows: list[tuple[Any, ...]] = [ - # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + # (geom_wkt, does_intersect, intersection_length_m, distance_m) ( "LINESTRING(0 0, 1 1)", True, - 500.0, + 45.3, # длина пересечения в метрах 0.0, # intersecting → distance_m becomes None in result ), ] @@ -314,14 +314,14 @@ def test_get_red_lines_intersects() -> None: assert len(result) == 1 r = result[0] assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)" - assert r["intersection_area_sqm"] == 500.0 + assert r["intersection_length_m"] == 45.3 assert r["distance_m"] is None # null when intersecting def test_get_red_lines_nearby_only() -> None: - """Red line nearby only (не intersect) → distance_m filled, intersection_area_sqm=None.""" + """Red line nearby only (не intersect) → distance_m filled, intersection_length_m=None.""" rows: list[tuple[Any, ...]] = [ - # (geom_wkt, does_intersect, intersection_area_sqm, distance_m) + # (geom_wkt, does_intersect, intersection_length_m, distance_m) ( "LINESTRING(10 10, 20 20)", False, @@ -335,10 +335,18 @@ def test_get_red_lines_nearby_only() -> None: assert len(result) == 1 r = result[0] - assert r["intersection_area_sqm"] is None + assert r["intersection_length_m"] is None assert r["distance_m"] == 85.5 +def test_get_red_lines_db_exception_returns_empty() -> None: + """DB exception → пустой список (не propagate).""" + db = MagicMock() + db.execute.side_effect = Exception("DB connection lost") + result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + def test_get_red_lines_empty() -> None: """Нет red lines features — пустой список.""" db = _make_mock_db_with_rows([]) diff --git a/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql b/data/sql/98_nspd_quarter_dumps_opportunity_flag.sql similarity index 98% rename from data/sql/89_nspd_quarter_dumps_opportunity_flag.sql rename to data/sql/98_nspd_quarter_dumps_opportunity_flag.sql index cc56e5e8..5c972a54 100644 --- a/data/sql/89_nspd_quarter_dumps_opportunity_flag.sql +++ b/data/sql/98_nspd_quarter_dumps_opportunity_flag.sql @@ -1,4 +1,4 @@ --- 89_nspd_quarter_dumps_opportunity_flag.sql +-- 98_nspd_quarter_dumps_opportunity_flag.sql -- Context : TIER 4 opportunity denorm columns for nspd_quarter_dumps. -- Enables fast map filter "find quarters with auction parcels" without -- unpacking features_json JSONB on every query. diff --git a/frontend/src/components/site-finder/NspdRedLinesBlock.tsx b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx index e6628c6e..651bab9b 100644 --- a/frontend/src/components/site-finder/NspdRedLinesBlock.tsx +++ b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx @@ -6,10 +6,10 @@ interface Props { redLines: RedLine[] | null | undefined; } -function formatArea(sqm: number | null): string | null { - if (sqm === null) return null; - if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`; - return `${Math.round(sqm).toLocaleString("ru-RU")} м²`; +function formatLength(m: number | null): string | null { + if (m === null) return null; + if (m >= 1000) return `${(m / 1000).toFixed(2)} км`; + return `${Math.round(m)} м`; } function formatDistance(m: number | null): string | null { @@ -25,8 +25,8 @@ export function NspdRedLinesBlock({ redLines }: Props) { return null; } - const intersecting = lines.filter((l) => l.intersection_area_sqm !== null); - const nearbyOnly = lines.filter((l) => l.intersection_area_sqm === null); + const intersecting = lines.filter((l) => l.intersection_length_m !== null); + const nearbyOnly = lines.filter((l) => l.intersection_length_m === null); const hasIntersect = intersecting.length > 0; return ( @@ -64,11 +64,11 @@ export function NspdRedLinesBlock({ redLines }: Props) {
{intersecting.map((l, idx) => { - const areaStr = formatArea(l.intersection_area_sqm); + const lenStr = formatLength(l.intersection_length_m); return (
Линия {idx + 1} - {areaStr ? `: пересечение ${areaStr}` : ""} + {lenStr ? `: длина пересечения ${lenStr}` : ""}
); })} diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index 7f8cdae6..7b49b3ad 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -59,7 +59,7 @@ export interface OpportunityParcel { export interface RedLine { geom_wkt: string | null; - intersection_area_sqm: number | null; // null if only nearby, not intersecting + intersection_length_m: number | null; // null if only nearby, not intersecting distance_m: number | null; // null if intersecting }