diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index ff17da88..8f159ad3 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -146,8 +146,9 @@ def map_class(target_class: str | None) -> str | None: """Live target_class → forecast obj_class. PURE. None/неизвестное → None. Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite — в «бизнес», - Economy — в «эконом» (3 массовых класса §9.7). None → None = «без фильтра - класса», вызывающий отдаёт движку дефолтную сетку классов. + Economy — в «стандарт» (#1959: нижний массовый класс objective_lots — + «стандарт», эконома в данных нет). 3 массовых класса §9.7. None → None = + «без фильтра класса», вызывающий отдаёт движку дефолтную сетку классов. """ if target_class is None: return None diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index f8bc6c99..da102fc7 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -278,11 +278,20 @@ def _room_bucket(rooms_int: int | None) -> str: # objective_lots — append-per-snapshot (~2.9× инфляция строк), без дедупа сток # раздут и симметрично раздувает спрос И предложение → деградирует посегментный # дефицит. Ключ физлота = (project_name, corpus_name, section, floor, lot_number); -# берём строку с MAX(snapshot_date), tie-break MAX(id) (зеркало -# objective_lots_physflat_latest_idx из 100_*.sql). project_name NOT NULL; -# corpus/section/floor/lot_number могут быть NULL (~2.5% лотов) — для таких лотов -# DISTINCT ON схлопывает NULL=NULL в одну группу (неидентифицируемые физлоты, в -# дефицит-расчёте безопасно), для остального — точный per-flat дедуп. +# берём строку с MAX(snapshot_date), tie-break MAX(id). Поддерживается индексом +# objective_lots_physflat_latest_idx (создаётся в миграции 173) — он совпадает с +# ключом и направлениями этого DISTINCT ON → Index Only Scan + Unique без Sort. +# project_name NOT NULL; corpus/section/floor/lot_number могут быть NULL (~2.5% +# лотов) — для таких лотов DISTINCT ON схлопывает NULL=NULL в одну группу +# (неидентифицируемые физлоты, в дефицит-расчёте безопасно), для остального — +# точный per-flat дедуп. +# +# ⚠️ ДЕДУП БЕЗУСЛОВНЫЙ — применяется ко ВСЕМ вызовам compute_market_metrics, не +# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных +# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated), +# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп +# здесь касается ТОЛЬКО выборки внутри метрик — дедуп самой сырой таблицы +# objective_lots (для всех platform-консьюмеров) остаётся отдельной задачей #1964. # # Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим # LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр @@ -369,11 +378,15 @@ _STOCK_SQL = text( # → velocity/absorption раздуты ~2.9× (раздувают спрос симметрично раздутому # предложению → дефицит вырождается в −1.0). Дедупим в CTE `latest`, окно по # contract_date и сегмент-фильтры применяем уже к дедуплицированному набору. -# Ключ/направления дедупа — зеркало _STOCK_SQL / objective_lots_physflat_latest_idx. -# contract_date / rooms_int / area_pd берём из последнего снапшота физлота. +# Ключ/направления дедупа — зеркало _STOCK_SQL (оба поддержаны индексом +# objective_lots_physflat_latest_idx из миграции 173). contract_date / rooms_int / +# area_pd берём из последнего снапшота физлота. # Сегмент-фильтры (#1959): obj_class (lowercase, LOWER=LOWER) + room_bucket -# (Source-A вокабуляр студия/1/2/3/4/5+) — применяются ПОСЛЕ дедупа (объективны -# по физлоту). ROLLUP остаётся: при room_bucket-фильтре отдаёт {bucket, total}. +# (Source-B room_area-вокабуляр "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/ +# "80+ м²" — ТОЧНОЕ зеркало sales_series.room_area_bucket_of, как в _STOCK_SQL) — +# применяются ПОСЛЕ дедупа (объективны по физлоту: класс/комнатность/площадь не +# меняются между снапшотами). ROLLUP остаётся: при room_bucket-фильтре отдаёт +# {bucket, total}. _SALES_WINDOW_SQL = text( """ WITH latest AS ( @@ -452,13 +465,15 @@ def compute_market_metrics( (имеет смысл для ЕКБ-wide baseline). #1959 (посегментный дефицит): `obj_class` (lowercase в objective_lots, - матчим регистронезависимо) и `room_bucket` (Source-A вокабуляр — - "студия"/"1"/"2"/"3"/"4"/"5+", зеркало `_room_bucket()`) сужают выборку до - одного сегмента. Так base_pace (unit_velocity) и доступный сток становятся - ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки what_to_build делят один - темп → дефицит вырождается в −1.0). None по обеим осям → прежнее district-wide - поведение. ВАЖНО: SQL дедуплицирует objective_lots до последнего снапшота на - физлот → counts (а значит и confidence) считаются на дедуплицированном наборе. + матчим регистронезависимо) и `room_bucket` (Source-B room_area-вокабуляр — + "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²", зеркало + `sales_series.room_area_bucket_of` → what_to_build фильтрует БЕЗ перевода) + сужают выборку до одного сегмента. Так base_pace (unit_velocity) и доступный + сток становятся ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки + what_to_build делят один темп → дефицит вырождается в −1.0). None по обеим осям + → прежнее district-wide поведение. ВАЖНО: SQL дедуплицирует objective_lots до + последнего снапшота на физлот → counts (а значит и confidence) считаются на + дедуплицированном наборе. Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики = None, confidence='low'. Никогда не бросает на отсутствии данных.