Merge pull request 'fix(week-review): дожим — 4 фикса (#1569, #1590, #1642, #801)' (#1662) from fix/week-review-finish into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m40s
Deploy / build-frontend (push) Successful in 3m40s
Deploy / build-worker (push) Successful in 4m27s
Deploy / deploy (push) Successful in 1m34s

This commit is contained in:
lekss361 2026-06-17 17:23:10 +00:00
commit 18da92ccc7
16 changed files with 502 additions and 104 deletions

View file

@ -5,14 +5,13 @@ from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
# Import the models package so every ORM model registers on Base.metadata.
# Add new model modules in app/models/__init__.py as they appear.
import app.models # noqa: F401
from alembic import context from alembic import context
from app.core.config import settings from app.core.config import settings
from app.core.db import Base from app.core.db import Base
# Import models so they register on Base.metadata.
# Add new model modules here as they appear.
from app.models import job_settings, parcel # noqa: F401
config = context.config config = context.config
# Inject runtime DB URL. # Inject runtime DB URL.

View file

@ -0,0 +1,15 @@
"""ORM model registry.
Importing this package loads every SQLAlchemy model module so the classes
register on `Base.metadata`. Alembic (alembic/env.py) imports `app.models`
to populate `target_metadata` for autogenerate / drift detection.
Add new ORM model modules to the imports below as they appear. Tables managed
by raw SQL (data/sql/*.sql, e.g. cad_parcels / cad_parcels_geom) have no ORM
model by design and intentionally stay out of Base.metadata.
"""
from app.core.db import Base
from app.models import job_settings, parcel # noqa: F401
__all__ = ["Base"]

View file

@ -10,7 +10,7 @@
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из
`report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря, `report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря,
`_fmt` строковое форматирование ячейки/None"", `_level_ru`, `_as_dict`/`_as_list`, `_fmt` строковое форматирование ячейки/None"", `_level_ru`, `_as_dict`/`_as_list`,
`_future_supply_pairs`, `_scenario_deficit_index`) + переиспользуем его named-константы `_future_supply_pairs`, `_scenario_deficit_cell`) + переиспользуем его named-константы
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные (`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные
микро-билдеры (заголовки/абзацы/таблицы python-docx). микро-билдеры (заголовки/абзацы/таблицы python-docx).
@ -44,7 +44,7 @@ from app.services.exporters.report_pdf import (
_future_supply_pairs, _future_supply_pairs,
_level_ru, _level_ru,
_normalize, _normalize,
_scenario_deficit_index, _scenario_deficit_cell,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -76,7 +76,8 @@ _TABLE_STYLE: str = "Table Grid"
_COMPETITORS_TOP_N: int = 5 _COMPETITORS_TOP_N: int = 5
# Основной продуктовый горизонт (мес) — подпись столбца сводного дефицита сценария # Основной продуктовый горизонт (мес) — подпись столбца сводного дефицита сценария
# (зеркало report_md._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index). # (зеркало report_md._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell —
# при fallback на чужой горизонт ячейка несёт «(гор. N мес)», #1590).
_PRIMARY_HORIZON_MONTHS: int = 12 _PRIMARY_HORIZON_MONTHS: int = 12
@ -272,7 +273,7 @@ def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None:
rate_str = ( rate_str = (
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
) )
rows.append([name, _scenario_deficit_index(data), rate_str, data.get("advisory")]) rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
headers = [ headers = [
"Сценарий", "Сценарий",

View file

@ -39,6 +39,7 @@ from app.services.exporters.report_pdf import (
_future_supply_pairs, _future_supply_pairs,
_level_ru, _level_ru,
_normalize, _normalize,
_scenario_deficit_horizon,
_scenario_deficit_index, _scenario_deficit_index,
) )
@ -109,10 +110,7 @@ def _md_table(headers: list[str], rows: list[list[Any]]) -> str:
if not rows: if not rows:
empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |" empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |"
return "\n".join([head, sep, empty]) return "\n".join([head, sep, empty])
body = [ body = ["| " + " | ".join(_md_cell(cell) for cell in row) + " |" for row in rows]
"| " + " | ".join(_md_cell(cell) for cell in row) + " |"
for row in rows
]
return "\n".join([head, sep, *body]) return "\n".join([head, sep, *body])
@ -250,6 +248,22 @@ def _build_future_market(report: dict[str, Any]) -> str:
) )
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. Зеркало report_pdf._scenario_deficit_cell: при fallback `_scenario_deficit_index`
на чужой горизонт (нет 12-мес прогноза) помечаем число реальным горизонтом, чтобы под
общей шапкой «(12 мес)» оно не читалось как 12-месячное. Дефицит None как есть ( "").
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
def _build_scenarios(report: dict[str, Any]) -> str: def _build_scenarios(report: dict[str, Any]) -> str:
"""§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path.""" """§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
scenarios = _as_dict(report.get("scenarios")) scenarios = _as_dict(report.get("scenarios"))
@ -259,12 +273,8 @@ def _build_scenarios(report: dict[str, Any]) -> str:
for name, payload in by_scenario.items(): for name, payload in by_scenario.items():
data = _as_dict(payload) data = _as_dict(payload)
rate_path = _as_dict(data.get("rate_path")) rate_path = _as_dict(data.get("rate_path"))
rate_str = ( rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
)
rows.append(
[name, _scenario_deficit_index(data), rate_str, data.get("advisory")]
)
headers = [ headers = [
"Сценарий", "Сценарий",
@ -273,9 +283,7 @@ def _build_scenarios(report: dict[str, Any]) -> str:
"Advisory", "Advisory",
] ]
return ( return (
f"## {_TITLE_SCENARIOS}\n\n" f"## {_TITLE_SCENARIOS}\n\n{_fmt(scenarios.get('summary'))}\n\n{_md_table(headers, rows)}"
f"{_fmt(scenarios.get('summary'))}\n\n"
f"{_md_table(headers, rows)}"
) )
@ -297,9 +305,7 @@ def _build_product_tz(report: dict[str, Any]) -> str:
if isinstance(m, dict) if isinstance(m, dict)
] ]
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)] usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
reason_rows = [ reason_rows = [[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)]
[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)
]
return ( return (
f"## {_TITLE_PRODUCT_TZ}\n\n" f"## {_TITLE_PRODUCT_TZ}\n\n"
@ -451,7 +457,9 @@ def _tg_scenario_deficit_line(report: dict[str, Any]) -> str | None:
parts: list[str] = [] parts: list[str] = []
for name in ordered: for name in ordered:
label = _TG_SCENARIO_RU.get(name, str(name)) label = _TG_SCENARIO_RU.get(name, str(name))
deficit = _scenario_deficit_index(_as_dict(by_scenario.get(name))) # _scenario_deficit_cell сам помечает «(гор. N мес)» при fallback на чужой горизонт,
# чтобы метка периода в чате не лгала (см. _build_scenarios).
deficit = _scenario_deficit_cell(_as_dict(by_scenario.get(name)))
parts.append(f"{label} {_fmt(deficit)}") parts.append(f"{label} {_fmt(deficit)}")
return " · ".join(parts) if parts else None return " · ".join(parts) if parts else None
@ -511,8 +519,20 @@ def render_report_telegram_summary(report: Any) -> str:
# Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии). # Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии).
scenario_line = _tg_scenario_deficit_line(data) scenario_line = _tg_scenario_deficit_line(data)
if scenario_line is not None: if scenario_line is not None:
horizons = _as_list(meta.get("horizons")) # Метку «(гор. 12 мес)» ставим, только если ВСЕ выведенные дефициты реально взяты
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if horizons else "" # с основного 12-мес горизонта; при fallback на чужой горизонт у сценария метка
# «(гор. N мес)» уже стоит в самой строке (см. _scenario_deficit_cell), а общая
# метка молчит, чтобы не лгать про период (баг #1590).
by_scenario = _as_dict(_as_dict(data.get("scenarios")).get("by_scenario"))
with_deficit = [
_as_dict(payload)
for payload in by_scenario.values()
if _scenario_deficit_index(_as_dict(payload)) is not None
]
all_primary = bool(with_deficit) and all(
_scenario_deficit_horizon(p) == _PRIMARY_HORIZON_MONTHS for p in with_deficit
)
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if all_primary else ""
lines.append(f"• Дефицит по сценариям{horizon_note}: {scenario_line}") lines.append(f"• Дефицит по сценариям{horizon_note}: {scenario_line}")
# Рекомендованный класс продукта (если задан). # Рекомендованный класс продукта (если задан).

View file

@ -479,12 +479,14 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
return None return None
def _scenario_deficit_horizon(payload: dict[str, Any]) -> Any: def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None:
"""Фактический горизонт (мес), из которого взят `_scenario_deficit_index`. PURE. """Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE.
Зеркалит выбор `_scenario_deficit_index`: основной горизонт, иначе первый с не-None Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес первый с не-None
дефицитом. Нужен, чтобы НЕ врать подписью «(12 мес)» при fallback на чужой горизонт дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала
(#1590). Нет дефицита → None. при fallback на чужой горизонт. Нет дефицита None. NB: сигнатура
`_scenario_deficit_index` (импортируется в report_md/docx/pptx) НЕ меняется это
отдельная чистая функция-зеркало.
""" """
forecasts = _as_list(payload.get("forecasts")) forecasts = _as_list(payload.get("forecasts"))
primary = next( primary = next(
@ -499,10 +501,27 @@ def _scenario_deficit_horizon(payload: dict[str, Any]) -> Any:
return _PRIMARY_HORIZON_MONTHS return _PRIMARY_HORIZON_MONTHS
for f in forecasts: for f in forecasts:
if isinstance(f, dict) and f.get("deficit_index") is not None: if isinstance(f, dict) and f.get("deficit_index") is not None:
return f.get("horizon_months") h = f.get("horizon_months")
return h if isinstance(h, int) else None
return None return None
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. При отсутствии 12-мес прогноза `_scenario_deficit_index` делает fallback на чужой
горизонт помечаем ячейку реальным горизонтом, чтобы число под общей шапкой
«(12 мес)» не читалось как 12-месячное. Дефицит None отдаём как есть ( "").
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
def _build_scenarios(report: dict[str, Any]) -> str: def _build_scenarios(report: dict[str, Any]) -> str:
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful.""" """Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
scenarios = _as_dict(report.get("scenarios")) scenarios = _as_dict(report.get("scenarios"))
@ -511,19 +530,9 @@ def _build_scenarios(report: dict[str, Any]) -> str:
rows: list[list[Any]] = [] rows: list[list[Any]] = []
for name, payload in by_scenario.items(): for name, payload in by_scenario.items():
data = _as_dict(payload) data = _as_dict(payload)
deficit = _scenario_deficit_index(data) rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
horizon = _scenario_deficit_horizon(data)
# Подпись столбца жёстко «(12 мес)» — если значение от другого горизонта
# (fallback), помечаем ячейку фактическим горизонтом, чтобы не врать (#1590).
if deficit is not None and horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
deficit = f"{_fmt(deficit)} (гор. {horizon} мес)"
rows.append([name, deficit, data.get("advisory")])
headers = [ headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
"Сценарий",
f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)",
"Advisory",
]
return f""" return f"""
<div class="section" id="scenarios"> <div class="section" id="scenarios">
<h2>{html.escape(_TITLE_SCENARIOS)}</h2> <h2>{html.escape(_TITLE_SCENARIOS)}</h2>

View file

@ -12,7 +12,7 @@
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из
`report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря, `report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря,
`_fmt` строковое форматирование значения/None"", `_level_ru`, `_as_dict`/`_as_list`, `_fmt` строковое форматирование значения/None"", `_level_ru`, `_as_dict`/`_as_list`,
`_scenario_deficit_index`) + переиспользуем его named-константы `_scenario_deficit_cell`) + переиспользуем его named-константы
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные (`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные
микро-билдеры (слайды/буллеты/таблица python-pptx). микро-билдеры (слайды/буллеты/таблица python-pptx).
@ -46,7 +46,7 @@ from app.services.exporters.report_pdf import (
_fmt, _fmt,
_level_ru, _level_ru,
_normalize, _normalize,
_scenario_deficit_index, _scenario_deficit_cell,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -78,7 +78,8 @@ _LAYOUT_TITLE_CONTENT: int = 1
_LAYOUT_TITLE_ONLY: int = 5 _LAYOUT_TITLE_ONLY: int = 5
# Основной продуктовый горизонт (мес) — подпись сводного дефицита сценария # Основной продуктовый горизонт (мес) — подпись сводного дефицита сценария
# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index). # (зеркало report_docx._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell —
# при fallback на чужой горизонт буллет несёт «(гор. N мес)», #1590).
_PRIMARY_HORIZON_MONTHS: int = 12 _PRIMARY_HORIZON_MONTHS: int = 12
# Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно). # Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно).
@ -235,7 +236,7 @@ def _build_scenarios(prs: _PptxPresentation, report: dict[str, Any]) -> None:
rate_str = ( rate_str = (
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH
) )
deficit = _fmt(_scenario_deficit_index(data)) deficit = _fmt(_scenario_deficit_cell(data))
bullets.append( bullets.append(
(str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}") (str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}")
) )

View file

@ -174,9 +174,10 @@ _MORTG_MAX_X_PCT: float = 25.0
# дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш). # дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш).
_DIFF_TARGET_USP: float = 3.0 _DIFF_TARGET_USP: float = 3.0
# commercial: commercial_share_pct ∈ [0,100] (§10.4 реализованная доля нежилого) → /100. # commercial: commercial_sell_through_pct ∈ [0,100] (§10.4 темп распродажи нежилого,
# Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): overlay.commercial.available=False # sell-through, прокси спроса) → /100. Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё):
# → скор None (НЕ 0). available=True → share/100 как прокси силы спроса на коммерцию. # overlay.commercial.available=False → скор None (НЕ 0). available=True → sell_through/100
# как прокси силы спроса на коммерцию.
# confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] → # confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] →
# усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 — # усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 —
@ -463,21 +464,24 @@ def _score_differentiation(
def _score_commercial( def _score_commercial(
commercial: dict[str, Any] | None, commercial: dict[str, Any] | None,
) -> tuple[float | None, Confidence, str]: ) -> tuple[float | None, Confidence, str]:
"""commercial ← recommendation §10.4 сигнал → share_pct/100. ДЕГРАДИРУЕТ в None. PURE. """commercial ← recommendation §10.4 сигнал → sell_through_pct/100. ДЕГРАДИРУЕТ в None. PURE.
overlay.commercial.available=True commercial_share_pct/100 (прокси спроса на overlay.commercial.available=True commercial_sell_through_pct/100 (темп распродажи
нежилое). available=False / None (objective ~ жильё, тонко) None (НЕ 0-как-заглушка). нежилого, sell-through, прокси спроса). available=False / None (objective ~ жильё, тонко)
confidence наследуется из сигнала (или 'low'). None (НЕ 0-как-заглушка). confidence наследуется из сигнала (или 'low').
""" """
if not commercial or not commercial.get("available"): if not commercial or not commercial.get("available"):
return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)." return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)."
share_pct = commercial.get("commercial_share_pct") sell_through_pct = commercial.get("commercial_sell_through_pct")
if share_pct is None: if sell_through_pct is None:
return None, "low", "Коммерческий сигнал §10.4 недоступен (доля не измерена)." return None, "low", "Коммерческий сигнал §10.4 недоступен (темп распродажи не измерен)."
raw_conf = commercial.get("confidence", "low") raw_conf = commercial.get("confidence", "low")
conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low" conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low"
value = _clamp01(float(share_pct) / 100.0) value = _clamp01(float(sell_through_pct) / 100.0)
reason = f"Реализованная доля коммерции ~{round(float(share_pct), 1)}% (§10.4, прокси спроса)." reason = (
f"Темп распродажи нежилого ~{round(float(sell_through_pct), 1)}% (§10.4, прокси "
"ликвидности/спроса на нежилое; НЕ доля нежилого в объёме застройки)."
)
return value, conf, reason return value, conf, reason

View file

@ -28,9 +28,17 @@ LIVE-ENDPOINT-SAFE: НИКОГДА не бросает — на любом то
mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты
НЕизмеримы считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default НЕизмеримы считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default
room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace
(0,1] это ПРОКСИ относительной силы спроса, НЕ supply-based дефицит; (0,1]; `balance_units=None`, confidence='low', обязательный warning что supply
`balance_units=None`, confidence='low', обязательный warning что supply НЕ учтён. НЕ учтён. НИКОГДА не фабрикуем предложение/конкурентов из воздуха.
НИКОГДА не фабрикуем предложение/конкурентов из воздуха. ВАЖНО (#1593): сегодня §9.2 unit_velocity (base_pace) и §9.4 norm.coefficient
ОДИНАКОВЫ для всех 5 room-bucket'ов (market_metrics.compute_market_metrics не
принимает room_bucket, а §9.4-β фитится district×obj_class без room_bucket),
поэтому оба постоянных множителя СОКРАЩАЮТСЯ в pace/max_pace и порядок форматов
определяется ИСКЛЮЧИТЕЛЬНО §9.5 macro_coef = segment_steepness(room_bucket).
Т.е. фактический deficit_index в demand_only = ПРОКСИ rate-крутизны нарезки, а
НЕ относительной силы спроса. Честный per-bucket §9.2/§9.4-сигнал требует
room_bucket-параметра в market_metrics + §9.4/§9.6 (другие файлы + доменное
решение, как мерить per-bucket спрос) см. #1593.
ИМПОРТЫ §9.x ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет ИМПОРТЫ §9.x ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет
`forecasting/__init__` тянет `affordability`, который импортит `forecasting/__init__` тянет `affordability`, который импортит
@ -565,9 +573,17 @@ def _demand_only_overlay(
БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса
pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient
per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace
(0,1] ПРОКСИ относительной силы спроса (НЕ supply-based дефицит). (0,1]. balance_units=None, confidence='low', обязательный warning. НИКОГДА не
balance_units=None, confidence='low', обязательный warning. НИКОГДА не
фабрикуем supply. Graceful []. фабрикуем supply. Graceful [].
ВАЖНО (#1593, честность ранкинга): §9.2 base_pace и §9.4 norm.coefficient
сейчас ОДИНАКОВЫ для всех bucket'ов (нет room_bucket-фильтра в
market_metrics.compute_market_metrics; §9.4-β фитится district×obj_class без
room_bucket) и СОКРАЩАЮТСЯ в pace/max_pace. Поэтому фактический порядок
форматов и deficit_index определяются ИСКЛЮЧИТЕЛЬНО §9.5 macro_coef =
segment_steepness(room_bucket) это ПРОКСИ rate-крутизны нарезки, НЕ силы
спроса. Полноценный per-bucket §9.2/§9.4-сигнал = другие файлы + доменное
решение (#1593); до тех пор не интерпретировать как «самый дефицитный спрос».
""" """
# Локальные импорты — избегаем import-cycle (см. module docstring). # Локальные импорты — избегаем import-cycle (см. module docstring).
from app.services.forecasting.demand_normalization import compute_demand_normalization from app.services.forecasting.demand_normalization import compute_demand_normalization
@ -640,7 +656,9 @@ def _demand_only_overlay(
demand_only=True, demand_only=True,
) )
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1] (ПРОКСИ, НЕ supply-based). # DESC по темпу; deficit_index = pace/max_pace ∈ (0,1]. NB #1593: base_pace и
# §9.4 norm одинаковы для всех bucket'ов и сокращаются → порядок = §9.5
# segment_steepness (rate-крутизна), НЕ сила спроса. Per-bucket §9.2/§9.4 = TODO.
paces.sort(key=lambda t: t[2], reverse=True) paces.sort(key=lambda t: t[2], reverse=True)
ranked_segments: list[dict[str, Any]] = [ ranked_segments: list[dict[str, Any]] = [
{ {
@ -709,8 +727,9 @@ def _commercial_signal(
"нежилое")`. objective покрывает в основном жильё выборка обычно тонкая. Тогда "нежилое")`. objective покрывает в основном жильё выборка обычно тонкая. Тогда
возвращаем degraded-honest {available: False, caveat, advisory} НЕ фабрикуем число. возвращаем degraded-honest {available: False, caveat, advisory} НЕ фабрикуем число.
Если данных достаточно ( _COMMERCIAL_MIN_LOTS лотов) советующая оценка ТЕМПА Если данных достаточно ( _COMMERCIAL_MIN_LOTS лотов) советующая оценка ТЕМПА
РАСПРОДАЖИ нежилого стока (sell_through_pct = проданные ÷ (проданные+доступные)·100, РАСПРОДАЖИ нежилого: sell_through_pct = проданные ÷ (проданные+доступные)·100
прокси ликвидности/спроса НЕ доля нежилого в объёме застройки) + §16-подобный reason. (market_metrics §-определение) прокси ликвидности/спроса на нежилое, НЕ доля
нежилого в объёме застройки + §16-подобный reason.
НИКОГДА не бросает: любой сбой движка/импорта degraded-honest None-сигнал. НИКОГДА не бросает: любой сбой движка/импорта degraded-honest None-сигнал.
Args: Args:
@ -753,16 +772,16 @@ def _commercial_signal(
) )
return {"available": False, "caveat": caveat, "advisory": True} return {"available": False, "caveat": caveat, "advisory": True}
# Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (sell_through_pct # Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (прокси
# = проданные ÷ (проданные+доступные)·100 — ликвидность/спрос, НЕ доля застройки). # ликвидности/спроса), НЕ доли нежилого в объёме застройки (#1635).
# NB: ключ commercial_share_pct мислейблит метрику; честное переименование требует
# согласованной правки product_scoring._score_commercial (другой файл) → не трогаем.
sell_through_pct = round(sell_through, 1) sell_through_pct = round(sell_through, 1)
confidence = confidence if confidence in ("high", "medium", "low") else "low" confidence = confidence if confidence in ("high", "medium", "low") else "low"
return { return {
"available": True, "available": True,
"premise_kind": _COMMERCIAL_PREMISE_KIND, "premise_kind": _COMMERCIAL_PREMISE_KIND,
"commercial_share_pct": sell_through_pct, # #1635: ключ = ТЕМП РАСПРОДАЖИ нежилого (sell_through, прокси
# ликвидности/спроса), НЕ доля нежилого в объёме застройки.
"commercial_sell_through_pct": sell_through_pct,
"n_lots": n_lots, "n_lots": n_lots,
"confidence": confidence, "confidence": confidence,
"reason": { "reason": {
@ -790,14 +809,14 @@ def _as_int(value: Any) -> int | None:
Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой
нечисловой/bool/сбойный вход None degraded-honest путь, без падения. нечисловой/bool/сбойный вход None degraded-honest путь, без падения.
""" """
if isinstance(value, bool) or not isinstance(value, (int, float)): if isinstance(value, bool) or not isinstance(value, int | float):
return None return None
return int(value) return int(value)
def _as_float(value: Any) -> float | None: def _as_float(value: Any) -> float | None:
"""Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE.""" """Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE."""
if isinstance(value, bool) or not isinstance(value, (int, float)): if isinstance(value, bool) or not isinstance(value, int | float):
return None return None
return float(value) return float(value)

View file

@ -87,41 +87,51 @@ _PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)") _PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
# Email. # Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b") _EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН-кандидат: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим # ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
# цифрам. ВНИМАНИЕ: regex здесь — лишь поиск кандидата. Голый «N{10}|N{12}» ловил бы и # ВНИМАНИЕ: regex лишь НАХОДИТ кандидатов; редактируется только то, что проходит
# легитимные слитные суммы (1 млрд+ руб = 10 цифр, ~трлн = 12) и молча затирал бы их # контроль-сумму ФНС (см. _inn_checksum_valid + _inn_repl). Это сделано, чтобы
# до отправки в LLM (#1640). Поэтому замена проходит ТОЛЬКО если кандидат проходит # легитимные слитные суммы в рублях (1_000_000_000, 123456789012) НЕ затирались
# официальную проверку контрольных цифр ИНН (_inn_checksum_valid) — см. _inn_repl. # маркером [REDACTED:inn] перед отправкой в LLM (#1640).
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)") _INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
def _inn_checksum_valid(digits: str) -> bool: def _inn_checksum_valid(token: str) -> bool:
"""Проверить контрольные цифры ИНН (официальный алгоритм ФНС). """Проверить контрольные цифры ИНН по алгоритму ФНС.
10-значный (юрлицо): одна контрольная цифра. 12-значный (физлицо/ИП): две. 10-значный (юрлицо): одна контрольная цифра (последняя).
Вес-коэффициенты фиксированы стандартом. Любая слитная группа 10/12 цифр, НЕ 12-значный (физлицо/ИП): две контрольные цифры (11-я и 12-я).
проходящая checksum (напр. круглая сумма «1000000000»), считается НЕ-ИНН и не Веса стандартные коэффициенты ФНС.
редактируется это и закрывает ложные срабатывания #1640.
NB: checksum резко снижает false-positive rate, но не доводит его до нуля Назначение сузить вторичный regex-scrub так, чтобы случайные слитные
~1/11 случайных 10-значных чисел совпадает с валидным ИНН по контрольной цифре. 10/12-значные числа (крупные суммы в рублях) НЕ принимались за ИНН (#1640).
Это приемлемо для вторичной (belt-and-suspenders) защиты.
Ограничение (accepted limit belt-and-suspenders слоя): контроль-сумма НЕ
разрешает полностью неоднозначность «сумма vs ИНН» примерно 1/11 произвольных
10-значных чисел случайно проходит ИНН-10 контроль (в т.ч. буквальный пример
аудита 1200000000, который остаётся под редактированием). Полное устранение
false-positive на суммах (требовать рядом якорь «ИНН» либо вовсе убрать
INN-scrub из chat free-text) бизнес-решение, оставлено на Leha.
""" """
digits = [int(c) for c in token]
if len(digits) == 10: if len(digits) == 10:
weights = (2, 4, 10, 3, 5, 9, 4, 6, 8) weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
control = sum(int(digits[i]) * weights[i] for i in range(9)) % 11 % 10 control = sum(w * d for w, d in zip(weights, digits[:9], strict=True)) % 11 % 10
return control == int(digits[9]) return control == digits[9]
if len(digits) == 12: if len(digits) == 12:
w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8) w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
c1 = sum(int(digits[i]) * w1[i] for i in range(10)) % 11 % 10 c1 = sum(w * d for w, d in zip(w1, digits[:10], strict=True)) % 11 % 10
c2 = sum(int(digits[i]) * w2[i] for i in range(11)) % 11 % 10 c2 = sum(w * d for w, d in zip(w2, digits[:11], strict=True)) % 11 % 10
return c1 == int(digits[10]) and c2 == int(digits[11]) return c1 == digits[10] and c2 == digits[11]
return False return False
def _inn_repl(match: re.Match[str]) -> str: def _inn_repl(match: re.Match[str]) -> str:
"""re.sub-callback: редактировать кандидат ТОЛЬКО если checksum валиден.""" """re.sub callback: редактировать ИНН-кандидата только при валидной контроль-сумме.
Иначе вернуть исходный токен без изменений так слитные суммы в рублях
переживают scrub (#1640).
"""
token = match.group(0) token = match.group(0)
return "[REDACTED:inn]" if _inn_checksum_valid(token) else token return "[REDACTED:inn]" if _inn_checksum_valid(token) else token
@ -191,7 +201,7 @@ def _scrub_value(value: Any) -> Any:
return scrub_text(value) return scrub_text(value)
if isinstance(value, dict): if isinstance(value, dict):
return {k: _scrub_value(v) for k, v in value.items()} return {k: _scrub_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)): if isinstance(value, list | tuple):
scrubbed = [_scrub_value(v) for v in value] scrubbed = [_scrub_value(v) for v in value]
return type(value)(scrubbed) return type(value)(scrubbed)
return value return value

View file

@ -37,14 +37,24 @@ def make_thumbnail(
) -> Path | None: ) -> Path | None:
"""Generate a WebP cover-thumbnail next to src. Returns thumb path on success. """Generate a WebP cover-thumbnail next to src. Returns thumb path on success.
Skips if thumb already exists and overwrite=False. Returns None on any error Skips if thumb already exists, is not stale (dst mtime >= src mtime) and
overwrite=False. A stale thumb (src rewritten after thumb, e.g. original
re-downloaded on size mismatch) is regenerated. Returns None on any error
(logged) so callers can keep going with the next file. (logged) so callers can keep going with the next file.
""" """
if not src.exists(): if not src.exists():
return None return None
dst = thumb_path_for(src) dst = thumb_path_for(src)
if dst.exists() and not overwrite: if dst.exists() and not overwrite:
return dst # Existence alone is not freshness: if src was rewritten after dst was
# generated, the thumb is stale and must be regenerated. Only skip when
# the thumb is at least as new as the source.
try:
if dst.stat().st_mtime >= src.stat().st_mtime:
return dst
except OSError as e:
# stat failed (race / removed) — fall through and try to regenerate.
logger.warning("thumbnail freshness check %s failed: %s", src, e)
try: try:
with Image.open(src) as im: with Image.open(src) as im:
im = ImageOps.exif_transpose(im) im = ImageOps.exif_transpose(im)

View file

@ -409,7 +409,7 @@ class TestScoreDifferentiation:
class TestScoreCommercial: class TestScoreCommercial:
def test_available_share_scaled(self) -> None: def test_available_share_scaled(self) -> None:
signal = {"available": True, "commercial_share_pct": 40.0, "confidence": "medium"} signal = {"available": True, "commercial_sell_through_pct": 40.0, "confidence": "medium"}
value, conf, _r = _score_commercial(signal) value, conf, _r = _score_commercial(signal)
assert value == pytest.approx(0.4) assert value == pytest.approx(0.4)
assert conf == "medium" assert conf == "medium"
@ -417,7 +417,7 @@ class TestScoreCommercial:
def test_in_range(self) -> None: def test_in_range(self) -> None:
for share in (0.0, 25.0, 100.0): for share in (0.0, 25.0, 100.0):
value, _c, _r = _score_commercial( value, _c, _r = _score_commercial(
{"available": True, "commercial_share_pct": share, "confidence": "low"} {"available": True, "commercial_sell_through_pct": share, "confidence": "low"}
) )
assert value is not None assert value is not None
assert 0.0 <= value <= 1.0 assert 0.0 <= value <= 1.0
@ -439,7 +439,7 @@ class TestScoreCommercial:
def test_bad_confidence_falls_back_low(self) -> None: def test_bad_confidence_falls_back_low(self) -> None:
value, conf, _r = _score_commercial( value, conf, _r = _score_commercial(
{"available": True, "commercial_share_pct": 10.0, "confidence": "garbage"} {"available": True, "commercial_sell_through_pct": 10.0, "confidence": "garbage"}
) )
assert value is not None assert value is not None
assert conf == "low" assert conf == "low"
@ -728,7 +728,7 @@ def _patch_all(
overlay: Any = None, overlay: Any = None,
) -> Any: ) -> Any:
"""Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом).""" """Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом)."""
commercial = {"available": True, "commercial_share_pct": 30.0, "confidence": "medium"} commercial = {"available": True, "commercial_sell_through_pct": 30.0, "confidence": "medium"}
forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)] forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)]
competitors_rv = ( competitors_rv = (
competitors if competitors is not None else _competitors_response_stub([0.4, 0.2]) competitors if competitors is not None else _competitors_response_stub([0.4, 0.2])

View file

@ -744,7 +744,7 @@ class TestCommercialSignal:
out = _commercial_signal(MagicMock(), "Ленинский", 12) out = _commercial_signal(MagicMock(), "Ленинский", 12)
assert out is not None assert out is not None
assert out["available"] is True assert out["available"] is True
assert out["commercial_share_pct"] == 42.5 assert out["commercial_sell_through_pct"] == 42.5
assert out["n_lots"] == 120 assert out["n_lots"] == 120
assert out["confidence"] == "medium" assert out["confidence"] == "medium"
assert out["reason"]["advisory"] is True assert out["reason"]["advisory"] is True

View file

@ -0,0 +1,68 @@
# `/__preview/*` — mock-render маршруты для UI-аудита (issue #801)
Dev/CI-only страницы, которые рендерят «тяжёлый» UI на **статичных fixture**
без единого сетевого запроса. Нужны потому, что live-фронт за auth-gate (Caddy
basic_auth), а dev без бэкенда отвечает `/api/v1/me` + `/estimate` 401 /
network-fail → реальные страницы рендерят пустой EmptyState / NoAccessScreen.
axe / lighthouse требуют **reachable rendered content**его и даёт preview.
## Маршруты
| URL | Рендерит | Fixture |
|---|---|---|
| `/__preview/estimate` | `<EstimateResult>` — HeroSummary + price-range bar + таблицы аналогов/сделок («денежный экран» Trade-In) | `src/lib/mocks/trade-in-estimate.ts` |
> Folder назван `%5F%5Fpreview` (URL-encoded `__preview`): App Router исключает
> из роутинга папки с leading `_`, поэтому underscore-сегмент задаётся `%5F`.
## Гарды (preview не утекает в prod)
1. **Страница** 404'ит (`notFound()`), если не выполнено
`NODE_ENV !== "production"` **или** `NEXT_PUBLIC_ENABLE_PREVIEW=true`.
В `next dev` доступна всегда; в собранном prod-build — только при явном флаге.
2. **RouteGuard** (`components/auth/RouteGuard.tsx`) пропускает `/__preview/*`
без вызова `/me`, иначе network-fail подменил бы content на `<NoAccessScreen>`.
## Локальный прогон аудита
```bash
# 1. Поднять preview без бэкенда (NODE_ENV=development → preview включён)
cd frontend && npm run dev # http://localhost:3000/__preview/estimate
# 2a. axe (a11y) — через @axe-core/cli или @axe-core/playwright
npx @axe-core/cli http://localhost:3000/__preview/estimate
# 2b. lighthouse (CWV: LCP / CLS / TBT)
npx lighthouse http://localhost:3000/__preview/estimate \
--only-categories=performance,accessibility \
--preset=desktop --output=json --output-path=./lh-estimate.json
```
Для собранного build вместо `dev`:
```bash
NEXT_PUBLIC_ENABLE_PREVIEW=true npm run build && \
NEXT_PUBLIC_ENABLE_PREVIEW=true npm run start
```
## Baseline-метрики (заполнить после первого реального прогона)
Зафиксировать здесь, чтобы ловить регрессии (DoD #801). Прочерки — TODO
followup-issue: реальный axe/lighthouse прогон.
| Метрика | Baseline | Дата | Порог регрессии |
|---|---|---|---|
| axe violations (critical/serious) | _TODO_ | — | 0 critical |
| lighthouse a11y score | _TODO_ | — | ≥ 90 |
| LCP (desktop) | _TODO_ | — | ≤ 2.5 s |
| CLS | _TODO_ | — | ≤ 0.1 |
> Известные кандидаты-дефекты для followup-issue (UI-аудит 2026-05-30, #559):
> контраст `text-xs` / `#9ca3af` на белом, ARIA на sortable `<th>` таблиц
> (`AnalogsTable`), LCP/CLS от Hero + фото-ячеек.
## CI (опционально — вне scope #801, отдельный followup)
Деп `@axe-core/playwright` + `lighthouse` не добавлены в `package.json` (правило:
изменение deps требует regen lockfile, иначе deploy aborts). Интеграция в
`.forgejo/workflows/ci.yml` — отдельная задача для devops-engineer: поднять
`npm run dev`, прогнать команды выше, fail job при превышении порогов.

View file

@ -0,0 +1,73 @@
/**
* `/__preview/estimate` dev/CI-only mock-render «денежного экрана» (issue #801).
*
* Зачем: без бэкенда `/api/v1/me` + `POST /estimate` отвечают 401 / network-fail,
* `/trade-in` рендерит пустой EmptyState axe / lighthouse нечего аудировать
* (нет reachable rendered content). Этот маршрут рендерит реальный
* `<EstimateResult>` (HeroSummary + price-range bar + таблицы аналогов/сделок)
* на статичном fixture БЕЗ единого сетевого запроса.
*
* Folder назван `%5F%5Fpreview` (URL-encoded `__preview`): App Router исключает
* из роутинга папки с leading `_` (private folders), поэтому литеральный
* underscore-сегмент задаётся через `%5F`. Итоговый URL `/__preview/estimate`.
*
* Production guard: страница 404'ит (`notFound()`) если `NEXT_PUBLIC_ENABLE_PREVIEW`
* не выставлен в `"true"`. В dev (`next dev`) preview включён всегда. Так
* preview-bundle не утекает в публичный prod (live за auth-gate Caddy).
* RouteGuard дополнительно пропускает `/__preview/*` без вызова `/me`
* (см. `components/auth/RouteGuard.tsx`).
*/
import { notFound } from "next/navigation";
import { EstimateResult } from "@/components/trade-in/EstimateResult";
import {
MOCK_ESTIMATE,
MOCK_ESTIMATE_INPUT,
} from "@/lib/mocks/trade-in-estimate";
/** Preview доступен в dev всегда; в собранном build — только при явном флаге. */
function previewEnabled(): boolean {
return (
process.env.NODE_ENV !== "production" ||
process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "true"
);
}
export default function PreviewEstimatePage() {
if (!previewEnabled()) {
notFound();
}
return (
<main
style={{
minHeight: "100vh",
background: "#f8f9fb",
padding: "24px 16px",
boxSizing: "border-box",
}}
>
<div style={{ maxWidth: 1280, margin: "0 auto 20px" }}>
<h1
style={{
margin: "0 0 4px",
fontSize: 22,
fontWeight: 800,
color: "#1a1d23",
}}
>
Trade-In Estimator
</h1>
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>
Превью результата оценки на статичных данных (без бэкенда) для
a11y / lighthouse аудита.
</p>
</div>
<div style={{ maxWidth: 1280, margin: "0 auto" }}>
<EstimateResult estimate={MOCK_ESTIMATE} input={MOCK_ESTIMATE_INPUT} />
</div>
</main>
);
}

View file

@ -35,6 +35,21 @@ export function RouteGuard({ children }: RouteGuardProps) {
const pathname = usePathname() ?? "/"; const pathname = usePathname() ?? "/";
const { data, isLoading, error } = useMe(); const { data, isLoading, error } = useMe();
// #801: dev/CI-only mock-render маршрут `/__preview/*` — намеренно БЕЗ
// auth-gate. Без этого bypass `useMe()` без бэкенда падает network-fail'ом
// → ветка `if (error)` ниже подменяет content на <NoAccessScreen>, и
// axe / lighthouse аудируют экран «доступа нет» вместо «денежного экрана».
// Сама preview-страница 404'ит вне dev/CI (NEXT_PUBLIC_ENABLE_PREVIEW),
// и сам bypass дополнительно gated тем же env-условием — чтобы будущий
// `/__preview/*` route не оказался без auth в prod (defense-in-depth).
if (
pathname.startsWith("/__preview") &&
(process.env.NODE_ENV !== "production" ||
process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "true")
) {
return <>{children}</>;
}
// Initial load — короткое окно, рендерим пустоту чтобы не моргать UI. // Initial load — короткое окно, рендерим пустоту чтобы не моргать UI.
if (isLoading) return null; if (isLoading) return null;

View file

@ -0,0 +1,154 @@
/**
* Trade-In estimate fixture статичные данные «денежного экрана» для
* mock-render маршрута `/__preview/estimate` (issue #801).
*
* Зачем: без бэкенда `/api/v1/me` + `/estimate` отвечают 401/network-fail и
* `/trade-in` рендерит пустой EmptyState нечего скормить axe / lighthouse.
* Этот fixture даёт реалистичный AggregatedEstimate (HeroSummary + две таблицы
* аналогов/сделок + price-range bar) для прогона a11y + CWV без сети.
*
* Контракт строго по `@/types/trade-in` (TS strict, без any). Если backend
* Pydantic-схема изменится `npm run codegen` + поправить типы этот fixture
* упадёт на type-check, что и нужно (early signal о contract drift).
*
* НЕ импортировать в prod-bundle: используется только preview-страницей,
* которая сама 404'ит вне dev/CI (см. `app/%5F%5Fpreview/estimate/page.tsx`).
*/
import type {
AggregatedEstimate,
AnalogLot,
TradeInEstimateInput,
} from "@/types/trade-in";
/** Параметры оцениваемой квартиры (левая колонка / блок «Параметры объекта»). */
export const MOCK_ESTIMATE_INPUT: TradeInEstimateInput = {
address: "Екатеринбург, ул. Малышева, 4Б",
area_m2: 58.4,
rooms: 2,
floor: 7,
total_floors: 16,
year_built: 2018,
house_type: "monolith_brick",
repair_state: "good",
has_balcony: true,
};
/** Объявления-аналоги (top-аналоги в радиусе/выборке). */
const MOCK_ANALOGS: AnalogLot[] = [
{
address: "Екатеринбург, ул. Малышева, 8",
area_m2: 56.2,
rooms: 2,
floor: 9,
total_floors: 16,
price_rub: 9_650_000,
price_per_m2: 171_708,
listing_date: "2026-05-12",
days_on_market: 34,
photo_url: null,
},
{
address: "Екатеринбург, ул. Радищева, 33",
area_m2: 60.1,
rooms: 2,
floor: 5,
total_floors: 12,
price_rub: 10_200_000,
price_per_m2: 169_717,
listing_date: "2026-04-28",
days_on_market: 48,
photo_url: null,
},
{
address: "Екатеринбург, ул. 8 Марта, 21",
area_m2: 54.8,
rooms: 2,
floor: 11,
total_floors: 18,
price_rub: 9_280_000,
price_per_m2: 169_343,
listing_date: "2026-05-30",
days_on_market: 16,
photo_url: null,
},
{
address: "Екатеринбург, ул. Хохрякова, 74",
area_m2: 61.5,
rooms: 2,
floor: 3,
total_floors: 10,
price_rub: 10_750_000,
price_per_m2: 174_797,
listing_date: "2026-03-19",
days_on_market: 88,
photo_url: null,
},
{
address: "Екатеринбург, ул. Шейнкмана, 90",
area_m2: 57.0,
rooms: 2,
floor: 14,
total_floors: 25,
price_rub: 9_900_000,
price_per_m2: 173_684,
listing_date: "2026-05-02",
days_on_market: 41,
photo_url: null,
},
];
/** Фактические сделки (Росреестр, последние 12 мес). */
const MOCK_DEALS: AnalogLot[] = [
{
address: "Екатеринбург, ул. Малышева, 12",
area_m2: 57.8,
rooms: 2,
floor: 8,
total_floors: 16,
price_rub: 9_400_000,
price_per_m2: 162_630,
listing_date: "2026-02-14",
days_on_market: null,
photo_url: null,
},
{
address: "Екатеринбург, ул. Энгельса, 36",
area_m2: 59.3,
rooms: 2,
floor: 6,
total_floors: 14,
price_rub: 9_750_000,
price_per_m2: 164_418,
listing_date: "2025-12-03",
days_on_market: null,
photo_url: null,
},
{
address: "Екатеринбург, ул. Куйбышева, 48",
area_m2: 55.6,
rooms: 2,
floor: 4,
total_floors: 9,
price_rub: 8_950_000,
price_per_m2: 160_971,
listing_date: "2025-11-21",
days_on_market: null,
photo_url: null,
},
];
/** Готовый AggregatedEstimate — то, что вернул бы POST /estimate. */
export const MOCK_ESTIMATE: AggregatedEstimate = {
estimate_id: "00000000-0000-4000-8000-000000000801",
median_price_rub: 9_780_000,
range_low_rub: 9_120_000,
range_high_rub: 10_540_000,
median_price_per_m2: 167_466,
confidence: "high",
n_analogs: MOCK_ANALOGS.length,
period_months: 24,
analogs: MOCK_ANALOGS,
actual_deals: MOCK_DEALS,
expires_at: "2099-12-31T23:59:59Z",
};