feat(nspd): TIER 4 opportunity + red lines (#94 PR2 of 4) #220

Merged
lekss361 merged 2 commits from feat/94-pr2-opportunity-redlines into main 2026-05-16 17:09:53 +00:00
12 changed files with 979 additions and 9 deletions

View file

@ -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(

View file

@ -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 ──────────────────────────────────────────────

View file

@ -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(),

View file

@ -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:

View file

@ -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:

View file

@ -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) ──────────────────────────────────────────

View 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;

View file

@ -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>

View 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>
);
}

View 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>
);
}

View file

@ -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 {

View file

@ -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;
} }