diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 9a315b14..f8c407d3 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -30,6 +30,7 @@ from app.schemas.parcel import ( RiskZone, ) from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf +from app.services.exporters.snapshot_pdf import generate_snapshot_pdf from app.services.site_finder.best_layouts import get_best_layouts from app.services.site_finder.cadastre_fetch import ( cad_exists_in_db, @@ -43,6 +44,7 @@ from app.services.site_finder.custom_pois import ( get_overlaps_for_scoring as _get_custom_poi_overlaps, ) from app.services.site_finder.gate_verdict import compute_gate_verdict +from app.services.site_finder.poi_score import PoiScoreResponse, compute_poi_weighted_top7 from app.services.site_finder.quarter_dump_lookup import ( get_connection_points, get_quarter_dump_data, @@ -1701,6 +1703,217 @@ def analyze_parcel( except Exception as e: logger.warning("parcel_meta query failed for %s: %s", cad_num, e) + # B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5) + egrn_block: dict[str, Any] = {} + try: + egrn_row = ( + db.execute( + text(""" + SELECT cost_value AS cadastral_value_rub, + cost_index AS cost_index_per_m2, + land_record_category_type AS land_category, + permitted_use_established_by_document AS permitted_use_text, + cost_registration_date AS last_egrn_update_date, + land_record_area AS area_m2, + ownership_type, + right_type, + status, + readable_address, + registration_date + FROM cad_parcels + WHERE cad_num = CAST(:c AS text) + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + if egrn_row: + _cad_val = ( + float(egrn_row["cadastral_value_rub"]) + if egrn_row["cadastral_value_rub"] is not None + else None + ) + _area_m2 = float(egrn_row["area_m2"]) if egrn_row["area_m2"] is not None else None + _idx = egrn_row["cost_index_per_m2"] + _cad_per_m2: float | None = None + if _idx is not None: + _cad_per_m2 = float(_idx) + elif _cad_val is not None and _area_m2 and _area_m2 > 0: + _cad_per_m2 = round(_cad_val / _area_m2, 2) + egrn_block = { + "cadastral_value_rub": _cad_val, + "cadastral_value_per_m2": _cad_per_m2, + "land_category": egrn_row["land_category"], + "permitted_use_text": egrn_row["permitted_use_text"], + "last_egrn_update_date": ( + egrn_row["last_egrn_update_date"].isoformat() + if egrn_row["last_egrn_update_date"] is not None + else None + ), + "area_m2": _area_m2, + "ownership_type": egrn_row["ownership_type"], + "right_type": egrn_row["right_type"], + "parcel_status": egrn_row["status"], + "address": egrn_row["readable_address"], + "registration_date": ( + egrn_row["registration_date"].isoformat() + if egrn_row["registration_date"] is not None + else None + ), + } + except Exception as e: + logger.warning("egrn_block query failed for %s: %s", cad_num, e) + + # B5-2) Encumbrance block — ЗОУИТ из cad_zouit (SF-B5) + encumbrance_block: dict[str, Any] = { + "has_zouit": False, + "zouit_types": [], + "zouit_count": 0, + } + try: + zouit_rows = ( + db.execute( + text(""" + SELECT type_zone, name_by_doc + FROM cad_zouit + WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326)) + ORDER BY id + """), + {"wkt": geom_wkt}, + ) + .mappings() + .all() + ) + if zouit_rows: + _zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]}) + encumbrance_block = { + "has_zouit": True, + "zouit_types": _zouit_types, + "zouit_count": len(zouit_rows), + } + except Exception as e: + logger.warning("encumbrance_block query failed for %s: %s", cad_num, e) + + # B5-3) Red lines block — пересечение с cad_red_lines (SF-B5) + red_lines_block: dict[str, Any] = {"intersects": False, "count": 0} + try: + rl_row = ( + db.execute( + text(""" + SELECT COUNT(*) AS cnt + FROM cad_red_lines + WHERE ST_Intersects( + geom::geometry, + ST_GeomFromText(:wkt, 4326) + ) + """), + {"wkt": geom_wkt}, + ) + .mappings() + .first() + ) + if rl_row: + _rl_cnt = int(rl_row["cnt"]) + red_lines_block = { + "intersects": _rl_cnt > 0, + "count": _rl_cnt, + } + except Exception as e: + logger.warning("red_lines_block query failed for %s: %s", cad_num, e) + + # B5-4) Metro placeholder — заполнится после merge 22h metro scraper + metro_block: dict[str, Any] = {"nearest_top3": None} + + # B5-5) District price ranges из objective_lots (SF-B5) + district_price_block: dict[str, Any] = { + "district_price_per_m2_min": None, + "district_price_per_m2_max": None, + "district_price_per_m2_median": None, + "district_price_sample_size": None, + } + if district_row and district_row["district_name"]: + try: + dp_row = ( + db.execute( + text(""" + SELECT + MIN(price_per_m2_rub) AS price_min, + MAX(price_per_m2_rub) AS price_max, + PERCENTILE_CONT(0.5) WITHIN GROUP ( + ORDER BY price_per_m2_rub + ) AS price_median, + COUNT(*) AS sample_size + FROM objective_lots + WHERE district = CAST(:dn AS text) + AND price_per_m2_rub IS NOT NULL + AND price_per_m2_rub BETWEEN 30000 AND 600000 + """), + {"dn": district_row["district_name"]}, + ) + .mappings() + .first() + ) + if dp_row and dp_row["sample_size"] and int(dp_row["sample_size"]) > 0: + district_price_block = { + "district_price_per_m2_min": ( + round(float(dp_row["price_min"])) if dp_row["price_min"] else None + ), + "district_price_per_m2_max": ( + round(float(dp_row["price_max"])) if dp_row["price_max"] else None + ), + "district_price_per_m2_median": ( + round(float(dp_row["price_median"])) if dp_row["price_median"] else None + ), + "district_price_sample_size": int(dp_row["sample_size"]), + } + except Exception as e: + logger.warning("district_price_block query failed for %s: %s", cad_num, e) + + # B5-6) Risk indicators — flood_zone из cad_risk_zones + noise_score + geology proxy (SF-B5) + risks_block: dict[str, Any] = { + "flood_zone": False, + "noise_score": round(noise_score, 2), + "geology_risk_label": None, + } + try: + flood_row = ( + db.execute( + text(""" + SELECT COUNT(*) AS cnt + FROM cad_risk_zones + WHERE ST_Intersects( + geom::geometry, + ST_GeomFromText(:wkt, 4326) + ) + AND (risk_type ILIKE '%flood%' OR risk_type ILIKE '%подтоп%' + OR risk_type ILIKE '%затоп%') + """), + {"wkt": geom_wkt}, + ) + .mappings() + .first() + ) + _flood = bool(flood_row and int(flood_row["cnt"]) > 0) + # Geology proxy через hydrology flood_risk_flag (уже посчитан выше) + _geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False + _has_flood = _flood or _geo_flood + # geology_risk_label: high если flooding, medium если шум > 65дБ, иначе low + if _has_flood: + _geo_label: str | None = "high" + elif noise_db_max >= 65.0: + _geo_label = "medium" + else: + _geo_label = "low" + risks_block = { + "flood_zone": _has_flood, + "noise_score": round(noise_score, 2), + "geology_risk_label": _geo_label, + } + except Exception as e: + logger.warning("risks_block query failed for %s: %s", cad_num, e) + # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев market_trend: dict[str, Any] | None = None try: @@ -2299,6 +2512,16 @@ def analyze_parcel( }, # #254: custom POI scoring — user-defined points (via X-Session-Id header). "custom_poi_score_items": custom_poi_items, + # SF-B5: EGRN + encumbrance + red_lines + metro + district prices + risks + "egrn": egrn_block, + "encumbrance": encumbrance_block, + "red_lines": red_lines_block, + "metro": metro_block, + "district_price_per_m2_min": district_price_block["district_price_per_m2_min"], + "district_price_per_m2_max": district_price_block["district_price_per_m2_max"], + "district_price_per_m2_median": district_price_block["district_price_per_m2_median"], + "district_price_sample_size": district_price_block["district_price_sample_size"], + "risks": risks_block, } @@ -2455,6 +2678,56 @@ def get_isochrones( } +@router.get( + "/{cad_num}/poi-score", + response_model=PoiScoreResponse, + summary="POI weighted top-7 (B6)", +) +async def get_poi_score( + cad_num: str, + db: Annotated[Session, Depends(get_db)], + radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000, +) -> PoiScoreResponse: + """Вернуть top-7 ближайших POI для участка, взвешенных по формуле: + + weight = (1 / (distance_m + 100)) * category_weight + + POI берутся из osm_poi_ekb в заданном радиусе (default 2000м). + Отсортированы по weight DESC — наиболее значимые объекты первыми. + """ + # Получить координаты центроида участка из геометрических таблиц + coord_row = ( + db.execute( + text(""" + SELECT ST_X(ST_Centroid(g.geom)) AS lon, + ST_Y(ST_Centroid(g.geom)) AS lat + FROM ( + SELECT geom FROM cad_quarters_geom WHERE cad_number = :c + UNION ALL + SELECT geom FROM cad_buildings WHERE cad_num = :c + UNION ALL + SELECT geom FROM cad_parcels_geom WHERE cad_num = :c + ) g + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + + if not coord_row: + raise HTTPException( + status_code=404, + detail=f"Геометрия для {cad_num} не найдена.", + ) + + lat = float(coord_row["lat"]) + lon = float(coord_row["lon"]) + + return compute_poi_weighted_top7(db, cad_num, lat, lon, radius_m=radius_m) + + @router.post("/{cad_num}/competitors", response_model=CompetitorsResponse) async def get_parcel_competitors( cad_num: str, @@ -2532,3 +2805,189 @@ async def get_parcel_best_layouts_pdf( except Exception as exc: logger.error("best_layouts PDF endpoint failed for %s: %s", cad_num, exc) raise HTTPException(status_code=500, detail="Internal server error") from exc + + +@router.get( + "/{cad_num}/snapshot.pdf", + summary="1-page PDF snapshot участка (НСПД + POI + конкуренты)", +) +def parcel_snapshot_pdf( + cad_num: str, + db: Annotated[Session, Depends(get_db)], +) -> Response: + """Генерирует одностраничный PDF-снимок участка (A4). + + Содержимое: + - Header: кадастровый номер, адрес, район, площадь + - Block 1: 5 KPI (площадь, кадастровая стоимость, категория, ВРИ, дата обновления) + - Block 2: Топ-7 POI по взвешенному баллу (из osm_poi_ekb, радиус 1 км) + - Block 3: Топ-5 конкурентов (из domrf_kn_objects, радиус 3 км) + - Footer: gendsgn.ru + дата генерации + + Не является официальной выпиской ЕГРН — только аналитические данные НСПД. + Генерация <2 сек. Открывается в Adobe Reader / Chrome. + """ + # 1) Получить метаданные участка из cad_parcels + parcel_row = ( + db.execute( + text(""" + SELECT readable_address AS address, + land_record_area AS area_m2, + land_record_category_type AS land_category, + permitted_use_established_by_document AS vri, + cost_value AS cadastral_cost, + updated_at AS last_update + FROM cad_parcels + WHERE cad_num = CAST(:c AS text) + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + + if not parcel_row: + raise HTTPException( + status_code=404, + detail=f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки.", + ) + + # 2) Получить геометрию (WKT) для POI / competitor queries + geom_row = ( + db.execute( + text(""" + SELECT ST_AsText(COALESCE( + (SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1), + (SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1) + )) AS wkt + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + geom_wkt: str | None = geom_row["wkt"] if geom_row else None + + # 3) POI в радиусе 1 км (только если есть геометрия) + poi_rows: list[dict[str, Any]] = [] + if geom_wkt: + poi_rows = [ + dict(r) + for r in db.execute( + text(""" + SELECT category, + name, + ST_Distance( + p.geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m + FROM osm_poi_ekb p + WHERE ST_DWithin( + p.geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + 1000 + ) + ORDER BY distance_m ASC + LIMIT 50 + """), + {"wkt": geom_wkt}, + ) + .mappings() + .all() + ] + + # 4) Конкуренты в радиусе 3 км (только если есть геометрия) + competitor_rows: list[dict[str, Any]] = [] + if geom_wkt: + competitor_rows = [ + dict(r) + for r in 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, + 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 flat_count DESC NULLS LAST + LIMIT 20 + """), + {"wkt": geom_wkt}, + ) + .mappings() + .all() + ] + + # 5) Получить district (через пересечение с ekb_districts если есть геом) + district: str | None = None + if geom_wkt: + district_row = ( + db.execute( + text(""" + SELECT d.district_name + FROM ekb_districts d + WHERE ST_Contains(d.geom, ST_Centroid(ST_GeomFromText(:wkt, 4326))) + LIMIT 1 + """), + {"wkt": geom_wkt}, + ) + .mappings() + .first() + ) + if district_row: + district = district_row["district_name"] + + # 6) Форматировать last_update + raw_update = parcel_row["last_update"] + last_update_str: str | None = None + if raw_update is not None: + try: + last_update_str = raw_update.strftime("%d.%m.%Y") + except AttributeError: + last_update_str = str(raw_update)[:10] + + # 7) Сгенерировать PDF + try: + pdf_bytes = generate_snapshot_pdf( + cad_num=cad_num, + address=parcel_row["address"], + district=district, + area_m2=float(parcel_row["area_m2"]) if parcel_row["area_m2"] is not None else None, + cadastral_cost_rub=( + float(parcel_row["cadastral_cost"]) + if parcel_row["cadastral_cost"] is not None + else None + ), + land_category=parcel_row["land_category"], + vri=parcel_row["vri"], + last_update=last_update_str, + poi_rows=poi_rows, + competitor_rows=competitor_rows, + competitors_limit=5, + ) + except Exception as exc: + logger.error("snapshot PDF generation failed for %s: %s", cad_num, exc) + raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc + + cad_safe = cad_num.replace(":", "-") + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="snapshot-{cad_safe}.pdf"'}, + ) diff --git a/backend/app/api/v1/pilot.py b/backend/app/api/v1/pilot.py new file mode 100644 index 00000000..7ff2b4e4 --- /dev/null +++ b/backend/app/api/v1/pilot.py @@ -0,0 +1,74 @@ +"""Pilot request lead-gen endpoint. + +POST /api/v1/pilot/request — принимает заявку на пилот (лид с лендинга или страницы анализа), +сохраняет в таблицу pilot_requests. +Telegram-уведомление — TODO (creds не настроены, см. #307 SF-B3). +""" + +from __future__ import annotations + +import logging +from typing import Annotated, Any, Literal + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import get_db + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class PilotRequestInput(BaseModel): + name: str = Field(min_length=2, max_length=200) + phone: str | None = Field(default=None, max_length=50) + email: EmailStr | None = None + company: str | None = Field(default=None, max_length=200) + message: str | None = Field(default=None, max_length=2000) + source: Literal["landing", "analyze_page", "other"] = "landing" + + +@router.post("/request") +async def create_pilot_request( + payload: PilotRequestInput, + request: Request, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, Any]: + """Сохраняет заявку на пилот в pilot_requests.""" + user_agent = request.headers.get("user-agent") + + row = ( + db.execute( + text( + """ + INSERT INTO pilot_requests (name, phone, email, company, message, source, user_agent) + VALUES (:name, :phone, :email, :company, :message, :source, :user_agent) + RETURNING CAST(id AS text), created_at + """ + ), + { + "name": payload.name, + "phone": payload.phone, + "email": str(payload.email) if payload.email else None, + "company": payload.company, + "message": payload.message, + "source": payload.source, + "user_agent": user_agent, + }, + ) + .mappings() + .one() + ) + + db.commit() + + logger.info("pilot_request saved id=%s source=%s", row["id"], payload.source) + + return { + "id": row["id"], + "created_at": row["created_at"].isoformat(), + "status": "received", + } diff --git a/backend/app/main.py b/backend/app/main.py index c22dcbe8..07071a70 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,7 @@ from app.api.v1 import ( landing, parcels, photos, + pilot, trade_in, users, ) @@ -102,6 +103,7 @@ app.include_router( ) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(landing.router, prefix="/api/v1", tags=["landing"]) +app.include_router(pilot.router, prefix="/api/v1/pilot", tags=["pilot"]) app.include_router(users.router, prefix="/api/v1", tags=["users"]) diff --git a/backend/app/services/exporters/snapshot_pdf.py b/backend/app/services/exporters/snapshot_pdf.py new file mode 100644 index 00000000..756e25a6 --- /dev/null +++ b/backend/app/services/exporters/snapshot_pdf.py @@ -0,0 +1,204 @@ +"""Генерация одностраничного PDF-снимка кадастрового участка. + +Использует WeasyPrint + Jinja2. Шрифты — DejaVu Sans из системы (Dockerfile) +или из пакета weasyprint (font fallback). Шаблон: app/templates/parcel_snapshot.html. +""" + +import datetime +import logging +import pathlib +from typing import Any + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +logger = logging.getLogger(__name__) + +# Путь к директории шаблонов (относительно этого файла — 2 уровня вверх, затем templates) +_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent / "templates" + +# Системные пути DejaVu Sans (Ubuntu/Debian Docker-образ + Alpine резерв) +_DEJAVU_CANDIDATES: list[str] = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", +] + +_CATEGORY_RU: dict[str, str] = { + "school": "Школа", + "kindergarten": "Детский сад", + "pharmacy": "Аптека", + "hospital": "Больница", + "shop_mall": "ТЦ", + "shop_supermarket": "Супермаркет", + "shop_small": "Магазин", + "park": "Парк", + "bus_stop": "Автобус", + "metro_stop": "Метро", + "tram_stop": "Трамвай", +} + +# Веса POI-категорий — должны совпадать с _POI_WEIGHTS в parcels.py. +# Дублированы здесь чтобы exporter не импортировал из api-слоя. +_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, +} + +_WALK_SPEED_M_PER_MIN: float = 80.0 # ~5 км/ч + + +def _find_font_url() -> str: + """Вернуть file:// URL для DejaVu Sans или пустую строку (system fallback). + + WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig, + поэтому пустая строка допустима — шрифт тогда подбирается CSS generic. + """ + for path in _DEJAVU_CANDIDATES: + if pathlib.Path(path).exists(): + return f"file://{path}" + logger.warning( + "snapshot_pdf: DejaVu Sans не найден в стандартных путях — используем system fallback" + ) + return "" + + +def _format_cost(value: float | None) -> str: + """Форматировать кадастровую стоимость в читаемый вид (млн/тыс ₽).""" + if value is None: + return "—" + if value >= 1_000_000: + return f"{value / 1_000_000:.1f} млн ₽" + if value >= 1_000: + return f"{value / 1_000:.0f} тыс ₽" + return f"{value:.0f} ₽" + + +def _build_poi_items(poi_rows: list[dict[str, Any]], limit: int = 7) -> list[dict[str, Any]]: + """Вычислить weighted_score для каждого POI и вернуть топ-N отсортированных. + + Формула: weighted_score = weight * max(0, 1 - distance_m / 1000) + Отрицательные вклады (трамвай) — не включаем в топ-список. + """ + items: list[dict[str, Any]] = [] + for p in poi_rows: + cat: str = p.get("category", "") + w = _POI_WEIGHTS.get(cat, 0.0) + distance_m = float(p.get("distance_m") or 0) + decay = max(0.0, 1.0 - distance_m / 1000.0) + score = round(w * decay, 2) + if score <= 0: + continue + walk_min = max(1, round(distance_m / _WALK_SPEED_M_PER_MIN)) + items.append( + { + "category_ru": _CATEGORY_RU.get(cat, cat), + "name": p.get("name") or "", + "distance_m": round(distance_m), + "walk_min": walk_min, + "weighted_score": score, + } + ) + items.sort(key=lambda x: x["weighted_score"], reverse=True) + return items[:limit] + + +def generate_snapshot_pdf( + *, + cad_num: str, + address: str | None, + district: str | None, + area_m2: float | None, + cadastral_cost_rub: float | None, + land_category: str | None, + vri: str | None, + last_update: str | None, + poi_rows: list[dict[str, Any]], + competitor_rows: list[dict[str, Any]], + competitors_limit: int = 5, +) -> bytes: + """Сгенерировать PDF-снимок участка (1 страница A4). + + Аргументы: + cad_num: кадастровый номер. + address: адрес из cad_parcels. + district: район города. + area_m2: площадь в кв. м (конвертируем в га для отображения). + cadastral_cost_rub: кадастровая стоимость в рублях. + land_category: категория земель. + vri: вид разрешённого использования. + last_update: строка даты последнего обновления данных. + poi_rows: сырые строки из osm_poi_ekb (category, name, distance_m). + competitor_rows: строки конкурентов из domrf_kn_objects. + competitors_limit: сколько конкурентов выводить (3-5 по ТЗ). + + Возвращает: bytes PDF-документа. + """ + # WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля + try: + from weasyprint import HTML + except ImportError as exc: + raise RuntimeError( + "WeasyPrint не установлен. Добавь 'weasyprint>=62.0' в pyproject.toml." + ) from exc + + env = Environment( + loader=FileSystemLoader(str(_TEMPLATE_DIR)), + autoescape=select_autoescape(["html"]), + ) + template = env.get_template("parcel_snapshot.html") + + area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else "—" + poi_items = _build_poi_items(poi_rows, limit=7) + + # Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC; + # переупорядочиваем по distance_m для удобства чтения) + competitors_display = sorted( + competitor_rows[:competitors_limit], + key=lambda r: float(r.get("distance_m") or 0), + ) + competitors_ctx: list[dict[str, Any]] = [ + { + "comm_name": r.get("comm_name"), + "dev_name": r.get("dev_name"), + "obj_class": r.get("obj_class"), + "flat_count": r.get("flat_count"), + "distance_m": round(float(r.get("distance_m") or 0)), + } + for r in competitors_display + ] + + generated_at = datetime.datetime.now(tz=datetime.UTC).strftime("%d.%m.%Y %H:%M UTC") + + html_str = template.render( + cad_num=cad_num, + address=address, + district=district, + area_ha=area_ha, + cadastral_cost=_format_cost(cadastral_cost_rub), + land_category=land_category, + vri=vri, + last_update=last_update or "—", + poi_items=poi_items, + competitors=competitors_ctx, + generated_at=generated_at, + font_url=_find_font_url(), + ) + + logger.info( + "snapshot_pdf: rendering PDF for %s (%d POI, %d competitors)", + cad_num, + len(poi_items), + len(competitors_ctx), + ) + + pdf_bytes: bytes = HTML(string=html_str, base_url=str(_TEMPLATE_DIR)).write_pdf() + return pdf_bytes diff --git a/backend/app/services/site_finder/poi_score.py b/backend/app/services/site_finder/poi_score.py new file mode 100644 index 00000000..31a7032b --- /dev/null +++ b/backend/app/services/site_finder/poi_score.py @@ -0,0 +1,159 @@ +"""POI weighted score для Site Finder (B6). + +Формула: weight = (1 / (distance_m + 100)) * category_weight + +Возвращает top-7 ближайших POI из osm_poi_ekb, отсортированных по weight DESC. +Категории и их веса согласованы с _POI_WEIGHTS в parcels.py. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from pydantic import BaseModel +from sqlalchemy import text + +logger = logging.getLogger(__name__) + +# Веса по категории — согласованы с _POI_WEIGHTS в parcels.py + новые из vault B6. +# Задача: "2GIS-style ranking", метро самое приоритетное. +CATEGORY_WEIGHTS: dict[str, float] = { + "metro_stop": 6.0, + "school": 5.0, + "kindergarten": 4.5, + "hospital": 4.0, + "shop_supermarket": 3.5, + "shop_mall": 4.0, + "park": 3.5, + "bus_stop": 4.5, + "tram_stop": 2.0, + "pharmacy": 2.5, + "shop_small": 2.0, + "default": 1.0, +} + + +class PoiScoreItem(BaseModel): + """Один POI в ranked-ответе.""" + + name: str | None + category: str + distance_m: float + weight: float + address: str | None + + +class PoiScoreResponse(BaseModel): + cad_num: str + radius_m: int + top_poi: list[PoiScoreItem] + + +def _category_weight(category: str) -> float: + """Вернуть вес категории. Если не знаем — default.""" + return CATEGORY_WEIGHTS.get(category, CATEGORY_WEIGHTS["default"]) + + +def compute_poi_weighted_top7( + db: Any, + cad_num: str, + lat: float, + lon: float, + radius_m: int = 2000, + top_n: int = 7, +) -> PoiScoreResponse: + """Найти top-N POI вокруг (lat, lon) в radius_m, ранжировать по weighted score. + + Запрос к osm_poi_ekb через ST_DWithin + ST_Distance. + Формула: weight = (1 / (distance_m + 100)) * category_weight + + Args: + db: SQLAlchemy Session + cad_num: кадастровый номер (для ответа) + lat: широта центроида участка + lon: долгота центроида участка + radius_m: радиус поиска в метрах (default 2000) + top_n: количество POI в ответе (default 7) + + Returns: + PoiScoreResponse с отсортированными по weight DESC POI. + """ + # ST_DWithin с geography=true использует метры напрямую. + # ST_Distance тоже в метрах при geography=true. + rows = ( + db.execute( + text(""" + SELECT + p.name, + p.category, + p.tags, + CAST( + ST_Distance( + p.geom::geography, + ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography + ) AS double precision + ) AS distance_m + FROM osm_poi_ekb p + WHERE ST_DWithin( + p.geom::geography, + ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, + :radius_m + ) + ORDER BY distance_m ASC + LIMIT :limit + """), + { + "lat": lat, + "lon": lon, + "radius_m": radius_m, + "limit": top_n * 10, # запрашиваем больше, потом ранжируем + }, + ) + .mappings() + .all() + ) + + logger.debug( + "poi_score: cad=%s lat=%.5f lon=%.5f radius=%dm → %d candidates", + cad_num, + lat, + lon, + radius_m, + len(rows), + ) + + items: list[PoiScoreItem] = [] + for row in rows: + distance_m = float(row["distance_m"]) + category = row["category"] or "default" + cat_weight = _category_weight(category) + weight = (1.0 / (distance_m + 100.0)) * cat_weight + + # Адрес из tags jsonb если есть + tags: dict[str, str] = row["tags"] or {} + addr_parts = [ + tags.get("addr:street"), + tags.get("addr:housenumber"), + ] + address = ", ".join(p for p in addr_parts if p) or None + + items.append( + PoiScoreItem( + name=row["name"], + category=category, + distance_m=round(distance_m, 1), + weight=round(weight, 6), + address=address, + ) + ) + + # Сортировка по weight DESC, берём top_n + items.sort(key=lambda x: x.weight, reverse=True) + top_items = items[:top_n] + + return PoiScoreResponse( + cad_num=cad_num, + radius_m=radius_m, + top_poi=top_items, + ) diff --git a/backend/app/templates/parcel_snapshot.html b/backend/app/templates/parcel_snapshot.html new file mode 100644 index 00000000..b8e9ae74 --- /dev/null +++ b/backend/app/templates/parcel_snapshot.html @@ -0,0 +1,240 @@ + + +
+ +| Категория | +Название | +Расстояние | +Пешком | +Балл | +
|---|---|---|---|---|
| {{ poi.category_ru }} | +{{ poi.name or '—' }} | +{{ poi.distance_m }} м | +{{ poi.walk_min }} мин | +{{ poi.weighted_score }} | +
POI в радиусе 1 км не найдены.
+{% endif %} + + +| ЖК / Объект | +Застройщик | +Класс | +Квартир | +Расстояние | +
|---|---|---|---|---|
| {{ c.comm_name or '—' }} | +{{ c.dev_name or '—' }} | +{{ c.obj_class or '—' }} | +{{ c.flat_count or '—' }} | +{{ c.distance_m }} м | +
Конкурентов в радиусе 3 км не обнаружено.
+{% endif %} + +