From f70be684da675bc1f646ab47fac1fca2fc3dcf92 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 08:17:22 +0300 Subject: [PATCH] feat(#29,#232): wire cad_parcels.permitted_use in analyze + cad_zouit fallback (G2+G3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK A (#29 G2): add parcel_meta to analyze response - New ParcelMeta Pydantic schema in app/schemas/parcel.py - SELECT from cad_parcels WHERE cad_num=:c in analyze_parcel() (step 9f) - Returns permitted_use_established_by_document, land_record_category_type, land_record_subtype, cost_value; None when row absent - Tests: test_analyze_parcel_meta.py (found + not-found cases) TASK B (#232 G3): cad_zouit fallback in _get_zouit_overlaps - When nspd_quarter_dumps has zouit_count==0, fall back to ST_Intersects query on cad_zouit (3483 rows, GIST indexed) - Overlaps tagged with source='cad_zouit'; format compatible with NSPD path - gate_verdict.py: BLOCKER_TYPE_ZONE_KEYWORDS tuple for keyword-based classification (охранная зона / трубопровод / электр / газ -> blocker; СЗЗ -> warning); NSPD subcategory path preserved backward-compat - Tests: 6 new test cases in test_gate_verdict.py covering cad_zouit path and backward-compat for NSPD subcategory path Updated db.execute call sequence in test_analyze_*.py (index shift +1 at pos 10). --- backend/app/api/v1/parcels.py | 32 +++ backend/app/schemas/parcel.py | 16 ++ .../app/services/site_finder/gate_verdict.py | 76 +++++-- .../site_finder/quarter_dump_lookup.py | 116 +++++++--- .../api/v1/test_analyze_inline_weights.py | 30 +-- .../tests/api/v1/test_analyze_market_price.py | 35 +-- .../tests/api/v1/test_analyze_parcel_meta.py | 210 ++++++++++++++++++ .../api/v1/test_analyze_recent_permits.py | 36 +-- backend/tests/test_gate_verdict.py | 92 +++++++- 9 files changed, 551 insertions(+), 92 deletions(-) create mode 100644 backend/tests/api/v1/test_analyze_parcel_meta.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index ba80c31c..8f0064a4 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -23,6 +23,7 @@ from app.schemas.parcel import ( ConnectionPointsResponse, OpportunityParcel, ParcelDetail, + ParcelMeta, ParcelSearchRequest, ParcelSearchResponse, RedLine, @@ -1627,6 +1628,35 @@ def analyze_parcel( # Independent dict per request — never mutate module singleton. nspd_dump_data = make_empty_result() + # 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2) + parcel_meta: ParcelMeta | None = None + try: + pm_row = ( + db.execute( + text(""" + SELECT permitted_use_established_by_document AS permitted_use, + land_record_category_type AS land_category, + land_record_subtype AS land_subtype, + cost_value AS cad_cost + FROM cad_parcels + WHERE cad_num = CAST(:c AS text) + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + if pm_row: + parcel_meta = ParcelMeta( + permitted_use=pm_row["permitted_use"], + land_category=pm_row["land_category"], + land_subtype=pm_row["land_subtype"], + cad_cost=float(pm_row["cad_cost"]) if pm_row["cad_cost"] is not None else None, + ) + except Exception as e: + logger.warning("parcel_meta query failed for %s: %s", cad_num, e) + # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев market_trend: dict[str, Any] | None = None try: @@ -2051,6 +2081,8 @@ def analyze_parcel( "market_trend": market_trend, # #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2 "market_price": market_price, + # #29 G2: кадастровые метаданные участка (ВРИ, категория, кад. стоимость) + "parcel_meta": parcel_meta.model_dump() if parcel_meta is not None else None, # #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m) "recent_permits_in_quarter": recent_permits, "permits_summary": permits_summary, diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 8d210704..2452bc99 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -315,6 +315,22 @@ class MarketPrice(BaseModel): source: Literal["quarter_mv", "no_data"] = "no_data" +# ── Analyze endpoint parcel meta (#29 G2) ──────────────────────────────────── + + +class ParcelMeta(BaseModel): + """Кадастровые метаданные участка из cad_parcels (#29 G2). + + Поля берутся из NSPD bulk-ingest (issue #168). Строки сырые, без нормализации. + """ + + permitted_use: str | None = None # permitted_use_established_by_document + land_category: str | None = None # land_record_category_type + land_subtype: str | None = None # land_record_subtype + cad_cost: float | None = None # cost_value (кадастровая стоимость, руб.) + source: str = "cad_parcels" + + # ── Analyze endpoint inline weights (#201) ──────────────────────────────────── diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 209cbeda..8b854cb3 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -18,11 +18,25 @@ RESIDENTIAL_KEYWORDS = ("жил",) # ── ЗОУИТ taxonomy ──────────────────────────────────────────────────────────── -# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода) +# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода). +# Используется для overlaps из NSPD dump (subcategory INT там заполнен). BLOCKER_SUBCATEGORIES: dict[int, str] = { 17: "Инженерные коммуникации (охранная зона ЛЭП/газа/трубопровода)", } +# Keyword-based blockers для cad_zouit fallback (#232). +# cad_zouit.subcategory = 100% NULL, поэтому классификация по type_zone substring. +# Порядок: lowercase, substring match (any wins → blocker). +BLOCKER_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ( + "охранная зона", + "трубопровод", + "электр", + "газ", +) + +# type_zone substrings, которые дают warning (не blocker) из cad_zouit. +WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-защитная") + # Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py ENGINEERING_NEARBY_THRESHOLD_M = 200 @@ -136,21 +150,57 @@ def compute_gate_verdict( # Check 2 — ЗОУИТ overlaps checks.append("ЗОУИТ пересечения") for overlap in nspd_zouit_overlaps or []: - sub = overlap.get("subcategory") - if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES: - blockers.append( - Blocker( - code=f"ZOUIT_OVERLAP_SUB{sub}", - detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}", + src = overlap.get("source", "nspd-quarter-dump") + if src == "cad_zouit": + # cad_zouit fallback path: classify by type_zone keywords (#232). + # subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется. + type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower() + if any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS): + blockers.append( + Blocker( + code="ZOUIT_CAD_BLOCKER", + detail=( + f"Охранная зона ({overlap.get('type_zone', '')}): " + f"{overlap.get('name', '')}" + ), + ) + ) + elif any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS): + warnings.append( + Warning( + code="ZOUIT_CAD_SZZ", + detail=( + f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}" + ), + ) + ) + else: + warnings.append( + Warning( + code="ZOUIT_CAD_OTHER", + detail=( + f"ЗОУИТ cad ({overlap.get('type_zone', '')}): " + f"{overlap.get('name', '')}" + ), + ) ) - ) else: - warnings.append( - Warning( - code=f"ZOUIT_SUB{sub if sub is not None else 'unknown'}", - detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}", + # NSPD dump path: subcategory-based logic (backward-compat). + sub = overlap.get("subcategory") + if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES: + blockers.append( + Blocker( + code=f"ZOUIT_OVERLAP_SUB{sub}", + detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}", + ) + ) + else: + warnings.append( + Warning( + code=f"ZOUIT_SUB{sub if sub is not None else 'unknown'}", + detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}", + ) ) - ) # Check 3 — Engineering nearby (warning only, not a blocker) checks.append(f"Инженерные сети в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м") diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index b6b17e06..fbf5c463 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -296,54 +296,108 @@ def _get_zouit_overlaps( Проверяем 5 групп: zouit_okn, zouit_engineering, zouit_natural, zouit_protected, zouit_other. layer_counts — денормализованные счётчики. Если zouit_count == 0 — - пропускаем heavy jsonb_array_elements scan. + пропускаем nspd dump scan и делаем fallback на cad_zouit (#232). + """ + nspd_has_zouit = layer_counts is None or layer_counts.get("zouit_count", 1) != 0 + if nspd_has_zouit: + try: + rows = db.execute( + text( + """ + SELECT feat.value->>'layer' AS layer, + feat.value->'properties' AS props + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' LIKE 'zouit_%' + 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) + ) + """ + ), + {"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 {} + group_key = layer.removeprefix("zouit_") + result.append( + { + "group_key": group_key, + "layer": layer, + "subcategory": props.get("subcategory") or props.get("type_zone"), + "name": props.get("name") or props.get("object_name"), + "raw_props": props, + "source": "nspd-quarter-dump", + } + ) + return result + except Exception as e: + logger.warning("nspd zouit query failed for quarter=%s: %s", quarter, e) + return [] + + # Fallback: cad_zouit (#232) — nspd dump пуст по ЗОУИТ. + # cad_zouit.subcategory = 100% NULL (verified), используем type_zone keywords. + return _get_cad_zouit_overlaps(db, parcel_wkt) + + +def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]]: + """Fallback: читать ЗОУИТ из cad_zouit когда nspd_quarter_dumps пуст (#232). + + Использует GIST index cad_zouit_geom_gist через ST_Intersects. + Возвращает тот же формат что nspd-dump path (с доп. полем source='cad_zouit'). """ - if layer_counts is not None and layer_counts.get("zouit_count", 1) == 0: - return [] try: rows = db.execute( text( """ - SELECT feat.value->>'layer' AS layer, - feat.value->'properties' AS props - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q - AND feat.value->>'layer' LIKE 'zouit_%' - 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) - ) + SELECT type_zone, + category_name, + name_by_doc AS name, + reg_numb_border, + id AS zouit_id + FROM cad_zouit + WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326)) + ORDER BY id """ ), - {"q": quarter, "wkt": parcel_wkt}, + {"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 {} - group_key = layer.removeprefix("zouit_") + type_zone: str = r[0] or "" result.append( { - "group_key": group_key, - "layer": layer, - "subcategory": props.get("subcategory") or props.get("type_zone"), - "name": props.get("name") or props.get("object_name"), - "raw_props": props, + "group_key": "cad_zouit", + "layer": type_zone, + "subcategory": None, # subcategory = 100% NULL в cad_zouit + "name": r[2], + "raw_props": { + "type_zone": type_zone, + "category_name": r[1], + "reg_numb_border": r[3], + "zouit_id": r[4], + }, + "source": "cad_zouit", + "type_zone": type_zone, } ) + logger.info("cad_zouit fallback: found %d overlaps for wkt prefix", len(result)) return result except Exception as e: - logger.warning("nspd zouit query failed for quarter=%s: %s", quarter, e) + logger.warning("cad_zouit fallback query failed: %s", e) return [] diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index f49eeed6..14b18bb5 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -58,14 +58,15 @@ def _make_db_for_analyze( 7. Noise rows → .mappings().all() 8. Hydrology → .mappings().all() 9. Utilities → .mappings().all() - 10. Market trend → .mappings().first() - 11. Zoning (begin_nested) → .mappings().first() - 12. Success recommendation (begin_nested) → .mappings().all() - 13. Market price (begin_nested) → .mappings().first() - 14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 Phase 5 - 15. _geotech_risk (industrial count) → .scalar() - 16. _neighbors_summary (neighbor_rows) → .mappings().all() - 17. _neighbors_summary (overlap_row) → .mappings().first() + 10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2 + 11. Market trend → .mappings().first() + 12. Zoning (begin_nested) → .mappings().first() + 13. Success recommendation (begin_nested) → .mappings().all() + 14. Market price (begin_nested) → .mappings().first() + 15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5 + 16. _geotech_risk (industrial count) → .scalar() + 17. _neighbors_summary (neighbor_rows) → .mappings().all() + 18. _neighbors_summary (overlap_row) → .mappings().first() begin_nested() — возвращаем context manager чтобы поддержать `with` statement. """ @@ -106,12 +107,13 @@ def _make_db_for_analyze( ("all", []), # 7: noise rows ("all", []), # 8: hydrology rows ("all", []), # 9: utilities rows - ("first", None), # 10: market trend - ("first", None), # 11: zoning (inside begin_nested) - ("all", []), # 12: success recommendation (inside begin_nested) - ("scalar", 0), # 13: geotech_risk industrial count - ("all", []), # 14: neighbors - ("first", None), # 15: overlap + ("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2 + ("first", None), # 11: market trend + ("first", None), # 12: zoning (inside begin_nested) + ("all", []), # 13: success recommendation (inside begin_nested) + ("scalar", 0), # 14: geotech_risk industrial count + ("all", []), # 15: neighbors + ("first", None), # 16: overlap ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: diff --git a/backend/tests/api/v1/test_analyze_market_price.py b/backend/tests/api/v1/test_analyze_market_price.py index 4052f882..4fbe9081 100644 --- a/backend/tests/api/v1/test_analyze_market_price.py +++ b/backend/tests/api/v1/test_analyze_market_price.py @@ -8,7 +8,7 @@ Стратегия mock: аналогична test_analyze_inline_weights.py — DB mock через dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. -Порядок db.execute calls в analyze_parcel (v3.7 + #33): +Порядок db.execute calls в analyze_parcel (v3.7 + #33 + #29 G2): 0. UNION ALL geom + source → .mappings().first() 1. WKT query → .mappings().first() 2. District → .mappings().first() @@ -19,14 +19,15 @@ dependency_overrides, тяжёлые сервисы патчим через unit 7. Noise rows → .mappings().all() 8. Hydrology → .mappings().all() 9. Utilities → .mappings().all() - 10. Market trend → .mappings().first() - 11. Zoning (begin_nested) → .mappings().first() - 12. Success recommendation (begin_nested) → .mappings().all() - 13. Market price (begin_nested) → .mappings().first() ← NEW #33 - 14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 Phase 5 - 15. _geotech_risk (industrial count) → .scalar() - 16. _neighbors_summary (neighbor_rows) → .mappings().all() - 17. _neighbors_summary (overlap_row) → .mappings().first() + 10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2 + 11. Market trend → .mappings().first() + 12. Zoning (begin_nested) → .mappings().first() + 13. Success recommendation (begin_nested) → .mappings().all() + 14. Market price (begin_nested) → .mappings().first() ← #33 + 15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5 + 16. _geotech_risk (industrial count) → .scalar() + 17. _neighbors_summary (neighbor_rows) → .mappings().all() + 18. _neighbors_summary (overlap_row) → .mappings().first() """ from __future__ import annotations @@ -104,13 +105,15 @@ def _make_db_for_analyze( ("all", []), # 7: noise rows ("all", []), # 8: hydrology rows ("all", []), # 9: utilities rows - ("first", None), # 10: market trend - ("first", None), # 11: zoning (begin_nested) - ("all", []), # 12: success recommendation (begin_nested) - ("first", mp_mock), # 13: market price (begin_nested) ← #33 - ("scalar", 0), # 14: geotech_risk - ("all", []), # 15: neighbors - ("first", None), # 16: overlap + ("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2 + ("first", None), # 11: market trend + ("first", None), # 12: zoning (begin_nested) + ("all", []), # 13: success recommendation (begin_nested) + ("first", mp_mock), # 14: market price (begin_nested) ← #33 + ("all", []), # 15: recent permits (begin_nested) + ("scalar", 0), # 16: geotech_risk + ("all", []), # 17: neighbors + ("first", None), # 18: overlap ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: diff --git a/backend/tests/api/v1/test_analyze_parcel_meta.py b/backend/tests/api/v1/test_analyze_parcel_meta.py new file mode 100644 index 00000000..7dfb9e76 --- /dev/null +++ b/backend/tests/api/v1/test_analyze_parcel_meta.py @@ -0,0 +1,210 @@ +"""Тесты для parcel_meta в POST /api/v1/parcels/{cad_num}/analyze (#29 G2). + +Покрывает: +1. analyze когда cad_parcels has row → parcel_meta содержит permitted_use/land_category/cad_cost +2. analyze когда cad_parcels row отсутствует → parcel_meta == None + +Стратегия mock: аналогична test_analyze_market_price.py — DB mock через +dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. + +Порядок db.execute calls в analyze_parcel (с #29 G2): + 0. UNION ALL geom + source → .mappings().first() + 1. WKT query → .mappings().first() + 2. District → .mappings().first() + 3. POI rows → .mappings().all() + 4. Competitor rows → .mappings().all() + 5. Pipeline rows → .mappings().all() + 6. Centroid lat/lon → .mappings().first() + 7. Noise rows → .mappings().all() + 8. Hydrology → .mappings().all() + 9. Utilities → .mappings().all() + 10. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2 + 11. Market trend → .mappings().first() + 12. Zoning (begin_nested) → .mappings().first() + 13. Success recommendation (begin_nested) → .mappings().all() + 14. Market price (begin_nested) → .mappings().first() + 15. Recent permits (begin_nested) → .mappings().all() + 16. _geotech_risk (industrial count) → .scalar() + 17. _neighbors_summary (neighbor_rows) → .mappings().all() + 18. _neighbors_summary (overlap_row) → .mappings().first() +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from app.main import app + +_CAD = "66:41:0204016:10" +_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" +_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' + + +def _make_mapping(data: dict[str, Any]) -> MagicMock: + m = MagicMock() + m.__getitem__ = lambda self, k: data[k] + m.get = lambda k, default=None: data.get(k, default) + return m + + +def _make_db_for_analyze( + parcel_meta_row: dict[str, Any] | None = None, +) -> MagicMock: + """Сконструировать mock DB Session для analyze_parcel с фокусом на parcel_meta. + + parcel_meta_row=None → имитирует "строки нет в cad_parcels" → parcel_meta=None. + parcel_meta_row={...} → имитирует найденную строку. + """ + db = MagicMock() + + geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}) + wkt_row = _make_mapping({"wkt": _WKT}) + district_row = _make_mapping( + { + "district_name": "Октябрьский", + "median_price_per_m2": 120000, + "dist_to_center": 1500.0, + } + ) + centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605}) + + pm_mock = _make_mapping(parcel_meta_row) if parcel_meta_row is not None else None + + call_idx = [0] + responses: list[Any] = [ + ("first", geom_row), # 0: geom UNION ALL + ("first", wkt_row), # 1: WKT + ("first", district_row), # 2: district + ("all", []), # 3: POI rows + ("all", []), # 4: competitor rows + ("all", []), # 5: pipeline rows + ("first", centroid_row), # 6: centroid + ("all", []), # 7: noise rows + ("all", []), # 8: hydrology rows + ("all", []), # 9: utilities rows + ("first", pm_mock), # 10: parcel_meta ← #29 G2 + ("first", None), # 11: market trend + ("first", None), # 12: zoning (begin_nested) + ("all", []), # 13: success recommendation (begin_nested) + ("first", None), # 14: market price (begin_nested) + ("all", []), # 15: recent permits (begin_nested) + ("scalar", 0), # 16: geotech_risk + ("all", []), # 17: neighbors + ("first", None), # 18: overlap + ] + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + idx = call_idx[0] + call_idx[0] += 1 + if idx >= len(responses): + r = MagicMock() + r.mappings.return_value.first.return_value = None + r.mappings.return_value.all.return_value = [] + r.scalar.return_value = 0 + return r + kind, data = responses[idx] + r = MagicMock() + r.mappings.return_value.first.return_value = data + r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] + r.scalar.return_value = data if kind == "scalar" else 0 + return r + + db.execute.side_effect = _execute_side_effect + + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +_PATCHES = [ + patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), + patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), + patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_risk_zones": [], + "nspd_opportunity_parcels": [], + "nspd_red_lines": [], + "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, + }, + ), + patch("app.api.v1.parcels.compute_velocity", return_value=None), + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), +] + + +def _start_patches() -> None: + for p in _PATCHES: + p.start() + + +def _stop_patches() -> None: + for p in _PATCHES: + p.stop() + + +def test_parcel_meta_found_in_cad_parcels() -> None: + """analyze → cad_parcels has row → parcel_meta содержит permitted_use/land_category/cad_cost.""" + from app.core.db import get_db + + pm_data: dict[str, Any] = { + "permitted_use": "многоквартирный дом", + "land_category": "Земли населённых пунктов", + "land_subtype": "жилая застройка", + "cad_cost": 5_000_000.0, + } + db = _make_db_for_analyze(parcel_meta_row=pm_data) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + assert "parcel_meta" in body, "parcel_meta отсутствует в ответе" + pm = body["parcel_meta"] + assert pm is not None, "parcel_meta должен быть не None при наличии строки" + assert pm["permitted_use"] == "многоквартирный дом" + assert pm["land_category"] == "Земли населённых пунктов" + assert pm["land_subtype"] == "жилая застройка" + assert pm["cad_cost"] == 5_000_000.0 + assert pm["source"] == "cad_parcels" + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_parcel_meta_none_when_cad_parcels_missing() -> None: + """analyze → cad_parcels row отсутствует → parcel_meta == None.""" + from app.core.db import get_db + + db = _make_db_for_analyze(parcel_meta_row=None) + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + assert "parcel_meta" in body, "parcel_meta ключ должен присутствовать в ответе" + assert body["parcel_meta"] is None, "parcel_meta должен быть None при отсутствии строки" + finally: + app.dependency_overrides.clear() + _stop_patches() diff --git a/backend/tests/api/v1/test_analyze_recent_permits.py b/backend/tests/api/v1/test_analyze_recent_permits.py index 1549da02..e2db9849 100644 --- a/backend/tests/api/v1/test_analyze_recent_permits.py +++ b/backend/tests/api/v1/test_analyze_recent_permits.py @@ -5,7 +5,7 @@ 2. analyze с quarter без permits → recent_permits=[], summary={rns_count=0, ...} 3. analyze 404 на invalid cad (no regression) -Порядок db.execute calls в analyze_parcel (после #105 Phase 5): +Порядок db.execute calls в analyze_parcel (после #105 Phase 5 + #29 G2): 0. UNION ALL geom + source → .mappings().first() 1. WKT query → .mappings().first() 2. District → .mappings().first() @@ -16,14 +16,15 @@ 7. Noise rows → .mappings().all() 8. Hydrology → .mappings().all() 9. Utilities → .mappings().all() - 10. Market trend → .mappings().first() - 11. Zoning (begin_nested) → .mappings().first() - 12. Success recommendation (begin_nested) → .mappings().all() - 13. Market price (begin_nested) → .mappings().first() - 14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 - 15. _geotech_risk (industrial count) → .scalar() - 16. _neighbors_summary (neighbor_rows) → .mappings().all() - 17. _neighbors_summary (overlap_row) → .mappings().first() + 10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2 + 11. Market trend → .mappings().first() + 12. Zoning (begin_nested) → .mappings().first() + 13. Success recommendation (begin_nested) → .mappings().all() + 14. Market price (begin_nested) → .mappings().first() + 15. Recent permits (begin_nested) → .mappings().all() ← #105 + 16. _geotech_risk (industrial count) → .scalar() + 17. _neighbors_summary (neighbor_rows) → .mappings().all() + 18. _neighbors_summary (overlap_row) → .mappings().first() """ from __future__ import annotations @@ -95,14 +96,15 @@ def _make_db_for_analyze( ("all", []), # 7: noise rows ("all", []), # 8: hydrology rows ("all", []), # 9: utilities rows - ("first", None), # 10: market trend - ("first", None), # 11: zoning (begin_nested) - ("all", []), # 12: success recommendation (begin_nested) - ("first", None), # 13: market price (begin_nested) - ("all", raw_permits), # 14: recent permits (begin_nested) ← #105 - ("scalar", 0), # 15: geotech_risk - ("all", []), # 16: neighbors - ("first", None), # 17: overlap + ("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2 + ("first", None), # 11: market trend + ("first", None), # 12: zoning (begin_nested) + ("all", []), # 13: success recommendation (begin_nested) + ("first", None), # 14: market price (begin_nested) + ("all", raw_permits), # 15: recent permits (begin_nested) ← #105 + ("scalar", 0), # 16: geotech_risk + ("all", []), # 17: neighbors + ("first", None), # 18: overlap ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py index d5eb04e9..903f0f7a 100644 --- a/backend/tests/test_gate_verdict.py +++ b/backend/tests/test_gate_verdict.py @@ -1,6 +1,9 @@ """Tests for gate_verdict aggregator — pure function, no DB.""" -from app.services.site_finder.gate_verdict import compute_gate_verdict, is_residential_zone +from app.services.site_finder.gate_verdict import ( + compute_gate_verdict, + is_residential_zone, +) # ── is_residential_zone unit tests ──────────────────────────────────────────── @@ -115,3 +118,90 @@ def test_pzz_unknown_warning_when_no_zoning(): verdict = compute_gate_verdict(None, [], [{"name": "ТП-1"}], {"available": True}) assert any(w["code"] == "PZZ_UNKNOWN" for w in verdict["warnings"]) assert verdict["can_build_mkd"] is True + + +# ── cad_zouit fallback path (#232) ──────────────────────────────────────────── + + +def test_cad_zouit_ohranaya_zona_truboprovodov_blocks(): + """cad_zouit overlap с 'охранная зона трубопроводов' → ZOUIT_CAD_BLOCKER → Нельзя.""" + nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"} + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Охранная зона трубопроводов", + "layer": "Охранная зона трубопроводов", + "name": "Газопровод высокого давления", + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) + assert verdict["can_build_mkd"] is False + assert verdict["verdict_label"] == "Нельзя" + assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"]) + + +def test_cad_zouit_elektr_blocks(): + """cad_zouit overlap с 'электр' substring в type_zone → blocker.""" + nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"} + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Охранная зона электросетевого объекта", + "layer": "Охранная зона электросетевого объекта", + "name": "ЛЭП 110 кВ", + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) + assert verdict["can_build_mkd"] is False + assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"]) + + +def test_cad_zouit_szz_is_warning_not_blocker(): + """cad_zouit overlap с 'СЗЗ' → ZOUIT_CAD_SZZ warning, не blocker.""" + nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"} + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Санитарно-защитная зона предприятия", + "layer": "Санитарно-защитная зона предприятия", + "name": "СЗЗ завода", + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True}) + assert verdict["can_build_mkd"] is True + assert verdict["verdict_label"] == "С ограничениями" + assert any(w["code"] == "ZOUIT_CAD_SZZ" for w in verdict["warnings"]) + assert verdict["blockers"] == [] + + +def test_cad_zouit_other_is_warning(): + """cad_zouit overlap без blocker/СЗЗ keyword → ZOUIT_CAD_OTHER warning.""" + nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"} + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Зона публичного сервитута", + "layer": "Зона публичного сервитута", + "name": None, + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True}) + assert verdict["can_build_mkd"] is True + assert any(w["code"] == "ZOUIT_CAD_OTHER" for w in verdict["warnings"]) + assert verdict["blockers"] == [] + + +def test_nspd_subcategory_path_backward_compat(): + """NSPD dump path (source != 'cad_zouit') продолжает работать через subcategory.""" + nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"} + overlaps = [ + { + "source": "nspd-quarter-dump", + "subcategory": 17, + "name": "ЛЭП 110кВ", + "layer": "37578", + } + ] + verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) + assert verdict["can_build_mkd"] is False + assert any(b["code"] == "ZOUIT_OVERLAP_SUB17" for b in verdict["blockers"])