Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv). Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538 Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498 Требуют cross-file (3, не тронуты): #1338, #1363, #1421 Пропущено (1): #1539 Не входило в партию: 22 needs-Leha issue (нужны решения владельца). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
255 lines
12 KiB
Python
255 lines
12 KiB
Python
"""Унифицированный источник «наших проектов» для §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:
|
||
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
|