From 944fee599f065436e9d06c7c9348ff80cf743de3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 04:02:57 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20supply-only=20fallback=20?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=20=D0=B2=20best-layouts=20(#2177=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/schemas/parcel.py | 27 +++ .../app/services/site_finder/best_layouts.py | 205 ++++++++++++++++++ .../services/site_finder/test_best_layouts.py | 188 +++++++++++++++- .../site-finder/BestLayoutsBlock.tsx | 131 +++++++++++ frontend/src/lib/api-types.ts | 35 +++ frontend/src/types/best-layouts.ts | 19 ++ 6 files changed, 604 insertions(+), 1 deletion(-) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 744e9bc2..d52677d9 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -510,6 +510,25 @@ class TopLayoutRow(BaseModel): is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна) +class SupplyOnlyLayoutRow(BaseModel): + """Строка блока «по предложению» (#2177 шаг 3). + + Структура текущего предложения (комнатность × площадь × цена) для комплексов, + которые сматчены на objective project_name по ЯДРУ имени, но у которых НЕТ + сделок (velocity). Источник — objective_lots (снапшот предложения). Это НЕ + velocity-ранжир: темпа продаж здесь нет, только структура остатков в продаже. + """ + + project_name: str + room_bucket: str # {studio,1,2,3,4+} — вокабуляр velocity-стороны + area_bin: str + lots_count: int + median_price_m2: float | None # NULL если цены не прошли price-sanity + median_area_m2: float | None + # Маркер источника: всегда 'objective_lots' — честная семантика «без темпа продаж». + source: str = "objective_lots" + + class LayoutTzMixRow(BaseModel): """Строка рекомендации unit-mix для ТЗ.""" @@ -553,6 +572,10 @@ class LayoutDataQuality(BaseModel): # прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в # радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping. groups_matched_by_core: int = 0 + # Issue #2177 шаг 3: сколько комплексов попало в блок «по предложению» — + # сматчены по ядру на objective project_name, БЕЗ velocity, но с лотами в + # продаже (objective_lots). Аддитивно, не пересекается с velocity-покрытием. + groups_with_supply_only: int = 0 velocity_coverage_pct: float confidence: Literal["high", "medium", "low"] @@ -563,6 +586,10 @@ class BestLayoutsResponse(BaseModel): top_layouts: list[TopLayoutRow] recommendation_for_tz: LayoutTzRecommendation data_quality: LayoutDataQuality + # Issue #2177 шаг 3: fallback-блок «по предложению без темпа продаж» — + # структура текущего предложения для сматченных по ядру комплексов БЕЗ velocity. + # Аддитивно; НЕ смешивается с top_layouts (там velocity-ранжир). Пусто по умолчанию. + supply_only_layouts: list[SupplyOnlyLayoutRow] = Field(default_factory=list) # ── Analyze endpoint market price (#33) ────────────────────────────────────── diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 1573ae44..1250dcb9 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -45,6 +45,7 @@ from app.schemas.parcel import ( LayoutDataQuality, LayoutTzMixRow, LayoutTzRecommendation, + SupplyOnlyLayoutRow, TopLayoutRow, ) from app.services.site_finder.layout_signature import area_bin, layout_signature @@ -340,6 +341,101 @@ _SUPPLY_BATCH_SQL = text(""" GROUP BY rb, ab """) +# ── SQL: supply-only по objective_lots для комплексов БЕЗ velocity (#2177 шаг 3) ─ +# Fallback-блок «по предложению»: для сматченных по ЯДРУ project_name, у которых +# НЕТ сделок (velocity), берём СТРУКТУРУ ТЕКУЩЕГО ПРЕДЛОЖЕНИЯ из objective_lots. +# Это снапшот остатков в продаже (комнатность × площадь × цена), НЕ темп продаж — +# никакого деления на месяцы (не velocity). +# +# ДЕДУП физлотов (КРИТИЧНО): objective_lots — current-state UPSERT (UNIQUE +# objective_lot_id, ~5 distinct snapshot_date), но ОДНОМУ физлоту +# (project_name, corpus_name, section, floor, lot_number) присвоено НЕСКОЛЬКО +# objective_lot_id за время пере-листингов (прод 2026-06-28: инфляция ~2.91×, +# 1.75M сырых строк vs 603k физлотов). Считать count/медианы по сырым строкам +# → задвоение. Дедупим DISTINCT ON (physflat) ORDER BY snapshot_date DESC, id DESC +# — та же инвариантная связка, что v_objective_lots_latest (#1964) и _STOCK_SQL +# (#1959). View НЕ используем: он материализует ВСЮ таблицу (~5.8 с, см. +# 175_v_objective_lots_latest.sql), а нам нужен узкий срез по project_name — +# фильтр в CTE проталкивается в objective_lots_project_idx (project_name, +# snapshot_date DESC) → index probe вместо seq scan 1.75M строк. +# +# ФИЛЬТР «в продаже»: is_sold IS NOT TRUE И LOWER(status) <> 'продан' — паттерн из +# market_metrics.compute_offer_price_trend (бронь/резерв считаем предложением). +# Применяется ПОСЛЕ дедупа: сегментация (комнатность/площадь/цена/статус) объективна +# по физлоту (не меняется между снапшотами в пределах одного лота), но статус «продан» +# — самое свежее состояние, поэтому берём его из последнего снапшота (DISTINCT ON). +# +# ФИЛЬТР ВМЕНЯЕМОСТИ ЦЕНЫ: price_per_m2_rub BETWEEN 10000 AND 1000000 — отсекает +# ETL-мусор (0 / гросс-суммы). Границы зеркалят _OFFER_PRICE_MIN/MAX. Лот БЕЗ цены +# (price_per_m2_rub NULL или вне границ) в count попадает, но в медиану цены — нет +# (PERCENTILE_CONT игнорирует NULL) → median_price_m2 может быть NULL при валидном count. +# +# room_bucket — вокабуляр velocity-стороны {studio,1,2,3,4+} из rooms_int +# (0/NULL→studio, 4/5→4+). area_bin — зеркало layout_signature.area_bin. +# :names — list[str] project_name'ов БЕЗ velocity (ANY(CAST(... AS text[])) — psycopg3). +_SUPPLY_ONLY_LOTS_SQL = text(""" + WITH latest AS ( + SELECT DISTINCT ON ( + ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number + ) + ol.project_name, + ol.rooms_int, + ol.area_pd, + ol.price_per_m2_rub, + ol.is_sold, + ol.status + FROM objective_lots ol + WHERE ol.project_name = ANY(CAST(:names AS text[])) + AND ol.premise_kind = CAST(:premise_kind AS text) + ORDER BY ol.project_name, ol.corpus_name, ol.section, ol.floor, + ol.lot_number, ol.snapshot_date DESC, ol.id DESC + ), + on_sale AS ( + SELECT + project_name, + CASE + WHEN rooms_int IS NULL OR rooms_int <= 0 THEN 'studio' + WHEN rooms_int IN (1, 2, 3) THEN rooms_int::text + ELSE '4+' + END AS rb, + CASE + WHEN area_pd IS NULL THEN '<25' + WHEN area_pd < 25 THEN '<25' + WHEN area_pd < 40 THEN '25-40' + WHEN area_pd < 60 THEN '40-60' + WHEN area_pd < 80 THEN '60-80' + WHEN area_pd < 100 THEN '80-100' + ELSE '100+' + END AS ab, + area_pd, + CASE + WHEN price_per_m2_rub BETWEEN CAST(:price_min AS numeric) + AND CAST(:price_max AS numeric) + THEN price_per_m2_rub + ELSE NULL + END AS price_ok + FROM latest + WHERE is_sold IS NOT TRUE + AND LOWER(COALESCE(status, '')) <> 'продан' + ) + SELECT + project_name AS project_name, + rb AS room_bucket, + ab AS area_bin, + COUNT(*) AS lots_count, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_ok) AS median_price_m2, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area_pd) AS median_area_m2 + FROM on_sale + GROUP BY project_name, rb, ab + ORDER BY project_name, lots_count DESC, rb, ab +""") + +# Границы вменяемости цены предложения (руб/м²) — зеркало market_metrics offer-trend. +_SUPPLY_ONLY_PRICE_MIN: int = 10_000 +_SUPPLY_ONLY_PRICE_MAX: int = 1_000_000 +# premise_kind: только жилые квартиры (не паркинг/кладовые/нежилое). +_SUPPLY_ONLY_PREMISE_KIND: str = "квартира" + # ── Вспомогательные функции ─────────────────────────────────────────────────── @@ -818,6 +914,71 @@ def match_groups_to_projects( return result +# ── Supply-only fallback: структура предложения по objective_lots (#2177 шаг 3) ─ + + +def _fetch_supply_only_layouts( + db: Session, + project_names: list[str], +) -> list[SupplyOnlyLayoutRow]: + """Собрать блок «по предложению» из objective_lots для project_name'ов БЕЗ velocity. + + Args: + db: сессия. + project_names: сматченные по ядру project_name'ы, у которых НЕТ сделок в окне + (velocity). Пустой список → пустой результат (SQL не выполняется). + + Логика: снапшот текущего предложения (комнатность × площадь × цена) активных + (в продаже) лотов, дедуплицированных до физлота. Это НЕ velocity — темпа продаж + здесь нет, только структура остатков. См. _SUPPLY_ONLY_LOTS_SQL. + + Returns: + Список SupplyOnlyLayoutRow (может быть пустым). Ошибка SQL не роняет ответ — + логируется, откатывается транзакция, возвращается [] (блок аддитивен). + """ + if not project_names: + return [] + try: + rows = ( + db.execute( + _SUPPLY_ONLY_LOTS_SQL, + { + "names": project_names, + "premise_kind": _SUPPLY_ONLY_PREMISE_KIND, + "price_min": _SUPPLY_ONLY_PRICE_MIN, + "price_max": _SUPPLY_ONLY_PRICE_MAX, + }, + ) + .mappings() + .all() + ) + except Exception: + # Аддитивный блок — не роняем весь ответ. rollback: следующего execute нет, + # но транзакция в aborted-state сломала бы commit/дальнейшую работу caller'а. + db.rollback() + logger.exception( + "best_layouts: supply-only lots query failed for %d projects — empty block", + len(project_names), + ) + return [] + + out: list[SupplyOnlyLayoutRow] = [] + for r in rows: + median_price = r["median_price_m2"] + median_area = r["median_area_m2"] + out.append( + SupplyOnlyLayoutRow( + project_name=str(r["project_name"]), + room_bucket=str(r["room_bucket"]), + area_bin=str(r["area_bin"]), + lots_count=int(r["lots_count"]), + median_price_m2=(round(float(median_price)) if median_price is not None else None), + median_area_m2=(round(float(median_area), 1) if median_area is not None else None), + ) + ) + return out + + # ── Главная функция ─────────────────────────────────────────────────────────── @@ -978,11 +1139,23 @@ def get_best_layouts( raise if not vel_rows: + # Нет velocity вообще — но у сматченных по ядру групп могут быть ЛОТЫ в + # продаже (структура предложения без темпа продаж, #2177 шаг 3). Все + # matched-проекты здесь supply-only (velocity нет ни у одного). + empty_supply_only = _fetch_supply_only_layouts(db, matched_project_names) + empty_projects_with_supply = {row.project_name for row in empty_supply_only} + empty_groups_supply_only = sum( + 1 + for g in complex_groups + if core_group_to_project.get(g.key) in empty_projects_with_supply + ) return _empty_response( radius_km=request.radius_km, time_window=request.time_window, objects_total_in_radius=len(complex_groups), raw_objects_total=raw_objects_total_in_radius, + supply_only_layouts=empty_supply_only, + groups_with_supply_only=empty_groups_supply_only, ) # ── Step 5: supply side (батч-запрос) ──────────────────────────────────── @@ -1177,11 +1350,35 @@ def get_best_layouts( else: confidence = "low" + # ── Step 9b: supply-only fallback «по предложению» (#2177 шаг 3) ────────── + # Для групп, сматченных на project_name по ЯДРУ, но БЕЗ velocity (нет сделок в + # окне → нет в projects_with_data): показываем СТРУКТУРУ предложения из + # objective_lots. Группа уже НЕ покрыта velocity (_group_covered=False), но у + # неё есть project_name-матч → лоты в продаже атрибутируются по имени. + # Проекты с velocity ИСКЛЮЧЕНЫ (их темп продаж уже в top_layouts — не дублируем). + supply_only_project_names = sorted( + { + core_group_to_project[g.key] + for g in complex_groups + if g.key in core_group_to_project + and not _group_covered(g) # нет velocity → кандидат на supply-only + } + ) + supply_only_layouts = _fetch_supply_only_layouts(db, supply_only_project_names) + # Счётчик — по КОМПЛЕКСАМ (группам), реально попавшим в supply-блок (есть строки лотов). + projects_with_supply = {row.project_name for row in supply_only_layouts} + groups_with_supply_only = sum( + 1 + for g in complex_groups + if core_group_to_project.get(g.key) in projects_with_supply and not _group_covered(g) + ) + data_quality = LayoutDataQuality( objects_with_velocity_data=groups_with_data, objects_total_in_radius=groups_total_after_filter, raw_objects_total=raw_objects_total_in_radius, groups_matched_by_core=groups_matched_by_core, + groups_with_supply_only=groups_with_supply_only, velocity_coverage_pct=coverage_pct, confidence=confidence, # type: ignore[arg-type] ) @@ -1190,6 +1387,7 @@ def get_best_layouts( top_layouts=top_layouts, recommendation_for_tz=recommendation, data_quality=data_quality, + supply_only_layouts=supply_only_layouts, ) @@ -1295,11 +1493,16 @@ def _empty_response( time_window: str, objects_total_in_radius: int, raw_objects_total: int = 0, + supply_only_layouts: list[SupplyOnlyLayoutRow] | None = None, + groups_with_supply_only: int = 0, ) -> BestLayoutsResponse: """Ответ когда нет конкурентов или нет MV данных. objects_total_in_radius — число комплексов (сгруппированных obj_id, #2177), raw_objects_total — сырое число obj_id до группировки (прозрачность). + supply_only_layouts / groups_with_supply_only — fallback-блок «по предложению» + (#2177 шаг 3): даже без velocity сматченные по ядру комплексы могут иметь лоты + в продаже. По умолчанию пусто (нет конкурентов / нет матчей). """ today = dt.date.today() tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get( @@ -1324,7 +1527,9 @@ def _empty_response( objects_total_in_radius=objects_total_in_radius, raw_objects_total=raw_objects_total, groups_matched_by_core=0, + groups_with_supply_only=groups_with_supply_only, velocity_coverage_pct=0.0, confidence="low", ), + supply_only_layouts=supply_only_layouts or [], ) diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 38038a02..4a07d593 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -34,6 +34,7 @@ from app.services.site_finder.best_layouts import ( _OBJECTIVE_PROJECT_NAMES_SQL, _PARCEL_CENTROID_SQL, _SUPPLY_BATCH_SQL, + _SUPPLY_ONLY_LOTS_SQL, _TIME_WINDOW_PARAMS, MAX_BUCKET_SHARE_PCT, NAMELESS_CLUSTER_M, @@ -123,6 +124,27 @@ def _supply_row(rb: str, ab: str, units: int) -> MagicMock: return r +def _supply_only_row( + project_name: str, + room_bucket: str = "1", + area_bin: str = "25-40", + lots_count: int = 12, + median_price_m2: float | None = 145000.0, + median_area_m2: float | None = 38.5, +) -> MagicMock: + """Строка из _SUPPLY_ONLY_LOTS_SQL (#2177 шаг 3, .mappings().all()).""" + r = MagicMock() + r.__getitem__ = lambda self, k: { + "project_name": project_name, + "room_bucket": room_bucket, + "area_bin": area_bin, + "lots_count": lots_count, + "median_price_m2": median_price_m2, + "median_area_m2": median_area_m2, + }[k] + return r + + def _proj_name_rows(names: list[str]) -> list[tuple[str]]: """Строки из _OBJECTIVE_PROJECT_NAMES_SQL (.all() → row[0] = project_name).""" return [(n,) for n in names] @@ -140,6 +162,7 @@ def _make_db( supply_rows: list[MagicMock] | None = None, project_names: list[str] | None = None, developers_by_project: dict[str, list[str]] | None = None, + supply_only_rows: list[MagicMock] | None = None, ) -> MagicMock: """Сконструировать mock Session с диспатчем по SQL-стейтменту. @@ -152,6 +175,11 @@ def _make_db( • _OBJECTIVE_DEVELOPERS_SQL → .all() (dev-confirm) • _INLINE_VELOCITY_SQL → .mappings().all() • _SUPPLY_BATCH_SQL → .mappings().all() + • _SUPPLY_ONLY_LOTS_SQL → .mappings().all() (#2177 шаг 3) + + supply_only_rows фильтруются по bind :names (сматченные-без-velocity проекты), + чтобы тест на «группа С velocity → НЕ дублируется» проверял реальный контракт: + сервис НЕ должен передавать velocity-проект в этот SQL. """ db = MagicMock() @@ -173,6 +201,8 @@ def _make_db( r_supply = MagicMock() r_supply.mappings.return_value.all.return_value = supply_rows or [] + all_supply_only = supply_only_rows or [] + dispatch = { _PARCEL_CENTROID_SQL: r_centroid, _COMPETITORS_IN_RADIUS_SQL: r_comp, @@ -182,7 +212,13 @@ def _make_db( _SUPPLY_BATCH_SQL: r_supply, } - def _execute(stmt, *_args, **_kwargs): + def _execute(stmt, params=None, *_args, **_kwargs): + if stmt is _SUPPLY_ONLY_LOTS_SQL: + names = set((params or {}).get("names") or []) + filtered = [r for r in all_supply_only if r["project_name"] in names] + res = MagicMock() + res.mappings.return_value.all.return_value = filtered + return res return dispatch[stmt] db.execute.side_effect = _execute @@ -1005,3 +1041,153 @@ def test_matched_project_names_passed_to_velocity_bind() -> None: assert params["competitor_obj_ids"] == [15731] # sanity: ANY использован только чтобы не падать на прочих полях assert params.get("window_interval", ANY) is not None + + +# ── Issue #2177 шаг 3: fallback-блок «по предложению» (supply-only) ─────────── + + +def test_supply_only_group_without_velocity_appears_in_block() -> None: + """Группа сматчена по ядру, БЕЗ velocity, но с лотами → в supply_only_layouts + счётчик. + + Радиус: две группы. «Траектория» имеет velocity (сделки в окне) → top_layouts. + «Символ» сматчен по ядру, но velocity нет (не в vel_rows) → лоты в продаже + из objective_lots попадают в supply_only_layouts, счётчик = 1. + """ + id_rows = [ + _obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60), + _obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601), + ] + # velocity ТОЛЬКО у Траектории (обе группы сматчены по ядру ниже). + vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] + supply_only_rows = [ + _supply_only_row("Символ", room_bucket="1", area_bin="25-40", lots_count=18), + _supply_only_row( + "Символ", room_bucket="studio", area_bin="<25", lots_count=7, median_area_m2=22.0 + ), + ] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + project_names=["Траектория", "Символ"], + developers_by_project={}, + supply_only_rows=supply_only_rows, + ) + req = _request(time_window="last_quarter") + resp = get_best_layouts(db, CAD_NUM, req) + + # supply-блок содержит ТОЛЬКО «Символ» (без velocity), 2 строки + assert len(resp.supply_only_layouts) == 2 + assert {r.project_name for r in resp.supply_only_layouts} == {"Символ"} + first = resp.supply_only_layouts[0] + assert first.lots_count == 18 + assert first.median_price_m2 == 145000 + assert first.source == "objective_lots" + # счётчик групп в supply-блоке + assert resp.data_quality.groups_with_supply_only == 1 + + +def test_supply_only_velocity_group_not_duplicated() -> None: + """Группа С velocity НЕ попадает в supply-блок (её темп продаж уже в top_layouts). + + Контракт: сервис НЕ передаёт velocity-проект в _SUPPLY_ONLY_LOTS_SQL (mock + фильтрует по :names). Даже если бы для «Траектории» существовали строки лотов, + их не должно быть в ответе — проект исключён как velocity-covered. + """ + id_rows = [_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60)] + vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] + # Ловушка: строки лотов для velocity-проекта существуют в БД… + supply_only_rows = [_supply_only_row("Траектория", lots_count=99)] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + project_names=["Траектория"], + developers_by_project={}, + supply_only_rows=supply_only_rows, + ) + req = _request(time_window="last_quarter") + resp = get_best_layouts(db, CAD_NUM, req) + + # velocity-группа в top_layouts, НЕ в supply-блоке + assert len(resp.top_layouts) == 1 + assert resp.supply_only_layouts == [] + assert resp.data_quality.groups_with_supply_only == 0 + # контракт: velocity-проект НЕ уходит в supply-only SQL. Когда supply-only + # набор пуст, сервис короткозамыкает БЕЗ запроса (эффективность) — но если + # запрос всё же сделан, :names НЕ должен содержать velocity-проект. + supply_calls = [ + c for c in db.execute.call_args_list if c.args and c.args[0] is _SUPPLY_ONLY_LOTS_SQL + ] + for call in supply_calls: + params = call.args[1] if len(call.args) > 1 else call.kwargs + assert "Траектория" not in ( + params.get("names") or [] + ), "velocity-проект не должен уходить в supply-only запрос" + + +def test_supply_only_empty_snapshot_yields_empty_block() -> None: + """Пустой снапшот лотов (нет строк) → supply_only_layouts пуст, счётчик 0. + + «Символ» сматчен по ядру и без velocity, но в objective_lots для него лотов нет + (пустой снапшот) → блок пустой, счётчик 0 (аддитивная семантика, без ошибок). + """ + id_rows = [_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601)] + vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + project_names=["Траектория", "Символ"], + developers_by_project={}, + supply_only_rows=[], # пустой снапшот + ) + req = _request(time_window="last_quarter") + resp = get_best_layouts(db, CAD_NUM, req) + + assert resp.supply_only_layouts == [] + assert resp.data_quality.groups_with_supply_only == 0 + + +def test_supply_only_median_price_none_preserved() -> None: + """Лот без валидной цены (median_price_m2 NULL) → count есть, цена None (не 0).""" + id_rows = [_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601)] + vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] + supply_only_rows = [ + _supply_only_row("Символ", lots_count=5, median_price_m2=None, median_area_m2=None), + ] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + project_names=["Траектория", "Символ"], + developers_by_project={}, + supply_only_rows=supply_only_rows, + ) + req = _request(time_window="last_quarter") + resp = get_best_layouts(db, CAD_NUM, req) + + assert len(resp.supply_only_layouts) == 1 + row = resp.supply_only_layouts[0] + assert row.lots_count == 5 + assert row.median_price_m2 is None + assert row.median_area_m2 is None + + +def test_supply_only_sql_uses_price_sanity_and_on_sale_filter() -> None: + """Regression guard: SQL фильтрует «в продаже» + price-sanity + дедуп физлота. + + Инварианты контракта (#2177 шаг 3): + • is_sold IS NOT TRUE + status<>'продан' — фильтр «в продаже»; + • price BETWEEN price_min/price_max — вменяемость цены; + • DISTINCT ON (physflat) — дедуп ре-листинг-инфляции objective_lots; + • room_bucket в вокабуляре velocity {studio,4+}, НЕ velocity (без деления на мес). + """ + sql_text = str(_SUPPLY_ONLY_LOTS_SQL.text) + assert "is_sold IS NOT TRUE" in sql_text + assert "<> 'продан'" in sql_text + assert "price_min" in sql_text and "price_max" in sql_text + assert "DISTINCT ON (" in sql_text, "нет физлот-дедупа → ре-листинг-инфляция" + assert "'studio'" in sql_text and "'4+'" in sql_text + # psycopg3: CAST(:x AS type), НЕ :x::type + assert "::text[]" not in sql_text.replace("CAST(:names AS text[])", "") diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index a20f80a6..c42d70fa 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -8,6 +8,7 @@ import type { BestLayoutsResponse, Confidence, LayoutTzMixRow, + SupplyOnlyLayoutRow, TimeWindow, TopLayoutRow, } from "@/types/best-layouts"; @@ -450,6 +451,133 @@ function TopLayoutsTable({ ); } +// Issue #2177 шаг 3: компактный блок «По предложению (без темпа продаж)». +// Рендерится только при непустом supply_only_layouts. Честная семантика: +// комплексы без данных о сделках — показана структура текущего предложения. +function SupplyOnlyTable({ rows }: { rows: SupplyOnlyLayoutRow[] }) { + if (rows.length === 0) return null; + + const headers = ["Комплекс", "Тип", "Площадь", "Лотов", "Медиана ₽/м²"]; + + return ( +
+
+ По предложению (без темпа продаж) +
+
+ + + + {headers.map((h, idx) => ( + + ))} + + + + {rows.map((row, i) => ( + + + + + + + + ))} + +
= 3 ? "right" : "left", + borderBottom: "1px solid var(--border-card)", + fontWeight: 600, + color: "var(--fg-tertiary)", + whiteSpace: "nowrap", + background: "var(--bg-card-alt)", + }} + > + {h} +
+ {row.project_name} + + {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} + + {row.median_area_m2 != null + ? `${row.median_area_m2.toLocaleString("ru-RU")} м²` + : `${row.area_bin} м²`} + + {row.lots_count.toLocaleString("ru-RU")} + + {row.median_price_m2 != null + ? `${Math.round(row.median_price_m2).toLocaleString("ru-RU")} ₽/м²` + : "—"} +
+
+
+ Комплексы без данных о сделках — показана структура текущего предложения + (Объектив). +
+
+ ); +} + function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) { const COLORS = [ "var(--viz-1)", @@ -1077,6 +1205,9 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) { ] } /> + {data.supply_only_layouts.length > 0 && ( + + )} )} diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 6baa4573..88879caf 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -3119,6 +3119,8 @@ export interface components { top_layouts: components["schemas"]["TopLayoutRow"][]; recommendation_for_tz: components["schemas"]["LayoutTzRecommendation"]; data_quality: components["schemas"]["LayoutDataQuality"]; + /** Supply Only Layouts */ + supply_only_layouts?: components["schemas"]["SupplyOnlyLayoutRow"][]; }; /** * BuildingProgramItem @@ -4302,6 +4304,11 @@ export interface components { * @default 0 */ groups_matched_by_core: number; + /** + * Groups With Supply Only + * @default 0 + */ + groups_with_supply_only: number; /** Velocity Coverage Pct */ velocity_coverage_pct: number; /** @@ -5043,6 +5050,34 @@ export interface components { */ neighborhood_price: number; }; + /** + * SupplyOnlyLayoutRow + * @description Строка блока «по предложению» (#2177 шаг 3). + * + * Структура текущего предложения (комнатность × площадь × цена) для комплексов, + * которые сматчены на objective project_name по ЯДРУ имени, но у которых НЕТ + * сделок (velocity). Источник — objective_lots (снапшот предложения). Это НЕ + * velocity-ранжир: темпа продаж здесь нет, только структура остатков в продаже. + */ + SupplyOnlyLayoutRow: { + /** Project Name */ + project_name: string; + /** Room Bucket */ + room_bucket: string; + /** Area Bin */ + area_bin: string; + /** Lots Count */ + lots_count: number; + /** Median Price M2 */ + median_price_m2: number | null; + /** Median Area M2 */ + median_area_m2: number | null; + /** + * Source + * @default objective_lots + */ + source: string; + }; /** * TEAP * @description Технико-экономические показатели. diff --git a/frontend/src/types/best-layouts.ts b/frontend/src/types/best-layouts.ts index 6a90d2b0..ec7fdea7 100644 --- a/frontend/src/types/best-layouts.ts +++ b/frontend/src/types/best-layouts.ts @@ -33,6 +33,19 @@ export interface TopLayoutRow { is_oversold: boolean; // raw ratio was >100% (incompatible time windows) } +export interface SupplyOnlyLayoutRow { + // Issue #2177 шаг 3: структура текущего ПРЕДЛОЖЕНИЯ (без темпа продаж) для + // комплексов, сматченных по ядру имени, но без данных о сделках. Источник — + // objective_lots (снапшот остатков в продаже). + project_name: string; + room_bucket: string; + area_bin: string; + lots_count: number; + median_price_m2: number | null; + median_area_m2: number | null; + source: string; // всегда 'objective_lots' +} + export interface LayoutTzMixRow { room_bucket: string; pct: number; @@ -61,6 +74,9 @@ export interface LayoutDataQuality { // Issue #2177 шаг 2b: сколько комплексов покрыто по ЯДРУ имени (project_name-матч), // а не через mapping-obj_id — прозрачность источника атрибуции velocity. groups_matched_by_core: number; + // Issue #2177 шаг 3: сколько комплексов попало в блок «по предложению» (сматчены + // по ядру, без velocity, но с лотами в продаже). Аддитивно, default 0. + groups_with_supply_only: number; velocity_coverage_pct: number; confidence: Confidence; } @@ -69,4 +85,7 @@ export interface BestLayoutsResponse { top_layouts: TopLayoutRow[]; recommendation_for_tz: LayoutTzRecommendation; data_quality: LayoutDataQuality; + // Issue #2177 шаг 3: fallback-блок «по предложению без темпа продаж». Пустой + // массив, если у всех сматченных комплексов есть velocity или нет лотов. + supply_only_layouts: SupplyOnlyLayoutRow[]; } -- 2.45.3