feat(site-finder): supply-only fallback в §4.2 — предложение без темпа продаж (#2177 шаг 3) #2240
6 changed files with 604 additions and 1 deletions
|
|
@ -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) ──────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 [],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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[])", "")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 Технико-экономические показатели.
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue