diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index d16458c5..5dd72961 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -24,6 +24,7 @@ from app.schemas.parcel import ( ParcelDetail, ParcelSearchRequest, ParcelSearchResponse, + RiskZone, ) from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf from app.services.site_finder.best_layouts import get_best_layouts @@ -2063,10 +2064,12 @@ def analyze_parcel( # nspd_zoning: ПЗЗ зона из territorial_zones кеша (G1) # nspd_zouit_overlaps: ЗОУИТ пересечения (G3) # nspd_engineering_nearby: инженерные сооружения в 200м (I3) + # nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни # 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_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 7da0ce4b..48990eff 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -68,6 +68,18 @@ class ConnectionPointsResponse(BaseModel): dump_fetched_at: str | None +# ── NSPD Risk Zones schemas (issue #94 TIER 3) ─────────────────────────────── + + +class RiskZone(BaseModel): + """Одна риск-зона НСПД (TIER 3), пересекающая участок.""" + + layer: str # e.g. "risk_flooding", "risk_landslide" + subtype: str | None # человекочитаемое название из NSPD properties + geom_wkt: str | None # WKT геометрии risk-зоны в EPSG:4326 + intersection_area_sqm: float | None # площадь пересечения с участком, м² + + # ── Parcel search/detail schemas ────────────────────────────────────────────── diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index ce5d9213..d44d9136 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -15,6 +15,7 @@ from __future__ import annotations import json import logging +import math from datetime import UTC, datetime, timedelta from typing import Any @@ -37,6 +38,7 @@ EMPTY_DUMP_RESULT: dict[str, Any] = { "nspd_zoning": None, "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], + "nspd_risk_zones": [], "nspd_dump": { "available": False, "fetched_at_utc": None, @@ -62,6 +64,7 @@ def make_empty_result( "nspd_zoning": None, "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], + "nspd_risk_zones": [], "nspd_dump": { "available": False, "fetched_at_utc": fetched_at_utc, @@ -124,7 +127,8 @@ def get_quarter_dump_data( harvest_error, territorial_zones_count, zouit_count, - engineering_count + engineering_count, + risks_count FROM nspd_quarter_dumps WHERE quarter_cad = :q """ @@ -150,6 +154,7 @@ def get_quarter_dump_data( territorial_zones_count: int = row[4] or 0 zouit_count: int = row[5] or 0 engineering_count: int = row[6] or 0 + risks_count: int = row[7] or 0 is_stale = (now - fetched_at) > max_age has_error = harvest_error is not None @@ -179,6 +184,7 @@ def get_quarter_dump_data( "nspd_zoning": None, "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], + "nspd_risk_zones": [], "nspd_dump": dump_meta, } @@ -186,15 +192,18 @@ def get_quarter_dump_data( "territorial_zones_count": territorial_zones_count, "zouit_count": zouit_count, "engineering_count": engineering_count, + "risks_count": risks_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) return { "nspd_zoning": nspd_zoning, "nspd_zouit_overlaps": nspd_zouit, "nspd_engineering_nearby": nspd_engineering, + "nspd_risk_zones": nspd_risk_zones, "nspd_dump": dump_meta, } @@ -394,6 +403,152 @@ def _get_engineering_nearby( return [] +# ── Risk zones (issue #94 TIER 3) ──────────────────────────────────────────── + +# Human-readable subtype labels for risk layer keys. +# Keys match NSPDClient.QUARTER_RISK_LAYERS short_names. +_RISK_SUBTYPE_LABELS: dict[str, str] = { + "flooding_underground": "Подтопление", + "flooding": "Затопление", + "swampification": "Заболачивание", + "landslide": "Обвально-осыпные процессы", + "abrasion": "Абразия", + "erosion_water": "Водная эрозия", + "erosion_linear": "Линейная эрозия", + "erosion_wind": "Ветровая эрозия", + "desertification": "Опустынивание", + "clutter": "Захламление", + "burns": "Гари", +} + + +def _extract_features_by_layer( + features: list[dict[str, Any]], + layer_prefix: str, +) -> list[dict[str, Any]]: + """Generic filter: вернуть features у которых layer начинается с layer_prefix. + + Предназначен для re-use в PR 2+ (opportunity layers, etc.). + + Args: + features: list of feature dicts из features_json (уже загруженных в Python). + layer_prefix: префикс для фильтрации, e.g. 'risk_', 'zouit_'. + + Returns: + Подсписок features где feat['layer'].startswith(layer_prefix). + """ + return [ + f + for f in features + if isinstance(f.get("layer"), str) and f["layer"].startswith(layer_prefix) + ] + + +def _get_risk_zones( + db: Session, + quarter: str, + parcel_wkt: str, + layer_counts: dict[str, int] | None = None, +) -> list[dict[str, Any]]: + """TIER 3: риск-зоны НСПД (11 слоёв), пересекающие участок. + + Возвращает список риск-зон с полями: + layer, subtype, geom_wkt, intersection_area_sqm. + + layer_counts — денормализованные счётчики. Если risks_count == 0 — + пропускаем heavy jsonb_array_elements scan. + """ + if layer_counts is not None and layer_counts.get("risks_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_Area( + ST_Intersection( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) + ) AS intersection_area_sqm + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' LIKE 'risk_%' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ), + ST_GeomFromText(:wkt, 4326) + ) + ORDER BY layer, intersection_area_sqm DESC NULLS LAST + """ + ), + {"q": quarter, "wkt": parcel_wkt}, + ).fetchall() + + result: list[dict[str, Any]] = [] + for r in rows: + layer: str = r[0] or "" + props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {} + geom_wkt_val: str | None = r[2] + raw_area: Any = r[3] + intersection_area_sqm: float | None = None + if 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 + + # Извлечь short_name из "risk_" для человекочитаемого subtype + short_name = layer.removeprefix("risk_") + # Попытка взять subtype из properties, затем из mapping + subtype = ( + props.get("type_zone") + or props.get("subcategory") + or props.get("name") + or _RISK_SUBTYPE_LABELS.get(short_name) + ) + if subtype is not None: + subtype = str(subtype) + + result.append( + { + "layer": layer, + "subtype": subtype, + "geom_wkt": geom_wkt_val, + "intersection_area_sqm": intersection_area_sqm, + } + ) + return result + except Exception as e: + logger.warning("nspd risk zones query failed for quarter=%s: %s", quarter, e) + return [] + + # ── Connection-points lookup (issue #115) ──────────────────────────────────── diff --git a/backend/app/workers/tasks/nspd_sync.py b/backend/app/workers/tasks/nspd_sync.py index dc7eda85..f4eec507 100644 --- a/backend/app/workers/tasks/nspd_sync.py +++ b/backend/app/workers/tasks/nspd_sync.py @@ -389,7 +389,10 @@ def harvest_stale_quarters( enqueued = 0 for cad in stale_cads: try: - harvest_quarter.apply_async(args=[cad, region_code]) + harvest_quarter.apply_async( + args=[cad, region_code], + kwargs={"include_zouit": True, "include_risks": True}, + ) enqueued += 1 except Exception as e: logger.warning("harvest_stale_quarters: enqueue failed for cad=%s: %s", cad, e) diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py new file mode 100644 index 00000000..244b7a9e --- /dev/null +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -0,0 +1,200 @@ +"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction. + +Покрывает: +- _extract_features_by_layer: filter by prefix +- _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect, + no-risks-in-dump +Не требует реального PostgreSQL — мокает db.execute(). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from app.services.site_finder.quarter_dump_lookup import ( + _extract_features_by_layer, + _get_risk_zones, + derive_quarter_cad, + make_empty_result, +) + +# ── Fixtures & helpers ──────────────────────────────────────────────────────── + + +def _make_feature(layer: str, geom: dict[str, Any] | None = None) -> dict[str, Any]: + """Минимальный feature dict в формате features_json.""" + return { + "layer": layer, + "feature_id": "test-id", + "geometry": geom or {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, + "properties": {"name": f"test-{layer}", "type_zone": f"zone-{layer}"}, + } + + +# ── _extract_features_by_layer ──────────────────────────────────────────────── + + +def test_extract_features_by_layer_prefix_match() -> None: + """Возвращает только features с нужным prefix.""" + features = [ + _make_feature("risk_flooding"), + _make_feature("risk_burns"), + _make_feature("zouit_engineering"), + _make_feature("parcels"), + _make_feature("risk_landslide"), + ] + result = _extract_features_by_layer(features, "risk_") + assert len(result) == 3 + layers = {f["layer"] for f in result} + assert layers == {"risk_flooding", "risk_burns", "risk_landslide"} + + +def test_extract_features_by_layer_zouit_prefix() -> None: + """Работает для zouit_ prefix — generic re-use.""" + features = [ + _make_feature("zouit_engineering"), + _make_feature("zouit_okn"), + _make_feature("risk_flooding"), + ] + result = _extract_features_by_layer(features, "zouit_") + assert len(result) == 2 + assert all(f["layer"].startswith("zouit_") for f in result) + + +def test_extract_features_by_layer_empty_list() -> None: + result = _extract_features_by_layer([], "risk_") + assert result == [] + + +def test_extract_features_by_layer_no_match() -> None: + features = [_make_feature("parcels"), _make_feature("buildings")] + result = _extract_features_by_layer(features, "risk_") + assert result == [] + + +def test_extract_features_by_layer_non_string_layer() -> None: + """Gracefully skips features with non-string layer.""" + features: list[dict[str, Any]] = [ + {"layer": None, "feature_id": "x", "geometry": None, "properties": {}}, + _make_feature("risk_flooding"), + ] + result = _extract_features_by_layer(features, "risk_") + assert len(result) == 1 + assert result[0]["layer"] == "risk_flooding" + + +# ── _get_risk_zones ─────────────────────────────────────────────────────────── + + +def _make_mock_db_with_rows(rows: list[tuple[Any, ...]]) -> MagicMock: + """Мок Session где execute().fetchall() возвращает rows.""" + db = MagicMock() + mock_result = MagicMock() + mock_result.fetchall.return_value = rows + db.execute.return_value = mock_result + return db + + +def test_get_risk_zones_intersects() -> None: + """Три risk features → три записи в результате.""" + rows: list[tuple[Any, ...]] = [ + # (layer, props, geom_wkt, intersection_area_sqm) + ( + "risk_flooding", + {"type_zone": "Затопление 1%"}, + "POLYGON((0 0,1 0,1 1,0 0))", + 1234.5, + ), + ( + "risk_burns", + {"name": "Гарь"}, + "POLYGON((0 0,2 0,2 2,0 0))", + 500.0, + ), + ( + "risk_landslide", + {}, + "POLYGON((0 0,3 0,3 3,0 0))", + 999.9, + ), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 3 + + flooding = next(r for r in result if r["layer"] == "risk_flooding") + assert flooding["subtype"] == "Затопление 1%" + assert flooding["intersection_area_sqm"] == 1234.5 + assert flooding["geom_wkt"] == "POLYGON((0 0,1 0,1 1,0 0))" + + burns = next(r for r in result if r["layer"] == "risk_burns") + assert burns["subtype"] == "Гарь" + + landslide = next(r for r in result if r["layer"] == "risk_landslide") + # No properties name/type_zone → falls back to _RISK_SUBTYPE_LABELS + assert landslide["subtype"] == "Обвально-осыпные процессы" + + +def test_get_risk_zones_no_intersect() -> None: + """Пустой fetchall → пустой список.""" + db = _make_mock_db_with_rows([]) + result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +def test_get_risk_zones_no_risks_in_dump() -> None: + """risks_count == 0 → early exit, db.execute не вызывается.""" + db = MagicMock() + layer_counts = {"risks_count": 0} + result = _get_risk_zones(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_risk_zones_db_exception_returns_empty() -> None: + """DB exception → пустой список (не propagate).""" + db = MagicMock() + db.execute.side_effect = Exception("DB connection lost") + result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert result == [] + + +def test_get_risk_zones_null_area() -> None: + """NULL intersection_area_sqm → intersection_area_sqm=None в результате.""" + rows = [("risk_erosion_water", {}, "POINT(0 0)", None)] + db = _make_mock_db_with_rows(rows) + result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") + assert len(result) == 1 + assert result[0]["intersection_area_sqm"] is None + + +# ── make_empty_result includes nspd_risk_zones ──────────────────────────────── + + +def test_make_empty_result_has_risk_zones_key() -> None: + """make_empty_result возвращает nspd_risk_zones: [].""" + result = make_empty_result() + assert "nspd_risk_zones" in result + assert result["nspd_risk_zones"] == [] + + +# ── derive_quarter_cad (smoke tests) ────────────────────────────────────────── + + +@pytest.mark.parametrize( + "cad,expected", + [ + ("66:41:0204016", "66:41:0204016"), + ("66:41:0204016:10", "66:41:0204016"), + ("66:41:0204016:10:1", "66:41:0204016"), + ("invalid", None), + ("66:41", None), + ], +) +def test_derive_quarter_cad(cad: str, expected: str | None) -> None: + assert derive_quarter_cad(cad) == expected diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index e72b10ad..601b1ebd 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -10,6 +10,7 @@ import { NeighborsBlock } from "./NeighborsBlock"; import { NspdZoningBlock } from "./NspdZoningBlock"; import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock"; import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock"; +import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock"; import { NspdFreshnessBadge } from "./NspdFreshnessBadge"; interface Props { @@ -24,7 +25,8 @@ export function LandTab({ data }: Props) { data.neighbors_summary !== undefined || data.nspd_zoning !== undefined || data.nspd_zouit_overlaps !== undefined || - data.nspd_engineering_nearby !== undefined; + data.nspd_engineering_nearby !== undefined || + data.nspd_risk_zones !== undefined; return (
@@ -68,6 +70,16 @@ export function LandTab({ data }: Props) {
)} + {/* Issue #94 TIER 3 — Риск-зоны НСПД */} + {data.nspd_risk_zones !== undefined && ( +
+ + Природные риски (НСПД) + + +
+ )} + {/* Issue #202 — Инженерные объекты в 200 м */} {data.nspd_engineering_nearby !== undefined && (
diff --git a/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx new file mode 100644 index 00000000..2c45c1bf --- /dev/null +++ b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx @@ -0,0 +1,147 @@ +"use client"; + +import type { RiskZone } from "@/types/nspd"; + +interface Props { + riskZones: RiskZone[] | null | undefined; + parcelAreaSqm?: number | null; +} + +// Severity mapping: layer key suffix → severity tier +function getSeverity(layer: string): "high" | "medium" | "low" { + const key = layer.replace(/^risk_/, ""); + if (key === "flooding" || key === "landslide") return "high"; + if (key === "flooding_underground" || key === "burns") return "medium"; + return "low"; +} + +const SEVERITY_STYLES: Record< + "high" | "medium" | "low", + { bg: string; badgeBg: string; color: string; label: string } +> = { + high: { + bg: "#fff1f2", + badgeBg: "#fee2e2", + color: "#991b1b", + label: "ВЫСОКИЙ", + }, + medium: { + bg: "#fffbeb", + badgeBg: "#fef3c7", + color: "#92400e", + label: "СРЕДНИЙ", + }, + low: { + bg: "#fff7ed", + badgeBg: "#ffedd5", + color: "#9a3412", + label: "НИЗКИЙ", + }, +}; + +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")} м²`; +} + +export function NspdRiskZonesBlock({ riskZones, parcelAreaSqm }: Props) { + const zones = riskZones ?? []; + + if (zones.length === 0) { + return ( +
+ + Риски не обнаружены + + + Риск-зоны НСПД на участке не выявлены + +
+ ); + } + + // Sort: high first, then medium, then low + const sorted = [...zones].sort((a, b) => { + const order = { high: 0, medium: 1, low: 2 }; + return order[getSeverity(a.layer)] - order[getSeverity(b.layer)]; + }); + + return ( +
+ {sorted.map((zone, idx) => { + const severity = getSeverity(zone.layer); + const style = SEVERITY_STYLES[severity]; + const areaStr = formatArea(zone.intersection_area_sqm); + const pct = + parcelAreaSqm && zone.intersection_area_sqm + ? Math.min( + 100, + Math.round((zone.intersection_area_sqm / parcelAreaSqm) * 100), + ) + : null; + + return ( +
+ + {style.label} + +
+
+ {zone.subtype ?? zone.layer} +
+ {(areaStr || pct !== null) && ( +
+ {areaStr && Площадь пересечения: {areaStr}} + {areaStr && pct !== null && · } + {pct !== null && {pct}% участка} +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index daf84416..024f5313 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -32,6 +32,15 @@ export interface NspdDumpMeta { total_features: number | null; } +// Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field + +export interface RiskZone { + layer: string; // e.g. "risk_flooding", "risk_landslide" + subtype: string | null; // human-readable label from NSPD properties or mapping + geom_wkt: string | null; + intersection_area_sqm: number | null; +} + // 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 2af9ee13..3d66b542 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -383,6 +383,7 @@ export interface ParcelAnalysis { nspd_zoning?: import("./nspd").NspdZoning | null; nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[]; nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[]; + nspd_risk_zones?: import("./nspd").RiskZone[]; nspd_dump?: import("./nspd").NspdDumpMeta | null; }