merge: resolve main.py conflict (keep landing + pilot + users routers)

This commit is contained in:
lekss361 2026-05-18 00:15:59 +03:00
commit 53598cc874
10 changed files with 1342 additions and 0 deletions

View file

@ -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"'},
)

View file

@ -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",
}

View file

@ -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"])

View file

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

View file

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

View file

@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Карточка участка {{ cad_num }}</title>
<style>
@font-face {
font-family: 'DejaVu Sans';
src: url('{{ font_url }}') format('truetype');
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DejaVu Sans', Arial, sans-serif;
font-size: 10pt;
color: #1a1a2e;
background: #ffffff;
padding: 20mm 18mm 18mm 18mm;
}
/* ── HEADER ── */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid #2563eb;
padding-bottom: 8px;
margin-bottom: 14px;
}
.header-left h1 {
font-size: 14pt;
font-weight: bold;
color: #2563eb;
}
.header-left .subtitle {
font-size: 9pt;
color: #64748b;
margin-top: 2px;
}
.header-right {
text-align: right;
font-size: 8pt;
color: #64748b;
}
/* ── SECTION TITLE ── */
.section-title {
font-size: 10pt;
font-weight: bold;
color: #1e3a5f;
background: #eff6ff;
padding: 4px 8px;
border-left: 3px solid #2563eb;
margin-bottom: 8px;
}
/* ── KPI GRID ── */
.kpi-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.kpi-card {
flex: 1 1 140px;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 7px 10px;
background: #f8fafc;
}
.kpi-card .kpi-label {
font-size: 7.5pt;
color: #64748b;
margin-bottom: 2px;
}
.kpi-card .kpi-value {
font-size: 11pt;
font-weight: bold;
color: #1e3a5f;
}
/* ── TABLE ── */
table {
width: 100%;
border-collapse: collapse;
font-size: 8.5pt;
margin-bottom: 14px;
}
table thead tr th {
background: #1e3a5f;
color: #ffffff;
padding: 5px 8px;
text-align: left;
font-weight: bold;
}
table tbody tr:nth-child(even) td {
background: #f1f5f9;
}
table tbody tr td {
padding: 4px 8px;
border-bottom: 1px solid #e2e8f0;
color: #1a1a2e;
}
.badge {
display: inline-block;
padding: 1px 6px;
border-radius: 10px;
font-size: 7.5pt;
font-weight: bold;
}
.badge-green { background: #dcfce7; color: #166534; }
.badge-yellow { background: #fef9c3; color: #854d0e; }
.badge-blue { background: #dbeafe; color: #1e40af; }
/* ── FOOTER ── */
.footer {
position: fixed;
bottom: 12mm;
left: 18mm;
right: 18mm;
border-top: 1px solid #cbd5e1;
padding-top: 5px;
display: flex;
justify-content: space-between;
font-size: 7.5pt;
color: #94a3b8;
}
.disclaimer {
font-size: 7pt;
color: #94a3b8;
margin-top: 4px;
font-style: italic;
}
</style>
</head>
<body>
<!-- HEADER -->
<div class="header">
<div class="header-left">
<h1>GenDesign &mdash; Карточка участка</h1>
<div class="subtitle">Данные НСПД / ЕГРНsource: cad_parcels. Не является официальной выпиской ЕГРН.</div>
</div>
<div class="header-right">
<strong>{{ cad_num }}</strong><br/>
{{ district or '&mdash;' }}<br/>
{{ address or '&mdash;' }}
</div>
</div>
<!-- BLOCK 1: KPI -->
<div class="section-title">Основные характеристики</div>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">Площадь</div>
<div class="kpi-value">{{ area_ha }} га</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Кадастровая стоимость</div>
<div class="kpi-value">{{ cadastral_cost }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Категория земель</div>
<div class="kpi-value">{{ land_category or '&mdash;' }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">ВРИ</div>
<div class="kpi-value">{{ vri or '&mdash;' }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Последнее обновление</div>
<div class="kpi-value">{{ last_update or '&mdash;' }}</div>
</div>
</div>
<!-- BLOCK 2: Top-7 POI -->
<div class="section-title">Ближайшая инфраструктура (топ-7 по взвешенному баллу)</div>
{% if poi_items %}
<table>
<thead>
<tr>
<th>Категория</th>
<th>Название</th>
<th>Расстояние</th>
<th>Пешком</th>
<th>Балл</th>
</tr>
</thead>
<tbody>
{% for poi in poi_items %}
<tr>
<td>{{ poi.category_ru }}</td>
<td>{{ poi.name or '&mdash;' }}</td>
<td>{{ poi.distance_m }} м</td>
<td>{{ poi.walk_min }} мин</td>
<td><span class="badge badge-blue">{{ poi.weighted_score }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#64748b; font-size:8.5pt; margin-bottom:14px;">POI в радиусе 1 км не найдены.</p>
{% endif %}
<!-- BLOCK 3: Competitors -->
<div class="section-title">Конкуренты в радиусе 3 км (топ {{ competitors|length }})</div>
{% if competitors %}
<table>
<thead>
<tr>
<th>ЖК / Объект</th>
<th>Застройщик</th>
<th>Класс</th>
<th>Квартир</th>
<th>Расстояние</th>
</tr>
</thead>
<tbody>
{% for c in competitors %}
<tr>
<td>{{ c.comm_name or '&mdash;' }}</td>
<td>{{ c.dev_name or '&mdash;' }}</td>
<td>{{ c.obj_class or '&mdash;' }}</td>
<td>{{ c.flat_count or '&mdash;' }}</td>
<td>{{ c.distance_m }} м</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#64748b; font-size:8.5pt; margin-bottom:14px;">Конкурентов в радиусе 3 км не обнаружено.</p>
{% endif %}
<div class="disclaimer">
Не является выпиской из ЕГРН. Данные носят аналитический характер.
Для официальной выписки: rosreestr.gov.ru
</div>
<!-- FOOTER -->
<div class="footer">
<span>gendsgn.ru &mdash; GenDesign Analytics</span>
<span>Сформировано: {{ generated_at }}</span>
</div>
</body>
</html>

View file

@ -22,6 +22,7 @@ dependencies = [
"tenacity>=9.0.0",
"pillow>=10.4.0",
"weasyprint>=62.0",
"jinja2>=3.1.0",
"ezdxf>=1.3.0",
"openpyxl>=3.1.0",
"pandas>=2.2.0",

View file

@ -0,0 +1,168 @@
"""Tests for POI weighted score service (B6).
Юнит-тесты для чистой функции без DB.
"""
from app.services.site_finder.poi_score import (
CATEGORY_WEIGHTS,
PoiScoreResponse,
_category_weight,
compute_poi_weighted_top7,
)
# ── unit: _category_weight ─────────────────────────────────────────────────────
def test_category_weight_metro():
"""Метро имеет наибольший вес из всех категорий."""
metro_w = _category_weight("metro_stop")
for cat in CATEGORY_WEIGHTS:
if cat != "metro_stop" and cat != "default":
assert metro_w >= _category_weight(
cat
), f"metro_stop weight {metro_w} должен быть >= {cat} weight {_category_weight(cat)}"
def test_category_weight_unknown_returns_default():
w = _category_weight("unknown_category_xyz")
assert w == CATEGORY_WEIGHTS["default"]
def test_category_weight_all_positive():
"""Все веса в CATEGORY_WEIGHTS должны быть положительными (B6 — ranking, не штраф)."""
for cat, w in CATEGORY_WEIGHTS.items():
assert w > 0, f"Вес {cat}={w} должен быть > 0"
# ── unit: weight formula ratio ─────────────────────────────────────────────────
def test_weight_formula_ratio():
"""Ближний объект той же категории должен иметь больший вес."""
cat = "school"
cw = _category_weight(cat)
w_near = (1.0 / (100.0 + 100.0)) * cw # 100м
w_far = (1.0 / (1000.0 + 100.0)) * cw # 1000м
assert w_near > w_far
def test_weight_formula_category_dominates_at_equal_distance():
"""При одинаковом расстоянии метро должно быть впереди автобусной остановки."""
dist = 500.0
w_metro = (1.0 / (dist + 100.0)) * _category_weight("metro_stop")
w_bus = (1.0 / (dist + 100.0)) * _category_weight("bus_stop")
assert w_metro > w_bus
# ── unit: compute_poi_weighted_top7 with mock DB ───────────────────────────────
class _MockMappings:
def __init__(self, data: list[dict]) -> None:
self._data = data
def all(self) -> list[dict]:
return self._data # type: ignore[return-value]
class _MockResult:
def __init__(self, data: list[dict]) -> None:
self._data = data
def mappings(self) -> "_MockMappings":
return _MockMappings(self._data)
class _MockDb:
"""Минимальный мок SQLAlchemy Session для тестирования без БД."""
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
def execute(self, *_args: object, **_kwargs: object) -> _MockResult:
return _MockResult(self._rows)
def _make_row(name: str, category: str, distance_m: float) -> dict:
return {
"name": name,
"category": category,
"tags": {},
"distance_m": distance_m,
}
def test_top7_returns_at_most_7():
rows = [_make_row(f"POI {i}", "school", float(i * 50)) for i in range(1, 20)]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
assert isinstance(result, PoiScoreResponse)
assert len(result.top_poi) <= 7
def test_top7_sorted_by_weight_desc():
rows = [
_make_row("Дальняя школа", "school", 1500.0),
_make_row("Метро", "metro_stop", 300.0),
_make_row("Близкая школа", "school", 100.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
weights = [item.weight for item in result.top_poi]
assert weights == sorted(weights, reverse=True), "top_poi должны быть по weight DESC"
def test_metro_beats_school_at_equal_distance():
"""Метро в 300м должно быть на первом месте перед школой в 300м (равное расстояние)."""
rows = [
_make_row("Школа №1", "school", 300.0),
_make_row("Метро Чкаловская", "metro_stop", 300.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
assert (
result.top_poi[0].category == "metro_stop"
), "При равном расстоянии метро (category_weight=6.0) должно быть выше школы (5.0)"
def test_metro_first_when_close():
"""Метро в 50м должно быть на первом месте перед школой в 300м."""
rows = [
_make_row("Школа №1", "school", 300.0),
_make_row("Метро Чкаловская", "metro_stop", 50.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
assert result.top_poi[0].category == "metro_stop", (
"Метро (weight=6.0) в 50м должно быть впереди школы (weight=5.0) в 300м — "
f"metro_weight={(1/(50+100))*6:.5f} vs school_weight={(1/(300+100))*5:.5f}"
)
def test_empty_db_returns_empty_top_poi():
db = _MockDb([])
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
assert result.top_poi == []
assert result.cad_num == "66:41:0204016:10"
assert result.radius_m == 2000
def test_address_built_from_tags():
rows = [
{
"name": "Магазин",
"category": "shop_small",
"tags": {"addr:street": "ул. Ленина", "addr:housenumber": "10"},
"distance_m": 200.0,
}
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "test", 56.838, 60.605)
assert result.top_poi[0].address == "ул. Ленина, 10"
def test_address_none_when_no_tags():
rows = [_make_row("Парк", "park", 400.0)]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "test", 56.838, 60.605)
assert result.top_poi[0].address is None

14
backend/uv.lock generated
View file

@ -568,6 +568,7 @@ dependencies = [
{ name = "geopandas" },
{ name = "httpx" },
{ name = "ijson" },
{ name = "jinja2" },
{ name = "numpy" },
{ name = "openpyxl" },
{ name = "pandas" },
@ -608,6 +609,7 @@ requires-dist = [
{ name = "geopandas", specifier = ">=1.0.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "ijson", specifier = ">=3.2.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "numpy", specifier = ">=2.0.0" },
{ name = "openpyxl", specifier = ">=3.1.0" },
{ name = "pandas", specifier = ">=2.2.0" },
@ -871,6 +873,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "joblib"
version = "1.5.3"

View file

@ -0,0 +1,21 @@
BEGIN;
CREATE TABLE IF NOT EXISTS pilot_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
phone text,
email text,
company text,
message text,
source text, -- 'landing', 'analyze_page', etc
user_agent text,
created_at timestamptz NOT NULL DEFAULT NOW(),
notified_at timestamptz -- when Telegram sent
);
CREATE INDEX IF NOT EXISTS pilot_requests_created_idx
ON pilot_requests (created_at DESC);
COMMENT ON TABLE pilot_requests IS '#307 SF-B3 lead generation, опционально notified в Telegram.';
COMMIT;