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
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:
commit
7431615415
114 changed files with 13330 additions and 5873 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 «Доступ заблокирован», БЕЗ
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))],
|
||||
|
|
|
|||
|
|
@ -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"))],
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
146
backend/tests/_sentry_wiring_probe.py
Normal file
146
backend/tests/_sentry_wiring_probe.py
Normal 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]))
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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> <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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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-аутентификации.
|
||||
|
|
|
|||
|
|
@ -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). */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* и иметь один безопасный канал кодирования кадастрового номера.
|
||||
*
|
||||
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
|
||||
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
|
||||
* NspdOpportunityBlock, ParcelDrawer.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
242
scripts/check-migration-lock-timeout.py
Normal file
242
scripts/check-migration-lock-timeout.py
Normal 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())
|
||||
125
scripts/check-workflow-ports.py
Normal file
125
scripts/check-workflow-ports.py
Normal 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())
|
||||
5
tradein-mvp/.gitignore
vendored
5
tradein-mvp/.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
140
tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql
Normal file
140
tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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:%';
|
||||
|
|
@ -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.0–6.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.0–6.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.0–6.0 м). Читает estimator (comp-scoring #2012).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
204
tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py
Normal file
204
tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py
Normal 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()
|
||||
189
tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
Normal file
189
tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
Normal 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
|
||||
|
|
@ -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}с) — потолок темпа не работает"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
193
tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py
Normal file
193
tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
8641
tradein-mvp/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3708
tradein-mvp/frontend/pnpm-lock.yaml
generated
3708
tradein-mvp/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
111
tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
Normal file
111
tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 эт.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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("не показываем");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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("—");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,7–7,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: "обычно 18–180 дн.",
|
||||
count: "69 аналогов",
|
||||
variant: "success",
|
||||
},
|
||||
{
|
||||
tier: "По медиане",
|
||||
days: "~65 дн.",
|
||||
range: "обычно 18–182 дн.",
|
||||
count: "79 аналогов",
|
||||
variant: "accent",
|
||||
},
|
||||
{
|
||||
tier: "+5%",
|
||||
days: "~78 дн.",
|
||||
range: "обычно 16–212 дн.",
|
||||
count: "61 аналог",
|
||||
variant: "gold",
|
||||
},
|
||||
{
|
||||
tier: "+10%",
|
||||
days: "~67 дн.",
|
||||
range: "обычно 26–260 дн.",
|
||||
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";
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
62
tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts
Normal file
62
tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts
Normal 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
Loading…
Add table
Reference in a new issue