gendesign/backend/app/services/site_finder/own_portfolio.py
bot-backend d3f3370bed
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m30s
Deploy / build-worker (push) Successful in 3m45s
Deploy / deploy (push) Successful in 1m54s
fix(site_finder): SAVEPOINT supply/market + convert 2 bare rollbacks (#2464 Wave 2) (#2469)
2026-07-08 06:46:09 +00:00

264 lines
12 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.

"""Унифицированный источник «наших проектов» для §25.3 каннибализации (#1169 PR1).
`get_own_portfolio(db) -> list[OwnProject]` сводит ДВА источника «нашего портфеля» в
общий нормализованный shape `OwnProject`, чтобы движок пересечения §25.3 (PR2) мог
сравнивать кандидата с нашими проектами по 4 осям: аудитория/класс, тайминг (месяц
выхода), цена (₽/м²), квартирография (unit_mix).
Два источника (origins):
1. «Текущие» (source='current') ← domrf_kn_objects, отфильтрованные по
settings.own_developer_ids — целочисленные DOM.РФ developer-id «наших»
застройщиков. КАВЕАТ ФОРМАТА: domrf_kn_objects.dev_id — composite TEXT
'<numericDevId>_<suffix>' (напр. '12345_0'), а конфиг хранит ИНТЫ. Поэтому
матчим по ЧИСЛОВОМУ ПРЕФИКСУ dev_id (split_part(dev_id,'_',1)::int = ANY(:ids)).
Пустой own_developer_ids → НЕ запрашиваем БД, возвращаем [] (graceful, без ошибки).
2. «Будущие/планируемые» (source='future') ← все строки own_planned_project
(manual-entry приватного пайплайна, миграция 148).
Оба нормализуются в OwnProject. Дисциплина None-not-0: отсутствующее поле → None
(а НЕ 0/«» — «нет данных» ≠ «ноль», чтобы PR2 деградировал честно). class-вокабуляр
и district-нейминг зеркалят forecasting SegmentSpec (sales_series.py) — OwnProject
напрямую сопоставим с кандидатом-сегментом в PR2.
Graceful degradation (дух market_metrics / sales_series):
• own_developer_ids пусто И own_planned_project пуст → get_own_portfolio вернёт [].
• own_developer_ids пусто, но manual-строки есть → только future-строки.
• сбой одного из запросов → этот источник даёт [], второй считается (НЕ crash).
PR2-движок при пустом портфеле честно отдаёт прокси/None вместо фабрикации сигнала.
psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) — никогда
:x::type. Чистого SQL-каста (split_part(...)::int) избегаем в bind-контексте: пишем
CAST(... AS integer).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from typing import Any, Literal
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
OwnProjectSource = Literal["current", "future"]
@dataclass(frozen=True)
class OwnProject:
"""Нормализованный «наш проект» — общий shape для §25.3 (current + future).
Дисциплина None-not-0: любое неизвестное поле = None (НЕ 0/«»). Цена — вилка
min/max ₽/м² (любая граница может быть None). unit_mix — доли квартирографии
{"studio":0.3,...} или None. release_month — ПЕРВОЕ число месяца (тайминг §25.3).
lon/lat — центроид (оба None, если координат нет). obj_class — «человеческий»
класс (зеркало forecasting SegmentSpec; матчинг в PR2 регистронезависимый).
"""
name: str
source: OwnProjectSource
obj_class: str | None
release_month: date | None
price_min_per_m2: float | None
price_max_per_m2: float | None
unit_mix: dict[str, float] | None
district: str | None
lon: float | None
lat: float | None
def as_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"source": self.source,
"obj_class": self.obj_class,
"release_month": self.release_month.isoformat() if self.release_month else None,
"price_min_per_m2": self.price_min_per_m2,
"price_max_per_m2": self.price_max_per_m2,
"unit_mix": dict(self.unit_mix) if self.unit_mix is not None else None,
"district": self.district,
"lon": self.lon,
"lat": self.lat,
}
# ── SQL ────────────────────────────────────────────────────────────────────────
# «Текущие» проекты: последний snapshot domrf_kn_objects «наших» застройщиков.
# Матчинг по числовому префиксу composite dev_id (см. module docstring). Берём
# только последний snapshot на dev_id, чтобы не дублировать объект по истории.
# ready_dt → release_month (нормализуем к 1-му числу). Цена ₽/м² — вилка
# price_per_m2_min/max (22e-колонки; могут быть NULL → None). district_name (56/57).
_CURRENT_SQL = text(
"""
WITH ours AS (
SELECT o.*
FROM domrf_kn_objects o
WHERE o.dev_id IS NOT NULL
AND o.dev_id ~ '^[0-9]+(_|$)'
AND CAST(split_part(o.dev_id, '_', 1) AS integer) = ANY(CAST(:ids AS integer[]))
),
latest AS (
SELECT obj_id, MAX(snapshot_date) AS snap
FROM ours
GROUP BY obj_id
)
SELECT o.obj_id,
o.comm_name,
o.obj_class,
o.district_name,
o.ready_dt,
o.price_per_m2_min,
o.price_per_m2_max,
o.latitude,
o.longitude
FROM ours o
JOIN latest l ON l.obj_id = o.obj_id AND l.snap = o.snapshot_date
ORDER BY o.ready_dt DESC NULLS LAST, o.obj_id
"""
)
# «Будущие/планируемые»: все строки own_planned_project (миграция 148). unit_mix —
# jsonb (psycopg3 → dict). planned_release_month уже нормализован к 1-му числу.
_FUTURE_SQL = text(
"""
SELECT id,
name,
obj_class,
district,
planned_release_month,
price_min_per_m2,
price_max_per_m2,
unit_mix,
lon,
lat
FROM own_planned_project
ORDER BY planned_release_month DESC NULLS LAST, id
"""
)
def _f(value: Any) -> float | None:
"""NUMERIC/Decimal/float → float | None (None-not-0: NULL остаётся None)."""
if value is None:
return None
return float(value)
def _month_start(value: date | None) -> date | None:
"""Нормализовать дату к ПЕРВОМУ числу месяца. None → None (None-not-0)."""
if value is None:
return None
return value.replace(day=1)
def _query_current(db: Session) -> list[OwnProject]:
"""«Текущие» проекты из domrf по settings.own_developer_ids. Graceful → [].
Пустой own_developer_ids → СРАЗУ [] (не идём в БД — нечего матчить, это штатная
деградация, а не ошибка). Сбой запроса → [] (второй источник не должен страдать).
"""
ids = list(settings.own_developer_ids)
if not ids:
logger.info(
"own_portfolio: own_developer_ids пуст → current-проектов нет "
"(graceful; §25.3 деградирует на future/None)"
)
return []
try:
# SAVEPOINT — db shared с caller (get_own_portfolio в цепочке §25.3 / analyze).
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, оставляя db usable для
# _query_future (следующий вызов на этой же сессии в get_own_portfolio).
with db.begin_nested():
rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
except Exception:
logger.exception("own_portfolio: current (domrf) query failed → []")
return []
out: list[OwnProject] = []
for r in rows:
out.append(
OwnProject(
name=r["comm_name"] or f"obj {r['obj_id']}",
source="current",
obj_class=r["obj_class"],
release_month=_month_start(r["ready_dt"]),
price_min_per_m2=_f(r["price_per_m2_min"]),
price_max_per_m2=_f(r["price_per_m2_max"]),
# domrf-объект не несёт долей квартирографии в этом запросе → None
# (PR2 может добрать из domrf_kn_flats отдельно; здесь не фабрикуем).
unit_mix=None,
district=r["district_name"],
lon=_f(r["longitude"]),
lat=_f(r["latitude"]),
)
)
return out
def _query_future(db: Session) -> list[OwnProject]:
"""«Будущие/планируемые» проекты из own_planned_project. Graceful → [].
Сбой/отсутствие таблицы (cold-start до миграции 148) → [] (НЕ crash).
"""
try:
# SAVEPOINT — db shared с caller (см. _query_current выше). begin_nested
# откатывает только эту savepoint (НЕ голый db.rollback(), см. velocity.py:170),
# оставляя db usable для остальных потребителей той же сессии (§25.3 / analyze).
with db.begin_nested():
rows = db.execute(_FUTURE_SQL).mappings().all()
except Exception:
logger.exception("own_portfolio: future (own_planned_project) query failed → []")
return []
out: list[OwnProject] = []
for r in rows:
unit_mix = r["unit_mix"]
out.append(
OwnProject(
name=r["name"],
source="future",
obj_class=r["obj_class"],
release_month=_month_start(r["planned_release_month"]),
price_min_per_m2=_f(r["price_min_per_m2"]),
price_max_per_m2=_f(r["price_max_per_m2"]),
unit_mix=dict(unit_mix) if isinstance(unit_mix, dict) else None,
district=r["district"],
lon=_f(r["lon"]),
lat=_f(r["lat"]),
)
)
return out
def get_own_portfolio(db: Session) -> list[OwnProject]:
"""Собрать унифицированный «наш портфель» (current + future) для §25.3 (PR2).
Объединяет два источника, нормализованных в OwnProject:
• current ← domrf_kn_objects по settings.own_developer_ids (пусто → [], graceful);
• future ← все строки own_planned_project (manual-entry).
Каждый источник в собственном try/except (см. _query_current/_query_future): сбой
одного НЕ валит второй. Дисциплина None-not-0 соблюдается в маппинге. Возвращается
ВСЕГДА (никогда не crash); пустой список — штатная деградация, когда own_developer_ids
пуст И manual-строк нет. PR2-движок при пустом портфеле честно отдаёт прокси/None.
Args:
db: SQLAlchemy sync Session.
Returns:
list[OwnProject] — объединённый портфель (current впереди future). Может быть [].
"""
current = _query_current(db)
future = _query_future(db)
portfolio = current + future
logger.info(
"own_portfolio: собрано %d проектов (current=%d future=%d, own_developer_ids=%d)",
len(portfolio),
len(current),
len(future),
len(settings.own_developer_ids),
)
return portfolio