- nspd_geo: add _save_parcel() for thematic_id=1 → cad_parcels_geom (UPSERT, ST_Transform from Web Mercator); _persist_target now handles 1/2/5 - parcels.py: analyze endpoint geom lookup extended with cad_parcels_geom as 3rd fallback source (after cad_quarters_geom, cad_buildings); both SELECT and WKT subqueries updated - parcels.py: POI score_breakdown items now include lat/lon for map markers - poi_loader: OSM_CATEGORIES expanded — college+university→school, hypermarket→shop_supermarket; coverage +3 tag pairs
236 lines
7.8 KiB
Python
236 lines
7.8 KiB
Python
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
|
||
UNION ALL
|
||
SELECT ST_AsGeoJSON(p.geom) AS geom_geojson,
|
||
p.geom AS geom_wkb,
|
||
'cad_parcel' AS source
|
||
FROM cad_parcels_geom p
|
||
WHERE p.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
|
||
UNION ALL
|
||
SELECT p.geom FROM cad_parcels_geom p WHERE p.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"])),
|
||
"lat": float(p["lat"]) if p["lat"] is not None else None,
|
||
"lon": float(p["lon"]) if p["lon"] is not None else None,
|
||
"last_edit": (
|
||
p["last_osm_edit_date"].isoformat() if p["last_osm_edit_date"] else None
|
||
),
|
||
}
|
||
)
|
||
|
||
# 5) Конкуренты в радиусе 3 км из DOM.РФ.
|
||
# NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
|
||
# latest snapshot, иначе дубликаты ЖК в выдаче.
|
||
competitor_rows = (
|
||
db.execute(
|
||
text("""
|
||
WITH latest_obj AS (
|
||
SELECT DISTINCT ON (obj_id) *
|
||
FROM domrf_kn_objects
|
||
WHERE latitude IS NOT NULL
|
||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||
)
|
||
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 latest_obj o
|
||
WHERE 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],
|
||
}
|