feat(nspd): TIER 4 opportunity + red lines (#94 PR2 of 4) #220
12 changed files with 979 additions and 9 deletions
|
|
@ -21,9 +21,11 @@ from app.schemas.parcel import (
|
||||||
CompetitorsRequest,
|
CompetitorsRequest,
|
||||||
CompetitorsResponse,
|
CompetitorsResponse,
|
||||||
ConnectionPointsResponse,
|
ConnectionPointsResponse,
|
||||||
|
OpportunityParcel,
|
||||||
ParcelDetail,
|
ParcelDetail,
|
||||||
ParcelSearchRequest,
|
ParcelSearchRequest,
|
||||||
ParcelSearchResponse,
|
ParcelSearchResponse,
|
||||||
|
RedLine,
|
||||||
RiskZone,
|
RiskZone,
|
||||||
)
|
)
|
||||||
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
||||||
|
|
@ -2065,11 +2067,17 @@ def analyze_parcel(
|
||||||
# nspd_zouit_overlaps: ЗОУИТ пересечения (G3)
|
# nspd_zouit_overlaps: ЗОУИТ пересечения (G3)
|
||||||
# nspd_engineering_nearby: инженерные сооружения в 200м (I3)
|
# nspd_engineering_nearby: инженерные сооружения в 200м (I3)
|
||||||
# nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни
|
# nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни
|
||||||
|
# nspd_opportunity_parcels: TIER 4 opportunity ЗУ (#94 PR2)
|
||||||
|
# nspd_red_lines: TIER 4 красные линии застройки (#94 PR2, #54 Generative)
|
||||||
# nspd_dump: freshness metadata — available, stale, harvest_triggered
|
# nspd_dump: freshness metadata — available, stale, harvest_triggered
|
||||||
"nspd_zoning": nspd_dump_data["nspd_zoning"],
|
"nspd_zoning": nspd_dump_data["nspd_zoning"],
|
||||||
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
|
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
|
||||||
"nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
|
"nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
|
||||||
"nspd_risk_zones": [RiskZone(**rz) for rz in nspd_dump_data.get("nspd_risk_zones", [])],
|
"nspd_risk_zones": [RiskZone(**rz) for rz in nspd_dump_data.get("nspd_risk_zones", [])],
|
||||||
|
"nspd_opportunity_parcels": [
|
||||||
|
OpportunityParcel(**op) for op in nspd_dump_data.get("nspd_opportunity_parcels", [])
|
||||||
|
],
|
||||||
|
"nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])],
|
||||||
"nspd_dump": nspd_dump_data["nspd_dump"],
|
"nspd_dump": nspd_dump_data["nspd_dump"],
|
||||||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
||||||
"gate_verdict": compute_gate_verdict(
|
"gate_verdict": compute_gate_verdict(
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,46 @@ class RiskZone(BaseModel):
|
||||||
intersection_area_sqm: float | None # площадь пересечения с участком, м²
|
intersection_area_sqm: float | None # площадь пересечения с участком, м²
|
||||||
|
|
||||||
|
|
||||||
|
# ── NSPD Opportunity + Red Lines schemas (issue #94 TIER 4) ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class OpportunityParcel(BaseModel):
|
||||||
|
"""TIER 4: opportunity ЗУ вблизи участка (auction/scheme/free/future/oopt).
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
layer: тип opportunity — "auction_parcels" | "scheme_parcels" |
|
||||||
|
"free_parcels" | "future_parcels" | "oopt"
|
||||||
|
cad_num: кадастровый номер ЗУ (если доступен в NSPD properties)
|
||||||
|
distance_m: расстояние от centroid анализируемого участка до ЗУ, м
|
||||||
|
geom_wkt: WKT геометрии ЗУ в EPSG:4326
|
||||||
|
"""
|
||||||
|
|
||||||
|
layer: str
|
||||||
|
cad_num: str | None
|
||||||
|
distance_m: float | None
|
||||||
|
geom_wkt: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class RedLine(BaseModel):
|
||||||
|
"""TIER 4: красная линия застройки (NSPD layer 879243).
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
geom_wkt: WKT геометрии линии в EPSG:4326
|
||||||
|
intersection_length_m: длина пересечения линии с участком, м
|
||||||
|
(null если только nearby, не intersect)
|
||||||
|
distance_m: расстояние от ближайшей точки линии до участка, м
|
||||||
|
(null если линия пересекает участок)
|
||||||
|
|
||||||
|
UI-семантика:
|
||||||
|
intersection_length_m != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT)
|
||||||
|
distance_m != null → красная линия рядом (warning)
|
||||||
|
"""
|
||||||
|
|
||||||
|
geom_wkt: str | None
|
||||||
|
intersection_length_m: float | None # null если только nearby, не intersect
|
||||||
|
distance_m: float | None # null если intersect
|
||||||
|
|
||||||
|
|
||||||
# ── Parcel search/detail schemas ──────────────────────────────────────────────
|
# ── Parcel search/detail schemas ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,9 @@ class QuarterDump:
|
||||||
engineering_structures: list[NSPDFeature]
|
engineering_structures: list[NSPDFeature]
|
||||||
zouit: dict[str, list[NSPDFeature]] # {"okn": [...], "engineering": [...], ...}
|
zouit: dict[str, list[NSPDFeature]] # {"okn": [...], "engineering": [...], ...}
|
||||||
risks: dict[str, list[NSPDFeature]] # {"flooding": [...], "landslide": [...], ...}
|
risks: dict[str, list[NSPDFeature]] # {"flooding": [...], "landslide": [...], ...}
|
||||||
|
# TIER 4 opportunity layers (issue #94 PR2).
|
||||||
|
# {"auction_parcels": [...], "scheme_parcels": [...], "free_parcels": [...], ...}
|
||||||
|
opportunity: dict[str, list[NSPDFeature]]
|
||||||
# tuple, не list — frozen dataclass + immutable contents (audit/debug snapshot)
|
# tuple, не list — frozen dataclass + immutable contents (audit/debug snapshot)
|
||||||
layers_fetched: tuple[str, ...]
|
layers_fetched: tuple[str, ...]
|
||||||
bbox_3857: tuple[float, float, float, float] | None # bbox квартала
|
bbox_3857: tuple[float, float, float, float] | None # bbox квартала
|
||||||
|
|
@ -215,6 +218,7 @@ class QuarterDump:
|
||||||
+ len(self.engineering_structures)
|
+ len(self.engineering_structures)
|
||||||
+ sum(len(v) for v in self.zouit.values())
|
+ sum(len(v) for v in self.zouit.values())
|
||||||
+ sum(len(v) for v in self.risks.values())
|
+ sum(len(v) for v in self.risks.values())
|
||||||
|
+ sum(len(v) for v in self.opportunity.values())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -489,6 +493,16 @@ class NSPDClient:
|
||||||
"clutter": "risk_clutter",
|
"clutter": "risk_clutter",
|
||||||
"burns": "risk_burns",
|
"burns": "risk_burns",
|
||||||
}
|
}
|
||||||
|
# TIER 4 — Opportunity layers (issue #94 PR2).
|
||||||
|
# short_name → LAYERS dict key (for get_features_in_bbox lookup).
|
||||||
|
# Features stored in features_json с layer = "opportunity_<short_name>".
|
||||||
|
QUARTER_OPPORTUNITY_LAYERS: dict[str, str] = { # noqa: RUF012
|
||||||
|
"auction_parcels": "auction_parcels", # 37299 — аукционные ЗУ
|
||||||
|
"scheme_parcels": "scheme_parcels", # 37294 — схемы расположения ЗУ
|
||||||
|
"free_parcels": "free_parcels", # 37298 — свободные ЗУ
|
||||||
|
"future_parcels": "future_parcels", # 36473 — планируемые ЗУ
|
||||||
|
"oopt": "protected_areas", # 875845 — ООПТ
|
||||||
|
}
|
||||||
|
|
||||||
def search_by_quarter(
|
def search_by_quarter(
|
||||||
self,
|
self,
|
||||||
|
|
@ -496,6 +510,7 @@ class NSPDClient:
|
||||||
*,
|
*,
|
||||||
include_zouit: bool = True,
|
include_zouit: bool = True,
|
||||||
include_risks: bool = False,
|
include_risks: bool = False,
|
||||||
|
include_opportunity: bool = False,
|
||||||
) -> QuarterDump:
|
) -> QuarterDump:
|
||||||
"""Harvest всех NSPD-данных для квартала: 1 vacuum, N layers.
|
"""Harvest всех NSPD-данных для квартала: 1 vacuum, N layers.
|
||||||
|
|
||||||
|
|
@ -517,13 +532,17 @@ class NSPDClient:
|
||||||
include_zouit: Включать TIER 2 ЗОУИТ layers (G3). Default True.
|
include_zouit: Включать TIER 2 ЗОУИТ layers (G3). Default True.
|
||||||
include_risks: Включать TIER 3 risk zones. Default False (rate-limit
|
include_risks: Включать TIER 3 risk zones. Default False (rate-limit
|
||||||
budget; для отдельного D-N risk score можно включить).
|
budget; для отдельного D-N risk score можно включить).
|
||||||
|
include_opportunity: Включать TIER 4 opportunity layers (auction_parcels,
|
||||||
|
scheme_parcels, free_parcels, future_parcels, oopt). Default False.
|
||||||
|
+5 HTTP запросов при включении.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QuarterDump с per-layer feature lists. Если NSPD пуст / quarter
|
QuarterDump с per-layer feature lists. Если NSPD пуст / quarter
|
||||||
не найден — `quarter=None`, `bbox_3857=None`, все feature lists
|
не найден — `quarter=None`, `bbox_3857=None`, все feature lists
|
||||||
пустые (no bulk-fetch без bounds — нет смысла). При этом dict-
|
пустые (no bulk-fetch без bounds — нет смысла). При этом dict-
|
||||||
поля `zouit` / `risks` всё равно populated с пустыми lists для
|
поля `zouit` / `risks` / `opportunity` всё равно populated с пустыми
|
||||||
каждого включённого short_name (структура контракта стабильна).
|
lists для каждого включённого short_name
|
||||||
|
(структура контракта стабильна).
|
||||||
`layers_fetched` в этом случае содержит только `('search',)`.
|
`layers_fetched` в этом случае содержит только `('search',)`.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
|
@ -532,7 +551,7 @@ class NSPDClient:
|
||||||
операция атомарна (failure → exception).
|
операция атомарна (failure → exception).
|
||||||
|
|
||||||
Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors,
|
Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors,
|
||||||
E1 #51 parcels backfill, #96 ЕГРН помещения.
|
E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity.
|
||||||
"""
|
"""
|
||||||
# 1. Quarter geometry через REST search
|
# 1. Quarter geometry через REST search
|
||||||
quarter_search = self.search_by_cad(quarter_cad, thematic_id=2)
|
quarter_search = self.search_by_cad(quarter_cad, thematic_id=2)
|
||||||
|
|
@ -577,6 +596,12 @@ class NSPDClient:
|
||||||
for short_name, layer_key in self.QUARTER_RISK_LAYERS.items():
|
for short_name, layer_key in self.QUARTER_RISK_LAYERS.items():
|
||||||
risks[short_name] = _fetch_layer(f"risk_{short_name}", layer_key)
|
risks[short_name] = _fetch_layer(f"risk_{short_name}", layer_key)
|
||||||
|
|
||||||
|
# 6. Opportunity layers (TIER 4, issue #94 PR2)
|
||||||
|
opportunity: dict[str, list[NSPDFeature]] = {}
|
||||||
|
if include_opportunity:
|
||||||
|
for short_name, layer_key in self.QUARTER_OPPORTUNITY_LAYERS.items():
|
||||||
|
opportunity[short_name] = _fetch_layer(f"opportunity_{short_name}", layer_key)
|
||||||
|
|
||||||
return QuarterDump(
|
return QuarterDump(
|
||||||
quarter_cad=quarter_cad,
|
quarter_cad=quarter_cad,
|
||||||
quarter=quarter_feat,
|
quarter=quarter_feat,
|
||||||
|
|
@ -587,6 +612,7 @@ class NSPDClient:
|
||||||
engineering_structures=engineering_structures,
|
engineering_structures=engineering_structures,
|
||||||
zouit=zouit,
|
zouit=zouit,
|
||||||
risks=risks,
|
risks=risks,
|
||||||
|
opportunity=opportunity,
|
||||||
layers_fetched=tuple(layers_fetched),
|
layers_fetched=tuple(layers_fetched),
|
||||||
bbox_3857=bbox,
|
bbox_3857=bbox,
|
||||||
fetched_at_utc=_dt.datetime.now(_dt.UTC).isoformat(),
|
fetched_at_utc=_dt.datetime.now(_dt.UTC).isoformat(),
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ EMPTY_DUMP_RESULT: dict[str, Any] = {
|
||||||
"nspd_zouit_overlaps": [],
|
"nspd_zouit_overlaps": [],
|
||||||
"nspd_engineering_nearby": [],
|
"nspd_engineering_nearby": [],
|
||||||
"nspd_risk_zones": [],
|
"nspd_risk_zones": [],
|
||||||
|
"nspd_opportunity_parcels": [],
|
||||||
|
"nspd_red_lines": [],
|
||||||
"nspd_dump": {
|
"nspd_dump": {
|
||||||
"available": False,
|
"available": False,
|
||||||
"fetched_at_utc": None,
|
"fetched_at_utc": None,
|
||||||
|
|
@ -65,6 +67,8 @@ def make_empty_result(
|
||||||
"nspd_zouit_overlaps": [],
|
"nspd_zouit_overlaps": [],
|
||||||
"nspd_engineering_nearby": [],
|
"nspd_engineering_nearby": [],
|
||||||
"nspd_risk_zones": [],
|
"nspd_risk_zones": [],
|
||||||
|
"nspd_opportunity_parcels": [],
|
||||||
|
"nspd_red_lines": [],
|
||||||
"nspd_dump": {
|
"nspd_dump": {
|
||||||
"available": False,
|
"available": False,
|
||||||
"fetched_at_utc": fetched_at_utc,
|
"fetched_at_utc": fetched_at_utc,
|
||||||
|
|
@ -128,7 +132,9 @@ def get_quarter_dump_data(
|
||||||
territorial_zones_count,
|
territorial_zones_count,
|
||||||
zouit_count,
|
zouit_count,
|
||||||
engineering_count,
|
engineering_count,
|
||||||
risks_count
|
risks_count,
|
||||||
|
COALESCE(opportunity_count, 0) AS opportunity_count,
|
||||||
|
COALESCE(red_lines_count, 0) AS red_lines_count
|
||||||
FROM nspd_quarter_dumps
|
FROM nspd_quarter_dumps
|
||||||
WHERE quarter_cad = :q
|
WHERE quarter_cad = :q
|
||||||
"""
|
"""
|
||||||
|
|
@ -155,6 +161,8 @@ def get_quarter_dump_data(
|
||||||
zouit_count: int = row[5] or 0
|
zouit_count: int = row[5] or 0
|
||||||
engineering_count: int = row[6] or 0
|
engineering_count: int = row[6] or 0
|
||||||
risks_count: int = row[7] or 0
|
risks_count: int = row[7] or 0
|
||||||
|
opportunity_count: int = row[8] or 0
|
||||||
|
red_lines_count: int = row[9] or 0
|
||||||
|
|
||||||
is_stale = (now - fetched_at) > max_age
|
is_stale = (now - fetched_at) > max_age
|
||||||
has_error = harvest_error is not None
|
has_error = harvest_error is not None
|
||||||
|
|
@ -185,6 +193,8 @@ def get_quarter_dump_data(
|
||||||
"nspd_zouit_overlaps": [],
|
"nspd_zouit_overlaps": [],
|
||||||
"nspd_engineering_nearby": [],
|
"nspd_engineering_nearby": [],
|
||||||
"nspd_risk_zones": [],
|
"nspd_risk_zones": [],
|
||||||
|
"nspd_opportunity_parcels": [],
|
||||||
|
"nspd_red_lines": [],
|
||||||
"nspd_dump": dump_meta,
|
"nspd_dump": dump_meta,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,17 +203,23 @@ def get_quarter_dump_data(
|
||||||
"zouit_count": zouit_count,
|
"zouit_count": zouit_count,
|
||||||
"engineering_count": engineering_count,
|
"engineering_count": engineering_count,
|
||||||
"risks_count": risks_count,
|
"risks_count": risks_count,
|
||||||
|
"opportunity_count": opportunity_count,
|
||||||
|
"red_lines_count": red_lines_count,
|
||||||
}
|
}
|
||||||
nspd_zoning = _get_zoning(db, quarter, parcel_wkt, layer_counts)
|
nspd_zoning = _get_zoning(db, quarter, parcel_wkt, layer_counts)
|
||||||
nspd_zouit = _get_zouit_overlaps(db, quarter, parcel_wkt, layer_counts)
|
nspd_zouit = _get_zouit_overlaps(db, quarter, parcel_wkt, layer_counts)
|
||||||
nspd_engineering = _get_engineering_nearby(db, quarter, parcel_wkt, layer_counts)
|
nspd_engineering = _get_engineering_nearby(db, quarter, parcel_wkt, layer_counts)
|
||||||
nspd_risk_zones = _get_risk_zones(db, quarter, parcel_wkt, layer_counts)
|
nspd_risk_zones = _get_risk_zones(db, quarter, parcel_wkt, layer_counts)
|
||||||
|
nspd_opportunity = _get_opportunity_parcels(db, quarter, parcel_wkt, layer_counts)
|
||||||
|
nspd_red_lines = _get_red_lines(db, quarter, parcel_wkt, layer_counts)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"nspd_zoning": nspd_zoning,
|
"nspd_zoning": nspd_zoning,
|
||||||
"nspd_zouit_overlaps": nspd_zouit,
|
"nspd_zouit_overlaps": nspd_zouit,
|
||||||
"nspd_engineering_nearby": nspd_engineering,
|
"nspd_engineering_nearby": nspd_engineering,
|
||||||
"nspd_risk_zones": nspd_risk_zones,
|
"nspd_risk_zones": nspd_risk_zones,
|
||||||
|
"nspd_opportunity_parcels": nspd_opportunity,
|
||||||
|
"nspd_red_lines": nspd_red_lines,
|
||||||
"nspd_dump": dump_meta,
|
"nspd_dump": dump_meta,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -549,6 +565,245 @@ def _get_risk_zones(
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Opportunity parcels (issue #94 TIER 4) ───────────────────────────────────
|
||||||
|
|
||||||
|
# Human-readable type labels for opportunity layer short_names.
|
||||||
|
_OPPORTUNITY_TYPE_LABELS: dict[str, str] = {
|
||||||
|
"auction_parcels": "auction_parcels",
|
||||||
|
"scheme_parcels": "scheme_parcels",
|
||||||
|
"free_parcels": "free_parcels",
|
||||||
|
"future_parcels": "future_parcels",
|
||||||
|
"oopt": "oopt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Radius (m) for opportunity parcel proximity search from parcel centroid.
|
||||||
|
_OPPORTUNITY_RADIUS_M = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _get_opportunity_parcels(
|
||||||
|
db: Session,
|
||||||
|
quarter: str,
|
||||||
|
parcel_wkt: str,
|
||||||
|
layer_counts: dict[str, int] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""TIER 4: opportunity parcels вблизи участка (auction, scheme, free, future, oopt).
|
||||||
|
|
||||||
|
Возвращает список ближайших opportunity ЗУ с полями:
|
||||||
|
layer, cad_num, distance_m, geom_wkt.
|
||||||
|
|
||||||
|
layer_counts — денормализованные счётчики. Если opportunity_count == 0 —
|
||||||
|
пропускаем heavy jsonb_array_elements scan (early-exit pattern как у risks).
|
||||||
|
"""
|
||||||
|
if layer_counts is not None and layer_counts.get("opportunity_count", 1) == 0:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT feat.value->>'layer' AS layer,
|
||||||
|
feat.value->'properties' AS props,
|
||||||
|
ST_AsText(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
)
|
||||||
|
) AS geom_wkt,
|
||||||
|
ST_Distance(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
)::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
|
) AS distance_m
|
||||||
|
FROM nspd_quarter_dumps d,
|
||||||
|
jsonb_array_elements(d.features_json) AS feat(value)
|
||||||
|
WHERE d.quarter_cad = :q
|
||||||
|
AND feat.value->>'layer' LIKE 'opportunity_%'
|
||||||
|
AND (feat.value->'geometry') IS NOT NULL
|
||||||
|
AND feat.value->>'geometry' != 'null'
|
||||||
|
AND ST_DWithin(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
)::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
|
:radius_m
|
||||||
|
)
|
||||||
|
ORDER BY distance_m ASC
|
||||||
|
LIMIT 30
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for r in rows:
|
||||||
|
raw_layer: str = r[0] or ""
|
||||||
|
props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {}
|
||||||
|
geom_wkt_val: str | None = r[2]
|
||||||
|
distance_m = float(r[3]) if r[3] is not None else None
|
||||||
|
|
||||||
|
# Strip "opportunity_" prefix to get short_name → type label
|
||||||
|
short_name = raw_layer.removeprefix("opportunity_")
|
||||||
|
layer_type = _OPPORTUNITY_TYPE_LABELS.get(short_name, short_name)
|
||||||
|
|
||||||
|
cad_num = props.get("cad_num") or props.get("cadastral_number")
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"layer": layer_type,
|
||||||
|
"cad_num": cad_num,
|
||||||
|
"distance_m": round(distance_m, 1) if distance_m is not None else None,
|
||||||
|
"geom_wkt": geom_wkt_val,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("nspd opportunity parcels query failed for quarter=%s: %s", quarter, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# Radius (m) for red lines proximity search — beyond intersect check.
|
||||||
|
_RED_LINES_NEARBY_M = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _get_red_lines(
|
||||||
|
db: Session,
|
||||||
|
quarter: str,
|
||||||
|
parcel_wkt: str,
|
||||||
|
layer_counts: dict[str, int] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""TIER 4: красные линии застройки (layer 879243).
|
||||||
|
|
||||||
|
Возвращает red lines пересекающие участок ИЛИ ближайшие (до 200м) с полями:
|
||||||
|
geom_wkt, intersection_length_m, distance_m.
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
- intersecting: intersection_length_m >= 0, distance_m = None
|
||||||
|
- nearby only: intersection_length_m = None, distance_m = расстояние
|
||||||
|
|
||||||
|
ST_Intersection выполняется в planar EPSG:4326, затем результат кастуется
|
||||||
|
в ::geography для ST_Length — это избегает PostGIS 3.4 tolerance bug
|
||||||
|
(ST_Intersection geography × geography на LINESTRING бросает transform error).
|
||||||
|
|
||||||
|
layer_counts — денормализованные счётчики. Если red_lines_count == 0 —
|
||||||
|
пропускаем heavy jsonb_array_elements scan.
|
||||||
|
"""
|
||||||
|
if layer_counts is not None and layer_counts.get("red_lines_count", 1) == 0:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT ST_AsText(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
)
|
||||||
|
) AS geom_wkt,
|
||||||
|
ST_Intersects(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
),
|
||||||
|
ST_GeomFromText(:wkt, 4326)
|
||||||
|
) AS does_intersect,
|
||||||
|
ST_Length(
|
||||||
|
ST_Intersection(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
),
|
||||||
|
ST_GeomFromText(:wkt, 4326)
|
||||||
|
)::geography
|
||||||
|
) AS intersection_length_m,
|
||||||
|
ST_Distance(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
)::geography,
|
||||||
|
ST_GeomFromText(:wkt, 4326)::geography
|
||||||
|
) AS distance_m
|
||||||
|
FROM nspd_quarter_dumps d,
|
||||||
|
jsonb_array_elements(d.features_json) AS feat(value)
|
||||||
|
WHERE d.quarter_cad = :q
|
||||||
|
AND feat.value->>'layer' = 'red_lines'
|
||||||
|
AND (feat.value->'geometry') IS NOT NULL
|
||||||
|
AND feat.value->>'geometry' != 'null'
|
||||||
|
AND ST_DWithin(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||||
|
3857
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
)::geography,
|
||||||
|
ST_GeomFromText(:wkt, 4326)::geography,
|
||||||
|
:nearby_m
|
||||||
|
)
|
||||||
|
ORDER BY distance_m ASC
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for r in rows:
|
||||||
|
geom_wkt_val: str | None = r[0]
|
||||||
|
does_intersect: bool = bool(r[1]) if r[1] is not None else False
|
||||||
|
raw_length: Any = r[2]
|
||||||
|
raw_dist: Any = r[3]
|
||||||
|
|
||||||
|
intersection_length_m: float | None = None
|
||||||
|
if does_intersect and raw_length is not None:
|
||||||
|
try:
|
||||||
|
val = float(raw_length)
|
||||||
|
intersection_length_m = round(val, 1) if not math.isnan(val) else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
distance_m: float | None = None
|
||||||
|
if not does_intersect and raw_dist is not None:
|
||||||
|
try:
|
||||||
|
distance_m = round(float(raw_dist), 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"geom_wkt": geom_wkt_val,
|
||||||
|
"intersection_length_m": intersection_length_m,
|
||||||
|
"distance_m": distance_m,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("nspd red lines query failed for quarter=%s: %s", quarter, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ── Connection-points lookup (issue #115) ────────────────────────────────────
|
# ── Connection-points lookup (issue #115) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -863,7 +1118,15 @@ def _trigger_harvest(quarter: str) -> bool:
|
||||||
try:
|
try:
|
||||||
from app.workers.tasks.nspd_sync import harvest_quarter
|
from app.workers.tasks.nspd_sync import harvest_quarter
|
||||||
|
|
||||||
harvest_quarter.apply_async(args=[quarter], kwargs={"region_code": 66})
|
harvest_quarter.apply_async(
|
||||||
|
args=[quarter],
|
||||||
|
kwargs={
|
||||||
|
"region_code": 66,
|
||||||
|
"include_zouit": True,
|
||||||
|
"include_risks": True,
|
||||||
|
"include_opportunity": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
logger.info("quarter dump harvest triggered for quarter=%s", quarter)
|
logger.info("quarter dump harvest triggered for quarter=%s", quarter)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,12 @@ def _build_features_json(dump: QuarterDump) -> list[dict[str, Any]]:
|
||||||
for feat in features:
|
for feat in features:
|
||||||
out.append(_feat_to_dict(layer_name, feat))
|
out.append(_feat_to_dict(layer_name, feat))
|
||||||
|
|
||||||
|
# TIER 4 opportunity groups — keys: auction_parcels, scheme_parcels, ...
|
||||||
|
for short_name, features in dump.opportunity.items():
|
||||||
|
layer_name = f"opportunity_{short_name}"
|
||||||
|
for feat in features:
|
||||||
|
out.append(_feat_to_dict(layer_name, feat))
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -95,6 +101,16 @@ def _build_risks_count(dump: QuarterDump) -> int:
|
||||||
return sum(len(v) for v in dump.risks.values())
|
return sum(len(v) for v in dump.risks.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _build_opportunity_count(dump: QuarterDump) -> int:
|
||||||
|
"""Сумма features по всем TIER 4 opportunity слоям (issue #94 PR2)."""
|
||||||
|
return sum(len(v) for v in dump.opportunity.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _build_has_auction_parcels(dump: QuarterDump) -> bool:
|
||||||
|
"""True если квартал содержит >= 1 feature auction_parcels (layer 37299)."""
|
||||||
|
return len(dump.opportunity.get("auction_parcels", [])) > 0
|
||||||
|
|
||||||
|
|
||||||
# ── UPSERT helper ─────────────────────────────────────────────────────────────
|
# ── UPSERT helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_UPSERT_SQL = text(
|
_UPSERT_SQL = text(
|
||||||
|
|
@ -103,6 +119,7 @@ _UPSERT_SQL = text(
|
||||||
quarter_cad, quarter_geom, bbox_3857,
|
quarter_cad, quarter_geom, bbox_3857,
|
||||||
parcels_count, buildings_count, territorial_zones_count,
|
parcels_count, buildings_count, territorial_zones_count,
|
||||||
red_lines_count, engineering_count, zouit_count, risks_count, total_features,
|
red_lines_count, engineering_count, zouit_count, risks_count, total_features,
|
||||||
|
has_auction_parcels, opportunity_count,
|
||||||
features_json, layers_fetched, fetched_at_utc, harvest_duration_ms,
|
features_json, layers_fetched, fetched_at_utc, harvest_duration_ms,
|
||||||
harvest_error, region_code
|
harvest_error, region_code
|
||||||
) VALUES (
|
) VALUES (
|
||||||
|
|
@ -115,6 +132,7 @@ _UPSERT_SQL = text(
|
||||||
END,
|
END,
|
||||||
:parcels_count, :buildings_count, :territorial_zones_count,
|
:parcels_count, :buildings_count, :territorial_zones_count,
|
||||||
:red_lines_count, :engineering_count, :zouit_count, :risks_count, :total_features,
|
:red_lines_count, :engineering_count, :zouit_count, :risks_count, :total_features,
|
||||||
|
:has_auction_parcels, :opportunity_count,
|
||||||
CAST(:features_json AS jsonb),
|
CAST(:features_json AS jsonb),
|
||||||
CAST(:layers_fetched AS text[]),
|
CAST(:layers_fetched AS text[]),
|
||||||
CAST(:fetched_at_utc AS timestamptz),
|
CAST(:fetched_at_utc AS timestamptz),
|
||||||
|
|
@ -133,6 +151,8 @@ _UPSERT_SQL = text(
|
||||||
zouit_count = EXCLUDED.zouit_count,
|
zouit_count = EXCLUDED.zouit_count,
|
||||||
risks_count = EXCLUDED.risks_count,
|
risks_count = EXCLUDED.risks_count,
|
||||||
total_features = EXCLUDED.total_features,
|
total_features = EXCLUDED.total_features,
|
||||||
|
has_auction_parcels = EXCLUDED.has_auction_parcels,
|
||||||
|
opportunity_count = EXCLUDED.opportunity_count,
|
||||||
features_json = EXCLUDED.features_json,
|
features_json = EXCLUDED.features_json,
|
||||||
layers_fetched = EXCLUDED.layers_fetched,
|
layers_fetched = EXCLUDED.layers_fetched,
|
||||||
fetched_at_utc = EXCLUDED.fetched_at_utc,
|
fetched_at_utc = EXCLUDED.fetched_at_utc,
|
||||||
|
|
@ -178,6 +198,8 @@ def _upsert_dump(
|
||||||
"engineering_count": len(dump.engineering_structures),
|
"engineering_count": len(dump.engineering_structures),
|
||||||
"zouit_count": _build_zouit_count(dump),
|
"zouit_count": _build_zouit_count(dump),
|
||||||
"risks_count": _build_risks_count(dump),
|
"risks_count": _build_risks_count(dump),
|
||||||
|
"has_auction_parcels": _build_has_auction_parcels(dump),
|
||||||
|
"opportunity_count": _build_opportunity_count(dump),
|
||||||
"total_features": dump.total_features,
|
"total_features": dump.total_features,
|
||||||
"features_json": json.dumps(features_json or [], ensure_ascii=False),
|
"features_json": json.dumps(features_json or [], ensure_ascii=False),
|
||||||
"layers_fetched": list(dump.layers_fetched),
|
"layers_fetched": list(dump.layers_fetched),
|
||||||
|
|
@ -202,6 +224,8 @@ def _upsert_dump(
|
||||||
"engineering_count": 0,
|
"engineering_count": 0,
|
||||||
"zouit_count": 0,
|
"zouit_count": 0,
|
||||||
"risks_count": 0,
|
"risks_count": 0,
|
||||||
|
"has_auction_parcels": False,
|
||||||
|
"opportunity_count": 0,
|
||||||
"total_features": 0,
|
"total_features": 0,
|
||||||
"features_json": "[]",
|
"features_json": "[]",
|
||||||
"layers_fetched": [],
|
"layers_fetched": [],
|
||||||
|
|
@ -239,20 +263,26 @@ def harvest_quarter(
|
||||||
region_code: int = 66,
|
region_code: int = 66,
|
||||||
include_zouit: bool = True,
|
include_zouit: bool = True,
|
||||||
include_risks: bool = False,
|
include_risks: bool = False,
|
||||||
|
include_opportunity: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Single-quarter harvest. NSPDClient.search_by_quarter → UPSERT nspd_quarter_dumps.
|
"""Single-quarter harvest. NSPDClient.search_by_quarter → UPSERT nspd_quarter_dumps.
|
||||||
|
|
||||||
Идемпотентен: повторный вызов обновляет строку (ON CONFLICT DO UPDATE).
|
Идемпотентен: повторный вызов обновляет строку (ON CONFLICT DO UPDATE).
|
||||||
WAF 403/429 → autoretry с exponential backoff (max 3 попытки).
|
WAF 403/429 → autoretry с exponential backoff (max 3 попытки).
|
||||||
Другие исключения → запись harvest_error в строку, return error dict (не raise).
|
Другие исключения → запись harvest_error в строку, return error dict (не raise).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
include_opportunity: Фетчить TIER 4 opportunity layers (+5 HTTP запросов).
|
||||||
"""
|
"""
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
logger.info(
|
logger.info(
|
||||||
"harvest_quarter start: cad=%s region=%d include_zouit=%s include_risks=%s",
|
"harvest_quarter start: cad=%s region=%d include_zouit=%s "
|
||||||
|
"include_risks=%s include_opportunity=%s",
|
||||||
quarter_cad,
|
quarter_cad,
|
||||||
region_code,
|
region_code,
|
||||||
include_zouit,
|
include_zouit,
|
||||||
include_risks,
|
include_risks,
|
||||||
|
include_opportunity,
|
||||||
)
|
)
|
||||||
|
|
||||||
client = NSPDClient()
|
client = NSPDClient()
|
||||||
|
|
@ -264,6 +294,7 @@ def harvest_quarter(
|
||||||
quarter_cad,
|
quarter_cad,
|
||||||
include_zouit=include_zouit,
|
include_zouit=include_zouit,
|
||||||
include_risks=include_risks,
|
include_risks=include_risks,
|
||||||
|
include_opportunity=include_opportunity,
|
||||||
)
|
)
|
||||||
features_json = _build_features_json(dump)
|
features_json = _build_features_json(dump)
|
||||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
|
@ -391,7 +422,11 @@ def harvest_stale_quarters(
|
||||||
try:
|
try:
|
||||||
harvest_quarter.apply_async(
|
harvest_quarter.apply_async(
|
||||||
args=[cad, region_code],
|
args=[cad, region_code],
|
||||||
kwargs={"include_zouit": True, "include_risks": True},
|
kwargs={
|
||||||
|
"include_zouit": True,
|
||||||
|
"include_risks": True,
|
||||||
|
"include_opportunity": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
enqueued += 1
|
enqueued += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction.
|
"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction + TIER 4.
|
||||||
|
|
||||||
Покрывает:
|
Покрывает:
|
||||||
- _extract_features_by_layer: filter by prefix
|
- _extract_features_by_layer: filter by prefix
|
||||||
- _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect,
|
- _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect,
|
||||||
no-risks-in-dump
|
no-risks-in-dump
|
||||||
|
- _get_opportunity_parcels: auction/scheme/free/future/oopt layers, distance sort, empty
|
||||||
|
- _get_red_lines: intersecting, nearby-only, empty, early-exit
|
||||||
|
- EMPTY_DUMP_RESULT / make_empty_result: new keys присутствуют
|
||||||
Не требует реального PostgreSQL — мокает db.execute().
|
Не требует реального PostgreSQL — мокает db.execute().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -15,7 +18,10 @@ from unittest.mock import MagicMock
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.site_finder.quarter_dump_lookup import (
|
from app.services.site_finder.quarter_dump_lookup import (
|
||||||
|
EMPTY_DUMP_RESULT,
|
||||||
_extract_features_by_layer,
|
_extract_features_by_layer,
|
||||||
|
_get_opportunity_parcels,
|
||||||
|
_get_red_lines,
|
||||||
_get_risk_zones,
|
_get_risk_zones,
|
||||||
derive_quarter_cad,
|
derive_quarter_cad,
|
||||||
make_empty_result,
|
make_empty_result,
|
||||||
|
|
@ -183,6 +189,204 @@ def test_make_empty_result_has_risk_zones_key() -> None:
|
||||||
assert result["nspd_risk_zones"] == []
|
assert result["nspd_risk_zones"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── _get_opportunity_parcels ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_opportunity_parcels_finds_auction() -> None:
|
||||||
|
"""3 features (1 auction + 2 other types) — все возвращаются, auction в нужном layer."""
|
||||||
|
rows: list[tuple[Any, ...]] = [
|
||||||
|
# (layer, props, geom_wkt, distance_m)
|
||||||
|
(
|
||||||
|
"opportunity_auction_parcels",
|
||||||
|
{"cad_num": "66:41:0204016:101"},
|
||||||
|
"POLYGON((0 0,1 0,1 1,0 0))",
|
||||||
|
50.0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opportunity_scheme_parcels",
|
||||||
|
{"cadastral_number": "66:41:0204016:102"},
|
||||||
|
"POLYGON((1 1,2 1,2 2,1 1))",
|
||||||
|
150.0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opportunity_oopt",
|
||||||
|
{},
|
||||||
|
"POLYGON((2 2,3 2,3 3,2 2))",
|
||||||
|
300.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db = _make_mock_db_with_rows(rows)
|
||||||
|
|
||||||
|
result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
|
||||||
|
auction = next(r for r in result if r["layer"] == "auction_parcels")
|
||||||
|
assert auction["cad_num"] == "66:41:0204016:101"
|
||||||
|
assert auction["distance_m"] == 50.0
|
||||||
|
|
||||||
|
scheme = next(r for r in result if r["layer"] == "scheme_parcels")
|
||||||
|
assert scheme["cad_num"] == "66:41:0204016:102"
|
||||||
|
|
||||||
|
oopt = next(r for r in result if r["layer"] == "oopt")
|
||||||
|
assert oopt["cad_num"] is None
|
||||||
|
assert oopt["distance_m"] == 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_opportunity_parcels_distance_sort() -> None:
|
||||||
|
"""Результаты отсортированы по distance_m ASC (SQL ORDER BY передаётся DB)."""
|
||||||
|
rows: list[tuple[Any, ...]] = [
|
||||||
|
# Порядок: ближайший первым (SQL уже сортирует — тест проверяет что порядок сохраняется)
|
||||||
|
(
|
||||||
|
"opportunity_free_parcels",
|
||||||
|
{"cad_num": "66:41:0204016:10"},
|
||||||
|
"POINT(0 0)",
|
||||||
|
10.0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opportunity_auction_parcels",
|
||||||
|
{"cad_num": "66:41:0204016:20"},
|
||||||
|
"POINT(1 1)",
|
||||||
|
200.0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opportunity_future_parcels",
|
||||||
|
{},
|
||||||
|
"POINT(2 2)",
|
||||||
|
450.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db = _make_mock_db_with_rows(rows)
|
||||||
|
|
||||||
|
result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
# Порядок из DB (mock возвращает в порядке rows) — ближайший первый
|
||||||
|
assert result[0]["distance_m"] == 10.0
|
||||||
|
assert result[1]["distance_m"] == 200.0
|
||||||
|
assert result[2]["distance_m"] == 450.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_opportunity_parcels_empty() -> None:
|
||||||
|
"""Нет opportunity features — пустой список."""
|
||||||
|
db = _make_mock_db_with_rows([])
|
||||||
|
result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_opportunity_parcels_early_exit() -> None:
|
||||||
|
"""opportunity_count == 0 → early exit, db.execute не вызывается."""
|
||||||
|
db = MagicMock()
|
||||||
|
layer_counts = {"opportunity_count": 0}
|
||||||
|
result = _get_opportunity_parcels(
|
||||||
|
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
db.execute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_opportunity_parcels_db_exception_returns_empty() -> None:
|
||||||
|
"""DB exception → пустой список (не propagate)."""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = Exception("connection lost")
|
||||||
|
result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── _get_red_lines ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_red_lines_intersects() -> None:
|
||||||
|
"""Red line intersecting parcel → intersection_length_m filled, distance_m=None."""
|
||||||
|
rows: list[tuple[Any, ...]] = [
|
||||||
|
# (geom_wkt, does_intersect, intersection_length_m, distance_m)
|
||||||
|
(
|
||||||
|
"LINESTRING(0 0, 1 1)",
|
||||||
|
True,
|
||||||
|
45.3, # длина пересечения в метрах
|
||||||
|
0.0, # intersecting → distance_m becomes None in result
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db = _make_mock_db_with_rows(rows)
|
||||||
|
|
||||||
|
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
r = result[0]
|
||||||
|
assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)"
|
||||||
|
assert r["intersection_length_m"] == 45.3
|
||||||
|
assert r["distance_m"] is None # null when intersecting
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_red_lines_nearby_only() -> None:
|
||||||
|
"""Red line nearby only (не intersect) → distance_m filled, intersection_length_m=None."""
|
||||||
|
rows: list[tuple[Any, ...]] = [
|
||||||
|
# (geom_wkt, does_intersect, intersection_length_m, distance_m)
|
||||||
|
(
|
||||||
|
"LINESTRING(10 10, 20 20)",
|
||||||
|
False,
|
||||||
|
0.0, # нет пересечения
|
||||||
|
85.5,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db = _make_mock_db_with_rows(rows)
|
||||||
|
|
||||||
|
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
r = result[0]
|
||||||
|
assert r["intersection_length_m"] is None
|
||||||
|
assert r["distance_m"] == 85.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_red_lines_db_exception_returns_empty() -> None:
|
||||||
|
"""DB exception → пустой список (не propagate)."""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = Exception("DB connection lost")
|
||||||
|
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_red_lines_empty() -> None:
|
||||||
|
"""Нет red lines features — пустой список."""
|
||||||
|
db = _make_mock_db_with_rows([])
|
||||||
|
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_red_lines_early_exit() -> None:
|
||||||
|
"""red_lines_count == 0 → early exit, db.execute не вызывается."""
|
||||||
|
db = MagicMock()
|
||||||
|
layer_counts = {"red_lines_count": 0}
|
||||||
|
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts)
|
||||||
|
assert result == []
|
||||||
|
db.execute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── EMPTY_DUMP_RESULT / make_empty_result includes new TIER 4 keys ────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_dump_result_has_opportunity_key() -> None:
|
||||||
|
"""EMPTY_DUMP_RESULT содержит nspd_opportunity_parcels: []."""
|
||||||
|
assert "nspd_opportunity_parcels" in EMPTY_DUMP_RESULT
|
||||||
|
assert EMPTY_DUMP_RESULT["nspd_opportunity_parcels"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_dump_result_has_red_lines_key() -> None:
|
||||||
|
"""EMPTY_DUMP_RESULT содержит nspd_red_lines: []."""
|
||||||
|
assert "nspd_red_lines" in EMPTY_DUMP_RESULT
|
||||||
|
assert EMPTY_DUMP_RESULT["nspd_red_lines"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_empty_result_has_tier4_keys() -> None:
|
||||||
|
"""make_empty_result() возвращает nspd_opportunity_parcels и nspd_red_lines."""
|
||||||
|
result = make_empty_result()
|
||||||
|
assert "nspd_opportunity_parcels" in result
|
||||||
|
assert result["nspd_opportunity_parcels"] == []
|
||||||
|
assert "nspd_red_lines" in result
|
||||||
|
assert result["nspd_red_lines"] == []
|
||||||
|
|
||||||
|
|
||||||
# ── derive_quarter_cad (smoke tests) ──────────────────────────────────────────
|
# ── derive_quarter_cad (smoke tests) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
43
data/sql/98_nspd_quarter_dumps_opportunity_flag.sql
Normal file
43
data/sql/98_nspd_quarter_dumps_opportunity_flag.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- 98_nspd_quarter_dumps_opportunity_flag.sql
|
||||||
|
-- Context : TIER 4 opportunity denorm columns for nspd_quarter_dumps.
|
||||||
|
-- Enables fast map filter "find quarters with auction parcels" without
|
||||||
|
-- unpacking features_json JSONB on every query.
|
||||||
|
-- Part of issue #94 PR 2 (TIER 4 opportunity layers).
|
||||||
|
-- Dependencies: 88_nspd_quarter_dumps.sql (table must exist)
|
||||||
|
-- Deploy order: after 88_nspd_quarter_dumps.sql
|
||||||
|
-- Idempotent: yes — ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS
|
||||||
|
-- Related:
|
||||||
|
-- - backend/app/services/scrapers/nspd_client.py :: LAYERS dict (TIER 4)
|
||||||
|
-- - backend/app/services/site_finder/quarter_dump_lookup.py :: _get_opportunity_parcels
|
||||||
|
-- - backend/app/workers/tasks/nspd_sync.py :: _build_opportunity_count, _build_has_auction_parcels
|
||||||
|
-- Note: red_lines (layer 879243) is a TIER 1 core layer — always harvested,
|
||||||
|
-- counted in red_lines_count (88_*.sql). No separate v2 column needed.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Denorm columns for fast filter on map (find quarters with opportunity)
|
||||||
|
ALTER TABLE nspd_quarter_dumps
|
||||||
|
ADD COLUMN IF NOT EXISTS has_auction_parcels BOOLEAN DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS opportunity_count INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN nspd_quarter_dumps.has_auction_parcels IS
|
||||||
|
'TRUE если квартал содержит >= 1 feature слоя auction_parcels (NSPD layer 37299). '
|
||||||
|
'Заполняется при harvest (include_opportunity=True). Используется для быстрого '
|
||||||
|
'поиска кварталов с аукционными ЗУ на карте. (#94 PR2)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN nspd_quarter_dumps.opportunity_count IS
|
||||||
|
'Сумма features TIER 4 opportunity layers: '
|
||||||
|
'auction_parcels (37299) + scheme_parcels (37294) + free_parcels (37298) + '
|
||||||
|
'future_parcels (36473) + protected_areas/oopt (875845). '
|
||||||
|
'Заполняется при harvest (include_opportunity=True). (#94 PR2)';
|
||||||
|
|
||||||
|
-- Partial index for quick "find quarters with auction parcels" map filter
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nspd_quarter_dumps_auction
|
||||||
|
ON nspd_quarter_dumps (quarter_cad)
|
||||||
|
WHERE has_auction_parcels = TRUE;
|
||||||
|
|
||||||
|
COMMENT ON INDEX idx_nspd_quarter_dumps_auction IS
|
||||||
|
'Partial index: быстрый поиск кварталов с аукционными ЗУ для UI-фильтра карты. '
|
||||||
|
'Малая кардинальность (только TRUE rows) — очень компактный. (#94 PR2)';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -11,6 +11,8 @@ import { NspdZoningBlock } from "./NspdZoningBlock";
|
||||||
import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
|
import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
|
||||||
import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
|
import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
|
||||||
import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock";
|
import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock";
|
||||||
|
import { NspdOpportunityBlock } from "./NspdOpportunityBlock";
|
||||||
|
import { NspdRedLinesBlock } from "./NspdRedLinesBlock";
|
||||||
import { NspdFreshnessBadge } from "./NspdFreshnessBadge";
|
import { NspdFreshnessBadge } from "./NspdFreshnessBadge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -26,7 +28,11 @@ export function LandTab({ data }: Props) {
|
||||||
data.nspd_zoning !== undefined ||
|
data.nspd_zoning !== undefined ||
|
||||||
data.nspd_zouit_overlaps !== undefined ||
|
data.nspd_zouit_overlaps !== undefined ||
|
||||||
data.nspd_engineering_nearby !== undefined ||
|
data.nspd_engineering_nearby !== undefined ||
|
||||||
data.nspd_risk_zones !== undefined;
|
data.nspd_risk_zones !== undefined ||
|
||||||
|
(data.nspd_opportunity_parcels !== undefined &&
|
||||||
|
(data.nspd_opportunity_parcels?.length ?? 0) > 0) ||
|
||||||
|
(data.nspd_red_lines !== undefined &&
|
||||||
|
(data.nspd_red_lines?.length ?? 0) > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
|
@ -70,6 +76,28 @@ export function LandTab({ data }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Issue #94 PR2 TIER 4 — Opportunity parcels */}
|
||||||
|
{(data.nspd_opportunity_parcels?.length ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel style={{ marginBottom: 10 }}>
|
||||||
|
Возможности рядом (НСПД)
|
||||||
|
</SectionLabel>
|
||||||
|
<NspdOpportunityBlock
|
||||||
|
opportunityParcels={data.nspd_opportunity_parcels}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Issue #94 PR2 TIER 4 — Red lines */}
|
||||||
|
{(data.nspd_red_lines?.length ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel style={{ marginBottom: 10 }}>
|
||||||
|
Красные линии застройки (НСПД)
|
||||||
|
</SectionLabel>
|
||||||
|
<NspdRedLinesBlock redLines={data.nspd_red_lines} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Issue #94 TIER 3 — Риск-зоны НСПД */}
|
{/* Issue #94 TIER 3 — Риск-зоны НСПД */}
|
||||||
{data.nspd_risk_zones !== undefined && (
|
{data.nspd_risk_zones !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
162
frontend/src/components/site-finder/NspdOpportunityBlock.tsx
Normal file
162
frontend/src/components/site-finder/NspdOpportunityBlock.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { OpportunityParcel } from "@/types/nspd";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
opportunityParcels: OpportunityParcel[] | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LayerKey = OpportunityParcel["layer"];
|
||||||
|
|
||||||
|
const LAYER_CONFIG: Record<
|
||||||
|
LayerKey,
|
||||||
|
{ label: string; bg: string; badgeBg: string; color: string }
|
||||||
|
> = {
|
||||||
|
auction_parcels: {
|
||||||
|
label: "На аукционе",
|
||||||
|
bg: "#fff7ed",
|
||||||
|
badgeBg: "#fed7aa",
|
||||||
|
color: "#c2410c",
|
||||||
|
},
|
||||||
|
scheme_parcels: {
|
||||||
|
label: "Схема расположения",
|
||||||
|
bg: "#eff6ff",
|
||||||
|
badgeBg: "#bfdbfe",
|
||||||
|
color: "#1d4ed8",
|
||||||
|
},
|
||||||
|
free_parcels: {
|
||||||
|
label: "Свободный от прав",
|
||||||
|
bg: "#f0fdf4",
|
||||||
|
badgeBg: "#bbf7d0",
|
||||||
|
color: "#15803d",
|
||||||
|
},
|
||||||
|
future_parcels: {
|
||||||
|
label: "Планируемый (проект межевания)",
|
||||||
|
bg: "#f9fafb",
|
||||||
|
badgeBg: "#e5e7eb",
|
||||||
|
color: "#374151",
|
||||||
|
},
|
||||||
|
oopt: {
|
||||||
|
label: "ООПТ",
|
||||||
|
bg: "#faf5ff",
|
||||||
|
badgeBg: "#e9d5ff",
|
||||||
|
color: "#7e22ce",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDistance(m: number | null): string {
|
||||||
|
if (m === null) return "";
|
||||||
|
if (m < 1000) return `${Math.round(m)} м`;
|
||||||
|
return `${(m / 1000).toFixed(1)} км`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNspdViewerUrl(cadNum: string): string {
|
||||||
|
return `https://nspd.gov.ru/map?cadastralNumber=${encodeURIComponent(cadNum)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NspdOpportunityBlock({ opportunityParcels }: Props) {
|
||||||
|
const parcels = opportunityParcels ?? [];
|
||||||
|
|
||||||
|
if (parcels.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by layer type
|
||||||
|
const grouped = parcels.reduce<Record<LayerKey, OpportunityParcel[]>>(
|
||||||
|
(acc, p) => {
|
||||||
|
const key = p.layer as LayerKey;
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(p);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<LayerKey, OpportunityParcel[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stable display order: auction first (highest priority)
|
||||||
|
const layerOrder: LayerKey[] = [
|
||||||
|
"auction_parcels",
|
||||||
|
"free_parcels",
|
||||||
|
"scheme_parcels",
|
||||||
|
"future_parcels",
|
||||||
|
"oopt",
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{layerOrder.map((layerKey) => {
|
||||||
|
const items = grouped[layerKey];
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
const cfg = LAYER_CONFIG[layerKey];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={layerKey}
|
||||||
|
style={{
|
||||||
|
background: cfg.bg,
|
||||||
|
border: `1px solid ${cfg.badgeBg}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: items.length > 1 ? 8 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: cfg.badgeBg,
|
||||||
|
color: cfg.color,
|
||||||
|
borderRadius: 5,
|
||||||
|
padding: "2px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 13, color: "#4b5563" }}>
|
||||||
|
{items.length} уч.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.map((p, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.cad_num ? (
|
||||||
|
<a
|
||||||
|
href={buildNspdViewerUrl(p.cad_num)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: cfg.color, textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
{p.cad_num}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "#6b7280" }}>—</span>
|
||||||
|
)}
|
||||||
|
{p.distance_m !== null && (
|
||||||
|
<span style={{ color: "#6b7280" }}>
|
||||||
|
{formatDistance(p.distance_m)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
frontend/src/components/site-finder/NspdRedLinesBlock.tsx
Normal file
136
frontend/src/components/site-finder/NspdRedLinesBlock.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { RedLine } from "@/types/nspd";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
redLines: RedLine[] | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLength(m: number | null): string | null {
|
||||||
|
if (m === null) return null;
|
||||||
|
if (m >= 1000) return `${(m / 1000).toFixed(2)} км`;
|
||||||
|
return `${Math.round(m)} м`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistance(m: number | null): string | null {
|
||||||
|
if (m === null) return null;
|
||||||
|
if (m < 1000) return `${Math.round(m)} м`;
|
||||||
|
return `${(m / 1000).toFixed(1)} км`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NspdRedLinesBlock({ redLines }: Props) {
|
||||||
|
const lines = redLines ?? [];
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intersecting = lines.filter((l) => l.intersection_length_m !== null);
|
||||||
|
const nearbyOnly = lines.filter((l) => l.intersection_length_m === null);
|
||||||
|
const hasIntersect = intersecting.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{/* Alert banner when red lines intersect the parcel */}
|
||||||
|
{hasIntersect && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fef2f2",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "#fee2e2",
|
||||||
|
color: "#991b1b",
|
||||||
|
borderRadius: 5,
|
||||||
|
padding: "2px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ПЕРЕСЕЧЕНИЕ
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: "#991b1b" }}>
|
||||||
|
Красные линии пересекают участок — отступы могут быть критичны
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#7f1d1d", marginTop: 4 }}>
|
||||||
|
{intersecting.map((l, idx) => {
|
||||||
|
const lenStr = formatLength(l.intersection_length_m);
|
||||||
|
return (
|
||||||
|
<div key={idx}>
|
||||||
|
Линия {idx + 1}
|
||||||
|
{lenStr ? `: длина пересечения ${lenStr}` : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nearby-only red lines */}
|
||||||
|
{nearbyOnly.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff7ed",
|
||||||
|
border: "1px solid #fed7aa",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "#ffedd5",
|
||||||
|
color: "#9a3412",
|
||||||
|
borderRadius: 5,
|
||||||
|
padding: "2px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
РЯДОМ
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: "#9a3412" }}>
|
||||||
|
{nearbyOnly.length} кр. лини{nearbyOnly.length === 1 ? "я" : "и"}{" "}
|
||||||
|
вблизи участка
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#7c2d12", marginTop: 4 }}>
|
||||||
|
{nearbyOnly.map((l, idx) => {
|
||||||
|
const distStr = formatDistance(l.distance_m);
|
||||||
|
return (
|
||||||
|
<div key={idx}>
|
||||||
|
Линия {idx + 1}
|
||||||
|
{distStr ? `: ${distStr} от границы` : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary line */}
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280" }}>
|
||||||
|
Итого: {lines.length} красн. лини{lines.length === 1 ? "я" : "и"}
|
||||||
|
{hasIntersect
|
||||||
|
? `, из них ${intersecting.length} пересека${intersecting.length === 1 ? "ет" : "ют"} участок`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,28 @@ export interface RiskZone {
|
||||||
intersection_area_sqm: number | null;
|
intersection_area_sqm: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opportunity parcels (issue #94 TIER 4)
|
||||||
|
|
||||||
|
export interface OpportunityParcel {
|
||||||
|
layer:
|
||||||
|
| "auction_parcels"
|
||||||
|
| "scheme_parcels"
|
||||||
|
| "free_parcels"
|
||||||
|
| "future_parcels"
|
||||||
|
| "oopt";
|
||||||
|
cad_num: string | null;
|
||||||
|
distance_m: number | null;
|
||||||
|
geom_wkt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red lines (issue #94 TIER 4 + #54 Generative foundation)
|
||||||
|
|
||||||
|
export interface RedLine {
|
||||||
|
geom_wkt: string | null;
|
||||||
|
intersection_length_m: number | null; // null if only nearby, not intersecting
|
||||||
|
distance_m: number | null; // null if intersecting
|
||||||
|
}
|
||||||
|
|
||||||
// Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points)
|
// Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points)
|
||||||
|
|
||||||
export interface EngineeringStructure {
|
export interface EngineeringStructure {
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,9 @@ export interface ParcelAnalysis {
|
||||||
nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[];
|
nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[];
|
||||||
nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[];
|
nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[];
|
||||||
nspd_risk_zones?: import("./nspd").RiskZone[];
|
nspd_risk_zones?: import("./nspd").RiskZone[];
|
||||||
|
// Issue #94 PR2 — TIER 4 opportunity layers + red lines
|
||||||
|
nspd_opportunity_parcels?: import("./nspd").OpportunityParcel[];
|
||||||
|
nspd_red_lines?: import("./nspd").RedLine[];
|
||||||
nspd_dump?: import("./nspd").NspdDumpMeta | null;
|
nspd_dump?: import("./nspd").NspdDumpMeta | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue