diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index da6a9b8a..97271169 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2338,8 +2338,14 @@ def analyze_parcel( cost_index AS cost_index_per_m2, land_record_category_type AS land_category, permitted_use_established_by_document AS permitted_use_text, - cost_registration_date AS last_egrn_update_date, - land_record_area AS area_m2, + -- #1954: cost_registration_date — мёртвая колонка (0/42233 заполнено). + -- Репойнт на updated_at (последнее обновление записи, 42233/42233). + updated_at AS last_egrn_update_date, + -- #1954: land_record_area часто NULL (29424/42233); добираем + -- specified_area / declared_area, чтобы площадь не была «—». + COALESCE( + land_record_area, specified_area, declared_area + ) AS area_m2, ownership_type, right_type, status, @@ -2673,10 +2679,7 @@ def analyze_parcel( market_trend = { "status": "source_stale", "as_of_date": max_period.isoformat(), - "label": ( - "Источник сделок устарел " - f"(данные до {max_period.strftime('%m.%Y')})" - ), + "label": (f"Источник сделок устарел (данные до {max_period.strftime('%m.%Y')})"), "recent_deals_count": 0, } else: @@ -2902,9 +2905,7 @@ def analyze_parcel( ) _grp_n = int(grp_row["n"]) if grp_row and grp_row["n"] is not None else 0 _grp_nc = ( - int(grp_row["n_complexes"]) - if grp_row and grp_row["n_complexes"] is not None - else 0 + int(grp_row["n_complexes"]) if grp_row and grp_row["n_complexes"] is not None else 0 ) # Порог устойчивости медианы: n≥10 лотов И ≥2 ЖК (один ЖК не должен доминировать). if ( @@ -3249,8 +3250,7 @@ def analyze_parcel( # analyze-рекомендатор (топ-квартирография по district). if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None: success_recommendation["gate_caveat"] = ( - "Строительство МКД под вопросом (gate: Нельзя) — " - "рекомендация носит условный характер" + "Строительство МКД под вопросом (gate: Нельзя) — рекомендация носит условный характер" ) # PR-4 (#1881): мост финмодели — синтез лёгкого ТЭП из buildability участка @@ -4010,7 +4010,7 @@ def parcel_snapshot_pdf( raise HTTPException( status_code=404, detail=( - f"Участок {cad_num} не найден в БД. " "Используйте POST /analyze для загрузки." + f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки." ), ) diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index 6e9ae21e..e0bc0855 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -286,10 +286,15 @@ def _component_confidences( conf = _confidence_of(source) if conf is not None: out.append((name, conf)) - for f in forecasts: - conf = _confidence_of(f) - if conf is not None: - out.append(("forecasts", conf)) + # #1958: forecasts отдаёт по одному confidence НА КАЖДЫЙ горизонт (#952: 3/6/12/24 + # мес). Раньше каждый горизонт эмитился отдельной парой → в списке факторов #990 + # «Прогноз спрос/предложение» дублировался ×N (обычно ×4) с одинаковой меткой. + # Сворачиваем в ОДИН фактор weakest-link'ом (MIN ранга по горизонтам) — худший + # горизонт тянет общий прогноз вниз, как и остальной weakest-link стек. + forecast_confs = [c for c in (_confidence_of(f) for f in forecasts) if c is not None] + if forecast_confs: + weakest = min(forecast_confs, key=lambda c: _CONFIDENCE_RANK[c]) + out.append(("forecasts", weakest)) return out @@ -564,7 +569,7 @@ def _build_signal(deficit_index: Any) -> str | None: недонасыщенность → «строить», <−0.05 затоварка → «избегать», иначе «баланс». deficit_index None (тонкие данные) → None (фронт рисует «тонкие данные», НЕ 0/сигнал). """ - if not isinstance(deficit_index, (int, float)) or isinstance(deficit_index, bool): + if not isinstance(deficit_index, int | float) or isinstance(deficit_index, bool): return None if deficit_index > _SIGNAL_BALANCE_EPS: return "строить" diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index d8c6e005..dd493801 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -65,6 +65,70 @@ EMPTY_DUMP_RESULT: dict[str, Any] = { }, } +# Унифицированный group_key для ЗОУИТ-overlaps (#1957). Оба пути (nspd-dump +# _get_zouit_overlaps и cad_zouit fallback _get_cad_zouit_overlaps) эмитят один из +# этих ключей; фронт NspdZouitOverlapsBlock.GROUP_LABELS уже знает их (кроме сырого +# 'cad_zouit', который мы перестаём отдавать). NSPD-слой → наш group_key. +_ZOUIT_LAYER_GROUP: dict[str, str] = { + "engineering": "engineering", + "okn": "okn", + "natural": "natural", + "protected": "protected", + "other": "other", +} + +# NSPD ЗОУИТ subcategory-код (category 36940) → человекочитаемый тип зоны (#1957). +# В дампе features несут ТОЛЬКО опаковый числовой `subcategory` без name/type_zone → +# раньше отдавали blank-строки. Коды подтверждены кросс-джойном dump↔cad_zouit на +# проде (17/26/31/41 — точное совпадение type_zone) + классификатор НСПД «Виды ЗОУИТ». +# Неизвестный код → None (graceful: фронт покажет group-label без точечного типа). +_ZOUIT_SUBCATEGORY_TYPE: dict[int, str] = { + # protected (СЗЗ / охраняемые объекты) + 22: "Зона охраняемого объекта", + 26: "Санитарно-защитная зона", + 27: "Зона санитарной охраны источников водоснабжения", + # engineering (охранные зоны инж. коммуникаций) + 14: "Охранная зона линий и сооружений связи", + 15: "Охранная зона электросетевого хозяйства", + 16: "Охранная зона газораспределительных сетей", + 17: "Охранная зона инженерных коммуникаций", + 18: "Охранная зона трубопроводов", + 19: "Охранная зона тепловых сетей", + 28: "Охранная зона особо охраняемой природной территории", + # natural (природные территории) + 5: "Особо охраняемая природная территория", + 6: "Водоохранная зона", + 7: "Прибрежная защитная полоса", + 35: "Зона затопления / подтопления", + # okn (объекты культурного наследия) + 4: "Защитная зона объекта культурного наследия", + 12: "Охранная зона объекта культурного наследия", + 13: "Зона охраны объекта культурного наследия", + 20: "Территория объекта культурного наследия", + # other + 31: "Зона публичного сервитута", + 39: "Придорожная полоса автомобильной дороги", + 40: "Охранная зона стационарного пункта наблюдений", + 41: "Охранная зона инженерных сетей", +} + + +def _zouit_subcategory_type(raw: Any) -> tuple[int | None, str | None]: + """NSPD subcategory-код → (int-код, RU-тип) или (None, None). PURE (#1957). + + `raw` приходит как str/int из dump props. Невалидный/неизвестный код → тип None + (фронт деградирует к group-label). Возвращаем и нормализованный int — он же едет + в поле `subcategory` ответа (фронт умеет string|number|null). + """ + code: int | None = None + if raw is not None: + try: + code = int(raw) + except (TypeError, ValueError): + code = None + type_zone = _ZOUIT_SUBCATEGORY_TYPE.get(code) if code is not None else None + return code, type_zone + def make_empty_result( *, @@ -433,13 +497,25 @@ def _get_zouit_overlaps( 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_") + layer_suffix = layer.removeprefix("zouit_") + group_key = _ZOUIT_LAYER_GROUP.get(layer_suffix, "other") + # #1957: dump-фичи несут только опаковый числовой subcategory (например + # 26 = СЗЗ) без name/type_zone → раньше отдавали blank-строки. Маппим код + # в RU-тип; рег-номер границы лежит в props.options.reg_numb_border + # (дублируется в descr/label/externalKey — берём первый доступный). + subcat_code, type_zone = _zouit_subcategory_type(props.get("subcategory")) + options = props.get("options") + reg = options.get("reg_numb_border") if isinstance(options, dict) else None + reg = reg or props.get("descr") or props.get("label") or props.get("externalKey") + name = props.get("name") or props.get("object_name") or type_zone 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"), + "subcategory": subcat_code, + "name": name, + "type_zone": type_zone, + "reg_numb_border": reg, "coverage_pct": _coerce_coverage(r[2]), "raw_props": props, "source": "nspd-quarter-dump", @@ -466,10 +542,16 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any] # GEOGRAPHY(4326) → ST_Intersection требует geometry-операндов, поэтому intersect # считаем в planar 4326 (CAST(... AS geometry)), затем результат и делитель кастуем # в ::geography для корректных м². NULLIF guard от деления на ноль → coverage None. + # #1957: cad_zouit хранит одну физическую зону 2× (одинаковый reg_numb_border, + # разный category_name) → дубль-строки в overlaps. DISTINCT ON (reg_numb_border) + # схлопывает в одну, оставляя наименьший id (ORDER BY reg_numb_border, id). + # reg_numb_border — TEXT NOT NULL (0 NULL на проде), так что рег-номер есть у + # каждой строки и дедуп идёт по реальному идентификатору зоны. rows = db.execute( text( """ - SELECT type_zone, + SELECT DISTINCT ON (reg_numb_border) + type_zone, category_name, name_by_doc AS name, reg_numb_border, @@ -485,7 +567,7 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any] ) AS coverage_pct FROM cad_zouit WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326)) - ORDER BY id + ORDER BY reg_numb_border, id """ ), {"wkt": parcel_wkt}, @@ -500,10 +582,16 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any] net_kind = classify_network_zone(type_zone) result.append( { - "group_key": "cad_zouit", + # #1957: унифицируем group_key с nspd-dump path — 'protected' + # (cad_zouit fallback несёт СЗЗ/охранные зоны; фронт уже знает + # 'protected' → «Охраняемые зоны»). Сырой 'cad_zouit' (без RU-метки) + # больше не отдаём. + "group_key": "protected", "layer": type_zone, "subcategory": None, # subcategory = 100% NULL в cad_zouit "name": r[2], + # #1957: top-level reg_numb_border — единый shape с nspd-dump path. + "reg_numb_border": r[3], "raw_props": { "type_zone": type_zone, "category_name": r[1], diff --git a/backend/tests/services/forecasting/test_report_assembler.py b/backend/tests/services/forecasting/test_report_assembler.py index 20fddbdb..85cb7b09 100644 --- a/backend/tests/services/forecasting/test_report_assembler.py +++ b/backend/tests/services/forecasting/test_report_assembler.py @@ -31,6 +31,7 @@ from app.services.forecasting.report import SiteFinderReport from app.services.forecasting.report_assembler import ( _analog_count, _as_dict_or, + _component_confidences, _confounded, _deal_count, _domrf_coverage, @@ -477,6 +478,63 @@ class TestConfidence: assert "confounded_window" not in factors +# ── _component_confidences — per-horizon forecast collapse (#1958) ──────────── + + +class TestComponentConfidencesForecastCollapse: + """#1958: forecasts отдаёт confidence НА КАЖДЫЙ горизонт; раньше каждый горизонт + эмитился отдельной парой → «Прогноз спрос/предложение» дублировался ×N в факторах. + Теперь сворачиваем в ОДНУ пару weakest-link'ом (MIN ранга по горизонтам).""" + + def test_four_horizons_collapse_to_one_forecast_pair(self) -> None: + forecasts = [ + {"horizon_months": 6, "confidence": "high"}, + {"horizon_months": 12, "confidence": "high"}, + {"horizon_months": 18, "confidence": "high"}, + {"horizon_months": 24, "confidence": "high"}, + ] + out = _component_confidences(None, None, forecasts, None, None) + forecast_pairs = [p for p in out if p[0] == "forecasts"] + assert len(forecast_pairs) == 1, "per-горизонт forecast confidences не свёрнуты (#1958)" + assert forecast_pairs[0] == ("forecasts", "high") + + def test_weakest_link_min_across_horizons(self) -> None: + # Худший горизонт (low) тянет общий forecast-фактор вниз. + forecasts = [ + {"horizon_months": 6, "confidence": "high"}, + {"horizon_months": 12, "confidence": "medium"}, + {"horizon_months": 18, "confidence": "low"}, + {"horizon_months": 24, "confidence": "high"}, + ] + out = _component_confidences(None, None, forecasts, None, None) + forecast_pairs = [p for p in out if p[0] == "forecasts"] + assert forecast_pairs == [("forecasts", "low")] + + def test_no_forecast_pair_when_all_confidences_missing(self) -> None: + forecasts = [ + {"horizon_months": 6}, # нет confidence + {"horizon_months": 12, "confidence": "garbage"}, # не whitelisted + ] + out = _component_confidences(None, None, forecasts, None, None) + assert [p for p in out if p[0] == "forecasts"] == [] + + def test_other_service_pairs_intact(self) -> None: + # Свёртка forecasts не должна задевать остальные per-service факторы. + out = _component_confidences( + {"confidence": "medium"}, # market_metrics + {"confidence": "high"}, # future_supply + [{"horizon_months": 12, "confidence": "low"}], + {"confidence": "high"}, # product_scores + {"confidence": "medium"}, # special_indices + ) + names = [p[0] for p in out] + assert names.count("forecasts") == 1 + assert ("market_metrics", "medium") in out + assert ("future_supply", "high") in out + assert ("product_scores", "high") in out + assert ("special_indices", "medium") in out + + # ── exec_summary — синтез ───────────────────────────────────────────────────── diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py index d98e1908..87ef06a1 100644 --- a/backend/tests/services/test_quarter_dump_lookup.py +++ b/backend/tests/services/test_quarter_dump_lookup.py @@ -24,6 +24,8 @@ from app.services.site_finder.quarter_dump_lookup import ( _get_opportunity_parcels, _get_red_lines, _get_risk_zones, + _get_zouit_overlaps, + _zouit_subcategory_type, derive_quarter_cad, get_quarter_dump_data, make_empty_result, @@ -567,11 +569,15 @@ def test_get_cad_zouit_overlaps_returns_correct_schema() -> None: assert len(result) == 2 lep = result[0] - assert lep["group_key"] == "cad_zouit" + # #1957: group_key унифицирован на 'protected' (был сырой 'cad_zouit' без RU-метки). + assert lep["group_key"] == "protected" + # source остаётся 'cad_zouit' — gate_verdict.py ветвится по нему (keyword-классификация). assert lep["source"] == "cad_zouit" assert lep["type_zone"] == "Охранная зона ЛЭП 110кВ" assert lep["subcategory"] is None # всегда NULL в cad_zouit assert lep["name"] == "ВЛ-110" + # #1957: top-level reg_numb_border — единый shape с nspd-dump path. + assert lep["reg_numb_border"] == "66-123" assert lep["raw_props"]["zouit_id"] == 7 # area-gate: coverage_pct проброшен (0..1) и нормализован assert lep["coverage_pct"] == 0.42 @@ -582,6 +588,96 @@ def test_get_cad_zouit_overlaps_returns_correct_schema() -> None: assert result[1]["coverage_pct"] is None # NULL coverage (делитель 0) → None +# ── #1957: NSPD subcategory → human type map + dump-path enrichment ─────────── + + +def test_zouit_subcategory_type_known_codes() -> None: + """Известный subcategory-код → (int, RU-тип). #1957 СЗЗ=26 обязателен.""" + assert _zouit_subcategory_type(26) == (26, "Санитарно-защитная зона") + assert _zouit_subcategory_type("26") == (26, "Санитарно-защитная зона") # str из dump + assert _zouit_subcategory_type(17) == (17, "Охранная зона инженерных коммуникаций") + + +def test_zouit_subcategory_type_unknown_and_invalid() -> None: + """Неизвестный/невалидный код → тип None (graceful), int нормализован если возможно.""" + assert _zouit_subcategory_type(9999) == (9999, None) # код вне карты + assert _zouit_subcategory_type("garbage") == (None, None) # не int + assert _zouit_subcategory_type(None) == (None, None) + + +def test_dump_zouit_overlap_maps_subcategory_26_to_szz() -> None: + """#1957: dump-фича с subcategory=26 (СЗЗ) и БЕЗ name/type_zone больше не blank. + + type_zone заполняется из карты, reg читается из props.options.reg_numb_border, + group_key унифицирован ('protected'), name fallback → type_zone. + """ + props = { + "subcategory": 26, + "descr": "66:41-6.15834", + "label": "66:41-6.15834", + "options": {"reg_numb_border": "66:41-6.15834"}, + } + row = MagicMock() + row.__getitem__ = lambda self, i: {0: "zouit_protected", 1: props, 2: 0.12}[i] + + db = MagicMock() + exec_result = MagicMock() + exec_result.fetchall.return_value = [row] + db.execute.return_value = exec_result + + # layer_counts с zouit_count>0 → идём по dump-path (не cad_zouit fallback). + overlaps = _get_zouit_overlaps( + db, "66:41:0205010", "POLYGON((0 0,1 0,1 1,0 0))", {"zouit_count": 3} + ) + assert len(overlaps) == 1 + o = overlaps[0] + assert o["group_key"] == "protected" + assert o["subcategory"] == 26 # нормализован к int + assert o["type_zone"] == "Санитарно-защитная зона" # из карты (был null → blank) + assert o["name"] == "Санитарно-защитная зона" # name отсутствовал → fallback на тип + assert o["reg_numb_border"] == "66:41-6.15834" # из options + assert o["source"] == "nspd-quarter-dump" + + +def test_dump_zouit_overlap_unknown_subcategory_graceful() -> None: + """Неизвестный subcategory-код → type_zone None, но фактор не теряется.""" + props = {"subcategory": 9999, "options": {"reg_numb_border": "66:41-9.99"}} + row = MagicMock() + row.__getitem__ = lambda self, i: {0: "zouit_other", 1: props, 2: None}[i] + + db = MagicMock() + exec_result = MagicMock() + exec_result.fetchall.return_value = [row] + db.execute.return_value = exec_result + + overlaps = _get_zouit_overlaps( + db, "66:41:0205010", "POLYGON((0 0,1 0,1 1,0 0))", {"zouit_count": 1} + ) + assert len(overlaps) == 1 + assert overlaps[0]["group_key"] == "other" + assert overlaps[0]["subcategory"] == 9999 + assert overlaps[0]["type_zone"] is None + assert overlaps[0]["reg_numb_border"] == "66:41-9.99" + + +def test_cad_zouit_overlaps_distinct_on_in_sql() -> None: + """#1957: cad_zouit SELECT использует DISTINCT ON (reg_numb_border) для дедупа. + + Дубль-строки (одна физ. зона 2× с разным category_name) схлопывает Postgres + через DISTINCT ON — проверяем, что SQL действительно его содержит (мок не + выполняет SQL, поэтому верифицируем форму запроса).""" + db = MagicMock() + exec_result = MagicMock() + exec_result.fetchall.return_value = [] + db.execute.return_value = exec_result + + _get_cad_zouit_overlaps(db, "POLYGON((0 0,1 0,1 1,0 0))") + + sql_text = str(db.execute.call_args[0][0]) + assert "DISTINCT ON (reg_numb_border)" in sql_text + assert "ORDER BY reg_numb_border, id" in sql_text + + def test_nspd_path_used_when_zouit_count_nonzero() -> None: """#243 backward compat: когда dump существует с zouit_count>0 — NSPD path, не cad_zouit. diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index 0f7001ff..bc0d84aa 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -19,7 +19,7 @@ export interface NspdZoning { } export interface NspdZouitOverlap { - group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other", "cad_zouit" + group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other" layer: string; // e.g. "zouit_engineering"; для cad_zouit fallback = type_zone subcategory: string | number | null; name: string | null;