gendesign/backend/app/api/v1/parcels.py
lekss361 a4e8fff5ac feat(site-finder): POST /parcels/{cad}/analyze endpoint + UI
Backend:
- analyze endpoint: cad → geom lookup → POI within 1km → district
  context → competitors within 3km. Tram_stop carries negative weight.

Frontend /site-finder:
- CadInput regex-validated (default 66:41:0204016:10)
- SiteMap (Leaflet via next/dynamic) with parcel polygon + legend
- ScoreCard color-coded (>5 green, 2-5 yellow, <2 red) with tram-warning
- CompetitorTable top-20 with same-district highlight
- useSiteAnalysis hook + TS types
2026-05-11 18:11:58 +03:00

219 lines
7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# Веса POI-категорий для scoring (Максим: трамвай = минус)
_POI_WEIGHTS: dict[str, float] = {
"school": 1.5,
"kindergarten": 1.5,
"pharmacy": 0.8,
"hospital": 0.6,
"shop_mall": 1.2,
"shop_supermarket": 1.0,
"shop_small": 0.5,
"park": 1.8,
"bus_stop": 0.3,
"metro_stop": 1.5,
"tram_stop": -0.5, # негативный вес — шум / вибрация
}
@router.post("/search", response_model=ParcelSearchResponse)
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
"""Search parcels by filters + scoring.
TODO Stage 2b: PostGIS query + scorer service.
"""
return ParcelSearchResponse(items=[], total=0)
@router.get("/{parcel_id}", response_model=ParcelDetail)
async def get_parcel(parcel_id: str) -> ParcelDetail:
"""TODO Stage 2b: fetch parcel by id from DB."""
raise HTTPException(status_code=501, detail="Not implemented yet")
@router.post("/{cad_num}/analyze")
def analyze_parcel(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Анализ участка: близость к социалке + district context + конкуренты.
Порядок поиска геометрии: cad_quarters_geom → cad_buildings.
"""
# 1) Получить геометрию участка — GeoJSON строка через ST_AsGeoJSON
row = (
db.execute(
text("""
SELECT ST_AsGeoJSON(g.geom) AS geom_geojson,
g.geom AS geom_wkb,
'cad_quarter' AS source
FROM cad_quarters_geom g
WHERE g.cad_number = :c
UNION ALL
SELECT ST_AsGeoJSON(b.geom) AS geom_geojson,
b.geom AS geom_wkb,
'cad_building' AS source
FROM cad_buildings b
WHERE b.cad_num = :c
LIMIT 1
"""),
{"c": cad_num},
)
.mappings()
.first()
)
if not row:
raise HTTPException(
status_code=404,
detail=f"Геометрия для {cad_num} не найдена. Загрузи через NSPD geo.",
)
geom_geojson: str = row["geom_geojson"]
source: str = row["source"]
# Используем ST_AsText для передачи геометрии в последующие запросы.
# Все PostGIS-запросы принимают текстовый WKT через ST_GeomFromText.
geom_row = (
db.execute(
text("""
SELECT ST_AsText(g.geom) AS wkt
FROM (
SELECT g.geom FROM cad_quarters_geom g WHERE g.cad_number = :c
UNION ALL
SELECT b.geom FROM cad_buildings b WHERE b.cad_num = :c
) g
LIMIT 1
"""),
{"c": cad_num},
)
.mappings()
.first()
)
geom_wkt: str = geom_row["wkt"] # type: ignore[index]
# 2) District context — ближайший район ЕКБ
district_row = (
db.execute(
text("""
SELECT district_name,
median_price_per_m2,
ST_Distance(
d.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS dist_to_center
FROM ekb_districts d
WHERE ST_DWithin(
d.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
5000
)
ORDER BY dist_to_center ASC
LIMIT 1
"""),
{"wkt": geom_wkt},
)
.mappings()
.first()
)
# 3) POI в радиусе 1 км — список с distance_m
poi_rows = (
db.execute(
text("""
SELECT category,
name,
lat,
lon,
ST_Distance(
p.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
last_osm_edit_date
FROM osm_poi_ekb p
WHERE ST_DWithin(
p.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
1000
)
ORDER BY distance_m ASC
"""),
{"wkt": geom_wkt},
)
.mappings()
.all()
)
# 4) Scoring: weighted sum с distance decay
score = 0.0
by_category: dict[str, list[dict[str, Any]]] = {}
for p in poi_rows:
cat: str = p["category"]
w = _POI_WEIGHTS.get(cat, 0.0)
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
decay = max(0.0, 1.0 - float(p["distance_m"]) / 1000.0)
score += w * decay
by_category.setdefault(cat, []).append(
{
"name": p["name"],
"distance_m": round(float(p["distance_m"])),
"last_edit": (
p["last_osm_edit_date"].isoformat() if p["last_osm_edit_date"] else None
),
}
)
# 5) Конкуренты в радиусе 3 км из DOM.РФ
competitor_rows = (
db.execute(
text("""
SELECT obj_id,
comm_name,
dev_name,
obj_class,
flat_count,
district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM domrf_kn_objects o
WHERE o.latitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
ORDER BY o.flat_count DESC NULLS LAST
LIMIT 20
"""),
{"wkt": geom_wkt},
)
.mappings()
.all()
)
return {
"cad_num": cad_num,
"source": source,
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
"district": dict(district_row) if district_row else None,
"score": round(score, 2),
"score_breakdown": by_category,
"poi_count": len(poi_rows),
"competitors": [dict(c) for c in competitor_rows],
}