This commit is contained in:
parent
fc55c2c0b7
commit
9cfefc0ca4
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% (несопоставимые окна)
|
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):
|
class LayoutTzMixRow(BaseModel):
|
||||||
"""Строка рекомендации unit-mix для ТЗ."""
|
"""Строка рекомендации unit-mix для ТЗ."""
|
||||||
|
|
||||||
|
|
@ -553,6 +572,10 @@ class LayoutDataQuality(BaseModel):
|
||||||
# прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в
|
# прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в
|
||||||
# радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping.
|
# радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping.
|
||||||
groups_matched_by_core: int = 0
|
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
|
velocity_coverage_pct: float
|
||||||
confidence: Literal["high", "medium", "low"]
|
confidence: Literal["high", "medium", "low"]
|
||||||
|
|
||||||
|
|
@ -563,6 +586,10 @@ class BestLayoutsResponse(BaseModel):
|
||||||
top_layouts: list[TopLayoutRow]
|
top_layouts: list[TopLayoutRow]
|
||||||
recommendation_for_tz: LayoutTzRecommendation
|
recommendation_for_tz: LayoutTzRecommendation
|
||||||
data_quality: LayoutDataQuality
|
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) ──────────────────────────────────────
|
# ── Analyze endpoint market price (#33) ──────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ from app.schemas.parcel import (
|
||||||
LayoutDataQuality,
|
LayoutDataQuality,
|
||||||
LayoutTzMixRow,
|
LayoutTzMixRow,
|
||||||
LayoutTzRecommendation,
|
LayoutTzRecommendation,
|
||||||
|
SupplyOnlyLayoutRow,
|
||||||
TopLayoutRow,
|
TopLayoutRow,
|
||||||
)
|
)
|
||||||
from app.services.site_finder.layout_signature import area_bin, layout_signature
|
from app.services.site_finder.layout_signature import area_bin, layout_signature
|
||||||
|
|
@ -340,6 +341,101 @@ _SUPPLY_BATCH_SQL = text("""
|
||||||
GROUP BY rb, ab
|
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
|
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
|
raise
|
||||||
|
|
||||||
if not vel_rows:
|
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(
|
return _empty_response(
|
||||||
radius_km=request.radius_km,
|
radius_km=request.radius_km,
|
||||||
time_window=request.time_window,
|
time_window=request.time_window,
|
||||||
objects_total_in_radius=len(complex_groups),
|
objects_total_in_radius=len(complex_groups),
|
||||||
raw_objects_total=raw_objects_total_in_radius,
|
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 (батч-запрос) ────────────────────────────────────
|
# ── Step 5: supply side (батч-запрос) ────────────────────────────────────
|
||||||
|
|
@ -1177,11 +1350,35 @@ def get_best_layouts(
|
||||||
else:
|
else:
|
||||||
confidence = "low"
|
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(
|
data_quality = LayoutDataQuality(
|
||||||
objects_with_velocity_data=groups_with_data,
|
objects_with_velocity_data=groups_with_data,
|
||||||
objects_total_in_radius=groups_total_after_filter,
|
objects_total_in_radius=groups_total_after_filter,
|
||||||
raw_objects_total=raw_objects_total_in_radius,
|
raw_objects_total=raw_objects_total_in_radius,
|
||||||
groups_matched_by_core=groups_matched_by_core,
|
groups_matched_by_core=groups_matched_by_core,
|
||||||
|
groups_with_supply_only=groups_with_supply_only,
|
||||||
velocity_coverage_pct=coverage_pct,
|
velocity_coverage_pct=coverage_pct,
|
||||||
confidence=confidence, # type: ignore[arg-type]
|
confidence=confidence, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
@ -1190,6 +1387,7 @@ def get_best_layouts(
|
||||||
top_layouts=top_layouts,
|
top_layouts=top_layouts,
|
||||||
recommendation_for_tz=recommendation,
|
recommendation_for_tz=recommendation,
|
||||||
data_quality=data_quality,
|
data_quality=data_quality,
|
||||||
|
supply_only_layouts=supply_only_layouts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1295,11 +1493,16 @@ def _empty_response(
|
||||||
time_window: str,
|
time_window: str,
|
||||||
objects_total_in_radius: int,
|
objects_total_in_radius: int,
|
||||||
raw_objects_total: int = 0,
|
raw_objects_total: int = 0,
|
||||||
|
supply_only_layouts: list[SupplyOnlyLayoutRow] | None = None,
|
||||||
|
groups_with_supply_only: int = 0,
|
||||||
) -> BestLayoutsResponse:
|
) -> BestLayoutsResponse:
|
||||||
"""Ответ когда нет конкурентов или нет MV данных.
|
"""Ответ когда нет конкурентов или нет MV данных.
|
||||||
|
|
||||||
objects_total_in_radius — число комплексов (сгруппированных obj_id, #2177),
|
objects_total_in_radius — число комплексов (сгруппированных obj_id, #2177),
|
||||||
raw_objects_total — сырое число obj_id до группировки (прозрачность).
|
raw_objects_total — сырое число obj_id до группировки (прозрачность).
|
||||||
|
supply_only_layouts / groups_with_supply_only — fallback-блок «по предложению»
|
||||||
|
(#2177 шаг 3): даже без velocity сматченные по ядру комплексы могут иметь лоты
|
||||||
|
в продаже. По умолчанию пусто (нет конкурентов / нет матчей).
|
||||||
"""
|
"""
|
||||||
today = dt.date.today()
|
today = dt.date.today()
|
||||||
tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get(
|
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,
|
objects_total_in_radius=objects_total_in_radius,
|
||||||
raw_objects_total=raw_objects_total,
|
raw_objects_total=raw_objects_total,
|
||||||
groups_matched_by_core=0,
|
groups_matched_by_core=0,
|
||||||
|
groups_with_supply_only=groups_with_supply_only,
|
||||||
velocity_coverage_pct=0.0,
|
velocity_coverage_pct=0.0,
|
||||||
confidence="low",
|
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,
|
_OBJECTIVE_PROJECT_NAMES_SQL,
|
||||||
_PARCEL_CENTROID_SQL,
|
_PARCEL_CENTROID_SQL,
|
||||||
_SUPPLY_BATCH_SQL,
|
_SUPPLY_BATCH_SQL,
|
||||||
|
_SUPPLY_ONLY_LOTS_SQL,
|
||||||
_TIME_WINDOW_PARAMS,
|
_TIME_WINDOW_PARAMS,
|
||||||
MAX_BUCKET_SHARE_PCT,
|
MAX_BUCKET_SHARE_PCT,
|
||||||
NAMELESS_CLUSTER_M,
|
NAMELESS_CLUSTER_M,
|
||||||
|
|
@ -123,6 +124,27 @@ def _supply_row(rb: str, ab: str, units: int) -> MagicMock:
|
||||||
return r
|
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]]:
|
def _proj_name_rows(names: list[str]) -> list[tuple[str]]:
|
||||||
"""Строки из _OBJECTIVE_PROJECT_NAMES_SQL (.all() → row[0] = project_name)."""
|
"""Строки из _OBJECTIVE_PROJECT_NAMES_SQL (.all() → row[0] = project_name)."""
|
||||||
return [(n,) for n in names]
|
return [(n,) for n in names]
|
||||||
|
|
@ -140,6 +162,7 @@ def _make_db(
|
||||||
supply_rows: list[MagicMock] | None = None,
|
supply_rows: list[MagicMock] | None = None,
|
||||||
project_names: list[str] | None = None,
|
project_names: list[str] | None = None,
|
||||||
developers_by_project: dict[str, list[str]] | None = None,
|
developers_by_project: dict[str, list[str]] | None = None,
|
||||||
|
supply_only_rows: list[MagicMock] | None = None,
|
||||||
) -> MagicMock:
|
) -> MagicMock:
|
||||||
"""Сконструировать mock Session с диспатчем по SQL-стейтменту.
|
"""Сконструировать mock Session с диспатчем по SQL-стейтменту.
|
||||||
|
|
||||||
|
|
@ -152,6 +175,11 @@ def _make_db(
|
||||||
• _OBJECTIVE_DEVELOPERS_SQL → .all() (dev-confirm)
|
• _OBJECTIVE_DEVELOPERS_SQL → .all() (dev-confirm)
|
||||||
• _INLINE_VELOCITY_SQL → .mappings().all()
|
• _INLINE_VELOCITY_SQL → .mappings().all()
|
||||||
• _SUPPLY_BATCH_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()
|
db = MagicMock()
|
||||||
|
|
||||||
|
|
@ -173,6 +201,8 @@ def _make_db(
|
||||||
r_supply = MagicMock()
|
r_supply = MagicMock()
|
||||||
r_supply.mappings.return_value.all.return_value = supply_rows or []
|
r_supply.mappings.return_value.all.return_value = supply_rows or []
|
||||||
|
|
||||||
|
all_supply_only = supply_only_rows or []
|
||||||
|
|
||||||
dispatch = {
|
dispatch = {
|
||||||
_PARCEL_CENTROID_SQL: r_centroid,
|
_PARCEL_CENTROID_SQL: r_centroid,
|
||||||
_COMPETITORS_IN_RADIUS_SQL: r_comp,
|
_COMPETITORS_IN_RADIUS_SQL: r_comp,
|
||||||
|
|
@ -182,7 +212,13 @@ def _make_db(
|
||||||
_SUPPLY_BATCH_SQL: r_supply,
|
_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]
|
return dispatch[stmt]
|
||||||
|
|
||||||
db.execute.side_effect = _execute
|
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]
|
assert params["competitor_obj_ids"] == [15731]
|
||||||
# sanity: ANY использован только чтобы не падать на прочих полях
|
# sanity: ANY использован только чтобы не падать на прочих полях
|
||||||
assert params.get("window_interval", ANY) is not None
|
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,
|
BestLayoutsResponse,
|
||||||
Confidence,
|
Confidence,
|
||||||
LayoutTzMixRow,
|
LayoutTzMixRow,
|
||||||
|
SupplyOnlyLayoutRow,
|
||||||
TimeWindow,
|
TimeWindow,
|
||||||
TopLayoutRow,
|
TopLayoutRow,
|
||||||
} from "@/types/best-layouts";
|
} 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[] }) {
|
function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) {
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
"var(--viz-1)",
|
"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} />
|
<RecommendationCard rec={data.recommendation_for_tz} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3119,6 +3119,8 @@ export interface components {
|
||||||
top_layouts: components["schemas"]["TopLayoutRow"][];
|
top_layouts: components["schemas"]["TopLayoutRow"][];
|
||||||
recommendation_for_tz: components["schemas"]["LayoutTzRecommendation"];
|
recommendation_for_tz: components["schemas"]["LayoutTzRecommendation"];
|
||||||
data_quality: components["schemas"]["LayoutDataQuality"];
|
data_quality: components["schemas"]["LayoutDataQuality"];
|
||||||
|
/** Supply Only Layouts */
|
||||||
|
supply_only_layouts?: components["schemas"]["SupplyOnlyLayoutRow"][];
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* BuildingProgramItem
|
* BuildingProgramItem
|
||||||
|
|
@ -4302,6 +4304,11 @@ export interface components {
|
||||||
* @default 0
|
* @default 0
|
||||||
*/
|
*/
|
||||||
groups_matched_by_core: number;
|
groups_matched_by_core: number;
|
||||||
|
/**
|
||||||
|
* Groups With Supply Only
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
groups_with_supply_only: number;
|
||||||
/** Velocity Coverage Pct */
|
/** Velocity Coverage Pct */
|
||||||
velocity_coverage_pct: number;
|
velocity_coverage_pct: number;
|
||||||
/**
|
/**
|
||||||
|
|
@ -5043,6 +5050,34 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
neighborhood_price: number;
|
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
|
* TEAP
|
||||||
* @description Технико-экономические показатели.
|
* @description Технико-экономические показатели.
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,19 @@ export interface TopLayoutRow {
|
||||||
is_oversold: boolean; // raw ratio was >100% (incompatible time windows)
|
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 {
|
export interface LayoutTzMixRow {
|
||||||
room_bucket: string;
|
room_bucket: string;
|
||||||
pct: number;
|
pct: number;
|
||||||
|
|
@ -61,6 +74,9 @@ export interface LayoutDataQuality {
|
||||||
// Issue #2177 шаг 2b: сколько комплексов покрыто по ЯДРУ имени (project_name-матч),
|
// Issue #2177 шаг 2b: сколько комплексов покрыто по ЯДРУ имени (project_name-матч),
|
||||||
// а не через mapping-obj_id — прозрачность источника атрибуции velocity.
|
// а не через mapping-obj_id — прозрачность источника атрибуции velocity.
|
||||||
groups_matched_by_core: number;
|
groups_matched_by_core: number;
|
||||||
|
// Issue #2177 шаг 3: сколько комплексов попало в блок «по предложению» (сматчены
|
||||||
|
// по ядру, без velocity, но с лотами в продаже). Аддитивно, default 0.
|
||||||
|
groups_with_supply_only: number;
|
||||||
velocity_coverage_pct: number;
|
velocity_coverage_pct: number;
|
||||||
confidence: Confidence;
|
confidence: Confidence;
|
||||||
}
|
}
|
||||||
|
|
@ -69,4 +85,7 @@ export interface BestLayoutsResponse {
|
||||||
top_layouts: TopLayoutRow[];
|
top_layouts: TopLayoutRow[];
|
||||||
recommendation_for_tz: LayoutTzRecommendation;
|
recommendation_for_tz: LayoutTzRecommendation;
|
||||||
data_quality: LayoutDataQuality;
|
data_quality: LayoutDataQuality;
|
||||||
|
// Issue #2177 шаг 3: fallback-блок «по предложению без темпа продаж». Пустой
|
||||||
|
// массив, если у всех сматченных комплексов есть velocity или нет лотов.
|
||||||
|
supply_only_layouts: SupplyOnlyLayoutRow[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue