Merge pull request 'fix(report): EGRN площадь/обновлено + forecast-confidence dedup + ЗОУИТ типизация (#1954 #1958 #1957)' (#1973) from fix/report-quickwins-1953 into main
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 2m9s
Deploy / build-frontend (push) Successful in 3m14s
Deploy / build-worker (push) Successful in 3m23s
Deploy / deploy (push) Successful in 1m24s

This commit is contained in:
bot-backend 2026-06-27 09:34:44 +00:00
commit ebdbedcabb
6 changed files with 272 additions and 25 deletions

View file

@ -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 для загрузки."
),
)

View file

@ -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 "строить"

View file

@ -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],

View file

@ -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 — синтез ─────────────────────────────────────────────────────

View file

@ -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.

View file

@ -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;