Merge remote-tracking branch 'forgejo/main' into feat/tradein-paid-retention
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 3m57s

# Conflicts:
#	tradein-mvp/backend/data/sql/_manifest_applied.txt
#	tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts
This commit is contained in:
bot-backend 2026-08-07 15:40:52 +03:00
commit 7431615415
114 changed files with 13330 additions and 5873 deletions

View file

@ -49,6 +49,7 @@ cd frontend && npm install --legacy-peer-deps --no-audit --no-fund
- Pre-push check: `git diff main..HEAD -- frontend/package.json frontend/package-lock.json` — если только один из двух тронут → STOP, regen lock.
- Imports без deps entry (TypeScript авто-resolve через transitive) — **latent bomb** до first `npm ci`.
- Reference incident: PR #344 (2026-05-17) добавил `lucide-react` без regen lockfile → deploy #135 fail → P0 hotfix PR #345 (commit `6ee20294f2`).
- **То же правило для `tradein-mvp/frontend/`** (#2770): там теперь тоже tracked `package-lock.json` + `npm ci` в Dockerfile и в `ci-tradein.yml`. До #2770 лока не было вовсе (лежал `pnpm-lock.yaml`, из которого никто не ставил), и состав зависимостей прод-образа определялся датой сборки.
## Prettier / lint

View file

@ -16,11 +16,43 @@ paths:
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
BEGIN;
SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout
-- DDL здесь (idempotent)
COMMIT;
```
## lock_timeout при блокирующем DDL (обязательно)
Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) /
`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';`
сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml`
на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL).
**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX`
на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической
psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за
ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только
ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный
отказ, повторить позже) вместо тихой очереди перед приложением.
**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена
мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду,
поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит
очередь запросов приложения.
**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует):
`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через
VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение,
оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально
в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и
обрежет `CONCURRENTLY` ниже по файлу.
Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в
`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX
CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий.
## Idempotency (обязательно)
- `CREATE TABLE IF NOT EXISTS`

View file

@ -285,25 +285,42 @@ jobs:
- name: Set up Node
# Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine).
# npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет
# package-lock.json (Dockerfile ставит через npm install), а cache=npm
# без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint.
# cache: npm включён с #2770 — package-lock.json теперь tracked.
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: tradein-mvp/frontend/package-lock.json
- name: Install deps (npm install, no lockfile)
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage):
# --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches;
# --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`):
# в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но
# Dockerfile ставит именно npm install) → `npm ci` упал бы.
run: npm install --legacy-peer-deps --no-audit --no-fund
- name: Install deps (npm ci)
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage), чтобы гейт
# видел то же дерево, что уедет в образ. `ci`, а не `install` (#2770): до
# него лока не было вовсе (лежал мёртвый pnpm-lock.yaml, из которого никто
# не ставил), и версии в CI и в прод-образе выбирались независимо по дате
# сборки — гейт проверял не тот код, который деплоится.
#
# Правишь package.json — регенерируй лок в том же PR: `npm ci` требует
# точного match и иначе роняет и этот job, и build образа.
run: npm ci --legacy-peer-deps --no-audit --no-fund
- name: Type-check (tsc --noEmit)
# Blocking: любая TS-ошибка → job RED.
run: npm run type-check
- name: Run tests (vitest)
# Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ
# проверки поведения: лэйн гейтил только типы и статический анализ, а оба
# молчат про то, что видит пользователь — пустое поле, погашенное число,
# отказ по частоте. Инфраструктура не изобретена, а взята у соседнего
# frontend/ (vitest + jsdom + testing-library), где сьют живёт давно.
#
# Пропусков в сьюте нет и быть не должно: сторож пропусков
# (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому
# пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722
# запретил на бэкенде. Тест либо чинится, либо помечается `.fails`
# с причиной В КОДЕ.
run: npm test
- name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED.
run: npm run lint

View file

@ -12,8 +12,18 @@ name: CI
# единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через
# connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango
# установлен ниже), тогда как на macOS-dev они runtime-skip'аются.
# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см.
# .github/workflows/ci.yml как образец service-блока.
#
# FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через
# `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис-
# контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ
# Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод.
# В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного
# способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект
# по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres
# и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример
# (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не
# исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions)
# уронит сборку, если такая публикация всё же появится.
on:
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709).
# WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым
@ -45,6 +55,25 @@ jobs:
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- name: "Guard: host-port collisions in workflows (#2757)"
# Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на
# КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает
# дефицитный слот раннера. Падение = merge заблокирован.
# python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3).
run: |
python3 scripts/check-workflow-ports.py --selftest
python3 scripts/check-workflow-ports.py
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
# фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ
# каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину.
run: |
python3 scripts/check-migration-lock-timeout.py --selftest
python3 scripts/check-migration-lock-timeout.py
- uses: dorny/paths-filter@v3
id: filter
with:

View file

@ -164,15 +164,25 @@ jobs:
run: uv sync --frozen
- name: Run pytest (tradein-mvp/backend)
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
# leak из другого test-модуля, pre-existing. Второй исторический deselect
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном
# прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
# совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
run: |
uv run pytest -q \
--deselect "tests/test_search_api.py::test_search_cache_hit"
# БЕЗ deselect'ов — сьют гоняется целиком, как в ci-tradein.yml.
#
# Здесь жил `--deselect tests/test_search_api.py::test_search_cache_hit` с
# объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
# global-state leak из другого модуля». Объяснение было неверным в обеих
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что ходил в
# /api/v1/search БЕЗ заголовка X-Authenticated-User, а RBAC-гард отвечает на
# такое 401. Причина была в самом тесте; заголовок добавлен в #2729, и в
# pre-merge гейте deselect снят тогда же. Здесь строка пережила починку ещё
# на месяц — файл был занят открытым #2680. Тот смержен, долг закрыт.
#
# Не добавлять сюда новые deselect'ы: молча выключенный тест — тот же класс
# дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
# помечается xfail с причиной В КОДЕ, рядом с самим тестом.
#
# `-rs`: каждый пропуск печатает причину (#2745). Ожидание в этом лэйне —
# 13 пропусков, все объявлены в tests/skip_allowlist.txt; неучтённый
# пропуск роняет прогон через хук в tests/conftest.py.
run: uv run pytest -q -rs
build-backend:
runs-on: ubuntu-latest
@ -442,6 +452,35 @@ jobs:
done
echo "All migrations applied."
# (3b) Невалидные индексы после цикла (#2752). Оборванный
# CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
# планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка
# на записи всё равно платится. Молчит это так (воспроизведено на
# PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена
# применённой → следующий деплой прогоняет её заново → `CREATE INDEX
# CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает
# «relation already exists, skipping», выходит с кодом 0 → миграция
# помечается применённой, а индекс остаётся невалидным навсегда.
# Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она
# ловит и этот путь, и невалидные индексы любого другого
# происхождения (отменённый job, ручной CIC оператором).
# На 2026-08-07 на проде таких индексов 0 — это профилактика.
invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
if [ -n "$invalid_idx" ]; then
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
echo " пересоздать индекс и повторить деплой."
exit 1
fi
echo "✓ невалидных индексов нет."
# Bootstrap gendesign_reader password from env (post-migration, #976).
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.

View file

@ -309,6 +309,31 @@ jobs:
done
echo "All migrations applied."
# Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX
# CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ
# пользуется, а re-run миграции его не чинит — `CREATE INDEX
# CONCURRENTLY IF NOT EXISTS` печатает «relation already exists,
# skipping» и выходит с кодом 0, после чего миграция помечается
# применённой, а индекс остаётся битым навсегда (воспроизведено на
# PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY.
# Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07
# на проде таких индексов 0 — это профилактика.
invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
if [ -n "$invalid_idx" ]; then
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
echo " пересоздать индекс и повторить деплой."
exit 1
fi
echo "✓ невалидных индексов нет."
# Set tradein_fdw_reader password from env (post-migration bootstrap).
# SQL migration 100_tradein_fdw_role.sql creates role passwordless;
# password lives only in /opt/gendesign/backend/.env.runtime.

View file

@ -27,7 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800
SCRAPE_KN_DEFAULT_REGIONS=66
# Путь к Playwright storage_state.json (commited в git, обновляется --save-state).
SCRAPE_KN_STATE_PATH=data/playwright_state.json
# DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен).
# Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep.
# Переменная сохранена в core/deps.py для быстрого rollback.
SCRAPE_ADMIN_TOKEN=
# SCRAPE_ADMIN_TOKEN удалён в #2775. App-level admin-auth сняли ещё в PR #437,
# а поле держали «для быстрого rollback» — за полтора месяца у него не появилось
# ни одного вызывающего. `/api/v1/admin/*` закрыт middleware rbac_guard
# (app/main.py, role != admin → 403) + Caddy basic_auth (PR #426).

View file

@ -146,9 +146,6 @@ class Settings(BaseSettings):
# Path to a pre-captured Playwright storage_state.json (committed in repo,
# used by worker to skip cold-start WAF challenge).
scrape_kn_state_path: str = "data/playwright_state.json"
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
# Empty string = endpoint disabled.
scrape_admin_token: str = ""
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ

View file

@ -1,23 +0,0 @@
"""Shared FastAPI dependencies."""
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from app.core.config import settings
def verify_admin_token(
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> None:
"""Verify admin token header. Raises 503 if not configured, 401 if invalid or missing."""
if not settings.scrape_admin_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
)
if x_admin_token != settings.scrape_admin_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]

View file

@ -48,7 +48,7 @@ from app.core import auth_db
from app.core.audit_middleware import audit_log_middleware
from app.core.auth import get_role
from app.core.config import settings
from app.observability.sentry_scrub import scrub_sensitive_query
from app.observability.sentry_scrub import scrub_event
from app.services.auth_session import resolve_session_token
logger = logging.getLogger(__name__)
@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers):
# (middleware, маршруты) видели активный client с самого старта процесса.
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
if settings.glitchtip_dsn:
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review):
# Starlette-интеграция кладёт request.data на transaction-scope так же, как
# на error-scope, поэтому голый scrub_sensitive_query (только URL) на
# before_send_transaction оставлял бы PII-канал открытым при любом
# glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring).
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
@ -82,8 +87,14 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_sensitive_query,
before_send_transaction=scrub_sensitive_query,
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753).
# Дефолт SDK — True: при любом исключении кадр несёт значения аргументов
# (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event
# сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не
# дополнительная мера, а условие, без которого скраб не полон.
include_local_variables=False,
before_send=scrub_event,
before_send_transaction=scrub_event,
integrations=[
StarletteIntegration(),
FastApiIntegration(),

View file

@ -1,17 +1,72 @@
"""Хук before_send_transaction для GlitchTip/Sentry SDK.
"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK.
Redact-ит api keys / tokens из URL-spans перед отправкой чтобы
секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip
через HttpxIntegration performance-spans.
`scrub_sensitive_query` redact-ит api keys / tokens из URL-spans перед
отправкой чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в
GlitchTip через HttpxIntegration performance-spans.
`scrub_pii_event` redact-ит consumer-PII (client_name / client_phone /
client_email / phone / email / name / company / message) из events перед
отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на
sentry-sdk 2.58) НЕ покрывает эти поля это user-data, попадающий в
request.data / extra / contexts (pilot-заявки `PilotRequestInput` в
`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/
`message`, куда чаще всего прилетают телефоны/имена/адреса; чат свободный
вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет
сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/
sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/
client_email Птица их не использует сегодня, но одинаковый механизм на
оба продукта проще сопровождать), плюс `company`/`message`, специфичные для
`PilotRequestInput` (#2457-review).
`scrub_event` composed-хендлер (PII-scrub + URL-secret redact), которым
надо вешать ОБА канала `before_send` И `before_send_transaction`.
Starlette-интеграция кладёт тело запроса в `request_info["data"]` на
transaction-scope точно так же, как на error-scope (scope-обработчики для
transactions НЕ пропускаются пропуск бывает только на availability-чеках).
Если повесить PII-scrub только на `before_send`, а `before_send_transaction`
оставить на голом `scrub_sensitive_query` PII продолжит течь через
transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review,
воспроизведено: pilot-заявка с реальными данными ~1/20 попадает в
транзакцию с полным телом).
"""
from __future__ import annotations
import logging
import re
from typing import Any
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.types import Event
logger = logging.getLogger(__name__)
# Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753).
# LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе
# в новое событие, которое снова пойдёт через этот же обработчик; при
# детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено:
# 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика
# остаётся в stdout контейнера: текст трассировки значений переменных не несёт.
ignore_logger(__name__)
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ
# (client_name/client_phone/client_email/phone/email/name, #396) + company/
# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля
# свободного текста (#2457-review).
_PII_KEYS = frozenset(
{
"client_name",
"client_phone",
"client_email",
"phone",
"email",
"name",
"company",
"message",
}
)
_SENSITIVE_PARAM_RE = re.compile(
r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)",
re.IGNORECASE,
@ -47,3 +102,63 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None:
request["url"] = _redact(request["url"])
return event
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(key, str) and key.lower() in _PII_KEYS:
obj[key] = _REDACTED
else:
_scrub(value)
elif isinstance(obj, list):
for item in obj:
_scrub(item)
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction)
перед отправкой в GlitchTip.
Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list),
заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event
(не None) иначе SDK дропнет отчёт целиком.
"""
if not isinstance(event, dict):
return event
request = event.get("request")
if isinstance(request, dict):
_scrub(request.get("data"))
_scrub(event.get("extra"))
_scrub(event.get("contexts"))
return event
def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None:
"""Composed `before_send` / `before_send_transaction` handler: PII-scrub +
URL query-secret redact. Вешать ОДИНАКОВО на оба канала см. module
docstring (#2457-review): transaction-scope несёт `request.data` точно так
же, как error-scope.
try/except предохранитель: sentry_sdk оборачивает вызов `before_send` в
`capture_internal_exceptions`, который при исключении внутри хендлера
ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что
редактор упал, event просто не уйдёт). Наблюдаемость важнее полноты
покрытия редактора: лучше отправить событие в состоянии "сколько успели
отредактировать до сбоя", чем не отправить вообще и молча остаться без
сигнала в мониторинге.
"""
try:
scrub_pii_event(event, hint)
scrub_sensitive_query(event, hint)
except Exception as exc:
# Ни трассировки, ни str(exc): и то и другое способно нести значения из
# ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что
# защищает (#2753). Имя класса исключения данных не несёт. Событием
# мониторинга эта строка не станет — см. ignore_logger выше.
logger.error(
"sentry_scrub.scrub_event: handler failed (%s), sending event as-is",
type(exc).__name__,
)
return event

View file

@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N
["Земля", _fmt_money_signed(financial.get("land_rub"))],
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],

View file

@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str:
["Земля", _fmt_money_signed(financial.get("land_rub"))],
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],

View file

@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from app.core.config import settings
from app.observability.sentry_scrub import scrub_sensitive_query
from app.observability.sentry_scrub import scrub_event
logger = logging.getLogger(__name__)
@ -23,6 +23,11 @@ logger = logging.getLogger(__name__)
# чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного
# вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента.
if settings.glitchtip_dsn:
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review,
# см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker
# вообще не скрабил error-события (тут before_send не было), а
# before_send_transaction был на голом scrub_sensitive_query (только URL) —
# оба канала пропускали PII.
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
@ -30,7 +35,12 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
before_send_transaction=scrub_sensitive_query,
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753) — см.
# app/main.py: скраб сверяет ИМЕНА ключей, а имя переменной произвольно.
# В воркере вектор шире: задачи держат в кадрах сырые ответы источников.
include_local_variables=False,
before_send=scrub_event,
before_send_transaction=scrub_event,
integrations=[
CeleryIntegration(monitor_beat_tasks=True),
SqlalchemyIntegration(),

View file

@ -0,0 +1,146 @@
"""Проба проводки GlitchTip: запускается ОТДЕЛЬНЫМ процессом из test_sentry_init.py.
Зачем подпроцесс. `app/main.py` и `app/workers/celery_app.py` зовут
`sentry_sdk.init()` на импорте модуля и только при непустом `GLITCHTIP_DSN`. В
процессе pytest этот путь недостижим (модуль уже в `sys.modules`, DSN пуст), а
если бы и был достижим глобальный клиент SDK остался бы живым для всех
последующих тестов. Отдельный процесс даёт настоящую инициализацию и умирает
вместе с ней.
Наружу не уходит ничего: `capture_envelope` подменяется ДО первого события, а
DSN в тесте указывает на несуществующий хост. Значения-маркеры генерируются
случайно на каждый запуск кадр стека несёт не только переменные, но и строки
исходника, поэтому литерал в коде пробы сделал бы проверку вечно красной.
stdout одна строка JSON: counts / scrub_handler_entries / markers / payloads
(тело каждого канала отдельно см. `main`).
"""
from __future__ import annotations
import importlib
import io
import itertools
import json
import sys
import uuid
from typing import Any
import sentry_sdk
_FAILURE_CAP = 3
def _fresh(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex}"
def _leaking_event(markers: dict[str, str]) -> dict[str, Any]:
"""Событие с ПДн в трёх местах, которые закрывает scrub_event."""
return {
"message": "sentry-wiring-probe",
"level": "error",
"request": {
"data": {"phone": markers["phone"], "message": markers["free_text"]},
"url": f"https://example.invalid/probe?api_key={markers['url_secret']}",
},
}
def main(module: str) -> int:
importlib.import_module(module) # ← здесь отрабатывает sentry_sdk.init()
client = sentry_sdk.get_client()
sent: list[str] = []
def _record(envelope: Any) -> None:
buf = io.BytesIO()
envelope.serialize_into(buf)
sent.append(buf.getvalue().decode("utf-8", "replace"))
client.transport.capture_envelope = _record # type: ignore[union-attr,method-assign]
markers = {
"phone": _fresh("probe-phone"),
"free_text": _fresh("probe-free-text"),
"url_secret": _fresh("probe-url-secret"),
"local_var": _fresh("probe-local-var"),
}
# 1. Канал ошибок (before_send).
sentry_sdk.capture_event(_leaking_event(markers))
after_error = len(sent)
# 2. Канал транзакций (before_send_transaction) — Starlette кладёт
# request.data на transaction-scope так же, как на error-scope.
transaction = _leaking_event(markers)
transaction["type"] = "transaction"
transaction["transaction"] = "sentry-wiring-probe-tx"
transaction["contexts"] = {"trace": {"trace_id": "0" * 32, "span_id": "0" * 16}}
transaction["start_timestamp"] = "2026-01-01T00:00:00.000000Z"
transaction["timestamp"] = "2026-01-01T00:00:01.000000Z"
transaction["spans"] = []
sentry_sdk.capture_event(transaction)
after_transaction = len(sent)
# 3. Локальные переменные кадра стека (include_local_variables). Имя
# переменной произвольное — ключевой скраб такое не ловит по построению.
def _raise_with_local() -> None:
applicant_note = markers["local_var"] # noqa: F841 — ради кадра стека
raise RuntimeError("sentry-wiring-probe boom")
try:
_raise_with_local()
except RuntimeError:
sentry_sdk.capture_exception()
after_exception = len(sent)
# 4. Сбой самого скраба не должен порождать ВТОРОЕ событие: иначе строка
# журнала об отказе уходит в мониторинг через LoggingIntegration, снова
# попадает в скраб, снова падает — рекурсия (#2753; на коде до фикса
# проверено: не завершается, 1000+ вложенных трассировок за минуту).
# Считаем ВХОДЫ в обработчик; после _FAILURE_CAP перестаём падать, иначе
# проба на сломанном коде висела бы вместо того, чтобы честно покраснеть.
from app.observability import sentry_scrub
original = sentry_scrub.scrub_pii_event
entries: list[int] = []
def _boom(*_a: Any, **_kw: Any) -> Any:
entries.append(1)
if len(entries) > _FAILURE_CAP:
return None
raise RuntimeError("sentry-wiring-probe scrubber failure")
sentry_scrub.scrub_pii_event = _boom # type: ignore[assignment]
try:
# Без маркеров: это событие по замыслу уходит НЕОЧИЩЕННЫМ ("as-is").
sentry_sdk.capture_event({"message": "sentry-wiring-probe-failure", "level": "error"})
finally:
sentry_scrub.scrub_pii_event = original # type: ignore[assignment]
after_scrub_failure = len(sent)
# Тело каждого канала — отдельно: иначе утечка из одного (напр. локальные
# переменные шага 3 несут те же маркеры, что тело запроса шага 1) красит
# чужую проверку и мешает понять, что именно сломано.
bounds = [0, after_error, after_transaction, after_exception, after_scrub_failure]
names = ["error", "transaction", "exception", "scrub_failure"]
spans = dict(zip(names, itertools.pairwise(bounds), strict=True))
print(
json.dumps(
{
"counts": {name: end - start for name, (start, end) in spans.items()},
"scrub_handler_entries": len(entries),
"markers": markers,
"payloads": {
name: "\n".join(sent[start:end]) for name, (start, end) in spans.items()
},
}
)
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))

View file

@ -15,10 +15,6 @@ from fastapi.testclient import TestClient
from app.main import app
# Токен для тестов (не реальный)
ADMIN_TOKEN = "test-admin-token"
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None):
"""Создать mock db session с преднастроенными ответами."""
@ -97,7 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]:
# ── Tests ────────────────────────────────────────────────────────────────────
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_create_job_pilot_returns_job_id() -> None:
"""POST /cadastre/jobs scope=pilot → job_id + targets_total."""
quarters_50 = [f"66:41:{i:07d}" for i in range(50)]
@ -114,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None:
response = client.post(
"/api/v1/admin/cadastre/jobs",
json={"scope": "pilot"},
headers=ADMIN_HEADERS,
)
assert response.status_code == 200, response.text
@ -127,7 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_create_job_manual_list() -> None:
"""POST /cadastre/jobs scope=manual_list с явным списком."""
db = _make_mock_db()
@ -146,7 +139,6 @@ def test_create_job_manual_list() -> None:
"scope": "manual_list",
"quarters": ["66:41:0303161", "66:41:0303162"],
},
headers=ADMIN_HEADERS,
)
assert response.status_code == 200, response.text
@ -156,7 +148,6 @@ def test_create_job_manual_list() -> None:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_create_job_manual_list_empty_quarters_returns_400() -> None:
"""scope=manual_list без quarters → 400."""
db = _make_mock_db()
@ -170,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None:
response = client.post(
"/api/v1/admin/cadastre/jobs",
json={"scope": "manual_list"},
headers=ADMIN_HEADERS,
)
assert response.status_code == 400
finally:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_list_jobs_returns_list() -> None:
"""GET /cadastre/jobs → список jobs."""
db = _make_mock_db(job_row=_make_sample_job_row())
@ -194,7 +183,7 @@ def test_list_jobs_returns_list() -> None:
try:
client = TestClient(app)
response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS)
response = client.get("/api/v1/admin/cadastre/jobs")
assert response.status_code == 200, response.text
body = response.json()
assert isinstance(body, list)
@ -206,7 +195,6 @@ def test_list_jobs_returns_list() -> None:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_get_job_not_found_returns_404() -> None:
"""GET /cadastre/jobs/9999 → 404."""
db = MagicMock()
@ -220,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None:
try:
client = TestClient(app)
response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS)
response = client.get("/api/v1/admin/cadastre/jobs/9999")
assert response.status_code == 404
finally:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_cancel_job_success() -> None:
"""POST /cadastre/jobs/42/cancel → {cancelled: true}."""
db = MagicMock()
@ -243,7 +230,6 @@ def test_cancel_job_success() -> None:
client = TestClient(app)
response = client.post(
"/api/v1/admin/cadastre/jobs/42/cancel",
headers=ADMIN_HEADERS,
)
assert response.status_code == 200, response.text
body = response.json()
@ -253,7 +239,6 @@ def test_cancel_job_success() -> None:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_cancel_job_not_found_returns_404() -> None:
"""POST /cadastre/jobs/9999/cancel когда job не найден → 404."""
db = MagicMock()
@ -270,7 +255,6 @@ def test_cancel_job_not_found_returns_404() -> None:
client = TestClient(app)
response = client.post(
"/api/v1/admin/cadastre/jobs/9999/cancel",
headers=ADMIN_HEADERS,
)
assert response.status_code == 404
finally:

View file

@ -4,7 +4,11 @@
- валидный запрос без year scope all_years_2022_2026, task_id в ответе
- валидный запрос с year=2026 scope year_2026
- year < 2022 или > 2030 422
- отсутствие X-Admin-Token 401/503
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие
X-Admin-Token 401/503» стояла в этом докстринге, но соответствующего теста в
файле нет. Гейт `/api/v1/admin/*` middleware `rbac_guard` (app/main.py),
покрыт tests/test_rbac.py.
"""
from __future__ import annotations
@ -16,8 +20,6 @@ from fastapi.testclient import TestClient
from app.main import app
ADMIN_TOKEN = "test-admin-token"
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
ENDPOINT = "/api/v1/admin/scrape/ekburg-permits"
@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock:
return result
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_trigger_refresh_all_returns_task_id() -> None:
"""POST без year → refresh_all queued, scope=all_years_2022_2026."""
mock_result = _mock_task("task-all-001")
@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None:
):
mock_refresh_all.apply_async.return_value = mock_result
client = TestClient(app)
response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS)
response = client.post(ENDPOINT, json={})
assert response.status_code == 200, response.text
body = response.json()
@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None:
assert "queued_at" in body
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_trigger_refresh_year_returns_task_id() -> None:
"""POST year=2026 → refresh_year queued, scope=year_2026."""
mock_result = _mock_task("task-year-002")
@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None:
):
mock_refresh_year.apply_async.return_value = mock_result
client = TestClient(app)
response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS)
response = client.post(ENDPOINT, json={"year": 2026})
assert response.status_code == 200, response.text
body = response.json()
@ -67,9 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None:
@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999])
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_trigger_invalid_year_returns_422(bad_year: int) -> None:
"""year вне диапазона [2022, 2030] → 422 Unprocessable Entity."""
client = TestClient(app)
response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS)
response = client.post(ENDPOINT, json={"year": bad_year})
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"

View file

@ -197,32 +197,74 @@ class TestParseBabushkinaBlock:
assert site.krt_kind == "жилой застройки"
class TestMultiSiteCountFromDump:
"""Проверка количества площадок из реального дампа страницы."""
# ── Многоблочная страница: два года, три площадки, повторяющийся номер ────────
#
# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший
# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у
# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а
# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому
# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ.
#
# Свойство, ради которого они писались, от дампа не зависит и выражается
# страницей из блоков, которые уже лежат в этом файле:
# • несколько <h2>-секций с годами разбираются в одном проходе;
# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ,
# поэтому «2.» встречается дважды — и site_name всё равно обязан быть
# уникальным (номер сам по себе ключом не является);
# • блоки лежат внутри контейнера <div> — это ветка `_extract_krt_section`
# через `find_parent`, которой одиночные блоки выше не касаются вовсе.
#
# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778):
# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора
# реального документа, синтетической страницей оно не заменяется. На проде
# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но
# автоматической проверки этого больше нет — она вернётся, когда в репозитории
# появится образец страницы.
_MULTISITE_PAGE = (
"<div class='content'>"
+ _BRUSNIKA_BLOCK
+ _BABUSHKINA_BLOCK
+ textwrap.dedent(
"""\
<p>&nbsp;<strong>2. КРТ нежилой застройки в районе ул. Краснофлотцев:</strong></p>
<p>Площадь территории: 3,65 га;<br />\
Градостроительный потенциал: 117 640 кв. м;<br />\
Всего многоквартирных домов: 13.</p>
"""
)
+ "</div>"
)
class TestMultiSitePage:
"""Разбор страницы из нескольких блоков — без локального дампа."""
@pytest.fixture(scope="class")
def dump_html(self):
"""Загружает локальный дамп страницы.
def sites(self):
return parse_krt_page(_MULTISITE_PAGE)
Тест пропускается если файл отсутствует (CI без дампа).
"""
import os
def test_all_three_sites_parsed(self, sites):
"""Три блока в двух годовых секциях → три площадки."""
assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}"
dump_path = os.path.join(
os.path.dirname(__file__),
"../../../../.playwright-mcp/ekb-docs/krt_page_raw.html",
)
if not os.path.exists(dump_path):
pytest.skip("krt_page_raw.html dump not found — skip real-dump test")
with open(dump_path, encoding="utf-8") as fh:
return fh.read()
def test_site_count_from_dump(self, dump_html):
"""Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15)."""
sites = parse_krt_page(dump_html)
assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}"
def test_no_duplicate_site_names(self, dump_html):
sites = parse_krt_page(dump_html)
def test_no_duplicate_site_names(self, sites):
"""Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны."""
names = [s.site_name for s in sites]
assert len(names) == len(set(names)), "Дубликаты site_name в результате"
assert sum(n.startswith("2.") for n in names) == 2, names
assert len(names) == len(set(names)), f"Дубликаты site_name: {names}"
def test_blocks_do_not_leak_into_each_other(self, sites):
"""Границы блоков соблюдены: договор и застройщик остались у своей площадки.
Регресс, который этим ловится: если block_end съезжает, «Брусника» и
договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему.
"""
with_developer = [s for s in sites if s.developer_name]
assert len(with_developer) == 1, [s.developer_name for s in sites]
assert "Брусника" in with_developer[0].developer_name
assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1
def test_kinds_are_read_per_block(self, sites):
"""Третий блок — «нежилой застройки», не должен унаследовать вид соседа."""
kinds = sorted(s.krt_kind for s in sites if s.krt_kind)
assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds

View file

@ -10,8 +10,10 @@
DNS-fail повторяет timeout на каждый analyze.
3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две
раздельные таблицы внутри модуля).
4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start
ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store).
4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ сеть зовётся не больше раза на
поток, все получают одно и то же значение, и шторм заканчивается сложившимся
кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно
(#1370, см. сам тест).
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
следующий вызов идёт по сети заново (а не из устаревшего кэша).
@ -23,6 +25,7 @@ from __future__ import annotations
import os
import threading
import time
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate:
class TestConcurrencySafe:
def test_single_flight_cold_start_one_network_call(self) -> None:
"""16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов."""
# GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock.
def test_cold_start_storm_bounded_and_cache_converges(self) -> None:
"""16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток,
все получают одно и то же значение, и после шторма кэш отвечает без сети.
ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это
было требование, которого код НЕ выполняет и выполнять не собирается:
сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze
сериализуются на время httpx-вызова даже для разных координат), а рядом с
ним написано, что cold-start на один ключ «может породить несколько
параллельных запросов приемлемо». Тест зеленел не потому, что защита
работает, а потому что при GIL первый поток обычно успевал сложить
результат раньше остальных.
Замер 2026-08-07, 200 штормов подряд: при дефолтном
`sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 те самые
~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов,
было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда
потоки реально чередуются, больше одного вызова дали 197 штормов из 200,
и в 173 из них вызовов было все 16. То есть утверждение ложно почти
всегда, когда гонка вообще случается, чинить надо было тест.
Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ
стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние
запросы бывают только на cold-start одного ключа и они идемпотентны.
Понадобится это отдельная задача с отдельным обоснованием, а не
побочный эффект правки теста.
`time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти
промах кэша до первой записи. Так тест мерит худший случай той самой
уступки, а не везение планировщика.
"""
start_barrier = threading.Barrier(16)
get_call_count = 0
get_lock = threading.Lock()
@ -253,8 +284,7 @@ class TestConcurrencySafe:
nonlocal get_call_count
with get_lock:
get_call_count += 1
# Микро-задержка — окно для других потоков добраться до lock'а.
# Не делаем sleep большим, чтобы тест не висел.
time.sleep(0.05) # окно, в котором остальные потоки видят промах
return _make_httpx_response(_make_forecast_response())
client_ctx = MagicMock()
@ -276,11 +306,27 @@ class TestConcurrencySafe:
t.start()
for t in threads:
t.join()
storm_calls = get_call_count
# Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что
# поход в сеть был бы виден счётчиком, а не отказом коннекта.
after_storm = weather_cache.get_weather_cached(56.84, 60.59)
assert len(results) == 16
assert all(r is not None for r in results)
# Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов.
assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}"
assert results[0] is not None
assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения"
# Потолок — число участников: в сеть идут только промахнувшиеся, по разу
# каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная
# запись в кэш); меньше единицы невозможно, кэш был пуст.
assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках"
# Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз:
# следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен
# снятого single-flight — без этого уступка превращается в дыру.
assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)]
assert after_storm == results[0]
assert get_call_count == storm_calls, (
f"после шторма кэш обязан отвечать без сети, а вызовов стало "
f"{get_call_count} против {storm_calls}"
)
# ──────────────────────────────────────────────────────────────────────────────

View file

@ -73,14 +73,22 @@ tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb
tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real
tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped
# ── Образцы, которых нет в git ────────────────────────────────────────────────
# ДОЛГ, а не норма: парсеры проверяются на реальных дампах, которые лежат только
# в untracked `.playwright-mcp/`. Пока файлы не в репозитории, эти 8 проверок не
# исполняются нигде. Правильная починка — закоммитить образцы в tests/fixtures/
# (как сделано в tradein-mvp/backend/tests/fixtures/*.html), после чего строки
# отсюда убрать.
tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_no_duplicate_site_names
tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_site_count_from_dump
# ── Образец, которого нет в git ───────────────────────────────────────────────
# ДОЛГ, а не норма. Было 8 записей; 2 из них (test_ekb_krt_registry.py::
# TestMultiSiteCountFromDump) сняты в #2778: дампа `krt_page_raw.html` не
# оказалось НИГДЕ — ни в репозитории, ни на дисках, ни на проде (в БД лежит
# `raw_block` = уже очищенный текст, вход парсера из него не восстановить), —
# зато свойство, которое они проверяли (несколько блоков на странице, уникальные
# site_name), выразимо без дампа и теперь проверяется на многоблочной странице,
# собранной из блоков, уже лежащих в том же тест-файле.
#
# Оставшиеся 6 так не лечатся: они проверяют извлечение таблиц из НАСТОЯЩЕГО PDF
# через pdfplumber, соседний класс TestParsePptTep уже мокает pdfplumber целиком.
# Нужен файл `ppt2018_22823_poyasnit_PPT.pdf` (или рабочий URL на ГИСОГД ЕКБ) —
# запрошен у Anton в #2778, там же список того, что оживёт. Причин не коммитить
# нет: официальный градостроительный документ, без персональных данных.
# После получения — положить в tests/fixtures/, поправить `_REAL_PDF_PATH`,
# убрать эти шесть строк.
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty

View file

@ -6,9 +6,15 @@
- GET /{id} 200 / 404
- PUT /{id} 200 / 404
- DELETE /{id} 204 / 404
- 401 при отсутствии X-Admin-Token
- 422 при невалидных weights (неизвестная категория, вес вне диапазона)
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «401 при
отсутствии X-Admin-Token» стояла в этом докстринге, но соответствующего теста в
файле нет заголовок просто отправлялся во все запросы и никем не читался
(app-level токен снят в PR #437). Гейт `/api/v1/admin/*` живёт в middleware
`rbac_guard` (app/main.py, `_ADMIN_API_RE` 403 для role != admin) и покрыт
tests/test_rbac.py.
Mock-based: get_db переопределяется через dependency override.
"""
@ -24,9 +30,6 @@ from app.core.db import get_db
from app.main import app
from app.services.site_finder.weight_profiles import WeightProfile
_ADMIN_TOKEN = "test-admin-token"
_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN}
_NOW = datetime.now(UTC)
@ -50,9 +53,7 @@ def _make_profile(
@pytest.fixture()
def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient:
"""TestClient с переопределённым SCRAPE_ADMIN_TOKEN."""
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
def client() -> TestClient:
return TestClient(app)
@ -76,7 +77,7 @@ def _clear_overrides():
# ── GET list ───────────────────────────────────────────────────────────────────
def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_list_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""GET ?user_id= → 200 + пустой список."""
mock = MagicMock()
_override_db(mock)
@ -85,10 +86,9 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
"app.api.v1.admin_weight_profiles.list_profiles",
lambda db, user_id: [],
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-x"},
headers=_HEADERS,
)
assert r.status_code == 200
assert r.json() == []
@ -96,9 +96,7 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
_clear_overrides()
def test_list_returns_profiles(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_list_returns_profiles(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""GET ?user_id= → 200 + список профилей."""
profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")]
mock = MagicMock()
@ -108,10 +106,9 @@ def test_list_returns_profiles(
"app.api.v1.admin_weight_profiles.list_profiles",
lambda db, user_id: profiles,
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 200
body = r.json()
@ -124,7 +121,7 @@ def test_list_returns_profiles(
# ── POST create ────────────────────────────────────────────────────────────────
def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_create_then_get(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""POST создаёт профиль, возвращает его со статусом 201."""
created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5})
mock = MagicMock()
@ -134,7 +131,7 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
"app.api.v1.admin_weight_profiles.create_profile",
lambda db, payload: created,
)
r = client_with_token.post(
r = client.post(
"/api/v1/admin/site-finder/weight-profiles",
json={
"user_id": "user-1",
@ -142,7 +139,6 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
"weights": {"school": 2.0, "park": 1.5},
"is_default": False,
},
headers=_HEADERS,
)
assert r.status_code == 201
body = r.json()
@ -153,30 +149,28 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
_clear_overrides()
def test_create_validation_unknown_category(client_with_token: TestClient) -> None:
def test_create_validation_unknown_category(client: TestClient) -> None:
"""POST с неизвестной POI-категорией → 422 (Pydantic validation)."""
r = client_with_token.post(
r = client.post(
"/api/v1/admin/site-finder/weight-profiles",
json={
"user_id": "user-1",
"profile_name": "Bad",
"weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES
},
headers=_HEADERS,
)
assert r.status_code == 422
def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None:
def test_create_validation_weight_out_of_bounds(client: TestClient) -> None:
"""POST с весом вне [-2, 3] → 422."""
r = client_with_token.post(
r = client.post(
"/api/v1/admin/site-finder/weight-profiles",
json={
"user_id": "user-1",
"profile_name": "Bad",
"weights": {"school": 99.0},
},
headers=_HEADERS,
)
assert r.status_code == 422
@ -184,7 +178,7 @@ def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -
# ── GET one ────────────────────────────────────────────────────────────────────
def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_profile_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""GET /{id}?user_id= → 200."""
profile = _make_profile(7)
mock = MagicMock()
@ -194,10 +188,9 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
"app.api.v1.admin_weight_profiles.get_profile",
lambda db, user_id, profile_id: profile,
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles/7",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 200
assert r.json()["id"] == 7
@ -205,9 +198,7 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
_clear_overrides()
def test_get_profile_not_found(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_get_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""GET /{id} несуществующего профиля → 404."""
mock = MagicMock()
_override_db(mock)
@ -216,10 +207,9 @@ def test_get_profile_not_found(
"app.api.v1.admin_weight_profiles.get_profile",
lambda db, user_id, profile_id: None,
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles/999",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 404
finally:
@ -229,7 +219,7 @@ def test_get_profile_not_found(
# ── PUT update ─────────────────────────────────────────────────────────────────
def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_update_profile(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""PUT /{id} → 200 + обновлённый профиль."""
updated = _make_profile(3, profile_name="Обновлённый")
mock = MagicMock()
@ -239,11 +229,10 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
"app.api.v1.admin_weight_profiles.update_profile",
lambda db, user_id, profile_id, payload: updated,
)
r = client_with_token.put(
r = client.put(
"/api/v1/admin/site-finder/weight-profiles/3",
params={"user_id": "user-1"},
json={"profile_name": "Обновлённый"},
headers=_HEADERS,
)
assert r.status_code == 200
assert r.json()["profile_name"] == "Обновлённый"
@ -251,9 +240,7 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
_clear_overrides()
def test_update_profile_not_found(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_update_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""PUT /{id} несуществующего → 404."""
mock = MagicMock()
_override_db(mock)
@ -262,11 +249,10 @@ def test_update_profile_not_found(
"app.api.v1.admin_weight_profiles.update_profile",
lambda db, user_id, profile_id, payload: None,
)
r = client_with_token.put(
r = client.put(
"/api/v1/admin/site-finder/weight-profiles/999",
params={"user_id": "user-1"},
json={"profile_name": "X"},
headers=_HEADERS,
)
assert r.status_code == 404
finally:
@ -276,7 +262,7 @@ def test_update_profile_not_found(
# ── DELETE ─────────────────────────────────────────────────────────────────────
def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_delete_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""DELETE /{id} → 204."""
mock = MagicMock()
_override_db(mock)
@ -285,17 +271,16 @@ def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.Monke
"app.api.v1.admin_weight_profiles.delete_profile",
lambda db, user_id, profile_id: True,
)
r = client_with_token.delete(
r = client.delete(
"/api/v1/admin/site-finder/weight-profiles/5",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 204
finally:
_clear_overrides()
def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_delete_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""DELETE /{id} несуществующего → 404."""
mock = MagicMock()
_override_db(mock)
@ -304,10 +289,9 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
"app.api.v1.admin_weight_profiles.delete_profile",
lambda db, user_id, profile_id: False,
)
r = client_with_token.delete(
r = client.delete(
"/api/v1/admin/site-finder/weight-profiles/999",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 404
finally:
@ -318,7 +302,7 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
def test_list_include_system_calls_with_system(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""GET ?include_system=true вызывает list_profiles_with_system, возвращает presets."""
system_profile = _make_profile(
@ -332,10 +316,9 @@ def test_list_include_system_calls_with_system(
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
lambda db, user_id: [user_profile, system_profile],
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1", "include_system": "true"},
headers=_HEADERS,
)
assert r.status_code == 200
body = r.json()
@ -348,7 +331,7 @@ def test_list_include_system_calls_with_system(
def test_list_without_include_system_does_not_call_with_system(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""GET без include_system → list_profiles (только пользовательские профили)."""
user_profile = _make_profile(1, user_id="user-1")
@ -364,10 +347,9 @@ def test_list_without_include_system_does_not_call_with_system(
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
lambda db, user_id: called_with_system.append(True) or [],
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 200
assert len(r.json()) == 1

View file

@ -2,14 +2,27 @@
Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init()
только при непустом GLITCHTIP_DSN, что release-fallback работает корректно,
и что scrub_sensitive_query redact-ит api keys из URL spans.
что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event
redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/
company/message) из request.data/extra/contexts (#2457-review), и — в конце
файла что до транспорта не доезжают ни ПДн тела запроса, ни значения
локальных переменных кадра стека, ни второе событие о сбое самого скраба
(#2753, поведение через подставной транспорт вместо поиска подстроки).
"""
import json
import os
import pathlib
import subprocess
import sys
from functools import lru_cache
from unittest.mock import patch
import pytest
import sentry_sdk
_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1]
def test_sdk_imports_without_error() -> None:
"""Все интеграции импортируются без ModuleNotFoundError."""
@ -156,3 +169,331 @@ def test_scrub_handles_missing_spans() -> None:
event: dict = {"request": {"url": "https://example.com"}}
result = scrub_sensitive_query(event, {})
assert result["request"]["url"] == "https://example.com"
# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ────────────
#
# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий
# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data,
# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk
# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором.
def test_scrub_pii_redacts_request_data() -> None:
"""scrub_pii_event заменяет client_name/phone/email в request.data."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"request": {
"data": {
"client_name": "Иван Иванов",
"phone": "+79991234567",
"email": "ivan@example.com",
"address": "Екатеринбург, ул. Ленина 1",
}
}
}
result = scrub_pii_event(event, {})
data = result["request"]["data"]
assert data["client_name"] == "[REDACTED]"
assert data["phone"] == "[REDACTED]"
assert data["email"] == "[REDACTED]"
# non-PII поле не трогаем
assert data["address"] == "Екатеринбург, ул. Ленина 1"
def test_scrub_pii_redacts_pilot_request_company_and_message() -> None:
"""scrub_pii_event заменяет company/message — свободный текст
PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают
телефоны/имена/адреса, а не только фиксированные name/phone/email
(#2457-review)."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"request": {
"data": {
"company": "ООО Ромашка",
"message": "Меня зовут Иван, звоните на +79991234567",
"source": "landing",
}
}
}
result = scrub_pii_event(event, {})
data = result["request"]["data"]
assert data["company"] == "[REDACTED]"
assert data["message"] == "[REDACTED]"
# non-PII поле не трогаем
assert data["source"] == "landing"
def test_scrub_pii_redacts_client_prefixed_keys() -> None:
"""Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) —
Птица их сегодня не использует, но одинаковый механизм на оба продукта
проще сопровождать (#2457-review)."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"extra": {
"client_name": "Иван",
"client_phone": "+79991234567",
"client_email": "ivan@example.com",
}
}
result = scrub_pii_event(event, {})
extra = result["extra"]
assert extra["client_name"] == "[REDACTED]"
assert extra["client_phone"] == "[REDACTED]"
assert extra["client_email"] == "[REDACTED]"
def test_scrub_pii_redacts_extra() -> None:
"""scrub_pii_event заменяет PII-ключи в extra, не трогая остальное."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"extra": {
"phone": "+79990000000",
"email": "x@y.ru",
"name": "Пётр",
"lead_id": 42,
}
}
result = scrub_pii_event(event, {})
extra = result["extra"]
assert extra["phone"] == "[REDACTED]"
assert extra["email"] == "[REDACTED]"
assert extra["name"] == "[REDACTED]"
assert extra["lead_id"] == 42
def test_scrub_pii_is_case_insensitive() -> None:
"""Сверка PII-ключей case-insensitive."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}}
result = scrub_pii_event(event, {})
assert result["extra"]["Client_Name"] == "[REDACTED]"
assert result["extra"]["PHONE"] == "[REDACTED]"
def test_scrub_pii_redacts_nested_contexts() -> None:
"""scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}}
result = scrub_pii_event(event, {})
assert result["contexts"]["lead"]["email"] == "[REDACTED]"
assert result["contexts"]["trace"]["op"] == "http"
def test_scrub_pii_leaves_non_pii_untouched() -> None:
"""scrub_pii_event не трогает event без PII-ключей."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"request": {"data": {"region": "66", "area_sqm": 50}},
"extra": {"job": "geocode"},
"level": "error",
}
result = scrub_pii_event(event, {})
assert result["request"]["data"] == {"region": "66", "area_sqm": 50}
assert result["extra"] == {"job": "geocode"}
assert result["level"] == "error"
def test_scrub_pii_handles_missing_sections() -> None:
"""scrub_pii_event не падает на пустом event."""
from app.observability.sentry_scrub import scrub_pii_event
result = scrub_pii_event({}, {})
assert result == {}
def test_scrub_pii_handles_none_and_non_dict_sections() -> None:
"""scrub_pii_event не бросает исключений на None/не-dict секциях."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"}
result = scrub_pii_event(event, {})
assert result is event
def test_scrub_pii_returns_event_not_none() -> None:
"""before_send должен вернуть event (не None) — иначе SDK дропнет отчёт."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {"request": {"data": {"client_name": "X"}}}
result = scrub_pii_event(event, {})
assert result is not None
assert result is event
# ── scrub_event (composed before_send / before_send_transaction handler) ───────
#
# scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА
# канала (before_send И before_send_transaction). До #2457-review composed-хук
# висел только на before_send, а before_send_transaction оставался на голом
# scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data
# на transaction-scope так же, как на error-scope, поэтому PII продолжало течь
# через transaction-канал при glitchtip_traces_sample_rate > 0.
def test_scrub_event_composes_pii_and_url_secret_scrub() -> None:
"""scrub_event применяет PII-scrub (ключ-based) И URL query-string secret
redact (regex) оба разом, не заменяя друг друга разные классы данных."""
from app.observability.sentry_scrub import scrub_event
event: dict = {
"request": {
"data": {"client_name": "Иван"},
"url": "https://example.com?api_key=supersecret",
}
}
result = scrub_event(event, {})
assert result is not None
assert result["request"]["data"]["client_name"] == "[REDACTED]"
assert "[REDACTED]" in result["request"]["url"]
assert "supersecret" not in result["request"]["url"]
def test_scrub_event_returns_event_not_none() -> None:
"""scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт."""
from app.observability.sentry_scrub import scrub_event
event: dict = {"request": {"data": {"name": "X"}}}
result = scrub_event(event, {})
assert result is not None
assert result is event
def test_scrub_event_survives_scrub_pii_event_exception() -> None:
"""try/except в scrub_event — предохранитель: sentry_sdk оборачивает
before_send в capture_internal_exceptions, который при исключении ТОЛЬКО
логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал).
Если scrub_pii_event падает scrub_event обязан вернуть event, а не
пробросить исключение дальше (#2457-review)."""
from app.observability.sentry_scrub import scrub_event
event: dict = {"request": {"data": {"client_name": "X"}}}
with patch(
"app.observability.sentry_scrub.scrub_pii_event",
side_effect=RuntimeError("boom"),
):
result = scrub_event(event, {})
assert result is not None
assert result is event
def test_scrub_event_survives_scrub_sensitive_query_exception() -> None:
"""То же самое для второго шага композиции (URL-secret redact)."""
from app.observability.sentry_scrub import scrub_event
event: dict = {"request": {"data": {"name": "X"}}}
with patch(
"app.observability.sentry_scrub.scrub_sensitive_query",
side_effect=RuntimeError("boom"),
):
result = scrub_event(event, {})
assert result is not None
assert result is event
# ── wiring: ПДн не доходят до транспорта (поведение, а не текст исходника) ─────
#
# До #2753 проводка проверялась поиском подстроки `before_send=scrub_event` в
# файле. Такой гейт зелен и на разорванной проводке: обе точки входа несут
# многострочные комментарии, где те же подстроки встречаются, — достаточно
# удалить сам аргумент, оставив комментарий. Хуже того, подстрока ничего не
# говорит о том, ДОШЛИ ли ПДн до транспорта: их можно выпустить и при живом
# before_send (локальные переменные кадра стека уходят мимо ключевого скраба).
#
# Поэтому проверяем поведение: поднимаем настоящую инициализацию в подпроцессе
# (`tests/_sentry_wiring_probe.py`), подменяем транспорт и смотрим, что до него
# доехало. Наружу не уходит ничего — DSN указывает на несуществующий хост, а
# `capture_envelope` подменён до первого события.
@lru_cache(maxsize=2)
def _probe(module: str) -> str:
"""Прогнать пробу проводки для точки входа `module`; вернуть JSON-строку."""
env = {
**os.environ,
"TESTING": "1",
# Синтаксически валидный DSN на несуществующий хост: init отработает,
# сети не будет даже если транспорт когда-нибудь перестанут подменять.
"GLITCHTIP_DSN": "https://probe@localhost.invalid/1",
# Явно: у запуска скрипта в sys.path[0] попадает КАТАЛОГ СКРИПТА (tests/),
# и без этого `import app` уехал бы в editable-установку пакета — то есть
# проба мерила бы чужое дерево, а не то, что рядом с ней лежит.
"PYTHONPATH": os.pathsep.join([str(_BACKEND_ROOT), os.environ.get("PYTHONPATH", "")]),
}
proc = subprocess.run(
[sys.executable, str(_BACKEND_ROOT / "tests" / "_sentry_wiring_probe.py"), module],
cwd=_BACKEND_ROOT,
env=env,
capture_output=True,
text=True,
timeout=300,
check=False,
)
assert proc.returncode == 0, f"проба упала: {proc.stderr[-3000:]}"
return proc.stdout.strip().splitlines()[-1]
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
def test_pii_never_reaches_transport(module: str) -> None:
"""Оба канала (error И transaction) отдают транспорту событие без ПДн.
Красный, если из `sentry_sdk.init()` убрать `before_send` ИЛИ
`before_send_transaction` комментарий с теми же словами не спасает.
"""
probe = json.loads(_probe(module))
markers = probe["markers"]
for channel in ("error", "transaction"):
payload = probe["payloads"][channel]
# Контроль «событие вообще доехало»: без него проверка была бы зелёной
# и на пробе, которая молча ничего не отправила.
assert probe["counts"][channel] == 1, f"{module}/{channel}: событие не доехало"
assert "[REDACTED]" in payload, f"{module}/{channel}: скраб не отработал"
leaked = [key for key in ("phone", "free_text", "url_secret") if markers[key] in payload]
assert leaked == [], f"{module}/{channel}: до транспорта дошли ПДн — {leaked}"
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
def test_local_variables_never_reach_transport(module: str) -> None:
"""`include_local_variables=False`: значения локальных переменных кадра стека
не уходят в мониторинг (#2753).
Ключевой скраб такое не ловит по построению имя переменной произвольно,
а сверка идёт по именам. Красный, если флаг убрать из `sentry_sdk.init()`
(в sentry-sdk он по умолчанию `True`).
"""
probe = json.loads(_probe(module))
payload = probe["payloads"]["exception"]
assert probe["counts"]["exception"] == 1
assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало"
assert (
probe["markers"]["local_var"] not in payload
), f"{module}: значение локальной переменной ушло в мониторинг"
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
def test_scrub_failure_does_not_spawn_second_event(module: str) -> None:
"""Сбой самого скраба не порождает ВТОРОГО события (#2753).
`logger` этого модуля внесён в `ignore_logger`, иначе строка журнала об
отказе ушла бы в мониторинг через LoggingIntegration (event_level=ERROR),
снова попала бы в скраб, снова упала рекурсия, защиты от которой в SDK
нет (проверено на коде до фикса: не завершается). Красный, если
`ignore_logger` убрать: обработчик войдёт повторно.
"""
probe = json.loads(_probe(module))
assert (
probe["scrub_handler_entries"] == 1
), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг"
assert probe["counts"]["scrub_failure"] == 1

View file

@ -17,10 +17,66 @@
#
# Postgres + Redis run alongside the app on the same VM (Discovery mode).
# Volumes are shared with docker-compose.yml so switching between files preserves data.
#
# ── logging: journald (#2761) ────────────────────────────────────────────────
# До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ
# и живёт в /var/lib/docker/containers/<id>/ (умирает вместе с контейнером).
# Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН
# способ на обе половины, расхождение двух стеков дороже в поддержке.
#
# Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера):
# glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека
# postgres 2.2 ← 162 МБ за 74 дня
# backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5
# ИТОГО ~39 МБ/сутки
# Бюджет journald (замер там же, сообщение самого systemd-journald):
# "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён
# (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G).
# Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки
# 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины.
# Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с
# самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс
# пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ.
#
# КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть):
# docker logs gendesign-backend-1 # как и раньше — только текущий контейнер
# # История через пересоздания: журнал принадлежит root:systemd-journal, а
# # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и
# # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт
# # "No entries". Рабочий однострочник — через docker-группу:
# docker run --rm -v /:/host:ro alpine chroot /host sh -c \
# 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"'
# # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста
# # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760).
# # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID=<id>
# # — так читается лог УЖЕ УДАЛЁННОГО контейнера).
# Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl
# заработает напрямую (host-config, не этот файл). До этого правка не регрессия.
#
# ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так
# что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так
# force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки:
# postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка
# MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без
# ночных прогонов.
# Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при
# флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker
# 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это
# host drop-in journald.conf.d, не этот файл.
# tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями.
#
# ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной
# разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры
# просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size.
x-logging: &default-logging
driver: journald
options:
tag: "{{.Name}}"
services:
postgres:
image: postgis/postgis:16-3.4
logging: *default-logging
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
@ -46,6 +102,7 @@ services:
redis:
image: redis:7-alpine
logging: *default-logging
restart: unless-stopped
volumes:
- redis_data:/data
@ -54,6 +111,40 @@ services:
interval: 10s
timeout: 3s
retries: 5
# #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ
# его достать. До этого redis жил только в gendesign_default, а tradein — в
# gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не
# резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а
# отсутствующая связность (см. #2709).
#
# Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml
# поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES —
# ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый
# сервис в tradein-compose в этот список не попадает и `--no-deps` его не
# подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал
# бы в пустоту. Правка того списка = правка deploy-tradein.yml, который
# сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком.
#
# aliases: тот же приём, что уже применён к postgres выше — стабильное имя
# gendesign-redis вместо compose-зависимого gendesign-redis-1.
# ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется
# блок networks:, неявная привязка к default пропадает, и backend/worker/
# beat/glitchtip потеряли бы брокер (та же грабля описана у postgres).
#
# Разделение ключей — по НОМЕРУ БД, инстанс общий:
# db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей
# db1 — trade-in (SearchCache) ← вводится здесь
# db2 — glitchtip (см. REDIS_URL ниже)
# Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на
# брокере celery вытеснял бы поставленные в очередь таски. Значит tradein
# обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если
# tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая
# celery. Тогда — отдельный инстанс, а не смена политики вытеснения.
networks:
default: {}
shared:
aliases:
- gendesign-redis
# OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI
# вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze —
@ -76,6 +167,7 @@ services:
# отдельно (docs/osrm-routing.md).
osrm:
image: osrm/osrm-backend:latest
logging: *default-logging
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm
volumes:
@ -103,6 +195,7 @@ services:
# (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000.
osrm-walk:
image: osrm/osrm-backend:latest
logging: *default-logging
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
volumes:
@ -118,6 +211,7 @@ services:
backend:
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
# .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG).
# required: false — compose не падает если файла нет (первый деплой).
@ -153,6 +247,7 @@ services:
frontend:
image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
@ -172,6 +267,7 @@ services:
worker:
# Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium.
image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
@ -201,6 +297,7 @@ services:
beat:
# Lean backend-образ (без Chromium) — beat только триггерит таски в Redis.
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
@ -219,6 +316,7 @@ services:
glitchtip-web:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-web
logging: *default-logging
# profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`.
# Bootstrap script activates the profile after DB + secrets are ready.
# On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env.
@ -254,6 +352,7 @@ services:
glitchtip-worker:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-worker
logging: *default-logging
profiles: ["glitchtip"]
depends_on:
postgres:
@ -272,6 +371,7 @@ services:
caddy:
image: caddy:2
logging: *default-logging
restart: unless-stopped
ports:
- "80:80"
@ -310,6 +410,7 @@ services:
# deploy.yml запускает: docker compose build glitchtip-auth-forwarder
build: ./ops/glitchtip-auth-forwarder
container_name: gendesign-auth-forwarder
logging: *default-logging
restart: unless-stopped
environment:
GLITCHTIP_DSN: ${GLITCHTIP_DSN}

View file

@ -69,7 +69,7 @@
| `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) |
| `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** |
| `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** |
| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) |
| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — |
### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`)
@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`):
---
## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436
(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth.
Поле оставлено в `core/deps.py` только для быстрого rollback.
Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
**Ротировать нечего: переменной больше нет.**
**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока
`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F**
(процедура: `sed` в `backend/.env.runtime``up -d --force-recreate --no-deps backend beat`,
downtime отсутствует). Фактический прод-прогон ротации — операционное действие
(Anton), не выполняется в рамках этого PR.
История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран
со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и
dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до
#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему
репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте —
в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно
означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть
хранение поля не экономило ничего. Удалено вместе с `core/deps.py`,
`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах.
Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**:
1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only`
для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста).
2. Caddy basic_auth на весь `gendsgn.ru` (PR #426).
Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно:
`Settings.model_config``extra="ignore"`, лишняя переменная безвредна.
`JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует**
добавить в реестр (класс **F**) при внедрении JWT-аутентификации.

View file

@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
import { LandTab } from "@/components/site-finder/LandTab";
import { MarketTab } from "@/components/site-finder/MarketTab";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
import { useCustomPois } from "@/hooks/useCustomPois";
@ -22,6 +22,7 @@ import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
} from "@/lib/api/weightProfiles";
import { useMe } from "@/lib/useMe";
// SiteMap imports Leaflet which requires browser APIs — load without SSR
const SiteMap = dynamic(
@ -129,16 +130,16 @@ function SiteFinderContent() {
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
const weightsChangeInitializedRef = useRef(false);
const [profileUserId, setProfileUserId] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_user_id") ?? ""),
);
const [adminToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
// Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения
// из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`,
// который сервер не читает с #437) — оба недостижимы без DevTools.
//
// 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без
// `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом
// `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без
// user_id → tram_stop 0.5 вместо 0.4 из профиля). То есть выбранный профиль
// молча не применялся бы, а UI показывал бы его ползунки.
const profileUserId = useMe().data?.username ?? "";
// Lazy init: считаем initialTab один раз на mount (useState всё равно
// игнорирует initializer после первого render — не тратим CPU).
const [tab, setTabState] = useState<TabId>(() => {
@ -192,12 +193,7 @@ function SiteFinderContent() {
setIsochrones(undefined);
mutate({
cad: currentData.cad_num,
options:
profileId != null
? { profileId }
: currentProfileUserId
? { profileUserId: currentProfileUserId, weights }
: { weights },
options: buildAnalyzeOptions(weights, profileId, currentProfileUserId),
});
// mutate is stable from useMutation — safe to omit from deps.
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
@ -216,12 +212,11 @@ function SiteFinderContent() {
// slider values are always respected even without a saved profile (#201).
mutate({
cad: cadNum,
options:
activeProfileId != null
? { profileId: activeProfileId }
: profileUserId
? { profileUserId, weights: currentWeights }
: { weights: currentWeights },
options: buildAnalyzeOptions(
currentWeights,
activeProfileId,
profileUserId,
),
});
}
@ -329,46 +324,9 @@ function SiteFinderContent() {
{/* Weight profile panel — collapsible, below header */}
<div style={{ marginBottom: 16 }}>
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
{!!adminToken && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
>
User ID (для профилей):
</label>
<input
type="text"
value={profileUserId}
placeholder="user-abc"
style={{
padding: "4px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 6,
width: 180,
}}
onChange={(e) => {
setProfileUserId(e.target.value);
if (typeof window !== "undefined") {
localStorage.setItem("admin_user_id", e.target.value);
}
}}
/>
</div>
)}
<WeightProfilePanel
currentWeights={currentWeights}
onWeightsChange={handleWeightsChange}
userId={profileUserId || undefined}
adminToken={adminToken || undefined}
/>
{/* Recalculation indicator shown while re-analyze is in-flight after
weights change (data already loaded, pendingWeightsChange set). */}

View file

@ -10,8 +10,8 @@
/* ===================== SCOPED TOKENS (dark) ===================== */
.pticaRoot[data-theme="dark"] {
--font-ui:
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI",
system-ui, sans-serif;
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui,
sans-serif;
--font-mono:
var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace,
monospace;
@ -907,31 +907,6 @@
letter-spacing: 0.08em;
}
/* ===================== PLACEHOLDER PANEL ===================== */
.placeholderPanel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
min-height: 240px;
border-style: dashed;
}
.placeholderPanel .soon {
font-size: 22px;
letter-spacing: 0.12em;
color: var(--text-soft);
font-weight: 700;
text-transform: uppercase;
}
.placeholderPanel p {
font-size: 10px;
color: var(--text-soft);
margin: 0;
max-width: 280px;
}
/* ===================== STATE SCREENS ===================== */
.stateScreen {
display: grid;

View file

@ -526,14 +526,15 @@ function VariantPanel({
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
: ""}
НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК
РФ); НДС начисляется только на паркинг (нежилые машиноместа).
Входной НДС по строительству уже учтён в себестоимости. Налог на
прибыль 25% (с 2025 года). Цена продажи жилья {" "}
РФ); НДС начисляется на нежилое паркинг (машиноместа) и
коммерцию 1-го этажа. Входной НДС по строительству уже учтён в
себестоимости. Налог на прибыль 25% (с 2025 года). Цена продажи
жилья {" "}
{financial.price_is_calibrated
? `калибрована по рынку (${priceSourceCaption(financial)})`
: "норматив класса (нет рыночных данных по участку)"}
; себестоимость СМР и цена паркинга нормативные ориентиры.
Коммерческие и офисные площади не учитываются.
; себестоимость СМР, цена паркинга и цена нежилого нормативные
ориентиры.
{financial.financing_enabled && financial.financing_is_simplified
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
"кредитом по ставке-нормативу, проценты капитализируются, " +
@ -656,7 +657,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
value={formatMoneyCompact(financial.gross_margin_rub)}
/>
<CascadeRow
label="НДС (паркинг)"
label="НДС (паркинг + коммерция)"
value={formatMoneyCompact(financial.vat_rub)}
/>
<CascadeRow

View file

@ -1,5 +0,0 @@
// ScoreCard is superseded by the tabbed dashboard in page.tsx.
// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab.
// File kept to avoid breaking any external imports; exports an empty stub.
export {};

View file

@ -3,6 +3,7 @@
import { useCallback, useState } from "react";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { useMe } from "@/lib/useMe";
import {
POI_CATEGORIES,
POI_DEFAULT_WEIGHTS,
@ -29,13 +30,6 @@ interface Props {
weights: Record<PoiCategoryKey, number>,
profileId: number | null,
) => void;
/**
* If provided, enables save/load from DB.
* Must be non-empty for CRUD functionality.
*/
userId?: string;
/** Admin token for CRUD API calls. */
adminToken?: string;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -66,12 +60,7 @@ function weightsEqual(
// ── Component ─────────────────────────────────────────────────────────────────
export function WeightProfilePanel({
currentWeights,
onWeightsChange,
userId,
adminToken,
}: Props) {
export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
const [open, setOpen] = useState(false);
// Local draft weights — editable before "Применить"
@ -89,11 +78,19 @@ export function WeightProfilePanel({
const [saveName, setSaveName] = useState("");
const [saveDefault, setSaveDefault] = useState(false);
// Profiles query (only when userId + adminToken provided)
const canUseCrud = !!userId && !!adminToken;
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
// Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили
// руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было
// взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в
// #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут
// RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется).
// В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто
// бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен.
const { data: me } = useMe();
const userId = me?.username ?? "";
const canUseCrud = !!userId;
const profilesQuery = useWeightProfiles(userId);
const createMutation = useCreateProfile(adminToken ?? "");
const createMutation = useCreateProfile();
// ── Handlers ────────────────────────────────────────────────────────────────
@ -117,7 +114,7 @@ export function WeightProfilePanel({
}
const handleSaveProfile = useCallback(async () => {
if (!canUseCrud || !userId || !adminToken) {
if (!canUseCrud || !userId) {
setShowSaveDialog(false);
return;
}
@ -136,15 +133,7 @@ export function WeightProfilePanel({
} catch {
// Error visible through createMutation.error
}
}, [
canUseCrud,
userId,
adminToken,
saveName,
draft,
saveDefault,
createMutation,
]);
}, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]);
// ── Derived ─────────────────────────────────────────────────────────────────
@ -242,6 +231,7 @@ export function WeightProfilePanel({
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
title={`Профили сохраняются отдельно для каждого пользователя. Ваш: ${userId}`}
>
Профиль:
</label>
@ -278,10 +268,13 @@ export function WeightProfilePanel({
</div>
)}
{/* Hint when no crud */}
{/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт
Caddy basic_auth) остаётся для dev-запуска без прокси, где
/api/v1/me отвечает 401. Текст называет причину, а не действие:
вводить тут больше нечего. */}
{!canUseCrud && (
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
Укажите User ID и Admin Token для сохранения профилей.
Пользователь не определён сохранение профилей недоступно.
</p>
)}
@ -362,6 +355,9 @@ export function WeightProfilePanel({
}}
>
<SectionLabel>Новый профиль</SectionLabel>
<p style={{ fontSize: 11, color: "#6b7280", margin: 0 }}>
Сохранится для пользователя {userId} другие его не увидят.
</p>
<input
autoFocus
type="text"

View file

@ -0,0 +1,151 @@
/**
* #2782 CRUD профилей весов был недостижим никому без DevTools.
*
* Панель отпирала сохранение/загрузку профилей значением `admin_token` из
* localStorage. Записывать его было нечем: инпут удалили в #442. Сервер этот
* заголовок к тому моменту уже не читал (#437, остатки убраны в #2775) то есть
* фича стояла за признаком, который ничего не решал.
*
* Тесты ниже пиннят три вещи, каждая из которых на старом коде красная:
* 1) панель берёт владельца профилей из /api/v1/me и включает CRUD без пропсов;
* 2) в запросы CRUD не уходит X-Admin-Token;
* 3) `profileId` не уходит в /analyze без `profileUserId` (иначе бэкенд молча
* считает по системным весам, рапортуя source="profile").
*/
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WeightProfilePanel } from "../WeightProfilePanel";
import { buildAnalyzeOptions } from "@/hooks/useSiteAnalysis";
import { POI_DEFAULT_WEIGHTS } from "@/lib/api/weightProfiles";
const fetchMock = vi.fn<typeof fetch>();
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const PROFILE = {
id: 1,
user_id: "admin",
profile_name: "Мой профиль",
weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 },
is_default: true,
description: null,
created_at: "2026-05-15T05:30:51Z",
updated_at: "2026-05-15T05:30:51Z",
};
function renderPanel() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<WeightProfilePanel
currentWeights={{ ...POI_DEFAULT_WEIGHTS }}
onWeightsChange={() => {}}
/>
</QueryClientProvider>,
);
}
/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */
async function openPanel() {
const { default: userEvent } = await import("@testing-library/user-event");
await userEvent.setup().click(screen.getByText("POI Веса"));
}
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockImplementation(async (input) => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/api/v1/me")) {
return jsonResponse({
username: "admin",
role: "admin",
allowed_paths: ["/**"],
deny_paths: [],
});
}
if (url.includes("/weight-profiles")) {
return jsonResponse([PROFILE]);
}
throw new Error(`unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => {
it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => {
renderPanel();
await openPanel();
// Профили грузятся по user_id из /me, без ручного ввода и без токена.
await waitFor(() =>
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
);
expect(screen.getByText("Профиль:")).toBeTruthy();
});
it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => {
renderPanel();
await openPanel();
await waitFor(() =>
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
);
expect(screen.queryByText(/Admin Token/i)).toBeNull();
expect(screen.queryByText(/Укажите User ID/i)).toBeNull();
});
it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => {
renderPanel();
await openPanel();
await waitFor(() => {
const crudCall = fetchMock.mock.calls.find(([input]) =>
String(input).includes("/weight-profiles"),
);
expect(crudCall).toBeTruthy();
const headers = new Headers(
(crudCall?.[1] as RequestInit | undefined)?.headers,
);
expect(headers.has("X-Admin-Token")).toBe(false);
});
});
});
describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => {
const weights = { ...POI_DEFAULT_WEIGHTS };
it("выбранный профиль уходит вместе с profileUserId", () => {
expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({
profileId: 7,
profileUserId: "admin",
});
});
it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => {
const options = buildAnalyzeOptions(weights, 7, "");
expect(options.profileId).toBeUndefined();
expect(options.weights).toEqual(weights);
});
it("без профиля уходят inline-веса и владелец для default-профиля", () => {
expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({
profileUserId: "admin",
weights,
});
});
});

View file

@ -1,82 +0,0 @@
"use client";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
interface AnalysisBreadcrumbProps {
cadNum: string;
}
export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) {
return (
<nav
aria-label="Breadcrumb"
style={{
display: "flex",
alignItems: "center",
gap: 4,
flexWrap: "wrap",
minWidth: 0,
flex: 1,
}}
>
{/* SiteFinder root */}
<Link
href="/site-finder"
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
whiteSpace: "nowrap",
}}
>
SiteFinder
</Link>
<ChevronRight
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
aria-hidden
/>
{/* Cad number */}
<Link
href={`/site-finder?selected=${encodeURIComponent(cadNum)}`}
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 260,
}}
title={cadNum}
>
{cadNum}
</Link>
<ChevronRight
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
aria-hidden
/>
{/* Current page */}
<span
aria-current="page"
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--fg-primary)",
whiteSpace: "nowrap",
}}
>
Анализ
</span>
</nav>
);
}

View file

@ -1,266 +0,0 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { ExternalLink } from "lucide-react";
// ── Types ─────────────────────────────────────────────────────────────────────
interface SubSection {
id: string;
label: string;
}
interface NavSection {
id: string;
label: string;
sub?: SubSection[];
}
// ── Config ────────────────────────────────────────────────────────────────────
const NAV_SECTIONS: NavSection[] = [
{ id: "section-1", label: "1. Объект" },
{ id: "section-2", label: "2. Земля и риски" },
{
id: "section-3",
label: "3. Рынок",
sub: [
{ id: "section-3-1", label: "3.1 Настройки выборки" },
{ id: "section-3-2", label: "3.2 Планировки" },
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
],
},
{ id: "section-4", label: "4. Оценка" },
{ id: "section-5", label: "5. Атмосфера" },
{
id: "section-6",
label: "6. Прогноз",
sub: [
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
{ id: "section-6-2", label: "6.2 Сценарии" },
{ id: "section-6-3", label: "6.3 Уверенность" },
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
],
},
];
// All section IDs in scroll order (for IntersectionObserver)
const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) =>
s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id],
);
// ── Component ─────────────────────────────────────────────────────────────────
export function AnalysisSidebar() {
const [activeId, setActiveId] = useState<string>(ALL_SECTION_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(null);
// Scrollspy via IntersectionObserver
useEffect(() => {
const candidates = ALL_SECTION_IDS.map((id) =>
document.getElementById(id),
).filter((el): el is HTMLElement => el !== null);
if (candidates.length === 0) return;
// Track which sections are visible; pick topmost visible one
const visible = new Set<string>();
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
visible.add(entry.target.id);
} else {
visible.delete(entry.target.id);
}
});
// Pick the topmost section that is currently visible
const next = ALL_SECTION_IDS.find((id) => visible.has(id));
if (next) setActiveId(next);
},
{
root: null,
// Trigger when section top enters top 60% of viewport
rootMargin: "-8px 0px -40% 0px",
threshold: 0,
},
);
candidates.forEach((el) => observerRef.current!.observe(el));
return () => {
observerRef.current?.disconnect();
};
}, []);
function handleAnchorClick(
e: React.MouseEvent<HTMLAnchorElement>,
targetId: string,
) {
e.preventDefault();
const el = document.getElementById(targetId);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
setActiveId(targetId);
}
return (
<aside
style={{
width: 240,
flexShrink: 0,
background: "var(--bg-card)",
borderRight: "1px solid var(--border-card)",
padding: "16px 12px",
display: "flex",
flexDirection: "column",
gap: 4,
position: "sticky",
top: 56,
height: "calc(100vh - 56px)",
overflowY: "auto",
}}
>
{/* Section label */}
<p
style={{
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: "0 0 8px",
padding: "0 4px",
}}
>
Навигация
</p>
{/* Nav items */}
{NAV_SECTIONS.map((section) => {
const isParentActive =
activeId === section.id ||
section.sub?.some((s) => s.id === activeId);
return (
<div key={section.id}>
<a
href={`#${section.id}`}
onClick={(e) => handleAnchorClick(e, section.id)}
style={{
display: "block",
padding: "7px 10px",
borderRadius: 8,
fontSize: 13,
fontWeight: isParentActive ? 600 : 400,
color: isParentActive ? "var(--accent)" : "var(--fg-secondary)",
background: isParentActive
? "var(--accent-soft)"
: "transparent",
textDecoration: "none",
transition: "background 100ms, color 100ms",
lineHeight: 1.4,
}}
>
{section.label}
</a>
{/* Sub-sections */}
{section.sub && (
<div
style={{
paddingLeft: 12,
marginTop: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{section.sub.map((sub) => {
const isSubActive = activeId === sub.id;
return (
<a
key={sub.id}
href={`#${sub.id}`}
onClick={(e) => handleAnchorClick(e, sub.id)}
style={{
display: "block",
padding: "5px 10px",
borderRadius: 6,
fontSize: 12,
fontWeight: isSubActive ? 600 : 400,
color: isSubActive
? "var(--accent)"
: "var(--fg-tertiary)",
background: isSubActive
? "var(--accent-soft)"
: "transparent",
textDecoration: "none",
transition: "background 100ms, color 100ms",
lineHeight: 1.4,
}}
>
{sub.label}
</a>
);
})}
</div>
)}
</div>
);
})}
{/* Sources footer */}
<div
style={{
marginTop: "auto",
paddingTop: 16,
borderTop: "1px solid var(--border-soft)",
}}
>
<p
style={{
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: "0 0 6px",
padding: "0 4px",
}}
>
Источники
</p>
{[
{ label: "Росреестр / ЕГРН", href: "https://rosreestr.gov.ru" },
{ label: "НСПД", href: "https://nspd.gov.ru" },
{ label: "2ГИС / OSM", href: "https://2gis.ru" },
].map(({ label, href }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 4px",
fontSize: 11,
color: "var(--fg-tertiary)",
textDecoration: "none",
}}
>
<ExternalLink size={10} strokeWidth={1.5} />
{label}
</a>
))}
</div>
</aside>
);
}

View file

@ -1,410 +0,0 @@
"use client";
/**
* MassingEconomics LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b,
* epic #1953).
*
* Driven by the interactive 3D MassingScene: every time the user drags the
* этажность / секций sliders, Section7Concept maps the scene's `computeModel`
* result + the analysis context into a `MassingProgram` and hands it here via
* `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and
* render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость /
* прибыль / ROI).
*
* Robustness:
* debounce slider drags fire many programs; only the settled one is sent.
* latest-wins an in-flight request is superseded by a newer one via a
* monotonic request id; a stale response is dropped, never overwriting a
* fresher result (mutateAsync + id guard, no UI flicker from out-of-order).
* last-good on a failed recompute we keep the last successful values and
* show a subtle inline note rather than blanking the panel.
* skeleton a plain grey fade KPI grid while the FIRST recompute is in
* flight (no shimmer, per ui-conventions).
*
* Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but
* this strip uses the light KPI tokens via the shared KpiCard.
*/
import { useEffect, useRef, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { KpiCard } from "@/components/analytics/KpiCard";
import { Section } from "@/components/analytics/Section";
import {
priceSourceCaption,
useRecomputeMassing,
type FinancialModel,
type MassingProgram,
type MassingRecomputeOutput,
type Teap,
} from "@/lib/concept-api";
const DEBOUNCE_MS = 250;
// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ─────────
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
function formatMoneyCompact(rub: number): string {
const abs = Math.abs(rub);
if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
return `${nf.format(Math.round(rub))}`;
}
function formatInt(n: number): string {
return nf.format(Math.round(n));
}
function formatPct(fraction: number): string {
return `${(fraction * 100).toFixed(1)}%`;
}
function formatFar(far: number): string {
return far.toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
// ── KPI grid ───────────────────────────────────────────────────────────────────
interface KpiGridProps {
teap: Teap;
financial: FinancialModel;
/** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */
farTarget: number;
/** True → факт-КСИТ превышает регламентный потолок (model.over). */
ksitOver: boolean;
/** Dim the strip while a fresher recompute is in flight (last-good values). */
stale: boolean;
}
function KpiGrid({
teap,
financial,
farTarget,
ksitOver,
stale,
}: KpiGridProps) {
const netPositive =
financial.net_profit_rub > 0
? true
: financial.net_profit_rub < 0
? false
: null;
return (
<div
style={{
opacity: stale ? 0.55 : 1,
transition: "opacity 150ms linear",
}}
>
{/* ТЭП */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Общая площадь (GFA)"
value={formatInt(teap.total_floor_area_sqm)}
unit="м²"
hint="Поэтажная площадь всех корпусов = пятно застройки × этажность."
/>
<KpiCard
label="Продаваемая площадь"
value={formatInt(teap.residential_area_sqm)}
unit="м²"
hint="Жилая к продаже = (GFA нежилой 1-й этаж) × коэффициент эффективности класса."
/>
<KpiCard
label="Квартир"
value={formatInt(teap.apartments_count)}
unit="шт"
hint="Продаваемая площадь ÷ средняя площадь квартиры класса."
/>
<KpiCard
label="КСИТ — факт / цель"
value={`${formatFar(teap.density)} / ${formatFar(farTarget)}`}
delta={{
value: ksitOver
? "Факт превышает регламентный потолок"
: "В пределах регламента",
positive: ksitOver ? false : true,
}}
hint="КСИТ (коэффициент строительного использования) = надземная GFA ÷ площадь участка. Цель — предельный max_far по регламенту НСПД."
/>
</div>
{/* Финмодель */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
marginTop: 12,
}}
>
<KpiCard
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}
hint="Продаваемая площадь × цена продажи м² (+ машиноместа и нежилой 1-й этаж по ценам класса)."
/>
<KpiCard
label="Себестоимость"
value={formatMoneyCompact(financial.cost_rub)}
hint="Строительство (GFA × удельная по классу) + ПИР, сети, услуги застройщика, непредвиденные, маркетинг + стоимость земли."
/>
<KpiCard
label="Чистая прибыль"
value={formatMoneyCompact(financial.net_profit_rub)}
delta={{
value:
netPositive === true
? "Положительная (после НДС и налога на прибыль)"
: netPositive === false
? "Отрицательная (после НДС и налога на прибыль)"
: "Нулевая",
positive: netPositive,
}}
hint="Выручка себестоимость НДС на нежилое налог на прибыль 25%. Жильё по ДДУ от НДС освобождено."
/>
<KpiCard
label="ROI на затраты"
value={formatPct(financial.roi)}
delta={{
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
positive: null,
}}
hint="Чистая прибыль ÷ себестоимость. Маржа считается от выручки."
/>
<KpiCard
label="NPV (DCF)"
value={formatMoneyCompact(financial.npv_rub)}
delta={{
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
positive:
financial.npv_rub > 0
? true
: financial.npv_rub < 0
? false
: null,
}}
hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct(
financial.discount_rate_used,
)} годовых).`}
/>
<KpiCard
label="IRR (DCF, годовой)"
value={formatPct(financial.irr)}
delta={{
value: financial.irr_is_proxy
? "Оценочный (вырожденный поток, не DCF)"
: "Дисконтированный денежный поток",
positive: financial.irr_is_proxy
? null
: financial.irr > financial.discount_rate_used
? true
: false,
}}
hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)."
/>
<KpiCard
label="Цена продажи жилья"
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
delta={{ value: priceSourceCaption(financial), positive: null }}
hint="Цена, заложенная в выручку: медиана объявлений Objective по району (источник указан выше)."
/>
</div>
</div>
);
}
// ── Skeleton (grey fade, no shimmer — ui-conventions) ──────────────────────────
function SkeletonGrid() {
const cells = Array.from({ length: 7 });
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
aria-hidden="true"
>
{cells.map((_, i) => (
<div
key={i}
style={{
height: 92,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
}}
/>
))}
</div>
);
}
// ── Component ───────────────────────────────────────────────────────────────────
interface Props {
/**
* The current massing program (Σ footprint × floors + context), mapped by
* Section7Concept from the 3D scene's computeModel result. `null` until the
* scene has fired its first onModelChange (or when geometry is missing).
*/
program: MassingProgram | null;
/** Регламентная КСИТ-цель (max_far) — for the факт/цель comparison + over-cap. */
farTarget: number;
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
ksitOver: boolean;
/**
* True when the parcel is regulatorily constrained for МКД (gate-blocked /
* non-residential / ЗОУИТ-СЗЗ) drives the honest «условный расчёт» caveat
* below the strip alongside the negative-economics case.
*/
gateConstrained?: boolean;
}
export function MassingEconomics({
program,
farTarget,
ksitOver,
gateConstrained = false,
}: Props) {
const recompute = useRecomputeMassing();
// Last successful result kept locally so a failed/stale recompute never blanks
// the panel (last-good values stay on screen).
const [result, setResult] = useState<MassingRecomputeOutput | null>(null);
const [errored, setErrored] = useState(false);
// Monotonic request id → latest-wins: a response is applied only if it belongs
// to the most recently issued request, so out-of-order arrivals are dropped.
const reqIdRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable JSON key so we only recompute when the program actually changes
// (not on every parent re-render that hands an equal-but-new object).
const programKey = program ? JSON.stringify(program) : null;
useEffect(() => {
if (!program) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const id = ++reqIdRef.current;
recompute
.mutateAsync(program)
.then((out) => {
if (id !== reqIdRef.current) return; // stale — a newer request won.
setResult(out);
setErrored(false);
})
.catch(() => {
if (id !== reqIdRef.current) return; // stale failure — ignore.
setErrored(true); // keep last-good `result`.
});
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// recompute is a stable mutation object; we key off the serialized program.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [programKey]);
// No program yet → nothing to show (parent gates this on geometry anyway).
if (!program) return null;
// First recompute in flight, no last-good value yet → skeleton.
if (!result) {
return (
<Section
title="Экономика по 3D-модели"
subtitle="Пересчёт ТЭП и финмодели по текущей массе застройки (этажность / секции) из 3D-модели слева."
>
<SkeletonGrid />
</Section>
);
}
// A fresher request is in flight over the last-good values.
const stale = recompute.isPending;
// Honest «условный расчёт» caveat: when the economics turn negative OR the
// parcel is regulatorily constrained (gate-blocked / ЗОУИТ-СЗЗ / нежилое),
// we say so plainly rather than presenting the figures as a viable project.
const economicsNegative =
result.financial.net_profit_rub < 0 || result.financial.npv_rub < 0;
const showConditionalNote = economicsNegative || gateConstrained;
return (
<Section
title="Экономика по 3D-модели"
subtitle="Пересчитывается вживую при изменении этажности / секций в 3D-модели слева. Цена продажи — из оценки участка, без рыночного DB-запроса; ТЭП синтезируется из массинг-программы."
>
<KpiGrid
teap={result.teap}
financial={result.financial}
farTarget={farTarget}
ksitOver={ksitOver}
stale={stale}
/>
{showConditionalNote ? (
<p
role="note"
style={{
margin: "12px 0 0",
display: "flex",
alignItems: "flex-start",
gap: 8,
fontSize: 12,
lineHeight: "16px",
color: "var(--warn)",
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden="true"
style={{ flexShrink: 0, marginTop: 1 }}
/>
<span>
Расчёт условный: участок ограничен регламентом (см. блокеры выше)
и/или экономика отрицательна при текущих вводных. Измените
этажность, число секций или класс модель пересчитается.
</span>
</p>
) : null}
{errored ? (
<p
role="status"
style={{
margin: "12px 0 0",
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
color: "var(--warn)",
}}
>
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden="true" />
Не удалось пересчитать экономику по последнему изменению показаны
предыдущие значения. Измените параметры ещё раз для повторного
расчёта.
</p>
) : null}
</Section>
);
}

View file

@ -1,104 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Building2 } from "lucide-react";
// ── Helpers ───────────────────────────────────────────────────────────────────
function getStoredOrgId(): string | null {
// Guard against SSR — localStorage not available on server
if (typeof window === "undefined") return null;
try {
return localStorage.getItem("gd_org_id");
} catch {
return null;
}
}
function orgInitials(orgId: string): string {
// Build a 2-letter monogram from org ID string
const parts = orgId
.toUpperCase()
.replace(/[^A-ZА-Я0-9]/gu, " ")
.split(" ")
.filter(Boolean);
if (parts.length === 0) return "??";
if (parts.length === 1) return parts[0].slice(0, 2);
return parts[0][0] + parts[1][0];
}
// ── Component ─────────────────────────────────────────────────────────────────
export function UserAvatar() {
const [orgId, setOrgId] = useState<string | null>(null);
// Hydration-safe: read localStorage after mount
useEffect(() => {
setOrgId(getStoredOrgId());
}, []);
const displayLabel = orgId ?? "Demo Org";
const initials = orgId ? orgInitials(orgId) : "DO";
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexShrink: 0,
}}
title={displayLabel}
>
{/* Avatar circle */}
<div
aria-hidden
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "var(--accent-soft)",
border: "1px solid var(--border-card)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{orgId ? (
<span
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--accent)",
letterSpacing: "0.02em",
}}
>
{initials}
</span>
) : (
<Building2
size={14}
strokeWidth={1.5}
style={{ color: "var(--accent)" }}
/>
)}
</div>
{/* Org name — hidden on narrow viewports via maxWidth trick */}
<span
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary)",
maxWidth: 140,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{displayLabel}
</span>
</div>
);
}

View file

@ -1,22 +0,0 @@
"use client";
/**
* PticaPlaceholderPanel honest "В разработке" panel for cockpit tabs/sections
* that aren't wired in INCREMENT 1 (Scenarios / Reports / Compare).
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
interface Props {
label: string;
hint?: string;
}
export function PticaPlaceholderPanel({ label, hint }: Props) {
return (
<div className={`${styles.panel} ${styles.placeholderPanel}`}>
<div className={styles.soon}>{label}</div>
{hint && <p>{hint}</p>}
</div>
);
}

View file

@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
// /fetch-status every 2s, re-triggers analyze когда status=ready.
export type FetchStatus =
| "ready"
| "fetching"
| "failed"
| "not_in_nspd"
| "invalid_format";
"ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
export interface FetchStatusResponse {
status: FetchStatus;
@ -52,6 +48,34 @@ export interface AnalyzeOptions {
weights?: Record<string, number> | null;
}
/**
* Собрать options для POST /analyze из состояния панели весов POI.
*
* 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
* стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
* profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
* not None` не выполняется, и веса тихо падают на системные. Ответ при этом
* рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
* #2782: `profile_id=1` без `profile_user_id` `tram_stop 0.5` вместо `0.4` из
* профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
*
* Когда пользователь неизвестен (dev без Caddy: /api/v1/me 401) шлём inline
* `weights`. Они всегда равны ползункам панели, так что расхождения нет.
*/
export function buildAnalyzeOptions(
weights: Record<string, number>,
profileId: number | null,
profileUserId: string,
): AnalyzeOptions {
if (profileId != null && profileUserId) {
return { profileId, profileUserId };
}
if (profileUserId) {
return { profileUserId, weights };
}
return { weights };
}
/**
* Custom hook для analyze flow с graceful on-demand fetch fallback.
*

View file

@ -91,35 +91,37 @@ export const POI_WEIGHT_MAX = 3;
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
function profilesHeaders(adminToken: string): HeadersInit {
return { "X-Admin-Token": adminToken };
}
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
// с корректным токеном, с мусорным и без заголовка вовсе.
//
// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
// (подставленный клиентом X-Authenticated-User туда же не проходит);
// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
// ── Hooks ─────────────────────────────────────────────────────────────────────
/** List all weight profiles for a given user_id. */
export function useWeightProfiles(userId: string, adminToken: string) {
export function useWeightProfiles(userId: string) {
return useQuery<WeightProfile[]>({
queryKey: ["weight-profiles", userId],
queryFn: () =>
apiFetch<WeightProfile[]>(
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
{
headers: profilesHeaders(adminToken),
},
),
enabled: !!userId && !!adminToken,
enabled: !!userId,
});
}
/** Create a new weight profile. Invalidates the list query on success. */
export function useCreateProfile(adminToken: string) {
export function useCreateProfile() {
const qc = useQueryClient();
return useMutation<WeightProfile, Error, WeightProfileCreate>({
mutationFn: (payload) =>
apiFetch<WeightProfile>(BASE_PATH, {
method: "POST",
headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
}),
onSuccess: (_, variables) => {
@ -131,11 +133,7 @@ export function useCreateProfile(adminToken: string) {
}
/** Update an existing weight profile by id. */
export function useUpdateProfile(
userId: string,
profileId: number,
adminToken: string,
) {
export function useUpdateProfile(userId: string, profileId: number) {
const qc = useQueryClient();
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
mutationFn: (payload) =>
@ -143,7 +141,6 @@ export function useUpdateProfile(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "PUT",
headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
},
),
@ -154,16 +151,13 @@ export function useUpdateProfile(
}
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
export function useDeleteProfile(userId: string, adminToken: string) {
export function useDeleteProfile(userId: string) {
const qc = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: (profileId) =>
apiFetch<void>(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "DELETE",
headers: profilesHeaders(adminToken),
},
{ method: "DELETE" },
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });

View file

@ -330,7 +330,7 @@ export interface MassingRecomputeOutput {
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
*
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
* with latest-wins sequencing handled by the caller (see MassingEconomics).
* with latest-wins sequencing handled by the caller.
*/
export function useRecomputeMassing() {
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({

View file

@ -7,7 +7,7 @@
* и иметь один безопасный канал кодирования кадастрового номера.
*
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
* NspdOpportunityBlock, ParcelDrawer.
*/
/**

View file

@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752).
ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала
на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за
чужой аналитической psql-сессией; вторая попытка деплоя ещё 16 минут. Опасность
не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за
ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих
data/sql то есть следующий блокирующий DDL повторил бы это.
`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный
CREATE INDEX он не оборвёт, а очередь не соберёт. Срабатывание = красный деплой
(ON_ERROR_STOP=on) вместо тихой очереди перед приложением.
ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант
«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4:
`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW
lock_timeout` 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX
CONCURRENTLY` тот ждёт завершения параллельных транзакций через VirtualXactLock,
и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная
сессия просто держала открытую транзакцию (ACCESS SHARE с CIC вообще не
конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение
изготавливало бы ровно ту аварию, от которой заведена проверка невалидных
индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят
противоположной политики, поэтому granularity файл, а не раннер.
ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца
сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL`
молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента.
ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы
задним числом не переписываются. Гейт смотрит только вперёд.
Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# каталог миграций -> минимальный NN, с которого правило обязательно.
# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая).
SQL_DIRS: dict[str, int] = {
"data/sql": 189,
"tradein-mvp/backend/data/sql": 250,
}
# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE,
# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать
# в очередь и увести за собой запросы приложения.
BLOCKING = re.compile(
r"\b(?:"
r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|"
r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|"
r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|"
r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL"
r")\b",
re.IGNORECASE,
)
# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку).
# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE
# по-прежнему обязан прикрыть свой ALTER.
CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE)
BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE)
SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE)
SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE)
NN_PREFIX = re.compile(r"^(\d+)")
def strip_noise(sql: str) -> str:
"""Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на
пробелы (сохраняя длину и переводы строк номера строк не съезжают).
Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...'
легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу.
Тела $$...$$ (DO-блоки) НЕ затираются там живёт исполняемый DDL.
"""
out: list[str] = []
i, n = 0, len(sql)
while i < n:
ch = sql[i]
nxt = sql[i + 1] if i + 1 < n else ""
if ch == "-" and nxt == "-":
while i < n and sql[i] != "\n":
out.append(" ")
i += 1
elif ch == "/" and nxt == "*":
depth = 1 # в PostgreSQL блочные комментарии вложенные
out.append(" ")
i += 2
while i < n and depth:
if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*":
depth += 1
out.append(" ")
i += 2
elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/":
depth -= 1
out.append(" ")
i += 2
else:
out.append("\n" if sql[i] == "\n" else " ")
i += 1
elif ch == "'":
out.append("'")
i += 1
while i < n:
if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
out.append(" ")
i += 2
continue
if sql[i] == "'":
break
out.append("\n" if sql[i] == "\n" else " ")
i += 1
if i < n:
out.append("'")
i += 1
else:
out.append(ch)
i += 1
return "".join(out)
def scan(sql: str) -> list[str]:
"""-> список претензий к файлу; пустой список = файл в порядке."""
clean = strip_noise(sql)
statements = clean.split(";")
first_blocking: int | None = None
blocking_text = ""
for idx, stmt in enumerate(statements):
if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt):
first_blocking = idx
blocking_text = " ".join(stmt.split())[:80]
break
if first_blocking is None:
return []
set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None)
if set_local is None:
if any(SET_BARE_LT.search(s) for s in statements):
return [
f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). "
"Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY "
"в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN."
]
return [
f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой "
"строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет "
"в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)."
]
problems: list[str] = []
if not any(BEGIN_STMT.search(s) for s in statements[:set_local]):
problems.append(
"`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). "
"Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)."
)
if set_local > first_blocking:
problems.append(
f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — "
"к моменту DDL он ещё не действует. Подними его сразу под BEGIN."
)
return problems
def selftest() -> None:
ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
assert scan(ok) == [], scan(ok)
# красное: ровно случай 250 до фикса
bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0]
assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n")
assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n")
assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n")
assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n")
# красное: правильная строка, но в местах, где она не действует
assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0]
late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n"
assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late))
bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
assert "без LOCAL" in scan(bare)[0]
# зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC)
assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == []
assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == []
assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == []
# ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL
mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле"
mixed_ok = (
"CREATE INDEX CONCURRENTLY i ON t (c);\n"
"BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
)
assert scan(mixed_ok) == [], scan(mixed_ok)
# зелёное: DDL, которого нет — он в комментарии или в строковом литерале
assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == []
assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == []
assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == []
assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == []
assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == []
# зелёное: не-DDL миграции (backfill/seed) правила не касаются
assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == []
assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == []
print("selftest OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
failed = False
checked = 0
for dirname, min_nn in SQL_DIRS.items():
sql_dir = Path(dirname)
if not sql_dir.is_dir():
print(f"::error::{sql_dir} не найден — запускать из корня репозитория")
return 1
for path in sorted(sql_dir.glob("*.sql")):
m = NN_PREFIX.match(path.name)
if not m or int(m.group(1)) < min_nn:
continue # применено на проде до внедрения гейта — не переписываем
checked += 1
for problem in scan(path.read_text(encoding="utf-8")):
failed = True
print(f"::error file={path}::{problem}")
if failed:
return 1
print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Гейт: сервис-контейнер сборки не должен публиковать порт, занятый на VPS (#2757).
ПОЧЕМУ. Forgejo-раннер запускает и job, и сервис-контейнеры с `--network host`
(проверено: `docker inspect FORGEJO-ACTIONS-TASK-... -f {{.HostConfig.NetworkMode}}`
`host`). На том же хосте слушают боевые сервисы. Поэтому обычный приём
`services: postgres` + публикация `5432:5432` не поднимает тестовую БД, а тихо
уводит job В ПРОД: занять порт сервис-контейнер не может, а `localhost:5432` из
job'а — это боевой Postgres. В #2745 отказ спас только несовпадение пароля.
Список занятых портов не догадки, а замер `ss -ltnp` на bot-server 2026-08-06.
Опровергнутая по ходу предпосылка issue: Redis / CouchDB / GlitchTip НА ХОСТЕ НЕ
СЛУШАЮТ (в `docker ps` у них `6379/tcp`, `5984/tcp`, `8000/tcp` без `->`, они
живут в bridge-сетях) сервис-контейнер с их портами ни с чем не столкнётся,
и запрещать их значило бы ронять сборку на ровном месте.
Правильный способ дать сборке БД как в ci-tradein.yml: явный `docker run`
в bridge-сети БЕЗ публикации порта + строка подключения по IP контейнера.
Запуск: python3 scripts/check-workflow-ports.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# host-порт -> кто его уже занял (ss -ltnp на bot-server, 2026-08-06)
RESERVED: dict[int, str] = {
22: "sshd хоста",
53: "systemd-resolved (127.0.0.53)",
80: "gendesign-caddy-1 (0.0.0.0:80)",
443: "gendesign-caddy-1 (0.0.0.0:443)",
2222: "forgejo, git-over-ssh (0.0.0.0:2222)",
3000: "gendesign-frontend-1 (127.0.0.1:3000)",
5432: "gendesign-postgres-1 — БОЕВАЯ БД (127.0.0.1:5432)",
8000: "gendesign-backend-1 — боевой API (127.0.0.1:8000)",
}
WORKFLOW_DIR = Path(".forgejo/workflows")
# "8080:80", "127.0.0.1:5432:5432", "5432:5432/tcp" -> (host_port, container_port)
PORT_PAIR = re.compile(r"(?:(?:\d{1,3}\.){3}\d{1,3}:)?(\d{1,5}):(\d{1,5})(?:/(?:tcp|udp))?")
PUBLISH_FLAG = re.compile(r"(?:^|\s)(?:-p|--publish)[=\s]+(\S+)")
def scan(text: str) -> list[tuple[int, str, int]]:
"""-> [(номер строки, строка, занятый host-порт)] для публикаций в ports:/-p."""
hits: list[tuple[int, str, int]] = []
in_ports = False
ports_indent = 0
for lineno, raw in enumerate(text.splitlines(), 1):
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue # комментарий ничего не запускает (в них живут анти-примеры)
indent = len(raw) - len(raw.lstrip())
if in_ports and (not stripped.startswith("- ") or indent <= ports_indent):
in_ports = False
candidates: list[str] = []
if stripped.startswith("ports:"):
rest = stripped[len("ports:") :].strip()
if rest:
candidates.append(rest) # инлайн-форма: ports: ["5432:5432"]
else:
in_ports, ports_indent = True, indent
elif in_ports:
candidates.append(stripped)
candidates += PUBLISH_FLAG.findall(raw) # docker run -p / --publish
for candidate in candidates:
for host_port, _container_port in PORT_PAIR.findall(candidate):
if int(host_port) in RESERVED:
hits.append((lineno, stripped, int(host_port)))
return hits
def selftest() -> None:
assert scan(" ports:\n - 5432:5432\n") == [(2, "- 5432:5432", 5432)]
assert scan(' ports: ["127.0.0.1:8000:8000"]\n')[0][2] == 8000
assert scan(" docker run -p 3000:3000 nginx\n")[0][2] == 3000
assert scan(" docker run --publish=443:443 nginx\n")[0][2] == 443
# безопасные формы и посторонние двоеточия — не должны ронять сборку
assert scan(" ports:\n - 5432\n") == []
assert scan(" ports:\n - 15432:5432\n") == []
assert scan(" image: postgis/postgis:16-3.4\n") == []
assert scan(' run: journalctl --since "2026-08-07 22:00"\n') == []
assert scan(" # ports:\n # - 5432:5432\n") == []
assert scan(" env:\n DATABASE_URL: pg://u:p@localhost:5432/test\n") == []
# выход из блока ports: по отступу
assert scan(" ports:\n - 9999:9999\n image: x\n - 80:80\n") == []
print("selftest OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
if not WORKFLOW_DIR.is_dir():
print(f"::error::{WORKFLOW_DIR} не найден — запускать из корня репозитория")
return 1
failed = False
for path in sorted(WORKFLOW_DIR.glob("*.yml")):
for lineno, line, port in scan(path.read_text(encoding="utf-8")):
failed = True
print(
f"::error file={path},line={lineno}::публикация порта {port} — он занят "
f"на VPS ({RESERVED[port]}). Раннер работает в сети хоста: контейнер "
f"порт не займёт, а job уйдёт в этот прод-сервис (#2757). Поднимай "
f"сервис через `docker run` в bridge-сети БЕЗ публикации и ходи по IP "
f"контейнера — образец в .forgejo/workflows/ci-tradein.yml. || {line}"
)
if failed:
return 1
print("✓ ни один workflow не публикует занятый на VPS порт")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -15,7 +15,10 @@ frontend/node_modules/
frontend/.next/
frontend/out/
frontend/dist/
frontend/package-lock.json
# frontend/package-lock.json — TRACKED с #2770: Dockerfile и CI ставят `npm ci`,
# которому лок обязателен. Правишь frontend/package.json — регенерируй лок
# (`npm install --legacy-peer-deps --no-audit --no-fund`) в том же PR, иначе
# `npm ci` роняет сборку образа и весь deploy откатывается.
frontend/next-env.d.ts
# Docker

View file

@ -2239,9 +2239,11 @@ class UnifiedScrapeRunRow(BaseModel):
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
# has_running_run перестал бы держать single-run guard.
cancellable: bool = False
# #2686: диагноз для status='banned' — 'platform' (площадка заблокировала) или
# 'infra' (не отдал наш браузерный сайдкар). Без него оператор видит только
# «забанен» и делает вывод «площадка нас палит» на 80% наших же отказов.
# #2686: диагноз для status='banned' — 'platform' (площадка заблокировала),
# 'infra' (не отдал наш браузерный сайдкар) или 'unknown' (#2764 — причина не
# установлена; раньше такие прогоны молча получали 'platform'). Без него
# оператор видит только «забанен» и делает вывод «площадка нас палит» на 80%
# наших же отказов.
ban_kind: str | None = None
params: dict | None = None
counters: dict | None = None
@ -2583,7 +2585,8 @@ _DQ_LISTING_FIELDS: list[tuple[str, str]] = [
("lon", "lon IS NOT NULL"),
("kitchen_area_m2", "kitchen_area_m2 IS NOT NULL"),
("living_area_m2", "living_area_m2 IS NOT NULL"),
("ceiling_height", "ceiling_height IS NOT NULL"),
# #2699: одна колонка вместо двух. ceiling_height (019) DEPRECATED — писатели
# переведены на ceiling_height_m, исторические значения перенесены (мигр. 238).
("ceiling_height_m", "ceiling_height_m IS NOT NULL"),
("metro_stations", "metro_stations IS NOT NULL AND metro_stations <> '[]'::jsonb"),
]
@ -2597,7 +2600,7 @@ def get_data_quality(
Один проход per source через COUNT(*)...FILTER не N запросов.
Поля listings: description, photo_urls, address, lat/lon, kitchen_area_m2,
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
living_area_m2, ceiling_height_m (все источники, #2699), metro_stations.
houses: total, avito_validated_at%, rating_score%, house_type%.
house_reviews: общий count.

View file

@ -1951,14 +1951,45 @@ def get_street_deals(
# в ДКП сохранён) и попадает в разрыв наблюдаемого распределения 37.6 → 33.9.
# Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → 46.5% показывался.
#
# ПОШТУЧНЫЙ discount_pct В СТРОКАХ ТАБЛИЦЫ (#2672 п.3). Гейт гасил сводное число,
# а таблица под ним продолжала показывать проценты, посчитанные из ТЕХ ЖЕ пар:
# на живом Космонавтове (2-комн., медиана 37.6% погашена) шесть из первых
# двенадцати строк — от +42% до +77%, и все против одной и той же цены
# предложения. Масштаб на проде 2026-08-07 (427 реальных запросов из
# trade_in_estimates, 135 групп с парами): медиана погашена у 102 групп, и в
# них видно 3 678 строк с процентом — 72.8% всех показываемых процентов.
#
# Гасим строку там, и только там, где причина — свойство САМОЙ ПАРЫ:
# а) объявлений < MIN_DISTINCT_LISTINGS — тогда столбец «разница» это
# столбец цены сделки, поделённый на одну и ту же константу: он не даёт
# ни одного наблюдения сверх уже показанных цен, но выглядит как N торгов;
# б) медиана вне санитарного диапазона — по определению медианы это
# утверждение О СТРОКАХ: половина из них ещё дальше от рынка, чем она.
# «Мало пар» строку НЕ гасит: это свойство ВЫБОРКИ, про отдельную пару оно
# ничего не говорит, а микрокопия «пар всего 4, поэтому процент в строке не
# показываем» была бы ложной причиной. Цена этого исключения — 3 группы / 20
# строк на проде, где медианы нет, а проценты в строках есть.
# Флаги (а)/(б) считаются НЕЗАВИСИМО от порядка веток гейта: порядок «мало пар
# → одно объявление → диапазон» прячет вторую причину за первой, и на проде 55
# групп гаснут как «мало пар», хотя стоят ещё и на ОДНОМ объявлении. По ветке
# гейта строки гасились бы не там, где надо.
# Цена на проде: из 5 054 строк с процентом гаснет 3 658 (72.4%), остаётся
# 1 396. Само число «медианный торг» этой правкой НЕ меняется — 33 группы из
# 135 и до, и после (замер обеих версий модуля в одном процессе на ОДНИХ И ТЕХ
# ЖЕ живых парах). Обе цены — сделки и объявления — в строке остаются:
# убирается не данные, а наша подпись «торг» под их разностью.
#
# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится):
# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721).
# Гейт по различным объявлениям честный промежуточный шаг, а не решение:
# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё
# сравнивают сделку в одном доме с объявлением в другом.
# 2. Поштучный discount_pct в таблице пар НЕ гасится, когда сводное число
# погашено (#2672 п.3, фронт): под погашенной медианой видны строки +76%,
# +73% против той же одной цены предложения. Отдельная задача.
# 2. В группах, ПРОШЕДШИХ гейт, поштучные проценты остаются как есть — включая
# 426 строк из 1 396 (31%), лежащих вне того же диапазона [35%, +20%], по
# которому мы гасим медиану. Отдельного порога для ОДНОЙ пары у нас нет:
# диапазон калиброван на медианах групп, а у одной сделки законный разброс
# шире (занижение цены в ДКП — механизм поштучный, не медианный). Считать
# его = вводить некалиброванный порог, чего #2672 прямо предостерегает.
SALES_VS_LISTINGS_MIN_PAIRS = 10
SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -35.0
@ -2110,6 +2141,19 @@ def get_sales_vs_listings(
{p.listing_id for p in pairs if p.discount_pct is not None and p.listing_id is not None}
)
# #2672 п.3: те же две проверки, но применённые к КАЖДОЙ СТРОКЕ таблицы, а не
# к сводному числу (обоснование — в шапке секции, блок «ПОШТУЧНЫЙ ПРОЦЕНТ»).
# Считаются ДО гейта, потому что гейт обнуляет median_discount, и порядок его
# веток (мало пар → одно объявление → диапазон) прячет вторую причину за
# первой: на проде 55 групп гасятся как «мало пар», хотя стоят ещё и на ОДНОМ
# объявлении. Для строк важна причина, а не то, какая ветка сработала раньше.
pairs_stand_on_one_listing = n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS
median_is_implausible = median_discount is not None and not (
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT
<= median_discount
<= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT
)
# #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо
# отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь
# прочитает как поломку, а не как честность.
@ -2124,7 +2168,7 @@ def get_sales_vs_listings(
f"{len(discounts)} — на такой выборке медиана гуляет на десятки "
f"процентных пунктов."
)
elif n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS:
elif pairs_stand_on_one_listing:
# Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1»
# грамматично при любом значении, «на 1 различных объявлений» — нет.
median_discount_explanation = (
@ -2132,11 +2176,7 @@ def get_sales_vs_listings(
f"объявлений для сравнения всего {n_distinct_listings} — такой процент "
f"говорит о цене одной конкретной квартиры, а не о торге на улице."
)
elif not (
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT
<= median_discount
<= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT
):
elif median_is_implausible:
# Типографский минус (U+2212) — как в fmtDiscount на фронте.
shown = f"{median_discount:+.1f}".replace("-", "")
# Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно
@ -2159,6 +2199,50 @@ def get_sales_vs_listings(
)
median_discount = None
# #2672 п.3: под погашенной медианой строки таблицы продолжали показывать
# проценты из ТЕХ ЖЕ пар (живой кейс — Космонавтов: +76%, +73%, +63% против
# одной и той же цены предложения). Гасим их там, и только там, где причина —
# свойство самой пары; «мало пар» свойство ВЫБОРКИ, про отдельную строку оно
# ничего не говорит, поэтому одну строку не трогает (обоснование и цена —
# в шапке секции). Обе цены остаются в строке: мы убираем не данные, а нашу
# подпись «торг» под разностью, которой не можем ручаться.
if discounts and (pairs_stand_on_one_listing or median_is_implausible):
if pairs_stand_on_one_listing:
# Оба числа названы совместно с фразой медианы: там «сделок N», здесь
# «одна и та же цена» — читателю видно и сколько строк, и на скольких
# объявлениях они стоят.
row_explanation = (
"Проценты по каждой сделке тоже не показываем: все они считаются "
"против одной и той же цены объявления."
)
else:
# Медиана вне диапазона — это утверждение О СТРОКАХ: по определению
# медианы половина из них лежит по дальнюю сторону от неё, т.е. тоже
# вне рыночного диапазона. Значение здесь НЕ повторяем: в ветке
# диапазона оно уже названо предыдущим предложением (вышло бы дважды
# в одном абзаце), а в ветке «мало пар» мы его намеренно не
# показываем — и печатать его в пояснении было бы отказом на словах.
row_explanation = (
"Проценты по каждой сделке тоже не показываем: половина из них — "
"за пределами того, как торгуется рынок."
)
for pair in pairs:
pair.discount_pct = None
median_discount_explanation = (
f"{median_discount_explanation} {row_explanation}"
if median_discount_explanation
else row_explanation
)
logger.info(
"sales-vs-listings: per-row discount_pct gated street=%r rooms=%d rows=%d "
"distinct_listings=%d reason=%s",
street_name,
rooms,
len(discounts),
n_distinct_listings,
"one_listing" if pairs_stand_on_one_listing else "implausible_median",
)
logger.info(
"sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d "
"linkage=%.1f%% median_disc=%s",

View file

@ -75,7 +75,10 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
"yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
# Yandex Valuation enrichment (existing house attrs)
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],
"ceiling_height": ["cian_detail", "yandex_valuation"],
# #2699: запись "ceiling_height" удалена — колонки с таким именем в `houses`
# никогда не было (проверено на проде: 0 колонок LIKE '%ceiling%'), правило не
# могло сработать ни разу. Высота потолков — атрибут ОБЪЯВЛЕНИЯ
# (listings.ceiling_height_m), см. LISTING_FIELD_PRIORITY ниже.
}
# ---------------------------------------------------------------------------
@ -88,7 +91,10 @@ LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = {
"area_m2": ["cian_serp", "avito_detail"],
"living_area_m2": ["cian_serp"],
"kitchen_area_m2": ["cian_serp", "avito_detail"],
"ceiling_height": ["cian_detail"],
# #2699: ключ — имя РЕАЛЬНОЙ колонки. Был "ceiling_height" (019), которую
# эстиматор не читает; канон — ceiling_height_m, и её пишут все три источника,
# а не только cian_detail.
"ceiling_height_m": ["cian_detail", "avito_detail", "yandex_detail"],
"floor": ["cian_serp", "avito_detail"],
"total_floors": ["cian_serp", "avito_detail"],
"year_built": ["cian_serp"],

View file

@ -350,6 +350,19 @@ async def _job_domclick_detail_backfill(
await run_domclick_detail_backfill(db, run_id=run_id, params=params)
# ── house_coords_from_listings — sync set-based UPDATE в executor (#2771) ─────
async def _job_house_coords_from_listings(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.backfill_house_coords_from_listings import run_house_coords_from_listings
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_house_coords_from_listings(db, run_id=run_id, params=params),
)
# ── geoportal_coords_backfill — sync local exact match в executor (#1967) ─────
async def _job_geoportal_coords_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -586,6 +599,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"geoportal_coords_backfill": Handler(
_job_geoportal_coords_backfill, "geoportal_coords_backfill"
),
"house_coords_from_listings": Handler(
_job_house_coords_from_listings, "house_coords_from_listings"
),
"geocode_missing_listings": Handler(
_job_geocode_missing_listings, "geocode_missing_listings"
),

View file

@ -33,7 +33,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Callable, Mapping
from collections.abc import Callable, Collection, Mapping
from functools import cache
from typing import Any
@ -116,6 +116,12 @@ def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int:
# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние.
BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее
# #2764: причина НЕ установлена. Дефолт mark_banned — именно он, а не 'platform':
# на проде оба прогона, помеченных после миграции 218, получили 'platform' по
# умолчанию (ни один их не передавал), то есть метка выглядела доказательством, не
# будучи им. 'unknown' делает пробел измеримым (SELECT ban_kind, count(*)), а
# 'platform'/'infra' начинают означать ровно то, что доказано типом исключения.
BAN_KIND_UNKNOWN = "unknown"
def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
@ -515,9 +521,9 @@ def mark_banned(
error: str,
counters: dict[str, int],
*,
ban_kind: str = BAN_KIND_PLATFORM,
ban_kind: str = BAN_KIND_UNKNOWN,
) -> None:
"""Финализация run: status='banned' + диагноз ban_kind (#2686).
"""Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764).
Per migration 015 'banned' задокументирован как 'Avito вернул 403/captcha'.
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
@ -526,10 +532,11 @@ def mark_banned(
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
- BAN_KIND_PLATFORM площадка нас заблокировала (firewall/403/captcha);
- BAN_KIND_INFRA упала НАША инфраструктура (браузерный сайдкар/прокси).
- BAN_KIND_INFRA упала НАША инфраструктура (браузерный сайдкар/прокси);
- BAN_KIND_UNKNOWN (дефолт) причина не установлена.
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
текста ошибки. Default 'platform' = историческая семантика статуса, поэтому
вызывающие, которым разводить нечего, не меняются.
текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому
разводить нечего, ничего и не знает а не «знает, что виновата площадка».
Оба исхода одинаково сохраняют чекпоинт они отличаются только диагнозом.
@ -579,6 +586,7 @@ def mark_backfill_finished(
source: str,
aborted_by_blocks: bool = False,
fail_hint: str | None = None,
ban_kinds: Collection[str] = (),
) -> None:
"""Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'.
@ -609,6 +617,13 @@ def mark_backfill_finished(
которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи
контейнера на этот вопрос отвечать не могут они исчезают при пересоздании
контейнера, то есть на первом же деплое после ночного прогона.
`ban_kinds` диагнозы (ban_kind_of_exception) ВСЕХ блоков, которые задача
поймала за прогон; пустой (дефолт) = задача типы не различает. Схлопываем сами,
в одном месте на все три backfill'а: все блоки сошлись в одном диагнозе → он и
пишется; разошлись (или их типы ничего не доказывают) 'unknown'. Смешанный
прогон честнее пометить неизвестным, чем выбрать из двух причин ту, что
попалась последней какая из них оборвала прогон, мы не знаем (#2764).
"""
attempted = int(counters.get("attempted") or 0)
enriched = int(counters.get("enriched") or 0)
@ -626,7 +641,14 @@ def mark_backfill_finished(
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
mark_banned(db, run_id, reason, counters)
kinds = set(ban_kinds)
mark_banned(
db,
run_id,
reason,
counters,
ban_kind=kinds.pop() if len(kinds) == 1 else BAN_KIND_UNKNOWN,
)
return
if produced == 0:

View file

@ -20,6 +20,12 @@ rotate IP on every block, abort after max_consecutive_blocks. Статус об
Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина
отказа пишется в текст статуса прогона (_failure_signature) иначе она живёт
только в логах контейнера, а те исчезают на первом же деплое.
ДИАГНОЗ бана (#2764): каждый пойманный блок классифицируется по ТИПУ исключения
(ban_kind_of_exception) и уходит в scrape_runs.ban_kind. До этой правки прогон
3306 (blocked=5, 0 обогащено) получил 'platform' по УМОЛЧАНИЮ финализатор
диагноз не передавал, а browser-режим fetch_detail всё равно превращал отказ
сайдкара в AvitoBlockedError, так что передавать было бы нечего.
"""
from __future__ import annotations
@ -40,7 +46,7 @@ from scraper_kit.avito_exceptions import (
AvitoRateLimitedError,
)
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_exception
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
@ -365,6 +371,10 @@ async def run_avito_detail_backfill(
# Перепись причин (блоки + отказы) — переживает пересоздание контейнера,
# в отличие от логов; см. _failure_signature.
failure_census: Counter[str] = Counter()
# #2764: диагнозы всех блоков прогона по ТИПУ исключения. Сойдутся в один —
# он и попадёт в scrape_runs.ban_kind, разойдутся — 'unknown' (схлопывает
# mark_backfill_finished, один узел на все три backfill'а).
block_ban_kinds: set[str] = set()
for idx, row in enumerate(snapshot):
# Budget guard
@ -541,6 +551,7 @@ async def run_avito_detail_backfill(
consecutive_blocks += 1
counters.blocked += 1
failure_census[_failure_signature(e)] += 1
block_ban_kinds.add(ban_kind_of_exception(e))
do_sleep = False
logger.warning(
"avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
@ -657,6 +668,7 @@ async def run_avito_detail_backfill(
source="avito_detail_backfill",
aborted_by_blocks=aborted_by_blocks,
fail_hint=_top_failure(failure_census),
ban_kinds=block_ban_kinds,
)
logger.info(
"avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "

View file

@ -0,0 +1,323 @@
"""Перенос координат из объявлений в дом, когда объявления между собой согласны (#2771).
ЗАЧЕМ. Дом получает координаты РОВНО ОДИН РАЗ в момент создания, из породившего его
объявления (`matching/houses.py`). Объявление без координат создаёт дом без координат;
позже объявление геокодируется (`geoportal_coords_backfill`, `geocode_missing_listings`
обе живые ежедневные задачи), но пишут они в `listings`, а `ON CONFLICT` у вставки дома
обновляет только `address`. Обратно в дом координаты не возвращались никогда: из 52
источников расписания `houses.lat`/`houses.lon` не писал ни один. Замер на проде 2026-08-07:
1945 домов из 9625 (20.2%) без координат, при этом у 1490 из них объявления с координатами
УЖЕ ЕСТЬ. Эта задача недостающее звено проводки, и именно ПОВТОРЯЮЩЕЕСЯ: однократный
скрипт закрыл бы дыру на день, а приток новых домов без координат никуда не девается.
ЧТО ДЕЛАЕТ. Для каждого дома с lat IS NULL берёт geom всех связанных объявлений, считает
центроид и РАЗБРОС максимальное расстояние от центроида до объявления (ST_DistanceSphere,
метры на сфере). Разброс <= max_spread_m центроид пишется в дом; иначе дом НЕ трогается
и попадает в счётчик skipped_scattered.
ПОРОГ 125 м почему столько (замер на проде 2026-08-07, ST_DistanceSphere, не приближение
«градусы × 111320»). Гистограмма разброса по 258 домам с >1 различной точкой не даёт обрыва:
плотное ядро 0-125 м (150 домов), затем ровное плато ~4-5 домов на каждые 25 м вплоть до
400 м, и масса из 67 домов дальше 400 м. Раз в данных обрыва нет, порог берётся не из них,
а из ПОТРЕБИТЕЛЯ: гео-ограждение `house_dedup_merge` (#2187) считает два дома одним зданием
при ST_DistanceSphere <= 250 м. Записанный сюда центроид оценка со своей погрешностью
ровно в этот разброс; если разрешить каждому дому ошибаться на все 250 м, два дома с
максимальной ошибкой разъедутся на 500 м, и ограждение отвергнет верную пару (или сведёт
неверную). Отдаём своей погрешности ПОЛОВИНУ бюджета ограждения 125 м. Цена решения
мала и потому оно не несущее: 125 м пропускает 1382 дома, 250 м 1403, разница 21 дом,
и это не потеря, а отсрочка (дом остаётся NULL и попадёт в следующий прогон, если его
объявления сойдутся).
ЧЕГО НЕ ДЕЛАЕТ. Не обещает роста числа слияний дублей. Сейчас гео-ограждение отбраковывает
проигравших с NULL geom, потому что проверить тождество нечем; после переноса у него
появляется ВОЗМОЖНОСТЬ оценить пару часть сольётся, часть будет отвергнута правильно
(дома в разных городах). Ни то ни другое здесь не предрешается.
ДОМА С БОЛЬШИМ РАЗБРОСОМ не задача переноса, а дефект сопоставителя: в одну запись дома
сшиты объявления из разных населённых пунктов (худший на проде «ул. Кирова,4», разброс
290 км). Они не переносятся, считаются отдельным счётчиком и логируются поимённо (топ-5),
чтобы пропуск был виден, а не читался как «покрыто всё».
ИДЕМПОТЕНТНОСТЬ. UPDATE только по `h.lat IS NULL` (тот же приём, что в
`backfill_listings_coords_geoportal`). Повторный прогон не трогает уже проставленные
координаты и на чистой базе no-op с нулевыми счётчиками.
geom СТРОИТСЯ ЯВНО. Триггер `houses_set_geom_trg` на проде BEFORE INSERT OR UPDATE OF
lat, lon (проверено 2026-08-07), то есть geom построился бы и сам; UPDATE всё равно пишет
geom явно как это делает `_update_listing_coords` в geoportal-задаче чтобы правка не
зависела от живости триггера и не оказалась беззвучной, если его когда-нибудь снимут.
Запуск:
python -m app.tasks.backfill_house_coords_from_listings
python -m app.tasks.backfill_house_coords_from_listings --max-spread-m 125
Ежедневно через in-app scheduler (source='house_coords_from_listings', migration 239).
"""
from __future__ import annotations
import argparse
import logging
import time
from dataclasses import dataclass, field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# Половина 250-метрового гео-ограждения house_dedup_merge (#2187) — обоснование в docstring.
DEFAULT_MAX_SPREAD_M = 125.0
# «Разброс в километрах» — порог отдельного счётчика для дефектов сопоставителя (#2777).
SCATTER_DEFECT_M = 5000.0
# Сколько худших домов с разбросом назвать поимённо в логе.
_SCATTER_LOG_TOP = 5
# Кандидаты: КАЖДЫЙ дом без координат + агрегат по его объявлениям. LEFT JOIN, чтобы
# «объявлений нет вовсе» и «объявления есть, но без координат» различались в счётчиках.
# ST_Collect/count(geom) пропускают NULL — дом без геокодированных объявлений получает
# cen IS NULL → lat/lon NULL → в UPDATE не попадает (n_geocoded = 0).
_BUILD_CANDIDATES_SQL = """
CREATE TEMP TABLE _2771_house_coords ON COMMIT DROP AS
WITH coordless AS (
SELECT id FROM houses WHERE lat IS NULL OR lon IS NULL
),
linked AS (
SELECT c.id AS house_id, l.id AS listing_id, l.geom
FROM coordless c
LEFT JOIN listings l ON l.house_id_fk = c.id
),
agg AS (
SELECT house_id,
count(listing_id) AS n_listings,
count(geom) AS n_geocoded,
ST_Centroid(ST_Collect(geom)) AS cen
FROM linked
GROUP BY house_id
),
spread AS (
SELECT l.house_id,
max(ST_DistanceSphere(l.geom, a.cen)) AS spread_m
FROM linked l
JOIN agg a ON a.house_id = l.house_id
WHERE l.geom IS NOT NULL
GROUP BY l.house_id
)
SELECT a.house_id,
a.n_listings,
a.n_geocoded,
ST_Y(a.cen) AS lat,
ST_X(a.cen) AS lon,
s.spread_m
FROM agg a
LEFT JOIN spread s ON s.house_id = a.house_id
"""
_COUNTERS_SQL = """
SELECT count(*) AS candidates,
count(*) FILTER (WHERE n_listings = 0) AS skipped_no_listings,
count(*) FILTER (WHERE n_listings > 0 AND n_geocoded = 0) AS skipped_no_listing_coords,
count(*) FILTER (WHERE n_geocoded > 0
AND spread_m > CAST(:max_spread_m AS double precision))
AS skipped_scattered,
count(*) FILTER (WHERE n_geocoded > 0
AND spread_m > CAST(:defect_m AS double precision))
AS scattered_over_5km,
COALESCE(max(spread_m) FILTER (WHERE n_geocoded > 0
AND spread_m > CAST(:max_spread_m AS double precision)), 0)
AS worst_spread_m,
count(*) FILTER (WHERE n_geocoded > 0
AND spread_m <= CAST(:max_spread_m AS double precision))
AS eligible
FROM _2771_house_coords
"""
# Идемпотентность: h.lat IS NULL повторяется в UPDATE (а не только в отборе кандидатов) —
# уже проставленные координаты не перезаписываются ни при каком стечении обстоятельств.
_APPLY_SQL = """
UPDATE houses h
SET lat = c.lat,
lon = c.lon,
geom = ST_SetSRID(ST_MakePoint(c.lon, c.lat), 4326)
FROM _2771_house_coords c
WHERE h.id = c.house_id
AND h.lat IS NULL
AND c.n_geocoded > 0
AND c.spread_m <= CAST(:max_spread_m AS double precision)
"""
_SCATTER_TOP_SQL = """
SELECT c.house_id, h.address, c.n_geocoded, c.spread_m
FROM _2771_house_coords c
JOIN houses h ON h.id = c.house_id
WHERE c.n_geocoded > 0
AND c.spread_m > CAST(:max_spread_m AS double precision)
ORDER BY c.spread_m DESC
LIMIT CAST(:top AS integer)
"""
@dataclass
class HouseCoordsResult:
"""Счётчики прогона. Пропуск виден отдельно по КАЖДОЙ причине — молчаливое усечение
читается как «покрыто всё»."""
candidates: int = 0 # домов без координат всего
eligible: int = 0 # объявления согласны (разброс <= порога)
updated: int = 0 # реально обновлено (UPDATE rowcount)
skipped_no_listings: int = 0 # объявлений у дома нет вовсе
skipped_no_listing_coords: int = 0 # объявления есть, но сами без координат
skipped_scattered: int = 0 # разброс больше порога — дефект сопоставителя (#2777)
scattered_over_5km: int = 0 # из них разъехавшиеся на километры
worst_spread_m: int = 0 # худший пропущенный разброс, метры
max_spread_m: int = 0 # применённый порог, метры (чтобы счётчики читались без кода)
duration_sec: float = field(default=0.0)
def to_counters(self) -> dict[str, int]:
return {
"candidates": self.candidates,
"eligible": self.eligible,
"updated": self.updated,
"skipped_no_listings": self.skipped_no_listings,
"skipped_no_listing_coords": self.skipped_no_listing_coords,
"skipped_scattered": self.skipped_scattered,
"scattered_over_5km": self.scattered_over_5km,
"worst_spread_m": self.worst_spread_m,
"max_spread_m": self.max_spread_m,
"duration_sec": int(self.duration_sec),
}
def backfill_house_coords(
db: Session,
*,
max_spread_m: float = DEFAULT_MAX_SPREAD_M,
) -> HouseCoordsResult:
"""Перенести координаты объявлений в дома, где объявления согласны.
Одна транзакция: TEMP-таблица кандидатов (ON COMMIT DROP) счётчики UPDATE.
Счётчики считаются по ТОЙ ЖЕ таблице, из которой идёт UPDATE, поэтому «сколько
пропущено» относится ровно к этому прогону, а не к соседнему запросу.
Args:
db: сессия tradein-БД.
max_spread_m: предел разброса объявлений, метры (см. DEFAULT_MAX_SPREAD_M).
Returns:
HouseCoordsResult со счётчиками по каждой причине пропуска.
"""
start = time.monotonic()
res = HouseCoordsResult(max_spread_m=int(max_spread_m))
params = {"max_spread_m": max_spread_m, "defect_m": SCATTER_DEFECT_M}
db.execute(text(_BUILD_CANDIDATES_SQL))
row = db.execute(text(_COUNTERS_SQL), params).mappings().one()
res.candidates = int(row["candidates"])
res.eligible = int(row["eligible"])
res.skipped_no_listings = int(row["skipped_no_listings"])
res.skipped_no_listing_coords = int(row["skipped_no_listing_coords"])
res.skipped_scattered = int(row["skipped_scattered"])
res.scattered_over_5km = int(row["scattered_over_5km"])
res.worst_spread_m = int(row["worst_spread_m"])
if res.skipped_scattered:
worst = (
db.execute(text(_SCATTER_TOP_SQL), {**params, "top": _SCATTER_LOG_TOP}).mappings().all()
)
logger.warning(
"house_coords: ПРОПУЩЕНО %d домов с разбросом объявлений > %.0f м "
"(из них %d дальше %.0f км) — это дефект сопоставителя (#2777), не переноса. "
"Худшие: %s",
res.skipped_scattered,
max_spread_m,
res.scattered_over_5km,
SCATTER_DEFECT_M / 1000,
"; ".join(
f"id={w['house_id']} {w['address']!r} n={w['n_geocoded']} "
f"разброс={w['spread_m'] / 1000:.1f} км"
for w in worst
),
)
res.updated = int(db.execute(text(_APPLY_SQL), params).rowcount or 0)
db.commit()
res.duration_sec = time.monotonic() - start
logger.info(
"house_coords: DONE — кандидатов=%d годных=%d обновлено=%d | пропущено: "
"без_объявлений=%d объявления_без_координат=%d разброс>%.0fм=%d (худший %d м) "
"время=%.1fs",
res.candidates,
res.eligible,
res.updated,
res.skipped_no_listings,
res.skipped_no_listing_coords,
max_spread_m,
res.skipped_scattered,
res.worst_spread_m,
res.duration_sec,
)
return res
# ── Run lifecycle wrapper (scheduler entrypoint) ─────────────────────────────
def run_house_coords_from_listings(
db: Session,
*,
run_id: int,
params: dict,
) -> HouseCoordsResult:
"""Run-lifecycle обёртка (kit scheduler, source='house_coords_from_listings').
Params (default_params jsonb):
max_spread_m: предел разброса в метрах (default 125).
"""
max_spread_m = float(params.get("max_spread_m", DEFAULT_MAX_SPREAD_M))
counters: dict[str, int] = HouseCoordsResult(max_spread_m=int(max_spread_m)).to_counters()
try:
runs_mod.update_heartbeat(db, run_id, counters)
res = backfill_house_coords(db, max_spread_m=max_spread_m)
counters = res.to_counters()
runs_mod.mark_done(db, run_id, counters)
return res
except Exception as exc:
logger.exception("run_house_coords_from_listings: run_id=%d FAILED", run_id)
try:
db.rollback()
except Exception:
logger.warning("run_house_coords_from_listings: rollback failed", exc_info=True)
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(
description="Перенос координат объявлений в дома, где объявления согласны (#2771)"
)
parser.add_argument(
"--max-spread-m",
type=float,
default=DEFAULT_MAX_SPREAD_M,
help=f"Предел разброса объявлений, метры (default: {DEFAULT_MAX_SPREAD_M:.0f})",
)
args = parser.parse_args()
db = SessionLocal()
try:
backfill_house_coords(db, max_spread_m=args.max_spread_m)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -43,6 +43,12 @@ Exception triad differs from Avito:
No IP-rotation/cooldown recovery step exists here
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an
aborted run simply retries the remaining backlog next window.
ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и
получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на
распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch
(наш тракт) -- см. providers/domclick/detail.py::fetch_detail. Пока эти два
случая не разведены отдельным подтипом (как AvitoSidecarUnavailableError у
avito), любой диагноз отсюда был бы назначенным, а не установленным.
- DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
block) -- counted as failed++, logged, does NOT touch consecutive_blocks and
does NOT abort the run (neutral to the block-breaker, mirrors how Avito's

View file

@ -119,6 +119,27 @@ class NewbuildingEnrichBackfillResult:
def to_dict(self) -> dict[str, int]:
return {f.name: int(getattr(self, f.name)) for f in fields(self)}
def to_backfill_counters(self) -> dict[str, int]:
"""to_dict() + ключи, которые читает общий финализатор (#2695, #2767).
runs.mark_backfill_finished смотрит на attempted / enriched / failed; у этой
задачи те же величины называются иначе, поэтому через общий финализатор она не
проходила и ЛЮБОЙ прогон закрывался mark_done включая 25 попыток с нулём
обогащений восемь суток подряд.
`attempted` дома, до которых дошли руки: пропущенный уже-обогащённый домом
попыткой не был, иначе прогон, которому просто нечего делать, выглядел бы
отказом. `blocked` НЕ заводим намеренно: задача блоки источника не различает,
а нулевой прогон без доказанных блоков финализатор помечает 'failed' наш
тракт, а не «виновата площадка» (#2764: диагноз ставится по доказательству).
"""
return {
**self.to_dict(),
"attempted": self.processed - self.skipped_already_enriched,
"enriched": self.succeeded,
"failed": self.failed_resolve + self.failed_fetch + self.failed_save,
}
# SQL: anchor on the canonical cian_newbuilding link, require a house we can FETCH, and
# (unless force) skip houses already enriched.
@ -673,7 +694,9 @@ async def run_newbuilding_enrich(
Thin scheduler wrapper around backfill_newbuilding_enrichment() mirrors
tasks/yandex_address_backfill.run_yandex_address_backfill: emit a heartbeat before the
batch, delegate to the proven backfill, then mark the scrape_run done/failed.
batch, delegate to the proven backfill, then finalise the scrape_run through the
SHARED runs.mark_backfill_finished (#2695) — ноль обогащений при ненулевых попытках
закрывается 'failed', а не 'done' (#2767; счётчики маппит to_backfill_counters).
Idempotency is inherited from backfill_newbuilding_enrichment(): with force=False its
SELECT excludes houses that already have BOTH price_dynamics AND reliability rows, and
@ -727,10 +750,31 @@ async def run_newbuilding_enrich(
on_progress=_heartbeat,
)
counters = result.to_dict()
runs_mod.mark_done(db, run_id, counters)
# Честный финал через ОБЩИЙ финализатор (#2695): ноль обогащений при ненулевых
# попытках больше не 'done'. fail_hint называет ЭТАП, на котором чаще всего
# отказывало (resolve / fetch / save) — «failed=25» на этот вопрос не отвечает.
counters = result.to_backfill_counters()
stage, stage_n = max(
(
("resolve", result.failed_resolve),
("fetch", result.failed_fetch),
("save", result.failed_save),
),
key=lambda kv: kv[1],
)
runs_mod.mark_backfill_finished(
db,
run_id,
counters,
source="newbuilding_enrich",
fail_hint=(
f"чаще всего отказ на этапе {stage} ({stage_n} из {counters['attempted']})"
if stage_n
else None
),
)
logger.info(
"scheduler: newbuilding_enrich run_id=%d done — processed=%d ok=%d skip=%d "
"scheduler: newbuilding_enrich run_id=%d finished — processed=%d ok=%d skip=%d "
"resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d "
"| pending=%d %.1fs",
run_id,

View file

@ -0,0 +1,140 @@
-- 222_db_audit_cleanup.sql
-- Уборка по итогам ручного аудита схемы tradein (2026-08-06). Три независимых
-- части, порядок между ними не важен (разные объекты, нет пересекающихся
-- зависимостей). Идемпотентно целиком: IF EXISTS везде, CREATE OR REPLACE VIEW.
--
-- ── A. Временные таблицы разовой чистки 02.07 ─────────────────────────────────
-- tmp_purged_junk_houses_0702 / tmp_purged_junk_links_0702 — снэпшоты записей,
-- вычищенных вручную 2026-07-02. Проверено на проде перед этой миграцией:
-- * обе существуют под этими именами, суммарно 2.9 МБ
-- (tmp_purged_junk_houses_0702 = 1608 kB, tmp_purged_junk_links_0702 = 1272 kB);
-- * pg_constraint: ни один FK НЕ ссылается на них (confrelid пусто);
-- * pg_depend: ни view, ни другой объект их не использует (только сами по себе).
-- Дальше не нужны — были just-in-case снэпшотом на случай отката чистки, месяц
-- прошёл без претензий.
--
-- ── B. Пять строгих дублей индексов ───────────────────────────────────────────
-- Строгий дубль = тот же access method + тот же УПОРЯДОЧЕННЫЙ список колонок
-- (включая ASC/DESC/NULLS) + тот же частичный предикат (или оба NULL) + тот же
-- opclass, независимо от UNIQUE-флага и имени. Проверено запросом по
-- pg_index/pg_stat_user_indexes/pg_opclass на проде — найдено РОВНО 5 пар (не 6,
-- см. примечание ниже), в каждой паре оставляем индекс, несущий UNIQUE-constraint
-- (дропнуть его нельзя без дропа constraint'а), дропаем чистый btree-дубль:
--
-- agents: DROP agents_source_ext_idx
-- (дубль agents_ext_source_ext_agent_id_key, btree (ext_source, ext_agent_id);
-- 2 скана за всё время — планировщик и так предпочитал unique-версию)
-- ekb_geoportal_buildings: DROP ix_ekb_geoportal_buildings_street_house
-- (дубль ekb_geoportal_buildings_street_norm_house_norm_key,
-- btree (street_norm, house_norm); 13 945 сканов, но unique-версия того же
-- определения покрывает те же запросы — 70 702 скана на ней)
-- house_placement_history: DROP hph_source_item_idx
-- (дубль house_placement_history_source_ext_item_id_key,
-- btree (source, ext_item_id); 0 сканов — полностью мёртв)
-- house_reviews: DROP hr_source_ext_idx
-- (дубль house_reviews_source_ext_review_id_key, btree (source, ext_review_id);
-- 15 сканов)
-- sellers: DROP sellers_source_idx
-- (дубль sellers_source_ext_seller_id_key, btree (source, ext_seller_id);
-- 2 скана)
--
-- ПРИМЕЧАНИЕ (расхождение с ожиданием «шесть»): при систематической проверке
-- (3 независимых метода: нормализованный DDL-текст, сравнение indkey/indoption,
-- сравнение opclass) строгих дублей найдено 5, не 6. Два похожих кандидата
-- ЦЕЛЕНАПРАВЛЕННО исключены — их «дубль» только по списку колонок, а порядок
-- сортировки различается (ASC,ASC у unique-версии против ASC,DESC у второй),
-- то есть это ТОТ ЖЕ класс исключения, что explicitly подтверждённые
-- idx_lss_source_date/listings_snapshots_listing_date_idx (#2607, см. миграцию 225):
-- * offer_price_history: oph_listing_time_idx (listing_id ASC, change_time DESC)
-- против offer_price_history_listing_change_uq (listing_id ASC, change_time ASC)
-- * houses_price_dynamics: hpd_house_dim_idx (..., month_date DESC)
-- против houses_price_dynamics_dim_key (..., month_date ASC)
-- Оба НЕ тронуты. Если «шесть» подразумевали один из них — нужно явное
-- подтверждение, что смешанный порядок сортировки в конкретном запросе не
-- используется (тем же способом, каким для idx_lss_source_date подтверждено
-- обратное).
--
-- ── C. v_data_quality — явный список колонок вместо SELECT * ─────────────────
-- Сейчас: `WITH active_listings AS (SELECT * FROM listings WHERE is_active = true)`.
-- Postgres разворачивает `*` в CREATE VIEW time в полный список колонок listings
-- (89 на момент миграции) и фиксирует pg_depend на КАЖДУЮ из них — это то самое
-- уже задокументированное в 214/216 предупреждение («View-зависимость: v_data_quality
-- содержит SELECT * FROM listings, что фиксирует column-level зависимость на ВСЕ
-- колонки»), которое обязывало делать DROP VIEW → DROP COLUMN → CREATE VIEW при
-- каждой чистке listings.
-- Фактически используются только 6 колонок active_listings ниже по телу view:
-- id — IN (SELECT active_listings.id FROM active_listings)
-- lat — pct_geocoded
-- cadastral_number — pct_cadastr
-- description — pct_description
-- house_id_fk — JOIN houses h ON h.id = l.house_id_fk (pct_year_built)
-- is_active — WHERE-фильтр самого CTE (создаёт зависимость даже не будучи
-- в SELECT-списке, поэтому перечислен явно для наглядности)
-- Поведение view НЕ меняется — только явный список вместо *. Тело SELECT (16
-- выходных колонок) скопировано без изменений из 216_dead_code_sweep.sql.
-- COMMENT ON VIEW сохраняется автоматически (CREATE OR REPLACE VIEW не сбрасывает
-- комментарий).
--
-- Dependencies: 002_core_tables.sql (listings), 214_drop_dead_run_metrics.sql,
-- 216_dead_code_sweep.sql (последний DDL v_data_quality).
-- Идемпотентно: DROP TABLE IF EXISTS / DROP INDEX IF EXISTS / CREATE OR REPLACE VIEW.
BEGIN;
-- ── A ──────────────────────────────────────────────────────────────────────
DROP TABLE IF EXISTS tmp_purged_junk_houses_0702, tmp_purged_junk_links_0702;
-- ── B ──────────────────────────────────────────────────────────────────────
DROP INDEX IF EXISTS agents_source_ext_idx;
DROP INDEX IF EXISTS ix_ekb_geoportal_buildings_street_house;
DROP INDEX IF EXISTS hph_source_item_idx;
DROP INDEX IF EXISTS hr_source_ext_idx;
DROP INDEX IF EXISTS sellers_source_idx;
-- ── C ──────────────────────────────────────────────────────────────────────
CREATE OR REPLACE VIEW v_data_quality AS
WITH active_listings AS (
SELECT id, lat, cadastral_number, description, house_id_fk, is_active
FROM listings
WHERE is_active = true
)
SELECT
(SELECT count(*) FROM houses) AS houses_total,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
) sub) AS houses_2plus_sources,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
) sub) AS houses_3plus_sources,
(SELECT count(*) FROM active_listings) AS listings_active,
(SELECT count(*) FROM (
SELECT listing_id FROM listing_sources
WHERE listing_id IN (SELECT id FROM active_listings)
GROUP BY listing_id HAVING count(*) >= 2
) sub) AS listings_dedup_2sources,
(SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
(SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr,
(SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
(SELECT count(*) FROM active_listings l
JOIN houses h ON h.id = l.house_id_fk
WHERE h.year_built IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago;
COMMIT;

View file

@ -0,0 +1,42 @@
-- 225_listing_source_snapshots_run_id_idx.sql
-- Индекс под внешний ключ listing_source_snapshots.run_id → scrape_runs(id)
-- ON DELETE SET NULL. Индекса на run_id нет (проверено \d listing_source_snapshots
-- на проде): есть только listing_source_snapshots_pkey (listing_source_id,
-- snapshot_date), idx_lss_source_date (listing_source_id, snapshot_date DESC),
-- idx_lss_snapshot_date (snapshot_date DESC) — ни один не начинается с run_id.
-- Таблица — 2 872 080 строк (reltuples), 696 MB (pg_total_relation_size).
-- Без индекса каждый DELETE из scrape_runs делает Seq Scan по 2.87М строк, чтобы
-- обнулить run_id у зависимых снэпшотов (ON DELETE SET NULL).
--
-- ── Почему CONCURRENTLY и почему в файле нет BEGIN/COMMIT ────────────────────
-- CREATE INDEX CONCURRENTLY не может выполняться внутри блока транзакции
-- (Postgres: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block").
-- На 2.87М строк / 696 MB обычный CREATE INDEX держит ACCESS EXCLUSIVE lock на
-- время сборки (секунды-десятки секунд под нагрузкой) — на боевой таблице,
-- которую пишет активный скрейпинг, это неприемлемо; нужен CONCURRENTLY.
--
-- Более ранние миграции с похожей потребностью (117, 120, 134, 137) сознательно
-- ОТКАЗАЛИСЬ от CONCURRENTLY с комментарием «deploy migration runner wraps each
-- file in an explicit transaction (BEGIN/COMMIT)». Перепроверено перед этой
-- миграцией: .forgejo/workflows/deploy-tradein.yml, шаг применения миграций
-- (`for sql_file in ...; psql -v ON_ERROR_STOP=on < "$sql_file"`) НЕ добавляет
-- собственный BEGIN/COMMIT и не передаёт `-1`/`--single-transaction` — транзакция
-- в тех файлах возникала ТОЛЬКО из-за их же собственных BEGIN;...COMMIT; внутри
-- файла, не из-за механизма деплоя. Эмпирическое подтверждение: в data/sql уже
-- есть применённые на проде миграции без BEGIN/COMMIT вовсе (003_seed_deals.sql,
-- 005_geocode_tracking.sql, 218_scrape_runs_ban_kind.sql,
-- 223_scrape_runs_time_columns_meaning.sql) — psql выполняет их операторы с
-- autocommit по одному, деплой не падает. Поэтому здесь BEGIN/COMMIT сознательно
-- ОПУЩЕН: файл — это один самостоятельный CREATE INDEX CONCURRENTLY, выполняемый
-- psql в autocommit-режиме.
--
-- Идемпотентно: IF NOT EXISTS. (Единственный неидемпотентный случай — если
-- предыдущая попытка CONCURRENTLY была прервана и оставила INVALID индекс с тем
-- же именем; тогда IF NOT EXISTS молча НЕ пересоздаст его валидным, и потребуется
-- ручной `DROP INDEX CONCURRENTLY idx_lss_run_id;` перед повтором — это штатное
-- поведение CONCURRENTLY, не специфика этого файла.)
-- Dependencies: 079_listing_source_history.sql (создала таблицу и оба FK).
-- Deploy order: standalone, независим от 222_db_audit_cleanup.sql.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lss_run_id
ON listing_source_snapshots (run_id);

View file

@ -0,0 +1,55 @@
-- 234_scrape_runs_ban_kind_unknown.sql
-- scrape_runs.ban_kind получает третье значение 'unknown' — «причина не установлена» (#2764).
--
-- Dependencies: 218_scrape_runs_ban_kind.sql (создала колонку и CHECK по ban_kind).
-- Apply after: 233_payments.sql
-- Идемпотентно: DROP/ADD CONSTRAINT + UPDATE, который после первого прогона
-- никого не находит (условие ban_kind='platform' перестаёт выполняться).
--
-- ── ЗАЧЕМ ────────────────────────────────────────────────────────────────────
-- 218 дала статусу 'banned' диагноз, но дефолт в сигнатуре mark_banned остался
-- 'platform', а передавали его 3 вызывающих из 13. Замер на проде 2026-08-07:
-- SELECT ban_kind, count(*) FROM scrape_runs WHERE status='banned' GROUP BY 1;
-- infra | 92 ← ретро-классификация 218 по тексту (разовая, осмысленная)
-- platform | 41 ← в т.ч. 2 строки, написанные УЖЕ в рантайме, по умолчанию
-- Обе рантайм-строки пришли из общего финализатора backfill-задач
-- (scrape_runs.mark_backfill_finished, #2674), который диагноз не передаёт вовсе:
-- 3306 avito_detail_backfill 2026-08-06 12:42 blocked=5, 0 обогащено из 5
-- 3313 domclick_detail_backfill 2026-08-06 15:20 blocked=3, 0 обогащено из 3
-- Причина отказов ни у одной из них не устанавливалась. Метка 'platform' у них —
-- не вывод, а значение по умолчанию, и выглядит она ровно как доказанные 10
-- строк «Avito SERP firewall». Оставить их — значит сохранить ложное
-- доказательство ровно того класса, ради которого 218 и делалась.
--
-- ── ГРАНИЦА ПРАВКИ ───────────────────────────────────────────────────────────
-- Ретро-классификацию 218 (по тексту ошибки, применена 2026-08-06 08:24:19 UTC)
-- НЕ трогаем: разбор текста был там законным — типов исключений у исторических
-- строк уже нет, а маркер сайдкара в тексте есть. Трогаем только строки, которые
-- та миграция УЖЕ НЕ ВИДЕЛА (finished_at позже её применения) и которые пришли из
-- финализатора backfill'ов — единственного вызывающего, который на тот момент
-- писал ban_kind по умолчанию. Условие по error здесь опознаёт ВЫЗЫВАЮЩЕГО
-- (префикс, который он сам ставит), а не причину отказа: диагноз из текста не
-- выводится ни здесь, ни тем более в рантайме.
--
-- Верхней границы по времени нет намеренно: миграции применяются деплоем ДО
-- рестарта приложения, но если между этим и новым кодом финализатор успеет
-- пометить ещё строки — они тоже написаны дефолтом и тоже подлежат переводу.
ALTER TABLE scrape_runs DROP CONSTRAINT IF EXISTS scrape_runs_ban_kind_check;
ALTER TABLE scrape_runs
ADD CONSTRAINT scrape_runs_ban_kind_check
CHECK (ban_kind IS NULL OR ban_kind IN ('platform', 'infra', 'unknown'));
COMMENT ON COLUMN scrape_runs.ban_kind IS
'Диагноз status=''banned'' (#2686, #2764): platform — площадка заблокировала '
'(firewall/403/captcha, доказано типом исключения); infra — не отдала НАША '
'инфраструктура (браузерный сайдкар/прокси); unknown — причина НЕ установлена '
'(дефолт mark_banned). NULL для прогонов с другим статусом. Пишется из типа '
'исключения в момент отказа, не из текста ошибки.';
UPDATE scrape_runs
SET ban_kind = 'unknown'
WHERE status = 'banned'
AND ban_kind = 'platform'
AND finished_at > TIMESTAMPTZ '2026-08-06 08:24:19.102335+00' -- 218 applied_at
AND error LIKE 'backfill-honest-status:%';

View file

@ -0,0 +1,90 @@
-- 238_listings_ceiling_height_unify.sql
-- Purpose (#2699): свести высоту потолков к ОДНОЙ колонке — listings.ceiling_height_m.
--
-- Dependencies: 019_listings_alter_cian.sql (ceiling_height numeric(3,2)),
-- 111_listings_avito_detail_fields.sql (ceiling_height_m numeric(5,2)).
-- Apply after: 234_scrape_runs_ban_kind_unknown.sql
-- Идемпотентно: UPDATE'ы по предикату «уже приведено» + COMMENT ON COLUMN.
--
-- ── ЧТО БЫЛО ─────────────────────────────────────────────────────────────────
-- Две колонки одного смысла, источники разложены по ним НЕ ПЕРЕСЕКАЯСЬ, а
-- потребитель (эстиматор, estimator._apply_kitchen_ceiling_signal) читает одну.
--
-- Прод-замер 2026-08-07, listings (всего 94 373):
-- source | всего | ceiling_height | ceiling_height_m | обе | расходятся
-- avito | 48 592 | 0 | 7 149 | 0 | 0
-- cian | 21 951 | 855 | 0 | 0 | 0
-- yandex | 16 854 | 7 699 | 7 675 | 7 675 | 0
-- domklik | 6 594 | 0 | 0 | 0 | 0
-- n1 | 382 | 0 | 0 | 0 | 0
--
-- РАСХОЖДЕНИЙ НЕТ: там, где заполнены обе (7 675 строк), значения СОВПАДАЮТ до
-- последнего знака. Значит это не «две разные правды», а одна правда в двух
-- ящиках — задача не про неверную цену, а про лишнюю колонку и потерянное
-- покрытие: 855 циановских + 24 яндексовых значения не видит ни один потребитель.
--
-- ── ВЫБОР КАНОНА: ceiling_height_m ──────────────────────────────────────────
-- 1. Единицы в имени. Так этот же признак назван ВЕЗДЕ в проекте:
-- domrf_kn_flats.ceiling_height_m, domrf_kn_objects.ceiling_height_m,
-- фронтовый ObjectSpecsTable. listings.ceiling_height — единственное исключение.
-- 2. Её читает единственный потребитель, влияющий на цену (эстиматор).
-- 3. numeric(5,2) против numeric(3,2): у 019-колонки потолок 9.99, и запись
-- out-of-range роняет ВЕСЬ батч DataError'ом (per-lot SAVEPOINT ловит только
-- IntegrityError) — это уже приходилось обходить инлайн-гейтом в yandex SERP.
-- 4. После переноса она — надмножество: 15 591 против 8 554 (посчитано
-- симуляцией на проде: 14 824 110 мусорных + 877 перенесённых).
--
-- ── ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ ─────────────────────────────────────────────────
-- 1. Чистит невозможные значения в ОБЕИХ колонках (вне 2.06.0 м).
-- 2. Переносит уникальные значения ceiling_height → ceiling_height_m.
-- 3. Помечает ceiling_height как DEPRECATED (писателей у неё больше нет).
-- DROP COLUMN намеренно НЕ здесь: сначала прод должен подтвердить, что колонку
-- никто не пишет и не читает. Снос — отдельным шагом, за database-expert.
BEGIN;
-- ── 1. Невозможные значения → NULL ──────────────────────────────────────────
-- ceiling_height_m: 26 строк > 6 м (максимум 29.90) + 84 строки < 2 м (83 из них
-- ровно 0.00) — все из avito detail, где парсер брал первое число строки без
-- проверки. Корень починен в scraper_kit.ceiling_height.plausible_ceiling_m;
-- здесь — исторический хвост. NULL, а не clamp: 0.00 и 29.90 это не потолок, а
-- мис-парс, и «починить» их в правдоподобное число мы не можем — сырой текст
-- остаётся в raw_payload / на карточке.
UPDATE listings
SET ceiling_height_m = NULL
WHERE ceiling_height_m IS NOT NULL
AND ceiling_height_m NOT BETWEEN 2.0 AND 6.0;
-- ceiling_height: 2 строки cian (0.00 и 1.70). Чистим до переноса, чтобы шаг 2
-- не затащил мусор в канон.
UPDATE listings
SET ceiling_height = NULL
WHERE ceiling_height IS NOT NULL
AND ceiling_height NOT BETWEEN 2.0 AND 6.0;
-- ── 2. Перенос уникальных значений в канон ──────────────────────────────────
-- Ожидаемо 877 строк: 855 cian (писал только в 019-колонку) 2 мусорных,
-- отсеянных шагом 1, + 24 yandex (detail записал, а SERP по этой карточке
-- значения не дал). Пересечение переносить нечего — значения там тождественны
-- (см. замер выше), поэтому только IS NULL.
-- Итог после миграции (симуляция на проде 2026-08-07): ceiling_height_m = 15 591
-- непустых, из них 0 вне диапазона 2.06.0.
UPDATE listings
SET ceiling_height_m = ceiling_height
WHERE ceiling_height_m IS NULL
AND ceiling_height IS NOT NULL;
-- ── 3. Пометка устаревшей колонки ───────────────────────────────────────────
COMMENT ON COLUMN listings.ceiling_height IS
'DEPRECATED (#2699, 2026-08-07). Канон — listings.ceiling_height_m. '
'Писателей нет: base.save_listings / cian_detail / yandex_detail переведены '
'на ceiling_height_m, coverage-дашборд строку убрал. Значения перенесены этой '
'миграцией. Оставлена временно как страховка отката; снос — отдельным шагом '
'после прод-подтверждения, что никто не пишет и не читает.';
COMMENT ON COLUMN listings.ceiling_height_m IS
'Высота потолков, метры. КАНОН (#2699) — единственная колонка этого признака. '
'Пишут все источники через scraper_kit.ceiling_height.plausible_ceiling_m '
'(гейт правдоподобия 2.06.0 м). Читает estimator (comp-scoring #2012).';
COMMIT;

View file

@ -0,0 +1,72 @@
-- 239_scrape_schedules_seed_house_coords_from_listings.sql
-- Ежедневный перенос координат из объявлений в дом, когда объявления согласны (#2771).
--
-- ЧТО (source='house_coords_from_listings'):
-- product_handlers._job_house_coords_from_listings → run_house_coords_from_listings
-- (app/tasks/backfill_house_coords_from_listings.py). Для каждого дома с lat IS NULL:
-- центроид geom его объявлений + разброс (max ST_DistanceSphere до центроида).
-- Разброс <= max_spread_m → центроид пишется в дом; больше → дом не трогается и
-- попадает в отдельный счётчик skipped_scattered.
--
-- ЗАЧЕМ ПОВТОРЯЮЩАЯСЯ, а не однократный скрипт:
-- Дом берёт координаты один раз при создании, из породившего его объявления. Объявление
-- геокодируется ПОЗЖЕ (geoportal_coords_backfill, geocode_missing_listings) и пишет в
-- listings; ON CONFLICT у вставки дома обновляет только address. Обратно координаты не
-- возвращались — на проде 2026-08-07 накопилось 1945 домов из 9625 (20.2%) без координат,
-- 1490 из них уже имеют объявления С координатами. Приток новых таких домов не
-- прекращается, поэтому однократный прогон закрыл бы дыру на день.
--
-- ПОРОГ 125 м: половина 250-метрового гео-ограждения house_dedup_merge (#2187). Записанный
-- центроид — оценка с погрешностью ровно в разброс; отдать ей весь бюджет ограждения
-- значит позволить двум домам разъехаться на 500 м и сломать сравнение. Гистограмма
-- разброса обрыва не даёт (ядро 0-125 м, затем ровное плато до 400 м), поэтому порог
-- взят от потребителя, а не подогнан к данным. Цена: 125 м пропускает 1382 дома,
-- 250 м — 1403; 21 дом разницы не теряется, а откладывается до следующего прогона.
--
-- ОКНО 03:00-04:00 UTC, ежедневно (interval_days по умолчанию = 1):
-- ПЕРЕД house_dedup_merge (04:00-05:00, migration 135) — недельный дедуп застаёт
-- координаты, перенесённые этой ночью из объявлений, геокодированных накануне
-- (geoportal_coords_backfill 05:00-06:00, geocode_missing_listings 06:00-09:00).
-- Чисто внутренняя операция БД: ни HTTP, ни прокси, ни anti-bot — конкуренции нет.
--
-- enabled=true: операция НЕ деструктивная — пишет только туда, где lat IS NULL, и не
-- перезаписывает ни одной существующей координаты (тот же приём, что у
-- geoportal_coords_backfill, migration 171, тоже засеянного enabled=true).
--
-- default_params:
-- max_spread_m -- 125: предел разброса объявлений в метрах (см. выше).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно применять повторно.
-- Runner применяет миграции БЕЗ --single-transaction, поэтому явные BEGIN/COMMIT.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'house_coords_from_listings',
true,
3,
4,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
'{"max_spread_m": 125}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (заменяет cron-script setup). Источники перечислены в '
'tests/test_scraper_kit_scheduler_parity.py::_PRODUCT_SOURCES и в сид-миграциях '
'data/sql/*scrape_schedules*seed*.sql. Последний добавленный: '
'house_coords_from_listings (#2771: ежедневный перенос координат из объявлений в дом '
'при согласии объявлений, разброс <= 125 м; дома с большим разбросом пропускаются '
'и считаются отдельно — это дефект сопоставителя #2777, не переноса).';
COMMIT;

View file

@ -230,5 +230,15 @@
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
#
# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент
# правки они уже слиты в main и применены на проде (см. _schema_migrations),
# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш;
# self-maintenance-контракт (см. докстринг test_migrations_manifest.py)
# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для
# 222/225 по прецеденту 233_payments.sql.
222_db_audit_cleanup.sql
225_listing_source_snapshots_run_id_idx.sql
233_payments.sql
234_scrape_runs_ban_kind_unknown.sql
240_trade_in_estimates_retain_until.sql

View file

@ -101,9 +101,17 @@ class TestYandexHousePriority:
out = resolve_house_field("has_lift", {"cian_bti": True, "yandex_valuation": True})
assert out is True
def test_house_ceiling_height_cian_detail_preferred(self) -> None:
out = resolve_house_field("ceiling_height", {"cian_detail": 2.7, "yandex_valuation": 2.5})
assert out == 2.7
def test_house_has_no_ceiling_height_rule(self) -> None:
"""#2699: правила для houses.ceiling_height быть не должно — колонки нет.
Раньше здесь стояло `resolve_house_field("ceiling_height", ...) == 2.7`.
Тест зеленел, но проверял ФАНТОМ: колонки `ceiling_height` в таблице
`houses` не существует (прод: 0 колонок LIKE '%ceiling%'), правило не
могло сработать ни разу. Высота потолков атрибут объявления.
"""
from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY
assert "ceiling_height" not in HOUSE_FIELD_PRIORITY
def test_house_has_panorama_yandex_valuation_only(self) -> None:
out = resolve_house_field("has_panorama", {"yandex_valuation": True})

View file

@ -28,6 +28,7 @@ tests/test_pdf_real_render.py
# docker run -d -p 5432:5432 -e POSTGRES_DB=tradein -e POSTGRES_USER=tradein \
# -e POSTGRES_PASSWORD=tradein postgis/postgis:16-3.4
# psql ... -f backend/data/sql/*.sql # порядок как в deploy-tradein.yml
tests/tasks/test_backfill_house_coords_from_listings.py::test_real_transfers_agreeing_coords_skips_scattered_and_is_idempotent
tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked
tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
@ -37,3 +38,24 @@ tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
tests/test_user_events.py::test_real_record_event_inserts_row
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
# поймал: 4 проверки не исполнились и не были объявлены. Проверено против живой
# БД (схема из backend/data/sql/ по 233 включительно) — 29 passed, тесты живы.
tests/test_data_erasure.py::test_real_erase_by_phone_does_not_match_different_number
tests/test_data_erasure.py::test_real_erase_by_phone_finds_differently_formatted_number
tests/test_data_erasure.py::test_real_erase_by_phone_finds_ru_trunk_prefix_variant
tests/test_purge_expired_trade_in_data.py::test_real_purge_deletes_only_anonymous_expired_estimates
# Payments retention (PR #2754), deep-review MEDIUM finding 2026-08-06 — тот же
# `_live_session()`. Проверяют предполётную проверку purge против реального
# retain_until/payments состояния (здоровая оплаченная строка не поднимает
# тревогу и не блокирует джобу), не только SQL-текст.
tests/test_purge_expired_trade_in_data.py::test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly
tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy_paid_row
# Диагноз оборванного прогона (#2764) — тот же `_live_session()`. Проверяет, что
# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint

View file

@ -4,6 +4,7 @@ import asyncio
import fnmatch
import os
import sys
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -12,6 +13,10 @@ _wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.avito_exceptions import ( # noqa: E402
AvitoBlockedError,
AvitoSidecarUnavailableError,
)
from app.core import shutdown as _sd # noqa: E402
from app.tasks.avito_detail_backfill import ( # noqa: E402
@ -192,6 +197,45 @@ async def test_backfill_build_warmed_session_receives_config() -> None:
runs.mark_failed.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("exc_factory", "expected_kinds"),
[
(lambda: AvitoBlockedError("ip blocked"), {"platform"}),
(lambda: AvitoSidecarUnavailableError("browser unavailable"), {"infra"}),
],
)
async def test_backfill_reports_ban_kind_of_the_blocks_it_saw(
exc_factory: Any, expected_kinds: set[str]
) -> None:
"""Диагноз блоков доезжает до финализатора по ТИПУ исключения (#2764).
Фальсификация: до правки задача не передавала ничего, и обе серии отказ
площадки и отказ нашего сайдкара давали в scrape_runs.ban_kind одинаковое
'platform' по умолчанию (прод, прогон 3306: blocked=5, причина не установлена).
"""
snapshot = _make_snapshot(10)
db = _mock_db(snapshot)
runs = MagicMock()
mock_scraper = MagicMock()
mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False)
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION, return_value=AsyncMock()),
patch(_SCRAPER, mock_scraper),
patch(_RUNS, runs),
patch(_FETCH, AsyncMock(side_effect=exc_factory())),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_avito_detail_backfill(
db, run_id=3, params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5}
)
runs.mark_backfill_finished.assert_called_once()
assert set(runs.mark_backfill_finished.call_args.kwargs["ban_kinds"]) == expected_kinds
@pytest.mark.asyncio
async def test_backfill_blocked_abort_after_max_consecutive() -> None:
"""5 consecutive AvitoBlockedError -> abort с пометкой aborted_by_blocks (#2674).

View file

@ -0,0 +1,218 @@
"""Тесты переноса координат из объявлений в дом (#2771).
Форма как у соседей (test_house_dedup_merge.py, tests/tasks/test_cadastral_geo_match.py):
статические проверки эмитируемого SQL + миграции (бегут везде) и поведенческий прогон на
ЖИВОМ Postgres/PostGIS (в CI он есть postgis/postgis:16-3.4; на ноутбуке self-skip,
запись в tests/skip_allowlist.txt).
Поведенческий тест обязан покрывать НЕ ТОЛЬКО перенос: главный риск задачи молча усечь
дома с разбросом и отчитаться «покрыто всё». Поэтому в фикстуре есть дом с объявлениями
за 11 км друг от друга, и на нём проверяется именно ПРОПУСК (lat остался NULL), а не запись.
Регистрация handler'а в scheduler-реестре проверяется НЕ здесь, а в каноническом месте —
test_scraper_kit_scheduler_parity.py::_PRODUCT_SOURCES (туда source добавлен).
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks import backfill_house_coords_from_listings as mod
_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "data"
/ "sql"
/ "239_scrape_schedules_seed_house_coords_from_listings.sql"
)
# ── статика: инварианты, которые нельзя потерять при рефакторе ────────────────
def test_update_touches_only_coordless_houses() -> None:
"""Идемпотентность: UPDATE обязан нести h.lat IS NULL — иначе прогон затрёт уже
проставленные (возможно более точные) координаты."""
assert "h.lat IS NULL" in mod._APPLY_SQL
def test_update_is_gated_by_spread_threshold() -> None:
"""Без этого условия перенос применился бы и к домам с объявлениями в разных городах."""
assert "c.spread_m <= CAST(:max_spread_m AS double precision)" in mod._APPLY_SQL
assert "c.n_geocoded > 0" in mod._APPLY_SQL
def test_spread_measured_in_metres_not_degrees() -> None:
"""Разброс считается ST_DistanceSphere (метры), а не приближением «градусы × 111320»."""
assert "ST_DistanceSphere" in mod._BUILD_CANDIDATES_SQL
def test_threshold_is_half_of_dedup_geo_guard() -> None:
"""125 м = половина 250-метрового гео-ограждения house_dedup_merge (#2187).
Если ограждение когда-нибудь поменяют, этот тест покажет, что порог переноса больше
не согласован с потребителем координат.
"""
from app.services import house_dedup_merge as hdm
assert "<= 250" in str(hdm._BUILD_MAPPING_SQL.text)
assert mod.DEFAULT_MAX_SPREAD_M == 125.0
def test_counters_name_every_skip_reason_separately() -> None:
"""Пропуск должен быть виден по причинам, иначе счётчики читаются как «покрыто всё»."""
counters = mod.HouseCoordsResult().to_counters()
for key in (
"skipped_no_listings",
"skipped_no_listing_coords",
"skipped_scattered",
"scattered_over_5km",
"worst_spread_m",
):
assert key in counters
def test_migration_239_seeds_daily_schedule_before_dedup() -> None:
sql = _MIGRATION.read_text(encoding="utf-8")
assert "'house_coords_from_listings'" in sql
assert '"max_spread_m": 125' in sql
assert "ON CONFLICT (source) DO NOTHING" in sql
# окно 03:00-04:00 UTC — до house_dedup_merge (04:00-05:00, migration 135)
assert re.search(r"^\s+3,\s*$", sql, re.M), "window_start_hour must be 3"
def test_migration_239_no_psycopg_v3_colon_colon_cast() -> None:
assert not re.search(r":\w+::", _MIGRATION.read_text(encoding="utf-8"))
# ── поведение на живом Postgres/PostGIS (self-skip без БД) ────────────────────
def _live_session() -> Any | None:
"""Session на достижимом не-заглушечном Postgres, иначе None (копия приёма из
tests/test_house_dedup_merge.py)."""
try:
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
# list, не tuple: psycopg3 адаптирует в массив только list (tuple уходит как composite).
_HOUSE_IDS = [902771, 902772, 902773, 902774, 902775]
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_transfers_agreeing_coords_skips_scattered_and_is_idempotent() -> None:
"""Пять домов, пять исходов:
902771 3 объявления в ~30 м координаты перенесены (центроид)
902772 2 объявления в 11 км НЕ тронут, посчитан как scattered
902773 объявления без geom НЕ тронут, посчитан отдельно
902774 объявлений нет вовсе НЕ тронут, посчитан отдельно
902775 координаты уже есть не кандидат, значение не перезаписано
"""
from sqlalchemy import text as _t
db = _live_session()
assert db is not None
try:
db.execute(_t("DELETE FROM listings WHERE house_id_fk = ANY(:ids)"), {"ids": _HOUSE_IDS})
db.execute(_t("DELETE FROM houses WHERE id = ANY(:ids)"), {"ids": _HOUSE_IDS})
# url NOT NULL (002_core_tables); 902775 приходит с уже проставленной координатой.
db.execute(
_t(
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES "
"(902771,'avito','E-2771-A','u','тестдом 2771 A', NULL, NULL),"
"(902772,'avito','E-2771-B','u','тестдом 2771 B', NULL, NULL),"
"(902773,'avito','E-2771-C','u','тестдом 2771 C', NULL, NULL),"
"(902774,'avito','E-2771-D','u','тестдом 2771 D', NULL, NULL),"
"(902775,'avito','E-2771-E','u','тестдом 2771 E', 56.80000, 60.50000)"
)
)
# listings: source_url/dedup_hash/price_rub NOT NULL, dedup_hash UNIQUE.
# 0.0002° по широте ≈ 22 м; 0.1° по широте ≈ 11.1 км (разброс = радиус от
# центроида, то есть ≈ 5.6 км — половина расстояния между точками).
db.execute(
_t("""
INSERT INTO listings
(id, source, source_url, source_id, dedup_hash, price_rub, house_id_fk, lat, lon,
geom)
VALUES
(912771,'avito','http://t/2771/1','L1','dh-2771-1',5000000,902771,
56.840000,60.600000, ST_SetSRID(ST_MakePoint(60.600000,56.840000),4326)),
(912772,'avito','http://t/2771/2','L2','dh-2771-2',5000000,902771,
56.840200,60.600000, ST_SetSRID(ST_MakePoint(60.600000,56.840200),4326)),
(912773,'avito','http://t/2771/3','L3','dh-2771-3',5000000,902771,
56.840100,60.600300, ST_SetSRID(ST_MakePoint(60.600300,56.840100),4326)),
(912774,'avito','http://t/2771/4','L4','dh-2771-4',5000000,902772,
56.840000,60.600000, ST_SetSRID(ST_MakePoint(60.600000,56.840000),4326)),
(912775,'avito','http://t/2771/5','L5','dh-2771-5',5000000,902772,
56.940000,60.600000, ST_SetSRID(ST_MakePoint(60.600000,56.940000),4326)),
(912776,'avito','http://t/2771/6','L6','dh-2771-6',5000000,902773,
NULL,NULL,NULL)
""")
)
db.commit()
res = mod.backfill_house_coords(db, max_spread_m=mod.DEFAULT_MAX_SPREAD_M)
rows = dict(
db.execute(
_t("SELECT id, lat FROM houses WHERE id = ANY(:ids)"), {"ids": _HOUSE_IDS}
).all()
)
# A: перенесено, и это ЦЕНТРОИД трёх точек, а не первая попавшаяся.
assert rows[902771] is not None
assert rows[902771] == pytest.approx(56.8401, abs=1e-4)
# geom построен (триггер houses_set_geom_trg + явный ST_SetSRID в UPDATE).
geom_ok = db.execute(_t("SELECT ST_Y(geom) FROM houses WHERE id = 902771")).scalar()
assert geom_ok == pytest.approx(56.8401, abs=1e-4)
# B: объявления в 11 км друг от друга — дом НЕ тронут (главная проверка задачи).
assert rows[902772] is None
# C/D: пропущены по своим причинам.
assert rows[902773] is None and rows[902774] is None
# E: чужая уже проставленная координата не перезаписана.
assert rows[902775] == pytest.approx(56.80000, abs=1e-6)
# Счётчики: таблица общая с другими тестами, поэтому проверяем вклад «не меньше».
assert res.updated >= 1
assert res.skipped_scattered >= 1
assert res.skipped_no_listings >= 1
assert res.skipped_no_listing_coords >= 1
assert res.worst_spread_m >= 5000
assert res.scattered_over_5km >= 1
# Второй прогон — no-op для наших домов (идемпотентность).
before = res.updated
res2 = mod.backfill_house_coords(db, max_spread_m=mod.DEFAULT_MAX_SPREAD_M)
assert res2.updated < before or res2.updated == 0
again = dict(
db.execute(
_t("SELECT id, lat FROM houses WHERE id = ANY(:ids)"), {"ids": _HOUSE_IDS}
).all()
)
assert again[902771] == rows[902771]
assert again[902772] is None
finally:
db.rollback()
db.execute(_t("DELETE FROM listings WHERE house_id_fk = ANY(:ids)"), {"ids": _HOUSE_IDS})
db.execute(_t("DELETE FROM houses WHERE id = ANY(:ids)"), {"ids": _HOUSE_IDS})
db.commit()
db.close()

View file

@ -101,11 +101,15 @@ async def test_platform_firewall_is_not_our_infra() -> None:
(AvitoSidecarUnavailableError("sidecar down"), kit_runs.BAN_KIND_INFRA),
(AvitoBlockedError("firewall"), kit_runs.BAN_KIND_PLATFORM),
(AvitoRateLimitedError("HTTP 429"), kit_runs.BAN_KIND_PLATFORM),
(RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_PLATFORM),
(RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_UNKNOWN),
],
)
def test_ban_kind_of_exception(exc: BaseException, expected: str) -> None:
"""Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип."""
"""Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип.
Тип, который НИЧЕГО не доказывает, с #2764 даёт 'unknown': `else PLATFORM` здесь
был тем же назначенным диагнозом, что и дефолт mark_banned, только уровнем ниже.
"""
assert ban_kind_of_exception(exc) == expected
@ -138,7 +142,7 @@ class _BanRecorder:
error: str,
counters: dict[str, Any],
*,
ban_kind: str = kit_runs.BAN_KIND_PLATFORM,
ban_kind: str = kit_runs.BAN_KIND_UNKNOWN,
) -> None:
self.banned.append((ban_kind, dict(counters)))
@ -193,7 +197,8 @@ async def test_full_load_sidecar_ban_is_infra_and_keeps_checkpoint() -> None:
Фальсификация: до правки mark_banned не принимал ban_kind вовсе вызов падал
бы на TypeError; с ban_kind, но без проброса из pipeline, диагноз был бы
'platform' (дефолт), то есть ровно то враньё, которое стоило миграции 206.
'unknown' (дефолт с #2764; до него — 'platform', ровно то враньё, которое
стоило миграции 206).
"""
recorder = await _drive_full_load(
AvitoSidecarUnavailableError("avito SERP browser-sidecar error (page=1)")
@ -225,12 +230,16 @@ async def test_full_load_platform_ban_keeps_the_same_checkpoint() -> None:
@pytest.mark.parametrize("module_name", ["kit", "app"])
@pytest.mark.parametrize(
("kwargs", "expected"),
[({}, "platform"), ({"ban_kind": "infra"}, "infra")],
[({}, "unknown"), ({"ban_kind": "infra"}, "infra")],
)
def test_mark_banned_writes_ban_kind(
module_name: str, kwargs: dict[str, str], expected: str
) -> None:
"""Обе копии runs-модуля кладут ban_kind в UPDATE (default — историческое 'platform')."""
"""Обе копии runs-модуля кладут ban_kind в UPDATE; дефолт — 'unknown' (#2764).
Дефолт был 'platform' и на проде обе строки, помеченные уже в рантайме, взяли
именно его: метка выглядела доказательством, не будучи им.
"""
if module_name == "kit":
mod: Any = kit_runs
else:

View file

@ -213,6 +213,9 @@ async def test_newbuilding_enrich_passes_progress_callback() -> None:
update_heartbeat=lambda db, run_id, counters: beats.append(counters),
mark_done=MagicMock(),
mark_failed=MagicMock(),
# Финал прогона ушёл в общий mark_backfill_finished (#2767) — без него подмена
# runs_mod роняет AttributeError и тест меряет не то, что проверяет.
mark_backfill_finished=MagicMock(),
)
with (

View file

@ -0,0 +1,204 @@
"""#2764: ban_kind перестаёт проставляться по умолчанию — метка либо доказана, либо 'unknown'.
Замер на проде 2026-08-07 (после миграции 218 и мержа #2711):
SELECT ban_kind, count(*) FROM scrape_runs WHERE status='banned' GROUP BY 1;
infra 92 / platform 41
Из них ДВЕ строки написаны уже в рантайме и обе получили 'platform' по дефолту
сигнатуры mark_banned, из общего финализатора backfill-задач, который диагноз не
передаёт вовсе (3306 avito 12:42, 3313 domclick 15:20). Для avito причина 1600
отказов вообще не устанавливалась, и всё равно была записана блокировкой площадкой.
Проверяем четыре звена:
1. дефолт mark_banned = 'unknown' (обе копии runs-модуля) test_2686 §4;
2. финализатор backfill'ов несёт диагноз, когда задача его знает, и 'unknown',
когда нет или когда блоки разошлись в диагнозах;
3. browser-режим fetch_detail больше не выдаёт отказ НАШЕГО сайдкара за
блокировку площадкой (зеркало #2686 для SERP-ветки);
4. 'unknown' проходит CHECK-констрейнт (миграция 234) иначе запись упала бы
на проде, а падение внутри задачи выглядело бы как обычный failed-прогон.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.avito_exceptions import (
AvitoBlockedError,
AvitoRateLimitedError,
AvitoSidecarUnavailableError,
)
from scraper_kit.orchestration.pipeline import ban_kind_of_exception
from scraper_kit.providers.avito.detail import fetch_detail
from app.services import scrape_runs as runs_mod
_BLOCKED_RUN = {"attempted": 5, "enriched": 0, "blocked": 5, "failed": 0}
def _ban_kind_of_finished(**kwargs: Any) -> str:
"""mark_backfill_finished на оборванном блоками прогоне → ban_kind, ушедший в mark_banned."""
recorded: list[str] = []
with patch.object(
runs_mod,
"mark_banned",
lambda db, rid, err, c, *, ban_kind: recorded.append(ban_kind),
):
runs_mod.mark_backfill_finished(
MagicMock(),
1,
dict(_BLOCKED_RUN),
source="avito_detail_backfill",
aborted_by_blocks=True,
**kwargs,
)
assert len(recorded) == 1, f"ожидался ровно один mark_banned, получено {recorded}"
return recorded[0]
# ── 1. Финализатор backfill'ов: диагноз или честное «не знаю» ────────────────
def test_finalizer_without_diagnosis_writes_unknown() -> None:
"""Задача типы не различает → 'unknown'.
Фальсификация: до правки этот вызывающий (единственный источник обеих
рантайм-строк на проде) не передавал ничего и получал дефолт 'platform'
assert падает на старом коде.
"""
assert _ban_kind_of_finished() == runs_mod.BAN_KIND_UNKNOWN
def test_finalizer_carries_the_diagnosis_when_all_blocks_agree() -> None:
"""Все блоки прогона — отказ нашего сайдкара → 'infra' доезжает до строки прогона."""
assert _ban_kind_of_finished(ban_kinds={runs_mod.BAN_KIND_INFRA}) == runs_mod.BAN_KIND_INFRA
assert (
_ban_kind_of_finished(ban_kinds=[runs_mod.BAN_KIND_PLATFORM, runs_mod.BAN_KIND_PLATFORM])
== runs_mod.BAN_KIND_PLATFORM
)
def test_finalizer_with_mixed_diagnoses_writes_unknown() -> None:
"""Блоки разошлись → 'unknown': какой из них оборвал прогон, мы не знаем.
Выбрать «последний попавшийся» значило бы вернуть назначенный диагноз под
другим именем.
"""
assert (
_ban_kind_of_finished(ban_kinds={runs_mod.BAN_KIND_INFRA, runs_mod.BAN_KIND_PLATFORM})
== runs_mod.BAN_KIND_UNKNOWN
)
# ── 2. Диагноз не врёт там, где он передаётся: browser-ветка fetch_detail ────
def _browser(*, side_effect: BaseException | None = None, html: str = "") -> Any:
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=side_effect, return_value=html)
return bf
def _sidecar_503() -> httpx.HTTPStatusError:
"""Ровно то, что прод писал 90 раз с 05.07 по 03.08 (см. миграцию 218)."""
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
response = httpx.Response(
503, json={"error": "browser unavailable (proxy may be down)"}, request=request
)
return httpx.HTTPStatusError("503", request=request, response=response)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sidecar_failure",
[_sidecar_503(), httpx.ConnectError("connection refused"), httpx.ReadTimeout("read timeout")],
)
async def test_browser_detail_sidecar_failure_is_infra(sidecar_failure: BaseException) -> None:
"""Сайдкар не отдал страницу → диагноз 'infra', поведение прогона прежнее.
Фальсификация: до правки ветка поднимала AvitoBlockedError на ЛЮБОЙ отказ
сайдкара, то есть ban_kind_of_exception возвращал 'platform' «площадка нас
заблокировала» на странице, которая до площадки не доехала.
Второй assert стережёт совместимость: все существующие
`except (AvitoBlockedError, AvitoRateLimitedError)` обязаны продолжать ловить
этот отказ, иначе прогон уйдёт в mark_failed и потеряет чекпоинт.
"""
with pytest.raises(AvitoSidecarUnavailableError) as excinfo:
await fetch_detail("/items/1", browser_fetcher=_browser(side_effect=sidecar_failure))
assert isinstance(excinfo.value, AvitoRateLimitedError)
assert ban_kind_of_exception(excinfo.value) == runs_mod.BAN_KIND_INFRA
@pytest.mark.asyncio
async def test_browser_detail_firewall_page_stays_platform() -> None:
"""Площадка ПОКАЗАЛА firewall → 'platform' остаётся: правка сужает метку, а не снимает."""
firewall = "<html><body>Доступ ограничен: проблема с IP</body></html>"
with pytest.raises(AvitoBlockedError) as excinfo:
await fetch_detail("/items/1", browser_fetcher=_browser(html=firewall))
assert not isinstance(excinfo.value, AvitoSidecarUnavailableError)
assert ban_kind_of_exception(excinfo.value) == runs_mod.BAN_KIND_PLATFORM
# ── 3. 'unknown' проходит CHECK-констрейнт (миграция 234) ────────────────────
def _live_session() -> Any | None:
"""Session к живой Postgres, если она есть (в CI есть — ci-tradein.yml, #2745)."""
try:
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_default_ban_kind_survives_the_check_constraint() -> None:
"""mark_banned без диагноза пишет 'unknown' в живую схему.
Фальсификация: без миграции 234 CHECK разрешает только platform/infra UPDATE
падает на IntegrityError. На проде это падение было бы неотличимо от обычного
сбоя задачи (внешний `except Exception` backfill'а увёл бы прогон в
mark_failed, потеряв done_buckets-чекпоинт).
"""
from sqlalchemy import text
db = _live_session()
assert db is not None
try:
run_id = db.execute(
text(
"INSERT INTO scrape_runs (source, status) "
"VALUES ('test_2764', 'running') RETURNING id"
)
).scalar_one()
db.commit()
runs_mod.mark_banned(db, int(run_id), "test 2764", {"lots_fetched": 0})
row = db.execute(
text("SELECT status, ban_kind FROM scrape_runs WHERE id = :id"), {"id": run_id}
).one()
assert row.status == "banned"
assert row.ban_kind == runs_mod.BAN_KIND_UNKNOWN
finally:
db.execute(text("DELETE FROM scrape_runs WHERE source = 'test_2764'"))
db.commit()
db.close()

View file

@ -0,0 +1,189 @@
"""#2767 — сделать причину отказа newbuilding_enrich видимой.
Восемь суток подряд прогон делал 25 попыток, обогащал ноль записей и закрывался
статусом 'done'. Различить «страница пришла целиком, изменилась разметка» и
«пришла защитная заглушка» было нельзя: в логе не было ни размера ответа, ни
признаков защиты, а статус утверждал успех.
Две проверки, обе падают на коде до правки:
1. строка отказа разбора несёт размер полученной страницы и найденные маркеры;
2. прогон с ненулевыми попытками и нулём обогащений финализируется через общий
runs.mark_backfill_finished (#2695), а не через mark_done.
Ни сети, ни БД: BrowserFetcher подменён спаем, финализаторы заглушками.
"""
from __future__ import annotations
import logging
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
# DATABASE_URL required by config before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint stub — not installed in CI without GTK.
sys.modules.setdefault("weasyprint", MagicMock())
import pytest # noqa: E402
from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
_describe_parse_miss,
fetch_newbuilding,
)
from app.tasks import newbuilding_enrich_backfill as task_mod # noqa: E402
from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
NewbuildingEnrichBackfillResult,
run_newbuilding_enrich,
)
# ---------------------------------------------------------------------------
# 1. Размер страницы + маркеры защиты в строке отказа разбора.
# ---------------------------------------------------------------------------
def test_describe_parse_miss_reports_size_and_no_markers() -> None:
"""Целая страница без маркеров: размер есть, маркеров нет → «изменилась разметка»."""
html = "<html>" + "x" * 1_170_000 + "</html>"
described = _describe_parse_miss(html)
assert f"html_len={len(html)}" in described
assert "antibot_markers=none" in described
def test_describe_parse_miss_names_found_markers() -> None:
"""Заглушка: маркер назван, но диагноз не выносится — только перечисление найденного."""
described = _describe_parse_miss("<html><div class='g-recaptcha'>Вы не робот?</div></html>")
assert "captcha" in described
assert "вы не робот" in described
assert "html_len=" in described
def _spy_browser_fetcher(html: str) -> MagicMock:
"""BrowserFetcher-подмена: async-CM, чей .fetch() отдаёт заданный html."""
fetcher = MagicMock()
fetcher.fetch = AsyncMock(return_value=html)
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
fetcher.__aexit__ = AsyncMock(return_value=None)
return MagicMock(return_value=fetcher)
@pytest.mark.asyncio
async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None:
"""Отказ разбора обязан писать РАЗМЕР полученной страницы — то самое число, которое
разделяет «изменилась разметка» и «защитная заглушка» (иначе log бесполезен)."""
shell = "<html><body>Доступ ограничен</body></html>" # короткая заглушка
monkeypatch.setattr(
"scraper_kit.providers.cian.newbuilding.BrowserFetcher", _spy_browser_fetcher(shell)
)
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"):
result = await fetch_newbuilding(
"https://zhk-tihiy-centr-ekb-i.cian.ru",
config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"),
)
assert result is None
message = "\n".join(r.getMessage() for r in caplog.records)
assert "initialState extraction failed" in message
assert f"html_len={len(shell)}" in message
assert "доступ ограничен" in message
# ---------------------------------------------------------------------------
# 2. Честный статус: ноль обогащений при ненулевых попытках ≠ 'done'.
# ---------------------------------------------------------------------------
def _stub_finalisers(monkeypatch: pytest.MonkeyPatch) -> dict:
"""Заглушить lifecycle-функции; вернуть журнал вызовов финализаторов."""
calls: dict = {}
monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None)
monkeypatch.setattr(
task_mod.runs_mod,
"mark_done",
lambda _db, run_id, counters: calls.setdefault("mark_done", dict(counters)),
)
monkeypatch.setattr(
task_mod.runs_mod,
"mark_failed",
lambda _db, run_id, err, counters: calls.setdefault("mark_failed", err),
)
monkeypatch.setattr(
task_mod.runs_mod,
"mark_backfill_finished",
lambda _db, run_id, counters, **kw: calls.setdefault(
"finished", {"counters": dict(counters), **kw}
),
)
return calls
def _stub_backfill(
monkeypatch: pytest.MonkeyPatch, result: NewbuildingEnrichBackfillResult
) -> None:
async def _fake(_db, **_kwargs):
return result
monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake)
@pytest.mark.asyncio
async def test_zero_enrichment_run_goes_through_shared_finaliser(monkeypatch) -> None:
"""Прод-картина: 25 попыток, 0 успехов, 25 отказов fetch. Финал — общий
mark_backfill_finished (который такой прогон помечает 'failed'), НЕ mark_done."""
_stub_backfill(
monkeypatch,
NewbuildingEnrichBackfillResult(processed=25, succeeded=0, failed_fetch=25),
)
calls = _stub_finalisers(monkeypatch)
await run_newbuilding_enrich(object(), run_id=42, params={}) # type: ignore[arg-type]
assert "mark_done" not in calls # ← падает на старом коде
finished = calls["finished"]
assert finished["source"] == "newbuilding_enrich"
assert finished["counters"]["attempted"] == 25
assert finished["counters"]["enriched"] == 0
assert finished["counters"]["failed"] == 25
# Диагноза «виновата площадка» здесь нет: блоки не считаются вовсе (#2764).
assert finished["counters"].get("blocked") is None
assert "fetch" in finished["fail_hint"]
@pytest.mark.asyncio
async def test_all_skipped_run_reports_zero_attempts(monkeypatch) -> None:
"""Все выбранные дома уже обогащены → attempted=0: честная пустота, а не отказ
(общий финализатор такой прогон закрывает 'done')."""
_stub_backfill(
monkeypatch,
NewbuildingEnrichBackfillResult(processed=25, skipped_already_enriched=25),
)
calls = _stub_finalisers(monkeypatch)
await run_newbuilding_enrich(object(), run_id=43, params={}) # type: ignore[arg-type]
assert calls["finished"]["counters"]["attempted"] == 0
assert calls["finished"]["fail_hint"] is None
@pytest.mark.asyncio
async def test_partial_success_keeps_rich_counters(monkeypatch) -> None:
"""Ключи финализатора ДОБАВЛЯЮТСЯ к своим счётчикам задачи, а не заменяют их."""
_stub_backfill(
monkeypatch,
NewbuildingEnrichBackfillResult(
processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_rows=7
),
)
calls = _stub_finalisers(monkeypatch)
await run_newbuilding_enrich(object(), run_id=44, params={}) # type: ignore[arg-type]
counters = calls["finished"]["counters"]
assert counters["attempted"] == 10
assert counters["enriched"] == 3
assert counters["failed"] == 7
assert counters["price_dynamics_rows"] == 7 # исходные счётчики на месте
assert counters["succeeded"] == 3

View file

@ -839,13 +839,31 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
len(probe_latencies) >= 10
), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
# 2. ТЕМП ограничен. Флуд предлагал тысячи попыток в секунду — до bcrypt их
# доехало не больше потолка (запас ×1.5 на планировщик).
assert len(codes) > connections, (
"флуд не состоялся: на каждое соединение вышло не больше одного ответа — "
"мерить потолок не на чем"
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает
# потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик).
max_attempts_per_s = ceiling_per_s * 1.5
offered_per_s = len(codes) / elapsed
# ПРЕДУСЛОВИЕ, и оно отделено от вердикта намеренно: «нагрузку создать не
# удалось» и «потолок не работает» — разные новости, и красный обязан их
# различать. Порог здесь ТОТ ЖЕ, с которым сверяется вердикт ниже, и это не
# совпадение: пока флуд предлагает меньше, следующее утверждение зелено даже
# на системе вовсе без потолка, то есть измерения нет.
#
# Раньше условием было `len(codes) > connections` — «каждое соединение
# успело сходить хотя бы дважды за секунду». Это мерило скорости РАННЕРА, а
# не нагрузки: на занятом первый круг из ста запросов сам съедал всю секунду,
# и сторож краснел неотличимо от настоящей поломки потолка (замер 2026-08-07:
# свободная машина — 0 падений из 20, ~800 ответов/с; под load average ~185 —
# 10 падений из 15, и в каждом флуд всё равно предлагал 55-80 запросов/с
# против порога 30/с, то есть мерить было на чем).
assert offered_per_s > max_attempts_per_s, (
f"нагрузку создать не удалось: флуд предложил {offered_per_s:.0f} запросов/с "
f"({len(codes)} за {elapsed:.2f}с) — не больше порога следующей проверки "
f"({max_attempts_per_s:.0f}/с), она прошла бы и без потолка. Это «раннер не "
f"потянул», НЕ «потолок сломан»"
)
assert attempts_per_s <= ceiling_per_s * 1.5, (
assert attempts_per_s <= max_attempts_per_s, (
f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с "
f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает"
)

View file

@ -29,8 +29,10 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st
patch.object(
runs_mod, "mark_failed", lambda db, rid, err, c: calls.append(("failed", err))
),
# **k — mark_banned принимает ещё и ban_kind (#2686/#2764); этот тест про
# ВЫБОР финализатора, диагноз проверяет test_2764_ban_kind_no_default.py.
patch.object(
runs_mod, "mark_banned", lambda db, rid, err, c: calls.append(("banned", err))
runs_mod, "mark_banned", lambda db, rid, err, c, **k: calls.append(("banned", err))
),
):
runs_mod.mark_backfill_finished(

View file

@ -0,0 +1,193 @@
"""#2699 — высота потолков сведена к одной колонке listings.ceiling_height_m.
Что было (прод-замер 2026-08-07, 94 373 объявления):
* ``ceiling_height`` (мигр. 019, numeric(3,2)) 8 554 значения: cian 855,
yandex 7 699. ``ceiling_height_m`` (мигр. 111, numeric(5,2)) 14 824:
avito 7 149, yandex 7 675.
* Пересечение 7 675 строк, РАСХОЖДЕНИЙ НОЛЬ одно и то же значение в двух
ящиках, а не два конфликтующих источника.
* Эстиматор (``_apply_kitchen_ceiling_signal``) читает только ``_m`` 855
циановских + 24 яндексовых значения не видит никто.
* Гейт правдоподобия был только у yandex SERP avito detail нагнал 26 значений
> 6 м (максимум 29.90) и 83 ровных 0.00.
Каждый тест ниже КРАСНЫЙ на коде до #2699.
"""
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import MagicMock
import pytest
from scraper_kit.ceiling_height import plausible_ceiling_m
from scraper_kit.providers.avito.detail import _parse_height_m
from scraper_kit.providers.cian.detail import DetailEnrichment as CianEnrichment
from scraper_kit.providers.cian.detail import save_detail_enrichment as cian_save
from scraper_kit.providers.yandex.detail import DetailEnrichment as YandexEnrichment
from scraper_kit.providers.yandex.detail import save_detail_enrichment as yandex_save
from app.services.matching.conflict_resolution import (
HOUSE_FIELD_PRIORITY,
LISTING_FIELD_PRIORITY,
)
# ── гейт правдоподобия ───────────────────────────────────────────────────────
@pytest.mark.parametrize(
("raw", "expected"),
[
(2.7, 2.7), # типовая панелька
(2.0, 2.0), # нижняя граница включительно
(6.0, 6.0), # верхняя граница включительно (двухуровневая карточка)
(5.97, 5.97), # реальный максимум cian на проде
("2,7".replace(",", "."), 2.7), # строковый вход от парсера
(29.90, None), # прод-максимум мусора avito
(18.0, None), # мусор yandex SERP из #2007
(0.0, None), # 83 строки avito на проде
(1.70, None), # ниже жилого потолка
(9.99, None), # влезает в numeric(3,2), но потолком не является
(None, None),
("не число", None),
],
)
def test_plausible_ceiling_m_band(raw: object, expected: float | None) -> None:
assert plausible_ceiling_m(raw) == expected # type: ignore[arg-type]
def test_avito_parse_height_rejects_impossible() -> None:
"""КОРЕНЬ мусора: парсер avito брал первое число строки без проверки.
Красный до #2699: возвращал 29.9 / 0.0 и они уезжали в ceiling_height_m,
который читает эстиматор.
"""
assert _parse_height_m("2,7 м") == pytest.approx(2.7)
assert _parse_height_m("3 м") == pytest.approx(3.0)
assert _parse_height_m("29,9 м") is None
assert _parse_height_m("0 м") is None
assert _parse_height_m("нет данных") is None
# ── писатели пишут В КАНОН ───────────────────────────────────────────────────
def _mock_db() -> MagicMock:
"""Session-заглушка, копящая (sql, params) каждого execute."""
db = MagicMock()
calls: list[tuple[str, dict]] = []
@contextmanager
def _nested(): # type: ignore[no-untyped-def]
yield MagicMock()
db.begin_nested.side_effect = _nested
def _execute(sql, params=None): # type: ignore[no-untyped-def]
calls.append((str(sql), params or {}))
result = MagicMock()
result.fetchone.return_value = None
result.scalar_one_or_none.return_value = None
result.rowcount = 1
return result
db.execute.side_effect = _execute
db._calls = calls
return db
def _yandex_enrichment(ceiling_height: float) -> YandexEnrichment:
return YandexEnrichment(
offer_id="1234567890",
source_url="https://realty.yandex.ru/offer/1234567890/",
ceiling_height=ceiling_height,
)
def _listings_update_sql(db: MagicMock) -> str:
for sql, _params in db._calls:
if "UPDATE listings" in sql:
return sql
raise AssertionError("не нашли UPDATE listings")
def test_cian_detail_writes_canonical_column() -> None:
"""855 значений Циана уходили в колонку, которую эстиматор не читает.
Красный до #2699: UPDATE присваивал `ceiling_height`, не `ceiling_height_m`.
"""
db = _mock_db()
cian_save(db, 55, CianEnrichment(ceiling_height=2.7))
sql = _listings_update_sql(db)
assert "ceiling_height_m = COALESCE" in sql
assert "ceiling_height = COALESCE" not in sql
def test_yandex_detail_writes_canonical_column() -> None:
"""Красный до #2699: yandex detail тоже писал только в 019-колонку."""
db = _mock_db()
yandex_save(db, 77, _yandex_enrichment(2.55))
sql = _listings_update_sql(db)
assert "ceiling_height_m = COALESCE" in sql
assert "ceiling_height = COALESCE" not in sql
def test_detail_writers_gate_impossible_values() -> None:
"""Невозможная высота не доезжает до БД ни одним из detail-путей."""
db_cian = _mock_db()
cian_save(db_cian, 55, CianEnrichment(ceiling_height=29.9))
assert next(p for s, p in db_cian._calls if "UPDATE listings" in s)["ch"] is None
db_yandex = _mock_db()
yandex_save(db_yandex, 77, _yandex_enrichment(0.0))
assert next(p for s, p in db_yandex._calls if "UPDATE listings" in s)["ceiling_height"] is None
def test_base_upsert_no_longer_writes_deprecated_column() -> None:
"""save_listings писал ОДИН param в ОБЕ колонки — источник дубля.
Красный до #2699: SQL содержал `ceiling_height,` в списке колонок INSERT.
Проверяем по исходнику модуля: собрать SQL иначе можно только выполнив
upsert против живой БД.
"""
import re
from pathlib import Path
import scraper_kit.base as base
src = Path(base.__file__).read_text(encoding="utf-8")
# SQL-упоминания колонки: `ceiling_height` не как префикс `ceiling_height_m`
# и не внутри комментария (`--`).
offenders = [
line
for line in src.splitlines()
# `ceiling_height` в позиции SQL-колонки: перед `,` (список INSERT) или
# перед `=` (SET). Импорт модуля-гейта и комментарии не считаются.
if re.search(r"\bceiling_height\b(?!_m)\s*[,=]", line) and "--" not in line
]
assert offenders == [], f"base.py всё ещё пишет DEPRECATED-колонку: {offenders}"
# ── карта приоритетов больше не указывает в пустоту ──────────────────────────
def test_house_priority_has_no_phantom_ceiling_rule() -> None:
"""Колонки `ceiling_height` в таблице houses нет и не было.
Красный до #2699: правило существовало и не могло сработать ни разу.
"""
assert not [k for k in HOUSE_FIELD_PRIORITY if "ceiling" in k]
def test_listing_priority_names_the_real_column() -> None:
"""Красный до #2699: ключ назывался `ceiling_height` — колонка есть, но её
не читает ни один потребитель, влияющий на цену."""
assert "ceiling_height" not in LISTING_FIELD_PRIORITY
assert LISTING_FIELD_PRIORITY["ceiling_height_m"] == [
"cian_detail",
"avito_detail",
"yandex_detail",
]

View file

@ -13,6 +13,7 @@
from __future__ import annotations
import logging
import os
from unittest.mock import AsyncMock, MagicMock
@ -52,12 +53,42 @@ def test_fixture_still_carries_the_phrase() -> None:
("", (None, None)),
(None, (None, None)),
("просмотров нет", (None, None)), # без цифр — пусто, а не 0
("нет данных", (None, None)), # ответ площадки «нечего показать»
],
)
def test_parse_views_forms(phrase: str | None, expected: tuple[int | None, int | None]) -> None:
assert _parse_views(phrase) == expected
def test_unparsed_phrase_is_logged_with_itself(caplog: pytest.LogCaptureFixture) -> None:
"""Провал разбора обязан быть отличим от «Циан не прислал» (эпик #2674).
Прошлая пропажа держалась месяцами ровно потому, что в журнале не было ни
строки: пустая колонка одинаково выглядела и при сломанном разборе, и при
молчании площадки. Поэтому в warning уходит САМА фраза.
"""
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"):
assert _parse_views("нет данных") == (None, None)
assert "нет данных" in caplog.text, "неразобранная фраза не видна в журнале"
def test_absent_phrase_is_not_logged(caplog: pytest.LogCaptureFixture) -> None:
"""Циан не прислал ключ вовсе — это не поломка разбора, шуметь не о чем."""
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"):
assert _parse_views(None) == (None, None)
assert caplog.text == ""
def test_no_warning_when_today_tail_absent(caplog: pytest.LogCaptureFixture) -> None:
"""«0 просмотров» без хвоста «за сегодня» — штатная форма, не повод для warning."""
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"):
assert _parse_views("0 просмотров") == (0, None)
assert caplog.text == ""
async def test_fetch_detail_fills_views_from_real_saved_page() -> None:
"""Настоящая сохранённая страница → views_total/views_today непусты (#2669)."""
fetcher = MagicMock()

View file

@ -220,8 +220,7 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None:
assert data["total_deals"] == 1
assert data["deals_with_listings"] == 1
assert data["linkage_rate_pct"] == 100.0
# #2666: сама пара отдаётся как есть (её discount_pct — наблюдаемый факт), а
# вот СВОДНАЯ медиана по одной паре гасится гейтом правдоподобия.
# #2666: сводная медиана по одной паре гасится гейтом правдоподобия.
assert data["median_discount_pct"] is None
# «всего 1 —» целиком: голое "1" было бы всегда истинно (подстрока "10").
assert "всего 1 —" in data["median_discount_explanation"]
@ -231,7 +230,12 @@ def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None:
assert pair["listing_id"] == 2001
assert pair["listing_source"] == "avito"
assert pair["days_listing_to_deal"] == 46
assert pair["discount_pct"] == -5.77
# #2672 п.3 (было: «пара — наблюдаемый факт, отдаём как есть»): эта пара стоит
# на ОДНОМ объявлении, поэтому её процент тоже гаснет. Обе цены — сделки и
# объявления — остаются: убирается подпись «торг», а не данные.
assert pair["discount_pct"] is None
assert pair["deal_price_rub"] == 4_900_000
assert pair["listing_price_rub"] == 5_200_000
# ── Test: LEFT JOIN — deal без listing match ─────────────────────────────────
@ -636,6 +640,62 @@ def test_median_discount_kept_at_min_distinct_listings_boundary(trade_in_app: Fa
assert data["median_discount_explanation"] is None
def test_row_discounts_gated_when_pairs_stand_on_one_listing(trade_in_app: FastAPI) -> None:
"""#2672 п.3: погашена медиана — гаснут и проценты в строках таблицы.
50 пар на ОДНОМ объявлении: столбец «разница» здесь столбец цены сделки,
поделённый на одну и ту же константу, т.е. ни одного наблюдения сверх уже
показанных цен. Старый код отдавал 50 процентов под погашенным сводным
числом. Цены сделки и объявления остаются убирается подпись «торг».
"""
data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.36] * 50, n_listings=1))
assert data["median_discount_pct"] is None
assert all(p["discount_pct"] is None for p in data["pairs"])
assert all(p["listing_price_rub"] is not None for p in data["pairs"])
# Микрокопия обязана назвать причину строк, а не только медианы.
assert "по каждой сделке тоже не показываем" in data["median_discount_explanation"]
assert "одной и той же цены" in data["median_discount_explanation"]
def test_row_discounts_gated_when_median_implausible(trade_in_app: FastAPI) -> None:
"""#2672 п.3, живой кейс: под погашенной медианой Космонавтова видны +76%.
7 пар по 37.57% и 4 по +76% на двух объявлениях: порог по объявлениям не
срабатывает (2 2), медиана 37.6% вне диапазона. По определению медианы
это утверждение о СТРОКАХ половина из них ещё дальше от рынка.
"""
rows = _rows_on_shared_listings([-37.57] * 7 + [76.0] * 4, n_listings=2)
data = _get_sales(trade_in_app, rows)
assert data["median_discount_pct"] is None
assert all(p["discount_pct"] is None for p in data["pairs"])
assert "половина из них" in data["median_discount_explanation"]
# Значение медианы в объяснении строк НЕ повторяется: в этой ветке оно уже
# названо предложением выше, а в ветке «мало пар» его намеренно не
# показывают — там оно стало бы отказом на словах.
assert data["median_discount_explanation"].count("37.6%") == 1
def test_row_discounts_kept_when_gate_fired_only_on_sample_size(trade_in_app: FastAPI) -> None:
"""«Мало пар» гасит медиану, но НЕ строки — это свойство выборки, не пары.
4 пары на 4 объявлениях с рыночной медианой: про отдельную пару «пар всего
4» не говорит ничего, и микрокопия «поэтому процент в строке не показываем»
была бы ложной причиной. На проде это 3 группы / 20 строк.
"""
data = _get_sales(trade_in_app, _rows_with_discounts([-8.0, -9.0, -10.0, -11.0]))
assert data["median_discount_pct"] is None
assert "пар «сделка ↔ объявление» всего 4" in data["median_discount_explanation"]
assert "по каждой сделке тоже не показываем" not in data["median_discount_explanation"]
assert [p["discount_pct"] for p in data["pairs"]] == [-8.0, -9.0, -10.0, -11.0]
def test_row_discounts_kept_when_median_shown(trade_in_app: FastAPI) -> None:
"""Обратное направление: медиана прошла гейт — строки не трогаем."""
data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.98] * 117, n_listings=2))
assert data["median_discount_pct"] == -12.98
assert all(p["discount_pct"] == -12.98 for p in data["pairs"])
def test_median_discount_gated_when_implausibly_positive(trade_in_app: FastAPI) -> None:
"""#2666: «продали на 36% дороже, чем просили» — артефакт пейринга по улице.
@ -735,10 +795,17 @@ def test_too_few_pairs_reported_before_out_of_range(trade_in_app: FastAPI) -> No
def test_median_discount_gate_leaves_pairs_and_linkage_untouched(
trade_in_app: FastAPI,
) -> None:
"""Гейт гасит ТОЛЬКО сводную медиану: linkage_rate_pct и per-pair discount_pct
остаются это наблюдаемые факты, а не оценка по улице."""
"""Гейт гасит ЧИСЛА, но не виджет: сами пары, цены и linkage остаются.
#2672 п.3 сузил это правило: 11 пар с медианой +36.4% вне диапазона теряют и
поштучный процент (утверждение о медиане здесь же и утверждение о строках
половина из них ещё дальше от рынка). Строка при этом остаётся полной: цена
сделки, цена объявления, срок и ссылка на месте.
"""
data = _get_sales(trade_in_app, _rows_with_discounts([36.4] * 11))
assert data["median_discount_pct"] is None
assert data["linkage_rate_pct"] == 100.0
assert len(data["pairs"]) == 11
assert all(p["discount_pct"] == 36.4 for p in data["pairs"])
assert all(p["discount_pct"] is None for p in data["pairs"])
assert all(p["listing_price_rub"] is not None for p in data["pairs"])
assert all(p["listing_source_url"] for p in data["pairs"])

View file

@ -373,7 +373,6 @@ def _make_dq_db_mock() -> MagicMock:
"f_lon": 950,
"f_kitchen_area_m2": 600,
"f_living_area_m2": 100,
"f_ceiling_height": 50,
"f_ceiling_height_m": 300,
"f_metro_stations": 700,
}
@ -388,8 +387,10 @@ def _make_dq_db_mock() -> MagicMock:
"f_lon": 480,
"f_kitchen_area_m2": 400,
"f_living_area_m2": 400,
"f_ceiling_height": 350,
"f_ceiling_height_m": 0,
# #2699: cian тоже пишет в канон ceiling_height_m — раньше в фикстуре
# стояло f_ceiling_height=350 / f_ceiling_height_m=0, ровно тот прод-раскол,
# из-за которого 855 циановских значений не видел ни один потребитель.
"f_ceiling_height_m": 350,
"f_metro_stations": 100,
}
@ -451,8 +452,8 @@ def test_data_quality_shape(client: TestClient) -> None:
cian = sources["cian"]
assert cian["active_count"] == 500
assert cian["stale_count"] == 300 # #2660: 60% «активных» на деле не виделись
assert cian["fields"]["ceiling_height"] == pytest.approx(70.0, abs=0.1)
assert cian["fields"]["ceiling_height_m"] == pytest.approx(0.0, abs=0.1)
assert cian["fields"]["ceiling_height_m"] == pytest.approx(70.0, abs=0.1)
assert "ceiling_height" not in cian["fields"] # #2699: DEPRECATED, убрана
houses = body["houses"]
assert houses["total"] == 2000

View file

@ -61,7 +61,7 @@ class _RunsRecorder:
error: str,
counters: dict[str, Any],
*,
ban_kind: str = "platform",
ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля
) -> None:
self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус
self.calls.append(("mark_banned", dict(counters)))

View file

@ -71,7 +71,7 @@ class _RunsRecorder:
error: str,
counters: dict[str, Any],
*,
ban_kind: str = "platform",
ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля
) -> None:
self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус
self.calls.append(("mark_banned", dict(counters)))

View file

@ -71,6 +71,7 @@ _PRODUCT_SOURCES: set[str] = {
"newbuilding_enrich",
"yandex_newbuilding_sweep",
"geoportal_coords_backfill",
"house_coords_from_listings",
"geocode_missing_listings",
"avito_detail_backfill",
"yandex_detail_backfill",

View file

@ -385,3 +385,42 @@ class TestHelpers:
result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL)
assert result is not None
assert result.metro_walk_min == 7
# ---------------------------------------------------------------------------
# #2669 — просмотры с разделителем тысяч
# ---------------------------------------------------------------------------
# Форма взята дословно из сохранённого ответа Яндекса
# tests/fixtures/yandex_offer_3402418396407468801.html (сверено 2026-08-07):
# <div data-test="OfferCardSummary">16 июня, 8 просмотров35,5 м², ...
# 3 850 000 ₽108 451 ₽ за м²...
# — тот же блок, из которого читаются просмотры, отдаёт числа с NBSP-разделителем.
# Прежняя RE_VIEWS `(\d+)\s+просмотр` обрывалась на разделителе и писала в БД
# ПОСЛЕДНИЕ ТРИ ЦИФРЫ (1 234 → 234): не пусто, а тихо неверно. Косвенное
# подтверждение с прода на 2026-08-07: из 1620 непустых views_total_yandex
# ровно НОЛЬ значений ≥ 1000, при 2512 таких у avito.
@pytest.mark.parametrize(
("views_fragment", "expected"),
[
("8\xa0просмотров", 8), # дословно из сохранённого ответа
("1\xa0234 просмотра", 1234), # NBSP-разделитель — до правки давало 234
("1 234 просмотра", 1234), # обычный пробел
("12\xa0345 просмотров", 12345),
("342 просмотра", 342), # без разделителя — прежнее поведение цело
],
)
def test_views_total_survives_thousands_separator(views_fragment: str, expected: int) -> None:
html = _make_html(summary_text=f"Екатеринбург, улица Малышева, д. 5 • {views_fragment}")
result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL)
assert result is not None
assert result.views_total == expected, "просмотры Яндекса снова режутся по разделителю тысяч"
def test_views_total_absent_stays_none() -> None:
"""Нет блока просмотров → None, а не 0: «не пришло» ≠ «ноль просмотров»."""
html = _make_html(summary_text="Екатеринбург, улица Малышева, д. 5 • нет данных")
result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL)
assert result is not None
assert result.views_total is None

View file

@ -23,13 +23,51 @@
# ПЕРЕСОЗДАСТ ВСЕ tradein-контейнеры, включая scraper — прервёт бегущий sweep
# (stop_grace_period 120s даёт unit'у до-checkpoint'иться). Допустимо, разово.
#
# logging: json-file с ротацией (20m × 3 = ≤60M на сервис). До этого драйвер по
# умолчанию рос без границ. Общий anchor ниже.
# logging: journald (#2741). Было json-file 20m × 3 — оно решало только размер,
# и ценой того, что лог ЖИВЁТ В КОНТЕЙНЕРЕ: `docker rm` уносит его целиком.
# Деплоев ~20/сутки, скрейперы работают ночью, разбор идёт утром → окно жизни
# лога почти никогда не покрывает интересное (#2695, #2698, #2676 — три разбора
# подряд уперлись в «логов уже нет»).
#
# journald: демон отдаёт stdout/stderr в /var/log/journal (persistent, на хосте),
# запись переживает пересоздание контейнера и читается ПО ВРЕМЕНИ, а не «сколько
# осталось от последнего рестарта».
#
# КАК ЧИТАТЬ (проверено на проде 2026-08-06):
# docker logs tradein-scraper # как и раньше: только текущий контейнер
# # история через пересоздания — журнал принадлежит root, а deploy-юзер
# # gendesign состоит в docker, но НЕ в adm/systemd-journal, и sudo просит пароль,
# # поэтому голый `journalctl` у него выдаёт «No entries». Рабочий однострочник:
# docker run --rm -v /:/host:ro alpine chroot /host sh -c \
# 'TZ=UTC journalctl -t tradein-scraper -o short-iso \
# --since "2026-08-07 00:39" --until "2026-08-07 01:40"'
# # TZ=UTC — не украшение: `--since/--until` разбираются в ЛОКАЛЬНОМ времени хоста
# # (а он в +03), и флаг `--utc` на это НЕ влияет — он меняет только вывод. Без
# # TZ=UTC окно уезжает на 3 часа и молча отдаёт чужой кусок ночи. Проверено.
# # То же по метке контейнера: CONTAINER_NAME=tradein-scraper (-o cat -f для tail -f)
# Владельцу стоит разово выдать `sudo usermod -aG adm gendesign` — после этого
# journalctl работает напрямую, без docker-обёртки (host-config, не этот файл).
#
# Ротация: журнал общий на хост, режется самим journald по размеру (SystemMaxUse)
# и НЕ обнуляется пересозданием контейнера — то есть глубина истории меряется
# сутками, а не «сколько прошло с последнего деплоя» (это и был баг #2741/#2715:
# 60 МБ json-file при флуде прокручивались за минуты, а деплой обнулял и их).
# Жёсткий потолок по возрасту (MaxRetentionSec) — при желании host drop-in.
# Замер 2026-08-06: /var/log/journal =
# 2.3G за 103 дня (~22 МБ/сутки системных) при дефолтном потолке SystemMaxUse=4G;
# tradein добавляет единицы-десятки МБ/сутки (5.8k карточек за сутки по
# scrape_runs, ~153 Б/строку) → до вытеснения старого ещё недели, требуемые
# «сутки-двое» покрыты с запасом. Дисковый риск нулевой: 4G — это ПОТОЛОК, при
# его достижении journald сам удаляет старейшее.
# Ceiling: journald рейт-лимитит (дефолт 10000 сообщений / 30s на сервис) — при
# превышении в журнал попадёт «Suppressed N messages». Июльский флуд postgres
# (40 МБ за 6 часов ≈ 12 строк/с) от лимита в ~25 раз ниже, но если такое
# появится — это host-config (drop-in journald.conf.d), не этот файл.
# tag: имя контейнера вместо ID — SYSLOG_IDENTIFIER стабилен между пересозданиями.
x-logging: &default-logging
driver: json-file
driver: journald
options:
max-size: "20m"
max-file: "3"
tag: "{{.Name}}"
services:
browser:
@ -168,6 +206,21 @@ services:
# Значение ДОЛЖНО совпадать с TRADEIN_INTERNAL_AUTH_SECRET в .env главного
# (Caddy) стека. Читается также из backend/.env.runtime (env_file выше).
TRADEIN_INTERNAL_AUTH_SECRET: "${TRADEIN_INTERNAL_AUTH_SECRET:-}"
# #2709. Дефолт в коде — redis://localhost:6379/0, и внутри контейнера это
# мгновенный ConnectionRefused (замер: 16.6 мс) → кэш не работал ни дня.
# ⚠️ ПОРЯДОК КРИТИЧЕН, и цена ошибки измерена тем же клиентом, что в
# app/services/cache.py (socket_timeout=2.0, socket_connect_timeout=2.0):
# localhost, отказ соединения 16.6 мс ← как было
# имя не резолвится (NXDOMAIN) 123.5 мс
# имя резолвится, но не отвечает 2003.7 мс ← вот эта яма
# get/set глотают исключение в logger.warning и возвращают промах, поэтому
# яма МОЛЧАЛИВАЯ: 2 с на GET + 2 с на SET на каждый запрос, без единой
# ошибки наверх. Отсюда правило: сначала связность, потом переменная.
# Здесь она задаётся только потому, что связность уже проверена на проде
# ДО этой строки (throwaway-redis на gendesign_shared, из tradein-backend:
# PING 19 мс, SET 0.35 мс, GET 0.31 мс) — см. описание PR.
# db1 — не 0 и не 2: 0 занят celery-брокером gendesign, 2 — glitchtip'ом.
REDIS_URL: "redis://gendesign-redis:6379/1"
depends_on:
browser:
condition: service_started

View file

@ -4,9 +4,13 @@
# ---- deps ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
# Лок ОБЯЗАТЕЛЕН (без `*`): `npm ci` без него падает, и это правильно —
# отсутствие лока раньше молча давало `npm install`, т.е. состав зависимостей
# определялся датой сборки (#2770).
COPY package.json package-lock.json ./
# --legacy-peer-deps: React 19 peer-dep mismatches (как в соседнем frontend/).
RUN --mount=type=cache,target=/root/.npm \
npm install --legacy-peer-deps --no-audit --no-fund
npm ci --legacy-peer-deps --no-audit --no-fund
# ---- builder ----

8641
tradein-mvp/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs"
},
"dependencies": {
@ -18,12 +20,18 @@
"recharts": "^2.15.4"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@eslint/eslintrc": "^3.0.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0",
"typescript": "^5.9.3"
"jsdom": "^25.0.1",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
/**
* #2766 отказ по частоте на форме входа должен читаться как отказ по частоте.
*
* PR #2712/#2717 ввели 429 при насыщении проверок пароля. До этого любой не-401
* падал в общую фразу «Не удалось войти», а человек, которого притормозил
* лимит, читал её как проблему сети и долбил форму дальше. Здесь закреплено,
* что 429 даёт СВОЁ сообщение, и что оно не путается ни с «неверный пароль»,
* ни с общей ошибкой.
*
* Подменяется `fetch`, а не `loginErrorMessage`: так в тесте работает настоящая
* `apiFetch` и настоящий `HTTPError` то есть проверяется весь путь от кода
* ответа до текста на экране, а не одна функция в вакууме.
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "../page";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
}));
/**
* Ответ на POST /auth/login задаётся тестом; всё остальное (поллинг непрочитанных
* у виджета поддержки на этой же странице) отвечает пустым 200, чтобы фон не
* подмешивал свои ошибки в проверяемое состояние формы.
*/
function stubFetch(loginStatus: number, loginBody: unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/auth/login")) {
return new Response(JSON.stringify(loginBody), {
status: loginStatus,
headers: { "Content-Type": "application/json" },
});
}
return new Response("{}", { status: 200 });
}),
);
}
function renderLogin() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<LoginPage />
</QueryClientProvider>,
);
}
function submitCredentials() {
fireEvent.change(screen.getByLabelText("Логин"), {
target: { value: "praktika" },
});
fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "hunter2" },
});
fireEvent.submit(screen.getByRole("form", { name: "Вход в Меру" }));
}
describe("LoginPage — сообщение об ошибке входа", () => {
beforeEach(() => vi.unstubAllGlobals());
afterEach(() => vi.unstubAllGlobals());
it("429 — сообщение про частоту попыток, а не про пароль и не общее", async () => {
stubFetch(429, { detail: "Too Many Requests" });
renderLogin();
submitCredentials();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(
"Слишком много попыток. Попробуйте через несколько минут",
);
expect(alert).not.toHaveTextContent("Неверный логин или пароль");
expect(alert).not.toHaveTextContent("Не удалось войти");
});
it("401 — сообщение про пароль (429-ветка не поглощает остальные коды)", async () => {
stubFetch(401, { detail: "Invalid credentials" });
renderLogin();
submitCredentials();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Неверный логин или пароль");
expect(alert).not.toHaveTextContent("Слишком много попыток");
});
it("500 — общая фраза (её текст не должен подменять собой 429)", async () => {
stubFetch(500, { detail: "boom" });
renderLogin();
submitCredentials();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(
"Не удалось войти. Проверьте подключение и попробуйте ещё раз",
);
});
it("до отправки формы сообщения об ошибке нет", async () => {
stubFetch(429, { detail: "Too Many Requests" });
renderLogin();
await waitFor(() =>
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
);
});
});

View file

@ -652,7 +652,7 @@ export default function TradeInV2Page() {
// TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the
// authoritative monthly counter; fall back to the loaded history length, then
// to the fixture default (undefined) for unwired/pre-load states.
// to undefined (TopNav renders 0) for pre-load states.
const reportsCount = quota.data?.used ?? history.data?.length;
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows

View file

@ -0,0 +1,106 @@
/**
* #2766 что показывает карточка дома, когда поле пришло пустым.
*
* Повод: `series_name` пуст у всех 9 457 домов на проде (#2700). Строка «Дом»
* склеивается из четырёх полей через « · », и вопрос не в том, есть ли серия,
* а в том, ЧТО видит пользователь на её месте: висящий разделитель, слово
* «null» или ничего. Здесь закреплён последний вариант.
*
* Проверка целится в текст ячейки ЦЕЛИКОМ (`toBe`, не `toContain`) именно
* хвостовой разделитель отличает «пришло пусто, нарисовали пусто» от
* «пришло пусто, нарисовали дыру», и подстрочное совпадение его не заметит.
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SaleShareList } from "../SaleShareList";
import type { BuildingSaleShare } from "@/types/sale-share";
function makeBuilding(
overrides: Partial<BuildingSaleShare> = {},
): BuildingSaleShare {
return {
house_id: 1,
address: "Ленина, 1",
lat: 56.83,
lon: 60.6,
sale_share_pct: 12.5,
sale_share_pct_45d: null,
listings_45d: null,
over_100: false,
active_secondary: 8,
flat_count_effective: 64,
gar_match_method: "exact",
median_price_rub: 6_500_000,
median_price_per_m2: 145_000,
avg_days_on_market: 42,
year_built: 1979,
house_type: "panel",
total_floors: 9,
series_name: "1-464Д",
is_emergency: false,
...overrides,
};
}
function renderList(buildings: BuildingSaleShare[]) {
return render(
<SaleShareList
buildings={buildings}
sort="share_desc"
onSort={vi.fn()}
selectedHouseId={null}
hoveredHouseId={null}
onSelect={vi.fn()}
onHover={vi.fn()}
isLoading={false}
isError={false}
minPct={5}
window="now"
/>,
);
}
/** Текст последней содержательной ячейки строки — колонка «Дом». */
function houseMetaText(address: string): string {
const row = screen.getByText(address).closest("tr");
if (row === null) throw new Error(`строка с адресом ${address} не отрисована`);
// Колонки: Адрес · Доля · В продаже · Медиана · Экспозиция · Дом · Действия.
return row.cells[5].textContent ?? "";
}
describe("SaleShareList — колонка «Дом» при пустом поле", () => {
it("серия есть — попадает в строку последним элементом", () => {
renderList([makeBuilding({ series_name: "1-464Д" })]);
expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт. · 1-464Д");
});
it("series_name = null — строка без хвостового разделителя (прод-состояние #2700)", () => {
renderList([makeBuilding({ series_name: null })]);
expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт.");
});
it("series_name = пустая строка — тоже без хвостового разделителя", () => {
renderList([makeBuilding({ series_name: "" })]);
expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт.");
});
it("пусты все поля дома — прочерк, а не пустая ячейка и не «null»", () => {
renderList([
makeBuilding({
year_built: null,
house_type: null,
total_floors: null,
series_name: null,
}),
]);
expect(houseMetaText("Ленина, 1")).toBe("—");
});
it("пустая серия не съедает остальные поля дома", () => {
renderList([
makeBuilding({ series_name: null, house_type: null, year_built: null }),
]);
expect(houseMetaText("Ленина, 1")).toBe("9 эт.");
});
});

View file

@ -0,0 +1,150 @@
/**
* #2766 погашенное число медианного торга обязано быть ОБЪЯСНЕНО.
*
* Гейт правдоподобия (#2666 + #2672, PR #2706) сократил показываемые числа
* вдвое: 64 группы из 128 32 после порога по различным объявлениям. Бэкенд в
* этом случае обнуляет `median_discount_pct` и кладёт в
* `median_discount_explanation` текст, называющий оба числа (сколько сделок и
* сколько разных объявлений). Пользователь должен увидеть этот текст не
* пустое место (читается как поломка виджета) и не «0%» (читается как «торга
* нет»).
*
* Оба живых потребителя проверяются здесь, потому что гейт один, а мест показа
* два: карточка `/` (StreetDealsCard) и HUD `/v2` (mapHistory dkpHeader.note).
*/
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UseQueryResult } from "@tanstack/react-query";
import { StreetDealsCard } from "../StreetDealsCard";
import { mapHistory } from "../v2/mappers";
import { useSalesVsListings } from "@/lib/trade-in-api";
import type {
AggregatedEstimate,
SalesListingPair,
SalesVsListingsResponse,
} from "@/types/trade-in";
vi.mock("@/lib/trade-in-api", () => ({
useSalesVsListings: vi.fn(),
}));
// Текст ровно в той форме, в какой его строит бэкенд для случая «мало разных
// объявлений» (app/api/v1/trade_in.py, ветка n_distinct_listings < порога).
const GATED_EXPLANATION =
"Медианный торг не показываем: сделок 50, а разных объявлений для сравнения " +
"всего 1 — такой процент говорит о цене одной конкретной квартиры, а не о " +
"торге на улице.";
function makePair(overrides: Partial<SalesListingPair> = {}): SalesListingPair {
return {
deal_id: 1,
deal_date: "2026-03-15",
deal_price_rub: 6_200_000,
deal_price_per_m2: 138_000,
deal_area_m2: 45,
deal_rooms: 2,
deal_floor: 4,
deal_address: "Белинского, 32",
listing_id: 777,
listing_source: "avito",
listing_source_url: "https://avito.ru/777",
listing_date: "2026-01-30",
listing_price_rub: 6_900_000,
listing_price_per_m2: 153_000,
listing_area_m2: 45,
days_listing_to_deal: 44,
discount_pct: -10.1,
...overrides,
};
}
function makeResponse(
overrides: Partial<SalesVsListingsResponse> = {},
): SalesVsListingsResponse {
return {
street: "Белинского",
period_months: 24,
window_days: 180,
area_tolerance: 0.15,
total_deals: 50,
deals_with_listings: 50,
linkage_rate_pct: 100,
median_discount_pct: -10.1,
median_discount_explanation: null,
data_quality: "house_linked",
pairs: [makePair(), makePair({ deal_id: 2 })],
...overrides,
};
}
/** Минимальный estimate — карточка читает из него только адрес/площадь/комнаты. */
const ESTIMATE = {
target_address: "Белинского, 32",
area_m2: 45,
rooms: 2,
} as AggregatedEstimate;
function stubQuery(data: SalesVsListingsResponse) {
vi.mocked(useSalesVsListings).mockReturnValue({
data,
isLoading: false,
isError: false,
} as UseQueryResult<SalesVsListingsResponse>);
}
describe("StreetDealsCard — погашенный медианный торг", () => {
beforeEach(() => vi.mocked(useSalesVsListings).mockReset());
it("число прошло гейт — показываем его, объяснения нет", () => {
stubQuery(makeResponse());
render(<StreetDealsCard estimate={ESTIMATE} />);
// Именно строка-шапка: тот же «10.1%» есть и в бейджах отдельных пар,
// поэтому целимся в блок linkage-hint, а не в первое совпадение на странице.
expect(
screen.getByText(/сделок имеют исторический ASK/),
).toHaveTextContent("медианный торг 10.1%");
expect(screen.queryByText(/не показываем/)).not.toBeInTheDocument();
});
it("число погашено — на его месте объяснение с обоими числами", () => {
stubQuery(
makeResponse({
median_discount_pct: null,
median_discount_explanation: GATED_EXPLANATION,
}),
);
render(<StreetDealsCard estimate={ESTIMATE} />);
expect(screen.getByText(GATED_EXPLANATION)).toBeInTheDocument();
// Ни строки «· медианный торг», ни подставленного вместо неё нуля.
expect(screen.queryByText(/· медианный торг/)).not.toBeInTheDocument();
expect(screen.queryByText(/[+]?0\.0%/)).not.toBeInTheDocument();
// Остальная карточка на месте: гаснет одно число, а не виджет целиком.
expect(screen.getByText("Сделок за 24 мес")).toBeInTheDocument();
expect(screen.getByText("100%")).toBeInTheDocument();
});
});
describe("mapHistory (/v2) — погашенный медианный торг", () => {
it("объяснение попадает в note, числа торга там нет", () => {
const { dkpHeader } = mapHistory(
null,
null,
makeResponse({
median_discount_pct: null,
median_discount_explanation: GATED_EXPLANATION,
}),
);
expect(dkpHeader.note).toContain(GATED_EXPLANATION);
expect(dkpHeader.note).not.toContain("медианный торг ");
});
it("прошедшее гейт число попадает в note вместо объяснения", () => {
const { dkpHeader } = mapHistory(null, null, makeResponse());
// Разделитель дробной части задаёт локаль ru-RU — знак и цифры пиним, а
// запятую/точку нет, иначе тест ловил бы сборку ICU, а не поведение гейта.
expect(dkpHeader.note).toMatch(/медианный торг 10[.,]1%/);
expect(dkpHeader.note).not.toContain("не показываем");
});
});

View file

@ -1,18 +1,20 @@
"use client";
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
// Accepts one composite `data` prop (default = the design fixture); page.tsx
// feeds mapAnalytics(...) output. The price-history dots navigate to the
// "Аналогичные объявления" overlay via onNavigate?.(2).
// Accepts one composite `data` prop; page.tsx feeds mapAnalytics(...) output.
// The price-history dots navigate to the "Аналогичные объявления" overlay via
// onNavigate?.(2).
import { useMemo, useState } from "react";
import { tokens } from "./tokens";
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
import { phYearX as histYearX, PH_GRID_Y } from "./mappers";
import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps {
data?: Analytics;
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
// mapAnalytics output) — an omitted prop must be a TS error, not a silent
// fallback to fabricated house-analytics numbers.
data: Analytics;
onNavigate?: (i: number) => void;
}
@ -130,7 +132,7 @@ function sellTimeTierValid(tier: SellTimeTier): boolean {
}
export default function AnalyticsView({
data = ANALYTICS_FIXTURE,
data,
onNavigate,
}: AnalyticsViewProps) {
const [hoverHist, setHoverHist] = useState(false);

View file

@ -1,13 +1,10 @@
// OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view).
// Faithful markup port of МЕРА Оценка.dc.html lines 593-613.
// Data via `data` prop (mapCache output); defaults to fixtures (cacheKpi + cacheRows).
// Data via the required `data` prop (mapCache output).
import { tokens } from "./tokens";
import { cacheKpi, cacheRows } from "./fixtures";
import type { CacheData } from "./mappers";
const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
/**
* M8 ВРЕМЯ column normalizer. `r.time` arrives PRE-FORMATTED as a string from
* mapCache (CacheRow.time, produced by fmtCacheTime), one of:
@ -34,13 +31,16 @@ function normTime(raw: string | null | undefined): string {
}
interface CacheViewProps {
data?: CacheData;
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
// mapCache output) — an omitted prop must be a TS error, not a silent
// fallback to fabricated «Предыдущие оценки» rows.
data: CacheData;
/** Row-click handler (#2420) navigates to the full report for that
* historical estimate. Optional: unwired/storybook usage leaves rows inert. */
* historical estimate. Optional: leaves rows inert when absent. */
onSelectRow?: (id: string) => void;
}
export function CacheView({ data = FIXTURE_CACHE, onSelectRow }: CacheViewProps) {
export function CacheView({ data, onSelectRow }: CacheViewProps) {
const showSrc = data.rows.some((r) => r.src && r.src !== "—");
// #2420 — ПАРАМЕТРЫ column inserted right after АДРЕС.
const gridCols = showSrc
@ -160,7 +160,7 @@ export function CacheView({ data = FIXTURE_CACHE, onSelectRow }: CacheViewProps)
// #2420 — clicking/activating a row navigates to the full report
// for that historical estimate (SectionOverlay -> page.tsx). Rows
// stay a passive role="row"/role="cell" ARIA-grid когда onSelectRow
// не задан (unwired/storybook usage), меняя только tabIndex/handlers
// не задан (SectionOverlay без обработчика), меняя только tabIndex/handlers
// — the grid structure itself is unchanged.
const clickable = Boolean(onSelectRow);
function activate() {

View file

@ -1,20 +1,23 @@
// Report footer for the /trade-in/v2 "МЕРА Оценка" design port.
// Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439):
// report id / date / valid-until on the left, a decorative centre line, and the
// МЕРА v2.0.6 wordmark on the right. Static markup, data from fixtures.
// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`.
import { tokens } from "./tokens";
import { report, version } from "./fixtures";
import { version } from "./ui-config";
import type { Report } from "./types";
interface FooterProps {
data?: Report;
// Required on the app path (v2/page.tsx always supplies mapReport output or
// EMPTY_REPORT, never the design fixtures) — an omitted prop must be a TS
// error, not a silent fallback to a fabricated report id/date.
data: Report;
// M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate
// (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it.
// (mirrors HeroBar). Optional: omitted → the line stays visible.
hasEstimate?: boolean;
}
export function Footer({ data = report, hasEstimate }: FooterProps) {
export function Footer({ data, hasEstimate }: FooterProps) {
return (
<div
style={{

View file

@ -13,12 +13,8 @@ import {
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens";
import { object, report } from "./fixtures";
import type { HeroBarData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures.
const HERO_FIXTURE: HeroBarData = { report, object };
// estimate ids are server-issued UUIDs — reject anything else before it lands
// in the PDF request path so a tampered id cannot be injected.
const PDF_UUID_RE =
@ -154,7 +150,10 @@ function HeroMiniMap({ lat, lon }: HeroMiniMapProps) {
/* eslint-enable @typescript-eslint/no-explicit-any */
interface HeroBarProps {
data?: HeroBarData;
// Required on the app path (v2/page.tsx always supplies mapObject/mapReport
// output, never the design fixtures) — an omitted prop must be a TS error,
// not a silent fallback to fabricated report numbers.
data: HeroBarData;
estimateId?: string | null;
// M4: when false (no real, sufficient estimate yet) the estimate-dependent
// meta/controls — the «ДЕЙСТВИТЕЛЕН ДО» validity line and «КАК РАССЧИТАНО» —
@ -198,7 +197,7 @@ const pdfFilledStyle: CSSProperties = {
};
export default function HeroBar({
data = HERO_FIXTURE,
data,
estimateId,
hasEstimate,
onOpenInfo,

View file

@ -2,27 +2,24 @@
// Faithful markup port of the design's showHistory block (МЕРА Оценка.dc.html,
// lines 457-489): "История продаж в этом доме" card (house sales) +
// "ДКП-сделки на улице" card (3 KPI + dealine table + footer count).
// Data arrives via the composite `data` prop (mapHistory); the design fixtures
// are the default for unwired/storybook usage. No fetch / no local state.
// Data arrives via the composite `data` prop (mapHistory). No fetch / no local
// state.
import { tokens } from "./tokens";
import { history, dkpRows } from "./fixtures";
import { pluralRu } from "./mappers";
import type { HistoryData } from "./mappers";
// Shared grid templates (kept verbatim from the design column tracks).
const houseGrid = "2fr 1.4fr 1fr 1fr";
// Default presentation data (unwired usage): the existing design fixtures.
const HISTORY_FIXTURE: HistoryData = { ...history, dkpRows };
interface HistoryViewProps {
data?: HistoryData;
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
// mapHistory output) — an omitted prop must be a TS error, not a silent
// fallback to fabricated house-sale/deal rows.
data: HistoryData;
}
export default function HistoryView({
data = HISTORY_FIXTURE,
}: HistoryViewProps) {
export default function HistoryView({ data }: HistoryViewProps) {
const showAsk = data.dkpRows.some((r) => r.ask && r.ask !== "—");
const dkpGridCols = showAsk
? "2.2fr 1fr 1.3fr 1fr .9fr 2fr"

View file

@ -1,20 +1,20 @@
// 03 СВОДКА ОБЪЕКТА — right-rail summary card for the /trade-in/v2 "МЕРА Оценка"
// design port. Faithful markup port of МЕРА Оценка.dc.html lines 397-422:
// address + "В РАСЧЁТЕ" badge, four clickable per-section totals, and a
// "КАЧЕСТВО ДАННЫХ" block with a donut. Data from fixtures; clicks fire
// onNavigate(row.nav) only — no API, no fetching.
// "КАЧЕСТВО ДАННЫХ" block with a donut. Data arrives via the `data` prop
// (mapObject/mapSummary output); clicks fire onNavigate(row.nav) only — no
// API, no fetching in this component itself.
"use client";
import { tokens } from "./tokens";
import { object, summary } from "./fixtures";
import type { ObjectSummaryData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures.
const OBJECT_SUMMARY_FIXTURE: ObjectSummaryData = { object, summary };
interface ObjectSummaryProps {
data?: ObjectSummaryData;
// Required on the app path (v2/page.tsx always supplies mapObject/mapSummary
// output) — an omitted prop must be a TS error, not a silent fallback to
// fabricated per-section totals.
data: ObjectSummaryData;
onNavigate: (i: number) => void;
}
@ -38,7 +38,7 @@ function rowHint(label: string): string {
}
export function ObjectSummary({
data = OBJECT_SUMMARY_FIXTURE,
data,
onNavigate,
}: ObjectSummaryProps) {
return (

View file

@ -27,7 +27,7 @@ import {
type SetStateAction,
} from "react";
import { tokens } from "./tokens";
import { dropdownOptions } from "./fixtures";
import { dropdownOptions } from "./ui-config";
import {
HOUSE_TYPE_FROM_RU,
HOUSE_TYPE_RU,
@ -541,8 +541,8 @@ const radiusPanel: CSSProperties = {
};
interface ParamsPanelProps {
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
* the still-unwired v2 page renders without props (tsc-safe). */
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional
* dates back to the pre-wiring page; v2/page.tsx always supplies it. */
onSubmit?: (input: TradeInEstimateInput) => void;
/** Disables the button + shows a pending label while the estimate is running. */
isPending?: boolean;

View file

@ -3,13 +3,6 @@
import { Fragment } from "react";
import type { Ref } from "react";
import { tokens } from "./tokens";
import {
ranges,
resultCards,
resultMeta,
scatterMini,
sources,
} from "./fixtures";
import type { ResultPanelData } from "./mappers";
const {
@ -39,17 +32,11 @@ const {
font,
} = tokens;
// Default presentation data (unwired usage): the existing design fixtures.
const RESULT_FIXTURE: ResultPanelData = {
cards: resultCards,
meta: resultMeta,
ranges,
scatterMini,
sources,
};
interface ResultPanelProps {
data?: ResultPanelData;
// Required on the app path (v2/page.tsx always supplies mapResultPanel
// output) — an omitted prop must be a TS error, not a silent fallback to
// fabricated price/analytics numbers on a paid estimate screen.
data: ResultPanelData;
onNavigate: (i: number) => void;
// #2264 C7: after a successful estimate the page moves focus here (a labelled,
// programmatically-focusable region) so keyboard/SR users land on the result
@ -92,7 +79,7 @@ function sourceName(name: string) {
}
export default function ResultPanel({
data = RESULT_FIXTURE,
data,
onNavigate,
regionRef,
}: ResultPanelProps) {

View file

@ -9,7 +9,7 @@
import { useEffect, useRef } from "react";
import { tokens } from "./tokens";
import { overlayNums, overlayTitles } from "./fixtures";
import { overlayNums, overlayTitles } from "./ui-config";
import HistoryView from "./HistoryView";
import SourcesView from "./SourcesView";
import AnalyticsView from "./AnalyticsView";
@ -23,12 +23,13 @@ interface SectionOverlayProps {
onClose: () => void;
onNavigate: (i: number) => void;
// Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics
// / mapCache). Optional so unwired/storybook usage falls back to each view's
// fixture default.
history?: HistoryData;
sources?: SourcesData;
analytics?: Analytics;
cache?: CacheData;
// / mapCache) — always computed unconditionally (never undefined), and
// required here in lockstep with the child views' own required `data` prop
// (no more silent fallback to design fixtures on a missing value).
history: HistoryData;
sources: SourcesData;
analytics: Analytics;
cache: CacheData;
// Raw current estimate — threaded straight to SourcesView's SourcesMap
// (real per-lot lat/lon; sources/SourcesData above is pre-mapped display
// strings with no geometry). Optional/null: no estimate yet -> map degrades
@ -36,7 +37,7 @@ interface SectionOverlayProps {
estimate?: AggregatedEstimate | null;
// #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for
// that historical estimate. Optional (threaded straight to CacheView's own
// optional onSelectRow — unwired usage leaves rows inert).
// optional onSelectRow — without a handler rows stay inert).
onSelectEstimate?: (id: string) => void;
}

View file

@ -4,7 +4,6 @@ import type { CSSProperties, ReactNode } from "react";
import { safeUrl } from "@/lib/safeUrl";
import type { AggregatedEstimate } from "@/types/trade-in";
import { tokens } from "./tokens";
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
import { SourcesMap } from "./SourcesMap";
@ -12,9 +11,9 @@ import { SourcesMap } from "./SourcesMap";
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
// Card B: фактические сделки (3 KPI + dealRows table).
// Markup is data-driven via the `data` prop; the design fixtures stay as the
// DEFAULT for unwired/storybook usage. (M11 below may hide an all-«—» column,
// so the storybook render can differ from the design HTML by a dead column.)
// Markup is data-driven via the required `data` prop (mapSources output).
// (M11 below may hide an all-«—» column, so the render can differ from the
// design HTML by a dead column.)
//
// M7 — the two "tables" are CSS grids of <span>s. They now carry real table
// semantics via ARIA roles (table / row / columnheader / cell) layered onto the
@ -140,8 +139,9 @@ const monoMuted2: CSSProperties = {
color: tokens.muted2,
};
// Shared "open original ↗" cell style (anchor + fixture-fallback span render
// identically; textDecoration:none keeps the anchor visually a button).
// "Открыть оригинал ↗" cell style; textDecoration:none keeps the anchor
// visually a button. Only a real <a> ever gets it — a row without a link
// renders a plain dash, not a button-shaped span you cannot click.
const linkBtn: CSSProperties = {
fontSize: "9.5px",
color: tokens.accent,
@ -211,7 +211,7 @@ const AD_COLS: Col<AdRowData>[] = [
head: <span role="columnheader">ИСТОЧНИК</span>,
cell: (r) => (
<span role="cell">
<span style={badge}>{r.source ?? "Avito"}</span>
<span style={badge}>{r.source}</span>
</span>
),
empty: () => false,
@ -264,7 +264,7 @@ const AD_COLS: Col<AdRowData>[] = [
head: <span role="columnheader">ДИСТ.</span>,
cell: (r) => (
<span role="cell" style={monoMuted2}>
{r.dist ?? "—"}
{r.dist}
</span>
),
empty: (r) => isDash(r.dist),
@ -277,11 +277,10 @@ const AD_COLS: Col<AdRowData>[] = [
// role="cell" sits on a display:contents wrapper so the <a> stays the grid
// item (identical layout) AND keeps its link role inside the cell.
cell: (r) => {
// safeUrl gates href against javascript:/data: schemes (XSS). url is
// undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps the
// storybook markup pixel-identical), null for a real row with no link
// (→ "—"), and a string when a real listing URL is available.
const href = typeof r.url === "string" ? safeUrl(r.url) : null;
// safeUrl gates href against javascript:/data: schemes (XSS). url is a
// string when the listing has a link and null when it has none (→ "—").
// There is no third case: mapSources always sets the field.
const href = r.url === null ? null : safeUrl(r.url);
return (
<span role="cell" style={{ display: "contents" }}>
{href ? (
@ -294,12 +293,8 @@ const AD_COLS: Col<AdRowData>[] = [
>
Источник
</a>
) : r.url === null ? (
<span style={monoMuted2}></span>
) : (
<span className="sv-rr" style={linkBtn}>
Росреестр
</span>
<span style={monoMuted2}></span>
)}
</span>
);
@ -337,9 +332,9 @@ const DEAL_COLS: Col<DealRowData>[] = [
head: <span role="columnheader">ИСТОЧНИК</span>,
cell: (r) => (
<span role="cell">
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
<span style={badge}>{r.source}</span>{" "}
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
{r.tier ?? "по улице"}
{r.tier}
</span>
</span>
),
@ -373,7 +368,7 @@ const DEAL_COLS: Col<DealRowData>[] = [
head: <span role="columnheader">ДАТА</span>,
cell: (r) => (
<span role="cell" style={monoMuted2}>
{r.date ?? "—"}
{r.date}
</span>
),
empty: (r) => isDash(r.date),
@ -485,26 +480,20 @@ function DataTable<R>({
);
}
// Default presentation data (unwired/storybook usage): the existing design
// fixtures. AdRow[]/DealRow[] satisfy AdRowData[]/DealRowData[] (extras optional).
const SOURCES_FIXTURE: SourcesData = {
adRows,
dealRows,
marketAds,
marketDeals,
};
interface SourcesViewProps {
data?: SourcesData;
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
// mapSources output) — an omitted prop must be a TS error, not a silent
// fallback to fabricated listing/deal rows.
data: SourcesData;
// Raw estimate (real lat/lon per lot) — the table rows above are pre-mapped
// display strings with no geometry, so SourcesMap needs the source object
// directly. Optional: unwired/storybook usage (no estimate) renders the
// tables only, same honest-empty contract as the rest of the mappers.
// directly. Optional: no estimate yet renders the tables only, same honest-
// empty contract as the rest of the mappers.
estimate?: AggregatedEstimate | null;
}
export default function SourcesView({
data = SOURCES_FIXTURE,
data,
estimate = null,
}: SourcesViewProps) {
const adCols = visibleCols(AD_COLS, data.adRows);

View file

@ -4,7 +4,7 @@
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
// Tabs change only local UI state via onNavigate; the user dropdown owns its
// own useState. No data fetching — labels/version come from fixtures, the user
// own useState. No data fetching — labels/version come from ./ui-config, the user
// identity is fed in from the page (real useMe), colours from tokens.
import { useState } from "react";
@ -13,7 +13,7 @@ import type { CSSProperties } from "react";
import { API_BASE_URL } from "@/lib/api";
import { tokens } from "./tokens";
import { navLabels, version } from "./fixtures";
import { navLabels, version } from "./ui-config";
import { useSupportChat } from "./SupportChatContext";
// Real logged-in user identity, derived by the page from useMe()

View file

@ -0,0 +1,94 @@
// #2755 — колонка ссылки в таблице аналогов «05 РЫНОК».
//
// Здесь до этого жила третья ветка: когда `url` приходил `undefined`, рисовался
// НЕкликабельный <span>, стилизованный под кнопку-ссылку «Росреестр ↗». Ветка
// была недостижима (mapSources всегда ставит `string | null`), но выглядела как
// рабочий элемент интерфейса. Ветку убрали, поле сделали обязательным — этот
// тест держит обе половины: ссылка есть только там, где есть URL, а строка без
// URL получает прочерк, а не кнопку-обманку.
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import SourcesView from "../SourcesView";
import { mapSources } from "../mappers";
import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture";
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
function lot(over: Partial<AnalogLot>): AnalogLot {
return {
address: "ул. Репина, 73",
area_m2: 54,
rooms: 2,
floor: 7,
total_floors: 16,
price_rub: 9_700_000,
price_per_m2: 179_629,
listing_date: "2026-05-12",
days_on_market: 18,
photo_url: null,
source: "avito",
source_url: null,
distance_m: 120,
tier: null,
lat: 56.8401,
lon: 60.5702,
...over,
};
}
// Оффлайн-фикстура оценки репозитория с подменёнными аналогами: остальные поля
// (sources_used, коридоры, KPI) настоящие, меняем ровно то, что проверяем.
function estimateWith(analogs: AnalogLot[]): AggregatedEstimate {
return {
...FIXTURE_ESTIMATE,
analogs,
n_analogs: analogs.length,
actual_deals: [],
};
}
describe("SourcesView — колонка ссылки на оригинал", () => {
it("есть URL — кликабельная ссылка на него", () => {
const estimate = estimateWith([
lot({
address: "ул. Репина, 73",
source_url: "https://www.avito.ru/ekaterinburg/kvartiry/1",
}),
]);
render(<SourcesView data={mapSources(estimate, null)} />);
const link = screen.getByRole("link", { name: /Источник/ });
expect(link).toHaveAttribute(
"href",
"https://www.avito.ru/ekaterinburg/kvartiry/1",
);
});
it("URL нет — прочерк, а не кнопка-обманка «Росреестр ↗»", () => {
const estimate = estimateWith([
lot({ address: "ул. Викулова, 33", source_url: null }),
]);
render(<SourcesView data={mapSources(estimate, null)} />);
expect(screen.queryByText(/Росреестр ↗/)).toBeNull();
expect(screen.queryByRole("link", { name: /Источник/ })).toBeNull();
// Прочерк — единственное содержимое ячейки ссылки в строке аналога.
const row = screen.getByText("ул. Викулова, 33").closest("div");
expect(row?.textContent).toContain("—");
});
it("javascript:-схема отбрасывается safeUrl — прочерк, а не ссылка", () => {
const estimate = estimateWith([
lot({ address: "ул. Кирова, 28", source_url: "javascript:alert(1)" }),
]);
render(<SourcesView data={mapSources(estimate, null)} />);
expect(screen.queryByRole("link", { name: /Источник/ })).toBeNull();
const row = screen.getByText("ул. Кирова, 28").closest("div");
expect(row?.textContent).toContain("—");
});
});

View file

@ -1,672 +0,0 @@
// Sample/markup data for the /trade-in/v2 "МЕРА Оценка" design port.
// Extracted verbatim from the pixel design (МЕРА Оценка.dc.html). This is the
// data source for the markup — components must NOT fetch; they read from here.
// Colour fields reference tokens so the palette stays the single source of truth.
import { tokens } from "./tokens";
import type {
Analytics,
CacheKpi,
CacheRow,
DkpRow,
AdRow,
DealRow,
DropdownOptions,
History,
MarketAds,
MarketDeals,
ObjectInfo,
Ranges,
Report,
ResultCard,
ResultMeta,
ScatterMini,
SourceCard,
Summary,
User,
} from "./types";
export const report: Report = {
id: "9b9e2737",
date: "20.06.2026",
validUntil: "21.06.2026",
retainUntil: null,
};
export const object: ObjectInfo = {
address: "ул. Малышева, 30",
city: "Екатеринбург, Россия",
area: "54",
rooms: "2",
floor: "5",
totalFloors: "17",
year: "1985",
houseType: "Панельный",
repair: "Хороший",
balcony: true,
locationIndexLabel: "+8%",
locationIndexOk: true,
// Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
// fixture coordinate for the HeroBar locator mini-map (unwired usage only).
lat: 56.8384,
lon: 60.6057,
};
// ---- 02 RESULT ------------------------------------------------------------
export const resultCards: ResultCard[] = [
{
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
value: "11,29",
unit: "млн ₽",
range: "7,72 — 14,46 млн ₽",
ppm: "225 801 ₽/м² · по объявлениям",
bars: [30, 55, 80, 100, 70, 45, 25, 35],
nav: 2,
},
{
title: ["ОЖИДАЕМАЯ СДЕЛКА", "(ПОЛУЧИТЕ)"],
value: "10,00",
unit: "млн ₽",
range: "6,83 — 12,81 млн ₽",
ppm: "199 973 ₽/м²",
delta: "11%",
deltaLabel: "К ОБЪЯВЛ.",
nav: 2,
},
{
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
value: "9,43",
unit: "млн ₽",
range: "6,10 — 12,72 млн ₽",
ppm: "188 672 ₽/м² · 44 сделки",
bars: [25, 45, 70, 100, 85, 55, 30, 20],
nav: 1,
},
];
export const resultMeta: ResultMeta = {
sources: "3 / 7",
confidence: "СРЕДНЯЯ",
cv: "59.7%",
};
export const ranges: Ranges = {
ads: {
label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
median: "11,29 млн ₽",
lo: "7,72 млн",
hi: "14,46 млн",
marker: { fillLeft: "8%", fillRight: "18%", dotLeft: "58%" },
},
deals: {
label: ["ДИАПАЗОН ЦЕН ПО", "ФАКТИЧЕСКИМ СДЕЛКАМ"],
median: "6,80 млн ₽",
lo: "4,30 млн",
hi: "19,70 млн",
marker: { fillLeft: "4%", fillRight: "55%", dotLeft: "33%" },
},
};
// Mini "ЦЕНА × СРОК ПРОДАЖИ" scatter inside the ranges card (viewBox 168×140).
export const scatterMini: ScatterMini = {
deals: [
{ x: 52, y: 96, r: 2.4 },
{ x: 64, y: 88, r: 2.4 },
{ x: 58, y: 101, r: 2.4 },
{ x: 74, y: 85, r: 2.4 },
{ x: 47, y: 103, r: 2.4 },
{ x: 88, y: 79, r: 2.4 },
{ x: 70, y: 93, r: 2.4 },
{ x: 55, y: 90, r: 2.4 },
{ x: 82, y: 99, r: 2.4 },
{ x: 44, y: 97, r: 2.4 },
],
analogs: [
{ x: 118, y: 48, r: 3 },
{ x: 100, y: 60, r: 3 },
{ x: 132, y: 40, r: 3 },
{ x: 92, y: 66, r: 3 },
{ x: 112, y: 54, r: 3 },
{ x: 142, y: 34, r: 3 },
],
subject: { x: 96, y: 70, r: 3.6 },
yTicks: [
{ label: "15М", y: 13 },
{ label: "10М", y: 77 },
{ label: "4М", y: 112 },
],
xTicks: [
{ label: "0", x: 34 },
{ label: "90", x: 96 },
{ label: "180", x: 158 },
],
};
export const sources: SourceCard[] = [
{ name: "ЦИАН", count: "7", label: "лотов", active: true },
{ name: "Я.НЕДВИЖИМОСТЬ", count: "1", label: "лот", active: true },
{ name: "РОСРЕЕСТР (ВНУТР.)", count: "10", label: "лотов", active: true },
{ name: "АВИТО ОЦЕНКА", count: "—", label: "нет данных", active: false },
{ name: "ДОМКЛИК ПРАЙС", count: "—", label: "нет данных", active: false },
{ name: "RESTATE", count: "—", label: "нет данных", active: false },
];
// ---- 03 SUMMARY -----------------------------------------------------------
export const summary: Summary = {
rows: [
{ label: "Продажи в доме", value: "2 · 32 дн", dot: "accent", nav: 1 },
{ label: "Аналоги", value: "23 · 6,13 млн", dot: "accent", nav: 2 },
{ label: "Сделки", value: "10 · 5,95 млн", dot: "muted", nav: 2 },
{ label: "Аналитика", value: "67 дн · 3%", dot: "accent", nav: 3 },
],
quality: { sources: "3 / 7", confidence: "средняя", cv: "59.7%" },
};
// ---- OVERLAY 04: ПРОДАЖИ В ДОМЕ -------------------------------------------
export const dkpRows: DkpRow[] = [
{
area: "33.9 м²",
price: "5 950 000 ₽",
ppm: "175 516",
ask: "5 272 800 ₽ за 3 дн до",
delta: "+12.8%",
deltaColor: tokens.danger,
},
{
area: "35.4 м²",
price: "4 900 000 ₽",
ppm: "138 418",
ask: "5 186 700 ₽ за 3 дн до",
delta: "5.5%",
deltaColor: tokens.success,
},
{
area: "36.2 м²",
price: "4 200 000 ₽",
ppm: "116 022",
ask: "5 186 700 ₽ за 3 дн до",
delta: "19.0%",
deltaColor: tokens.success,
},
{
area: "37.0 м²",
price: "7 503 227 ₽",
ppm: "202 790",
ask: "5 272 800 ₽ за 3 дн до",
delta: "+42.3%",
deltaColor: tokens.danger,
},
{
area: "36.8 м²",
price: "7 140 243 ₽",
ppm: "194 028",
ask: "5 272 800 ₽ за 3 дн до",
delta: "+35.4%",
deltaColor: tokens.danger,
},
{
area: "42.1 м²",
price: "6 535 050 ₽",
ppm: "155 227",
ask: "5 272 800 ₽ за 3 дн до",
delta: "+23.9%",
deltaColor: tokens.danger,
},
{
area: "42.0 м²",
price: "6 650 000 ₽",
ppm: "158 333",
ask: "5 781 600 ₽ за 3 дн до",
delta: "+15.0%",
deltaColor: tokens.danger,
},
{
area: "32.5 м²",
price: "5 362 650 ₽",
ppm: "165 005",
ask: "5 325 600 ₽ за 3 дн до",
delta: "+0.7%",
deltaColor: tokens.danger,
},
{
area: "38.8 м²",
price: "4 380 000 ₽",
ppm: "112 887",
ask: "5 325 600 ₽ за 3 дн до",
delta: "17.8%",
deltaColor: tokens.success,
},
{
area: "34.9 м²",
price: "5 000 000 ₽",
ppm: "143 266",
ask: "5 254 500 ₽ за 3 дн до",
delta: "4.8%",
deltaColor: tokens.success,
},
];
export const history: History = {
houseSales: [
{
lot: "2-к. квартира, 50 м², 6/16 эт.",
priceFrom: "6,25",
priceTo: "5,98 млн ₽",
date: "11.07.2025",
exposure: "32 дн.",
},
{
lot: "2-к. квартира, 60 м², 6/16 эт.",
priceFrom: "7,29",
priceTo: "7,09 млн ₽",
date: "11.07.2025",
exposure: "31 дн.",
},
],
dkpHeader: {
title: "ДКП-сделки на улице Академика Ландау",
period: "24 мес",
note: "78% сделок имеют историческую цену в объявлении · медианный торг 36.0%. Данные по улице, не по дому.",
},
dkpKpi: { count: "95", median: "140 066", range: "3,1 7,5" },
};
// ---- OVERLAY 05: АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ -----------------------------------
export const adRows: AdRow[] = [
{
addr: "ул. Академика Ландау, 39",
meta: "32.5 м² · 1-к · этаж 12/15",
ppm: "170 769",
price: "5 550 000",
},
{
addr: "ул. Академика Ландау, 35А/2",
meta: "33.5 м² · 1-к · этаж 13/25",
ppm: "174 626",
price: "5 850 000",
},
{
addr: "ул. Академика Ландау, 38",
meta: "32.7 м² · 1-к · этаж 10/16",
ppm: "171 253",
price: "5 600 000",
},
{
addr: "ул. Академика Ландау, 35",
meta: "32.7 м² · 1-к · этаж 6/12",
ppm: "178 999",
price: "5 853 300",
},
{
addr: "ул. Академика Ландау, 35",
meta: "37.4 м² · 1-к · этаж 8/12",
ppm: "149 732",
price: "5 600 000",
},
{
addr: "ул. Академика Парина, 29",
meta: "40.5 м² · 1-к · этаж 22/23",
ppm: "160 493",
price: "6 500 000",
},
{
addr: "ул. Академика Ландау, 39",
meta: "32.5 м² · 1-к · этаж 15/15",
ppm: "146 153",
price: "4 750 000",
},
{
addr: "ул. Академика Ландау, 39",
meta: "34.9 м² · 1-к · этаж 9/15",
ppm: "143 266",
price: "5 000 000",
},
{
addr: "ул. Академика Парина, 33",
meta: "37.1 м² · 1-к · этаж 6/14",
ppm: "160 916",
price: "5 970 000",
},
{
addr: "ул. Академика Ландау, 34",
meta: "41.7 м² · 1-к · этаж 22/23",
ppm: "167 865",
price: "6 999 999",
},
];
export const dealRows: DealRow[] = [
{
addr: "Екатеринбург, Академика Парина",
meta: "38.1 м² · 1-к",
ppm: "161 417",
price: "6 150 000",
},
{
addr: "Екатеринбург, Академика Ландау",
meta: "33.9 м² · 1-к",
ppm: "175 516",
price: "5 950 000",
},
{
addr: "Екатеринбург, Академика Ландау",
meta: "39.1 м² · 1-к",
ppm: "148 338",
price: "5 800 000",
},
{
addr: "Екатеринбург, Академика Парина",
meta: "33.8 м² · 1-к",
ppm: "147 929",
price: "5 000 000",
},
{
addr: "Екатеринбург, Академика Сахарова",
meta: "37.1 м² · 1-к",
ppm: "167 116",
price: "6 200 000",
},
{
addr: "Екатеринбург, Академика Вавилова",
meta: "38.8 м² · 1-к",
ppm: "165 437",
price: "6 418 972",
},
{
addr: "Екатеринбург, Академика Ландау",
meta: "42.0 м² · 1-к",
ppm: "158 333",
price: "6 650 000",
},
{
addr: "Екатеринбург, Академика Ландау",
meta: "34.9 м² · 1-к",
ppm: "143 266",
price: "5 000 000",
},
{
addr: "Екатеринбург, Академика Парина",
meta: "32.9 м² · 1-к",
ppm: "159 574",
price: "5 250 000",
},
{
addr: "Екатеринбург, Академика Парина",
meta: "41.2 м² · 1-к",
ppm: "101 942",
price: "4 200 000",
},
];
export const marketAds: MarketAds = {
kpi: {
count: "23",
median: "6,13",
ppm: "161 351",
range: "4,77,5",
cv: "45.9%",
},
filters: ["Расстояние ≤ 2 км", "Планировка 1-к", "Площадь 32.5 41.7 м²"],
};
export const marketDeals: MarketDeals = {
kpi: {
count: "10",
median: "5,95",
delta: "3% к рынку",
range: "4,2 6,7",
},
};
// ---- OVERLAY 06: АНАЛИТИКА ДОМА -------------------------------------------
export const analytics: Analytics = {
header: { ads: "615 объявлений", radius: "в радиусе 300 м" },
kpis: [
{
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
value: "67",
unit: "дн.",
sub: "по архивным объявлениям",
},
{
label: "СРЕДНИЙ ТОРГ",
value: "3.0%",
color: tokens.success,
sub: "от начальной до итоговой цены",
},
{
label: "ДОЛЯ СНЯТЫХ",
value: "69%",
sub: "421 из 615",
},
],
sellTimeNote: "Медиана экспозиции по архивным лотам · эталон 177 394 ₽/м²",
sellTime: [
{
tier: "5% от рынка",
days: "~63 дн.",
range: "обычно 18180 дн.",
count: "69 аналогов",
variant: "success",
},
{
tier: "По медиане",
days: "~65 дн.",
range: "обычно 18182 дн.",
count: "79 аналогов",
variant: "accent",
},
{
tier: "+5%",
days: "~78 дн.",
range: "обычно 16212 дн.",
count: "61 аналог",
variant: "gold",
},
{
tier: "+10%",
days: "~67 дн.",
range: "обычно 26260 дн.",
count: "57 аналогов",
variant: "danger",
},
],
priceHistory: {
note: "Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (596 лотов)",
years: ["2022", "2023", "2024", "2025", "2026"],
avito: "58,75 260,82 470,69 680,69 885,73",
yandex: "58,75 260,82 470,51 680,42 885,24",
yTicks: [
{ label: "200k", y: 20 },
{ label: "150k", y: 64 },
{ label: "100k", y: 108 },
{ label: "50k", y: 151 },
{ label: "0k", y: 195 },
],
},
scatterDetail: {
note: "16 сделок и 6 аналогов в радиусе 300 м",
deals: [
{ x: 150, y: 210, r: 4.5 },
{ x: 200, y: 196, r: 4.5 },
{ x: 175, y: 222, r: 4.5 },
{ x: 250, y: 188, r: 4.5 },
{ x: 220, y: 204, r: 4.5 },
{ x: 300, y: 176, r: 4.5 },
{ x: 270, y: 200, r: 4.5 },
{ x: 330, y: 166, r: 4.5 },
{ x: 360, y: 182, r: 4.5 },
{ x: 195, y: 214, r: 4.5 },
{ x: 285, y: 190, r: 4.5 },
{ x: 160, y: 200, r: 4.5 },
{ x: 345, y: 198, r: 4.5 },
{ x: 240, y: 172, r: 4.5 },
{ x: 315, y: 186, r: 4.5 },
{ x: 180, y: 232, r: 4.5 },
],
analogs: [
{ x: 600, y: 96, r: 5.5 },
{ x: 540, y: 124, r: 5.5 },
{ x: 670, y: 84, r: 5.5 },
{ x: 500, y: 142, r: 5.5 },
{ x: 640, y: 104, r: 5.5 },
{ x: 730, y: 70, r: 5.5 },
],
subject: { x: 445, y: 150, r: 6 },
yTicks: [
{ label: "15М", y: 24 },
{ label: "12М", y: 81 },
{ label: "9М", y: 138 },
{ label: "6М", y: 195 },
{ label: "4М", y: 252 },
],
xTicks: [
{ label: "0", x: 60 },
{ label: "45", x: 265 },
{ label: "90", x: 470 },
{ label: "135", x: 675 },
{ label: "180", x: 880 },
],
},
};
// ---- OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ ----------------------------------------
export const cacheRows: CacheRow[] = [
{
id: "fixture-1",
addr: "ул. Малышева, 30",
time: "12:45:02",
src: "3 / 7",
status: "свежий",
statusColor: tokens.success,
params: "2 комн · 54 м² · 5/9 эт.",
},
{
id: "fixture-2",
addr: "ул. Куйбышева, 48",
time: "12:31:50",
src: "4 / 7",
status: "свежий",
statusColor: tokens.success,
params: "3 комн · 72 м² · 12/16 эт.",
},
{
id: "fixture-3",
addr: "ул. Академика Ландау, 35",
time: "11:58:14",
src: "2 / 7",
status: "свежий",
statusColor: tokens.success,
params: "1 комн · 38 м²",
},
{
id: "fixture-4",
addr: "ул. 8 Марта, 120",
time: "09:12:40",
src: "3 / 7",
status: "устаревает",
statusColor: tokens.warn,
params: "студия · 24 м² · 3/5 эт.",
},
{
id: "fixture-5",
addr: "пр. Ленина, 5",
time: "вчера 18:04",
src: "5 / 7",
status: "устарел",
statusColor: tokens.danger,
params: "2 комн · 61,5 м²",
},
{
id: "fixture-6",
addr: "ул. Белинского, 86",
time: "вчера 14:22",
src: "2 / 7",
status: "устарел",
statusColor: tokens.danger,
params: "4 комн · 95 м² · 7/10 эт.",
},
];
export const cacheKpi: CacheKpi[] = [
{ label: "ВСЕГО ОЦЕНОК", value: "128", sub: "за последние 30 дней" },
{
label: "СРЕДНЯЯ ЦЕНА ОЦЕНОК",
value: "9,6",
unit: "млн ₽",
sub: "по проведённым оценкам",
},
{ label: "ПОВТОРНЫЕ АДРЕСА", value: "18", unit: "%", sub: "доля переоценок" },
];
// ---- INPUTS / DROPDOWNS ---------------------------------------------------
export const dropdownOptions: DropdownOptions = {
rooms: ["1", "2", "3", "4", "5+"],
houseType: [
"Не указано",
"Панельный",
"Кирпичный",
"Монолитный",
"Блочный",
"Монолитно-кирпичный",
],
repair: [
"Не указано",
"Без отделки",
"Требует ремонта",
"Удовлетворительный",
"Хороший",
"Отличный",
],
radius: ["300 м", "500 м", "1000 м", "2000 м"],
crm: [
"Не заполнять",
"Создать сделку",
"Привязать к контакту",
"Экспорт в CRM",
],
};
// Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient
// + base/coef/result formula + positives/negatives) lived here — removed
// together with the location-index rewrite. LocationDrawer now reads
// `LocationData` produced by `mapLocation` (./mappers.ts) from the real
// GET /trade-in/location-index response; its own default/unwired fixture is
// LOCATION_FIXTURE in ./LocationDrawer.tsx.
// ---- NAV / CHROME ---------------------------------------------------------
export const navLabels: string[] = [
"ОЦЕНКА",
"ПРОДАЖИ В ДОМЕ",
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
"АНАЛИТИКА",
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
];
export const overlayTitles: string[] = [
"",
"ПРОДАЖИ В ДОМЕ",
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
"АНАЛИТИКА ДОМА",
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
];
export const overlayNums: string[] = ["", "04", "05", "06", "07"];
export const user: User = {
name: "Андрей Петров",
org: "Брусника",
email: "a.petrov@mera.ru",
initials: "АП",
reports: 128,
};
export const version = "v2.0.6";

View file

@ -1,11 +1,13 @@
// Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port.
//
// These functions take the real trade-in API objects (AggregatedEstimate,
// StreetDealsResponse, HouseAnalyticsResponse) and produce the EXACT fixture
// shapes the v2 components already consume (see ./types + ./fixtures). The Wire
// phase swaps each component's hardcoded fixture for a `data` prop fed by one of
// these mappers; the fixtures stay as the prop DEFAULT so unwired usage still
// renders. ALL money/format/geometry lives here — never copy fixture literals.
// StreetDealsResponse, HouseAnalyticsResponse) and produce the display shapes
// the v2 components consume (see ./types). Every component's `data` prop is
// REQUIRED and fed by one of these mappers — since #2747 there is no fixture
// default to fall back on, and since #2755 there are no data fixtures at all
// (./ui-config keeps only labels/options/version). ALL money/format/geometry
// lives here; a value the API did not supply is rendered as a dash, never
// substituted with a plausible-looking constant.
//
// Reconciliations / known FE-approximations (look for "TODO BE-N"):
// BE-1 #2043 shipped 2026-07-02: AggregatedEstimate now carries real
@ -1297,9 +1299,9 @@ export function mapSummary(
//
// Three pure mappers feeding the SectionOverlay views. Each returns a composite
// presentation shape whose fields keep the exact ./types contract the views
// already render, so the Wire phase only swaps each view's fixture import for a
// `data` prop (fixture stays the DEFAULT). All number/format/geometry recomputed
// here from the real API objects — never copied from fixtures. Graceful nulls
// render. The `data` prop is required — the views had fixture imports until the
// Wire phase and fixture DEFAULTS until #2747; both are gone. All number/format/
// geometry is recomputed here from the real API objects. Graceful nulls
// degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay.
//
// NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/
@ -1969,26 +1971,31 @@ export function mapAnalytics(
// ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ───────────────────────────────
// Real per-row source/distance/url + tier/date carried alongside the existing
// display fields. Optional so the design fixtures (AdRow[]/DealRow[]) stay valid
// as the component DEFAULT; the mapper always populates them.
// display fields. REQUIRED: mapSources is the only producer of these rows and
// always populates every field. They used to be optional so the deleted design
// fixtures (AdRow[]/DealRow[]) would typecheck as the component DEFAULT — and
// that optionality is what let SourcesView keep unreachable `?? "Avito"` /
// `?? "Росреестр"` / "Росреестр ↗" fallbacks that looked like real data (#2755).
// Keep them required: an absent value must be a TS error here, not an invented
// brand name on screen.
export interface AdRowData extends AdRow {
/** Source brand label for the badge (was hardcoded "Avito"). */
source?: string;
/** Distance to the subject (was hardcoded "—"). */
dist?: string;
/** Original listing URL (was a hardcoded "Росреестр ↗" link). */
url?: string | null;
/** Source brand label for the badge. */
source: string;
/** Distance to the subject; "—" when unknown. */
dist: string;
/** Original listing URL; null when the row has no link. */
url: string | null;
/** Fix #8 /м² > OUTLIER_MULT× the pool median: excluded from spread/CV,
* flagged with a "возможный выброс" badge instead of silently distorting it. */
outlier?: boolean;
outlier: boolean;
}
export interface DealRowData extends DealRow {
/** Source brand label for the badge (was hardcoded "Росреестр"). */
source?: string;
/** Tier caption next to the badge (was hardcoded "по улице"). */
tier?: string;
/** Deal/listing date (was hardcoded "01.01.26"). */
date?: string;
/** Source brand label for the badge. */
source: string;
/** Tier caption next to the badge. */
tier: string;
/** Deal/listing date; "—" when unknown. */
date: string;
}
export interface SourcesData {

View file

@ -1,5 +1,7 @@
// TypeScript shapes for the /trade-in/v2 design fixtures.
// All values are sample/markup data (see fixtures.ts) — no API types here.
// Display shapes rendered by the /trade-in/v2 components. Not API types — the
// wire contract lives in @/types/trade-in and is converted here by ./mappers.
// Originally these described the pixel-design fixtures; the fixtures are gone
// (#2755), the shapes stayed because the mappers produce them.
export interface Report {
id: string;
@ -318,12 +320,7 @@ export interface DropdownOptions {
// (see ./mappers.ts), which reflects the real backend contract, not the old
// fabricated formula.
// ---- USER / CHROME --------------------------------------------------------
export interface User {
name: string;
org: string;
email: string;
initials: string;
reports: number;
}
// Note: the `User` shape («Андрей Петров» / «Брусника» / reports: 128) lived
// here for the fixture of the same name — both deleted in #2755. The real
// logged-in identity is `TopNavUser` in ./TopNav.tsx, fed from useMe(); a
// missing user renders the neutral «Гость», never a fabricated person.

View file

@ -0,0 +1,62 @@
// Static UI chrome for the /trade-in/v2 "МЕРА Оценка" port: nav/overlay labels,
// select options, build version. Constants only — no numbers that could be
// mistaken for measurements.
//
// This file used to be `fixtures.ts` and carried ~600 lines of made-up prices,
// deal rows and analytics lifted from the pixel design. Those were the prop
// DEFAULTS until #2747 made every data prop required; #2755 deleted them. All
// displayed data now comes from the API through ./mappers — if a value is
// missing the UI shows an honest dash, never a plausible-looking number.
import type { DropdownOptions } from "./types";
// ---- INPUTS / DROPDOWNS ---------------------------------------------------
export const dropdownOptions: DropdownOptions = {
rooms: ["1", "2", "3", "4", "5+"],
houseType: [
"Не указано",
"Панельный",
"Кирпичный",
"Монолитный",
"Блочный",
"Монолитно-кирпичный",
],
repair: [
"Не указано",
"Без отделки",
"Требует ремонта",
"Удовлетворительный",
"Хороший",
"Отличный",
],
radius: ["300 м", "500 м", "1000 м", "2000 м"],
crm: [
"Не заполнять",
"Создать сделку",
"Привязать к контакту",
"Экспорт в CRM",
],
};
// ---- NAV / CHROME ---------------------------------------------------------
export const navLabels: string[] = [
"ОЦЕНКА",
"ПРОДАЖИ В ДОМЕ",
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
"АНАЛИТИКА",
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
];
export const overlayTitles: string[] = [
"",
"ПРОДАЖИ В ДОМЕ",
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
"АНАЛИТИКА ДОМА",
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
];
export const overlayNums: string[] = ["", "04", "05", "06", "07"];
export const version = "v2.0.6";

Some files were not shown because too many files have changed in this diff Show more