fix(week-review): дожим ревью — #1569, #1590, #1642 + feat #801 MSW-preview
All checks were successful
CI / frontend-tests (pull_request) Successful in 1m2s
CI / openapi-codegen-check (push) Successful in 2m13s
CI / openapi-codegen-check (pull_request) Successful in 1m45s
CI / backend-tests (push) Successful in 9m29s
CI / backend-tests (pull_request) Successful in 9m29s
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (push) Successful in 1m7s

Финиш-волна по cross-file/partial остаткам аудита + a11y-харнесс.

Полностью (4): #1569 alembic регистрирует все ORM-модели (+убран фантомный Parcel) ·
#1590 «Индекс дефицита» больше не лжёт на fallback-горизонте (report_pdf+report_md) ·
#1642 thumbs mtime-freshness (нет устаревшей миниатюры) ·
#801 /__preview/estimate mock-render для axe/lighthouse (env-gated bypass).

Частично (остаток cross-file/домен, открыты): #1593 #1635 #1640.
needs-leha (открыт): #1650 (нужна новая миграция вьюхи supply-слоёв).

Verify: tsc --noEmit 0; py_compile OK.

Closes #1569
Closes #1590
Closes #1642
Closes #801
This commit is contained in:
Light1YT 2026-06-17 11:24:34 +05:00
parent e7901bc1e8
commit 249182b678
12 changed files with 543 additions and 64 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 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

@ -1,29 +1,15 @@
"""SQLAlchemy + GeoAlchemy2 ORM models. """Parcel ORM placeholder.
Stage 2a: real Parcel model. Geometry stored in WGS84 (EPSG:4326); The legacy `Parcel` model (`__tablename__ = "parcels"`) was removed: nothing in
project to МСК-66 via pyproj when computing distances/areas. 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 from app.core.db import Base
__all__ = ["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
)

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,
) )
@ -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: 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"))
@ -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 ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
) )
rows.append( rows.append(
[name, _scenario_deficit_index(data), rate_str, data.get("advisory")] [name, _scenario_deficit_cell(data), rate_str, data.get("advisory")]
) )
headers = [ headers = [
@ -444,7 +461,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
@ -504,8 +523,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

@ -476,6 +476,49 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
return None 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: 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"))
@ -484,9 +527,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)
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""" 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

@ -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
@ -635,7 +651,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]] = [
{ {
@ -698,13 +716,15 @@ def _overlay(
def _commercial_signal( def _commercial_signal(
db: Session, district: str | None, horizon_months: int db: Session, district: str | None, horizon_months: int
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful. """§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest.
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind= Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
"нежилое")`. objective покрывает в основном жильё выборка обычно тонкая. Тогда "нежилое")`. objective покрывает в основном жильё выборка обычно тонкая. Тогда
возвращаем degraded-honest {available: False, caveat, advisory} НЕ фабрикуем число. возвращаем degraded-honest {available: False, caveat, advisory} НЕ фабрикуем число.
Если данных достаточно ( _COMMERCIAL_MIN_LOTS лотов) советующая оценка доли Если данных достаточно ( _COMMERCIAL_MIN_LOTS лотов) советующая оценка ТЕМПА
коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason. РАСПРОДАЖИ нежилого: sell_through_pct = проданные ÷ (проданные+доступные)·100
(market_metrics §-определение) прокси ликвидности/спроса на нежилое, НЕ доля
нежилого в объёме застройки + §16-подобный reason.
НИКОГДА не бросает: любой сбой движка/импорта degraded-honest None-сигнал. НИКОГДА не бросает: любой сбой движка/импорта degraded-honest None-сигнал.
Args: Args:
@ -747,22 +767,29 @@ def _commercial_signal(
) )
return {"available": False, "caveat": caveat, "advisory": True} 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" 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": 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, "n_lots": n_lots,
"confidence": confidence, "confidence": confidence,
"reason": { "reason": {
"why": ( "why": (
f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} " f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} "
f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)." f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на "
f"нежилые помещения, НЕ доля нежилого в объёме застройки)."
), ),
"drivers": [ "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": "+"}, {"factor": "n_lots", "value": n_lots, "direction": "+"},
], ],
"rejected": [], "rejected": [],

View file

@ -24,6 +24,7 @@ from __future__ import annotations
import logging import logging
import re import re
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@ -80,7 +81,54 @@ _PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\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 лишь НАХОДИТ кандидатов; редактируется только то, что проходит
# контроль-сумму ФНС (см. _inn_checksum_valid + _inn_repl). Это сделано, чтобы
# легитимные слитные суммы в рублях (1_000_000_000, 123456789012) НЕ затирались
# маркером [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(token: str) -> 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 требует # СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается # формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ # с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
@ -97,18 +145,20 @@ _FULLNAME_RE = re.compile(
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b" r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
) )
# (regex, placeholder-kind). Применяются последовательно в этом порядке. # (regex, placeholder-kind, repl). Применяются последовательно в этом порядке.
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные # Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а). # с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( # repl — строка-замена ИЛИ re.sub callback (для ИНН: редактируем только при
(_SNILS_RE, "snils"), # валидной контроль-сумме, см. _inn_repl, #1640).
(_PASSPORT_RE, "passport"), _PII_PATTERNS: tuple[tuple[re.Pattern[str], str, str | Callable[[re.Match[str]], str]], ...] = (
(_PHONE_RE, "phone"), (_SNILS_RE, "snils", "[REDACTED:snils]"),
(_PHONE_BARE_RE, "phone"), (_PASSPORT_RE, "passport", "[REDACTED:passport]"),
(_EMAIL_RE, "email"), (_PHONE_RE, "phone", "[REDACTED:phone]"),
(_INN_RE, "inn"), (_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
(_SNILS_BARE_RE, "snils"), (_EMAIL_RE, "email", "[REDACTED:email]"),
(_FULLNAME_RE, "name"), (_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: if not value:
return value return value
redacted = value redacted = value
for pattern, kind in _PII_PATTERNS: for pattern, kind, repl in _PII_PATTERNS:
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted) before = redacted
if n: 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-значения. # Логируем ТОЛЬКО kind и количество — без самого PII-значения.
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind) logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
return redacted return redacted

View file

@ -36,14 +36,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

@ -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",
};