Merge pull request 'fix(week-review): дожим — 4 фикса (#1569, #1590, #1642, #801)' (#1662) from fix/week-review-finish into main
All checks were successful
All checks were successful
This commit is contained in:
commit
18da92ccc7
16 changed files with 502 additions and 104 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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 = [
|
||||||
"Сценарий",
|
"Сценарий",
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
# Рекомендованный класс продукта (если задан).
|
# Рекомендованный класс продукта (если задан).
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
68
frontend/src/app/%5F%5Fpreview/README.md
Normal file
68
frontend/src/app/%5F%5Fpreview/README.md
Normal 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 при превышении порогов.
|
||||||
73
frontend/src/app/%5F%5Fpreview/estimate/page.tsx
Normal file
73
frontend/src/app/%5F%5Fpreview/estimate/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
154
frontend/src/lib/mocks/trade-in-estimate.ts
Normal file
154
frontend/src/lib/mocks/trade-in-estimate.ts
Normal 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",
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue