gendesign/backend/app/services/site_finder/best_layouts.py
Light1YT 0acd72a325
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Successful in 13m45s
fix(best-layouts): per-object latest snapshot for supply (#1956)
_SUPPLY_BATCH_SQL джойнил domrf_kn_flats по ОДНОЙ глобальной дате
(f.snapshot_date = MAX(snapshot_date) по всей таблице). Но domrf_kn_flats —
ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в свой день. На единственной
глобал-max дате присутствует обычно 1 объект → у остальных 0 квартир →
supply_units_in_radius=0 для всех строк 4.2 Планировки → frontend показывал
«Срок продажи 0 мес» и «% продано —». Регрессия от #1944 (objects-first
дедуп snapshot'ов объектов, который сам по себе корректен).

Фикс: flats_latest CTE (DISTINCT ON (obj_id) ... ORDER BY obj_id,
snapshot_date DESC, id DESC) берёт для КАЖДОГО obj_id его собственный
последний снимок и джойнится к nearby. objects-first MATERIALIZED дедуп
(#1944) сохранён → fan-out по снимкам не возвращается. Глобальный
db.scalar(MAX(snapshot_date)) + :latest_snap bind удалены.

Прод (66:41:0205010:287, r=1км, 9 объектов): supply 0 (global-max) → 2675
(per-object, 4 объекта имеют flats на разных датах 2026-05-17/05-05; ни один
не на глобал-max 2026-06-22). Данные flats частично сломаны (#1945, отдельно),
но фикс корректно двигает supply с 0 к реальным per-object числам.

Тесты: новый guard test_supply_joins_flats_per_object_latest_snapshot;
обновлены mock-фабрики (db.scalar больше не вызывается).
2026-06-27 23:24:28 +05:00

850 lines
38 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.

"""Анализ лучших планировок конкурентов по velocity (Issue #113 Phase 2.1).
Источники:
cad_parcels_geom / cad_quarters_geom — центроид участка
domrf_kn_objects — ЖК в радиусе (latitude/longitude → geography)
objective_corpus_room_month — ежемесячные сделки по (project_name, room_bucket)
objective_complex_mapping — domrf_obj_id ↔ objective_complex_name
domrf_kn_flats — supply count по (room_bucket, area_bin)
Алгоритм:
Step 1: центроид участка (cad_parcels_geom → cad_quarters_geom fallback).
Step 2: obj_id конкурентов в радиусе (domrf_kn_objects + фильтры).
Step 3: inline SQL из objective_corpus_room_month с честным WHERE report_month фильтром.
Step 4: velocity_per_month = deals_window / months_in_window (честный time_window).
Step 5: supply side из domrf_kn_flats — один батч-запрос.
Step 6: per-row signature + sold_pct.
Step 7: фильтр min_velocity + sort + rank.
Step 8: build recommendation_for_tz (unit-mix, price, rationale).
Step 9: data_quality (coverage + confidence).
Fix SF-01: раньше mv_layout_velocity (24 мес) делился на divisor (4/12) — данные
не менялись при смене time_window. Теперь inline SQL с реальным фильтром report_month.
"""
from __future__ import annotations
import datetime as dt
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.schemas.parcel import (
BestLayoutsRequest,
BestLayoutsResponse,
LayoutDataQuality,
LayoutTzMixRow,
LayoutTzRecommendation,
TopLayoutRow,
)
from app.services.site_finder.layout_signature import area_bin, layout_signature
logger = logging.getLogger(__name__)
# Confidence thresholds (per coverage % of objects with MV velocity data)
# Tune via PR if business feedback требует.
LAYOUT_CONFIDENCE_HIGH_PCT = 50.0
LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0
# Fix SF-09: cap доминирующего bucket чтобы рекомендация не зеркалила перекос рынка.
# Избыток перераспределяется пропорционально остальным bucket'ам.
MAX_BUCKET_SHARE_PCT = 35
# Параметры time_window: (PostgreSQL interval string, months divisor для velocity_per_month).
# Используются в _INLINE_VELOCITY_SQL — реальный фильтр по report_month.
# Fix SF-01: убраны _VELOCITY_DIVISORS, которые делили MV (24 мес) без изменения данных.
_TIME_WINDOW_PARAMS: dict[str, tuple[str, float]] = {
"last_month": ("1 month", 1.0),
"last_quarter": ("3 months", 3.0),
"last_year": ("12 months", 12.0),
}
# ── SQL: центроид участка ─────────────────────────────────────────────────────
_PARCEL_CENTROID_SQL = text("""
SELECT ST_X(pt) AS center_lon,
ST_Y(pt) AS center_lat
FROM (
SELECT ST_Centroid(geom) AS pt
FROM cad_parcels_geom
WHERE cad_num = :cad_num AND geom IS NOT NULL
UNION ALL
SELECT ST_Centroid(geom) AS pt
FROM cad_quarters_geom
WHERE cad_number = :quarter AND geom IS NOT NULL
) sub
LIMIT 1
""")
# ── SQL: obj_id конкурентов в радиусе ─────────────────────────────────────────
# Геометрия domrf_kn_objects вычисляется on-the-fly из (latitude, longitude)
# как ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography
# (consistency с competitors.py).
# obj_class_filter: NULL = все классы.
# filter_competitor_obj_ids: NULL = не фильтровать по списку.
_COMPETITORS_IN_RADIUS_SQL = text("""
SELECT DISTINCT ON (obj_id) obj_id
FROM domrf_kn_objects
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography,
ST_SetSRID(
ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)),
4326
)::geography,
CAST(:radius_m AS float)
)
AND (
CAST(:obj_class_filter AS text) IS NULL
OR obj_class = CAST(:obj_class_filter AS text)
)
ORDER BY obj_id, snapshot_date DESC NULLS LAST
""")
# ── SQL: inline velocity из objective_corpus_room_month + mapping ─────────────
# Fix SF-01: честный фильтр по report_month вместо деления mv_layout_velocity (24 мес).
# Параметры:
# :window_interval — PostgreSQL interval string ('1 month', '3 months', '12 months')
# :competitor_obj_ids — list[int] obj_id конкурентов в радиусе
# CAST(:window_interval AS interval) — psycopg v3 / SQLAlchemy 2.0 safe (не ::interval).
#
# Fix #1391: fan-out по objective_complex_mapping завышал SUM-метрики. UNIQUE-ключ
# mapping — (objective_complex_name, objective_group), поэтому один project_name может
# матчиться на N mapping-строк (разные группы / domrf_obj_id). Прямой
# JOIN crm × cm дублировал каждую crm-строку N раз → SUM(deals_total_count),
# weighted-avg числители/знаменатели и deals_window раздувались в N раз. DISTINCT-метрики
# (competitor_obj_ids / competitor_count) не страдали, но SUM — да.
# Решение: deals агрегируем per (project_name, room_bucket) в CTE `crm_agg`
# (без mapping-джойна → fan-out невозможен). Список project_name'ов в радиусе берём из
# CTE `mapped_projects` (DISTINCT по objective_complex_name) и джойним один-к-одному —
# каждый project_name вносит свои сделки ровно один раз. competitor_obj_ids /
# competitor_count считаем отдельно в `obj_per_bucket` (через unnest mapping-obj_id),
# чтобы fan-out по obj_id не попал в SUM-агрегаты, и приджойниваем по room_bucket.
_INLINE_VELOCITY_SQL = text("""
WITH crm_agg AS (
SELECT
crm.project_name,
CASE
WHEN crm.room_bucket = 'студия' THEN 'studio'
ELSE crm.room_bucket
END AS room_bucket,
SUM(crm.deals_total_count) AS deals_window,
SUM(crm.deals_total_avg_area_m2 * crm.deals_total_count)
AS area_weighted_sum,
SUM(crm.deals_total_avg_price_thousand_rub_per_m2 * crm.deals_total_count)
AS price_weighted_sum,
MIN(crm.report_month) AS window_start,
MAX(crm.report_month) AS window_end
FROM objective_corpus_room_month crm
WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval))::date
AND crm.room_bucket IS NOT NULL
GROUP BY crm.project_name,
CASE
WHEN crm.room_bucket = 'студия' THEN 'studio'
ELSE crm.room_bucket
END
),
mapped_projects AS (
SELECT DISTINCT cm.objective_complex_name AS project_name
FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id = ANY(:competitor_obj_ids)
),
deals_per_bucket AS (
SELECT
a.room_bucket,
SUM(a.deals_window) AS deals_window,
COALESCE(
SUM(a.area_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
)::numeric(10, 2) AS avg_area_m2,
COALESCE(
SUM(a.price_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
MIN(a.window_start) AS window_start,
MAX(a.window_end) AS window_end
FROM crm_agg a
JOIN mapped_projects mp ON mp.project_name = a.project_name
GROUP BY a.room_bucket
),
obj_per_bucket AS (
SELECT
a.room_bucket,
array_agg(DISTINCT cm.domrf_obj_id) AS competitor_obj_ids,
COUNT(DISTINCT cm.domrf_obj_id) AS competitor_count
FROM crm_agg a
JOIN objective_complex_mapping cm
ON cm.objective_complex_name = a.project_name
WHERE cm.domrf_obj_id = ANY(:competitor_obj_ids)
GROUP BY a.room_bucket
)
SELECT
d.room_bucket AS room_bucket,
d.deals_window AS deals_window,
d.avg_area_m2 AS avg_area_m2,
d.avg_price_per_m2_rub AS avg_price_per_m2_rub,
o.competitor_obj_ids AS competitor_obj_ids,
o.competitor_count AS competitor_count,
d.window_start AS window_start,
d.window_end AS window_end
FROM deals_per_bucket d
JOIN obj_per_bucket o ON o.room_bucket = d.room_bucket
""")
# ── SQL: supply по (room_bucket, area_bin) за последний снимок ───────────────
# Один батч-запрос вместо N — возвращает map (rb, ab) → count.
# room_bucket и area_bin вычисляются в SQL аналогично layout_signature.py.
# Fix #1229: bucket'ы нормализованы под velocity-вокабуляр objective_corpus_room_month
# = {studio, 1, 2, 3, 4+}. Раньше supply отдельно отдавал euro-1/euro-2 для rooms=2
# (area<35 / area<50) — эти ключи никогда не матчились с velocity (Объектив
# euro-форматов не выделяет, см. _INLINE_VELOCITY_SQL passthrough room_bucket).
# Последствия: 1) supply '2' искусственно занижен (rooms=2 + area<50 уходили в
# euro-*) → sold_pct/is_oversold двушек завышены; 2) euro-* строки supply
# становились dead lookup. SF-08 euro-биннинг откладываем до момента, когда его
# начнёт отдавать velocity-сторона.
# Perf + CORRECTNESS fix (perf/best-layouts-objects-first):
# objects-first MATERIALIZED CTE + устранение fan-out по снимкам объекта.
#
# === Корректность (главное) ===
# domrf_kn_objects — это snapshot-таблица: UNIQUE (obj_id, snapshot_date)
# [uq_kn_objects_id_snap], ~8 снимков на obj_id, координаты могут меняться между
# снимками. Прежний запрос джойнил flats к domrf_kn_objects по obj_id БЕЗ фильтра
# o.snapshot_date → каждая квартира считалась по разу на КАЖДУЮ строку-объект,
# прошедшую ST_DWithin (fan-out ~×8.5 на проде, радиус 1.5км: 82251 → 9612).
# Последствие в выдаче (best_layouts.py): supply_units_in_radius завышен ~8.5×,
# sold_pct_of_supply (= deals/supply) занижен ~8.5×, is_oversold недосрабатывает.
# Фикс: latest_obj дедуплицирует до ОДНОЙ строки на obj_id (последний снимок —
# самые свежие координаты) через DISTINCT ON; ST_DWithin применяется к этим
# каноническим координатам. nearby содержит уникальные obj_id → JOIN с flats не
# фанаутит → units = ровно один счёт на квартиру (cross-check на проде:
# units == count(DISTINCT f.id) == 9612 для 65 объектов в радиусе).
#
# === Перформанс ===
# Селективный предикат — ST_DWithin по маленькой таблице объектов (~1.5k уникальных
# obj_id), поэтому фильтруем объекты ПЕРВЫМИ, затем джойним большую flats
# (последний полный снимок ~376k строк) через idx_kn_flats_obj. Прежний flats-first
# план параллельно seq-сканировал весь снимок flats → ~28k buffers / ~240-360ms
# (cold ~549ms). Этот план: objects-first + меньше строк после дедупа.
#
# MATERIALIZED у nearby — обязателен: без него планировщик инлайнит CTE и
# возвращается к flats-first seq scan (domrf_kn_objects не имеет GIST-индекса).
#
# === PER-OBJECT snapshot fix (#1956, regression от #1944) ===
# domrf_kn_flats — это ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в СВОЙ день,
# UNIQUE-снимок (obj_id, snapshot_date). Прежний джойн фильтровал flats по ОДНОЙ
# глобальной дате `f.snapshot_date = MAX(snapshot_date по всей таблице)`. На этой
# единственной глобал-max дате присутствует обычно лишь 1 объект (скрейпнутый
# последним) → у остальных объектов в радиусе 0 квартир → supply_units_in_radius=0
# для ВСЕХ строк → frontend salesPeriodMonths(0,v)=0 («Срок продажи 0 мес»),
# sold_pct_of_supply=null («% продано —»). Прод-факт (66:41:0205010:287): 9
# объектов в радиусе = 5344 квартир суммарно, но 0 на глобал-max снимке.
# Фикс: flats_latest берёт для КАЖДОГО obj_id его СОБСТВЕННЫЙ последний снимок
# (DISTINCT ON (obj_id) ... ORDER BY obj_id, snapshot_date DESC, id DESC) и
# джойнится к nearby по obj_id → supply = реальные per-object счётчики (~2675).
# Дедуп nearby (objects-first MATERIALIZED, #1944) сохранён — flats_latest даёт
# ровно одну строку на (obj_id) → fan-out по снимкам flats невозможен.
# NB: данные domrf_kn flats частично сломаны (#1945 — per-object снимки низкие/
# несвежие пока loader не починен), поэтому supply всё ещё ниже здорового
# состояния, но движется с 0 к реальным per-object числам. Loader — НЕ здесь.
_SUPPLY_BATCH_SQL = text("""
WITH latest_obj AS (
SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.latitude, o.longitude
FROM domrf_kn_objects o
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
ORDER BY o.obj_id, o.snapshot_date DESC
),
nearby AS MATERIALIZED (
SELECT lo.obj_id
FROM latest_obj lo
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(lo.longitude, lo.latitude), 4326)::geography,
ST_SetSRID(
ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)),
4326
)::geography,
CAST(:radius_m AS float)
)
),
flats_latest AS (
SELECT DISTINCT ON (f.obj_id)
f.obj_id, f.snapshot_date
FROM domrf_kn_flats f
JOIN nearby n ON n.obj_id = f.obj_id
ORDER BY f.obj_id, f.snapshot_date DESC, f.id DESC
)
SELECT
CASE
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
WHEN f.rooms = 0 THEN 'studio'
WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text
WHEN f.rooms >= 4 THEN '4+'
ELSE '1'
END AS rb,
CASE
WHEN f.total_area < 25 THEN '<25'
WHEN f.total_area < 40 THEN '25-40'
WHEN f.total_area < 60 THEN '40-60'
WHEN f.total_area < 80 THEN '60-80'
WHEN f.total_area < 100 THEN '80-100'
ELSE '100+'
END AS ab,
COUNT(*) AS units
FROM flats_latest fl
JOIN domrf_kn_flats f
ON f.obj_id = fl.obj_id
AND f.snapshot_date = fl.snapshot_date
GROUP BY rb, ab
""")
# ── Вспомогательные функции ───────────────────────────────────────────────────
def _quarter_from_cad(cad_num: str) -> str:
"""Извлечь кадастровый квартал: '66:41:0303161:123''66:41:0303161'."""
parts = cad_num.split(":")
if len(parts) >= 3:
return ":".join(parts[:3])
return cad_num
def _normalize_pct(buckets: dict[str, float]) -> dict[str, int]:
"""Нормировать доли до целых процентов с суммой ровно 100.
Алгоритм largest-remainder (Hamilton method):
1. Floor каждого значения.
2. Остаток 100 sum_floors распределить в top-bucket по дробной части.
"""
if not buckets:
return {}
total = sum(buckets.values())
if total <= 0:
n = len(buckets)
base = 100 // n
result = {k: base for k in buckets}
# распределить остаток
remainder = 100 - base * n
for k in list(buckets.keys())[:remainder]:
result[k] += 1
return result
raw = {k: v / total * 100.0 for k, v in buckets.items()}
floors = {k: int(v) for k, v in raw.items()}
remainder = 100 - sum(floors.values())
# sort by fractional part desc
fracs = sorted(buckets.keys(), key=lambda k: -(raw[k] - floors[k]))
for k in fracs[:remainder]:
floors[k] += 1
return floors
def _cap_and_redistribute(pct_map: dict[str, int]) -> tuple[dict[str, int], bool]:
"""Fix SF-09 round 2: capacity-aware redistribute, bounded iterations.
Round 1 bug: surplus распределялся пропорционально текущему `v` free bucket'а,
что переливало его выше cap — на 2-bucket вход цикл осциллировал бесконечно.
Round 2 fix: surplus распределяется пропорционально **available capacity**
`(cap - v)` каждого free bucket'а. Тогда free никогда не вылетит выше cap →
цикл сходится за ≤ len(pct_map) итераций. Hard guard `for _ in range(N+1)`.
Если surplus > total_capacity (геометрически невозможно поместить излишек ниже
cap) — забиваем все free к cap, возвращаем `cap_skipped=True` + warning log.
Returns:
(result_map, cap_skipped) — cap_skipped=True если cap не удержан
(pathological: всё хочет > cap, или surplus > available capacity).
"""
if not pct_map:
return pct_map, False
cap = MAX_BUCKET_SHARE_PCT
# Быстрый path: нет доминирующих
if all(v <= cap for v in pct_map.values()):
return pct_map, False
work: dict[str, float] = {k: float(v) for k, v in pct_map.items()}
# Bounded iteration: после k-й итерации число clamped не убывает только если
# surplus > capacity (тогда — pathological). При корректном capacity-aware
# redistribute достаточно ≤ len(pct_map) итераций.
for _ in range(len(pct_map) + 1):
clamped = [k for k, v in work.items() if v > cap]
if not clamped:
break
free = [k for k, v in work.items() if v < cap]
if not free:
# Все bucket'ы либо >cap либо ровно =cap — некуда переливать.
logger.warning(
"MAX_BUCKET_SHARE cap: нет free bucket'ов (%d total) — cap_skipped",
len(pct_map),
)
return pct_map, True
surplus = sum(work[k] - cap for k in clamped)
capacities = {k: cap - work[k] for k in free}
total_capacity = sum(capacities.values())
for k in clamped:
work[k] = float(cap)
if surplus > total_capacity + 1e-9:
# Излишек не помещается ниже cap — pathological.
# Возвращаем оригинал (sum=100 invariant) + флаг для frontend banner.
logger.warning(
"MAX_BUCKET_SHARE cap: surplus %.2f > total_capacity %.2f — cap_skipped",
surplus,
total_capacity,
)
return pct_map, True
for k in free:
work[k] += capacities[k] / total_capacity * surplus
else:
# Hard guard: не сошлись за N+1 итераций — bug. Лог + cap_skipped.
logger.error(
"MAX_BUCKET_SHARE cap: не сошлись за %d итераций — algorithm bug",
len(pct_map) + 1,
)
return pct_map, True
return _hamilton_round(work), False
def _hamilton_round(work: dict[str, float]) -> dict[str, int]:
"""Hamilton apportionment: float → integer pct с суммой ровно 100."""
floors = {k: int(v) for k, v in work.items()}
remainder = 100 - sum(floors.values())
fracs = sorted(work.keys(), key=lambda k: -(work[k] - floors[k]))
for k in fracs[: max(0, remainder)]:
floors[k] += 1
return floors
# ── Главная функция ───────────────────────────────────────────────────────────
def get_best_layouts(
db: Session,
cad_num: str,
request: BestLayoutsRequest,
) -> BestLayoutsResponse:
"""Top layouts (rooms × area_bin) конкурентов с рейтингом по velocity.
Raises:
ValueError: если центроид участка не найден (caller → HTTP 404).
"""
quarter = _quarter_from_cad(cad_num)
radius_m = request.radius_km * 1000.0
# time_window → (interval_str, months divisor)
window_interval, months_in_window = _TIME_WINDOW_PARAMS.get(
request.time_window, ("3 months", 3.0)
)
# ── Step 1: центроид участка ─────────────────────────────────────────────
try:
coord_row = (
db.execute(
_PARCEL_CENTROID_SQL,
{"cad_num": cad_num, "quarter": quarter},
)
.mappings()
.first()
)
except Exception:
logger.exception("best_layouts: centroid query failed for cad_num=%s", cad_num)
raise
if not coord_row:
raise ValueError(f"Геометрия для {cad_num} не найдена")
center_lon = float(coord_row["center_lon"])
center_lat = float(coord_row["center_lat"])
# ── Step 2: obj_id конкурентов в радиусе ────────────────────────────────
try:
id_rows = (
db.execute(
_COMPETITORS_IN_RADIUS_SQL,
{
"center_lon": center_lon,
"center_lat": center_lat,
"radius_m": radius_m,
"obj_class_filter": request.obj_class_filter,
},
)
.mappings()
.all()
)
except Exception:
logger.exception("best_layouts: competitors-in-radius query failed for cad_num=%s", cad_num)
raise
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_rows]
objects_total_in_radius = len(all_obj_ids)
# Применить exclude / filter из request
exclude_set = set(request.exclude_competitor_obj_ids)
if exclude_set:
all_obj_ids = [oid for oid in all_obj_ids if oid not in exclude_set]
if request.filter_competitor_obj_ids is not None:
filter_set = set(request.filter_competitor_obj_ids)
all_obj_ids = [oid for oid in all_obj_ids if oid in filter_set]
if not all_obj_ids:
return _empty_response(
radius_km=request.radius_km,
time_window=request.time_window,
objects_total_in_radius=objects_total_in_radius,
)
# ── Step 3: inline velocity из objective_corpus_room_month ──────────────
# Fix SF-01: честный фильтр report_month >= NOW() - window_interval.
# Разные time_window → разные deals_window, разный mix.
try:
vel_rows = (
db.execute(
_INLINE_VELOCITY_SQL,
{
"window_interval": window_interval,
"competitor_obj_ids": all_obj_ids,
},
)
.mappings()
.all()
)
except Exception:
logger.exception(
"best_layouts: inline velocity query failed for cad_num=%s obj_count=%d",
cad_num,
len(all_obj_ids),
)
raise
if not vel_rows:
return _empty_response(
radius_km=request.radius_km,
time_window=request.time_window,
objects_total_in_radius=objects_total_in_radius,
)
# ── Step 5: supply side (батч-запрос) ────────────────────────────────────
# Fix #1956: supply берёт ПЕР-ОБЪЕКТНЫЙ последний снимок из domrf_kn_flats
# (каждый ЖК скрейпится в свой день). Прежний единый глобальный
# MAX(snapshot_date) давал 0 квартир почти у всех объектов → «Срок продажи
# 0 мес» / «% продано —». Per-object latest snapshot встроен в _SUPPLY_BATCH_SQL
# (flats_latest CTE) → отдельный pre-compute MAX больше не нужен.
try:
supply_rows = (
db.execute(
_SUPPLY_BATCH_SQL,
{
"center_lon": center_lon,
"center_lat": center_lat,
"radius_m": radius_m,
},
)
.mappings()
.all()
)
except Exception:
logger.warning("best_layouts: supply query failed, supply=0 fallback")
supply_rows = []
supply_map: dict[tuple[str, str], int] = {
(str(r["rb"]), str(r["ab"])): int(r["units"]) for r in supply_rows
}
# ── Step 4 + 6: velocity из реального окна и enrichment per row ─────────
# Fix SF-01: velocity_per_month = deals_window / months_in_window.
# deals_window уже отфильтрован по report_month — разные time_window дают разные данные.
enriched: list[dict[str, Any]] = []
window_start: dt.date | None = None
window_end: dt.date | None = None
# Собираем obj_ids с данными в objective_corpus_room_month (для data_quality)
obj_ids_with_data: set[int] = set()
for r in vel_rows:
room_bucket = str(r["room_bucket"])
deals_window = float(r["deals_window"]) if r["deals_window"] is not None else 0.0
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else 0.0
price_rub = (
float(r["avg_price_per_m2_rub"]) if r["avg_price_per_m2_rub"] is not None else None
)
competitor_obj_ids: list[int] = (
[int(oid) for oid in r["competitor_obj_ids"]] if r["competitor_obj_ids"] else []
)
competitor_count = int(r["competitor_count"])
obj_ids_with_data.update(competitor_obj_ids)
# Step 4: честный velocity = сделки за окно / длина окна в месяцах
velocity_per_month = round(deals_window / months_in_window, 2)
# Step 6: area_bin по avg_area (layout_signature.area_bin)
ab = area_bin(avg_area) if avg_area > 0 else "<25"
sig = layout_signature(room_bucket, ab) # type: ignore[arg-type]
supply_count = supply_map.get((room_bucket, ab), 0)
sold_pct: float | None = None
is_oversold = False
if supply_count > 0:
sold_pct_raw = deals_window / supply_count * 100.0
is_oversold = sold_pct_raw > 100.0
# Clamp at 100%: сделки за 24 мес / текущий snapshot supply несопоставимы.
# Значения >100% артефакт окна, не реальная «распроданность».
sold_pct = round(min(sold_pct_raw, 100.0), 1)
# data window
if r["window_start"] is not None:
ws = r["window_start"]
if isinstance(ws, str):
ws = dt.date.fromisoformat(ws)
elif isinstance(ws, dt.datetime):
ws = ws.date()
window_start = ws if window_start is None else min(window_start, ws)
if r["window_end"] is not None:
we = r["window_end"]
if isinstance(we, str):
we = dt.date.fromisoformat(we)
elif isinstance(we, dt.datetime):
we = we.date()
window_end = we if window_end is None else max(window_end, we)
enriched.append(
{
"room_bucket": room_bucket,
"area_bin": ab,
"signature": sig,
"competitor_obj_ids": competitor_obj_ids,
"competitor_count": competitor_count,
"sum_deals": deals_window,
"velocity_per_month": velocity_per_month,
"avg_price_per_m2_rub": price_rub,
"avg_area_m2": avg_area,
"supply_units_in_radius": supply_count,
"sold_pct_of_supply": sold_pct,
"is_oversold": is_oversold,
}
)
# ── Step 7: фильтр min_velocity + sort + rank ────────────────────────────
filtered = [
row for row in enriched if row["velocity_per_month"] >= request.min_velocity_per_month
]
filtered.sort(key=lambda r: r["velocity_per_month"], reverse=True)
top_layouts: list[TopLayoutRow] = []
for rank_idx, row in enumerate(filtered, start=1):
top_layouts.append(
TopLayoutRow(
rank=rank_idx,
room_bucket=row["room_bucket"],
area_bin=row["area_bin"],
signature=row["signature"],
competitor_obj_ids=row["competitor_obj_ids"],
competitor_count=row["competitor_count"],
total_sold_in_window=int(row["sum_deals"]),
velocity_per_month=row["velocity_per_month"],
avg_price_per_m2_rub=row["avg_price_per_m2_rub"],
avg_area_m2=round(row["avg_area_m2"], 1),
supply_units_in_radius=row["supply_units_in_radius"],
sold_pct_of_supply=row["sold_pct_of_supply"],
is_oversold=row["is_oversold"],
)
)
# ── Step 8: build recommendation_for_tz ─────────────────────────────────
# Используем filtered (только > min_velocity) для recommendation.
# Если после фильтрации всё пустое — используем enriched (все данные без фильтра).
rec_source = filtered if filtered else enriched
today = dt.date.today()
ws_date = window_start if window_start is not None else today
we_date = window_end if window_end is not None else today
recommendation = _build_recommendation(
rows=rec_source,
radius_km=request.radius_km,
time_window=request.time_window,
target_total_flats=request.target_total_flats,
window_start=ws_date,
window_end=we_date,
all_enriched=enriched,
)
# ── Step 9: data_quality ─────────────────────────────────────────────────
# Denominator = post-filter set (effective consideration set после exclude/filter).
objects_total_after_filter = len(all_obj_ids)
objects_with_data = len(obj_ids_with_data & set(all_obj_ids))
coverage_pct = (
round(objects_with_data / objects_total_after_filter * 100.0, 1)
if objects_total_after_filter > 0
else 0.0
)
if coverage_pct >= LAYOUT_CONFIDENCE_HIGH_PCT:
confidence: str = "high"
elif coverage_pct >= LAYOUT_CONFIDENCE_MEDIUM_PCT:
confidence = "medium"
else:
confidence = "low"
data_quality = LayoutDataQuality(
objects_with_velocity_data=objects_with_data,
objects_total_in_radius=objects_total_after_filter,
velocity_coverage_pct=coverage_pct,
confidence=confidence, # type: ignore[arg-type]
)
return BestLayoutsResponse(
top_layouts=top_layouts,
recommendation_for_tz=recommendation,
data_quality=data_quality,
)
def _build_recommendation(
rows: list[dict[str, Any]],
radius_km: float,
time_window: str,
target_total_flats: int | None,
window_start: dt.date,
window_end: dt.date,
all_enriched: list[dict[str, Any]],
) -> LayoutTzRecommendation:
"""Собрать LayoutTzRecommendation из enriched rows."""
if not rows:
return LayoutTzRecommendation(
rationale_text=(
f"В радиусе {radius_km}км: нет layout-паттернов с достаточной velocity."
),
mix=[],
weighted_avg_price_per_m2_rub=None,
based_on_obj_count=0,
based_on_total_deals=0,
data_window_start=window_start,
data_window_end=window_end,
)
# Группировка по room_bucket (строки уже могут быть per-bucket из MV GROUP BY)
rb_deals: dict[str, float] = {}
rb_area_weighted: dict[str, float] = {}
rb_price_weighted: dict[str, float] = {}
rb_price_total_deals: dict[str, float] = {}
all_competitor_ids: set[int] = set()
for row in rows:
rb = row["room_bucket"]
sd = float(row["sum_deals"])
rb_deals[rb] = rb_deals.get(rb, 0.0) + sd
rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd
all_competitor_ids.update(row["competitor_obj_ids"])
if row["avg_price_per_m2_rub"] is not None:
rb_price_weighted[rb] = rb_price_weighted.get(rb, 0.0) + (
row["avg_price_per_m2_rub"] * sd
)
rb_price_total_deals[rb] = rb_price_total_deals.get(rb, 0.0) + sd
total_deals = sum(rb_deals.values())
pct_map = _normalize_pct(rb_deals)
pct_map, cap_skipped = _cap_and_redistribute(pct_map)
mix: list[LayoutTzMixRow] = []
for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]):
avg_area = (
round(rb_area_weighted[rb] / rb_deals[rb], 1) if rb_deals.get(rb, 0) > 0 else None
)
abs_units: int | None = None
if target_total_flats is not None:
abs_units = round(pct / 100.0 * target_total_flats)
mix.append(
LayoutTzMixRow(
room_bucket=rb,
pct=pct,
abs_units=abs_units,
avg_target_area_m2=avg_area,
)
)
# Weighted avg price across all room_buckets
total_price_deals = sum(rb_price_total_deals.values())
weighted_price: float | None = None
if total_price_deals > 0:
weighted_price = round(sum(rb_price_weighted.values()) / total_price_deals, 0)
# Rationale
competitor_count = len(all_competitor_ids)
tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get(
time_window, time_window
)
rationale_text = (
f"В радиусе {radius_km}км за {tw_label}: "
f"{len(rows)} активных layout-паттернов, "
f"total {int(total_deals)} продаж в {competitor_count} ЖК"
)
# based_on_obj_count из all_enriched (уникальные obj_id с данными MV)
all_mv_obj_ids: set[int] = set()
for row in all_enriched:
all_mv_obj_ids.update(row["competitor_obj_ids"])
return LayoutTzRecommendation(
rationale_text=rationale_text,
mix=mix,
weighted_avg_price_per_m2_rub=weighted_price,
based_on_obj_count=len(all_mv_obj_ids),
based_on_total_deals=int(total_deals),
data_window_start=window_start,
data_window_end=window_end,
cap_skipped=cap_skipped,
)
def _empty_response(
radius_km: float,
time_window: str,
objects_total_in_radius: int,
) -> BestLayoutsResponse:
"""Ответ когда нет конкурентов или нет MV данных."""
today = dt.date.today()
tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get(
time_window, time_window
)
return BestLayoutsResponse(
top_layouts=[],
recommendation_for_tz=LayoutTzRecommendation(
rationale_text=(
f"В радиусе {radius_km}км за {tw_label}: "
f"конкуренты не найдены или нет данных velocity."
),
mix=[],
weighted_avg_price_per_m2_rub=None,
based_on_obj_count=0,
based_on_total_deals=0,
data_window_start=today,
data_window_end=today,
),
data_quality=LayoutDataQuality(
objects_with_velocity_data=0,
objects_total_in_radius=objects_total_in_radius,
velocity_coverage_pct=0.0,
confidence="low",
),
)