feat(site-finder): supply-only fallback блок в best-layouts (#2177 шаг 3)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m14s
CI / openapi-codegen-check (pull_request) Successful in 2m6s
CI / backend-tests (pull_request) Successful in 14m24s

This commit is contained in:
bot-backend 2026-07-03 04:02:57 +05:00
parent fc55c2c0b7
commit 944fee599f
6 changed files with 604 additions and 1 deletions

View file

@ -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) ──────────────────────────────────────

View file

@ -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 [],
)

View file

@ -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[])", "")

View file

@ -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 (
<div
style={{
border: "1px solid var(--border-card)",
borderRadius: 12,
overflow: "hidden",
background: "var(--bg-card)",
}}
>
<div
style={{
padding: "12px 18px",
background: "var(--bg-card-alt)",
borderBottom: "1px solid var(--border-card)",
fontWeight: 600,
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
По предложению (без темпа продаж)
</div>
<div style={{ overflowX: "auto" }}>
<table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
>
<thead>
<tr>
{headers.map((h, idx) => (
<th
key={h}
style={{
padding: "7px 12px",
textAlign: idx >= 3 ? "right" : "left",
borderBottom: "1px solid var(--border-card)",
fontWeight: 600,
color: "var(--fg-tertiary)",
whiteSpace: "nowrap",
background: "var(--bg-card-alt)",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={`${row.project_name}__${row.room_bucket}__${row.area_bin}`}
style={{
background:
i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
borderBottom: "1px solid var(--border-soft)",
}}
>
<td
style={{
padding: "7px 12px",
fontWeight: 500,
color: "var(--fg-primary)",
}}
>
{row.project_name}
</td>
<td
style={{ padding: "7px 12px", color: "var(--fg-secondary)" }}
>
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
</td>
<td
style={{ padding: "7px 12px", color: "var(--fg-secondary)" }}
>
{row.median_area_m2 != null
? `${row.median_area_m2.toLocaleString("ru-RU")} м²`
: `${row.area_bin} м²`}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "var(--fg-secondary)",
fontVariantNumeric: "tabular-nums",
}}
>
{row.lots_count.toLocaleString("ru-RU")}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "var(--fg-secondary)",
fontVariantNumeric: "tabular-nums",
}}
>
{row.median_price_m2 != null
? `${Math.round(row.median_price_m2).toLocaleString("ru-RU")} ₽/м²`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div
style={{
padding: "10px 18px",
borderTop: "1px solid var(--border-soft)",
fontSize: 11,
color: "var(--fg-tertiary)",
lineHeight: 1.5,
}}
>
Комплексы без данных о сделках показана структура текущего предложения
(Объектив).
</div>
</div>
);
}
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 && (
<SupplyOnlyTable rows={data.supply_only_layouts} />
)}
<RecommendationCard rec={data.recommendation_for_tz} />
</div>
)}

View file

@ -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 Технико-экономические показатели.

View file

@ -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[];
}