6 changed files with 272 additions and 25 deletions
|
|
@ -2338,8 +2338,14 @@ def analyze_parcel(
|
||||||
cost_index AS cost_index_per_m2,
|
cost_index AS cost_index_per_m2,
|
||||||
land_record_category_type AS land_category,
|
land_record_category_type AS land_category,
|
||||||
permitted_use_established_by_document AS permitted_use_text,
|
permitted_use_established_by_document AS permitted_use_text,
|
||||||
cost_registration_date AS last_egrn_update_date,
|
-- #1954: cost_registration_date — мёртвая колонка (0/42233 заполнено).
|
||||||
land_record_area AS area_m2,
|
-- Репойнт на 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,
|
ownership_type,
|
||||||
right_type,
|
right_type,
|
||||||
status,
|
status,
|
||||||
|
|
@ -2673,10 +2679,7 @@ def analyze_parcel(
|
||||||
market_trend = {
|
market_trend = {
|
||||||
"status": "source_stale",
|
"status": "source_stale",
|
||||||
"as_of_date": max_period.isoformat(),
|
"as_of_date": max_period.isoformat(),
|
||||||
"label": (
|
"label": (f"Источник сделок устарел (данные до {max_period.strftime('%m.%Y')})"),
|
||||||
"Источник сделок устарел "
|
|
||||||
f"(данные до {max_period.strftime('%m.%Y')})"
|
|
||||||
),
|
|
||||||
"recent_deals_count": 0,
|
"recent_deals_count": 0,
|
||||||
}
|
}
|
||||||
else:
|
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_n = int(grp_row["n"]) if grp_row and grp_row["n"] is not None else 0
|
||||||
_grp_nc = (
|
_grp_nc = (
|
||||||
int(grp_row["n_complexes"])
|
int(grp_row["n_complexes"]) if grp_row and grp_row["n_complexes"] is not None else 0
|
||||||
if grp_row and grp_row["n_complexes"] is not None
|
|
||||||
else 0
|
|
||||||
)
|
)
|
||||||
# Порог устойчивости медианы: n≥10 лотов И ≥2 ЖК (один ЖК не должен доминировать).
|
# Порог устойчивости медианы: n≥10 лотов И ≥2 ЖК (один ЖК не должен доминировать).
|
||||||
if (
|
if (
|
||||||
|
|
@ -3249,8 +3250,7 @@ def analyze_parcel(
|
||||||
# analyze-рекомендатор (топ-квартирография по district).
|
# analyze-рекомендатор (топ-квартирография по district).
|
||||||
if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None:
|
if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None:
|
||||||
success_recommendation["gate_caveat"] = (
|
success_recommendation["gate_caveat"] = (
|
||||||
"Строительство МКД под вопросом (gate: Нельзя) — "
|
"Строительство МКД под вопросом (gate: Нельзя) — рекомендация носит условный характер"
|
||||||
"рекомендация носит условный характер"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# PR-4 (#1881): мост финмодели — синтез лёгкого ТЭП из buildability участка
|
# PR-4 (#1881): мост финмодели — синтез лёгкого ТЭП из buildability участка
|
||||||
|
|
@ -4010,7 +4010,7 @@ def parcel_snapshot_pdf(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=(
|
detail=(
|
||||||
f"Участок {cad_num} не найден в БД. " "Используйте POST /analyze для загрузки."
|
f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -286,10 +286,15 @@ def _component_confidences(
|
||||||
conf = _confidence_of(source)
|
conf = _confidence_of(source)
|
||||||
if conf is not None:
|
if conf is not None:
|
||||||
out.append((name, conf))
|
out.append((name, conf))
|
||||||
for f in forecasts:
|
# #1958: forecasts отдаёт по одному confidence НА КАЖДЫЙ горизонт (#952: 3/6/12/24
|
||||||
conf = _confidence_of(f)
|
# мес). Раньше каждый горизонт эмитился отдельной парой → в списке факторов #990
|
||||||
if conf is not None:
|
# «Прогноз спрос/предложение» дублировался ×N (обычно ×4) с одинаковой меткой.
|
||||||
out.append(("forecasts", conf))
|
# Сворачиваем в ОДИН фактор 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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -564,7 +569,7 @@ def _build_signal(deficit_index: Any) -> str | None:
|
||||||
недонасыщенность → «строить», <−0.05 затоварка → «избегать», иначе «баланс».
|
недонасыщенность → «строить», <−0.05 затоварка → «избегать», иначе «баланс».
|
||||||
deficit_index None (тонкие данные) → None (фронт рисует «тонкие данные», НЕ 0/сигнал).
|
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
|
return None
|
||||||
if deficit_index > _SIGNAL_BALANCE_EPS:
|
if deficit_index > _SIGNAL_BALANCE_EPS:
|
||||||
return "строить"
|
return "строить"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
def make_empty_result(
|
||||||
*,
|
*,
|
||||||
|
|
@ -433,13 +497,25 @@ def _get_zouit_overlaps(
|
||||||
for r in rows:
|
for r in rows:
|
||||||
layer: str = r[0] or ""
|
layer: str = r[0] or ""
|
||||||
props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {}
|
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(
|
result.append(
|
||||||
{
|
{
|
||||||
"group_key": group_key,
|
"group_key": group_key,
|
||||||
"layer": layer,
|
"layer": layer,
|
||||||
"subcategory": props.get("subcategory") or props.get("type_zone"),
|
"subcategory": subcat_code,
|
||||||
"name": props.get("name") or props.get("object_name"),
|
"name": name,
|
||||||
|
"type_zone": type_zone,
|
||||||
|
"reg_numb_border": reg,
|
||||||
"coverage_pct": _coerce_coverage(r[2]),
|
"coverage_pct": _coerce_coverage(r[2]),
|
||||||
"raw_props": props,
|
"raw_props": props,
|
||||||
"source": "nspd-quarter-dump",
|
"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
|
# GEOGRAPHY(4326) → ST_Intersection требует geometry-операндов, поэтому intersect
|
||||||
# считаем в planar 4326 (CAST(... AS geometry)), затем результат и делитель кастуем
|
# считаем в planar 4326 (CAST(... AS geometry)), затем результат и делитель кастуем
|
||||||
# в ::geography для корректных м². NULLIF guard от деления на ноль → coverage None.
|
# в ::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(
|
rows = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT type_zone,
|
SELECT DISTINCT ON (reg_numb_border)
|
||||||
|
type_zone,
|
||||||
category_name,
|
category_name,
|
||||||
name_by_doc AS name,
|
name_by_doc AS name,
|
||||||
reg_numb_border,
|
reg_numb_border,
|
||||||
|
|
@ -485,7 +567,7 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
|
||||||
) AS coverage_pct
|
) AS coverage_pct
|
||||||
FROM cad_zouit
|
FROM cad_zouit
|
||||||
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
|
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
|
||||||
ORDER BY id
|
ORDER BY reg_numb_border, id
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"wkt": parcel_wkt},
|
{"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)
|
net_kind = classify_network_zone(type_zone)
|
||||||
result.append(
|
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,
|
"layer": type_zone,
|
||||||
"subcategory": None, # subcategory = 100% NULL в cad_zouit
|
"subcategory": None, # subcategory = 100% NULL в cad_zouit
|
||||||
"name": r[2],
|
"name": r[2],
|
||||||
|
# #1957: top-level reg_numb_border — единый shape с nspd-dump path.
|
||||||
|
"reg_numb_border": r[3],
|
||||||
"raw_props": {
|
"raw_props": {
|
||||||
"type_zone": type_zone,
|
"type_zone": type_zone,
|
||||||
"category_name": r[1],
|
"category_name": r[1],
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from app.services.forecasting.report import SiteFinderReport
|
||||||
from app.services.forecasting.report_assembler import (
|
from app.services.forecasting.report_assembler import (
|
||||||
_analog_count,
|
_analog_count,
|
||||||
_as_dict_or,
|
_as_dict_or,
|
||||||
|
_component_confidences,
|
||||||
_confounded,
|
_confounded,
|
||||||
_deal_count,
|
_deal_count,
|
||||||
_domrf_coverage,
|
_domrf_coverage,
|
||||||
|
|
@ -477,6 +478,63 @@ class TestConfidence:
|
||||||
assert "confounded_window" not in factors
|
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 — синтез ─────────────────────────────────────────────────────
|
# ── exec_summary — синтез ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ from app.services.site_finder.quarter_dump_lookup import (
|
||||||
_get_opportunity_parcels,
|
_get_opportunity_parcels,
|
||||||
_get_red_lines,
|
_get_red_lines,
|
||||||
_get_risk_zones,
|
_get_risk_zones,
|
||||||
|
_get_zouit_overlaps,
|
||||||
|
_zouit_subcategory_type,
|
||||||
derive_quarter_cad,
|
derive_quarter_cad,
|
||||||
get_quarter_dump_data,
|
get_quarter_dump_data,
|
||||||
make_empty_result,
|
make_empty_result,
|
||||||
|
|
@ -567,11 +569,15 @@ def test_get_cad_zouit_overlaps_returns_correct_schema() -> None:
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
lep = result[0]
|
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["source"] == "cad_zouit"
|
||||||
assert lep["type_zone"] == "Охранная зона ЛЭП 110кВ"
|
assert lep["type_zone"] == "Охранная зона ЛЭП 110кВ"
|
||||||
assert lep["subcategory"] is None # всегда NULL в cad_zouit
|
assert lep["subcategory"] is None # всегда NULL в cad_zouit
|
||||||
assert lep["name"] == "ВЛ-110"
|
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
|
assert lep["raw_props"]["zouit_id"] == 7
|
||||||
# area-gate: coverage_pct проброшен (0..1) и нормализован
|
# area-gate: coverage_pct проброшен (0..1) и нормализован
|
||||||
assert lep["coverage_pct"] == 0.42
|
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
|
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:
|
def test_nspd_path_used_when_zouit_count_nonzero() -> None:
|
||||||
"""#243 backward compat: когда dump существует с zouit_count>0 — NSPD path, не cad_zouit.
|
"""#243 backward compat: когда dump существует с zouit_count>0 — NSPD path, не cad_zouit.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export interface NspdZoning {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NspdZouitOverlap {
|
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
|
layer: string; // e.g. "zouit_engineering"; для cad_zouit fallback = type_zone
|
||||||
subcategory: string | number | null;
|
subcategory: string | number | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue