6 changed files with 272 additions and 25 deletions
|
|
@ -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 для загрузки."
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "строить"
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 — синтез ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue