"""Унифицированный источник «наших проектов» для §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 '_' (напр. '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: 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: 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