feat(#29,#232): wire cad_parcels.permitted_use in analyze + cad_zouit fallback (G2+G3)
TASK A (#29 G2): add parcel_meta to analyze response - New ParcelMeta Pydantic schema in app/schemas/parcel.py - SELECT from cad_parcels WHERE cad_num=:c in analyze_parcel() (step 9f) - Returns permitted_use_established_by_document, land_record_category_type, land_record_subtype, cost_value; None when row absent - Tests: test_analyze_parcel_meta.py (found + not-found cases) TASK B (#232 G3): cad_zouit fallback in _get_zouit_overlaps - When nspd_quarter_dumps has zouit_count==0, fall back to ST_Intersects query on cad_zouit (3483 rows, GIST indexed) - Overlaps tagged with source='cad_zouit'; format compatible with NSPD path - gate_verdict.py: BLOCKER_TYPE_ZONE_KEYWORDS tuple for keyword-based classification (охранная зона / трубопровод / электр / газ -> blocker; СЗЗ -> warning); NSPD subcategory path preserved backward-compat - Tests: 6 new test cases in test_gate_verdict.py covering cad_zouit path and backward-compat for NSPD subcategory path Updated db.execute call sequence in test_analyze_*.py (index shift +1 at pos 10).
This commit is contained in:
parent
56bf28758c
commit
f70be684da
9 changed files with 551 additions and 92 deletions
|
|
@ -23,6 +23,7 @@ from app.schemas.parcel import (
|
||||||
ConnectionPointsResponse,
|
ConnectionPointsResponse,
|
||||||
OpportunityParcel,
|
OpportunityParcel,
|
||||||
ParcelDetail,
|
ParcelDetail,
|
||||||
|
ParcelMeta,
|
||||||
ParcelSearchRequest,
|
ParcelSearchRequest,
|
||||||
ParcelSearchResponse,
|
ParcelSearchResponse,
|
||||||
RedLine,
|
RedLine,
|
||||||
|
|
@ -1627,6 +1628,35 @@ def analyze_parcel(
|
||||||
# Independent dict per request — never mutate module singleton.
|
# Independent dict per request — never mutate module singleton.
|
||||||
nspd_dump_data = make_empty_result()
|
nspd_dump_data = make_empty_result()
|
||||||
|
|
||||||
|
# 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2)
|
||||||
|
parcel_meta: ParcelMeta | None = None
|
||||||
|
try:
|
||||||
|
pm_row = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT permitted_use_established_by_document AS permitted_use,
|
||||||
|
land_record_category_type AS land_category,
|
||||||
|
land_record_subtype AS land_subtype,
|
||||||
|
cost_value AS cad_cost
|
||||||
|
FROM cad_parcels
|
||||||
|
WHERE cad_num = CAST(:c AS text)
|
||||||
|
LIMIT 1
|
||||||
|
"""),
|
||||||
|
{"c": cad_num},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if pm_row:
|
||||||
|
parcel_meta = ParcelMeta(
|
||||||
|
permitted_use=pm_row["permitted_use"],
|
||||||
|
land_category=pm_row["land_category"],
|
||||||
|
land_subtype=pm_row["land_subtype"],
|
||||||
|
cad_cost=float(pm_row["cad_cost"]) if pm_row["cad_cost"] is not None else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("parcel_meta query failed for %s: %s", cad_num, e)
|
||||||
|
|
||||||
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
||||||
market_trend: dict[str, Any] | None = None
|
market_trend: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -2051,6 +2081,8 @@ def analyze_parcel(
|
||||||
"market_trend": market_trend,
|
"market_trend": market_trend,
|
||||||
# #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2
|
# #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2
|
||||||
"market_price": market_price,
|
"market_price": market_price,
|
||||||
|
# #29 G2: кадастровые метаданные участка (ВРИ, категория, кад. стоимость)
|
||||||
|
"parcel_meta": parcel_meta.model_dump() if parcel_meta is not None else None,
|
||||||
# #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m)
|
# #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m)
|
||||||
"recent_permits_in_quarter": recent_permits,
|
"recent_permits_in_quarter": recent_permits,
|
||||||
"permits_summary": permits_summary,
|
"permits_summary": permits_summary,
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,22 @@ class MarketPrice(BaseModel):
|
||||||
source: Literal["quarter_mv", "no_data"] = "no_data"
|
source: Literal["quarter_mv", "no_data"] = "no_data"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Analyze endpoint parcel meta (#29 G2) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ParcelMeta(BaseModel):
|
||||||
|
"""Кадастровые метаданные участка из cad_parcels (#29 G2).
|
||||||
|
|
||||||
|
Поля берутся из NSPD bulk-ingest (issue #168). Строки сырые, без нормализации.
|
||||||
|
"""
|
||||||
|
|
||||||
|
permitted_use: str | None = None # permitted_use_established_by_document
|
||||||
|
land_category: str | None = None # land_record_category_type
|
||||||
|
land_subtype: str | None = None # land_record_subtype
|
||||||
|
cad_cost: float | None = None # cost_value (кадастровая стоимость, руб.)
|
||||||
|
source: str = "cad_parcels"
|
||||||
|
|
||||||
|
|
||||||
# ── Analyze endpoint inline weights (#201) ────────────────────────────────────
|
# ── Analyze endpoint inline weights (#201) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,25 @@ RESIDENTIAL_KEYWORDS = ("жил",)
|
||||||
|
|
||||||
# ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
|
# ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода)
|
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода).
|
||||||
|
# Используется для overlaps из NSPD dump (subcategory INT там заполнен).
|
||||||
BLOCKER_SUBCATEGORIES: dict[int, str] = {
|
BLOCKER_SUBCATEGORIES: dict[int, str] = {
|
||||||
17: "Инженерные коммуникации (охранная зона ЛЭП/газа/трубопровода)",
|
17: "Инженерные коммуникации (охранная зона ЛЭП/газа/трубопровода)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Keyword-based blockers для cad_zouit fallback (#232).
|
||||||
|
# cad_zouit.subcategory = 100% NULL, поэтому классификация по type_zone substring.
|
||||||
|
# Порядок: lowercase, substring match (any wins → blocker).
|
||||||
|
BLOCKER_TYPE_ZONE_KEYWORDS: tuple[str, ...] = (
|
||||||
|
"охранная зона",
|
||||||
|
"трубопровод",
|
||||||
|
"электр",
|
||||||
|
"газ",
|
||||||
|
)
|
||||||
|
|
||||||
|
# type_zone substrings, которые дают warning (не blocker) из cad_zouit.
|
||||||
|
WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-защитная")
|
||||||
|
|
||||||
# Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py
|
# Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py
|
||||||
ENGINEERING_NEARBY_THRESHOLD_M = 200
|
ENGINEERING_NEARBY_THRESHOLD_M = 200
|
||||||
|
|
||||||
|
|
@ -136,21 +150,57 @@ def compute_gate_verdict(
|
||||||
# Check 2 — ЗОУИТ overlaps
|
# Check 2 — ЗОУИТ overlaps
|
||||||
checks.append("ЗОУИТ пересечения")
|
checks.append("ЗОУИТ пересечения")
|
||||||
for overlap in nspd_zouit_overlaps or []:
|
for overlap in nspd_zouit_overlaps or []:
|
||||||
sub = overlap.get("subcategory")
|
src = overlap.get("source", "nspd-quarter-dump")
|
||||||
if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES:
|
if src == "cad_zouit":
|
||||||
blockers.append(
|
# cad_zouit fallback path: classify by type_zone keywords (#232).
|
||||||
Blocker(
|
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
|
||||||
code=f"ZOUIT_OVERLAP_SUB{sub}",
|
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
|
||||||
detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}",
|
if any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
|
||||||
|
blockers.append(
|
||||||
|
Blocker(
|
||||||
|
code="ZOUIT_CAD_BLOCKER",
|
||||||
|
detail=(
|
||||||
|
f"Охранная зона ({overlap.get('type_zone', '')}): "
|
||||||
|
f"{overlap.get('name', '')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS):
|
||||||
|
warnings.append(
|
||||||
|
Warning(
|
||||||
|
code="ZOUIT_CAD_SZZ",
|
||||||
|
detail=(
|
||||||
|
f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
warnings.append(
|
||||||
|
Warning(
|
||||||
|
code="ZOUIT_CAD_OTHER",
|
||||||
|
detail=(
|
||||||
|
f"ЗОУИТ cad ({overlap.get('type_zone', '')}): "
|
||||||
|
f"{overlap.get('name', '')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
warnings.append(
|
# NSPD dump path: subcategory-based logic (backward-compat).
|
||||||
Warning(
|
sub = overlap.get("subcategory")
|
||||||
code=f"ZOUIT_SUB{sub if sub is not None else 'unknown'}",
|
if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES:
|
||||||
detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}",
|
blockers.append(
|
||||||
|
Blocker(
|
||||||
|
code=f"ZOUIT_OVERLAP_SUB{sub}",
|
||||||
|
detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
warnings.append(
|
||||||
|
Warning(
|
||||||
|
code=f"ZOUIT_SUB{sub if sub is not None else 'unknown'}",
|
||||||
|
detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Check 3 — Engineering nearby (warning only, not a blocker)
|
# Check 3 — Engineering nearby (warning only, not a blocker)
|
||||||
checks.append(f"Инженерные сети в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м")
|
checks.append(f"Инженерные сети в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м")
|
||||||
|
|
|
||||||
|
|
@ -296,54 +296,108 @@ def _get_zouit_overlaps(
|
||||||
Проверяем 5 групп: zouit_okn, zouit_engineering, zouit_natural,
|
Проверяем 5 групп: zouit_okn, zouit_engineering, zouit_natural,
|
||||||
zouit_protected, zouit_other.
|
zouit_protected, zouit_other.
|
||||||
layer_counts — денормализованные счётчики. Если zouit_count == 0 —
|
layer_counts — денормализованные счётчики. Если zouit_count == 0 —
|
||||||
пропускаем heavy jsonb_array_elements scan.
|
пропускаем nspd dump scan и делаем fallback на cad_zouit (#232).
|
||||||
|
"""
|
||||||
|
nspd_has_zouit = layer_counts is None or layer_counts.get("zouit_count", 1) != 0
|
||||||
|
if nspd_has_zouit:
|
||||||
|
try:
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT feat.value->>'layer' AS layer,
|
||||||
|
feat.value->'properties' AS props
|
||||||
|
FROM nspd_quarter_dumps d,
|
||||||
|
jsonb_array_elements(d.features_json) AS feat(value)
|
||||||
|
WHERE d.quarter_cad = :q
|
||||||
|
AND feat.value->>'layer' LIKE 'zouit_%'
|
||||||
|
AND (feat.value->'geometry') IS NOT NULL
|
||||||
|
AND feat.value->>'geometry' != 'null'
|
||||||
|
AND ST_Intersects(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
),
|
||||||
|
ST_GeomFromText(:wkt, 4326)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"q": quarter, "wkt": parcel_wkt},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
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_")
|
||||||
|
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"),
|
||||||
|
"raw_props": props,
|
||||||
|
"source": "nspd-quarter-dump",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("nspd zouit query failed for quarter=%s: %s", quarter, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Fallback: cad_zouit (#232) — nspd dump пуст по ЗОУИТ.
|
||||||
|
# cad_zouit.subcategory = 100% NULL (verified), используем type_zone keywords.
|
||||||
|
return _get_cad_zouit_overlaps(db, parcel_wkt)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]]:
|
||||||
|
"""Fallback: читать ЗОУИТ из cad_zouit когда nspd_quarter_dumps пуст (#232).
|
||||||
|
|
||||||
|
Использует GIST index cad_zouit_geom_gist через ST_Intersects.
|
||||||
|
Возвращает тот же формат что nspd-dump path (с доп. полем source='cad_zouit').
|
||||||
"""
|
"""
|
||||||
if layer_counts is not None and layer_counts.get("zouit_count", 1) == 0:
|
|
||||||
return []
|
|
||||||
try:
|
try:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT feat.value->>'layer' AS layer,
|
SELECT type_zone,
|
||||||
feat.value->'properties' AS props
|
category_name,
|
||||||
FROM nspd_quarter_dumps d,
|
name_by_doc AS name,
|
||||||
jsonb_array_elements(d.features_json) AS feat(value)
|
reg_numb_border,
|
||||||
WHERE d.quarter_cad = :q
|
id AS zouit_id
|
||||||
AND feat.value->>'layer' LIKE 'zouit_%'
|
FROM cad_zouit
|
||||||
AND (feat.value->'geometry') IS NOT NULL
|
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
|
||||||
AND feat.value->>'geometry' != 'null'
|
ORDER BY id
|
||||||
AND ST_Intersects(
|
|
||||||
ST_Transform(
|
|
||||||
ST_SetSRID(
|
|
||||||
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
|
||||||
3857
|
|
||||||
),
|
|
||||||
4326
|
|
||||||
),
|
|
||||||
ST_GeomFromText(:wkt, 4326)
|
|
||||||
)
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"q": quarter, "wkt": parcel_wkt},
|
{"wkt": parcel_wkt},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
result: list[dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
layer: str = r[0] or ""
|
type_zone: str = r[0] or ""
|
||||||
props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {}
|
|
||||||
group_key = layer.removeprefix("zouit_")
|
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"group_key": group_key,
|
"group_key": "cad_zouit",
|
||||||
"layer": layer,
|
"layer": type_zone,
|
||||||
"subcategory": props.get("subcategory") or props.get("type_zone"),
|
"subcategory": None, # subcategory = 100% NULL в cad_zouit
|
||||||
"name": props.get("name") or props.get("object_name"),
|
"name": r[2],
|
||||||
"raw_props": props,
|
"raw_props": {
|
||||||
|
"type_zone": type_zone,
|
||||||
|
"category_name": r[1],
|
||||||
|
"reg_numb_border": r[3],
|
||||||
|
"zouit_id": r[4],
|
||||||
|
},
|
||||||
|
"source": "cad_zouit",
|
||||||
|
"type_zone": type_zone,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
logger.info("cad_zouit fallback: found %d overlaps for wkt prefix", len(result))
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("nspd zouit query failed for quarter=%s: %s", quarter, e)
|
logger.warning("cad_zouit fallback query failed: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,15 @@ def _make_db_for_analyze(
|
||||||
7. Noise rows → .mappings().all()
|
7. Noise rows → .mappings().all()
|
||||||
8. Hydrology → .mappings().all()
|
8. Hydrology → .mappings().all()
|
||||||
9. Utilities → .mappings().all()
|
9. Utilities → .mappings().all()
|
||||||
10. Market trend → .mappings().first()
|
10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2
|
||||||
11. Zoning (begin_nested) → .mappings().first()
|
11. Market trend → .mappings().first()
|
||||||
12. Success recommendation (begin_nested) → .mappings().all()
|
12. Zoning (begin_nested) → .mappings().first()
|
||||||
13. Market price (begin_nested) → .mappings().first()
|
13. Success recommendation (begin_nested) → .mappings().all()
|
||||||
14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 Phase 5
|
14. Market price (begin_nested) → .mappings().first()
|
||||||
15. _geotech_risk (industrial count) → .scalar()
|
15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5
|
||||||
16. _neighbors_summary (neighbor_rows) → .mappings().all()
|
16. _geotech_risk (industrial count) → .scalar()
|
||||||
17. _neighbors_summary (overlap_row) → .mappings().first()
|
17. _neighbors_summary (neighbor_rows) → .mappings().all()
|
||||||
|
18. _neighbors_summary (overlap_row) → .mappings().first()
|
||||||
|
|
||||||
begin_nested() — возвращаем context manager чтобы поддержать `with` statement.
|
begin_nested() — возвращаем context manager чтобы поддержать `with` statement.
|
||||||
"""
|
"""
|
||||||
|
|
@ -106,12 +107,13 @@ def _make_db_for_analyze(
|
||||||
("all", []), # 7: noise rows
|
("all", []), # 7: noise rows
|
||||||
("all", []), # 8: hydrology rows
|
("all", []), # 8: hydrology rows
|
||||||
("all", []), # 9: utilities rows
|
("all", []), # 9: utilities rows
|
||||||
("first", None), # 10: market trend
|
("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2
|
||||||
("first", None), # 11: zoning (inside begin_nested)
|
("first", None), # 11: market trend
|
||||||
("all", []), # 12: success recommendation (inside begin_nested)
|
("first", None), # 12: zoning (inside begin_nested)
|
||||||
("scalar", 0), # 13: geotech_risk industrial count
|
("all", []), # 13: success recommendation (inside begin_nested)
|
||||||
("all", []), # 14: neighbors
|
("scalar", 0), # 14: geotech_risk industrial count
|
||||||
("first", None), # 15: overlap
|
("all", []), # 15: neighbors
|
||||||
|
("first", None), # 16: overlap
|
||||||
]
|
]
|
||||||
|
|
||||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
Стратегия mock: аналогична test_analyze_inline_weights.py — DB mock через
|
Стратегия mock: аналогична test_analyze_inline_weights.py — DB mock через
|
||||||
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
|
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
|
||||||
|
|
||||||
Порядок db.execute calls в analyze_parcel (v3.7 + #33):
|
Порядок db.execute calls в analyze_parcel (v3.7 + #33 + #29 G2):
|
||||||
0. UNION ALL geom + source → .mappings().first()
|
0. UNION ALL geom + source → .mappings().first()
|
||||||
1. WKT query → .mappings().first()
|
1. WKT query → .mappings().first()
|
||||||
2. District → .mappings().first()
|
2. District → .mappings().first()
|
||||||
|
|
@ -19,14 +19,15 @@ dependency_overrides, тяжёлые сервисы патчим через unit
|
||||||
7. Noise rows → .mappings().all()
|
7. Noise rows → .mappings().all()
|
||||||
8. Hydrology → .mappings().all()
|
8. Hydrology → .mappings().all()
|
||||||
9. Utilities → .mappings().all()
|
9. Utilities → .mappings().all()
|
||||||
10. Market trend → .mappings().first()
|
10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2
|
||||||
11. Zoning (begin_nested) → .mappings().first()
|
11. Market trend → .mappings().first()
|
||||||
12. Success recommendation (begin_nested) → .mappings().all()
|
12. Zoning (begin_nested) → .mappings().first()
|
||||||
13. Market price (begin_nested) → .mappings().first() ← NEW #33
|
13. Success recommendation (begin_nested) → .mappings().all()
|
||||||
14. Recent permits (begin_nested) → .mappings().all() ← NEW #105 Phase 5
|
14. Market price (begin_nested) → .mappings().first() ← #33
|
||||||
15. _geotech_risk (industrial count) → .scalar()
|
15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5
|
||||||
16. _neighbors_summary (neighbor_rows) → .mappings().all()
|
16. _geotech_risk (industrial count) → .scalar()
|
||||||
17. _neighbors_summary (overlap_row) → .mappings().first()
|
17. _neighbors_summary (neighbor_rows) → .mappings().all()
|
||||||
|
18. _neighbors_summary (overlap_row) → .mappings().first()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -104,13 +105,15 @@ def _make_db_for_analyze(
|
||||||
("all", []), # 7: noise rows
|
("all", []), # 7: noise rows
|
||||||
("all", []), # 8: hydrology rows
|
("all", []), # 8: hydrology rows
|
||||||
("all", []), # 9: utilities rows
|
("all", []), # 9: utilities rows
|
||||||
("first", None), # 10: market trend
|
("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2
|
||||||
("first", None), # 11: zoning (begin_nested)
|
("first", None), # 11: market trend
|
||||||
("all", []), # 12: success recommendation (begin_nested)
|
("first", None), # 12: zoning (begin_nested)
|
||||||
("first", mp_mock), # 13: market price (begin_nested) ← #33
|
("all", []), # 13: success recommendation (begin_nested)
|
||||||
("scalar", 0), # 14: geotech_risk
|
("first", mp_mock), # 14: market price (begin_nested) ← #33
|
||||||
("all", []), # 15: neighbors
|
("all", []), # 15: recent permits (begin_nested)
|
||||||
("first", None), # 16: overlap
|
("scalar", 0), # 16: geotech_risk
|
||||||
|
("all", []), # 17: neighbors
|
||||||
|
("first", None), # 18: overlap
|
||||||
]
|
]
|
||||||
|
|
||||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
|
|
|
||||||
210
backend/tests/api/v1/test_analyze_parcel_meta.py
Normal file
210
backend/tests/api/v1/test_analyze_parcel_meta.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""Тесты для parcel_meta в POST /api/v1/parcels/{cad_num}/analyze (#29 G2).
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
1. analyze когда cad_parcels has row → parcel_meta содержит permitted_use/land_category/cad_cost
|
||||||
|
2. analyze когда cad_parcels row отсутствует → parcel_meta == None
|
||||||
|
|
||||||
|
Стратегия mock: аналогична test_analyze_market_price.py — DB mock через
|
||||||
|
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
|
||||||
|
|
||||||
|
Порядок db.execute calls в analyze_parcel (с #29 G2):
|
||||||
|
0. UNION ALL geom + source → .mappings().first()
|
||||||
|
1. WKT query → .mappings().first()
|
||||||
|
2. District → .mappings().first()
|
||||||
|
3. POI rows → .mappings().all()
|
||||||
|
4. Competitor rows → .mappings().all()
|
||||||
|
5. Pipeline rows → .mappings().all()
|
||||||
|
6. Centroid lat/lon → .mappings().first()
|
||||||
|
7. Noise rows → .mappings().all()
|
||||||
|
8. Hydrology → .mappings().all()
|
||||||
|
9. Utilities → .mappings().all()
|
||||||
|
10. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2
|
||||||
|
11. Market trend → .mappings().first()
|
||||||
|
12. Zoning (begin_nested) → .mappings().first()
|
||||||
|
13. Success recommendation (begin_nested) → .mappings().all()
|
||||||
|
14. Market price (begin_nested) → .mappings().first()
|
||||||
|
15. Recent permits (begin_nested) → .mappings().all()
|
||||||
|
16. _geotech_risk (industrial count) → .scalar()
|
||||||
|
17. _neighbors_summary (neighbor_rows) → .mappings().all()
|
||||||
|
18. _neighbors_summary (overlap_row) → .mappings().first()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
_CAD = "66:41:0204016:10"
|
||||||
|
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
|
||||||
|
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mapping(data: dict[str, Any]) -> MagicMock:
|
||||||
|
m = MagicMock()
|
||||||
|
m.__getitem__ = lambda self, k: data[k]
|
||||||
|
m.get = lambda k, default=None: data.get(k, default)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_for_analyze(
|
||||||
|
parcel_meta_row: dict[str, Any] | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Сконструировать mock DB Session для analyze_parcel с фокусом на parcel_meta.
|
||||||
|
|
||||||
|
parcel_meta_row=None → имитирует "строки нет в cad_parcels" → parcel_meta=None.
|
||||||
|
parcel_meta_row={...} → имитирует найденную строку.
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
|
||||||
|
wkt_row = _make_mapping({"wkt": _WKT})
|
||||||
|
district_row = _make_mapping(
|
||||||
|
{
|
||||||
|
"district_name": "Октябрьский",
|
||||||
|
"median_price_per_m2": 120000,
|
||||||
|
"dist_to_center": 1500.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||||
|
|
||||||
|
pm_mock = _make_mapping(parcel_meta_row) if parcel_meta_row is not None else None
|
||||||
|
|
||||||
|
call_idx = [0]
|
||||||
|
responses: list[Any] = [
|
||||||
|
("first", geom_row), # 0: geom UNION ALL
|
||||||
|
("first", wkt_row), # 1: WKT
|
||||||
|
("first", district_row), # 2: district
|
||||||
|
("all", []), # 3: POI rows
|
||||||
|
("all", []), # 4: competitor rows
|
||||||
|
("all", []), # 5: pipeline rows
|
||||||
|
("first", centroid_row), # 6: centroid
|
||||||
|
("all", []), # 7: noise rows
|
||||||
|
("all", []), # 8: hydrology rows
|
||||||
|
("all", []), # 9: utilities rows
|
||||||
|
("first", pm_mock), # 10: parcel_meta ← #29 G2
|
||||||
|
("first", None), # 11: market trend
|
||||||
|
("first", None), # 12: zoning (begin_nested)
|
||||||
|
("all", []), # 13: success recommendation (begin_nested)
|
||||||
|
("first", None), # 14: market price (begin_nested)
|
||||||
|
("all", []), # 15: recent permits (begin_nested)
|
||||||
|
("scalar", 0), # 16: geotech_risk
|
||||||
|
("all", []), # 17: neighbors
|
||||||
|
("first", None), # 18: overlap
|
||||||
|
]
|
||||||
|
|
||||||
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
|
idx = call_idx[0]
|
||||||
|
call_idx[0] += 1
|
||||||
|
if idx >= len(responses):
|
||||||
|
r = MagicMock()
|
||||||
|
r.mappings.return_value.first.return_value = None
|
||||||
|
r.mappings.return_value.all.return_value = []
|
||||||
|
r.scalar.return_value = 0
|
||||||
|
return r
|
||||||
|
kind, data = responses[idx]
|
||||||
|
r = MagicMock()
|
||||||
|
r.mappings.return_value.first.return_value = data
|
||||||
|
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
|
||||||
|
r.scalar.return_value = data if kind == "scalar" else 0
|
||||||
|
return r
|
||||||
|
|
||||||
|
db.execute.side_effect = _execute_side_effect
|
||||||
|
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.__enter__ = MagicMock(return_value=ctx)
|
||||||
|
ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.begin_nested.return_value = ctx
|
||||||
|
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _override_db(db: MagicMock):
|
||||||
|
def _get_db_override():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
return _get_db_override
|
||||||
|
|
||||||
|
|
||||||
|
_PATCHES = [
|
||||||
|
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
|
||||||
|
patch("app.api.v1.parcels._fetch_weather_sync", return_value=None),
|
||||||
|
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None),
|
||||||
|
patch(
|
||||||
|
"app.api.v1.parcels.get_quarter_dump_data",
|
||||||
|
return_value={
|
||||||
|
"nspd_zoning": None,
|
||||||
|
"nspd_zouit_overlaps": [],
|
||||||
|
"nspd_engineering_nearby": [],
|
||||||
|
"nspd_risk_zones": [],
|
||||||
|
"nspd_opportunity_parcels": [],
|
||||||
|
"nspd_red_lines": [],
|
||||||
|
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
patch("app.api.v1.parcels.compute_velocity", return_value=None),
|
||||||
|
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _start_patches() -> None:
|
||||||
|
for p in _PATCHES:
|
||||||
|
p.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_patches() -> None:
|
||||||
|
for p in _PATCHES:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parcel_meta_found_in_cad_parcels() -> None:
|
||||||
|
"""analyze → cad_parcels has row → parcel_meta содержит permitted_use/land_category/cad_cost."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
pm_data: dict[str, Any] = {
|
||||||
|
"permitted_use": "многоквартирный дом",
|
||||||
|
"land_category": "Земли населённых пунктов",
|
||||||
|
"land_subtype": "жилая застройка",
|
||||||
|
"cad_cost": 5_000_000.0,
|
||||||
|
}
|
||||||
|
db = _make_db_for_analyze(parcel_meta_row=pm_data)
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert "parcel_meta" in body, "parcel_meta отсутствует в ответе"
|
||||||
|
pm = body["parcel_meta"]
|
||||||
|
assert pm is not None, "parcel_meta должен быть не None при наличии строки"
|
||||||
|
assert pm["permitted_use"] == "многоквартирный дом"
|
||||||
|
assert pm["land_category"] == "Земли населённых пунктов"
|
||||||
|
assert pm["land_subtype"] == "жилая застройка"
|
||||||
|
assert pm["cad_cost"] == 5_000_000.0
|
||||||
|
assert pm["source"] == "cad_parcels"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parcel_meta_none_when_cad_parcels_missing() -> None:
|
||||||
|
"""analyze → cad_parcels row отсутствует → parcel_meta == None."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = _make_db_for_analyze(parcel_meta_row=None)
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
_start_patches()
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert "parcel_meta" in body, "parcel_meta ключ должен присутствовать в ответе"
|
||||||
|
assert body["parcel_meta"] is None, "parcel_meta должен быть None при отсутствии строки"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
_stop_patches()
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
2. analyze с quarter без permits → recent_permits=[], summary={rns_count=0, ...}
|
2. analyze с quarter без permits → recent_permits=[], summary={rns_count=0, ...}
|
||||||
3. analyze 404 на invalid cad (no regression)
|
3. analyze 404 на invalid cad (no regression)
|
||||||
|
|
||||||
Порядок db.execute calls в analyze_parcel (после #105 Phase 5):
|
Порядок db.execute calls в analyze_parcel (после #105 Phase 5 + #29 G2):
|
||||||
0. UNION ALL geom + source → .mappings().first()
|
0. UNION ALL geom + source → .mappings().first()
|
||||||
1. WKT query → .mappings().first()
|
1. WKT query → .mappings().first()
|
||||||
2. District → .mappings().first()
|
2. District → .mappings().first()
|
||||||
|
|
@ -16,14 +16,15 @@
|
||||||
7. Noise rows → .mappings().all()
|
7. Noise rows → .mappings().all()
|
||||||
8. Hydrology → .mappings().all()
|
8. Hydrology → .mappings().all()
|
||||||
9. Utilities → .mappings().all()
|
9. Utilities → .mappings().all()
|
||||||
10. Market trend → .mappings().first()
|
10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2
|
||||||
11. Zoning (begin_nested) → .mappings().first()
|
11. Market trend → .mappings().first()
|
||||||
12. Success recommendation (begin_nested) → .mappings().all()
|
12. Zoning (begin_nested) → .mappings().first()
|
||||||
13. Market price (begin_nested) → .mappings().first()
|
13. Success recommendation (begin_nested) → .mappings().all()
|
||||||
14. Recent permits (begin_nested) → .mappings().all() ← NEW #105
|
14. Market price (begin_nested) → .mappings().first()
|
||||||
15. _geotech_risk (industrial count) → .scalar()
|
15. Recent permits (begin_nested) → .mappings().all() ← #105
|
||||||
16. _neighbors_summary (neighbor_rows) → .mappings().all()
|
16. _geotech_risk (industrial count) → .scalar()
|
||||||
17. _neighbors_summary (overlap_row) → .mappings().first()
|
17. _neighbors_summary (neighbor_rows) → .mappings().all()
|
||||||
|
18. _neighbors_summary (overlap_row) → .mappings().first()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -95,14 +96,15 @@ def _make_db_for_analyze(
|
||||||
("all", []), # 7: noise rows
|
("all", []), # 7: noise rows
|
||||||
("all", []), # 8: hydrology rows
|
("all", []), # 8: hydrology rows
|
||||||
("all", []), # 9: utilities rows
|
("all", []), # 9: utilities rows
|
||||||
("first", None), # 10: market trend
|
("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2
|
||||||
("first", None), # 11: zoning (begin_nested)
|
("first", None), # 11: market trend
|
||||||
("all", []), # 12: success recommendation (begin_nested)
|
("first", None), # 12: zoning (begin_nested)
|
||||||
("first", None), # 13: market price (begin_nested)
|
("all", []), # 13: success recommendation (begin_nested)
|
||||||
("all", raw_permits), # 14: recent permits (begin_nested) ← #105
|
("first", None), # 14: market price (begin_nested)
|
||||||
("scalar", 0), # 15: geotech_risk
|
("all", raw_permits), # 15: recent permits (begin_nested) ← #105
|
||||||
("all", []), # 16: neighbors
|
("scalar", 0), # 16: geotech_risk
|
||||||
("first", None), # 17: overlap
|
("all", []), # 17: neighbors
|
||||||
|
("first", None), # 18: overlap
|
||||||
]
|
]
|
||||||
|
|
||||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
"""Tests for gate_verdict aggregator — pure function, no DB."""
|
"""Tests for gate_verdict aggregator — pure function, no DB."""
|
||||||
|
|
||||||
from app.services.site_finder.gate_verdict import compute_gate_verdict, is_residential_zone
|
from app.services.site_finder.gate_verdict import (
|
||||||
|
compute_gate_verdict,
|
||||||
|
is_residential_zone,
|
||||||
|
)
|
||||||
|
|
||||||
# ── is_residential_zone unit tests ────────────────────────────────────────────
|
# ── is_residential_zone unit tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -115,3 +118,90 @@ def test_pzz_unknown_warning_when_no_zoning():
|
||||||
verdict = compute_gate_verdict(None, [], [{"name": "ТП-1"}], {"available": True})
|
verdict = compute_gate_verdict(None, [], [{"name": "ТП-1"}], {"available": True})
|
||||||
assert any(w["code"] == "PZZ_UNKNOWN" for w in verdict["warnings"])
|
assert any(w["code"] == "PZZ_UNKNOWN" for w in verdict["warnings"])
|
||||||
assert verdict["can_build_mkd"] is True
|
assert verdict["can_build_mkd"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── cad_zouit fallback path (#232) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
|
||||||
|
"""cad_zouit overlap с 'охранная зона трубопроводов' → ZOUIT_CAD_BLOCKER → Нельзя."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||||||
|
overlaps = [
|
||||||
|
{
|
||||||
|
"source": "cad_zouit",
|
||||||
|
"type_zone": "Охранная зона трубопроводов",
|
||||||
|
"layer": "Охранная зона трубопроводов",
|
||||||
|
"name": "Газопровод высокого давления",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is False
|
||||||
|
assert verdict["verdict_label"] == "Нельзя"
|
||||||
|
assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cad_zouit_elektr_blocks():
|
||||||
|
"""cad_zouit overlap с 'электр' substring в type_zone → blocker."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||||
|
overlaps = [
|
||||||
|
{
|
||||||
|
"source": "cad_zouit",
|
||||||
|
"type_zone": "Охранная зона электросетевого объекта",
|
||||||
|
"layer": "Охранная зона электросетевого объекта",
|
||||||
|
"name": "ЛЭП 110 кВ",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is False
|
||||||
|
assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cad_zouit_szz_is_warning_not_blocker():
|
||||||
|
"""cad_zouit overlap с 'СЗЗ' → ZOUIT_CAD_SZZ warning, не blocker."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||||||
|
overlaps = [
|
||||||
|
{
|
||||||
|
"source": "cad_zouit",
|
||||||
|
"type_zone": "Санитарно-защитная зона предприятия",
|
||||||
|
"layer": "Санитарно-защитная зона предприятия",
|
||||||
|
"name": "СЗЗ завода",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is True
|
||||||
|
assert verdict["verdict_label"] == "С ограничениями"
|
||||||
|
assert any(w["code"] == "ZOUIT_CAD_SZZ" for w in verdict["warnings"])
|
||||||
|
assert verdict["blockers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cad_zouit_other_is_warning():
|
||||||
|
"""cad_zouit overlap без blocker/СЗЗ keyword → ZOUIT_CAD_OTHER warning."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||||||
|
overlaps = [
|
||||||
|
{
|
||||||
|
"source": "cad_zouit",
|
||||||
|
"type_zone": "Зона публичного сервитута",
|
||||||
|
"layer": "Зона публичного сервитута",
|
||||||
|
"name": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is True
|
||||||
|
assert any(w["code"] == "ZOUIT_CAD_OTHER" for w in verdict["warnings"])
|
||||||
|
assert verdict["blockers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nspd_subcategory_path_backward_compat():
|
||||||
|
"""NSPD dump path (source != 'cad_zouit') продолжает работать через subcategory."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||||
|
overlaps = [
|
||||||
|
{
|
||||||
|
"source": "nspd-quarter-dump",
|
||||||
|
"subcategory": 17,
|
||||||
|
"name": "ЛЭП 110кВ",
|
||||||
|
"layer": "37578",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is False
|
||||||
|
assert any(b["code"] == "ZOUIT_OVERLAP_SUB17" for b in verdict["blockers"])
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue