diff --git a/backend/alembic/env.py b/backend/alembic/env.py
index 3b61fbfb..09bd58e7 100644
--- a/backend/alembic/env.py
+++ b/backend/alembic/env.py
@@ -5,14 +5,13 @@ from logging.config import fileConfig
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 app.core.config import settings
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 parcel # noqa: F401
-
config = context.config
# Inject runtime DB URL.
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index e69de29b..ce2a7c55 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -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"]
diff --git a/backend/app/models/parcel.py b/backend/app/models/parcel.py
index 048a0046..99a86ad2 100644
--- a/backend/app/models/parcel.py
+++ b/backend/app/models/parcel.py
@@ -1,29 +1,15 @@
-"""SQLAlchemy + GeoAlchemy2 ORM models.
+"""Parcel ORM placeholder.
-Stage 2a: real Parcel model. Geometry stored in WGS84 (EPSG:4326);
-project to МСК-66 via pyproj when computing distances/areas.
+The legacy `Parcel` model (`__tablename__ = "parcels"`) was removed: nothing in
+the app imports it, and real cadastral data lives in raw-SQL tables
+`cad_parcels` / `cad_parcels_geom` (see data/sql/92_cad_bulk_layers.sql,
+data/sql/83_cad_parcels_geom.sql), not in a `parcels` table. Keeping the model
+on Base.metadata made `alembic revision --autogenerate` emit a phantom
+`CREATE TABLE parcels` for a table that must never exist (issue #1569).
+
+No ORM model is defined here. `Base` is re-exported for import continuity.
"""
-from datetime import datetime
-
-from geoalchemy2 import Geometry
-from sqlalchemy import JSON, DateTime, Float, String
-from sqlalchemy.orm import Mapped, mapped_column
-
from app.core.db import Base
-
-class Parcel(Base):
- __tablename__ = "parcels"
-
- id: Mapped[str] = mapped_column(String, primary_key=True)
- cadastral_number: Mapped[str] = mapped_column(String, unique=True, index=True)
- vri: Mapped[str] = mapped_column(String, index=True)
- area_sqm: Mapped[float] = mapped_column(Float)
- address: Mapped[str | None] = mapped_column(String, nullable=True)
- geometry: Mapped[object] = mapped_column(Geometry("POLYGON", srid=4326))
- enrichment: Mapped[dict] = mapped_column(JSON, default=dict)
- created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
- updated_at: Mapped[datetime] = mapped_column(
- DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
- )
+__all__ = ["Base"]
diff --git a/backend/app/services/exporters/report_md.py b/backend/app/services/exporters/report_md.py
index 5c8f67bc..2f9caeaa 100644
--- a/backend/app/services/exporters/report_md.py
+++ b/backend/app/services/exporters/report_md.py
@@ -39,6 +39,7 @@ from app.services.exporters.report_pdf import (
_future_supply_pairs,
_level_ru,
_normalize,
+ _scenario_deficit_horizon,
_scenario_deficit_index,
)
@@ -243,6 +244,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:
"""§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
scenarios = _as_dict(report.get("scenarios"))
@@ -256,7 +273,7 @@ def _build_scenarios(report: dict[str, Any]) -> str:
", ".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")]
+ [name, _scenario_deficit_cell(data), rate_str, data.get("advisory")]
)
headers = [
@@ -444,7 +461,9 @@ def _tg_scenario_deficit_line(report: dict[str, Any]) -> str | None:
parts: list[str] = []
for name in ordered:
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)}")
return " · ".join(parts) if parts else None
@@ -504,8 +523,20 @@ def render_report_telegram_summary(report: Any) -> str:
# Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии).
scenario_line = _tg_scenario_deficit_line(data)
if scenario_line is not None:
- horizons = _as_list(meta.get("horizons"))
- horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if horizons else ""
+ # Метку «(гор. 12 мес)» ставим, только если ВСЕ выведенные дефициты реально взяты
+ # с основного 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}")
# Рекомендованный класс продукта (если задан).
diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py
index 538b245d..f6f74822 100644
--- a/backend/app/services/exporters/report_pdf.py
+++ b/backend/app/services/exporters/report_pdf.py
@@ -476,6 +476,49 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
return None
+def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None:
+ """Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE.
+
+ Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес → первый с не-None
+ дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала
+ при fallback на чужой горизонт. Нет дефицита → None. NB: сигнатура
+ `_scenario_deficit_index` (импортируется в report_md/docx/pptx) НЕ меняется — это
+ отдельная чистая функция-зеркало.
+ """
+ forecasts = _as_list(payload.get("forecasts"))
+ primary = next(
+ (
+ f
+ for f in forecasts
+ if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
+ ),
+ None,
+ )
+ if primary is not None and primary.get("deficit_index") is not None:
+ return _PRIMARY_HORIZON_MONTHS
+ for f in forecasts:
+ if isinstance(f, dict) and f.get("deficit_index") is not None:
+ h = f.get("horizon_months")
+ return h if isinstance(h, int) else 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:
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
scenarios = _as_dict(report.get("scenarios"))
@@ -484,9 +527,9 @@ def _build_scenarios(report: dict[str, Any]) -> str:
rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
- rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
+ rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
- headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
+ headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
return f"""
{html.escape(_TITLE_SCENARIOS)}
diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py
index fb06020b..3d6aff23 100644
--- a/backend/app/services/forecasting/recommendation.py
+++ b/backend/app/services/forecasting/recommendation.py
@@ -28,9 +28,17 @@ LIVE-ENDPOINT-SAFE: НИКОГДА не бросает — на любом то
• mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты
НЕизмеримы → считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default
room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace ∈
- (0,1] — это ПРОКСИ относительной силы спроса, НЕ supply-based дефицит;
- `balance_units=None`, confidence='low', обязательный warning что supply НЕ учтён.
- НИКОГДА не фабрикуем предложение/конкурентов из воздуха.
+ (0,1]; `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: пакет
`forecasting/__init__` тянет `affordability`, который импортит
@@ -565,9 +573,17 @@ def _demand_only_overlay(
БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса
pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient
per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace ∈
- (0,1] — ПРОКСИ относительной силы спроса (НЕ supply-based дефицит).
- balance_units=None, confidence='low', обязательный warning. НИКОГДА не
+ (0,1]. balance_units=None, confidence='low', обязательный warning. НИКОГДА не
фабрикуем 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).
from app.services.forecasting.demand_normalization import compute_demand_normalization
@@ -635,7 +651,9 @@ def _demand_only_overlay(
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)
ranked_segments: list[dict[str, Any]] = [
{
@@ -698,13 +716,15 @@ def _overlay(
def _commercial_signal(
db: Session, district: str | None, horizon_months: int
) -> dict[str, Any] | None:
- """§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful.
+ """§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest.
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
"нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда
возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число.
- Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка доли
- коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason.
+ Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка ТЕМПА
+ РАСПРОДАЖИ нежилого: sell_through_pct = проданные ÷ (проданные+доступные)·100
+ (market_metrics §-определение) — прокси ликвидности/спроса на нежилое, НЕ доля
+ нежилого в объёме застройки + §16-подобный reason.
НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал.
Args:
@@ -747,22 +767,29 @@ def _commercial_signal(
)
return {"available": False, "caveat": caveat, "advisory": True}
- # Достаточно данных: советующая оценка реализованной доли коммерции (прокси).
- share_pct = round(sell_through, 1)
+ # Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (прокси
+ # ликвидности/спроса), НЕ доли нежилого в объёме застройки (#1635).
+ sell_through_pct = round(sell_through, 1)
confidence = confidence if confidence in ("high", "medium", "low") else "low"
return {
"available": True,
"premise_kind": _COMMERCIAL_PREMISE_KIND,
- "commercial_share_pct": share_pct,
+ # NB #1635: ключ `commercial_share_pct` — МИСЛЕЙБЛ (величина = темп
+ # распродажи sell_through, а не доля нежилого в объёме). Это контракт-ключ,
+ # читаемый product_scoring._score_commercial (product_scoring.py:474, чей
+ # reason на :480 повторяет тот же мислейбл) — переименование в
+ # commercial_sell_through_pct cross-file, координировать с product_scoring.py.
+ "commercial_share_pct": sell_through_pct,
"n_lots": n_lots,
"confidence": confidence,
"reason": {
"why": (
- f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} "
- f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)."
+ f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} "
+ f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на "
+ f"нежилые помещения, НЕ доля нежилого в объёме застройки)."
),
"drivers": [
- {"factor": "sell_through_pct", "value": share_pct, "direction": "+"},
+ {"factor": "sell_through_pct", "value": sell_through_pct, "direction": "+"},
{"factor": "n_lots", "value": n_lots, "direction": "+"},
],
"rejected": [],
diff --git a/backend/app/services/llm/redaction.py b/backend/app/services/llm/redaction.py
index e2ffcef0..fddde4ba 100644
--- a/backend/app/services/llm/redaction.py
+++ b/backend/app/services/llm/redaction.py
@@ -24,6 +24,7 @@ from __future__ import annotations
import logging
import re
+from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
@@ -80,7 +81,54 @@ _PHONE_BARE_RE = re.compile(r"(? bool:
+ """Проверить контрольные цифры ИНН по алгоритму ФНС.
+
+ 10-значный (юрлицо): одна контрольная цифра (последняя).
+ 12-значный (физлицо/ИП): две контрольные цифры (11-я и 12-я).
+ Веса — стандартные коэффициенты ФНС.
+
+ Назначение — сузить вторичный regex-scrub так, чтобы случайные слитные
+ 10/12-значные числа (крупные суммы в рублях) НЕ принимались за ИНН (#1640).
+
+ Ограничение (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:
+ weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
+ control = sum(w * d for w, d in zip(weights, digits[:9], strict=True)) % 11 % 10
+ return control == digits[9]
+ if len(digits) == 12:
+ w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
+ w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
+ c1 = sum(w * d for w, d in zip(w1, digits[:10], strict=True)) % 11 % 10
+ c2 = sum(w * d for w, d in zip(w2, digits[:11], strict=True)) % 11 % 10
+ return c1 == digits[10] and c2 == digits[11]
+ return False
+
+
+def _inn_repl(match: re.Match[str]) -> str:
+ """re.sub callback: редактировать ИНН-кандидата только при валидной контроль-сумме.
+
+ Иначе вернуть исходный токен без изменений — так слитные суммы в рублях
+ переживают scrub (#1640).
+ """
+ token = match.group(0)
+ return "[REDACTED:inn]" if _inn_checksum_valid(token) else token
+
+
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
@@ -97,18 +145,20 @@ _FULLNAME_RE = re.compile(
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
)
-# (regex, placeholder-kind). Применяются последовательно в этом порядке.
+# (regex, placeholder-kind, repl). Применяются последовательно в этом порядке.
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
-_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
- (_SNILS_RE, "snils"),
- (_PASSPORT_RE, "passport"),
- (_PHONE_RE, "phone"),
- (_PHONE_BARE_RE, "phone"),
- (_EMAIL_RE, "email"),
- (_INN_RE, "inn"),
- (_SNILS_BARE_RE, "snils"),
- (_FULLNAME_RE, "name"),
+# repl — строка-замена ИЛИ re.sub callback (для ИНН: редактируем только при
+# валидной контроль-сумме, см. _inn_repl, #1640).
+_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, str | Callable[[re.Match[str]], str]], ...] = (
+ (_SNILS_RE, "snils", "[REDACTED:snils]"),
+ (_PASSPORT_RE, "passport", "[REDACTED:passport]"),
+ (_PHONE_RE, "phone", "[REDACTED:phone]"),
+ (_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
+ (_EMAIL_RE, "email", "[REDACTED:email]"),
+ (_INN_RE, "inn", _inn_repl),
+ (_SNILS_BARE_RE, "snils", "[REDACTED:snils]"),
+ (_FULLNAME_RE, "name", "[REDACTED:name]"),
)
@@ -121,9 +171,17 @@ def scrub_text(value: str) -> str:
if not value:
return value
redacted = value
- for pattern, kind in _PII_PATTERNS:
- redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
- if n:
+ for pattern, kind, repl in _PII_PATTERNS:
+ before = redacted
+ redacted = pattern.sub(repl, redacted)
+ if redacted == before:
+ continue
+ # subn для callable-repl считал бы ВСЕ совпадения (включая
+ # незаредактированных ИНН-кандидатов) — переоценка. Считаем фактически
+ # вставленные маркеры по приросту их числа в строке (#1640).
+ marker = f"[REDACTED:{kind}]"
+ n = redacted.count(marker) - before.count(marker)
+ if n > 0:
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
return redacted
diff --git a/backend/app/services/photos/thumbs.py b/backend/app/services/photos/thumbs.py
index 5b79e22e..c613ca80 100644
--- a/backend/app/services/photos/thumbs.py
+++ b/backend/app/services/photos/thumbs.py
@@ -36,14 +36,24 @@ def make_thumbnail(
) -> Path | None:
"""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.
"""
if not src.exists():
return None
dst = thumb_path_for(src)
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:
with Image.open(src) as im:
im = ImageOps.exif_transpose(im)
diff --git a/frontend/src/app/%5F%5Fpreview/README.md b/frontend/src/app/%5F%5Fpreview/README.md
new file mode 100644
index 00000000..40501b5e
--- /dev/null
+++ b/frontend/src/app/%5F%5Fpreview/README.md
@@ -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` | `
` — 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 на ``.
+
+## Локальный прогон аудита
+
+```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 `` таблиц
+> (`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 при превышении порогов.
diff --git a/frontend/src/app/%5F%5Fpreview/estimate/page.tsx b/frontend/src/app/%5F%5Fpreview/estimate/page.tsx
new file mode 100644
index 00000000..be839e01
--- /dev/null
+++ b/frontend/src/app/%5F%5Fpreview/estimate/page.tsx
@@ -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). Этот маршрут рендерит реальный
+ * `` (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 (
+
+
+
+ Trade-In Estimator
+
+
+ Превью результата оценки на статичных данных (без бэкенда) — для
+ a11y / lighthouse аудита.
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/auth/RouteGuard.tsx b/frontend/src/components/auth/RouteGuard.tsx
index 66f8c743..0455faa9 100644
--- a/frontend/src/components/auth/RouteGuard.tsx
+++ b/frontend/src/components/auth/RouteGuard.tsx
@@ -35,6 +35,21 @@ export function RouteGuard({ children }: RouteGuardProps) {
const pathname = usePathname() ?? "/";
const { data, isLoading, error } = useMe();
+ // #801: dev/CI-only mock-render маршрут `/__preview/*` — намеренно БЕЗ
+ // auth-gate. Без этого bypass `useMe()` без бэкенда падает network-fail'ом
+ // → ветка `if (error)` ниже подменяет content на , и
+ // 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.
if (isLoading) return null;
diff --git a/frontend/src/lib/mocks/trade-in-estimate.ts b/frontend/src/lib/mocks/trade-in-estimate.ts
new file mode 100644
index 00000000..a68d43b8
--- /dev/null
+++ b/frontend/src/lib/mocks/trade-in-estimate.ts
@@ -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",
+};
|