diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index fbf5c463..85422aa1 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -146,9 +146,14 @@ def get_quarter_dump_data( max_age = timedelta(days=_DUMP_MAX_AGE_DAYS) if row is None: - # Дампа нет — ставим harvest в очередь + # Дампа нет — ставим harvest в очередь и делаем fallback на cad_zouit (#243). harvest_triggered = _trigger_harvest(quarter) - return make_empty_result(harvest_triggered=harvest_triggered) + cad_zouit_overlaps: list[dict[str, Any]] = ( + _get_cad_zouit_overlaps(db, parcel_wkt) if parcel_wkt is not None else [] + ) + result = make_empty_result(harvest_triggered=harvest_triggered) + result["nspd_zouit_overlaps"] = cad_zouit_overlaps + return result fetched_at: datetime = row[1] # Убедимся что timezone-aware для корректного сравнения diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index 3b23a011..b11bd352 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -20,10 +20,12 @@ import pytest from app.services.site_finder.quarter_dump_lookup import ( EMPTY_DUMP_RESULT, _extract_features_by_layer, + _get_cad_zouit_overlaps, _get_opportunity_parcels, _get_red_lines, _get_risk_zones, derive_quarter_cad, + get_quarter_dump_data, make_empty_result, ) @@ -402,3 +404,153 @@ def test_make_empty_result_has_tier4_keys() -> None: ) def test_derive_quarter_cad(cad: str, expected: str | None) -> None: assert derive_quarter_cad(cad) == expected + + +# ── #243: cad_zouit fallback when dump absent ───────────────────────────────── + + +def _make_mock_db_no_dump_with_cad_zouit( + cad_zouit_rows: list[tuple[Any, ...]], +) -> MagicMock: + """Мок Session: nspd_quarter_dumps.first() → None; cad_zouit.fetchall() → rows.""" + db = MagicMock() + first_result = MagicMock() + first_result.first.return_value = None # нет дампа + + fetchall_result = MagicMock() + fetchall_result.fetchall.return_value = cad_zouit_rows + + # Первый вызов execute (SELECT nspd_quarter_dumps) → first()=None + # Второй вызов execute (SELECT cad_zouit) → fetchall()=rows + db.execute.side_effect = [first_result, fetchall_result] + return db + + +def test_cad_zouit_fallback_fires_when_no_dump() -> None: + """#243: cad_zouit fallback срабатывает когда nspd_quarter_dumps пуст глобально. + + Ожидаем: nspd_zouit_overlaps непустой, source='cad_zouit'. + """ + cad_zouit_rows: list[tuple[Any, ...]] = [ + # (type_zone, category_name, name_by_doc, reg_numb_border, id) + ("Охранная зона трубопровода", "Трубопровод", "ГВС ул. Ленина", "66-66-00/123", 42), + ] + db = _make_mock_db_no_dump_with_cad_zouit(cad_zouit_rows) + + wkt = "POLYGON((60 56,61 56,61 57,60 57,60 56))" + result = get_quarter_dump_data(db, "66:41:0603016:194", wkt) + + overlaps: list[dict[str, Any]] = result["nspd_zouit_overlaps"] + assert len(overlaps) == 1 + assert overlaps[0]["source"] == "cad_zouit" + assert overlaps[0]["type_zone"] == "Охранная зона трубопровода" + assert overlaps[0]["name"] == "ГВС ул. Ленина" + # dump.available остаётся False (нет дампа) + assert result["nspd_dump"]["available"] is False + + +def test_cad_zouit_fallback_no_dump_no_parcel_wkt() -> None: + """#243: dump=None + parcel_wkt=None → overlaps пустой, не вызываем cad_zouit.""" + db = MagicMock() + first_result = MagicMock() + first_result.first.return_value = None + db.execute.return_value = first_result + + result = get_quarter_dump_data(db, "66:41:0603016:194", None) + + assert result["nspd_zouit_overlaps"] == [] + # cad_zouit SELECT не должен вызываться (только один execute для dump lookup) + assert db.execute.call_count == 1 + + +def test_cad_zouit_fallback_empty_when_no_overlaps() -> None: + """#243: dump=None + cad_zouit возвращает 0 строк → nspd_zouit_overlaps пуст.""" + db = _make_mock_db_no_dump_with_cad_zouit([]) + + wkt = "POLYGON((60 56,61 56,61 57,60 57,60 56))" + result = get_quarter_dump_data(db, "66:41:0603016:194", wkt) + + assert result["nspd_zouit_overlaps"] == [] + + +def test_get_cad_zouit_overlaps_returns_correct_schema() -> None: + """_get_cad_zouit_overlaps: поля group_key/layer/source/type_zone корректны.""" + rows: list[tuple[Any, ...]] = [ + # (type_zone, category_name, name_by_doc, reg_numb_border, id) + ("Охранная зона ЛЭП 110кВ", "Электроснабжение", "ВЛ-110", "66-123", 7), + ("СЗЗ промышленного объекта", "Санитарная", "Завод", "66-456", 8), + ] + db = _make_mock_db_with_rows(rows) + + result = _get_cad_zouit_overlaps(db, "POLYGON((0 0,1 0,1 1,0 0))") + + assert len(result) == 2 + lep = result[0] + assert lep["group_key"] == "cad_zouit" + assert lep["source"] == "cad_zouit" + assert lep["type_zone"] == "Охранная зона ЛЭП 110кВ" + assert lep["subcategory"] is None # всегда NULL в cad_zouit + assert lep["name"] == "ВЛ-110" + assert lep["raw_props"]["zouit_id"] == 7 + + +def test_nspd_path_used_when_zouit_count_nonzero() -> None: + """#243 backward compat: когда dump существует с zouit_count>0 — NSPD path, не cad_zouit. + + Мок: row.zouit_count=3, fetchall возвращает NSPD features. + Проверяем source='nspd-quarter-dump' (не cad_zouit). + """ + # Строка дампа: все поля через MagicMock + from datetime import UTC, datetime + + dump_row = MagicMock() + dump_row.__getitem__ = lambda self, i: [ + "66:41:0603016", # quarter_cad + datetime(2026, 1, 1, tzinfo=UTC), # fetched_at_utc (свежий) + 100, # total_features + None, # harvest_error + 1, # territorial_zones_count + 3, # zouit_count > 0 → NSPD path + 0, # engineering_count + 0, # risks_count + 0, # opportunity_count + 0, # red_lines_count + ][i] + + nspd_zouit_rows: list[tuple[Any, ...]] = [ + # (layer, props) + ("zouit_engineering", {"name": "ЛЭП", "subcategory": 17}), + ] + + db = MagicMock() + first_result = MagicMock() + first_result.first.return_value = dump_row + + # Последующие вызовы execute (spatial queries) — возвращаем пустые кроме zouit + zouit_result = MagicMock() + zouit_result.fetchall.return_value = nspd_zouit_rows + + empty_result = MagicMock() + empty_result.fetchall.return_value = [] + empty_result.first.return_value = None + + # [0]=dumps lookup, [1]=zoning(first), [2]=zouit(fetchall), [3+]=others + db.execute.side_effect = [ + first_result, + empty_result, # _get_zoning first() + zouit_result, # _get_zouit_overlaps fetchall() + empty_result, # _get_engineering_nearby fetchall() + empty_result, # _get_risk_zones fetchall() + empty_result, # _get_opportunity_parcels fetchall() + empty_result, # _get_red_lines fetchall() + ] + + result = get_quarter_dump_data( + db, "66:41:0603016:194", "POLYGON((60 56,61 56,61 57,60 57,60 56))" + ) + + overlaps: list[dict[str, Any]] = result["nspd_zouit_overlaps"] + assert len(overlaps) == 1 + assert overlaps[0]["source"] == "nspd-quarter-dump" + # dump.available=True т.к. свежий дамп + assert result["nspd_dump"]["available"] is True