Merge pull request 'feat(nspd): TIER 4 opportunity + red lines (#94 PR2 of 4)' (#220) from feat/94-pr2-opportunity-redlines into main
This commit is contained in:
commit
5a7d405c77
12 changed files with 979 additions and 9 deletions
|
|
@ -21,9 +21,11 @@ from app.schemas.parcel import (
|
|||
CompetitorsRequest,
|
||||
CompetitorsResponse,
|
||||
ConnectionPointsResponse,
|
||||
OpportunityParcel,
|
||||
ParcelDetail,
|
||||
ParcelSearchRequest,
|
||||
ParcelSearchResponse,
|
||||
RedLine,
|
||||
RiskZone,
|
||||
)
|
||||
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
||||
|
|
@ -2065,11 +2067,17 @@ def analyze_parcel(
|
|||
# nspd_zouit_overlaps: ЗОУИТ пересечения (G3)
|
||||
# nspd_engineering_nearby: инженерные сооружения в 200м (I3)
|
||||
# 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_zoning": nspd_dump_data["nspd_zoning"],
|
||||
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
|
||||
"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_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"],
|
||||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
||||
"gate_verdict": compute_gate_verdict(
|
||||
|
|
|
|||
|
|
@ -80,6 +80,46 @@ class RiskZone(BaseModel):
|
|||
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 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -199,6 +199,9 @@ class QuarterDump:
|
|||
engineering_structures: list[NSPDFeature]
|
||||
zouit: dict[str, list[NSPDFeature]] # {"okn": [...], "engineering": [...], ...}
|
||||
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)
|
||||
layers_fetched: tuple[str, ...]
|
||||
bbox_3857: tuple[float, float, float, float] | None # bbox квартала
|
||||
|
|
@ -215,6 +218,7 @@ class QuarterDump:
|
|||
+ len(self.engineering_structures)
|
||||
+ 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.opportunity.values())
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -489,6 +493,16 @@ class NSPDClient:
|
|||
"clutter": "risk_clutter",
|
||||
"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(
|
||||
self,
|
||||
|
|
@ -496,6 +510,7 @@ class NSPDClient:
|
|||
*,
|
||||
include_zouit: bool = True,
|
||||
include_risks: bool = False,
|
||||
include_opportunity: bool = False,
|
||||
) -> QuarterDump:
|
||||
"""Harvest всех NSPD-данных для квартала: 1 vacuum, N layers.
|
||||
|
||||
|
|
@ -517,13 +532,17 @@ class NSPDClient:
|
|||
include_zouit: Включать TIER 2 ЗОУИТ layers (G3). Default True.
|
||||
include_risks: Включать TIER 3 risk zones. Default False (rate-limit
|
||||
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:
|
||||
QuarterDump с per-layer feature lists. Если NSPD пуст / quarter
|
||||
не найден — `quarter=None`, `bbox_3857=None`, все feature lists
|
||||
пустые (no bulk-fetch без bounds — нет смысла). При этом dict-
|
||||
поля `zouit` / `risks` всё равно populated с пустыми lists для
|
||||
каждого включённого short_name (структура контракта стабильна).
|
||||
поля `zouit` / `risks` / `opportunity` всё равно populated с пустыми
|
||||
lists для каждого включённого short_name
|
||||
(структура контракта стабильна).
|
||||
`layers_fetched` в этом случае содержит только `('search',)`.
|
||||
|
||||
Raises:
|
||||
|
|
@ -532,7 +551,7 @@ class NSPDClient:
|
|||
операция атомарна (failure → exception).
|
||||
|
||||
Закрывает: 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
|
||||
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():
|
||||
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(
|
||||
quarter_cad=quarter_cad,
|
||||
quarter=quarter_feat,
|
||||
|
|
@ -587,6 +612,7 @@ class NSPDClient:
|
|||
engineering_structures=engineering_structures,
|
||||
zouit=zouit,
|
||||
risks=risks,
|
||||
opportunity=opportunity,
|
||||
layers_fetched=tuple(layers_fetched),
|
||||
bbox_3857=bbox,
|
||||
fetched_at_utc=_dt.datetime.now(_dt.UTC).isoformat(),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ EMPTY_DUMP_RESULT: dict[str, Any] = {
|
|||
"nspd_zouit_overlaps": [],
|
||||
"nspd_engineering_nearby": [],
|
||||
"nspd_risk_zones": [],
|
||||
"nspd_opportunity_parcels": [],
|
||||
"nspd_red_lines": [],
|
||||
"nspd_dump": {
|
||||
"available": False,
|
||||
"fetched_at_utc": None,
|
||||
|
|
@ -65,6 +67,8 @@ def make_empty_result(
|
|||
"nspd_zouit_overlaps": [],
|
||||
"nspd_engineering_nearby": [],
|
||||
"nspd_risk_zones": [],
|
||||
"nspd_opportunity_parcels": [],
|
||||
"nspd_red_lines": [],
|
||||
"nspd_dump": {
|
||||
"available": False,
|
||||
"fetched_at_utc": fetched_at_utc,
|
||||
|
|
@ -128,7 +132,9 @@ def get_quarter_dump_data(
|
|||
territorial_zones_count,
|
||||
zouit_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
|
||||
WHERE quarter_cad = :q
|
||||
"""
|
||||
|
|
@ -155,6 +161,8 @@ def get_quarter_dump_data(
|
|||
zouit_count: int = row[5] or 0
|
||||
engineering_count: int = row[6] 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
|
||||
has_error = harvest_error is not None
|
||||
|
|
@ -185,6 +193,8 @@ def get_quarter_dump_data(
|
|||
"nspd_zouit_overlaps": [],
|
||||
"nspd_engineering_nearby": [],
|
||||
"nspd_risk_zones": [],
|
||||
"nspd_opportunity_parcels": [],
|
||||
"nspd_red_lines": [],
|
||||
"nspd_dump": dump_meta,
|
||||
}
|
||||
|
||||
|
|
@ -193,17 +203,23 @@ def get_quarter_dump_data(
|
|||
"zouit_count": zouit_count,
|
||||
"engineering_count": engineering_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_zouit = _get_zouit_overlaps(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_opportunity = _get_opportunity_parcels(db, quarter, parcel_wkt, layer_counts)
|
||||
nspd_red_lines = _get_red_lines(db, quarter, parcel_wkt, layer_counts)
|
||||
|
||||
return {
|
||||
"nspd_zoning": nspd_zoning,
|
||||
"nspd_zouit_overlaps": nspd_zouit,
|
||||
"nspd_engineering_nearby": nspd_engineering,
|
||||
"nspd_risk_zones": nspd_risk_zones,
|
||||
"nspd_opportunity_parcels": nspd_opportunity,
|
||||
"nspd_red_lines": nspd_red_lines,
|
||||
"nspd_dump": dump_meta,
|
||||
}
|
||||
|
||||
|
|
@ -549,6 +565,245 @@ def _get_risk_zones(
|
|||
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) ────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -863,7 +1118,15 @@ def _trigger_harvest(quarter: str) -> bool:
|
|||
try:
|
||||
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)
|
||||
return True
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -82,6 +82,12 @@ def _build_features_json(dump: QuarterDump) -> list[dict[str, Any]]:
|
|||
for feat in features:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -95,6 +101,16 @@ def _build_risks_count(dump: QuarterDump) -> int:
|
|||
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_SQL = text(
|
||||
|
|
@ -103,6 +119,7 @@ _UPSERT_SQL = text(
|
|||
quarter_cad, quarter_geom, bbox_3857,
|
||||
parcels_count, buildings_count, territorial_zones_count,
|
||||
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,
|
||||
harvest_error, region_code
|
||||
) VALUES (
|
||||
|
|
@ -115,6 +132,7 @@ _UPSERT_SQL = text(
|
|||
END,
|
||||
:parcels_count, :buildings_count, :territorial_zones_count,
|
||||
:red_lines_count, :engineering_count, :zouit_count, :risks_count, :total_features,
|
||||
:has_auction_parcels, :opportunity_count,
|
||||
CAST(:features_json AS jsonb),
|
||||
CAST(:layers_fetched AS text[]),
|
||||
CAST(:fetched_at_utc AS timestamptz),
|
||||
|
|
@ -133,6 +151,8 @@ _UPSERT_SQL = text(
|
|||
zouit_count = EXCLUDED.zouit_count,
|
||||
risks_count = EXCLUDED.risks_count,
|
||||
total_features = EXCLUDED.total_features,
|
||||
has_auction_parcels = EXCLUDED.has_auction_parcels,
|
||||
opportunity_count = EXCLUDED.opportunity_count,
|
||||
features_json = EXCLUDED.features_json,
|
||||
layers_fetched = EXCLUDED.layers_fetched,
|
||||
fetched_at_utc = EXCLUDED.fetched_at_utc,
|
||||
|
|
@ -178,6 +198,8 @@ def _upsert_dump(
|
|||
"engineering_count": len(dump.engineering_structures),
|
||||
"zouit_count": _build_zouit_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,
|
||||
"features_json": json.dumps(features_json or [], ensure_ascii=False),
|
||||
"layers_fetched": list(dump.layers_fetched),
|
||||
|
|
@ -202,6 +224,8 @@ def _upsert_dump(
|
|||
"engineering_count": 0,
|
||||
"zouit_count": 0,
|
||||
"risks_count": 0,
|
||||
"has_auction_parcels": False,
|
||||
"opportunity_count": 0,
|
||||
"total_features": 0,
|
||||
"features_json": "[]",
|
||||
"layers_fetched": [],
|
||||
|
|
@ -239,20 +263,26 @@ def harvest_quarter(
|
|||
region_code: int = 66,
|
||||
include_zouit: bool = True,
|
||||
include_risks: bool = False,
|
||||
include_opportunity: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Single-quarter harvest. NSPDClient.search_by_quarter → UPSERT nspd_quarter_dumps.
|
||||
|
||||
Идемпотентен: повторный вызов обновляет строку (ON CONFLICT DO UPDATE).
|
||||
WAF 403/429 → autoretry с exponential backoff (max 3 попытки).
|
||||
Другие исключения → запись harvest_error в строку, return error dict (не raise).
|
||||
|
||||
Args:
|
||||
include_opportunity: Фетчить TIER 4 opportunity layers (+5 HTTP запросов).
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
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,
|
||||
region_code,
|
||||
include_zouit,
|
||||
include_risks,
|
||||
include_opportunity,
|
||||
)
|
||||
|
||||
client = NSPDClient()
|
||||
|
|
@ -264,6 +294,7 @@ def harvest_quarter(
|
|||
quarter_cad,
|
||||
include_zouit=include_zouit,
|
||||
include_risks=include_risks,
|
||||
include_opportunity=include_opportunity,
|
||||
)
|
||||
features_json = _build_features_json(dump)
|
||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||
|
|
@ -391,7 +422,11 @@ def harvest_stale_quarters(
|
|||
try:
|
||||
harvest_quarter.apply_async(
|
||||
args=[cad, region_code],
|
||||
kwargs={"include_zouit": True, "include_risks": True},
|
||||
kwargs={
|
||||
"include_zouit": True,
|
||||
"include_risks": True,
|
||||
"include_opportunity": True,
|
||||
},
|
||||
)
|
||||
enqueued += 1
|
||||
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
|
||||
- _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect,
|
||||
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().
|
||||
"""
|
||||
|
||||
|
|
@ -15,7 +18,10 @@ from unittest.mock import MagicMock
|
|||
import pytest
|
||||
|
||||
from app.services.site_finder.quarter_dump_lookup import (
|
||||
EMPTY_DUMP_RESULT,
|
||||
_extract_features_by_layer,
|
||||
_get_opportunity_parcels,
|
||||
_get_red_lines,
|
||||
_get_risk_zones,
|
||||
derive_quarter_cad,
|
||||
make_empty_result,
|
||||
|
|
@ -183,6 +189,204 @@ def test_make_empty_result_has_risk_zones_key() -> None:
|
|||
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) ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
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 { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
|
||||
import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock";
|
||||
import { NspdOpportunityBlock } from "./NspdOpportunityBlock";
|
||||
import { NspdRedLinesBlock } from "./NspdRedLinesBlock";
|
||||
import { NspdFreshnessBadge } from "./NspdFreshnessBadge";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -26,7 +28,11 @@ export function LandTab({ data }: Props) {
|
|||
data.nspd_zoning !== undefined ||
|
||||
data.nspd_zouit_overlaps !== 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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
|
|
@ -70,6 +76,28 @@ export function LandTab({ data }: Props) {
|
|||
</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 — Риск-зоны НСПД */}
|
||||
{data.nspd_risk_zones !== undefined && (
|
||||
<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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
export interface EngineeringStructure {
|
||||
|
|
|
|||
|
|
@ -384,6 +384,9 @@ export interface ParcelAnalysis {
|
|||
nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[];
|
||||
nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[];
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue