Merge main в chore/ci-config-cleanup-v2
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 52s
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI Trade-In / backend-tests (pull_request) Successful in 5m7s

Слить актуальный main (билд-раннер #2841/#2869, невалидные индексы #2752,
честный health-check и deploy-status #2841) в ветку очистки CI. Один конфликт
в .forgejo/workflows/deploy.yml: список triggers.paths — main добавил
ops/docker-prune.sh (#2887), ветка добавила auth/** (RBAC roles config).
Разрешено сохранением обоих путей, без потери ни одного триггера.
This commit is contained in:
bot-backend 2026-08-15 22:30:47 +03:00
commit 87258075a2
317 changed files with 41573 additions and 7642 deletions

View file

@ -25,7 +25,8 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod
## Path triggers (Forgejo Actions, `.forgejo/workflows/`)
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `ops/db-bootstrap/**`, `ops/docker-prune.sh`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- ⚠️ `ops/**` целиком **не** триггерит — только перечисленные подпути. Любой новый файл в `ops/`, который исполняется на VM (cron / шаг деплоя), надо добавлять в `paths:` явно, иначе он не доедет до `/opt/gendesign` и будет молча исполняться в старой версии
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md``.forgejo/workflows/deploy-obsidian.yml`
- `docs/**` alone → НЕ триггерит деплой

View file

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

View file

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

View file

@ -116,7 +116,7 @@ jobs:
# бы, а тесты всё равно скипались.
run: |
set -u
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \
postgis/postgis:16-3.4
@ -230,7 +230,7 @@ jobs:
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог
@ -294,25 +294,42 @@ jobs:
- name: Set up Node
# Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine).
# npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет
# package-lock.json (Dockerfile ставит через npm install), а cache=npm
# без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint.
# cache: npm включён с #2770 — package-lock.json теперь tracked.
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: tradein-mvp/frontend/package-lock.json
- name: Install deps (npm install, no lockfile)
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage):
# --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches;
# --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`):
# в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но
# Dockerfile ставит именно npm install) → `npm ci` упал бы.
run: npm install --legacy-peer-deps --no-audit --no-fund
- name: Install deps (npm ci)
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage), чтобы гейт
# видел то же дерево, что уедет в образ. `ci`, а не `install` (#2770): до
# него лока не было вовсе (лежал мёртвый pnpm-lock.yaml, из которого никто
# не ставил), и версии в CI и в прод-образе выбирались независимо по дате
# сборки — гейт проверял не тот код, который деплоится.
#
# Правишь package.json — регенерируй лок в том же PR: `npm ci` требует
# точного match и иначе роняет и этот job, и build образа.
run: npm ci --legacy-peer-deps --no-audit --no-fund
- name: Type-check (tsc --noEmit)
# Blocking: любая TS-ошибка → job RED.
run: npm run type-check
- name: Run tests (vitest)
# Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ
# проверки поведения: лэйн гейтил только типы и статический анализ, а оба
# молчат про то, что видит пользователь — пустое поле, погашенное число,
# отказ по частоте. Инфраструктура не изобретена, а взята у соседнего
# frontend/ (vitest + jsdom + testing-library), где сьют живёт давно.
#
# Пропусков в сьюте нет и быть не должно: сторож пропусков
# (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому
# пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722
# запретил на бэкенде. Тест либо чинится, либо помечается `.fails`
# с причиной В КОДЕ.
run: npm test
- name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED.
run: npm run lint

View file

@ -12,8 +12,18 @@ name: CI
# единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через
# connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango
# установлен ниже), тогда как на macOS-dev они runtime-skip'аются.
# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см.
# .github/workflows/ci.yml как образец service-блока.
#
# FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через
# `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис-
# контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ
# Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод.
# В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного
# способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект
# по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres
# и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример
# (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не
# исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions)
# уронит сборку, если такая публикация всё же появится.
on:
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709).
# WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым
@ -45,6 +55,25 @@ jobs:
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- name: "Guard: host-port collisions in workflows (#2757)"
# Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на
# КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает
# дефицитный слот раннера. Падение = merge заблокирован.
# python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3).
run: |
python3 scripts/check-workflow-ports.py --selftest
python3 scripts/check-workflow-ports.py
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
# фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ
# каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину.
run: |
python3 scripts/check-migration-lock-timeout.py --selftest
python3 scripts/check-migration-lock-timeout.py
- uses: dorny/paths-filter@v3
id: filter
with:
@ -113,7 +142,7 @@ jobs:
# здесь не нужен вовсе, в отличие от tradein-лэйна.
run: |
set -u
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
postgres:16
@ -204,11 +233,18 @@ jobs:
# coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge).
# term-missing → видно непокрытые строки прямо в job-логе.
run: |
# #2871: код возврата печатаем ЯВНО. Сводка pytest («4647 passed») уходит
# в лог ДО выхода, поэтому зелёная сводка при ненулевом коде выглядит как
# «job упал неизвестно где» — а падал именно этот шаг. Гейт сохраняется:
# ниже `exit $rc`.
rc=0
uv run pytest -q -rs --ignore=tests/smoke \
--cov=app \
--cov-report=term-missing:skip-covered \
--cov-report=xml:coverage.xml \
--cov-fail-under=65
--cov-fail-under=65 || rc=$?
echo "### pytest вернул код $rc"
exit $rc
- name: Coverage summary → job output
# Дешёвый human-readable итог. Бежит даже если gate упал (if: always) —
@ -217,20 +253,34 @@ jobs:
# если переменная пустая/файла нет, печатаем в обычный лог (fallback).
if: always()
run: |
echo "### шаг «Coverage summary» начался"
[ -f coverage.xml ] || { echo "coverage.xml отсутствует — пропускаю summary"; exit 0; }
report="$(uv run coverage report --skip-covered --sort=cover | tail -40)"
# NB (#2871): `coverage report` уважает fail_under из pyproject и выходит с
# кодом 2, когда порог не набран, а `run:` идёт под `bash -eo pipefail` —
# то есть падение ЭТОГО шага гасит зелёный pytest и выглядит как «job упал
# неизвестно где». Разделяем вычисление и вывод, чтобы код возврата был виден.
# `|| cov_rc=$?`, а не отдельная строка: под `set -e` присваивание после
# упавшей команды просто не выполнится, и код возврата снова потеряется.
cov_rc=0
uv run coverage report --skip-covered --sort=cover > /tmp/cov_report.txt || cov_rc=$?
echo "### coverage report вернул код $cov_rc"
report="$(tail -40 /tmp/cov_report.txt)"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{ echo '```'; echo "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
else
echo "$report"
fi
echo "### шаг «Coverage summary» закончился успешно"
- name: Снести тестовый Postgres
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
run: |
echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})"
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
echo "### шаг «Снести тестовый Postgres» закончился успешно"
frontend-tests:
runs-on: ubuntu-latest

View file

@ -30,11 +30,26 @@ jobs:
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
app_version: ${{ steps.build-meta.outputs.app_version }}
build_sha: ${{ steps.build-meta.outputs.build_sha }}
build_date: ${{ steps.build-meta.outputs.build_date }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник
# правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки —
# проброшены как build-args в build-backend/build-frontend ниже (см.
# tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile).
# Считается ОДИН раз здесь, а не в каждой job отдельно.
- name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE)
id: build-meta
run: |
echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT"
echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
# Resolve base SHA: read last-successfully-deployed SHA from the VPS host file.
# The file is written by the deploy job on every successful deploy.
# Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD,
@ -107,8 +122,20 @@ jobs:
# scheduler_main импортирует пакет) — kit-only изменение обязано
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
- 'tradein-mvp/packages/scraper-kit/**'
# APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend
# ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ
# правок кода обязан пересобрать образ, иначе GET /version и колонтитул
# PDF продолжат отдавать старое значение при формально «успешном» деплое.
- 'tradein-mvp/VERSION'
frontend:
- 'tradein-mvp/frontend/**'
# NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же
# причина, что у backend выше.
- 'tradein-mvp/VERSION'
# /versions статически запекает CHANGELOG.md в билд (см.
# frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ
# frontend/** иначе не долетала бы до образа.
- 'tradein-mvp/CHANGELOG.md'
browser:
- 'tradein-mvp/browser/**'
infra:
@ -164,15 +191,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
@ -190,10 +227,49 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-backend
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
# валит весь шаг ДО push нового образа — деплой тогда молча
# пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай
# без cache-from — ниже.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
@ -201,12 +277,65 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Retry build & push tradein-backend без кеша (битый buildcache, #2841)
# cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2,
# issue #2): успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча
# оставалась навсегда, следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
# проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше).
# Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при
# упавшем build — этот шаг единственный это заметит: манифеста с этим SHA
# не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy
# ниже пропускается вместо накатки старого :latest на прод.
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-frontend:
runs-on: ubuntu-latest
needs: changes
@ -223,10 +352,55 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
# поэтому копируем внутрь ДО build. /versions статически запекает его
# содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage
# комментарий). Не влияет на кэш другого шага — читается только этим.
- name: Stage CHANGELOG.md into frontend build context
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
- name: Build & push tradein-frontend
# id + continue-on-error — см. tradein-backend (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
@ -236,15 +410,62 @@ jobs:
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
# уходит в notFound (404), не индексируется и не краулится.
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
push: true
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-browser:
runs-on: ubuntu-latest
needs: changes
@ -263,10 +484,47 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-browser
# id + continue-on-error — см. tradein-backend выше (#2841): битый blob
# в удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
@ -277,6 +535,41 @@ jobs:
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Retry build & push tradein-browser без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
push: true
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
deploy:
runs-on: ubuntu-latest
needs: [changes, test, build-backend, build-frontend, build-browser]
@ -442,6 +735,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.
@ -782,3 +1104,33 @@ jobs:
# The changes job reads this file on the next run to compute cumulative diff.
echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha
echo "→ Deployed SHA marker updated: $GITHUB_SHA"
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда `test` или один из build-* падает (например,
# битый blob в buildcache роняет `docker/build-push-action` — до ретрая
# выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это
# было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя
# tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`,
# кроме отмены прогона) и сама падает, если deploy не завершился success —
# неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/
# health-check/сверка образов #2679). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
deploy-status:
runs-on: ubuntu-latest
needs: [test, build-backend, build-frontend, build-browser, deploy]
if: always() && !cancelled()
steps:
- name: Итог прогона — деплой обязан быть success, не skipped/failure
run: |
echo "test: ${{ needs.test.result }}"
echo "build-backend: ${{ needs.build-backend.result }}"
echo "build-frontend: ${{ needs.build-frontend.result }}"
echo "build-browser: ${{ needs.build-browser.result }}"
echo "deploy: ${{ needs.deploy.result }}"
if [ "${{ needs.deploy.result }}" != "success" ]; then
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
"Смотри логи test/build-backend/build-frontend/build-browser/deploy выше."
exit 1
fi
echo "✓ деплой прошёл успешно"

View file

@ -28,6 +28,11 @@ on:
# ниже сбрасывает кэш перезапуском процесса; сам файл в образ не запекается,
# ребилда картинок для этого не нужно).
- "auth/**"
# То же самое, ровно тот же класс бага (#2887): скрипт запускается на VM
# по cron из /opt/gendesign/ops/, куда попадает только через `git reset --hard`
# шага деплоя. Без этой строки правка скрипта лежала бы в main, а cron месяцами
# исполнял бы старую версию — молча и без единого сигнала.
- "ops/docker-prune.sh"
workflow_dispatch:
concurrency:
@ -79,10 +84,49 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push backend (lean — без Chromium)
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
# (registry cache, не local) валит весь шаг ДО push нового образа —
# деплой тогда молча пропускается (#2841), хотя код собрать можно, просто
# без кеша. cache-from нефатален: при падении ретраим БЕЗ него ниже.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./backend
@ -94,6 +138,53 @@ jobs:
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Retry build & push backend без кеша (битый buildcache, #2841)
# cache-from опущен (источник падения), а cache-to ОСТАВЛЕН: успешный
# ретрай пушит свежие слои в buildcache-тег и тем самым сам перезаписывает
# битый blob (mode=max — полная перезапись манифеста). Раньше cache-to был
# опущен и здесь тоже — но следующий обычный прогон опять получает cache-from
# на детерминированно битый тег и падает СНОВА: самолечения не было НИКОГДА
# (ревью #2841 R2, issue #2). Если и retry упадёт — шаг красный БЕЗ
# continue-on-error, job честно FAILURE, и deploy ниже корректно
# пропускается (уже настоящая причина, не кеш).
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner
push: true
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Проверить, что backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
# проверяет РЕАЛЬНОЕ состояние registry напрямую через buildx (уже настроен
# выше). Если act_runner не заполняет outcome (не проверено живым прогоном,
# см. ревью), ретрай выше молча НЕ побежит при упавшем build, а этот шаг —
# единственный, кто это заметит: манифеста с этим SHA не будет → шаг падает
# БЕЗ continue-on-error → job честно FAILURE → deploy ниже пропускается
# вместо накатки старого :latest на прод.
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-worker:
runs-on: ubuntu-latest
needs: changes
@ -110,10 +201,47 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push worker (с Chromium для Playwright)
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./backend
@ -125,6 +253,45 @@ jobs:
${{ env.IMAGE_WORKER }}:latest
${{ env.IMAGE_WORKER }}:${{ github.sha }}
- name: Retry build & push worker без кеша (битый buildcache, #2841)
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
# навсегда — следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner-with-chromium
push: true
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_WORKER }}:latest
${{ env.IMAGE_WORKER }}:${{ github.sha }}
- name: Проверить, что worker:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
run: docker buildx imagetools inspect ${{ env.IMAGE_WORKER }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-frontend:
runs-on: ubuntu-latest
needs: changes
@ -141,10 +308,47 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push frontend
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./frontend
@ -158,6 +362,47 @@ jobs:
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Retry build & push frontend без кеша (битый buildcache, #2841)
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
# навсегда — следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
build-args: |
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
NEXT_PUBLIC_ENVIRONMENT=production
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Проверить, что frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
deploy:
runs-on: ubuntu-latest
needs: [changes, build-backend, build-worker, build-frontend]
@ -317,6 +562,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.
@ -450,8 +720,50 @@ jobs:
docker image prune -af || true
docker builder prune -af || true
# Health check
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (см. #2214,
# уже сделано так в deploy-tradein.yml; ревью #2841 R2 issue #3).
# `curl ... && break` под set -e НЕ мог провалить скрипт: curl — не
# последняя команда &&-списка, а POSIX прямо освобождает от errexit
# все команды AND/OR-списка кроме последней. После 30 неуспешных
# попыток цикл завершался кодом последнего sleep (0) — скрипт тихо
# продолжался, деплой уходил success с мёртвым бэкендом.
healthy=""
for i in $(seq 1 30); do
curl -fsS http://localhost:8000/health && break
if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ backend healthy на /health."
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда build падает (например, битый blob в
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).
# skipped-job НЕ красит прогон явным «FAILED» так, чтобы это было видно на
# первый взгляд — итог выглядит зелёным/нейтральным, хотя прод не обновился.
# Эта job бежит ВСЕГДА (`if: always()`, кроме отмены прогона) и сама падает,
# если deploy не завершился success — неважно, пропущен он (build упал) или
# упал сам (SSH/миграция/health-check). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
deploy-status:
runs-on: ubuntu-latest
needs: [build-backend, build-worker, build-frontend, deploy]
if: always() && !cancelled()
steps:
- name: Итог прогона — деплой обязан быть success, не skipped/failure
run: |
echo "build-backend: ${{ needs.build-backend.result }}"
echo "build-worker: ${{ needs.build-worker.result }}"
echo "build-frontend: ${{ needs.build-frontend.result }}"
echo "deploy: ${{ needs.deploy.result }}"
if [ "${{ needs.deploy.result }}" != "success" ]; then
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
"Смотри логи build-backend/build-worker/build-frontend/deploy выше."
exit 1
fi
echo "✓ деплой прошёл успешно"

View file

@ -251,6 +251,31 @@ meraocenka.ru {
}
}
# Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ
# самих документов (оферта ссылается на meraocenka.ru/refund, политика
# возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому
# обязаны резолвиться сами по себе, а не только длинным
# /trade-in/mera-public/<doc>. Обратное направление тоже рабочее: длинный
# путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что
# то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где
# короткого /oferta нет.
#
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
# который в ней указан. Каноничность для поисковиков задана отдельно, через
# `alternates.canonical` на каждой из трёх страниц.
#
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
# путь проксируется» ради трёх страниц нельзя.
@meraLegalDocs path /oferta /refund /privacy
handle @meraLegalDocs {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle

View file

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

2
backend/.gitignore vendored
View file

@ -1 +1,3 @@
.coverage
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
coverage.xml

View file

@ -2189,12 +2189,21 @@ def analyze_parcel(
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
_weights_source = "inline"
else:
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_weights_source = (
"profile"
if profile_id is not None
else ("user_default" if profile_user_id is not None else "system")
)
# Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811):
# profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и
# тогда веса системные или дефолтные, а не профильные.
_resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_effective_weights = _resolved.weights
_weights_source = _resolved.source
# «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит,
# был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" —
# иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли».
# None когда profile_id не передавали; False когда передали, но применилось
# другое (не найден / чужой / перебит inline-весами).
_requested_profile_applied: bool | None = (
None if profile_id is None else _weights_source == "profile"
)
# 4) Scoring: weighted sum с distance decay
score = 0.0
@ -2310,12 +2319,31 @@ def analyze_parcel(
-- (303 строки = 303 distinct) COUNT(*) по дедуп-физлотам корректен.
SELECT
np.domrf_obj_id,
ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
-- #2464-D: границы правдоподобия, как в двух соседних запросах
-- по этой же таблице (BETWEEN 30000 AND 600000) здесь их не было.
-- Замер 13.08 по проду ЧЕРЕЗ ЭТОТ ЖЕ ПУТЬ (physflat-дедуп +
-- маппинг на domrf_obj_id): вне диапазона 204 лота из 2 279 827,
-- из них 118 в 10 замапленных проектах и 86 в незамапленных.
-- Эффект сегодня МАЛЫЙ: меняются 6 проектов из 308, худший на
-- 2.4%, market_avg_price (среднее средних) 138 056 138 008;
-- NULL не появляется нигде. Ставим границы не ради этих 48 ,
-- а потому что среднее считается ПО ПРОЕКТУ и один лот держит
-- группу без ограничения сверху: максимум в таблице
-- 19 198 429 /м² (ЖК «Дебют»), и он вне экрана только потому,
-- что проект пока не замаплен (замаплено 308 имён из 881, список
-- растёт). Одна строка маппинга и это число на экране.
-- FILTER, а не WHERE: строки нужны целиком, иначе поедут
-- units_sold / units_available, считающие ВСЕ лоты.
ROUND(AVG(oll.price_per_m2_rub) FILTER (
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
)::numeric, 0) AS avg_price_per_m2_rub,
ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd,
COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold,
COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available,
-- Считаем ТУ ЖЕ популяцию, что кормит среднее: иначе счётчик
-- обещал бы выборку шире, чем на самом деле участвовала.
COUNT(*) FILTER (
WHERE oll.price_per_m2_rub IS NOT NULL
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
) AS lots_with_price
FROM nearby_projects np
JOIN obj_lots_latest oll
@ -4085,9 +4113,12 @@ def analyze_parcel(
# (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
"program_alternatives": program_alternatives,
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
# source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли
# удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали.
"weights_profile": {
"source": _weights_source,
"profile_id": profile_id,
"requested_profile_applied": _requested_profile_applied,
"user_id": profile_user_id,
"weights_applied": _effective_weights,
"inline_weights": _inline_weights,
@ -4203,6 +4234,7 @@ def analyze_parcel(
"profile_user_id": profile_user_id,
"inline_weights": _inline_weights,
"weights_source": _weights_source,
"requested_profile_applied": _requested_profile_applied,
"x_session_id": _session_id,
},
district=_district_name,

View file

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

View file

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

View file

@ -48,7 +48,7 @@ from app.core import auth_db
from app.core.audit_middleware import audit_log_middleware
from app.core.auth import get_role
from app.core.config import settings
from app.observability.sentry_scrub import scrub_sensitive_query
from app.observability.sentry_scrub import scrub_event
from app.services.auth_session import resolve_session_token
logger = logging.getLogger(__name__)
@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers):
# (middleware, маршруты) видели активный client с самого старта процесса.
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
if settings.glitchtip_dsn:
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review):
# Starlette-интеграция кладёт request.data на transaction-scope так же, как
# на error-scope, поэтому голый scrub_sensitive_query (только URL) на
# before_send_transaction оставлял бы PII-канал открытым при любом
# glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring).
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
@ -82,8 +87,14 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_sensitive_query,
before_send_transaction=scrub_sensitive_query,
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753).
# Дефолт SDK — True: при любом исключении кадр несёт значения аргументов
# (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event
# сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не
# дополнительная мера, а условие, без которого скраб не полон.
include_local_variables=False,
before_send=scrub_event,
before_send_transaction=scrub_event,
integrations=[
StarletteIntegration(),
FastApiIntegration(),
@ -497,3 +508,24 @@ async def health() -> dict[str, str]:
"environment": settings.environment,
"version": app.version,
}
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
# отдаёт 405. Это боевой прод-эндпоинт: Caddyfile:60 `handle /health {
# reverse_proxy backend:8000 }` — именно ЭТОТ хендлер отвечает на
# `HEAD https://gendsgn.ru/health`, которым бьёт внешний uptime-monitor
# (GlitchTip PING-тип шлёт HEAD, не GET) и не мог отличить "жив" от "мёртв" по
# статусу. media_type="application/json" — Content-Type совпадает с GET;
# Content-Length сознательно НЕ вычисляем под байт GET-ответа (пришлось бы
# дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload-
# заголовки (Content-Length) для HEAD, требует совпадения только заголовков
# представления (Content-Type).
# include_in_schema=False: HEAD-проба — инфраструктура (uptime-monitor), а не часть
# контракта, по которому фронт генерирует типы. Без этого флага операция попадает в
# app.openapi(), и job `openapi-codegen-check` краснеет, требуя перегенерации
# frontend/src/types/api-types.ts — правки в сгенерированном файле ради маршрута,
# который фронт никогда не вызывает.
@app.head("/health", include_in_schema=False)
async def health_head() -> Response:
return Response(status_code=200, media_type="application/json")

View file

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

View file

@ -30,7 +30,12 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkServerError
from app.scrapers.nspd_bulk_client import (
NSPDBulkClient,
NspdBulkRateLimitError,
NspdBulkServerError,
NspdBulkWafError,
)
from app.services.cadastre.grid_geometry import generate_grid_click_points, quarter_bbox_3857
logger = logging.getLogger(__name__)
@ -182,6 +187,13 @@ async def harvest_quarter(
try:
cat_snapshot = await client.search_by_quarter(quarter, category_id=cat_id)
result.snapshot_requests += 1
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: бан IP / исчерпанные ретраи — НЕ «этот cat не дошёл».
# Контракт harvest_quarter (Raises:) обещает пробросить их наверх,
# а голый except ниже их глотал: прогон доходил до status='done'
# с частичными данными. Прод-замер 13.08: 23 job'а, 50 WAF-блоков,
# 0 упавших — то есть бан ни разу не остановил сбор.
raise
except Exception as e:
logger.warning(
"harvest_quarter: per-cat probe failed cat=%d quarter=%s: %s",
@ -279,6 +291,9 @@ async def harvest_quarter(
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
raise
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
@ -399,6 +414,18 @@ async def _grid_walk_category(
requests += 1
server_errors += 1
continue
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: 403 WAF — бан IP, а не «этот cell не дошёл». Продолжать
# обход значит углублять бан и дописать в БД ложный нулевой слой.
# Зеркало уже исправленных nspd_bulk_client.get_features_in_bbox_grid
# и nspd_client.get_features_in_bbox_grid (#2464-G).
logger.warning(
"_grid_walk_category: WAF/rate-limit layer=%d quarter=%s cell=%d — прерываем",
layer_id,
quarter,
idx,
)
raise
except Exception as e:
# Прочие (сетевые / parse) ошибки одного cell — тоже не валим квартал,
# но это НЕ server-side 500 → не учитываем в server_errors (иначе сеть

View file

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

View file

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

View file

@ -3,10 +3,10 @@
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
покрытие domrfobjective, глубина истории, шок-окно) в ОДИН отчётный уровень
покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
покрытие domrfobjective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987.
цена известна у 12% ближних ЖК»). Наполняет слот `ReportConfidence` отчёта #987.
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x
движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только
@ -28,8 +28,10 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ,
мало сделок скоростные метрики статистически ненадёжны.
analog_count (ЖК-аналоги, = market_metrics.obj_count) high3 / medium2 / 1 low
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» ТЗ §15-пример).
domrf_coverage главный риск проекта (domrfobjective ~2.5%, см. market_metrics
docstring): низкое покрытие скрытый/будущий слой §9.3 недооценён.
domrf_coverage имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой
из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга
domrfobjective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08:
медиана 40%, среднее 31.7%. Низкое покрытие рынок и конкуренция оценены хуже.
history_months зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (2 года) / _MIN_OBS=8: короткий
ряд связь ratesales / тренды не установлены.
confounded шок-окно (is_confounded_window, PR2): ряд пересекает структурный
@ -91,9 +93,11 @@ _DEAL_COUNT_LOW: int = 15
_ANALOG_COUNT_HIGH: int = 3
_ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low
# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%).
# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог
# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства).
# domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1] (имя ключа историческое,
# см. _coverage_factor). high — покрытие плотное; low — рынок оценён по меньшинству ЖК.
# medium-порог созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6.
# NB: пороги подбирались под ожидавшиеся ~2.5% покрытия маппинга, а реальная величина
# другого порядка (медиана 40%) — их стоит пересмотреть отдельно, замером, а не на глаз.
_DOMRF_COVERAGE_HIGH: float = 0.6
_DOMRF_COVERAGE_LOW: float = 0.2
@ -252,23 +256,36 @@ _QUALITY_WORD: dict[Confidence, str] = {
def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
"""domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
"""Покрытие рынка ценами Objective ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ
(структурный §15-пример «покрытие domrfobjective 2.5%»). None low.
#2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие
маппинга domrfobjective, но такого продьюсера НЕТ и не было: слот
`supply_layers.domrf_coverage` никто не заполняет (см. явную оговорку в
`orchestrator._summarize_supply_layers`), и значение ВСЕГДА приходит из
`analyze.market_data_coverage_pct` = `competitors_priced / competitors_total`,
то есть доля БЛИЖНИХ ЖК (3 км), у которых есть цена из Objective.
Замер на проде 13.08: 2074 анализа, min 0% · медиана 40% · среднее 31.7% ·
max 70%. Это не «~2.5% покрытия domrfobjective», как было написано здесь
раньше, другая величина другого порядка.
Ключ фактора НЕ переименован намеренно: его читает фронт
(`ForecastConfidenceBlock`, `ConfidencePanel`) как стабильный контракт.
Порог и значение не меняются правится только то, что читает человек.
None low.
"""
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
if coverage is None:
note = (
"Доля будущих проектов с известными планировками и площадями неизвестна — "
"оценка будущего предложения и конкуренции менее надёжна"
"Доля ближних ЖК с известной ценой из Objective неизвестна — "
"оценка рынка и конкуренции менее надёжна"
)
else:
pct = round(float(coverage) * 100.0, 1)
note = (
f"Известные планировки и площади есть у {pct}% будущих проектов "
f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза "
"будущего предложения и конкуренции"
f"Цена из Objective известна у {pct}% ближних ЖК "
f"({_QUALITY_WORD[level]}) — от этого зависит точность оценки "
"рынка и конкуренции"
)
return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)
@ -294,9 +311,7 @@ def _history_factor(history_months: int | None) -> ConfidenceFactor:
"ряде тренды и чувствительность спроса к ставке оцениваются хуже "
"(поэтому в 6.2 может остаться один сценарий вместо трёх)"
)
return ConfidenceFactor(
name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note
)
return ConfidenceFactor(name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note)
def _confounded_factor(confounded: bool) -> ConfidenceFactor:
@ -479,7 +494,9 @@ def compute_report_confidence(
deal_count_months: окно наблюдения для deal_count (мес) добавляет «за N мес»
в ноту фактора («7 сделок за 6 мес мало»). None нота без периода.
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
domrf_coverage: доля domrfobjective [0,1] (главный sparse-риск проекта).
domrf_coverage: доля ближних ЖК с ценой из Objective [0,1]. Имя ключа
историческое про маппинг domrfobjective, продьюсера для которого
нет и не было (#2464-H, см. _coverage_factor).
history_months: глубина ряда (мес).
confounded: True, если окно ряда пересекает шок-период (PR2).
advisory: весь стек советующий cap 'medium' (по умолчанию True; почти всегда).

View file

@ -203,15 +203,23 @@ def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None
def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None:
"""Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE.
"""Покрытие рынка ценами Objective ∈ [0,1] — для фактора domrf_coverage. PURE.
Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ
per-branch НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8%
спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе,
который §15 призван флагать):
`supply_layers.domrf_coverage` уже ДОЛЯ [0,1] (0.025) берём как есть.
`analyze.market_data_coverage_pct` всегда ПРОЦЕНТ (2.5 == 2.5%) /100 доля.
Нет сигнала None (#990 → тянет в low: слой §9.3 недооценён).
Источники по приоритету (единица ЯВНАЯ per-branch НЕ угадываем по величине,
иначе настоящий sub-1% процент типа 0.8% спутался бы с долей 0.8 = 80%):
`supply_layers.domrf_coverage` ДОЛЯ [0,1] берём как есть.
`analyze.market_data_coverage_pct` ПРОЦЕНТ (40 == 40%) /100 доля.
Нет сигнала None.
#2464-H, важно для читающего: **первая ветка не исполнялась ни разу**. Слот
`supply_layers.domrf_coverage` никто не заполняет `_summarize_supply_layers`
в orchestrator это прямо оговаривает («domrf_coverage здесь НЕ выводим нет
дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и
величина у неё другая: не «покрытие маппинга domrfobjective ~2.5%», как
было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective
замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%.
Порядок веток оставлен: если продьюсер появится, приоритет у него.
"""
if supply_layers is not None:
coverage = supply_layers.get("domrf_coverage")

View file

@ -559,7 +559,11 @@ class NSPDClient:
"""
# Импортируем здесь чтобы избежать circular import:
# nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain)
from app.scrapers.nspd_bulk_client import NSPDBulkClient
from app.scrapers.nspd_bulk_client import (
NSPDBulkClient,
NspdBulkServerError,
NspdBulkWafError,
)
xmin, ymin, xmax, ymax = bbox
width_m = xmax - xmin
@ -607,10 +611,45 @@ class NSPDClient:
results = await asyncio.gather(*tasks, return_exceptions=True)
features: list[NSPDFeature] = []
for r in results:
if isinstance(r, Exception):
logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r)
# #2464-G: раньше ЛЮБОЕ исключение ячейки глушилось warning'ом и обход
# возвращал []. Отказ слоя (WAF-бан IP, 5xx на всех ячейках) становился
# неотличим от честного «здесь зон нет» — на проде это 124 дампа из 669
# с territorial_zones_count=0, из них у 50 legacy-слой данные нашёл.
# Ниже — зеркало уже исправленного близнеца
# nspd_bulk_client.get_features_in_bbox_grid (Issue #252-mirror).
server_errors = 0
ok_cells = 0
first_server_error: NspdBulkServerError | None = None
for idx, r in enumerate(results):
if isinstance(r, NspdBulkWafError):
# 403 WAF — бан IP. Пробрасываем немедленно: продолжать обход
# бессмысленно, а пустой результат соврал бы про отсутствие зон.
logger.warning(
"get_features_in_bbox_grid layer=%d cell=%d WAF 403 — прерываем обход: %s",
layer_id,
idx,
r,
)
raise r
if isinstance(r, NspdBulkServerError):
server_errors += 1
if first_server_error is None:
first_server_error = r
logger.debug(
"get_features_in_bbox_grid layer=%d cell=%d server error: %s",
layer_id,
idx,
r,
)
continue
if isinstance(r, Exception):
# Сетевые / parse-ошибки одной ячейки: обход не валим и НЕ
# считаем server-side, иначе сеть ложно поднимет layer_failed.
logger.warning(
"get_features_in_bbox_grid layer=%d cell=%d error: %s", layer_id, idx, r
)
continue
ok_cells += 1
for bulk_feat in r:
raw = {
"id": bulk_feat.id,
@ -618,6 +657,20 @@ class NSPDClient:
"properties": bulk_feat.properties,
}
features.append(NSPDFeature.from_raw(raw))
# Были server-side отказы И ни одна ячейка не прошла — лёг слой или
# весь NSPD. Возврат [] здесь означал бы «зон нет», хотя мы просто
# ничего не узнали. Пробрасываем, чтобы caller отличил одно от другого.
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
logger.warning(
"get_features_in_bbox_grid layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
"(%d server errors, 0 успешных ячеек) — бросаем вместо ложного пустого",
layer_id,
effective_n,
effective_n,
server_errors,
)
raise first_server_error
return features
raw_features = asyncio.run(_run_grid())
@ -679,6 +732,10 @@ class NSPDClient:
dict[layerId, list[NSPDFeature]]. Ключи все запрошенные layerId
(пустой list если слой пуст / упал). Стабильная форма для caller'а.
"""
# Локальный импорт по той же причине, что в get_features_in_bbox_grid:
# nspd_client ← nspd_bulk_client дало бы circular import на top-level.
from app.scrapers.nspd_bulk_client import NspdBulkServerError
layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys())
result: dict[int, list[NSPDFeature]] = {}
for layer_id in layer_ids:
@ -686,7 +743,15 @@ class NSPDClient:
feats = self.get_features_in_bbox_grid(
layer_id, bbox_3857, grid_n=grid_n, step_m=step_m
)
except (NspdLiteError, NspdLiteWafError) as exc:
except (NspdLiteError, NspdLiteWafError, NspdBulkServerError) as exc:
# #2464-G: с этой правки grid-walk умеет бросать NspdBulkServerError
# («слой лёг целиком»). Здесь ловим его И оставляем прежнее поведение —
# пустой список на слой, — потому что именно это обещает докстрока
# («пустой list если слой пуст / упал») и на это опирается вызывающий.
# NspdBulkWafError НЕ ловим намеренно: 403 — это бан IP, продолжать
# обход остальных слоёв значит углублять бан.
# Ограничение честно: наружу отсюда «упал» и «пусто» по-прежнему
# неразличимы — у функции нет канала для флага. Отдельным заходом.
logger.warning(
"get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем",
layer_id,
@ -840,9 +905,19 @@ class NSPDClient:
`layers_fetched` в этом случае содержит только `('search',)`.
Raises:
NspdLiteWafError при 403/429 на любом из layer запросов caller
должен делать backoff. Partial-success НЕ возвращается; вся
операция атомарна (failure exception).
NspdLiteWafError при 403/429 на legacy-запросах (parcels/buildings)
caller должен делать backoff.
NspdBulkWafError при 403 на любой ячейке grid-walk-слоя (#2464-G) —
бан IP, обход прерывается сразу.
NspdBulkServerError когда grid-walk-слой сбойный ЦЕЛИКОМ (были 5xx и
ни одна ячейка не прошла) иначе вернулся бы пустой список,
неотличимый от честного «здесь ничего нет».
До #2464-G это место обещало атомарность, которой не было: grid-walk
глушил любое исключение ячейки и отдавал []. Теперь обещание верно
для отказа слоя и бана, но partial-success внутри слоя ВОЗМОЖЕН:
если часть ячеек упала по сети, а часть прошла, вернётся то, что
собралось, с warning'ом в лог на каждую упавшую ячейку.
Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors,
E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity.

View file

@ -192,15 +192,30 @@ _INLINE_VELOCITY_SQL = text("""
SELECT
a.room_bucket,
SUM(a.deals_window) AS deals_window,
-- Здесь COALESCE(...,0) ОСТАЁТСЯ намеренно: TopLayoutRow.avg_area_m2
-- объявлен как float (не Optional), и NULL ронял бы контракт API.
-- Пустые комнатности получают площадь 0 м², и это тоже неправда но
-- честный NULL требует правки схемы + перегенерации типов фронта
-- и решения, что писать в area_bin. Отдельным заходом: #2867.
COALESCE(
SUM(a.area_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
)::numeric(10, 2) AS avg_area_m2,
COALESCE(
-- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL →
-- средней цены нет, и это NULL, а не «0 /м²». Схема так и объявлена
-- (TopLayoutRow.avg_price_per_m2_rub: float | None), и Python ниже уже
-- умеет None (пропускает строку во взвешенном роллапе) но COALESCE
-- делал эту ветку недостижимой.
-- Замер 13.08 по проду, окно 6 месяцев. Сработает ноль или нет зависит
-- от того, сколько замапленных проектов попало в радиус, поэтому цифры
-- по слоям: у 616 проектов 2083 пары (проект × комнатность), пустых 635;
-- 323 проекта имеют хотя бы одну пустую комнатность, 80 пустые ВСЕ.
-- При объединении по два пустых остаётся 255 из 1267, по всему городу
-- ноль. То есть чем беднее окрестность участка, тем чаще выдумывался 0.
(
SUM(a.price_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
/ NULLIF(SUM(a.deals_window), 0)
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
array_agg(DISTINCT a.project_name) AS matched_project_names,
MIN(a.window_start) AS window_start,

View file

@ -169,7 +169,16 @@ def _cell(row: tuple, idx: int) -> object:
def _pct_share_to_percent(value: object) -> float | None:
"""Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем.
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах.
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1).
#2464-B: продакшен-вызывающих у функции СЕЙЧАС НЕТ. Значение колонки E
раньше писалось в `load_index`, но это категориальная колонка
('open'|'limited'|'closed'|NULL) число в ней фронт отбрасывает в
«неизвестно» и плодит мусорный бакет в `power_summary.by_load_index`.
Функцию оставляю с тестами: она описывает формат листа, и она понадобится
в тот момент, когда под процент загрузки заведут числовую колонку.
Если такого решения не будет удалить вместе с тестом, а не держать молча.
None/мусор None.
"""
num = parse_reserve_number(value)
@ -214,7 +223,9 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
rows_seen += 1
district = _cell(row, 1) # B
load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %)
# Колонку E (степень загрузки ЦП долей) НЕ читаем и не храним: места
# под неё в power_supply_centers нет — load_index категориальный,
# current_load_mva в мегавольт-амперах (#2464-B, см. UPDATE ниже).
reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт)
name_norm = normalize_sc_name(str(sc_name))
@ -223,7 +234,6 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
"reserve": reserve,
"asof": reserve_asof,
"district": str(district).strip() if district else None,
"load_pct": load_pct,
"name_norm": name_norm,
}
@ -236,10 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
reserve_unit = 'МВт',
installed_capacity_mva = :installed,
district = :district,
load_index = COALESCE(
load_index,
CAST(:load_pct AS text)
),
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
-- load_index категориальная колонка
-- ('open'|'limited'|'closed'|NULL, см.
-- data/sql/180_connection_capacity.sql:35), её
-- заполняет rosseti_wfs_loader._map_load_index.
-- Раньше тут стоял COALESCE(load_index,
-- CAST(:load_pct AS text)) при пустой ячейке
-- в колонку легло бы число строкой ("72.5"),
-- а фронтовый classifyLoadIndex такое значение
-- отбрасывает в null («неизвестно»), и в
-- power_summary.by_load_index появился бы
-- бакет с именем "72.5".
-- Сегодня не стреляло только потому, что у всех
-- 3416 строк load_index уже заполнен
-- (open 2741 / limited 346 / closed 329, NULL 0)
-- и COALESCE не проваливался.
capacity_source = 'eesk_35_220',
reserve_asof = :asof
WHERE sc_name_norm = :name_norm

View file

@ -36,6 +36,48 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Конкуренты в радиусе — модульная константа (а не inline f-string), чтобы
# integration-тест мог прогнать EXPLAIN по обеим подстановкам `{class_filter}`.
# Ветка с фильтром до #2464-G не парсилась вообще: ссылалась на алиас `o`,
# которого внутри CTE нет (`missing FROM-clause entry for table "o"`).
_COMPETITORS_SQL_TMPL = """
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
# Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median).
# Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на
# один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка:
@ -173,9 +215,17 @@ def compute_velocity(
# только если явно передан. #38: при NULL реального класса используем
# obj_class_fallback (yandex_match / price_inference) — реальный obj_class
# в приоритете (COALESCE), поведение для размеченных ЖК не меняется.
class_filter = (
"AND COALESCE(o.obj_class, o.obj_class_fallback) = :obj_class" if obj_class else ""
)
# Колонки БЕЗ алиаса: фильтр подставляется ВНУТРЬ latest_obj, где FROM —
# голый domrf_kn_objects. Алиас `o` появляется только во внешнем SELECT,
# и `o.obj_class` здесь давал `missing FROM-clause entry for table "o"`
# (#2464-G, прод-EXPLAIN 13.08). Ошибку глотал except ниже → velocity
# молча выпадал из отчёта. Не срабатывало только потому, что единственный
# вызывающий (parcels.py) obj_class не передаёт.
# NB для первого, кто ветку включит: сравнение точное и регистрозависимое, а
# в проде классы с большой буквы и словарь шире ожидаемого — «Комфорт» 870,
# «Типовой» 224, «Бизнес» 95, «Премиум» 13, «Элит» 12, «Стандарт» 9,
# «Элитный» 4 объекта (замер 13.08). Передавать нужно ровно эти строки.
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :obj_class" if obj_class else ""
# SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx.
# db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction
# (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup).
@ -183,45 +233,7 @@ def compute_velocity(
with db.begin_nested():
comp_rows = (
db.execute(
text(
f"""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
),
text(_COMPETITORS_SQL_TMPL.format(class_filter=class_filter)),
{
"parcel_wkt": parcel_geom_wkt,
"radius_m": radius_km * 1000.0,

View file

@ -10,7 +10,7 @@ API surface:
- create_profile(db, payload) WeightProfile
- update_profile(db, user_id, profile_id, payload) WeightProfile | None
- delete_profile(db, user_id, profile_id) bool
- resolve_weights(db, user_id, profile_id) dict[str, float]
- resolve_weights(db, user_id, profile_id) ResolvedWeights(weights, source)
"""
from __future__ import annotations
@ -19,7 +19,7 @@ import json
import logging
import math
from datetime import datetime
from typing import Any
from typing import Any, NamedTuple
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
return True
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
"""Вернуть эффективные веса для analyze_parcel.
class ResolvedWeights(NamedTuple):
"""Веса + КАКОЙ источник фактически применился (#2811).
Лестница приоритетов ниже по построению стирает разницу между «взял, что
просили» и «не нашёл, взял что было» а метка в ответе /analyze строится
именно на этой разнице. Поэтому источник возвращается вместе с весами, а не
выводится вызывающим из своих же входных параметров. NamedTuple, а не голый
dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко,
молча «весами» этот объект не притворится.
"""
weights: dict[str, float]
source: str # "profile" | "user_default" | "system"
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights:
"""Вернуть эффективные веса для analyze_parcel + фактический их источник.
Порядок приоритетов:
1. profile_id задан загрузить именно этот профиль
2. user_id задан загрузить default-профиль пользователя
3. Иначе вернуть системные значения _SYSTEM_POI_WEIGHTS
1. profile_id задан загрузить именно этот профиль source="profile"
2. user_id задан загрузить default-профиль пользователя source="user_default"
3. Иначе системные значения _SYSTEM_POI_WEIGHTS source="system"
Запрошенный, но НЕ применённый profile_id не тишина: warning с
идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для
/analyze необязательный модификатор, а не адресуемый ресурс; 404 превратил
бы гонку «профиль удалили между списком и анализом» в отказ вместо честно
помеченного ответа. Клиенту хватает source + requested_profile_not_found.
"""
if profile_id is not None and user_id is not None:
profile = get_profile(db, user_id, profile_id)
@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic
logger.debug(
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
)
return dict(profile.weights)
return ResolvedWeights(dict(profile.weights), "profile")
resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system")
if user_id is not None:
profile = get_default_profile(db, user_id)
if profile is not None and profile.weights:
logger.debug("resolve_weights: user=%s → default profile weights", user_id)
return dict(profile.weights)
resolved = ResolvedWeights(dict(profile.weights), "user_default")
logger.debug("resolve_weights: returning system defaults")
return dict(_SYSTEM_POI_WEIGHTS)
if profile_id is not None:
# Сюда попадаем, если запрошенный профиль не применился: owner не передан
# (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights
# пустые. Раньше это был logger.debug, которого на проде нет, — и оценка
# молча считалась не по тем весам (#2811, ранее #2788).
logger.warning(
"resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — "
"фактический источник весов %r",
profile_id,
user_id,
resolved.source,
)
else:
logger.debug("resolve_weights: источник весов %s", resolved.source)
return resolved

View file

@ -406,16 +406,17 @@ def build_beat_schedule() -> dict:
# Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц.
# kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д.
# Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления.
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
# за ~5 недель полного обновления.
#
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
# cooldown 24-48h (проверить через targeted test).
# schedule["scrape-kn-catalog-objects-weekly"] = {
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
# "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
# "kwargs": {"region_code": 66, "max_objects": 300},
# "options": {"queue": "celery"},
# }
@ -430,10 +431,10 @@ def build_beat_schedule() -> dict:
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 UTC.
# напр. четверг 04:00 МСК.
# schedule["scrape-kn-catalog-flats-weekly"] = {
# "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
# "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC
# "schedule": _parse_cron("0 4 * * 4"), # четверг 04:00 МСК
# "kwargs": {"region_code": 66, "max_flats": 300},
# "options": {"queue": "celery"},
# }
@ -542,13 +543,20 @@ def build_beat_schedule() -> dict:
}
# Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings.
# Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК).
# 00:30 МСК ежедневно (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
# Комментарий до #2464-H говорил «00:30 UTC = 03:30 МСК» — считал сдвиг дважды,
# оставшись с эпохи UTC-расписания. Факт по логам beat (10-12.08): «Sending due
# task newbuilding-crossload-nightly» в 21:30 UTC = 00:30 МСК, то есть на три
# часа раньше обещанного.
# Расписание НЕ трогаем: на 00:30 МСК ничего не наложено, а сдвиг на 03:30 МСК
# завёл бы задачу прямо в окно tradein-задания newbuilding_enrich (00:00-01:00 UTC
# = 03:00-04:00 МСК), с которым она делит источник — tradein.houses.
# Не в job_settings (технический ETL, не требует конфигурации UI).
# Идемпотентен через ON CONFLICT (source, ext_house_id).
# Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения.
schedule["newbuilding-crossload-nightly"] = {
"task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload",
"schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК
"schedule": _parse_cron("30 0 * * *"), # 00:30 МСК
"options": {"queue": "celery"},
}

View file

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

View file

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

View file

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

View file

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

View file

@ -110,9 +110,9 @@ class TestCompetitorsSortOrder:
sorted_rows = sorted(_ROWS_MIXED, key=_sort_key)
first = dict(sorted_rows[0].items())
assert first["site_status"] == "Строящиеся", (
f"Первый конкурент должен быть 'Строящиеся', " f"но получили '{first['site_status']}'"
)
assert (
first["site_status"] == "Строящиеся"
), f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'"
def test_flat_count_desc_would_break_order(self) -> None:
"""Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми."""
@ -180,14 +180,44 @@ class TestObjPricingPushdown:
#1964: источник агрегатов сменился с сырого objective_lots (alias ol) на
physflat-дедуп CTE obj_lots_latest (alias oll) см. test_obj_pricing_*_physflat
ниже. Сами агрегатные выражения и группировка per-obj_id неизменны.
#2464-D: у среднего цены появились границы правдоподобия (те же, что в двух
соседних запросах по objective_lots) см. test_price_avg_has_sanity_bounds.
"""
sql = self._competitor_sql()
assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "AS avg_price_per_m2_rub" in sql
assert "lots_with_price" in sql
assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
assert "GROUP BY np.domrf_obj_id" in sql
def test_price_avg_has_sanity_bounds(self) -> None:
"""#2464-D: среднее цены считается по лотам в границах правдоподобия.
Среднее считается ПО ПРОЕКТУ, поэтому один лот держит группу без ограничения
сверху: максимум в objective_lots 19.2 млн /м² (замер 13.08). Границы
30000..600000 уже стоят в двух соседних запросах по этой же таблице; здесь
их не было. Дальше значение уходит в market_avg_price и на экран.
"""
sql = self._competitor_sql()
bounds = "WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000"
assert (
f"AVG(oll.price_per_m2_rub) FILTER ( {bounds} )" in sql
), "среднее цены должно фильтроваться границами правдоподобия (#2464-D)"
# Тот же набор кормит счётчик выборки — иначе счётчик обещает шире, чем
# реально участвовало в среднем.
assert (
f"COUNT(*) FILTER ( {bounds} ) AS lots_with_price" in sql
), "lots_with_price должен считать ту же популяцию, что и среднее"
# FILTER, а не WHERE на CTE: строки нужны целиком, иначе границы цены
# молча урежут счётчики продаж/остатка, которые считают ВСЕ лоты.
assert (
"COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
), "units_sold не должен зависеть от границ цены"
assert (
"COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
), "units_available не должен зависеть от границ цены"
def test_obj_pricing_dedups_physflat_inline(self) -> None:
"""#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.

View file

@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None:
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_missing_profile_is_not_labelled_profile() -> None:
"""#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'.
Три способа промахнуться мимо профиля (все три воспроизведены живым запросом
на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id.
В mock-БД профилей нет значит применились системные веса, и ответ обязан
это признать, а не утверждать, что считал по профилю.
"""
from app.core.db import get_db
from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS
for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"):
db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}")
assert resp.status_code == 200, resp.text
wp = resp.json()["weights_profile"]
# sanity: веса и правда системные, промах реальный
assert wp["weights_applied"]["tram_stop"] == pytest.approx(
_SYSTEM_POI_WEIGHTS["tram_stop"]
)
assert wp["source"] != "profile", (
f"?{qs}: применились системные веса, а метка source='profile'"
"ответ утверждает то, чего не было (#2811)"
)
assert wp["source"] == "system"
# «что просили» не теряется: запрошенный id + явный признак промаха
assert wp["profile_id"] == 999999
assert wp["requested_profile_applied"] is False
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_found_profile_keeps_label_and_flag() -> None:
"""Обратная сторона: профиль найден → source='profile', флаг промаха False."""
from datetime import UTC, datetime
import app.services.site_finder.weight_profiles as wp_module
from app.core.db import get_db
from app.services.site_finder.weight_profiles import WeightProfile
profile = WeightProfile(
id=7,
user_id="user-1",
profile_name="test",
weights={"tram_stop": -0.4},
is_default=False,
description=None,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
original = wp_module.get_profile
wp_module.get_profile = lambda _db, uid, pid: profile
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1")
assert resp.status_code == 200, resp.text
wp = resp.json()["weights_profile"]
assert wp["source"] == "profile"
assert wp["requested_profile_applied"] is True
assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4)
finally:
wp_module.get_profile = original
app.dependency_overrides.clear()
_stop_patches()

View file

@ -96,15 +96,24 @@ def pytest_sessionfinish(session, exitstatus) -> None:
unlisted = sorted(_observed_skips - _allowed_skips())
if not unlisted:
return
print(
f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
head = (
f"НЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:"
)
print(f"\n{head}")
for nodeid in unlisted:
print(f" - {nodeid}")
print(
"Почини тест либо внеси его в skip_allowlist.txt с причиной — "
"пропуск без записи неотличим от пройденной проверки."
)
# #2871: под Actions дублируем в ::error:: — иначе сообщение тонет.
# 13.08 этот сторож четыре прогона подряд ронял job'у совершенно правильно,
# а его строка лежала посреди тысячи других (обычный print, по-русски) —
# и поиск по «FAILED / ERROR» её не находил. Причину искали три часа
# в диске, раннере, покрытии и кэше. Сторож, который роняет прогон,
# обязан кричать так, чтобы его нашли.
if os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"):
print(f"::error::{head} " + "; ".join(unlisted))
if exitstatus == 0:
session.exitstatus = 1

View file

@ -39,6 +39,7 @@ from sqlalchemy.orm import Session
from app.api.v1.parcels import _NEIGHBORS_SUMMARY_SQL
from app.services.site_finder.ird_overlay_lookup import _IRD_OVERLAP_SQL
from app.services.site_finder.velocity import _COMPETITORS_SQL_TMPL
from tests.integration.conftest import requires_test_db
# NB: ``pytestmark`` НЕ ставим на модуль — здесь два класса compile-time
@ -103,9 +104,9 @@ class TestNeighborsSummarySql:
for kw in forbidden_aliases:
# ищем паттерн ``WITH <kw> AS (`` или ``, <kw> AS (`` — оба
# формы CTE-биндинга.
assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, (
f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
)
assert (
f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql
), f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
# ── parcel_ird_overlaps SQL ──────────────────────────────────────────────────
@ -167,3 +168,39 @@ class TestPsycopg3CastAntipattern:
f"{name} содержит psycopg v3 antipattern: {matches}. "
f"Используй CAST(:bind AS type) — см. .claude/rules/backend.md."
)
# ── velocity: конкуренты в радиусе (#2464-G) ─────────────────────────────────
class TestVelocityCompetitorsSql:
"""``_COMPETITORS_SQL_TMPL`` из ``app.services.site_finder.velocity``.
Шаблон подставляется в двух видах, и **вторая подстановка до #2464-G
не парсилась вообще**: фильтр класса ссылался на алиас ``o``, который
существует только во внешнем SELECT, а подставляется фильтр ВНУТРЬ CTE
``latest_obj`` (FROM domrf_kn_objects, без алиаса)
``missing FROM-clause entry for table "o"`` (прод-EXPLAIN 13.08).
Почему это не падало в проде: единственный вызывающий
(``analyze_parcel``) ``obj_class`` не передаёт ветка мёртвая.
Падало бы молча исключение глотает ``except`` в ``compute_velocity``,
и блок velocity просто исчезал бы из отчёта с одной строкой в логе.
Тест закрывает обе ветки, а не только ту, что сегодня исполняется.
"""
@requires_test_db
@pytest.mark.integration
@pytest.mark.parametrize(
"class_filter",
["", "AND COALESCE(obj_class, obj_class_fallback) = :obj_class"],
ids=["no_class_filter", "with_class_filter"],
)
def test_explain_competitors(self, phantom_check_session: Session, class_filter: str) -> None:
"""Обе подстановки шаблона парсятся и планируются против реальной схемы."""
_explain_text(
phantom_check_session,
_COMPETITORS_SQL_TMPL.format(class_filter=class_filter),
{"parcel_wkt": _EKB_WKT, "radius_m": 3000.0, "obj_class": "комфорт"},
)

View file

@ -190,6 +190,70 @@ class TestGetFeaturesInBboxGrid:
# 4 cells: 1 error + 3 good_feat → 1 unique feature
assert any(f.feature_id == "feat-ok" for f in result)
# ── #2464-G: отказ слоя больше не маскируется пустым результатом ──────────
def _grid(self, side_effect: Any, *, grid_n: int = 2) -> list[NSPDFeature]:
"""Прогнать grid-walk с подменённым wms_feature_info."""
mock_client_instance = AsyncMock()
mock_client_instance.wms_feature_info = AsyncMock(side_effect=side_effect)
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
with patch(
"app.scrapers.nspd_bulk_client.NSPDBulkClient",
return_value=mock_client_instance,
):
return NSPDClient().get_features_in_bbox_grid(
36328, self.BBOX, grid_n=grid_n, step_m=1.0
)
def test_waf_403_aborts_grid_instead_of_empty_result(self) -> None:
"""403 WAF на ячейке — бан IP, обход прерывается.
До #2464-G исключение глушилось и метод отдавал [] — «зон здесь нет»,
неотличимое от честного пустого слоя. На проде это 124 дампа из 669
с territorial_zones_count=0, у 50 из которых соседний legacy-слой
данные всё-таки нашёл.
"""
from app.scrapers.nspd_bulk_client import NspdBulkWafError
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
raise NspdBulkWafError("HTTP 403 WAF")
with pytest.raises(NspdBulkWafError):
self._grid(_wms)
def test_all_cells_5xx_raises_instead_of_empty_result(self) -> None:
"""Все ячейки упали с 5xx — слой лёг целиком, а не «пуст»."""
from app.scrapers.nspd_bulk_client import NspdBulkServerError
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
raise NspdBulkServerError("HTTP 500 ServiceException")
with pytest.raises(NspdBulkServerError):
self._grid(_wms)
def test_partial_5xx_keeps_data_and_does_not_raise(self) -> None:
"""Часть ячеек 5xx, часть прошла — отдаём собранное, не бросаем.
Контроль к двум тестам выше: правка НЕ превращает любую ошибку в отказ.
Именно этот тест ловил бы обратную крайность «чуть что, роняем обход».
"""
from app.scrapers.nspd_bulk_client import NspdBulkServerError
good_feat = _make_bulk_feature("feat-ok", {"cad_num": "66:41:001:1"})
call_n: list[int] = [0]
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
call_n[0] += 1
if call_n[0] <= 2:
raise NspdBulkServerError("HTTP 500 ServiceException")
return [good_feat]
result = self._grid(_wms)
assert any(
f.feature_id == "feat-ok" for f in result
), "успешные ячейки должны попасть в результат, даже если часть слоя упала"
def test_returns_nspd_feature_instances(self) -> None:
"""Метод возвращает list[NSPDFeature] а не NSPDBulkFeature."""
bulk_feat = _make_bulk_feature("feat-xyz", {"cad_num": "66:41:001:1"})

View file

@ -133,19 +133,33 @@ class TestFactorFromCount:
assert "12.5 мес истории" in f_frac.note
# ── _coverage_factor — покрытие domrf↔objective в % ────────────────────────────
# ── _coverage_factor — покрытие рынка ценами Objective в % ─────────────────────
class TestCoverageFactor:
def test_low_coverage_percent_in_note(self) -> None:
# Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15).
# 2.5% покрытия → low, % в ноте (структурный §15).
f = _coverage_factor(0.025)
assert f.level == "low"
assert f.value == 0.025
assert "2.5%" in f.note
# #1963: нота человеческая, без внутр.жаргона «domrf↔objective».
assert "domrf" not in f.note
assert "будущ" in f.note # говорит про будущее предложение/проекты
def test_note_names_what_is_actually_measured(self) -> None:
"""#2464-H: нота называет ближние ЖК и цену, а не «будущие проекты».
Значение фактора ВСЕГДА приходит из `analyze.market_data_coverage_pct`
= competitors_priced / competitors_total, то есть доля ближних ЖК (3 км)
с ценой из Objective. Слот `supply_layers.domrf_coverage`, под который
писалась старая формулировка, никто не заполняет.
"""
f = _coverage_factor(0.4)
assert "ближних ЖК" in f.note, f.note
assert "Objective" in f.note, f.note
assert (
"будущ" not in f.note
), "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами"
def test_high_coverage(self) -> None:
f = _coverage_factor(0.75)
@ -158,6 +172,14 @@ class TestCoverageFactor:
assert "неизвестн" in f.note
assert "domrf" not in f.note
def test_factor_key_unchanged(self) -> None:
"""Ключ фактора остаётся `domrf_coverage` — его читает фронт.
Контроль к правке #2464-H: меняем только человеческий текст, не контракт
(ForecastConfidenceBlock / ConfidencePanel маппят имя в RU-подпись).
"""
assert _coverage_factor(0.4).name == "domrf_coverage"
def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None:
# BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден).
# До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) —

View file

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

View file

@ -1393,6 +1393,65 @@ async def test_grid_walk_marks_layer_failed_when_all_cells_500() -> None:
assert layer_failed is True
@pytest.mark.asyncio
async def test_grid_walk_reraises_waf_instead_of_swallowing() -> None:
"""#2464-A: 403 WAF прерывает обход, а не превращается в «cell не дошёл».
Контракт harvest_quarter (Raises:) обещает пробросить NspdBulkWafError, но
голый `except Exception` в цикле ячеек его глотал. Прод-замер 13.08:
23 job'а в cadastre_jobs, суммарно 50 WAF-блоков — и НИ ОДНОГО упавшего
job'а. То есть бан ни разу не остановил сбор, как обещано.
"""
from app.scrapers.nspd_bulk_client import NspdBulkWafError
from app.services.cadastre.bulk_harvest import _grid_walk_category
db = _mock_db_grid_bbox()
client = AsyncMock()
client.wms_feature_info = AsyncMock(side_effect=NspdBulkWafError("HTTP 403 WAF"))
with pytest.raises(NspdBulkWafError):
await _grid_walk_category(
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
)
@pytest.mark.asyncio
async def test_grid_walk_reraises_rate_limit() -> None:
"""#2464-A: исчерпанные ретраи — тоже не «пустой слой» (caller может retry)."""
from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError
from app.services.cadastre.bulk_harvest import _grid_walk_category
db = _mock_db_grid_bbox()
client = AsyncMock()
client.wms_feature_info = AsyncMock(side_effect=NspdBulkRateLimitError("429"))
with pytest.raises(NspdBulkRateLimitError):
await _grid_walk_category(
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
)
@pytest.mark.asyncio
async def test_grid_walk_still_tolerates_network_error_per_cell() -> None:
"""Контроль обратной крайности: сетевая ошибка ячейки обход НЕ роняет.
Зелёный с обеих сторон правки проверяет, что #2464-A не превратил любое
исключение в отказ квартала.
"""
from app.services.cadastre.bulk_harvest import _grid_walk_category
db = _mock_db_grid_bbox()
client = AsyncMock()
client.wms_feature_info = AsyncMock(side_effect=OSError("connection reset"))
upserted, requests, layer_failed = await _grid_walk_category(
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
)
assert upserted == 0
assert requests == 9
assert layer_failed is False, "сетевые сбои НЕ должны поднимать layer_failed"
@pytest.mark.asyncio
async def test_grid_walk_layer_not_failed_when_some_cells_ok() -> None:
"""Issue #252: если хоть один cell прошёл — layer_failed=False (слой жив, просто пуст)."""

View file

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

View file

@ -34,6 +34,7 @@ tests/test_layout_tz_pdf.py
# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py.
# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**.
tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap
tests/integration/test_analyze_parcels_sql.py::TestVelocityCompetitorsSql::test_explain_competitors
tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary
tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query
@ -73,14 +74,22 @@ tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb
tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real
tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped
# ── Образцы, которых нет в git ────────────────────────────────────────────────
# ДОЛГ, а не норма: парсеры проверяются на реальных дампах, которые лежат только
# в untracked `.playwright-mcp/`. Пока файлы не в репозитории, эти 8 проверок не
# исполняются нигде. Правильная починка — закоммитить образцы в tests/fixtures/
# (как сделано в tradein-mvp/backend/tests/fixtures/*.html), после чего строки
# отсюда убрать.
tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_no_duplicate_site_names
tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_site_count_from_dump
# ── Образец, которого нет в git ───────────────────────────────────────────────
# ДОЛГ, а не норма. Было 8 записей; 2 из них (test_ekb_krt_registry.py::
# TestMultiSiteCountFromDump) сняты в #2778: дампа `krt_page_raw.html` не
# оказалось НИГДЕ — ни в репозитории, ни на дисках, ни на проде (в БД лежит
# `raw_block` = уже очищенный текст, вход парсера из него не восстановить), —
# зато свойство, которое они проверяли (несколько блоков на странице, уникальные
# site_name), выразимо без дампа и теперь проверяется на многоблочной странице,
# собранной из блоков, уже лежащих в том же тест-файле.
#
# Оставшиеся 6 так не лечатся: они проверяют извлечение таблиц из НАСТОЯЩЕГО PDF
# через pdfplumber, соседний класс TestParsePptTep уже мокает pdfplumber целиком.
# Нужен файл `ppt2018_22823_poyasnit_PPT.pdf` (или рабочий URL на ГИСОГД ЕКБ) —
# запрошен у Anton в #2778, там же список того, что оживёт. Причин не коммитить
# нет: официальный градостроительный документ, без персональных данных.
# После получения — положить в tests/fixtures/, поправить `_REAL_PDF_PATH`,
# убрать эти шесть строк.
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty

View file

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

View file

@ -184,10 +184,36 @@ def test_load_ps_35_220_parse_and_match() -> None:
assert first["installed"] == 40.0
assert first["reserve"] == 15.0
assert first["district"] == "Ленинский"
assert first["load_pct"] == 41.0 # доля 0.41 → 41.0%
assert first["asof"] == date(2026, 6, 30)
def test_load_ps_35_220_does_not_write_load_percent() -> None:
"""#2464-B: степень загрузки НЕ уходит в UPDATE и не попадает в load_index.
Раньше значение колонки E писалось как
`load_index = COALESCE(load_index, CAST(:load_pct AS text))`. load_index
категориальная колонка ('open'|'limited'|'closed'|NULL,
data/sql/180_connection_capacity.sql:35): число строкой фронт отбрасывает
в «неизвестно» (classifyLoadIndex), а в power_summary.by_load_index
появлялся бы бакет с именем вроде "41.0".
На проде не стреляло только потому, что load_index заполнен у всех строк
(open 2741 / limited 346 / closed 329, NULL 0 замер верификации 13.08),
и COALESCE не проваливался.
"""
from datetime import date
db = _FakeSession(scalar_value=None, rowcount=1)
ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
# Комментарии из SQL убираем: слово load_index встречается в пояснении,
# а проверять надо ИСПОЛНЯЕМЫЙ текст, а не прозу вокруг него.
sql_code = "\n".join(line.split("--", 1)[0] for line in str(db.calls[0][0]).splitlines())
assert "load_index" not in sql_code, sql_code
for _sql, params in db.calls:
assert "load_pct" not in params, params
def test_load_ps_35_220_unmatched_counted() -> None:
"""ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем."""
from datetime import date

View file

@ -9,3 +9,21 @@ def test_health() -> None:
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
def test_health_head_ok_no_body() -> None:
"""HEAD /health — то, что реально шлёт внешний uptime-monitor через Caddy
(`handle /health { reverse_proxy backend:8000 }`, Caddyfile:60), не GET.
Starlette не добавляет HEAD автоматически к `@app.get()` (в отличие от
низкоуровневого `Route(methods=["GET"])`) без явного `@app.head()`
прод-эндпоинт отдаёт 405 на HEAD.
"""
client = TestClient(app)
response = client.head("/health")
assert response.status_code == 200
assert response.content == b""
# RFC 9110 §9.3.2 — заголовки представления (Content-Type) должны совпадать
# с GET; Content-Length допустимо не совпадать (payload header field, MAY
# быть опущен для HEAD).
assert response.headers["content-type"] == "application/json"

View file

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

View file

@ -0,0 +1,44 @@
"""Проверка, что сторож пропусков кричит под Actions (#2871)."""
from __future__ import annotations
import types
import tests.conftest as ct
def _run_guard(monkeypatch, capsys, *, ci: bool, observed: set[str]) -> str:
monkeypatch.setattr(ct, "_observed_skips", observed)
monkeypatch.setattr(ct, "_allowed_skips", lambda: set())
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
monkeypatch.delenv("CI", raising=False)
if ci:
monkeypatch.setenv("GITHUB_ACTIONS", "true")
session = types.SimpleNamespace(exitstatus=0)
ct.pytest_sessionfinish(session, 0)
return capsys.readouterr().out, session.exitstatus
def test_guard_emits_error_annotation_under_actions(monkeypatch, capsys) -> None:
out, rc = _run_guard(monkeypatch, capsys, ci=True, observed={"tests/x.py::test_y"})
assert "::error::" in out, "под Actions сторож обязан подниматься в аннотации"
assert "tests/x.py::test_y" in out
assert rc == 1
def test_guard_stays_quiet_locally(monkeypatch, capsys) -> None:
"""Контроль: локально ::error:: не нужен, человеческое сообщение остаётся."""
out, rc = _run_guard(monkeypatch, capsys, ci=False, observed={"tests/x.py::test_y"})
assert "::error::" not in out
assert "НЕУЧТЁННЫЙ ПРОПУСК" in out
assert rc == 1
def test_guard_silent_when_all_skips_declared(monkeypatch, capsys) -> None:
"""Контроль: без незадекларированных пропусков сторож молчит и не роняет."""
monkeypatch.setattr(ct, "_observed_skips", set())
monkeypatch.setattr(ct, "_allowed_skips", lambda: set())
session = types.SimpleNamespace(exitstatus=0)
ct.pytest_sessionfinish(session, 0)
assert capsys.readouterr().out == ""
assert session.exitstatus == 0

View file

@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет:
- resolve_weights: нет user_id и profile_id системные дефолты
- resolve_weights: user_id задан, default-профиль есть его веса
- resolve_weights: profile_id задан его веса
- resolve_weights: профиль не найден системные дефолты (fallback)
- resolve_weights: профиль не найден системные дефолты (fallback) + source != profile
"""
from __future__ import annotations
import logging
from unittest.mock import MagicMock
import pytest
@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None:
"""Оба аргумента None → возвращаются системные веса."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
assert result == _SYSTEM_POI_WEIGHTS
assert result.weights == _SYSTEM_POI_WEIGHTS
assert result.source == "system"
# db не должен вызываться вообще
db.execute.assert_not_called()
@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None:
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
result["school"] = 999.0
result.weights["school"] = 999.0
# Оригинал не изменён
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None:
finally:
wp_module.get_default_profile = original
assert result == custom_weights
assert result.weights == custom_weights
assert result.source == "user_default"
def test_resolve_weights_uses_specific_profile() -> None:
@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None:
finally:
wp_module.get_profile = original
assert result == custom_weights
assert result.weights == custom_weights
assert result.source == "profile"
def test_resolve_weights_profile_not_found_fallback() -> None:
@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None:
wp_module.get_profile = original_get
wp_module.get_default_profile = original_default
assert result == _SYSTEM_POI_WEIGHTS
assert result.weights == _SYSTEM_POI_WEIGHTS
# #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли
assert result.source == "system"
def test_resolve_weights_empty_profile_weights_fallback() -> None:
@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None:
finally:
wp_module.get_default_profile = original_default
assert result == _SYSTEM_POI_WEIGHTS
assert result.weights == _SYSTEM_POI_WEIGHTS
assert result.source == "system"
def test_resolve_weights_profile_id_without_owner_is_not_profile(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется.
Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 —
source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он
-0.4). Метка обязана быть 'system', а промах попасть в warning.
"""
db = MagicMock()
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
result = resolve_weights(db, user_id=None, profile_id=1)
assert result.source == "system"
assert result.weights == _SYSTEM_POI_WEIGHTS
assert "profile_id=1" in caplog.text
db.execute.assert_not_called() # профиль даже не искали
def test_resolve_weights_missing_profile_falls_to_user_default_not_profile(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль.
Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не
видна. Метка должна сказать 'user_default', а не 'profile'.
"""
import app.services.site_finder.weight_profiles as wp_module
default_profile = _make_profile_mock({"school": 2.0})
db = MagicMock()
original_get = wp_module.get_profile
original_default = wp_module.get_default_profile
wp_module.get_profile = lambda _db, uid, pid: None
wp_module.get_default_profile = lambda _db, uid: default_profile
try:
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
result = resolve_weights(db, user_id="user-1", profile_id=999)
finally:
wp_module.get_profile = original_get
wp_module.get_default_profile = original_default
assert result.source == "user_default"
assert result.weights == {"school": 2.0}
assert "profile_id=999" in caplog.text

View file

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

View file

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

View file

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

View file

@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import {
AnalyzeWeightsContext,
adaptEgrn,
useParcelAnalyzeQuery,
} from "@/lib/site-finder-api";
import type { PoiCategoryKey } from "@/lib/api/weightProfiles";
import type {
ParcelAnalysis,
PendingConceptProgram,
@ -29,7 +34,40 @@ interface Props {
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
/**
* Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в
* контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние
* нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы
* контекст «сверху», то есть null, и страница разъехалась бы на два разных
* анализа свой у шапки, свой у секций.
*
* null = веса не применяли запрос как раньше, без тела.
*/
export function AnalysisPageContent({ cad }: Props) {
const [appliedWeights, setAppliedWeights] = useState<Record<
PoiCategoryKey,
number
> | null>(null);
return (
<AnalyzeWeightsContext.Provider value={appliedWeights}>
<AnalysisPageBody
cad={cad}
appliedWeights={appliedWeights}
onWeightsApply={setAppliedWeights}
/>
</AnalyzeWeightsContext.Provider>
);
}
function AnalysisPageBody({
cad,
appliedWeights,
onWeightsApply,
}: Props & {
appliedWeights: Record<PoiCategoryKey, number> | null;
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
}) {
const [horizon, setHorizon] = useState<number>(12);
const queryClient = useQueryClient();
@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) {
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
<GroupDivider label="Стройка и рынок" />
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
{/* 4. Рынок и конкуренты IMPLEMENTED in A7. Веса POI из §4.1
поднимаем сюда: «Применить» меняет ключ analyze-запроса скор
пересчитывается по ползункам во ВСЕХ секциях (#2790). */}
<Section3SettingsAndCompetitors
cad={cad}
data={analysis}
weights={appliedWeights}
onWeightsApply={onWeightsApply}
/>
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
<Section5Atmosphere cad={cad} />

View file

@ -0,0 +1,159 @@
/**
* #2790 п.1 «Применить» у весов POI в §4.1 ничего не применяло.
*
* Состояние весов жило в `Section31Settings` и читалось только обратно в ту же
* панель: до `/analyze` оно не доезжало никогда (слова `weights` в
* AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал
* «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам.
*
* Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и
* настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес карты,
* прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй
* POST /analyze не случается вообще красный.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnalysisPageContent } from "../AnalysisPageContent";
// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут
// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) —
// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста.
vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null }));
vi.mock("@/components/site-finder/GateVerdictBanner", () => ({
GateVerdictBanner: () => null,
}));
vi.mock("@/components/site-finder/HorizonSelector", () => ({
HorizonSelector: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({
Section1ParcelInfo: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({
Section2NetworksUtilities: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({
Section4Estimate: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({
Section5Atmosphere: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({
Section6Forecast: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({
Section7Concept: () => null,
}));
vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({
SectionAlternatives: () => null,
}));
vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({
BestLayoutsBlock: () => null,
}));
const CAD = "66:41:0702017:131";
const ANALYSIS = {
cad_num: CAD,
score: 18.91,
district: { district_name: "Чкаловский" },
egrn: null,
competitors: [],
};
/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */
const analyzeBodies: Array<Record<string, unknown> | undefined> = [];
const fetchMock = vi.fn<typeof fetch>();
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
analyzeBodies.length = 0;
fetchMock.mockReset();
fetchMock.mockImplementation(async (input, init) => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/analyze")) {
const raw = init?.body;
analyzeBodies.push(
typeof raw === "string"
? (JSON.parse(raw) as Record<string, unknown>)
: undefined,
);
return jsonResponse(ANALYSIS);
}
if (url.includes("/api/v1/me")) {
return jsonResponse({
username: "admin",
role: "admin",
allowed_paths: ["/**"],
deny_paths: [],
});
}
if (url.includes("/weight-profiles")) {
return jsonResponse([]);
}
throw new Error(`unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<AnalysisPageContent cad={CAD} />
</QueryClientProvider>,
);
}
/** Ползунок конкретной категории по подписи строки в панели весов. */
function sliderFor(label: string): HTMLInputElement {
const row = screen.getByText(label).closest("div");
if (!row) throw new Error(`не нашёл строку ползунка «${label}»`);
const input = row.querySelector('input[type="range"]');
if (!input) throw new Error(`в строке «${label}» нет ползунка`);
return input as HTMLInputElement;
}
describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => {
it("отправляет ползунки в тело повторного analyze", async () => {
renderPage();
// Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе,
// бэкенд считает по системным. Это же и baseline для «стало другим».
await waitFor(() => expect(analyzeBodies.length).toBe(1));
expect(analyzeBodies[0]).toBeUndefined();
fireEvent.click(await screen.findByText("POI Веса"));
fireEvent.change(sliderFor("Парки"), { target: { value: "3" } });
fireEvent.change(sliderFor("Трамвайные ост. ()"), {
target: { value: "-2" },
});
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
// Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что
// выставлены ползунками. До фикса второго запроса не было — красный здесь.
await waitFor(() => expect(analyzeBodies.length).toBe(2));
const applied = analyzeBodies[1]?.weights as Record<string, number>;
expect(applied.park).toBe(3);
expect(applied.tram_stop).toBe(-2);
// Нетронутые категории уходят как есть — бэкенд мержит поверх системных,
// но панель отправляет полный набор, чтобы ответ совпадал с ползунками.
expect(applied.school).toBe(1.5);
});
});

View file

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

View file

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

View file

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

View file

@ -3,12 +3,14 @@
import { useCallback, useState } from "react";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { useMe } from "@/lib/useMe";
import {
POI_CATEGORIES,
POI_DEFAULT_WEIGHTS,
POI_LABELS,
POI_WEIGHT_MAX,
POI_WEIGHT_MIN,
SYSTEM_PROFILE_USER_ID,
useCreateProfile,
useWeightProfiles,
type PoiCategoryKey,
@ -29,13 +31,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 +61,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 +79,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 ────────────────────────────────────────────────────────────────
@ -113,11 +111,22 @@ export function WeightProfilePanel({
}
function handleApply() {
onWeightsChange({ ...draft }, selectedProfileId);
// Системный пресет не адресуем через profile_id: resolve_weights() ищет
// профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не
// текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и
// отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для
// пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса,
// а они ровно те, что на ползунках.
const selected = profiles.find((p) => p.id === selectedProfileId) ?? null;
const addressableId =
selected && selected.user_id !== SYSTEM_PROFILE_USER_ID
? selected.id
: null;
onWeightsChange({ ...draft }, addressableId);
}
const handleSaveProfile = useCallback(async () => {
if (!canUseCrud || !userId || !adminToken) {
if (!canUseCrud || !userId) {
setShowSaveDialog(false);
return;
}
@ -136,15 +145,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 +243,7 @@ export function WeightProfilePanel({
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
title={`Профили сохраняются отдельно для каждого пользователя. Ваш: ${userId}`}
>
Профиль:
</label>
@ -268,6 +270,7 @@ export function WeightProfilePanel({
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.profile_name}
{p.user_id === SYSTEM_PROFILE_USER_ID ? " · пресет" : ""}
{p.is_default ? " ★" : ""}
</option>
))}
@ -278,10 +281,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 +368,9 @@ export function WeightProfilePanel({
}}
>
<SectionLabel>Новый профиль</SectionLabel>
<p style={{ fontSize: 11, color: "#6b7280", margin: 0 }}>
Сохранится для пользователя {userId} другие его не увидят.
</p>
<input
autoFocus
type="text"

View file

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

View file

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

View file

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

View file

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

View file

@ -28,6 +28,10 @@ interface Props {
cad: string;
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
data: ParcelAnalysis;
/** Уже применённые POI-веса; null = ничего не применяли (системные). */
weights: Record<PoiCategoryKey, number> | null;
/** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
}
interface FilterState {
@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) {
function Section31Settings({
filters,
onFiltersChange,
weights,
onWeightsApply,
}: {
filters: FilterState;
onFiltersChange: (f: FilterState) => void;
weights: Record<PoiCategoryKey, number> | null;
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
}) {
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
() => ({ ...POI_DEFAULT_WEIGHTS }),
);
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
onFiltersChange({ ...filters, [key]: !filters[key] });
}
function handleWeightsChange(
newWeights: Record<PoiCategoryKey, number>,
_profileId: number | null,
) {
setWeights(newWeights);
}
const chips: Array<{
key: keyof Omit<FilterState, "radiusKm">;
label: string;
@ -136,8 +133,8 @@ function Section31Settings({
margin: "4px 0 0",
}}
>
Фильтры применяются к конкурентам локально без повторного запроса к
бэкенду
Радиус и фильтры применяются к конкурентам локально. Веса POI
пересчёт анализа на бэкенде по кнопке «Применить»
</p>
</div>
@ -259,8 +256,8 @@ function Section31Settings({
Профиль весов POI
</div>
<WeightProfilePanel
currentWeights={weights}
onWeightsChange={handleWeightsChange}
currentWeights={weights ?? POI_DEFAULT_WEIGHTS}
onWeightsChange={onWeightsApply}
/>
</div>
</div>
@ -769,7 +766,12 @@ function applyFilters(
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
export function Section3SettingsAndCompetitors({
cad,
data,
weights,
onWeightsApply,
}: Props) {
const [filters, setFilters] = useState<FilterState>({
radiusKm: 2,
onlyUnderConstruction: false,
@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
<StageDetails>
{/* Sub-sections */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<Section31Settings filters={filters} onFiltersChange={setFilters} />
<Section31Settings
filters={filters}
onFiltersChange={setFilters}
weights={weights}
onWeightsApply={onWeightsApply}
/>
{/* Competitor table — moved before 3.2/3.3 for context */}
{filteredCompetitors.length > 0 && (

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@
* directly with a real AbortSignal and a per-URL `fetch` stub, under fake
* timers, and assert on abort behaviour + the happy path.
*/
import { renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Capture the options passed to useQuery ───────────────────────────────────
@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42";
* polling queryFn. Reads `captured.options` via a fresh binding so TS control-
* flow doesn't pin it (the hook mutates it opaquely through the mock).
*
* `useQuery` is fully mocked (it just records its options, no React state), so
* the rules-of-hooks invariant does not apply to this call disable locally.
* Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые
* веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет
* dispatcher'а «Cannot read properties of null». `useQuery` по-прежнему
* замокан и просто записывает options; провайдера над хуком нет, значит
* контекст = null, то есть ровно тот случай «весов не применяли», который этот
* тест и гоняет.
*/
function getQueryFn(): CapturedQueryOptions["queryFn"] {
// eslint-disable-next-line react-hooks/rules-of-hooks
useParcelAnalyzeQuery(CAD, 12);
renderHook(() => useParcelAnalyzeQuery(CAD, 12));
const options = captured.options;
if (options === null) throw new Error("useQuery options not captured");
return options.queryFn;

View file

@ -27,14 +27,18 @@ export interface WeightProfileCreate {
description?: string | null;
}
export interface WeightProfileUpdate {
profile_name?: string;
weights?: Record<string, number>;
is_default?: boolean;
description?: string | null;
}
// ── Constants ─────────────────────────────────────────────────────────────────
/**
* Владелец системных пресетов (Эконом / Комфорт / Бизнес) mirrors
* `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py.
* Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`:
* `resolve_weights()` ищет профиль в области владельца, у чужого пользователя
* его не найдёт и молча вернёт системные веса с ответом `source="profile"`
* (#2782). Их веса уходят в analyze inline см. WeightProfilePanel.
*/
export const SYSTEM_PROFILE_USER_ID = "__system__";
// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES.
// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py.
@ -91,35 +95,44 @@ 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) {
/**
* Профили пользователя + системные пресеты (#2790).
*
* `include_system=true` домешивает в конец списка три общих пресета (Эконом /
* Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`).
* Без него у пользователя без своих профилей дропдаун пустой пресеты лежали в
* проде с 16.05.2026 и не были видны никому.
*/
export function useWeightProfiles(userId: string) {
return useQuery<WeightProfile[]>({
queryKey: ["weight-profiles", userId],
queryFn: () =>
apiFetch<WeightProfile[]>(
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
{
headers: profilesHeaders(adminToken),
},
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`,
),
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) => {
@ -130,43 +143,9 @@ export function useCreateProfile(adminToken: string) {
});
}
/** Update an existing weight profile by id. */
export function useUpdateProfile(
userId: string,
profileId: number,
adminToken: string,
) {
const qc = useQueryClient();
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
mutationFn: (payload) =>
apiFetch<WeightProfile>(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "PUT",
headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
},
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
},
});
}
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
export function useDeleteProfile(userId: string, adminToken: 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),
},
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
},
});
}
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).

View file

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

View file

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

View file

@ -9,6 +9,7 @@
*/
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { createContext, useContext } from "react";
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
import { abortableSleep } from "@/lib/abortableSleep";
import type {
@ -503,9 +504,36 @@ export interface PoiScoreResponse {
const ANALYZE_POLL_INTERVAL_MS = 2000;
const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
/**
* Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли запрос
* уходит без тела, как и раньше (бэкенд считает по системным весам).
*
* Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)`
* зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) все они делят один
* ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос.
* Если веса доедут только до части из них, ключи разойдутся: половина страницы
* покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды.
* Контекст держит всех потребителей ключа на одном значении по построению
* забыть прокинуть проп в новую секцию нельзя.
*/
export const AnalyzeWeightsContext = createContext<Record<
string,
number
> | null>(null);
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
const weights = useContext(AnalyzeWeightsContext);
// Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем.
// null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до
// #2790, кэш не сбрасывается на ровном месте.
const weightsKey = weights
? JSON.stringify(Object.entries(weights).sort())
: null;
return useQuery({
queryKey: ["parcel-analyze", cad, horizon],
// Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI
// мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт.
queryKey: ["parcel-analyze", cad, horizon, weightsKey],
// TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
// it through the POST/GET fetches and check it before each poll iteration so
@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
cad,
)}/analyze?horizon=${horizon}`;
// Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id:
// тело запроса == ползункам панели, и ответ рапортует source="inline" —
// расхождению между показанными весами и посчитанным скором взяться
// неоткуда (в отличие от profile_id, см. #2782).
const analyzeInit: RequestInit = weights
? { method: "POST", signal, body: JSON.stringify({ weights }) }
: { method: "POST", signal };
// First request — POST /analyze. apiFetchWithStatus surfaces the 202
// Accepted code instead of treating it as a successful payload.
const first = await apiFetchWithStatus<
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
>(analyzeUrl, { method: "POST", signal });
>(analyzeUrl, analyzeInit);
// 200 → geometry was cached, full analysis is ready.
if (first.status === 200) {
@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
// rather than returning the stub (symmetry with the first request).
const second = await apiFetchWithStatus<
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
>(analyzeUrl, { method: "POST", signal });
>(analyzeUrl, analyzeInit);
if (second.status === 200) {
return second.body as ParcelAnalyzeResponse;
}

102
ops/docker-prune.sh Executable file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Периодическая уборка docker-мусора на прод-VM.
#
# ЗАЧЕМ. 2026-08-15 диск был занят на 76% (110 из 145 ГБ). Разбор показал 201
# том-сироту на 12.6 ГБ: 125 анонимных — каталоги данных PostgreSQL от тестовых
# прогонов CI, 76 — окружения задач Forgejo Actions. Прод-данных среди них не
# было ни одного.
#
# Корневая причина анонимных томов устранена отдельно: ci.yml и ci-tradein.yml
# снимали свой postgres через `docker rm -f` БЕЗ `-v`, поэтому контейнер уходил,
# а его том оставался. Теперь там `docker rm -fv`. Этот скрипт — страховка: он
# подбирает то, что runner не убрал за собой, и то, что накопилось раньше.
#
# ЧТО ИМЕННО УДАЛЯЕТСЯ (осознанно консервативно):
# - остановленные контейнеры старше 24ч;
# - висячие (dangling) образы старше 7 суток;
# - тома-сироты ТОЛЬКО двух известных форм: 64-символьный hex (анонимные) и
# FORGEJO-ACTIONS-TASK-*. Именованные тома со смыслом (gendesign_postgres_data,
# tradein-postgres-data, *_caddy_*, couchdb, redis и любые будущие) не трогаются
# НИКОГДА — даже если в моменте оказались отцеплены. Голый `docker volume prune`
# такой разницы не делает, поэтому здесь он намеренно не используется.
#
# Usage (cron на прод-VM; `bash <путь>`, а не голый путь — тогда снятый +x не ломает).
# Лог в /tmp — как у соседних записей в том же crontab (backup.sh, backfill'ы):
# 0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /tmp/gendesign-docker-prune.log 2>&1
#
# Воскресенье 04:00 UTC — свободный слот: рядом 03:30 backup.sh, 04:30 backup
# tradein, 05:00+ backfill'ы.
#
# Раз в неделю достаточно: после устранения корневой причины (docker rm -fv в CI)
# копятся только тома runner'а. DRY_RUN=1 — показать, что удалится, не трогая.
set -euo pipefail
DRY_RUN="${DRY_RUN:-0}"
STOPPED_AGE="${STOPPED_AGE:-24h}"
IMAGE_AGE="${IMAGE_AGE:-168h}"
log() { printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; }
disk_used_pct() { df --output=pcent / | tail -1 | tr -dc '0-9'; }
before_pct="$(disk_used_pct)"
log "старт: диск занят ${before_pct}%"
if [[ "$DRY_RUN" == "1" ]]; then
log "DRY_RUN=1 — только показываю"
fi
# ── 1. остановленные контейнеры ───────────────────────────────────────────────
if [[ "$DRY_RUN" == "1" ]]; then
# `until` поддерживает только `prune`, у `ls` его нет («invalid filter 'until'»),
# поэтому в dry-run считаем ВСЕ остановленные — это верхняя оценка.
log "остановленных контейнеров всего (удалятся только старше ${STOPPED_AGE}): \
$(docker container ls -aq --filter "status=exited" | wc -l)"
else
log "контейнеры: $(docker container prune -f --filter "until=${STOPPED_AGE}" \
2>&1 | tail -1)"
fi
# ── 2. висячие образы ─────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == "1" ]]; then
log "висячих образов: $(docker image ls -qf dangling=true | wc -l)"
else
log "образы: $(docker image prune -f --filter "until=${IMAGE_AGE}" 2>&1 | tail -1)"
fi
# ── 3. тома-сироты известных форм ─────────────────────────────────────────────
# Отбираем ПОИМЁННО, а не через `docker volume prune`: тот снёс бы любой
# отцепленный именованный том, включая боевой, если контейнер в моменте пересоздаётся.
mapfile -t candidates < <(
docker volume ls -qf dangling=true \
| grep -E '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true
)
skipped="$(docker volume ls -qf dangling=true \
| grep -vE '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true)"
if [[ -n "$skipped" ]]; then
log "ПРОПУЩЕНЫ (именованные, руками): $(echo "$skipped" | tr '\n' ' ')"
fi
if [[ "${#candidates[@]}" -eq 0 ]]; then
log "томов-сирот известных форм нет"
elif [[ "$DRY_RUN" == "1" ]]; then
log "томов к удалению: ${#candidates[@]}"
else
removed=0
for v in "${candidates[@]}"; do
if docker volume rm "$v" >/dev/null 2>&1; then
removed=$((removed + 1))
fi
done
log "томов удалено: ${removed} из ${#candidates[@]}"
fi
after_pct="$(disk_used_pct)"
log "готово: диск занят ${after_pct}% (было ${before_pct}%)"
# Сигнал в лог, если места всё равно мало — повод посмотреть глазами.
if [[ "$after_pct" -ge 85 ]]; then
log "ВНИМАНИЕ: диск занят ${after_pct}% — уборки уже недостаточно"
fi

View file

@ -8,6 +8,13 @@ Persistent offset в /state/offset.json — не дублируем при resta
Throttle: при >10 401 events за 60s однократный digest event
(чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются.
before_send=_drop_basic_auth_noise (glitchtip-noise фикс): все события отсюда
дропаются перед отправкой в GlitchTip 401 от неаутентифицированного запроса
не ошибка сервиса, это боты сканируют закрытый basic_auth'ом сайт. Раньше это
был крупнейший источник шума в трекере (3 738 issue). Скрипт по-прежнему тэйлит
лог и печатает `[forwarder] 401 event sent: ...` в stdout (docker logs) просто
больше не шлёт эти события в issue-трекер. Смотри `_drop_basic_auth_noise` docstring.
Реальный Caddy JSON access log (v2) структура:
{
"level": "info",
@ -73,6 +80,41 @@ _shutdown = False
_last_exc_sent: float = 0.0
_EXC_THROTTLE_S: float = 300.0
# event_type-теги, которыми emit_event/emit_digest помечают КАЖДОЕ отправляемое
# событие (см. scope.set_tag("event_type", ...) ниже) — используются как ключ
# для before_send-фильтра.
_BASIC_AUTH_EVENT_TYPES = frozenset({"basic_auth_failed", "basic_auth_storm"})
def _drop_basic_auth_noise(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""before_send-фильтр: 401 неаутентифицированного basic_auth-запроса — НЕ
ошибка сервиса, а expected-поведение сканеров-ботов, ломящихся в закрытый
basic_auth'ом gendsgn.ru (`GET /wp-admin/install.php` и подобное). До этого
фикса emit_event/emit_digest слали КАЖДЫЙ такой 401 individual-событием (или
storm-digest) в GlitchTip remote_ip в message/тегах раздувал кардинальность
(3 738 issue, 2 019 различных заголовков, топ 222 события на «GET
/wp-admin/install.p»), топя содержательные алерты (OperationalError, sweep
failures) в шуме сканеров.
Дропаем НА ИСТОЧНИКЕ (before_send), не постфактум-чисткой issue-трекера
так шум не появляется вообще, а не изредка удаляется руками. Фильтруем по
тегу `event_type`, который ставят ТОЛЬКО emit_event/emit_digest необработанные
исключения самого форвардера (`capture_exception` в конце `main()`, реальный
баг скрипта) этот тег не несут и проходят фильтр как есть (см. `except
Exception` ниже в `main()`).
"""
tags = event.get("tags")
event_type = None
if isinstance(tags, dict):
event_type = tags.get("event_type")
elif isinstance(tags, list):
# sentry_sdk в некоторых версиях сериализует tags как list[tuple[str, str]]
# вместо dict — на всякий случай поддерживаем обе формы.
event_type = dict(tags).get("event_type") if tags else None
if event_type in _BASIC_AUTH_EVENT_TYPES:
return None
return event
def _signal_handler(signum: int, frame: object) -> None:
global _shutdown
@ -221,6 +263,7 @@ def main() -> None:
traces_sample_rate=0.0,
attach_stacktrace=False,
send_default_pii=False,
before_send=_drop_basic_auth_noise,
# Отключаем интеграции которые не нужны тонкому sidecar
default_integrations=False,
)

View file

@ -0,0 +1,77 @@
"""Тесты для `_drop_basic_auth_noise` (before_send-фильтр, glitchtip-noise).
Раньше форвардер слал КАЖДЫЙ basic_auth 401 (сканеры-боты, ломящиеся в закрытый
basic_auth'ом gendsgn.ru) individual-событием в GlitchTip — 3 738 issue, 2 019
различных заголовков (remote_ip раздувал кардинальность), топя содержательный
сигнал. `_drop_basic_auth_noise` дропает эти события НА ИСТОЧНИКЕ (before_send),
но НЕ должен трогать unhandled-ошибки самого форвардера (реальный баг скрипта
`capture_exception` без `event_type`-тега, аналог "500 должен пройти").
"""
from __future__ import annotations
import os
# DSN обязателен на module-level (`os.environ["GLITCHTIP_DSN"]`, fail-fast) — задаём
# ДО импорта forwarder.py, иначе импорт падает KeyError.
os.environ.setdefault("GLITCHTIP_DSN", "http://test@localhost/1")
from forwarder import _BASIC_AUTH_EVENT_TYPES, _drop_basic_auth_noise
def test_drops_individual_basic_auth_401() -> None:
"""emit_event() тегирует event_type=basic_auth_failed — 401 от бота-сканера,
не ошибка сервиса, должен быть отброшен (return None)."""
event = {
"tags": {"event_type": "basic_auth_failed", "remote_ip": "95.165.147.218"},
"message": "basic_auth 401 — GET /wp-admin/install.php from 95.165.147.218",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_basic_auth_storm_digest() -> None:
"""emit_digest() тегирует event_type=basic_auth_storm — тоже 401-класс, тоже
не ошибка сервиса, дропаем."""
event = {
"tags": {"event_type": "basic_auth_storm"},
"message": "basic_auth storm — 15 failed attempts in 60s",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_when_tags_serialized_as_list_of_tuples() -> None:
"""Некоторые версии sentry_sdk сериализуют tags как list[tuple[str, str]]
вместо dict фильтр обязан поддерживать обе формы."""
event = {"tags": [("event_type", "basic_auth_failed")]}
assert _drop_basic_auth_noise(event, {}) is None
def test_passes_through_forwarder_own_crash() -> None:
"""500-аналог: unhandled exception самого форвардера (capture_exception в
конце main(), реальный баг скрипта напр. PermissionError на STATE_FILE) не
несёт event_type-тег должен пройти НЕТРОНУТЫМ, не быть молча проглоченным
вместе с ботовым шумом."""
event = {
"level": "error",
"exception": {"values": [{"type": "PermissionError", "value": "denied"}]},
}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_event_without_tags() -> None:
event: dict = {"message": "something unrelated"}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_unrelated_tag_value() -> None:
event = {"tags": {"event_type": "something_else"}}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_basic_auth_event_types_are_exactly_the_two_emitters_use() -> None:
"""Явная фиксация словаря — emit_event → basic_auth_failed,
emit_digest basic_auth_storm (см. forwarder.py)."""
assert _BASIC_AUTH_EVENT_TYPES == frozenset({"basic_auth_failed", "basic_auth_storm"})

0
ops/restore.sh Normal file → Executable file
View file

View file

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

View file

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

View file

@ -5,6 +5,8 @@
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
# политика ПДн, на которую ссылается футер.
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
# напечатаны внутри самих юридических документов и уходят эквайеру.
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
@ -52,6 +54,15 @@ check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
# эти три URL напечатаны внутри самих документов и уходят в заявку
# эквайеру — если rewrite выпадет из Caddyfile, оферта будет ссылаться на
# 404, и заявку завернут. Проверяем все три поимённо, потому что и в
# Caddyfile они перечислены поимённо (allowlist, не шаблон).
check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200
check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200
check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404

View file

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

46
tradein-mvp/CHANGELOG.md Normal file
View file

@ -0,0 +1,46 @@
# История версий «МЕРА»
Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и
[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно
`## <semver> — <YYYY-MM-DD>` (машинно читается страницей истории версий).
## 2.1.0 — 2026-08-10
Первая версия с явным версионированием. Номер продолжает ряд, который до этого
показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние
отчёты.
### Добавлено
- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и
реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень
уверенности в оценке.
- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав
аналогов и сделок, формирование выкупной стоимости.
- Аналитика по дому — история размещений объявлений и продаж в доме.
- История прошлых оценок в личном кабинете, автодополнение адреса при поиске.
- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история).
- Чат поддержки на сайте, в том числе без входа в личный кабинет.
- Публичный лендинг «МЕРА».
- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта
страница истории версий.
### Изменено
- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более
раннего технического макета.
### Исправлено
- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе
комнатности не было варианта «Студия», из-за чего для студии подбирались
однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась.
- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор
автоматически расширяется (студии, срок объявлений, новостройки, радиус),
а над результатом показывается предупреждение о сниженной точности и о том,
какие параметры пришлось расширить.
- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части
адресов улица не распознавалась, и разделы оставались пустыми.
- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов.
- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки
на обложке, некорректные допущения о сроке экспозиции.

1
tradein-mvp/VERSION Normal file
View file

@ -0,0 +1 @@
2.1.0

View file

@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages
COPY --from=builder --chown=app:app /app/backend/app /app/app
COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts
# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего
# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION).
# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста.
COPY --chown=app:app VERSION VERSION
# Версия продукта + короткий git SHA + дата сборки — запечены как build-args
# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend).
# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам
# (VERSION-файл выше / "dev" / момент импорта модуля).
#
# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых
# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на
# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои
# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале
# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get +
# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот
# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже).
ARG APP_VERSION=""
ARG BUILD_SHA=""
ARG BUILD_DATE=""
ENV APP_VERSION=$APP_VERSION \
BUILD_SHA=$BUILD_SHA \
BUILD_DATE=$BUILD_DATE
USER app
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые

View file

@ -74,6 +74,7 @@ from app.services import proxy_rotation as proxy_rotation_svc
from app.services import scrape_runs as runs_mod
from app.services.estimator import LISTINGS_FRESH_DAYS
from app.services.geocoder import geocode, known_city_hint
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
from app.services.proxy_pool import clear_source_bans
from app.services.scheduler import has_running_run
from app.services.scraper_adapters import (
@ -373,6 +374,56 @@ async def geocode_missing(
}
def _cian_verify_state_error(state: dict[str, Any] | None) -> HTTPException | None:
"""Маппинг исхода cian_session_svc.verify_session() на HTTP-ответ админки.
verify_session() возвращает 4 разных исхода (см. докстринг сервиса) плюс успех
их нельзя схлопывать в один "cookies invalid", иначе бан по IP выглядит так же,
как протухшие куки, и человек в момент инцидента перезаливает заведомо валидные
куки вместо починки egress/прокси (инцидент 2026-08-10).
Sentinel'ы сравниваются через `is`, НЕ `==` — так требует докстринг verify_session.
Возвращает None, если state это успешно распаршенный state dict (в т.ч. случай
"успех, но userId не найден" этот случай caller должен обработать отдельно).
"""
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
return HTTPException(
status_code=503,
detail=(
"Cian заблокировал наш IP (HTTP 403, TLS/bot-fingerprint ban). "
"Куки, скорее всего, валидны — блокировка не про них. "
"Нужно чинить egress: проверить SCRAPER_PROXY_URL и баны в "
"scrape_proxy_source_bans. Перезаливать куки бесполезно. "
"(CIAN_PROXY_URL — мёртвая переменная, снята в #2616.)"
),
)
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
return HTTPException(
status_code=503,
detail=(
"Cian временно недоступен (5xx или сетевой сбой при проверке кук). "
"Повторите проверку позже. Куки не трогать — источник просто не ответил."
),
)
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
return HTTPException(
status_code=500,
detail=(
"Cian изменил вёрстку/схему страницы — auth-state не найден/не "
"распарсился (scraper_kit.cian_state_parser.extract_state, MFE "
"header-frontend). Нужен инженерный фикс парсера, перезалив кук "
"проблему НЕ решит."
),
)
if state is None:
return HTTPException(
status_code=401,
detail="Куки протухли или сессия разлогинена на cian.ru — перезалейте куки.",
)
return None
@router.post("/scrape/cian/upload-cookies", status_code=200)
async def upload_cian_cookies(
cookies: dict[str, str],
@ -405,15 +456,21 @@ async def upload_cian_cookies(
)
state = await cian_session_svc.verify_session(cleaned)
if state is None:
raise HTTPException(
status_code=401,
detail="Cookies invalid or session not authenticated on cian.ru",
)
verify_error = _cian_verify_state_error(state)
if verify_error is not None:
raise verify_error
assert state is not None # narrowed by _cian_verify_state_error above
user_id = state.get("user", {}).get("userId")
if not user_id:
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
raise HTTPException(
status_code=400,
detail=(
"Cian подтвердил аутентификацию (state распарсился), но userId в "
"ответе не найден — структура state неожиданная, куки тут ни при "
"чём, смотрите server logs."
),
)
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
@ -480,15 +537,21 @@ async def cian_auto_login(
)
state = await cian_session_svc.verify_session(cleaned)
if state is None:
raise HTTPException(
status_code=401,
detail="Logged in but session not authenticated (cookies rejected by cian.ru)",
)
verify_error = _cian_verify_state_error(state)
if verify_error is not None:
raise verify_error
assert state is not None # narrowed by _cian_verify_state_error above
user_id = state.get("user", {}).get("userId")
if not user_id:
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
raise HTTPException(
status_code=400,
detail=(
"Cian подтвердил аутентификацию (state распарсился), но userId в "
"ответе не найден — структура state неожиданная, куки тут ни при "
"чём, смотрите server logs."
),
)
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
@ -500,6 +563,12 @@ async def test_cian_auth(
) -> dict:
"""Проверить что текущие сохранённые Cian cookies ещё валидны.
reason различает 5 исходов (см. cian_session_svc.verify_session докстринг):
"banned_403" куки, вероятно, ОК, блокирован IP; "source_unavailable"
Cian недоступен, куки ни при чём; "markup_changed" вёрстка Cian сменилась,
нужен фикс парсера; "session_expired_or_invalid" куки реально протухли;
"no_session_in_db" / "encryption_key_not_configured" конфигурация/данных нет.
Returns: {"authenticated": bool, "userId": <int|null>, "reason": <str|null>}
"""
if not settings.cookie_encryption_key:
@ -510,10 +579,14 @@ async def test_cian_auth(
return {"authenticated": False, "userId": None, "reason": "no_session_in_db"}
state = await cian_session_svc.verify_session(cookies)
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
return {"authenticated": False, "userId": None, "reason": "banned_403"}
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
return {"authenticated": False, "userId": None, "reason": "source_unavailable"}
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
return {"authenticated": False, "userId": None, "reason": "markup_changed"}
if state is None:
return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"}
if state.get("_ban"):
return {"authenticated": False, "userId": None, "reason": "banned_403"}
user_id = state.get("user", {}).get("userId")
return {"authenticated": True, "userId": user_id, "reason": None}
@ -1904,9 +1977,16 @@ async def scrape_cian_detail(
Without it debug-only (no DB write).
"""
_assert_allowed_url(offer_url)
from scraper_kit.cian_exceptions import CianBlockedError
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
try:
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
except CianBlockedError as exc:
# #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке
# нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с
# этого узла» — разные новости для того, кто дёргает ручку руками.
raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc
if enrichment is None:
raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}")
@ -1953,14 +2033,17 @@ async def scrape_cian_newbuilding(
save_newbuilding_enrichment,
)
enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
enrichment = await fetch_newbuilding(
zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
)
if enrichment is None:
raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
saved = False
if house_id is not None:
# save_newbuilding_enrichment — sync (def, returns None); await на sync-функции
# раньше поднимал TypeError на любом вызове с house_id.
# save_newbuilding_enrichment — sync (def, не корутина); await на sync-функции
# раньше поднимал TypeError на любом вызове с house_id. Возвращаемый счёт
# записанного (#2807) этой ручке не нужен — она отвечает фактом сохранения.
save_newbuilding_enrichment(db, house_id, enrichment)
saved = True
@ -2147,7 +2230,10 @@ class HouseIMVBackfillRequest(BaseModel):
)
only_status: str = Field(
default="pending",
description="Обрабатывать дома с этим imv_status. 'transient_error' — retry.",
description=(
"Обрабатывать дома с этим imv_status. По умолчанию 'pending' + автоповтор "
"'transient_error' на половине пакета; явное значение = только этот статус."
),
)
house_id: int | None = Field(
default=None,
@ -2184,7 +2270,12 @@ async def scrape_house_imv_backfill(
batch_size: сколько домов обработать за запуск (default 50).
request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV
реагирует на частые запросы с datacenter-IP. Не снижать < 3s.
only_status: по умолчанию 'pending'. Для retry failed 'transient_error'.
only_status: по умолчанию 'pending' и тогда половина пакета сама уходит на
повтор домов в 'transient_error' с непотраченным лимитом попыток (#2674:
раньше повтор существовал только как этот параметр, и за 41 прогон его
не передали ни разу 1390 домов застряли навсегда). Явное значение
отключает автоповтор и обрабатывает РОВНО указанный статус, включая
дома, исчерпавшие лимит (imv_transient_attempts >= 3).
house_id: обработать один дом (debug).
Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию.
@ -2239,9 +2330,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
@ -2286,18 +2379,33 @@ class ScraperHealthResponse(BaseModel):
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
def _provider_proxy_url(source: str) -> str | None:
"""Effective proxy URL для source (учитывает property-fallback в settings).
def _provider_proxy_url(db: Session, source: str) -> str | None:
"""Узел, который РЕАЛЬНО получит трафик этого источника (#2830).
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты мёртвая
mobileproxy-подписка, #2613).
Раньше здесь стоял `settings.scraper_proxy_url` одна и та же статичная
переменная для всех трёх источников. После #2825/#2831 egress выбирается из
`scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть
страница показывала один узел, а трафик шёл через другой слепое пятно ровно
того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел,
забаненный и Avito, и Cian), только теперь на диагностической странице.
Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`,
`*_detail_backfill`) не «похожая логика», иначе страница снова начнёт
расходиться с трафиком.
Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only,
lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не
его репутацию у Авито/Циана присваивать узлу отказ по чужой пробе значит
выдавать ему чужой бан.
"""
return {
"avito": settings.scraper_proxy_url,
"cian": settings.cian_proxy_url,
"yandex": settings.yandex_proxy_url,
}.get(source)
try:
return resolve_proxy_url(db, source)
except ProxyPoolExhaustedError:
# Пул не пуст, но для source не осталось ни одного здорового небаненного узла.
# resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть
# страница покажет «—», а не статичный env-узел (зелёная строка на месте
# отказа хуже пустой).
return None
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
@ -2413,19 +2521,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None:
@router.get("/scraper/health", response_model=ScraperHealthResponse)
async def scraper_health() -> ScraperHealthResponse:
async def scraper_health(
db: Annotated[Session, Depends(get_db)],
) -> ScraperHealthResponse:
"""Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры.
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
- providers: для avito/cian/yandex proxy host/port, rotate_supported
(#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
- providers: для avito/cian/yandex узел, который пул отдаст ЭТОМУ источнику
сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного
здорового узла), rotate_supported (#2616 шаг 2: всегда False — changeip
mobileproxy-ротация снята, мёртвый аккаунт #2613; живая ASocks-ротация —
POST /admin/proxies/{id}/rotate, #2611, не per-provider-source), best-effort
current_ip (пробинг через этот же узел на ipify).
Все пробинги параллельны (asyncio.gather) и time-boxed суммарно 10с.
"""
proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES}
proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES}
browser, *ips = await asyncio.gather(
_probe_browser_health(),
@ -2583,7 +2695,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 +2710,7 @@ def get_data_quality(
Один проход per source через COUNT(*)...FILTER не N запросов.
Поля listings: description, photo_urls, address, lat/lon, kitchen_area_m2,
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
living_area_m2, ceiling_height_m (все источники, #2699), metro_stations.
houses: total, avito_validated_at%, rating_score%, house_type%.
house_reviews: общий count.

View file

@ -51,16 +51,24 @@ _PHONE_MAX_DIGITS = 15
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
# только в audit-лог (#2497 TODO, теперь закрыт).
_CONSENT_POLICY_VERSION = "2026-07"
#
# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/
# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» →
# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию
# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует
# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет
# это соответствие автоматически, так что рассинхронизация здесь падает в CI.
_CONSENT_POLICY_VERSION = "2026-08-13"
# Снимок точного текста согласия, который видит пользователь при отправке лида.
# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/
# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять
# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки
# будут нести устаревший snapshot под новой version-меткой.
# Снимок точного текста согласия, который видит пользователь при отправке лида
# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом
# с этой фразой). Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/
# src/components/trade-in/v2/LeadForm.tsx) — если текст меняется, здесь нужно
# поднять _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые
# строки будут нести устаревший snapshot под новой version-меткой.
_CONSENT_TEXT_SNAPSHOT = (
"Согласен(-на) на обработку персональных данных в соответствии с "
"Федеральным законом «О персональных данных» № 152-ФЗ"
"Политикой обработки персональных данных"
)

View file

@ -5,10 +5,13 @@
from __future__ import annotations
import asyncio
import calendar
import json
import logging
import math
from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
@ -23,6 +26,8 @@ from app.schemas.trade_in import (
AnalogLot,
AvitoImvSummary,
CianPriceChangeStats,
CoverageProbeInput,
CoverageProbeResponse,
DkpCorridor,
HouseAnalyticsKpi,
HouseAnalyticsResponse,
@ -52,6 +57,27 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404,
# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись
# по коду ответа; третий потребитель (`/r/<token>`, PR-9) разошёлся бы
# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false
# в SQL — для всех существующих строк (retain_until IS NULL) поведение не
# меняется вообще. Не копировать это выражение по месту — только через
# константу/хелпер ниже. Payments retention, PR #2754.
ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())"
def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool:
"""Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД.
tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf)
`.replace(tzinfo=UTC)`, не переизобретается.
"""
now = datetime.now(tz=UTC)
if expires_at.replace(tzinfo=UTC) > now:
return True
return retain_until is not None and retain_until.replace(tzinfo=UTC) > now
def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None:
"""IDOR guard (#690): только владелец оценки или admin могут её читать.
@ -146,6 +172,239 @@ def _resolve_target_house_id(
return None
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────────
# Заказчик открыл сохранённую ссылку (?id=...) и увидел «НЕДОСТАТОЧНО ДАННЫХ»:
# запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825) и
# лежит в БД мёртвой (median_price<=0/NULL), хотя тот же адрес/параметры
# сейчас честно считаются. get_estimate() ниже пытается пересчитать такую
# строку ОДИН раз (throttled) через тот же estimate_quality(), что и POST
# /estimate, и пишет результат В ТУ ЖЕ строку (id/ссылка не меняются). Живую
# строку (median_price>0) этот путь не трогает вообще — сохранённая клиенту
# цена неприкосновенна.
def _precision_to_qc_geo(precision: str | None) -> int | None:
"""Best-effort обратное отображение к estimator._qc_geo_to_precision.
AggregatedEstimate наружу отдаёт только бакетированный address_precision
(house/street/approximate), не сырой dadata.qc_geo (0..5) тот остаётся
приватным для estimate_quality(). При revival нам нужно записать ЧТО-ТО в
колонку dadata_qc_geo, чтобы будущие (уже НЕ revival, обычные) GET той же
теперь-живой строки не откатили address_precision в None. Бакеты 2..5
(settlement/city/region/unknown) неразличимы ПОСЛЕ _qc_geo_to_precision
2 репрезентативно для всех: тот же helper на чтении схлопывает их обратно
в тот же "approximate", наблюдаемое поведение не меняется.
"""
if precision == "house":
return 0
if precision == "street":
return 1
if precision == "approximate":
return 2
return None
def _payload_from_dead_row(row: Any) -> TradeInEstimateInput:
"""Восстанавливает вход оценки из мёртвой сохранённой строки для revival.
Только поля, реально персистящиеся в trade_in_estimates при создании
(address/lat/lon/area_m2/rooms/floor/total_floors/year_built/house_type/
repair_state/has_balcony) CRM-only поля (ownership_type/has_mortgage)
на расчёт не влияют и не нужны здесь. radius_m НИКОГДА не персистится
(payload.radius_m живёт только в рамках одного POST-запроса, ни главный
INSERT, ни _empty_estimate его не пишут) None здесь даёт тот же
default-каскад (DEFAULT_RADIUS_M/FALLBACK_RADIUS_M), что у подавляющего
большинства сохранённых строк (явный радиус выбирает меньшинство).
consent=None + require_consent=False у вызывающего revival не новое
согласие физлица, а служебный recompute уже существующей записи.
"""
return TradeInEstimateInput(
address=row.address,
area_m2=float(row.area_m2),
rooms=row.rooms,
floor=row.floor,
total_floors=row.total_floors,
year_built=row.year_built,
house_type=row.house_type,
repair_state=row.repair_state,
has_balcony=row.has_balcony,
lat=row.lat,
lon=row.lon,
radius_m=None,
consent=None,
)
async def _try_revive_dead_estimate(
db: Session, estimate_id: UUID, row: Any
) -> AggregatedEstimate | None:
"""Пытается пересчитать «мёртвую» (median_price<=0/NULL) строку на месте.
Возвращает свежий AggregatedEstimate (estimate_id ПОДМЕНЁН на исходный
id/ссылка не меняются) при успехе; None если: (а) throttle ещё не истёк /
заявку уже забрал параллельный запрос anti-storm через атомарный
conditional `UPDATE ... RETURNING` ниже (тот же паттерн, что
account_quota.increment, #747): WHERE перепроверяет и «мертва ли строка
сейчас», и «давно ли последняя попытка» НЕПОСРЕДСТВЕННО в БД, а не по
значению, прочитанному раньше в Python TOCTOU-гонка между двумя
параллельными GET невозможна, проигравший просто не дублирует работу;
(б) пересчёт сам дал 0 (по-прежнему недостаточно данных); (в) пересчёт
упал с исключением (сеть/геокод/что угодно). Во всех трёх случаях caller
обязан отдать сохранённую (по-прежнему мёртвую) строку как раньше НЕ 500.
"""
claim = db.execute(
text(
"""
UPDATE trade_in_estimates
SET revival_attempted_at = NOW()
WHERE id = CAST(:id AS uuid)
AND (median_price <= 0 OR median_price IS NULL)
AND (
revival_attempted_at IS NULL
OR revival_attempted_at
< NOW() - make_interval(mins => CAST(:throttle AS integer))
)
RETURNING id
"""
),
{"id": str(estimate_id), "throttle": settings.trade_in_revival_throttle_minutes},
).fetchone()
db.commit()
if claim is None:
logger.info("estimate revival throttled/lost race: id=%s", estimate_id)
return None
from app.services.estimator import estimate_quality
try:
payload = _payload_from_dead_row(row)
result = await estimate_quality(
payload,
db,
created_by=row.created_by,
client_ip=None,
require_consent=False,
)
except Exception:
logger.exception("estimate revival failed: id=%s address=%r", estimate_id, row.address)
return None
temp_id = result.estimate_id
if result.median_price_rub <= 0:
logger.info("estimate revival still insufficient data: id=%s", estimate_id)
db.execute(
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
{"id": str(temp_id)},
)
db.commit()
return None
# estimate_quality() persists under a BRAND NEW uuid (temp_id) — it has no
# notion of "recompute this existing row". Copy the computed OUTPUT fields
# into the ORIGINAL row (id/link contract), then drop the throwaway one.
# INPUT snapshot (address/area/rooms/...) is untouched — it did not change,
# only the outputs were recomputed.
# #incident-2026-08-11: created_at is DELIBERATELY excluded from this SET —
# it is the client's original request date (printed in /history and in
# AggregatedEstimate.created_at, see app/schemas/trade_in.py:317-318), NOT
# a recompute output. It previously got clobbered with the throwaway temp
# row's created_at (=NOW() at recompute time), which also silently
# re-sorted the row to the top of `GET /history ORDER BY created_at DESC`.
# revival_completed_at (migration 256) is the audit trail for "when did a
# revival LAST successfully rewrite this row" — distinct from
# revival_attempted_at (255), which is stamped on every claim regardless
# of outcome (throttle loss / recompute failure included).
db.execute(
text(
"""
UPDATE avito_imv_evaluations
SET estimate_id = CAST(:orig AS uuid)
WHERE estimate_id = CAST(:temp AS uuid)
"""
),
{"orig": str(estimate_id), "temp": str(temp_id)},
)
db.execute(
text(
"""
UPDATE trade_in_estimates SET
median_price = :median_price,
range_low = :range_low,
range_high = :range_high,
median_price_per_m2 = :median_ppm2,
confidence = :confidence,
confidence_explanation = :explanation,
n_analogs = :n_analogs,
analogs = CAST(:analogs_json AS jsonb),
actual_deals = CAST(:deals_json AS jsonb),
sources_used = CAST(:sources_json AS jsonb),
data_freshness_minutes = :freshness,
canonical_address = :canonical_address,
house_cadnum = :house_cadnum,
house_fias_id = :house_fias_id,
dadata_qc_geo = :dadata_qc_geo,
dadata_metro = CAST(:dadata_metro_json AS jsonb),
expected_sold_price = :expected_sold_price,
expected_sold_range_low = :expected_sold_range_low,
expected_sold_range_high = :expected_sold_range_high,
expected_sold_per_m2 = :expected_sold_per_m2,
asking_to_sold_ratio = :asking_to_sold_ratio,
ratio_basis = :ratio_basis,
relaxations = CAST(:relaxations_json AS jsonb),
reliability = :reliability,
revival_completed_at = NOW()
WHERE id = CAST(:id AS uuid)
"""
),
{
"id": str(estimate_id),
"median_price": result.median_price_rub,
"range_low": result.range_low_rub,
"range_high": result.range_high_rub,
"median_ppm2": result.median_price_per_m2,
"confidence": result.confidence,
"explanation": result.confidence_explanation,
"n_analogs": result.n_analogs,
"analogs_json": json.dumps(
[a.model_dump(mode="json") for a in result.analogs], ensure_ascii=False
),
"deals_json": json.dumps(
[a.model_dump(mode="json") for a in result.actual_deals], ensure_ascii=False
),
"sources_json": json.dumps(result.sources_used, ensure_ascii=False),
"freshness": result.data_freshness_minutes,
"canonical_address": result.canonical_address,
"house_cadnum": result.house_cadnum,
"house_fias_id": result.house_fias_id,
"dadata_qc_geo": _precision_to_qc_geo(result.address_precision),
"dadata_metro_json": json.dumps(result.metro_nearest, ensure_ascii=False),
"expected_sold_price": result.expected_sold_price_rub,
"expected_sold_range_low": result.expected_sold_range_low_rub,
"expected_sold_range_high": result.expected_sold_range_high_rub,
"expected_sold_per_m2": result.expected_sold_per_m2,
"asking_to_sold_ratio": result.asking_to_sold_ratio,
"ratio_basis": result.ratio_basis,
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
"reliability": result.reliability,
},
)
db.execute(
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
{"id": str(temp_id)},
)
db.commit()
logger.info(
"estimate revived: id=%s median=%d n=%d confidence=%s reliability=%s",
estimate_id,
result.median_price_rub,
result.n_analogs,
result.confidence,
result.reliability,
)
# created_at on the returned object must mirror the DB row (untouched
# original request date, NOT the temp row's NOW()) — see UPDATE above.
return result.model_copy(update={"estimate_id": estimate_id, "created_at": row.created_at})
@router.post("/estimate", response_model=AggregatedEstimate)
async def estimate(
payload: TradeInEstimateInput,
@ -249,21 +508,22 @@ def get_estimate(
"""
row = db.execute(
text(
"""
f"""
SELECT id, median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
analogs, actual_deals, sources_used, data_freshness_minutes,
expires_at, address, lat, lon,
expires_at, retain_until, address, lat, lon,
area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony,
canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_metro,
expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, created_by, created_at
asking_to_sold_ratio, ratio_basis, created_by, created_at,
relaxations, reliability
FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
AND expires_at > NOW()
AND {ESTIMATE_READABLE_SQL}
"""
),
{"id": str(estimate_id)},
@ -274,6 +534,22 @@ def get_estimate(
_assert_estimate_access(row.created_by, x_authenticated_user)
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем
# пересчитать её на месте (throttled, race-safe — см. докстринг
# _try_revive_dead_estimate) через тот же путь, что и POST /estimate.
# Живую строку (median_price>0) не трогаем вообще. asyncio.run() — sync↔
# async мост (тот же паттерн, что app/scheduler_main.py): get_estimate
# остаётся `def` (Starlette гоняет его в threadpool, как сейчас), поэтому
# ОСТАЛЬНЫЕ синхронные db.execute() ниже по функции не переезжают на event
# loop — только сама попытка revival временно занимает свой поток на время
# await estimate_quality(). Любая ошибка расчёта — не 500: revived is None,
# и функция просто продолжает как раньше, отдавая сохранённую строку.
if row.median_price is None or row.median_price <= 0:
revived = asyncio.run(_try_revive_dead_estimate(db, estimate_id, row))
if revived is not None:
return revived
from app.services.estimator import (
_canonical_sources,
_cv_from_ppm2,
@ -283,11 +559,22 @@ def get_estimate(
_qc_geo_to_precision,
_resolve_target_city,
_source_counts,
rehydrate_search_radius_m,
)
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
# #2632: search_radius_m колонкой не персистится — восстанавливаем его из
# того, что персистится (подпись каскада «радиус расширен до N м», иначе
# размах сохранённых аналогов). Без этого GET отдавал null, фронт падал на
# превью-радиус 1 км и рисовал круг, за которым лежат его же пины (прод
# 2026-08-11: 10 из 10 аналогов вне круга, самый дальний — 4381 м).
persisted_relaxations = list(getattr(row, "relaxations", None) or [])
search_radius_m = rehydrate_search_radius_m(
persisted_relaxations, [a.distance_m for a in analogs]
)
# #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из
# сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
# свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
@ -372,6 +659,7 @@ def get_estimate(
analogs=analogs,
actual_deals=actual_deals,
expires_at=row.expires_at,
retain_until=row.retain_until,
target_address=row.address,
target_lat=row.lat,
target_lon=row.lon,
@ -409,6 +697,18 @@ def get_estimate(
cv=cv,
source_counts=source_counts,
created_at=row.created_at,
# PR #2823 open follow-up (fixed incident 2026-08-10, migration 255):
# relaxations/reliability теперь персистятся — GET-rehydrate больше не
# теряет красный баннер «точность снижена» при открытии по ссылке.
# getattr defensive: старые in-memory test doubles / любая строка без
# этих колонок (не должно случаться после миграции) деградируют в
# дефолт схемы (ok / []), а не падают AttributeError.
relaxations=persisted_relaxations,
reliability=getattr(row, "reliability", None) or "ok",
# #2632: фактический радиус подбора (реконструкция выше). requested_radius_m
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
search_radius_m=search_radius_m,
)
@ -433,14 +733,15 @@ def estimate_pdf(
SELECT id, median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
analogs, actual_deals, sources_used, data_freshness_minutes,
expires_at,
expires_at, retain_until,
address, lat, lon, area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony,
canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_metro,
expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, created_by
asking_to_sold_ratio, ratio_basis, created_by,
relaxations, reliability
FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
"""
@ -453,8 +754,12 @@ def estimate_pdf(
_assert_estimate_access(row.created_by, x_authenticated_user)
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
# PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше
# здесь была независимая Python-проверка expires_at, разошедшаяся с SQL-
# фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при
# годовом retain_until упоминание 24ч в ответе API стало бы ложью.
if not estimate_readable(row.expires_at, row.retain_until):
raise HTTPException(status_code=410, detail="estimate expired")
from app.services.estimator import _qc_geo_to_precision
@ -477,6 +782,7 @@ def estimate_pdf(
analogs=analogs,
actual_deals=actual_deals,
expires_at=row.expires_at,
retain_until=row.retain_until,
target_address=row.address,
target_lat=row.lat,
target_lon=row.lon,
@ -496,6 +802,10 @@ def estimate_pdf(
house_fias_id=row.house_fias_id,
address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
metro_nearest=(row.dadata_metro or []),
# migration 255 — та же сноска «точность снижена», что и на JSON GET,
# теперь и в PDF-регенерации сохранённой оценки (см. get_estimate).
relaxations=list(getattr(row, "relaxations", None) or []),
reliability=getattr(row, "reliability", None) or "ok",
)
input_snapshot = {
"address": row.address,
@ -1924,14 +2234,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
@ -2083,6 +2424,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 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо
# отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь
# прочитает как поломку, а не как честность.
@ -2097,7 +2451,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 = (
@ -2105,11 +2459,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("-", "")
# Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно
@ -2132,6 +2482,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",
@ -2158,3 +2552,268 @@ def get_sales_vs_listings(
data_quality="street_only" if total_deals > 0 else "no_data",
pairs=pairs,
)
# ── Coverage probe (#2894) — бесплатный шаг лэндинга, ЦЕНЫ НЕТ ─────────────────
# До оплаты человек видит, СКОЛЬКО похожих квартир продаётся рядом и КАК БЫСТРО
# они уходят — ни одной рублёвой цифры (см. CoverageProbeResponse docstring).
# Один SQL, ноль внешних вызовов, ноль записей — ручка дешёвая специально: её
# планируется открыть анонимам отдельной задачей (#2895, со своим consent-
# гейтом). RBAC здесь НЕ трогаем — путь остаётся закрытым (не в _PUBLIC_PATHS).
# строго 1000м по ТЗ #2894 (НЕ DEFAULT_RADIUS_M эстиматора — тот допускает fallback до 2000)
COVERAGE_RADIUS_M = 1000
COVERAGE_AREA_TOLERANCE = 0.15 # ±15% площади
COVERAGE_FRESH_DAYS = 14 # объявления не старше 14 дней (тот же канон, что LISTINGS_FRESH_DAYS)
# MAJOR-2 (независимый ревью #2894): days_on_market на проде заполнена практически
# только у yandex (avito/cian/domklik — 0 заполнено) — возраст известен у меньшинства
# когорты, и на тонких когортах "медиана" считалась по 1-2 объявлениям. Ниже порога
# n_with_age медиану не отдаём (null) — не продуктовое решение, а честность при
# заведомо шумной статистике по единичным точкам.
COVERAGE_MIN_AGE_SAMPLES = 5
# 15% свежих yandex-строк имеют days_on_market > 365 (максимум 4261) — это почти
# наверняка мёртвое/забытое объявление, которое никто не снял с публикации, а не
# сигнал о реальном времени экспозиции рынка. Отбрасываем как выброс из медианы.
COVERAGE_MAX_AGE_DAYS = 365
# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД.
COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда")
COVERAGE_GREEN_MIN_N = 8
COVERAGE_YELLOW_MIN_N = 12
def _fold_city(name: str) -> str:
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
}
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
# `listings.city` найденной когорты — оказалось, что `listings.city` это город
# СВИП-контекста скрейпера (миграция 196 — колонка заполняется тем городом,
# который скрейпер обходил, не геокодом самого объявления). Замер на проде:
# в радиусе 1000 м вокруг Берёзовского 90/90 строк имеют city='Екатеринбург';
# вокруг Ревды 74/74 — city='Первоуральск'. Следствие: продавец в Берёзовском
# видел на лэндинге «Екатеринбург», а сами COVERAGE_GREEN/YELLOW_CITIES для
# городов-спутников были НЕДОСТИЖИМЫ (в БД нет ни одной строки с их city).
# Фикс — детерминированный резолв по координатам ЗАПРОСА (никакого участия
# клиента, никакой моды когорты): ближайший центроид города из списка ниже,
# если он в пределах COVERAGE_CITY_MATCH_RADIUS_KM.
#
# Координаты — константа РЯДОМ С РУЧКОЙ, не таблица в БД: единственный
# существующий кандидат на "готовый реестр городов" — это
# frontend/src/lib/city-registry.ts (OBLAST_CITIES) и backend
# geocoder.py::SVERDLOVSK_OBLAST_CITIES — оба хранят ТОЛЬКО текстовые лейблы
# (city_hint для геокодера), без координат. Заводить миграцию + таблицу ради
# статичного справочника из 8 географических центров населённых пунктов —
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
# рядом с порогами, которые они резолвят.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Екатеринбург": (56.8389, 60.6057),
"Верхняя Пышма": (56.9789, 60.5636),
"Берёзовский": (56.9096, 60.8034),
"Среднеуральск": (56.9848, 60.4759),
"Нижний Тагил": (57.9099, 59.9819),
"Каменск-Уральский": (56.4110, 61.9243),
"Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298),
}
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
r_earth_km = 6371.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 2 * r_earth_km * math.asin(math.sqrt(a))
def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
и без участия клиента см. комментарий над `_CITY_CENTROIDS_DEG` про то,
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
больше НЕ допускаются в качестве источника истины.
"""
nearest_city: str | None = None
nearest_km = math.inf
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
distance_km = _haversine_km(lat, lon, clat, clon)
if distance_km < nearest_km:
nearest_km = distance_km
nearest_city = city
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
return "", 0, False
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
return display, threshold, True
@router.post("/coverage", response_model=CoverageProbeResponse)
def coverage_probe(
payload: CoverageProbeInput,
db: Annotated[Session, Depends(get_db)],
) -> CoverageProbeResponse:
"""Бесплатная проба покрытия (issue #2894) — сколько похожих квартир рядом.
Когорта тот же дедуп/cap-канон, что radius-тиры в estimator._fetch_analogs
(rn_dup по (source, source_id), rn_addr cap по адресу, реюз тех же
приватных helper'ов эстиматора — импорт локальный, как и в остальных
ручках этого файла, чтобы не тащить тяжёлый app.services.estimator
в module-level import graph): ST_DWithin 1000м, rooms точное совпадение,
area ±15%, scraped_at не старше 14 дней, is_active.
MAJOR-1 fix (независимый ревью #2894): когорта пробы обязана быть
ПОДМНОЖЕСТВОМ когорты платного эстиматора, не шире её иначе проба честно
отвечает "ok" там, где платный расчёт увидит 0. Три предиката ниже тот же
канон, что estimator._COMMON_WHERE (app/services/estimator.py:5441/5460) и
inline-копия Tier W (estimator.py:5910/5916/5932, radius-тир, откуда реально
берутся аналоги на 1000 м): guard новостроек, geo_precision != 'city'
(#769 Part E — city-centroid листинги без реального адреса), price_rub > 0.
В ответе НЕТ ни одной цены см. CoverageProbeResponse docstring.
MAJOR-2 (независимый ревью #2894): days_on_market на проде фактически
заполнена только у ОДНОГО источника (yandex) это ограничение данных, а
не продуктовое решение. n_with_age в ответе честно считает, по скольким
объявлениям взята медиана; ниже COVERAGE_MIN_AGE_SAMPLES null (см. поле
в ответе). Значения > COVERAGE_MAX_AGE_DAYS (почти наверняка мёртвое
объявление) в расчёт медианы не берутся.
#oblast (2026-08): house_placement_history.exposure_days — реальная (не
цензурированная) экспозиция history-строк НЕ используется здесь: это
house-level архив (join по house_id, не привязан к текущей radius/rooms/
area когорте один-в-один), а не активные листинги в подобранном радиусе;
сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша
в честности (у нас и так честное имя поля age активного объявления, не
срок продажи). См. openQuestions PR #2894 при ревью.
Повторная проверка ручки (2026-08): город больше НЕ берётся из моды
`listings.city` найденной когорты и НЕ зависит от `payload.city_hint`
оба источника ненадёжны (см. комментарий над `_CITY_CENTROIDS_DEG`).
Город резолвится детерминированно по `payload.lat/lon` через
`_resolve_coverage_city` `city_hint` в payload остаётся только
информационным полем (см. `CoverageProbeInput.city_hint`), на результат
не влияет.
"""
from app.services.estimator import _RN_DUP_WINDOW, MAX_ANALOGS_PER_ADDRESS
area_min = payload.area_m2 * (1 - COVERAGE_AREA_TOLERANCE)
area_max = payload.area_m2 * (1 + COVERAGE_AREA_TOLERANCE)
row = (
db.execute(
text(
f"""
WITH base AS (
SELECT
days_on_market,
row_number() OVER (
PARTITION BY address ORDER BY scraped_at DESC
) AS rn_addr,
{_RN_DUP_WINDOW}
FROM listings
WHERE is_active = true
AND rooms = :rooms
AND area_m2 BETWEEN :area_min AND :area_max
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND ST_DWithin(
geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
)
-- MAJOR-1: sync с estimator._COMMON_WHERE (5441) / Tier W (5916)
AND price_rub > 0
-- MAJOR-1: sync с estimator._COMMON_WHERE (5460) / Tier W (5932)
-- guard новостроек, NULL = legacy вторичка до м.011
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
-- MAJOR-1: sync с estimator Tier W (5910/5945-5948, #769 Part E) —
-- исключает city-centroid листинги без реального адреса;
-- IS DISTINCT FROM пропускает NULL (неизвестная точность)
AND (geo_precision IS DISTINCT FROM 'city')
)
SELECT
count(*) AS n_listings,
count(*) FILTER (
WHERE days_on_market IS NOT NULL
AND days_on_market <= :max_age_days
) AS n_with_age,
percentile_cont(0.5) WITHIN GROUP (ORDER BY days_on_market)
FILTER (
WHERE days_on_market IS NOT NULL
AND days_on_market <= :max_age_days
) AS median_age_days
FROM base
WHERE rn_addr <= :max_per_addr
AND rn_dup = 1
"""
),
{
"rooms": payload.rooms,
"area_min": area_min,
"area_max": area_max,
"fresh_days": COVERAGE_FRESH_DAYS,
"lat": payload.lat,
"lon": payload.lon,
"radius": COVERAGE_RADIUS_M,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"max_age_days": COVERAGE_MAX_AGE_DAYS,
},
)
.mappings()
.fetchone()
)
n_listings = int(row["n_listings"]) if row else 0
n_with_age = int(row["n_with_age"]) if row and row["n_with_age"] is not None else 0
median_age = (
round(row["median_age_days"])
if row is not None
and row["median_age_days"] is not None
and n_with_age >= COVERAGE_MIN_AGE_SAMPLES
else None
)
city, threshold, supported = _resolve_coverage_city(payload.lat, payload.lon)
if not supported or n_listings == 0:
status: Literal["ok", "thin", "not_covered"] = "not_covered"
# Nit-fix (повторная проверка #2894): threshold неприменим при
# not_covered — см. CoverageProbeResponse.threshold docstring. Раньше
# поддерживаемый (по координатам) город с пустой когортой отдавал
# реальный порог (8/12) вместе с not_covered — противоречило докстрингу.
threshold = 0
elif n_listings >= threshold:
status = "ok"
else:
status = "thin"
logger.info(
"coverage probe rooms=%d area=%.1f city=%r status=%s n=%d n_with_age=%d",
payload.rooms,
payload.area_m2,
city,
status,
n_listings,
n_with_age,
)
return CoverageProbeResponse(
status=status,
n_listings=n_listings,
median_listing_age_days=median_age,
n_with_age=n_with_age,
radius_m=COVERAGE_RADIUS_M,
city=city,
threshold=threshold,
)

View file

@ -0,0 +1,20 @@
"""GET /api/v1/trade-in/version — build metadata (product version + short SHA +
build date), source `app/core/version.py`.
Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) это не
секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только
process env / уже загруженные при импорте константы (без похода в БД)."""
from __future__ import annotations
from fastapi import APIRouter
from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA
router = APIRouter()
@router.get("/version")
def get_version() -> dict[str, str]:
"""{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}."""
return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE}

View file

@ -599,9 +599,13 @@ class Settings(BaseSettings):
cian_valuation_max_rub: float = 500_000_000
# ── #audit-5: data-age guards ─────────────────────────────────────────────
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
sber_index_max_age_days: int = 35
# #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению
# (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток),
# guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно
# место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог
# берёт из такта самой загрузки (scrape_schedules.default_params.interval_days),
# так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться.
# extra="ignore" в model_config защищает от startup-краха на leftover env var.
# avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
# на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
avito_imv_thin_market_threshold: int = 10
@ -629,6 +633,23 @@ class Settings(BaseSettings):
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
#
# Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр
# известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м,
# 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность
# цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и
# координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт
# денежный — поэтому включение отдельным решением, после замера.
#
# Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с
# заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И
# сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к
# 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить,
# а не оставлять «на вырост».
estimate_quarter_from_coords_enabled: bool = False
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,
@ -836,6 +857,35 @@ class Settings(BaseSettings):
# срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS.
trade_in_lead_retention_days: int = 180
# ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ─────
# trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/
# СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность
# расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см.
# migration 240 докстринг. Отдельная колонка, а не подъём expires_at:
# expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков
# для всех строк, поднять его до года = соврать в документе клиента про
# свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов.
# Единственный источник числа «12 месяцев» на фронте —
# `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты,
# экран после оплаты и SQL продления retain_until при оплате (платёжный
# код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе
# классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране
# «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS.
trade_in_paid_retention_days: int = 365
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────
# Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL)
# сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F,
# PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается
# пересчитать такую строку через тот же estimate_quality(), что и POST
# (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза
# в это число минут на строку — иначе каждый refresh страницы бил бы по
# геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс:
# достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро,
# чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV:
# TRADE_IN_REVIVAL_THROTTLE_MINUTES.
trade_in_revival_throttle_minutes: int = 10
# Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним
# DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама
# крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) —

View file

@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset(
"/api/v1/trade-in/support/anon/messages",
"/api/v1/trade-in/support/anon/unread",
"/api/v1/trade-in/support/anon/read",
# Версионирование (VERSION-файл + build-args, см. app/core/version.py):
# не секрет, читает только process env — быстрая справка для клиента/
# поддержки/смоук-теста, не должна требовать сессию.
"/api/v1/trade-in/version",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед

View file

@ -0,0 +1,83 @@
"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`.
`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env,
запечённые в образ через build-args в `backend/Dockerfile`
(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) там же
ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и
`date -u +%Y-%m-%dT%H:%M:%SZ`.
Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env
тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов,
см. `_find_version_file`), sha фолбэчит на `"dev"`, дата на момент импорта
модуля. Ничего здесь не должно падать при отсутствии env (потребитель
и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`).
Номер версии НЕ дублируется больше нигде в коде читай `APP_VERSION` отсюда.
Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION`
в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и
`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля /
`@/lib/buildInfo` соответственно; не заводи третий.
"""
from __future__ import annotations
import datetime as dt
import os
from pathlib import Path
_DEFAULT_VERSION = "0.0.0"
# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает
# и локальный layout (backend/app/core/version.py → ../../../VERSION ==
# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/
# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION).
_MAX_ANCESTORS = 6
def _find_version_file() -> Path | None:
here = Path(__file__).resolve()
for ancestor in list(here.parents)[:_MAX_ANCESTORS]:
candidate = ancestor / "VERSION"
if candidate.is_file():
return candidate
return None
def _read_version_file() -> str:
path = _find_version_file()
if path is None:
return _DEFAULT_VERSION
try:
text = path.read_text(encoding="utf-8").strip()
except OSError:
return _DEFAULT_VERSION
return text or _DEFAULT_VERSION
def _default_build_date() -> str:
return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
# Читаются один раз при импорте модуля (совпадает с паттерном `settings =
# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем,
# перечитывать на каждый запрос незачем.
APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file()
BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev"
BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date()
def format_build_date_human(build_date: str = BUILD_DATE) -> str:
"""ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF
колонтитул). Никогда не бросает исключение при неразборчивой строке
возвращает её как есть (это футер отчёта, не API-контракт)."""
try:
parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return build_date
return parsed.strftime("%d.%m.%Y")
def product_version_line(product_name: str = "Мера") -> str:
"""`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта
2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF
колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`."""
return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}"

View file

@ -12,7 +12,7 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import sentry_sdk
from fastapi import FastAPI
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
@ -34,6 +34,7 @@ from app.api.v1 import (
support,
team,
trade_in,
version,
)
from app.core.auth_db import get_auth_engine
from app.core.config import settings
@ -65,17 +66,28 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
from app.observability.sentry_scrub import redact_telegram_bot_token
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
sendMessage, а include_local_variables=False ниже первый рубеж защиты)."""
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) +
RetryError fingerprint-стабилизация (glitchtip-noise) см.
app/tgbot_main.py._before_send (та же композиция без последнего шага,
тот бот geocoder не зовёт). PII/token тот же риск: теперь этот процесс
тоже держит TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже первый рубеж защиты. RetryError
этот процесс обслуживает /api/v1/geocode/* (suggest/lookup/reverse),
которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type]
if detokened is None:
return None
return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -209,6 +221,26 @@ def health() -> dict[str, str]:
return {"status": "ok", "environment": settings.environment}
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
# отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только
# /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой
# docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам —
# только pg_isready для postgres) — маршрут пока используется лишь тестами.
# Внешний прод-симптом `HEAD gendsgn.ru/health -> 405` чинится в Site Finder
# (backend/app/main.py, за Caddyfile `handle /health`), не здесь.
# media_type="application/json" — Content-Type совпадает с GET; Content-Length
# сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload)
# — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для
# HEAD, требует совпадения только заголовков представления (Content-Type).
# include_in_schema=False — по той же причине, что и у Site Finder: HEAD-проба это
# инфраструктура, а не контракт API. Здесь codegen-джоба пока нет, флаг ставим
# симметрично, чтобы схема двух бэкендов не разъезжалась.
@app.head("/health", include_in_schema=False)
def health_head() -> Response:
return Response(status_code=200, media_type="application/json")
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
@ -216,6 +248,7 @@ app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"])
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])

View file

@ -25,6 +25,7 @@ import re
from typing import Any
from sentry_sdk.types import Event
from tenacity import RetryError
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
@ -76,6 +77,31 @@ _URL_SECRET_QUERY_RE = re.compile(
)
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
# httpx error-message URL query stabilization (GlitchTip-noise review round 2,
# claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`)
# bakes the FULL request URL — INCLUDING query string — into the exception message:
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
# search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response
# с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После
# app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError
# → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст
# становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть
# на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за
# прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто
# переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по
# нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue,
# невозможные при группировке чисто по stacktrace/culprit).
#
# Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных
# secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле
# сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет).
# Режем query целиком (не только конкретные параметры) — host+path остаются
# стабильными для группировки, "for url '...'" — единственная форма, которую бьёт
# regex (не трогает произвольные строки с `?`, см. тест
# test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched).
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
@ -90,32 +116,34 @@ def _scrub(obj: Any) -> None:
_scrub(item)
def _redact_url_secrets_inplace(obj: Any) -> None:
"""Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных
query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в
КАЖДОЙ строке event не ключ-based: секрет утекает через httpx span
`url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный
request URL), а не только через известные PII-поля формы. Мутирует dict/list
на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`)
сохраняет identity верхнеуровневого `event`, на что опирается контракт
`scrub_pii_event`/`before_send` и существующие тесты (`out is event`).
def _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None:
"""Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)`
по КАЖДОЙ строке event (не ключ-based) общий обход, переиспользуемый и для
URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message
URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают
через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx
содержит полный request URL), а не только через известные PII-поля формы.
Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от
`_redact_strings`) сохраняет identity верхнеуровневого `event`, на что
опирается контракт `scrub_pii_event`/`before_send` и существующие тесты
(`out is event`).
"""
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(value, str):
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[key] = redacted
else:
_redact_url_secrets_inplace(value)
_regex_redact_inplace(value, pattern, replacement)
elif isinstance(obj, list):
for i, value in enumerate(obj):
if isinstance(value, str):
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[i] = redacted
else:
_redact_url_secrets_inplace(value)
_regex_redact_inplace(value, pattern, replacement)
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
@ -123,16 +151,21 @@ def _redact_url_secrets_inplace(obj: Any) -> None:
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact consumer-PII + URL query-string секретов из error event перед отправкой.
"""Redact consumer-PII + URL query-string секретов/nondeterministic-данных из
error event перед отправкой.
Композиция (обе in-place, сохраняют identity `event`): (1) ключ-based
Композиция (все in-place, сохраняют identity `event`): (1) ключ-based
dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход
по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой
строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy
changeip #security-audit). Второй шаг не завязан на конкретные ключи полей —
ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может
оказаться независимо от include_local_variables/traces_sample_rate. Возвращает
event (не None).
changeip #security-audit), (3) full-text regex-проход, стабилизирующий httpx
error-message URL (`for url '...?...'`) убирает переменный query string
(адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на
каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у
`_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей
ловят секрет/переменные данные в frame locals, breadcrumb, exception message
и т.д., где они могут оказаться независимо от
include_local_variables/traces_sample_rate. Возвращает event (не None).
"""
if not isinstance(event, dict):
return event
@ -141,7 +174,8 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
_scrub(request.get("data"))
_scrub(event.get("extra"))
_scrub(event.get("contexts"))
_redact_url_secrets_inplace(event)
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
return event
@ -174,3 +208,61 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No
if not isinstance(event, dict):
return event
return _redact_strings(event) # type: ignore[return-value]
# ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ────────
# tenacity.RetryError.__str__() тащит repr() последнего Future
# (`RetryError[<Future at 0x7f... state=finished raised HTTPStatusError>]`) —
# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py
# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim
# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим
# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ
# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True`
# в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает
# реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само
# несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность
# для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в
# `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1).
#
# Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через
# tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get`
# в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на
# этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query
# string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE`
# не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН
# persistent issue per (culprit, класс исключения-причины) — culprit обязателен:
# БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр.
# geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError)
# схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2
# claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration`
# ставит его в имя logger'а (`logging.getLogger(__name__)`, напр.
# "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ
# `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от
# конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных
# данных — имя типа исключения-причины (небольшой фиксированный словарь вроде
# "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес,
# IP, id объявления и т.п.), иначе проблема повторится в других терминах.
def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None:
"""before_send-хук: схлопывает tenacity.RetryError в один persistent issue per
(источник, тип причины) РАЗНЫЕ источники (geocoder / scraper_kit / будущий
retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает.
Определяет тип exception через `hint["exc_info"]` (реальный объект
исключения, тот же контракт что sentry_sdk передаёт в before_send) не
парсит уже сериализованный event dict, надёжнее к изменениям формата SDK.
`isinstance` (не сравнение `type(...).__name__` со строкой) иначе любой
посторонний класс с совпадающим именем ложно матчился бы, а подкласс
`tenacity.RetryError` промахивался бы. Не-RetryError события возвращает без
изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает).
"""
if not isinstance(event, dict):
return event
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
if not isinstance(exc_value, RetryError):
return event
cause = exc_value.__cause__ or exc_value.__context__
cause_type = type(cause).__name__ if cause is not None else "Unknown"
culprit = event.get("logger") or event.get("transaction") or "unknown"
event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type]
return event

View file

@ -44,7 +44,24 @@ if settings.glitchtip_dsn:
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from app.observability.sentry_scrub import scrub_pii_event
from app.observability.sentry_scrub import (
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise).
Этот процесс гоняет `geocode_missing_listings` (ночной batch, сотни
адресов за прогон) @retry-декорированные Nominatim-хелперы
(app/services/geocoder.py) на исчерпанных ретраях исторически плодили
по отдельному GlitchTip issue на КАЖДЫЙ адрес (RetryError.__str__()
тащит нестабильный repr() Future). См. sentry_scrub docstring.
"""
scrubbed = scrub_pii_event(event, hint)
if scrubbed is None:
return None
return stabilize_retry_error_fingerprint(scrubbed, hint)
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -52,7 +69,7 @@ if settings.glitchtip_dsn:
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_pii_event,
before_send=_before_send,
integrations=[
SqlalchemyIntegration(),
HttpxIntegration(),

View file

@ -153,7 +153,20 @@ class DkpCorridor(BaseModel):
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: int # медиана ₽/м²
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор)
period_months: int # окно поиска сделок
period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date)
# #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/
# median/high — включая city-wide widen, если сработал), НЕ по всей таблице.
# period_months отвечает на «где искали», а не «насколько свежи сделки»: прод
# 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок
# даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы
# враньём в пользу свежести именно для них.
# Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день
# квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974
# сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно
# {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи
# на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад».
# None = сделки без даты (в проде не встречается) — потребитель молчит.
latest_deal_date: date | None = None
class PriceTrendPoint(BaseModel):
@ -196,6 +209,10 @@ class AggregatedEstimate(BaseModel):
analogs: list[AnalogLot]
actual_deals: list[AnalogLot] # реальные продажи last 12 mo
expires_at: datetime
# PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности
# расчёта — тот остаётся expires_at (не путать, см. migration 240).
# NULL = неоплачено (весь текущий трафик, B2B pilots включительно).
retain_until: datetime | None = None
# ── Дополнительные метаданные ──
target_address: str | None = None # geocoded full address
target_lat: float | None = None
@ -206,6 +223,13 @@ class AggregatedEstimate(BaseModel):
# UI (снизить доверие / переспросить город), НЕ персистится в БД
# (ephemeral, только для текущего POST /estimate ответа).
target_city_ambiguous: bool = False
# #2626: True если координаты дал ПОСЛЕДНИЙ тир geocode() — fallback на `houses`
# (см. `app.services.geocoder._local_houses_match`), а не Nominatim/geoportal/
# cadastral. Значит адрес пользователя не совпал буквально (разговорное/усечённое
# имя улицы или отсутствующий корпус), но был однозначно сопоставлен с домом из
# скрейпленных листингов. Честный сигнал для UI («адрес уточнён автоматически»),
# НЕ персистится в БД (ephemeral, как и `target_city_ambiguous`).
target_address_refined: bool = False
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов
@ -271,15 +295,26 @@ class AggregatedEstimate(BaseModel):
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
# search_radius_m — фактический радиус (метры), по которому реально отбирались
# listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может
# ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне):
# сервер молча расширяет 1 км → 2 км при нехватке аналогов (см.
# confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте
# по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально
# использованном радиусе. None на GET-rehydrate (не персистится, старые записи)
# и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае
# fallback'ает на выбор пользователя.
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
# при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад
# #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно,
# контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему
# выбору) — иначе карта врёт о реально использованном радиусе.
# На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ
# (estimator.rehydrate_search_radius_m): из persisted-подписи каскада
# «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из
# размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у
# _empty_estimate (поиск не выполнялся) и у старых строк без расстояний;
# фронт тогда fallback'ает на выбор пользователя, как раньше.
search_radius_m: int | None = None
# requested_radius_m — радиус, с которого поиск НАЧАЛСЯ: явный выбор
# пользователя (TradeInEstimateInput.radius_m) либо DEFAULT_RADIUS_M, если он
# выбрал «Авто». Отдаётся рядом с фактическим, чтобы ответ нёс ОБЕ величины —
# что просили и что получилось — и потребителю не приходилось выводить
# расхождение из своего локального состояния. None на GET-rehydrate:
# radius_m не персистится, а угадывать «просили 1 км» за пользователя —
# ровно та подмена входа результатом, которую чинит это поле.
requested_radius_m: int | None = None
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
# premium_building — целевой дом признан премиальным. Источник — curated overlay
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
@ -315,6 +350,41 @@ class AggregatedEstimate(BaseModel):
cv: float | None = None
source_counts: dict[str, int] = Field(default_factory=dict)
created_at: datetime | None = None
# ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10,
# #oblast-E priority RESTORED same day — see estimator.py module
# docstring for the full 3-way headline-source rule) ──────────────────
# Product requirement: an estimate is ALWAYS surfaced — a thin base sample
# (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First
# estimator.estimate_quality() progressively relaxes the analog SEARCH
# (room-count adjacency → freshness window → novostroyki segment → radius)
# trying to grow the sample past the threshold; if it's STILL thin,
# _price_from_inputs() prefers a usable ДКП deals corridor over a noisy
# thin listings median when one is available (restored #oblast-E
# priority — the Серов repro: 3 listings must not outrank 54 deals), and
# only falls back to the thin listings median itself when no corridor
# exists. Real refusal happens only at genuine zero (no listings AND no
# usable anchor/deals).
# relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага
# ослабления, готовые к показу пользователю как честный дисклеймер рядом с
# confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад
# не понадобился (обычный случай). Возможные значения (дословно, фронт
# может на них завязываться): "снят фильтр по году постройки",
# "учтены студии", "комнатность ±1", "объявления за 60 дней",
# "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м",
# "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП
# deals corridor because the base listings sample was thin — a source
# SWITCH, not a search widening, but surfaced the same way).
# reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs
# (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если
# relaxations непусто (выборка набралась только ценой ослаблений); капается
# на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой
# выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего».
# НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное
# ограничение, каскад не переигрывается из сохранённых analogs). НЕ
# путать с `confidence` (Literal low/medium/high — старая метрика на
# основе уникальных адресов/IQR, см. её собственный докстринг выше).
relaxations: list[str] = Field(default_factory=list)
reliability: Literal["ok", "low", "very_low"] = "ok"
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
area_m2: float | None = None
@ -686,3 +756,77 @@ class LocationIndexResponse(BaseModel):
radius_m: int
nearby_poi: list[NearbyPoiOut]
poi_status: str
class CoverageProbeInput(BaseModel):
"""Вход POST /api/v1/trade-in/coverage (issue #2894) — бесплатная проба покрытия.
lat/lon координаты, уже разрезолвленные фронтом (тот же контракт, что
TradeInEstimateInput.lat/lon geocode делает фронт/автокомплит, эта ручка
сама НИКОГО не геокодирует). Город (и, соответственно, порог ok/thin) для
ответа резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon см.
`app.api.v1.trade_in._resolve_coverage_city`.
city_hint ИНФОРМАЦИОННОЕ поле, на результат НЕ влияет (повторная проверка
#2894, 2026-08). Раньше оно участвовало в резолве города как фолбэк —
убрано вместе с модой `listings.city`: оба источника ненадёжны (`city_hint`
непроверенный клиентский вход, `listings.city` город свип-контекста
скрейпера, не адреса объявления, см. комментарий в trade_in.py). Поле
оставлено в схеме, потому что фронт его уже шлёт в других ручках того же
автокомплита (см. TradeInEstimateInput.city_hint) принимаем и молча
игнорируем, чтобы не ронять запрос лишней 422.
"""
lat: float = Field(ge=-90, le=90)
lon: float = Field(ge=-180, le=180)
rooms: int = Field(ge=0, le=10) # 0 = студия
area_m2: float = Field(gt=10, lt=500)
city_hint: str | None = Field(default=None, max_length=100)
class CoverageProbeResponse(BaseModel):
"""Ответ POST /api/v1/trade-in/coverage.
НАМЕРЕННО без единой цены (ни медианы, ни диапазона, ни /м²) продуктовое
правило issue #2894: бесплатный шаг доказывает, что похожие квартиры есть
и как быстро они уходят, а саму цену продукт продаёт на платном шаге.
status:
- "ok" n_listings >= порога для этого города (зелёный/жёлтый список).
- "thin" когорта непустая, но n_listings < порога.
- "not_covered" город вне зелёного/жёлтого списка ИЛИ когорта пустая
(n_listings == 0) независимо от того, поддерживается город или нет.
median_listing_age_days ЧЕСТНОЕ имя: возраст АКТИВНОГО объявления
(days_on_market на текущий момент), а НЕ срок до продажи. Цензурированная
выборка (активные объявления ещё висят) всегда завышена относительно
реального времени экспозиции проданных не путать со «сроком продажи».
ОГРАНИЧЕНИЕ ДАННЫХ (не продуктовое решение, см. coverage_probe docstring):
days_on_market на проде заполнена практически только у источника yandex
возраст известен у меньшинства строк когорты. n_with_age ниже честный
счётчик, по скольким объявлениям посчитана медиана; при n_with_age < порога
(COVERAGE_MIN_AGE_SAMPLES) median_listing_age_days принудительно null.
n_with_age сколько объявлений когорты реально имеют известный
(non-null, не-выброс) days_on_market и вошли в расчёт медианы. Фронт
обязан иметь возможность не показывать median_listing_age_days при
маленьком n_with_age цифра "медиана" по 1-2 объявлениям не медиана.
threshold n, начиная с которого статус переходит в "ok" для резолвленного
города; 0 всегда, когда status == "not_covered" (порог неприменим ни для
города вне зелёного/жёлтого списка, ни для поддерживаемого города с пустой
когортой), НЕ только для неподдерживаемого города.
city резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon запроса (ближайший центроид из
зелёного/жёлтого списка в пределах `COVERAGE_CITY_MATCH_RADIUS_KM`), не из
`city_hint` и не из моды `listings.city` найденной когорты см.
`app.api.v1.trade_in._resolve_coverage_city`.
"""
status: Literal["ok", "thin", "not_covered"]
n_listings: int
median_listing_age_days: int | None
n_with_age: int
radius_m: int
city: str
threshold: int

View file

@ -19,19 +19,40 @@ from dataclasses import dataclass, field
# golden-parity была доказана против legacy cian_detail-модуля до его удаления,
# #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным
# legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь).
# RealScraperConfig — тот же read-only адаптер над settings, что и остальные
# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url)
# идентичным прежнему прямому импорту settings.
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
from scraper_kit.proxy_errors import NoProxyAvailableError
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
from app.services.scraper_adapters import (
RealMatcherAdapter,
RealProxyProvider,
RealScraperConfig,
)
from app.services.scraper_settings import get_scraper_delay
logger = logging.getLogger(__name__)
class _PoolCurlConfig(RealScraperConfig):
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
services.scraper.environment), а этот бэкфилл запускается ручкой
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
`settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
Флаг рубильник раскатки pool-режима для планировщика, а не решение «этому пути
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
"""
@property
def use_proxy_pool_curl(self) -> bool:
return True
@dataclass
class CianPriceHistoryResult:
checked: int = 0
@ -60,6 +81,11 @@ async def backfill_cian_price_history(
result = CianPriceHistoryResult()
t0 = time.time()
delay = get_scraper_delay("cian") # default 5.0s
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
# (mark_banned на CianBlockedError / mark_health / release).
scraper_config = _PoolCurlConfig()
proxy_provider = RealProxyProvider()
if listing_id is not None:
rows = (
@ -107,9 +133,27 @@ async def backfill_cian_price_history(
url: str = row["source_url"]
try:
# config= обязателен — kit fetch_detail без него не читает cian_proxy_url
# (direct connection), а без прокси datacenter-IP блокируется Cian (#806).
enrichment = await fetch_detail(url, config=RealScraperConfig())
# config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси
# datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула
# (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про
# `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался.
enrichment = await fetch_detail(
url, config=scraper_config, proxy_provider=proxy_provider
)
except NoProxyAvailableError as exc:
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
# логом, который читается как «Циан нас блокирует».
logger.error(
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
"на listing_id=%s (обработано %d из %d)",
exc,
lid,
i,
len(rows),
)
result.errors += 1
break
except Exception as exc:
logger.warning(
"cian_price_history: fetch failed listing_id=%s url=%s: %s",

View file

@ -21,6 +21,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url_sync
logger = logging.getLogger(__name__)
@ -152,8 +153,12 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
try:
# proxies: mobile-proxy egress (#806) — Cian блокирует datacenter-IP даже
# при валидных DMIR_AUTH cookies. Без прокси verify всегда вернёт 403.
# Пусто (env не задан) → прямое подключение (dev/no-op).
_proxy_url = settings.cian_proxy_url
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
# scrape_proxy_source_bans, fallback на settings.cian_proxy_url только если
# пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
# нездоровы для cian -- ProxyPoolExhaustedError (fail-closed, #2616), см. except
# ниже.
_proxy_url = resolve_proxy_url_sync("cian")
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
async with AsyncSession(
impersonate="chrome120",
@ -190,6 +195,17 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
logger.info("Cian cookies verified — userId=%s", user.get("userId"))
return result
except ProxyPoolExhaustedError as exc:
# Fail-closed (#2616, #2825): пул scrape_proxies не пуст, но все узлы забанены
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
logger.error(
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
"cookies НЕ помечены протухшими, retry на следующем такте",
exc,
)
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
except Exception as exc:
# Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
# недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде

View file

@ -37,6 +37,12 @@ DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/sugge
_DADATA_TIMEOUT_S = 8.0
_DADATA_SUGGEST_TIMEOUT_S = 5.0
# Троттлинг WARNING «услуга CLEAN выключена на аккаунте» (#dadata-403-noise) —
# статичная конфигурация аккаунта, не транзиентный сбой. Первый раз за процесс
# логируется на WARNING, дальше — DEBUG, чтобы не заливать логи одним и тем же
# сообщением на каждый /estimate (было: logger.error на каждый запрос).
_clean_disabled_warned = False
@dataclass(frozen=True, slots=True)
class DadataAddressResult:
@ -172,15 +178,29 @@ async def clean_address(address: str) -> DadataAddressResult | None:
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
#
# Это НЕ сбой (аккаунт постоянно живёт с выключенной услугой, enrich_address уже
# graceful-деградирует на suggest — см. ниже) — раньше это било logger.error на
# КАЖДЫЙ пользовательский запрос (164 события/запрос-волна в проде), из-за чего
# ERROR переставал значить «настоящий сбой». WARNING один раз за процесс (дальше —
# DEBUG) сохраняет видимость причины без шума на каждый /estimate.
if status == 403 and (
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
):
logger.error(
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
body_preview,
)
global _clean_disabled_warned
if not _clean_disabled_warned:
logger.warning(
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
"полагайся на suggest-fallback (enrich_address). Ответ: %r "
"(повторы этого сообщения в рамках процесса логируются на DEBUG)",
body_preview,
)
_clean_disabled_warned = True
else:
logger.debug(
"dadata: HTTP 403 CLEAN disabled (уже предупреждено WARNING в этом процессе)"
)
else:
logger.error(
"dadata: HTTP %d — auth/secret rejected. "

File diff suppressed because it is too large Load diff

View file

@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price
from matplotlib.patches import Rectangle
from app.core.config import settings
from app.core.version import product_version_line
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
logger = logging.getLogger(__name__)
@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом)
_BORDER = _LINE
_BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)
# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит
# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не
# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь
# HUD, а не версия PDF-модуля/API.
_REPORT_ENGINE_VERSION = "v2.0.6"
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
@ -243,6 +238,11 @@ _FS_MD = "10.5pt" # базовый текст (body), значения в та
_FS_LG = "13pt" # заголовки страниц (h2, PT Serif)
_FS_XL = "16pt" # главный заголовок cover (h1, PT Serif)
_FS_XXL = "22pt" # крупные ценовые цифры (dual-price блок)
# Намеренное исключение из 6-шаговой шкалы: running-footer — @page margin-box с
# фиксированной высотой (19mm ≈ 53.9pt), делить с mono-мета-строкой/wordmark
# практически нечем (см. _page_footer). 135-ФЗ дисклеймер (Блок 4.2) должен
# влезать в ~380-450 симв. на каждой странице без пятой пустой страницы.
_FS_XXS = "5pt" # ТОЛЬКО 135-ФЗ футер-дисклеймер (_page_footer) — не переиспользовать
# ── Embedded fonts (PT Sans / PT Serif, ParaType, SIL OFL 1.1) ──────────────
@ -505,16 +505,48 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type:
"ДАТА", report_date.strftime("%d.%m.%Y")
)
# Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение
# владельца продукта 2026-08-10, см. app/core/version.py::product_version_line.
# Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска
# реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера»,
# одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под
# существующим masthead-рядом — не растёт по высоте ни при каком контенте
# (клипается по ширине, не переносится), top-margin (25mm) даёт под неё
# запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты.
version_html = (
f'<div style="text-align:right;font-size:6.5pt;letter-spacing:0.03em;'
f"color:{_MUTED_2};font-family:'IBM Plex Mono','DejaVu Sans Mono',monospace;"
f'white-space:nowrap;overflow:hidden;margin-bottom:6pt;">'
f"{_html.escape(product_version_line())}</div>"
)
return (
f"<div>"
f'<div style="display:flex;align-items:center;justify-content:space-between;'
f"flex-wrap:wrap;gap:6pt;border-bottom:2pt solid {brand.primary_color};"
f'padding-bottom:6pt;margin-bottom:9pt;">'
f'padding-bottom:6pt;margin-bottom:3pt;">'
f"{mark_html}"
f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
f"</div>"
f"{version_html}"
f"</div>"
)
# Блок 4.2 юр-требований: должен печататься в подвале КАЖДОЙ страницы отчёта
# (не только cover). Текст утверждён владельцем продукта дословно — не менять
# формулировку без явного запроса. Заведён как модульная константа (не inline
# в _page_footer), чтобы не расползалась по нескольким билдерам страниц.
_PDF_135FZ_FOOTER_NOTICE = (
"Документ содержит индикативный (ориентировочный) расчёт стоимости объекта, "
"сформированный автоматически сервисом «МЕРА». Не является отчётом об оценке "
"по Федеральному закону № 135-ФЗ и не имеет установленной этим законом "
"юридической силы. Не предназначен для использования при ипотечном "
"кредитовании, в судебных разбирательствах, нотариальных действиях и иных "
"случаях, где законом предусмотрено обязательное проведение независимой оценки."
)
def _page_footer(
brand, # type: ignore[no-untyped-def]
report_num: str,
@ -529,12 +561,30 @@ def _page_footer(
строка 1 mono meta ( отчёта / дата / срок действия); тонкая градиентная
линия-разделитель; строка 2 точка акцента + wordmark (brand.name НЕ
хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта.
хардкод «МЕРА», white-label остаётся рабочим); строка 3 135-ФЗ дисклеймер
(Блок 4.2, _PDF_135FZ_FOOTER_NOTICE) печатается на КАЖДОЙ странице, т.к.
footer рендерится один раз как running @page margin-box (см. вызов в
generate_trade_in_pdf), а не per-page. Номер версии продукта здесь
НЕ дублируется единственное место вывода версии в PDF running-header
(_page_header product_version_line()); раньше рядом с wordmark висел
decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной
версией продукта расходился с header на каждой странице, снесён.
page_note старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
удалён вынесен приглушённой строкой НАД HUD-баром, чтобы не терять
полезную для печатного многостраничного отчёта информацию.
#footer-height-budget (2026-08-14, Блок 4.2): @bottom-center margin-box
высотой = page margin-bottom (см. _build_css). Добавление 135-ФЗ текста
(~440 симв.) потребовало И сжать существующий HUD-хром (margin-top
64pt, padding-top 86pt, line-height мета/wordmark строк 1.351.15,
градиент-разделитель margin 6pt 03pt 0 экономия ~13pt), И минимально
поднять @page margin-bottom (19mm21mm, +2mm/+5.67pt) сжатия одного
подвала было недостаточно без деградации до нечитаемого. Риск: margin-bottom
режет тело КАЖДОЙ из 4 страниц потенциальный откат к 5-й почти пустой
странице (регрессия, чинившаяся в 42a50cf8) реальным рендером
(WeasyPrint/Pango, недоступен на Windows-деве) не подтверждено, см. PR.
"""
note_html = ""
if page_note:
@ -566,18 +616,18 @@ def _page_footer(
# тела страницы) и был источником сложности; заменён на простую тонкую
# градиентную линию-разделитель между строками meta/wordmark.
return f"""
<div style="margin-top:6pt;">
<div style="margin-top:4pt;">
{note_html}
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:6pt;
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
color:{_MUTED_2};">
color:{_MUTED_2};line-height:1.15;">
<div style="white-space:nowrap;overflow:hidden;">
ОТЧЁТ <span style="color:{_MUTED};">{_html.escape(report_num)}</span>
<span style="margin-left:16pt;">ДАТА
<span style="color:{_MUTED};">{report_date.strftime("%d.%m.%Y")}</span></span>
{valid_until_html}
</div>
<div style="height:1pt;margin:6pt 0;background:linear-gradient(90deg,
<div style="height:1pt;margin:3pt 0;background:linear-gradient(90deg,
transparent,{_LINE_DOTTED} 15%,{_ACCENT} 50%,{_LINE_DOTTED} 85%,
transparent);"></div>
<div style="display:flex;align-items:center;gap:7pt;min-width:0;">
@ -587,11 +637,13 @@ def _page_footer(
font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
min-width:0;overflow-wrap:anywhere;">
{_html.escape(brand.name).upper()}</span>
<span style="font-size:7pt;letter-spacing:0.08em;color:{_MUTED_2};
flex-shrink:0;white-space:nowrap;">
{_REPORT_ENGINE_VERSION}</span>
</div>
</div>
<div style="margin-top:3pt;font-family:'PT Sans','DejaVu Sans',sans-serif;
font-size:{_FS_XXS};line-height:1.15;color:{_MUTED_2};
overflow-wrap:anywhere;">
{_html.escape(_PDF_135FZ_FOOTER_NOTICE)}
</div>
</div>
"""
@ -1050,6 +1102,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
)
report_num = _report_number(estimate)
# PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа
# (retain_until), НЕ путать со «Срок действия данных» (expires_at,
# актуальность расчёта) над ней — эта строка не трогается. Рендерится
# ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий
# трафик — не видят этой строки вообще, поведение бит-в-бит текущее).
retain_until_row = (
f'<tr><td class="dotted-row">Ссылка доступна до</td>'
f'<td class="bold dotted-row">'
f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}</td></tr>"
if estimate.retain_until is not None
else ""
)
# Короткий адрес (для cover): берём первую часть до запятой
full_address = input_snapshot.get("address", "")
address_short = full_address.split(",")[0:3]
@ -1146,6 +1211,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
<td class="bold dotted-row">{_mono(today.strftime("%d.%m.%Y"))}</td></tr>
<tr><td class="dotted-row">Срок действия данных</td>
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
{retain_until_row}
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
<tr><td class="dotted-row">Год постройки</td>
<td class="bold dotted-row">{year_label}</td></tr>
@ -1229,11 +1295,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int
return min(prices), max(prices)
def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str:
"""#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest
footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals
corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT
estimate.analogs is non-empty (the thin listings that triggered the cession
are still shown below as reference cards never cleared, see estimator.py
#1871 ghost-anchor guard). Same tone/plain-sentence style as the web
LowConfidenceBanner for this scenario. Empty string (no-op) otherwise
covers both "healthy sample" and "genuinely zero, nothing to show" cases."""
if estimate.n_analogs != 0 or not estimate.analogs:
return ""
return (
f'<p style="margin:6pt 0 0 0;font-size:{_FS_SM};color:{_MUTED};line-height:1.35;">'
"Оценка построена по зарегистрированным сделкам Росреестра — подходящих "
"объявлений поблизости почти нет. Объявления ниже приведены справочно, "
"для наглядности рынка.</p>"
)
def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str:
"""#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability`
(estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report
already shows this (LowConfidenceBanner); the PDF stayed silent, a
client-visible discrepancy between the two. Empty string (no-op) when
reliability=='ok' and relaxations is empty the common, unrelaxed case,
byte-identical to the report before these fields existed."""
if estimate.reliability == "ok" and not estimate.relaxations:
return ""
if estimate.relaxations:
detail = "Подбор аналогов расширен: " + ", ".join(
_html.escape(r) for r in estimate.relaxations
)
else:
# relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка,
# которую каскад ослаблений не смог расширить, см. estimator.py
# #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика,
# что и в счётчике выше страницы).
detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)"
return f"""
<div style="margin-top:10pt;padding:9pt 12pt;border-left:3pt solid {_WARN};
background:{_ACCENT_2_SOFT};font-size:{_FS_SM};color:{_INK};line-height:1.35;">
<span style="font-weight:700;color:{_WARN};">Точность оценки снижена.</span>
{detail} данные ниже приведены с этой оговоркой.
</div>
"""
# ── Page 2: Listings (market) ────────────────────────────────────────────────
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
n_total = estimate.n_analogs
# #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw
# estimate.n_analogs is the count of listings that drove the HEADLINE math —
# it is deliberately 0 when the headline was ceded to the ДКП deals corridor
# (estimator.py `deals_headline_due_to_thin_listings`), even though the thin
# listings that triggered that cession are still shown below as display cards
# (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor
# guard comment). Printing raw n_analogs there read as "0 шт." above a
# non-empty examples table — a client-visible contradiction. n_analogs is
# normally >= len(analogs) (analogs is a top-10-capped SUBSET of what
# n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a
# no-op in that common case (count stays the honest FULL n_analogs) and only
# changes anything in this one pathological case, where it falls back to
# "how many are actually shown" instead of the dishonest zero.
n_total = max(estimate.n_analogs, len(estimate.analogs))
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
@ -1292,6 +1418,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
examples_rows = _examples_rows(top5)
heading_html = _section_heading("02", "РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
# #pdf-honesty — see helper docstrings above. Both no-op ("") in the common
# (unrelaxed, non-deals-sourced) case — byte-identical page in that case.
deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate)
reliability_note = _reliability_note_html(estimate, n_total)
return f"""
<div style="page-break-after:always;">
@ -1306,6 +1436,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
<td class="bold" style="text-align:right;">{_mono(f"{n_total} шт.")}</td></tr>
</table>
{deals_sourced_note}
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
<div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
@ -1330,6 +1461,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
</td>
</tr>
</table>
{reliability_note}
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
Диапазон цен в объявлениях</p>
@ -1422,11 +1554,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
# ── Page 3: Deals ────────────────────────────────────────────────────────────
_ROMAN_QUARTER = ("I", "II", "III", "IV")
def deals_as_of_label(estimate: AggregatedEstimate) -> str | None:
"""#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки.
Раньше страница печатала «Период сделок: 08.2025 08.2026» окно ПОИСКА,
посчитанное как `today - period_months*30 today`. Правым концом оно обещало
сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде
(замер 2026-08-12) I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно
по тем сделкам, из которых страница строит диапазон и таблицу примеров.
Гранулярность квартал: rosreestr пишет deal_date = первый день квартала
(#1995, ровно то, что помечает AnalogLot.date_precision == "quarter").
Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания
сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока
такого нет) подписывается месяцем.
None сделок нет либо ни у одной нет даты: подписывать нечего.
"""
dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date]
if not dated:
return None
newest, precision = max(dated, key=lambda p: p[0])
if precision == "day":
return f"по {newest.strftime('%m.%Y')}"
return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}"
def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
n_deals = len(estimate.actual_deals)
today = dt.date.today()
period_start = today - dt.timedelta(days=estimate.period_months * 30)
# #2846: «Период сделок» показывал окно поиска правым концом = сегодня.
# Реальная граница — as-of по показанным сделкам; окна поиска на странице
# больше нет (оно ничего не говорило о данных). None → строку не печатаем.
deals_as_of = deals_as_of_label(estimate)
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
# (тот же _discount_pct, что chip «N%» на обложке), а не хардкод «1018%»,
@ -1503,9 +1665,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
<tr><td style="padding:4pt 0;">Период сделок</td>
<td class="bold" style="text-align:right;">
{_mono(f"{period_start.strftime('%m.%Y')} {today.strftime('%m.%Y')}")}</td></tr>
{f'''<tr><td style="padding:4pt 0;">Сделки</td>
<td class="bold" style="text-align:right;">{_mono(deals_as_of)}</td></tr>'''
if deals_as_of else ""}
</table>
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
@ -1838,7 +2000,11 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
}}
@page {{
size: A4;
margin: 25mm 18mm 19mm 18mm;
/* bottom 19mm21mm (#footer-height-budget, Блок 4.2): +2mm — минимум,
которого не хватило внутри @bottom-center margin-box (высота margin-box
= margin-bottom) даже после сжатия HUD-хрома _page_footer под 135-ФЗ
дисклеймер на каждой странице. См. арифметику в _page_footer(). */
margin: 25mm 18mm 21mm 18mm;
@top-center {{ content: element(runningHeader); vertical-align: bottom; }}
@bottom-center {{ content: element(runningFooter); vertical-align: top; }}
}}

View file

@ -44,6 +44,22 @@ class GeocodeResult:
# результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог
# понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`.
city_ambiguous: bool = False
# #2626: True если результат дал ПОСЛЕДНИЙ локальный тир — fallback на `houses`
# (скрейпленные листинги, см. `_local_houses_match`) — а не Nominatim/geoportal/
# cadastral. Срабатывает, когда в тексте адреса опечатка/сокращение улицы
# («Онуфриева» вместо канонического «Начдива Онуфриева» в ГАР) или отсутствует
# корпус («49» вместо реального «49к1») — houses-фолбэк нашёл ОДНОЗНАЧНЫЙ дом по
# нормализованному совпадению. Честный сигнал вызывающему коду «адрес уточнён
# автоматически», НЕ эвристика на корректность — см. `geocode()`/`_local_houses_match`.
# Houses-фолбэк НЕ пишет свой результат в `geocode_cache` (менее надёжный
# источник координат, чем geoportal/cadastral/Nominatim — #2626 review R2 #4),
# поэтому этот сигнал переживает КАЖДЫЙ повторный запрос того же сырого
# адреса. `geocode_cache` вообще не хранит этот флаг (схему не трогаем) —
# если бы houses-хит когда-нибудь попал в кэш, на cache-hit `address_refined`
# вернулся бы `False` (та же судьба у `city_ambiguous` при cache-hit — см.
# `_geocode_resolve`, восстанавливается `replace()` из текущего вызова, а не
# из кэша).
address_refined: bool = False
# ── EKB bounding boxes ───────────────────────────────────────────────────────
@ -372,6 +388,134 @@ def _names_unrecognized_locality(address: str) -> bool:
return bool(_LOCALITY_MARKER_RE.search(normalized))
# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
# использовал, и если названный топоним туда не дожил — топоним подменён.
#
# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
# сам целевой город.
_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
# города — продукт РФ-only, новых значений у неё не появится (в отличие от
# списка городов области, ради ухода от которого всё и делается).
_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
_STREET_LEVEL_RE = re.compile(
r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
r"|корпус|лит|литера|снт|сад|гск)\b"
)
# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
_LOCALITY_TYPE_WORDS = frozenset(
{
"поселок",
"пос",
"село",
"деревня",
"дер",
"город",
"гор",
"округ",
"муниципальный",
"городской",
"сельское",
"поселение",
"тер",
"территория",
"станция",
"пгт",
"рп",
}
)
_WORD_RE = re.compile(r"[а-я][а-я-]*")
def _fold(value: str) -> str:
"""lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
return " ".join(value.lower().replace("ё", "е").split())
def _claimed_locality(address: str) -> str | None:
"""Имя населённого пункта, названное в тексте адреса, или None.
Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
(страна регион район НП улица дом), пропускаем уровни выше/ниже
НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент имя НП.
None означает «НП не назван» это основной трафик формы («Малышева 30»), и
для него инвариант не применяется вовсе.
"""
for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
segment = segment.strip()
if not segment:
continue
if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
return None # улица/дом: будь НП назван, он шёл бы раньше
if (
_COUNTRY_RE.search(segment)
or _REGION_LEVEL_RE.search(segment)
or _DISTRICT_LEVEL_RE.search(segment)
):
continue
name = " ".join(
w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
)
if name:
return name
return None
def _city_substituted(address: str, result: GeocodeResult) -> bool:
"""True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
Три условия вместе:
1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
2. этого имени НЕТ в адресе, который вернул провайдер то есть топоним не
пережил геокодинг;
3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
собственному ответу провайдера он называет Екатеринбург либо не
называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
тогда «внутри ЕКБ» подтверждают координаты).
Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
Советская ул., 5» ответ «Екатеринбург, Советская улица, 5» имя не дожило,
и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
корректный ответ по посёлку («Кедровка, Екатеринбург, » Nominatim и Yandex
пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
не проходит. Проверяется не география посёлка, а факт «топоним потерян».
Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит»
«М-12 Ачит-Екатеринбург», «Лесной» «Лесной переулок»), считается дожившим
пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
приставки («пионерский, советская»), понижается до `locality` честная
деградация, координаты не теряются.
"""
claimed = _claimed_locality(address)
if claimed is None or _EKATERINBURG_RE.search(claimed):
return False
answer = _fold(result.full_address or "")
if any(word in answer for word in claimed.split()):
return False # топоним дожил до ответа — провайдер искал там, где просили
if not is_within_ekb_bbox(result.lat, result.lon):
return False
answer_locality = _claimed_locality(answer)
return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
@ -598,7 +742,23 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
return oblast_fallback
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях
# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future
# (`<Future at 0x...>` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip
# группирует по этому нестабильному тексту → одна и та же причина (Nominatim
# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев
# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение
# (httpx.HTTPStatusError/TimeoutException) — стабильный ТИП+стек. НО httpx.HTTPStatusError
# сам несёт нестабильный ТЕКСТ (str() содержит полный request URL, включая query
# string с адресом — `for url '...search?q=<адрес>&...'`) — group-стабильность на
# ЭТОМ пути держит `_HTTPX_ERROR_URL_QUERY_RE` в app/observability/sentry_scrub.py
# (`scrub_pii_event`, часть before_send-композиции обоих entrypoint), которая режет
# query string из httpx-style "for url '...'" сообщений (GlitchTip-noise review
# round 2, claim #1 — reraise=True сам по себе НЕ закрывает per-address explosion).
# Отдельно — `stabilize_retry_error_fingerprint` (та же sentry_scrub.py) на случай
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
@ -797,7 +957,8 @@ async def _nominatim_query_city_aware(
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
@ -1060,12 +1221,13 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
ВНИМАНИЕ, цепочки различаются не путать:
* `geocode()` : geoportal cadastral `_cadastral_forward_sync`
Nominatim None. Тира DaData тут НЕТ.
Nominatim `_local_houses_match` (#2626, houses-фолбэк)
None. Тира DaData тут НЕТ.
* `suggest()` : cadastral DaData Nominatim (единственный вызов
`_dadata_suggest`).
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None
оценка не строится. Это сознательный выбор: честный отказ вместо
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, ни houses-фолбэку,
даёт None оценка не строится. Это сознательный выбор: честный отказ вместо
уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет
координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776`
`api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`).
@ -1182,6 +1344,283 @@ def _geoportal_house_match(db: Session, street: str, house: str) -> GeocodeSugge
)
# ── Local `houses` fallback (#2626) — последний тир geocode() ───────────────
# Мотивация: 28/1084 прод-оценок с lat IS NULL — гарантированный ноль аналогов,
# клиент не получает оценку вовсе. Живые примеры (адрес пользователя → ГАР/houses):
# «ул Крестинского, д 49» — «49» голого нет в houses, есть только «49к1»
# (корпус потерян при вводе, houses id 9980 «улица Крестинского, 49к1»);
# «ул Онуфриева, д 24» — houses называет улицу «Начдива Онуфриева» (ГАР),
# пользователь пишет только последнее слово имени.
# Дом уже ЕСТЬ в `houses` (скрейпленные листинги avito/cian/derived/yandex) с
# координатами — Nominatim и ЕКБ-реестры (geoportal/cad_buildings) эти формы не
# резолвят, а houses чаще содержит именно то написание, которым реально пользуются
# люди (агрегировано из объявлений, а не из официального ГАР).
#
# Номер дома в `houses.address` — СВОБОДНЫЙ текст источников (avito/cian/derived/
# yandex_valuation): «улица X, 49к1» / «X ул.,88/2» / «X, 44» — БЕЗ единого формата
# и без «д./дом»-маркера, в отличие от `gendesign_cad_buildings.readable_address`.
# Поэтому здесь — собственная, более широкая нормализация номера (со слэшем
# «88/2» и корпусом «49к1»), а НЕ переиспользование `_HOUSE_NUM`/`_norm_house`
# (те заточены под geoportal/cad_buildings реестры, где «/N» и «корпус N» реже).
_LOCAL_HOUSE_TOKEN_RE = re.compile(
r"(\d+(?:\s*/\s*\d+)?(?:\s*-?\s*(?:к|корп\.?|корпус)\.?\s*-?\s*\d+)?(?:\s*-?\s*[а-яё])?)",
re.IGNORECASE,
)
def _norm_local_house(raw: str) -> str:
"""Канон номера дома для houses-фолбэка.
«49 к 1» / «49-к1» / «49 корпус 1» «49к1»; «88 / 2» «88/2»; «35А» «35а».
"""
s = raw.strip().lower()
s = re.sub(r"\s+", "", s)
s = re.sub(r"корпус|корп\.?", "к", s)
s = re.sub(r"-(к\d+)", r"\1", s)
s = re.sub(r"-([а-яё])$", r"\1", s)
return s
# Хвостовой мусор ПОСЛЕ номера дома — квартира/офис/помещение/подъезд/этаж.
# НЕ включает «корп/корпус/к» (в отличие от `_RE_APT_TAIL` выше) — корпус тут
# ЧАСТЬ номера дома, который должен остаться видимым для `_LOCAL_HOUSE_TOKEN_RE`
# («49к1», «26 к 1» — корпус нельзя терять). Без этой зачистки
# `_extract_local_house_token` (берёт ПОСЛЕДНЕЕ число в строке) находит номер
# квартиры/этажа вместо дома — прод-баг #2626 review R2 #1: «...Педагогическая,
# д 15, кв 11» отдавал дом «11» (координаты ЧУЖОГО здания) вместо «15».
_RE_LOCAL_APT_TAIL = re.compile(
r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|лит|подъезд|этаж)\.?\s*\d.*$",
re.IGNORECASE,
)
def _extract_local_house_token(address: str) -> str | None:
"""Номер дома из ПОЛЬЗОВАТЕЛЬСКОГО адреса — с учётом «/N» и «корпус N» хвостов,
которые `_parse_street_house`/`_HOUSE_NUM` обрезают (см. коммент у
`_LOCAL_HOUSE_TOKEN_RE`). Берём ПОСЛЕДНЕЕ совпадение номер дома в русском
адресе почти всегда в хвосте строки. None, если цифр нет вовсе.
Квартирный/этажный/подъездный хвост зачищается ДО поиска номера
(`_RE_LOCAL_APT_TAIL`) иначе «последнее число в строке» это номер
квартиры/этажа, а не дома (см. докстринг у `_RE_LOCAL_APT_TAIL`).
"""
s = _RE_POSTAL.sub(" ", " ".join(address.lower().strip().split())).strip(" ,.")
if not s:
return None
s = _RE_LOCAL_APT_TAIL.sub(" ", s).strip(" ,.")
if not s:
return None
matches = list(_LOCAL_HOUSE_TOKEN_RE.finditer(s))
if not matches:
return None
return _norm_local_house(matches[-1].group(1))
# Маркеры района/города/страны — обрезаются из `houses.address` перед сравнением
# улицы (`_clean_local_house_street`). Хвостовое сравнение (см. ниже) и без этого
# устойчиво к ЛИШНЕМУ префиксу («р-н Ленинский, мкр. Юго-Западный, улица X» всё
# равно оканчивается на «... улица x» и матчит суффиксом), но тип улицы ПОСЛЕ
# имени («Хрустальногорская ул.») ломает суффикс без явной зачистки типа.
# Хвостовой якорь — lookahead на пробел/конец строки, а НЕ `\b`: «ул.» в самом
# конце сегмента (частая форма в houses.address) заканчивается точкой, а `\b`
# сразу после точки на границе строки не срабатывает (оба «символа» не-\w) —
# тип-слово матчилось бы БЕЗ точки, точка оставалась бы висеть («хрустальногорская .»)
# и ломала «хвостовое» сравнение улицы (реальный прод-кейс: id 13080 houses).
_LOCAL_HOUSE_STREET_TYPE_RE = re.compile(rf"\b(?:{_STREET_TYPE})\.?(?=\s|$)", re.IGNORECASE)
def _clean_local_house_street(segment: str) -> str:
"""«Хрустальногорская ул.» / «улица Начдива Онуфриева» → «хрустальногорская» /
«начдива онуфриева»: lower, без типа улицы, схлопнутые пробелы.
Общая нормализация и для запроса пользователя (уже typeless из
`_parse_street_house`, но повторный проход no-op), и для `houses.address`.
"""
s = _LOCAL_HOUSE_STREET_TYPE_RE.sub(" ", segment.lower())
return " ".join(s.split())
def _row_local_house(address: str) -> tuple[str, str] | None:
"""Разбирает ОДНУ строку `houses.address` на (street_clean, house_norm).
Номер дома ПОСЛЕДНИЙ через-запятую сегмент (во всех живых формах: «X, 49к1»,
«X ул.,88/2», «X, 44»), СОВПАДЕНИЕ С НАЧАЛА этого сегмента (не всей строки)
покрывает и «49к1» целиком, и «35к1 · р-н Академический» (хвостовой мусор
после номера отбрасывается). Известный неполный случай (не встретился в
выборке): номер дома БЕЗ запятой перед ним вернёт None, строка просто не
станет кандидатом (не ложный матч).
"""
segments = [s.strip() for s in address.split(",") if s.strip()]
if len(segments) < 2:
return None
m = _LOCAL_HOUSE_TOKEN_RE.match(segments[-1])
if not m:
return None
house_norm = _norm_local_house(m.group(1))
street_norm = _clean_local_house_street(" ".join(segments[:-1]))
if not street_norm or not house_norm:
return None
return street_norm, house_norm
def _street_tail_matches(row_street_norm: str, query_street_norm: str) -> bool:
"""True если `query_street_norm` — «хвост» (последнее слово/слова) имени улицы
в `houses` «онуфриева» находит «начдива онуфриева» (ГАР-каноничное имя),
регистронезависимо. Точное равенство тоже проходит (частый случай короткие
однословные улицы, «Малышева» == «Малышева»)."""
return row_street_norm == query_street_norm or row_street_norm.endswith(" " + query_street_norm)
# «24к1» → «24» (базовый номер варианта с корпусом/слэшем); «44» (голый номер,
# без суффикса) → None. Используется ТОЛЬКО для sibling-guard (см. ниже) —
# отличить «этот дом однозначно к1» от «этого дома несколько корпусов, а у
# нас в вводе просто нет данных, какой именно».
_LOCAL_HOUSE_VARIANT_BASE_RE = re.compile(r"^(\d+)(?:к\d+|/\d+)$")
def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
"""Последний локальный тир `geocode()` (#2626) — fallback на `houses`
(скрейпленные листинги avito/cian/derived/yandex, own DB table, БЕЗ FDW).
Вызывается ТОЛЬКО когда geoportal/cadastral/Nominatim уже не дали результата.
Допущения, все defensive (при неоднозначности None, не гадаем):
1. Улица матчится «по хвосту» (`_street_tail_matches`) ловит расхождение
разговорного/сокращённого имени («Онуфриева») и канонического ГАР-имени в
houses («Начдива Онуфриева»).
2. Координаты строки-кандидата обязаны лежать в широком ЕКБ-bbox
(`is_within_ekb_bbox_wide`) `houses` НЕ ЕКБ-only реестр (в отличие от
geoportal/cad_buildings): 21% строк с координатами лежат вне области ЕКБ,
местами вплоть до другого региона (#2626 review R2 #2 — прод-пример
«улица Маяковского, 7» в houses это Серов, а не запрошенный
Екатеринбург). `use_local_ekb` в `geocode()` гейтит только ЗАПРОС
пользователя, не страхует от грязной строки-источника.
3. Номер дома сперва точное совпадение; нет пробуем `<номер>к1` (частый
случай: пользователь ввёл «49», у дома есть только корпус «49к1»), но
ТОЛЬКО если среди кандидатов улицы НЕТ других корпусов/дробей этого же
номера («24к2», «24/2» и т.п.) иначе «к1» такая же угадайка, как и
любой другой корпус, и реальные дома могут быть в 250-400м друг от друга
(#2626 review R2 #3, прод-пример «Начдива Онуфриева, 24»: 24к1/24к2/24к3
три разных здания).
4. ЛЮБОЙ шаг, где кандидатов больше одного (после дедупа по округлённым
координатам разные source-строки ОДНОГО дома не в счёт), возвращает
None угадывать нельзя.
SQL дешёвый ILIKE-префильтр по последнему слову улицы (нет индекса на
`houses.address`, но тир последний и редкий не на каждый запрос) с
детерминированным ORDER BY (дедуп по координатам иначе непредсказуемо
выбирал бы, какая из двух ~идентичных source-строк станет ответом
#2626 review R2 #5); вся точная логика (суффикс улицы, bbox, равенство
номера) в Python, что и делает её юнит-тестируемой без реальной БД
(см. `test_geocoder_local_houses_fallback.py`).
Результат этого тира НЕ кэшируется в `geocode_cache` вызывающей стороной
(см. `geocode()`) `houses`-координаты из скрейпленных объявлений менее
надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый и локальный
(#2626 review R2 #4).
"""
query_street_norm = _clean_local_house_street(street)
if not query_street_norm:
return None
query_house_norm = _norm_local_house(house)
if not query_house_norm:
return None
last_word = query_street_norm.split()[-1]
try:
rows = db.execute(
text("""
SELECT address, lat, lon
FROM houses
WHERE address ILIKE CAST('%' || :w || '%' AS text)
AND lat IS NOT NULL AND lon IS NOT NULL
ORDER BY address, id
"""),
{"w": last_word},
).fetchall()
except Exception:
logger.warning(
"local houses fallback query failed for street=%r house=%r",
street,
house,
exc_info=True,
)
return None
# Street-tail + bbox фильтр — один проход, дальше переиспользуется и для
# точного совпадения, и для corpus-1 догадки, и для sibling-guard.
street_rows: list[tuple[str, float, float, str]] = [] # (house_norm, lat, lon, addr)
for r in rows:
parsed = _row_local_house(str(r.address or ""))
if parsed is None:
continue
row_street_norm, row_house_norm = parsed
if not _street_tail_matches(row_street_norm, query_street_norm):
continue
lat, lon = float(r.lat), float(r.lon)
if not is_within_ekb_bbox_wide(lat, lon):
continue
street_rows.append((row_house_norm, lat, lon, str(r.address)))
def _candidates(house_norm: str) -> list[tuple[str, float, float]]:
out: list[tuple[str, float, float]] = []
seen_coords: set[tuple[float, float]] = set()
for row_house_norm, lat, lon, addr in street_rows:
if row_house_norm != house_norm:
continue
coord_key = (round(lat, 4), round(lon, 4)) # ~11m — дедуп источников
if coord_key in seen_coords:
continue
seen_coords.add(coord_key)
out.append((addr, lat, lon))
return out
exact = _candidates(query_house_norm)
if len(exact) == 1:
addr, lat, lon = exact[0]
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
if len(exact) > 1:
logger.info(
"local houses fallback: %d неоднозначных кандидата для %r %r — skip",
len(exact),
street,
house,
)
return None
# Точного номера нет — пробуем «<номер>к1» (корпус потерян при вводе), ТОЛЬКО
# если запрошенный номер — голое число (не пытаемся достраивать «49/2» → «49/2к1»).
if query_house_norm.isdigit():
corpus1 = f"{query_house_norm}к1"
siblings = {
row_house_norm
for row_house_norm, _lat, _lon, _addr in street_rows
if row_house_norm != corpus1
and (m := _LOCAL_HOUSE_VARIANT_BASE_RE.match(row_house_norm)) is not None
and m.group(1) == query_house_norm
}
if siblings:
logger.info(
"local houses fallback: корпус-1 %r неоднозначен — есть другие "
"корпуса/дроби %s — skip",
corpus1,
sorted(siblings),
)
return None
guessed = _candidates(corpus1)
if len(guessed) == 1:
addr, lat, lon = guessed[0]
logger.info("local houses fallback: %r → корпус-1 %r (%s)", house, corpus1, addr)
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
if len(guessed) > 1:
logger.info(
"local houses fallback: корпус-1 %r неоднозначен (%d кандидата) — skip",
corpus1,
len(guessed),
)
return None
def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None:
"""Reverse lookup via gendesign_cad_buildings FDW.
@ -1287,6 +1726,42 @@ async def suggest(
# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
применяется ОДНОЙ точкой на выходе поэтому покрывает разом все источники,
включая попадание в кэш: отравленная запись, записанная до этого фикса,
больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
(обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
Сработал инвариант `confidence="locality"` + `city_ambiguous=True`.
`locality` не косметика: `estimator._geocode_is_coarse` уже трактует его
как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
оценки, (б) через `tasks.geocode_missing` проставляет листингу
`geo_precision='city'`, а этот признак исключает листинг из пула аналогов
(`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
за собой чужие оценки. Координаты НЕ выбрасываются деградация честная и
видимая, а не отказ.
"""
result = await _geocode_resolve(address, db, city_hint)
if result is None or not _city_substituted(address, result):
return result
logger.warning(
"geocode city substitution (#2590): %r%r (%.5f, %.5f) provider=%s"
"названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
address[:80],
(result.full_address or "")[:80],
result.lat,
result.lon,
result.provider,
)
return replace(result, confidence="locality", city_ambiguous=True)
async def _geocode_resolve(
address: str, db: Session, city_hint: str | None = None
) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
Args:
@ -1422,6 +1897,45 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
except Exception:
logger.exception("nominatim geocoder failed")
# 4. Local `houses` fallback (#2626) — САМЫЙ ПОСЛЕДНИЙ тир, до возврата None.
# 28/1084 прод-оценок имели lat IS NULL (гарантированный ноль аналогов) — дом
# был в `houses` (скрейпленные листинги), но не в geoportal/cad_buildings и не
# резолвился Nominatim'ом (разговорное/усечённое имя улицы или отсутствующий
# в вводе корпус). См. `_local_houses_match`. EKB-only гейт — тот же, что у
# geoportal/cadastral (houses — преимущественно ЕКБ-трафик, тот же риск
# коллизии улица+дом с другим городом региона, что и мотивировал #2582);
# координаты строки-кандидата ДОПОЛНИТЕЛЬНО проверяются bbox-ом внутри
# `_local_houses_match` (гейт здесь фильтрует только запрос пользователя,
# не грязь в самой таблице — #2626 review R2 #2).
if use_local_ekb and parsed is not None:
local_street, _parsed_house = parsed
local_house = _extract_local_house_token(address) or _parsed_house
hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house)
if hit is not None:
result = GeocodeResult(
lat=hit.lat,
lon=hit.lon,
full_address=hit.full_address,
provider="cache", # локальный DB-lookup, без внешнего HTTP — как geoportal
confidence="exact",
city_ambiguous=city_ambiguous,
address_refined=True,
)
# НЕ кэшируем: houses-координаты (скрейпленные листинги) менее
# надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый
# и локальный — кэш только продлевал бы жизнь возможной ошибке
# источника (#2626 review R2 #4). Побочный эффект: `address_refined`
# переживает КАЖДЫЙ повторный запрос этого сырого адреса, а не
# только первый (было известным пределом до этого фикса).
logger.info(
"geocode local houses fallback: %s → (%.5f, %.5f) [%s]",
addr_norm,
result.lat,
result.lon,
hit.full_address,
)
return result
return None
@ -1490,7 +2004,8 @@ def _format_reverse_address(addr: dict) -> str | None:
return ", ".join(parts)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.

View file

@ -13,8 +13,10 @@ WHAT this is:
pipeline, run inside ONE transaction so a crash leaves the table untouched.
Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address
COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod
confirmed in migration 040 so address is the real building key). The clean source matters:
COALESCE(short_address, full_address, address) the address is the only building key we
have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on
prod», is no longer true 2 648 of 9 179 rows carry one and the conclusion no longer
rests on it). The clean source matters:
`address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр»
canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66»
( «вайнера66») preferring the clean field lets such a row cluster with its twin. The canon
@ -116,6 +118,43 @@ MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230):
asymmetry merge allowed without a proximity check was invisible in data before; now
«how many merges happened beyond N metres, on which key» is one query.
KEY there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
#2690 asked for a cluster key that does not come from the normalized address, so that two
rows merge on two independent statements of identity rather than one restated twice. Every
field `houses` carries was checked against the live table. None qualifies:
cadastral_number 2 648 filled, ALL 2 648 values DISTINCT collapses nothing. Provenance:
all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are
DaData's answer to our address string, not a second observation of the
building. (The other cadastre we hold, listings.building_cadastral_number,
is the KNN geo-nearest hint 20.1% of its values cover >1 ГАР building;
#2674 refused it as an identity key and that stands.)
house_fias_id 3 678 filled, ALL DISTINCT the FIAS pass merges 0 rows today. Same
DaData provenance.
gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share
the canon (the guid restates it), 17 do not and 5 of those 17 are
>250 m apart, worst 5 064 km. Still circular, still noisy.
zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the
loader sets it WHERE gar_house_guid = <guid>, i.e. it IS the ГАР guid for
4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback
keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon,
193 come through the fallback, and 30 of the 31 pairs >250 m apart do too.
source+ext_house_id, cian_internal_house_id, yandex_jk_id
distinct by construction / 39 / 0 rows nothing to cluster.
coordinates a real independent observation, but not an IDENTITY: neighbours share a
yard. It is already used the only way it can be as the guard.
year_built+total_floors
a FALSE witness, not a corroborator: of the 391 same-canon pairs the
guard cannot judge, only 18 agree on both fields (357 have a NULL), while
306 pairs the guard rejected at >250 m DO agree it would confirm merges
that are provably wrong.
Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the
canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued see the
residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the
guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart
(median 1 084 m) those are not duplicates at all, the canon key is wrong about them.
IDEMPOTENCY:
Every UPDATE/DELETE keys off a temp mapping of (loserkeeper). On a clean table the
mapping is empty every statement touches 0 rows no-op. Re-running is safe.
@ -165,6 +204,14 @@ _COMPLETENESS_EXPR = """
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
#
# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле —
# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина
# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния).
# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого
# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить
# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом
# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей».
_KEEPER_ORDER = f"""
(h.geom IS NOT NULL) DESC,
listing_cnt DESC NULLS LAST,
@ -199,37 +246,17 @@ _CANON_KEY_EXPR = """
"""
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
def _ranked_cte(cluster_key_case: str) -> str:
"""Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row.
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
row shares one fias by construction).
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
- CANON pass True: the canon strips город/район, so same-street-number buildings in
different region-66 towns share a canon; the guard stops the cross-town over-merge.
- FIAS pass False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
geom-first keeper rule simultaneously repairs the broken coordinate).
Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census
(`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about a census
built from its own copy of the clustering would drift from the pass it describes and the
drift would be invisible (it is the same class of error as #2690's cluster key: two
expressions that look alike and are not).
`cluster_key_case` is a STATIC module constant (never runtime data) no value injection.
"""
geo_guard = (
"""
-- GEO GUARD (canon pass only #2187). tradein_canon_addr strips город/район, so two
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
-- keeper (<=250 m covers one building's geocode spread, prod: Мраморская 34к4 dupes at
-- 222 m; region-66 towns are km+ apart 250 m is safe from cross-town). >250 m, or NULL
-- geom on either side, left as separate rows (conservative never over-merges).
AND keeper_geom IS NOT NULL
AND loser_geom IS NOT NULL
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
if apply_geo_guard
else ""
)
return f"""
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
WITH clustered AS (
SELECT
id,
@ -281,7 +308,41 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
FROM dup_houses dh
JOIN houses h ON h.id = dh.id
LEFT JOIN listing_counts lc ON lc.house_id = dh.id
)"""
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
row shares one fias by construction).
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
- CANON pass True: the canon strips город/район, so same-street-number buildings in
different region-66 towns share a canon; the guard stops the cross-town over-merge.
- FIAS pass False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
geom-first keeper rule simultaneously repairs the broken coordinate).
`cluster_key_case` is a STATIC module constant (never runtime data) no value injection.
"""
geo_guard = (
"""
-- GEO GUARD (canon pass only #2187). tradein_canon_addr strips город/район, so two
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
-- keeper (<=250 m covers one building's geocode spread, prod: Мраморская 34к4 dupes at
-- 222 m; region-66 towns are km+ apart 250 m is safe from cross-town). >250 m, or NULL
-- geom on either side, left as separate rows (conservative never over-merges).
AND keeper_geom IS NOT NULL
AND loser_geom IS NOT NULL
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
if apply_geo_guard
else ""
)
return f"""
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
{_ranked_cte(cluster_key_case)}
-- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but
-- DIFFERENT house_fias_id provably different buildings the cluster key collapsed (canon
-- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per
@ -314,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
#
# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY.
# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered
# out is counted here, bucketed by the reason it survived.
#
# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there
# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a
# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the
# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821.
#
# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard
# rejected» are opposite facts:
# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN.
# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are
# NOT duplicates — the canon key is wrong about them (prod 2026-08-10:
# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt.
# residual_cross_fias — provably different buildings (two different ФИАС UUIDs).
# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real
# run; non-zero is a tripwire on the pass itself, not a census entry.
# residual_listings is the user-visible size of the remainder (listings hanging on those rows).
_RESIDUAL_SQL = text(
f"""
{_ranked_cte(_CANON_KEY_EXPR)}
SELECT
count(*) FILTER (WHERE rn > 1) AS residual_rows,
COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings,
count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias,
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL)
AS residual_no_geom,
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far,
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250)
AS residual_mergeable
FROM (
SELECT rn,
COALESCE(lc.listing_cnt, 0) AS lcnt,
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
THEN ST_DistanceSphere(loser_geom, keeper_geom)
END AS dist,
(NULLIF(loser_fias, '') IS NOT NULL
AND NULLIF(keeper_fias, '') IS NOT NULL
AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias
FROM ranked
LEFT JOIN listing_counts lc ON lc.house_id = ranked.id
) r
"""
)
# Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op.
_STEPS: list[tuple[str, str]] = [
# ── Plain re-point (no UNIQUE on the FK column) ───────────────────────────
@ -726,6 +835,15 @@ class DedupMergeResult:
listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper
children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children
children_repointed: int = 0 # survivor child rows moved loser→keeper
# Residual census (#2690): same-canon rows STILL in the table after this run, by reason.
# Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with,
# so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL.
residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets)
residual_listings: int = 0 # listings hanging on them (the user-visible size)
residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates
residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building
residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings
residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0
dry_run: bool = False
duration_sec: float = field(default=0.0)
@ -736,6 +854,12 @@ class DedupMergeResult:
"listings_repointed": self.listings_repointed,
"children_deleted": self.children_deleted,
"children_repointed": self.children_repointed,
"residual_rows": self.residual_rows,
"residual_listings": self.residual_listings,
"residual_no_geom": self.residual_no_geom,
"residual_far": self.residual_far,
"residual_cross_fias": self.residual_cross_fias,
"residual_mergeable": self.residual_mergeable,
"dry_run": int(self.dry_run),
"duration_sec": int(self.duration_sec),
}
@ -853,6 +977,49 @@ def _run_merge_pass(
db.execute(_BACKFILL_ALIASES_SQL)
def _measure_residual(db: Session, result: DedupMergeResult) -> None:
"""Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690).
Read-only; runs after both passes, so it describes the table as the run leaves it (under
dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of
`_run_merge_pass` because the census is about the CANON key only and must be taken once per
call, not once per pass.
Never fails the merge: the merge itself is the product, the census is instrumentation, and a
census that can abort a committed-by-now transaction would be worse than a missing number.
"""
try:
rows = db.execute(_RESIDUAL_SQL).all()
except Exception:
logger.exception("merge_duplicate_houses: residual census failed — counters left at 0")
return
if not rows:
return
r = rows[0]
result.residual_rows = int(r.residual_rows or 0)
result.residual_listings = int(r.residual_listings or 0)
result.residual_no_geom = int(r.residual_no_geom or 0)
result.residual_far = int(r.residual_far or 0)
result.residual_cross_fias = int(r.residual_cross_fias or 0)
result.residual_mergeable = int(r.residual_mergeable or 0)
logger.info(
"merge_duplicate_houses: residual rows=%d listings=%d "
"(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)",
result.residual_rows,
result.residual_listings,
result.residual_no_geom,
result.residual_far,
result.residual_cross_fias,
result.residual_mergeable,
)
if result.residual_mergeable:
logger.warning(
"merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass "
"left work on the table (expected 0)",
result.residual_mergeable,
)
def merge_duplicate_houses(
db: Session,
*,
@ -908,6 +1075,10 @@ def merge_duplicate_houses(
result=result,
)
# Census of what is LEFT (read-only). Runs before the no-op early return on purpose:
# a run that merged nothing is exactly the run whose remainder needs a number.
_measure_residual(db, result)
if result.losers_deleted == 0:
# Clean table — both passes empty. Roll back (we only opened temp tables).
db.rollback()

View file

@ -411,7 +411,8 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
UPDATE houses
SET imv_status = 'ok',
last_imv_attempt_at = NOW(),
imv_error_reason = NULL
imv_error_reason = NULL,
imv_transient_attempts = 0
WHERE id = :hid
"""),
{"hid": house_id},
@ -424,12 +425,19 @@ def _mark_status(
status: str,
reason: str | None = None,
) -> None:
# #2674: счётчик растёт ТОЛЬКО на transient_error — это «сколько раз подряд дом
# падал по временной причине», а не «сколько раз его трогали». no_params /
# no_address / not_found счётчик не двигают: они не занимают retry-слот.
db.execute(
text("""
UPDATE houses
SET imv_status = :s,
last_imv_attempt_at = NOW(),
imv_error_reason = :r
imv_error_reason = :r,
imv_transient_attempts = CASE
WHEN :s = 'transient_error' THEN imv_transient_attempts + 1
ELSE imv_transient_attempts
END
WHERE id = :hid
"""),
{"hid": house_id, "s": status, "r": reason},
@ -443,6 +451,104 @@ _IMVStatus = Literal[
"ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error"
]
# #2674: сколько раз подряд дом может упасть в transient_error, прежде чем
# перестанет занимать retry-слот. Число из замера: после починки сайдкара (04.08)
# доля отказов на попытку — 2/27 и 3/25 (прогоны 3708/3467), т.е. ~10%. На 1039
# застрявших это ~104 повторных отказа на первом проходе, ~10 на втором, ~1 на
# третьем. Порог 3 стоит максимум ~115 слотов ВСЕГО (≈2 прогона) и гарантирует,
# что дом со СВОЕЙ (не инфраструктурной) причиной не крутится в пакете вечно.
# Исчерпавшие лимит не исчезают из наблюдаемости: они остаются imv_status=
# 'transient_error' и считаются как
# WHERE imv_status='transient_error' AND imv_transient_attempts >= 3.
_MAX_TRANSIENT_ATTEMPTS = 3
# Доля пакета под повтор transient_error. Половина — потому что остальные слоты
# после #2674 достаются ТОЛЬКО домам, по которым реально будет запрос к площадке
# (см. _premark_unusable): раньше из 50 слотов до площадки доходили 17 (замер
# головы очереди на 12.08), так что pending на половине пакета всё равно идёт
# быстрее, чем на целом до правки.
_RETRY_SLOTS_SHARE = 0.5
# Дом без пригодных параметров backfill всё равно пометит no_params — но только
# заплатив слотом пакета и паузой request_delay_sec. Тот же вердикт берётся одним
# запросом: нет ни одного объявления с rooms+area (pick_lot_params вернёт {}) ИЛИ
# не из чего взять house_type (_map_house_type вернёт None → «unknown house_type»).
# Причины пишем ТЕМИ ЖЕ строками, что и поштучный путь, — старые разрезы по
# imv_error_reason продолжают работать.
# Условие сознательно УЖЕ питоновского: normalize_house_type схлопывает в None ещё
# и нераспознанный вокабуляр ('other', 'wireframe'), который тут остаётся текстом.
# Промахнуться можно только в безопасную сторону — пометить меньше, чем пометил бы
# поштучный путь.
_PREMARK_UNUSABLE_SQL = text("""
WITH unusable AS (
SELECT h.id,
CASE WHEN NOT EXISTS (
SELECT 1 FROM listings l
WHERE l.house_id_fk = h.id
AND l.rooms IS NOT NULL
AND l.area_m2 IS NOT NULL)
THEN 'no listings with rooms+area'
ELSE 'unknown house_type'
END AS reason
FROM houses h
WHERE h.imv_status = ANY(CAST(:statuses AS text[]))
AND h.lat IS NOT NULL
AND h.lon IS NOT NULL
AND h.address IS NOT NULL
AND (
NOT EXISTS (
SELECT 1 FROM listings l
WHERE l.house_id_fk = h.id
AND l.rooms IS NOT NULL
AND l.area_m2 IS NOT NULL)
OR COALESCE(
NULLIF(TRIM((
SELECT mode() WITHIN GROUP (ORDER BY l.house_type)
FROM listings l
WHERE l.house_id_fk = h.id
AND l.rooms IS NOT NULL
AND l.area_m2 IS NOT NULL)), ''),
NULLIF(TRIM(h.house_type), '')
) IS NULL
)
)
UPDATE houses
SET imv_status = 'no_params',
last_imv_attempt_at = NOW(),
imv_error_reason = unusable.reason
FROM unusable
WHERE houses.id = unusable.id
""")
# Основная очередь: один статус, как и было (only_status — публичный параметр
# admin-API, семантику не трогаем).
_QUEUE_SQL = text("""
SELECT id, address, full_address, lat, lon
FROM houses
WHERE imv_status = :status
AND lat IS NOT NULL
AND lon IS NOT NULL
AND address IS NOT NULL
ORDER BY last_imv_attempt_at NULLS FIRST, id
LIMIT :batch
""")
# Retry-очередь (#2674). Отдельный запрос, а не OR к основной: у pending
# last_imv_attempt_at всегда NULL, поэтому при общем ORDER BY ... NULLS FIRST
# transient_error не попал бы в пакет, пока не кончится pending (по замеру
# 12.08 — 5747 домов ≈ год). Отдельная квота = отдельный проход.
_RETRY_QUEUE_SQL = text("""
SELECT id, address, full_address, lat, lon
FROM houses
WHERE imv_status = 'transient_error'
AND imv_transient_attempts < :max_attempts
AND lat IS NOT NULL
AND lon IS NOT NULL
AND address IS NOT NULL
ORDER BY last_imv_attempt_at NULLS FIRST, id
LIMIT :batch
""")
@dataclass
class HouseIMVBackfillResult:
@ -452,6 +558,23 @@ class HouseIMVBackfillResult:
errors: int = 0
duration_sec: float = field(default=0.0)
status_counts: dict[str, int] = field(default_factory=dict)
# #2674: сколько домов пакета пришло из retry-очереди transient_error и
# сколько помечено no_params до пакета (без запроса к площадке).
retried: int = 0
premarked: int = 0
def _premark_unusable(db: Session, statuses: list[str]) -> int:
"""Пометить no_params дома, по которым запрос к площадке невозможен. → сколько.
Не новое поведение, а тот же вердикт _process_one_house одним запросом: на
12.08 в очереди 1925 таких домов из 5143 (113 без объявлений с rooms+area,
1812 без house_type) каждый занимал слот пакета и паузу, чтобы получить
ответ, который виден в SQL.
"""
res = db.execute(_PREMARK_UNUSABLE_SQL, {"statuses": statuses})
db.commit()
return int(res.rowcount or 0)
def _beat(heartbeat: Callable[[], None] | None) -> None:
@ -480,7 +603,11 @@ async def backfill_house_imv(
batch_size: max houses to process (ignored when house_id given).
request_delay_sec: sleep between Avito API calls (default 5s anti-bot).
only_status: process houses with this imv_status (default 'pending').
Use 'transient_error' to retry failures.
Use 'transient_error' to retry failures. При значении по умолчанию
часть пакета (_RETRY_SLOTS_SHARE) автоматически уходит на повтор
transient_error с непотраченным лимитом попыток (#2674) — явно
переданный only_status этот проход отключает, оператор получает
ровно то, что попросил, включая исчерпавшие лимит дома.
house_id: process a single specific house (debug).
heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES
домов caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies
@ -509,23 +636,45 @@ async def backfill_house_imv(
.all()
)
else:
rows = (
# Повторный проход только на расписании (only_status по умолчанию): явный
# only_status от оператора — это ручной запрос ровно одного статуса.
retry_lane = only_status == "pending"
statuses = [only_status] + (["transient_error"] if retry_lane else [])
result.premarked = _premark_unusable(db, statuses)
if result.premarked:
logger.info(
"house_imv_backfill: %d домов помечены no_params до пакета (нет rooms+area "
"или house_type) — слоты пакета не потрачены",
result.premarked,
)
retry_rows: list = []
if retry_lane:
retry_rows = (
db.execute(
_RETRY_QUEUE_SQL,
{
"max_attempts": _MAX_TRANSIENT_ATTEMPTS,
"batch": int(batch_size * _RETRY_SLOTS_SHARE),
},
)
.mappings()
.all()
)
result.retried = len(retry_rows)
# Недобор retry-очереди (она кончится раньше pending: 1039 против 3218 на
# 12.08) возвращается pending — пакет не простаивает.
fresh_rows = (
db.execute(
text("""
SELECT id, address, full_address, lat, lon
FROM houses
WHERE imv_status = :status
AND lat IS NOT NULL
AND lon IS NOT NULL
AND address IS NOT NULL
ORDER BY last_imv_attempt_at NULLS FIRST, id
LIMIT :batch
"""),
{"status": only_status, "batch": batch_size},
_QUEUE_SQL,
{"status": only_status, "batch": max(batch_size - result.retried, 0)},
)
.mappings()
.all()
)
rows = list(fresh_rows) + list(retry_rows)
result.checked = len(rows)
if not rows:
@ -534,9 +683,11 @@ async def backfill_house_imv(
return result
logger.info(
"house_imv_backfill: %d houses (status=%r delay=%.1fs)",
"house_imv_backfill: %d houses (status=%r retry=%d premarked=%d delay=%.1fs)",
result.checked,
only_status,
result.retried,
result.premarked,
request_delay_sec,
)
@ -610,11 +761,14 @@ async def backfill_house_imv(
result.duration_sec = time.time() - t0
logger.info(
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs %s",
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d "
"retried=%d premarked=%d %.1fs %s",
result.checked,
result.saved,
result.skipped,
result.errors,
result.retried,
result.premarked,
result.duration_sec,
result.status_counts,
)

View file

@ -71,11 +71,18 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
"commission_year": ["cian_serp", "yandex_realty_nb"],
"commission_month": ["yandex_realty_nb"], # raw RU month name
"developer_name": ["cian", "yandex_realty_nb"],
"has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag
# #2674 (хвост): запись про «панораму» удалена вместе с колонкой (мигр. 259).
# В отличие от ceiling_height ниже, правило было ИСПОЛНИМО — колонка существовала,
# источник её писал. Разрешать было нечего: yandex_valuation отдавал False всегда,
# потому что слова «панорам» на странице оценки нет (0 true из 1536 страниц на
# проде; живая проверка боевым трактом 13.08.2026 не нашла его и в сыром HTML).
"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 +95,10 @@ LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = {
"area_m2": ["cian_serp", "avito_detail"],
"living_area_m2": ["cian_serp"],
"kitchen_area_m2": ["cian_serp", "avito_detail"],
"ceiling_height": ["cian_detail"],
# #2699: ключ — имя РЕАЛЬНОЙ колонки. Был "ceiling_height" (019), которую
# эстиматор не читает; канон — ceiling_height_m, и её пишут все три источника,
# а не только cian_detail.
"ceiling_height_m": ["cian_detail", "avito_detail", "yandex_detail"],
"floor": ["cian_serp", "avito_detail"],
"total_floors": ["cian_serp", "avito_detail"],
"year_built": ["cian_serp"],

View file

@ -76,6 +76,7 @@ def match_or_create_house(
year_built: int | None = None,
building_cadastral_number: str | None = None,
source_url: str | None = None,
city: str | None = None,
) -> tuple[int | None, float, str]:
"""Match existing house or create new canonical record.
@ -88,6 +89,18 @@ def match_or_create_house(
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
Args:
city: город-цель развёртки, собравшей эту карточку (`save_listings(city=)`,
он же `listings.city`) НЕЗАВИСИМОЕ от строки адреса наблюдение города
(#2777). Нужен ровно там, где адресный токен города бессилен: областной
формат Avito SERP «ул. Кирова,4» города не называет, а бескоординатный
ключ Tier-2a вырождается в один нормализованный адрес и становится
глобально уникальным. Опционален: вызывающие без sweep-контекста
(estimate-путь, ad-hoc скрипты) передают None поведение прежнее.
Про независимость: в #2690 доказано, что усиление ключа полем, выведенным
из ТОЙ ЖЕ строки адреса (gar_house_guid), защиту отменяет, а не усиливает
здесь признак приходит другим каналом (какой город запрашивала развёртка).
Returns:
(house_id, confidence [0.0, 1.0], method {
'cadastr_exact', 'source_exact', 'fingerprint',
@ -212,18 +225,40 @@ def match_or_create_house(
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
# scope, only relevant once the oblast sweep is enabled.
#
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
# residual needs sweep-context and is out of this fix's scope.
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
# EKB happy-path is byte-identical: the guard fires ONLY when the card's city is known to
# be non-ЕКБ AND no coords disambiguate. ЕКБ cards and cards with no city signal at all
# (resolved city None) run Tier-2a/2b exactly as before.
#
# #2777: the residual the comment above used to describe as out of scope — a BARE oblast
# card ('ул. Кирова,4', today's Avito SERP format) — is closed here by the `city` kwarg.
# The sweep already knows which city it was crawling and stamps it on the listing row
# (save_listings → listings.city); that observation just never reached this guard, so
# 26 of 26 measured cross-city stitches went through Tier 2a on a coord-less key. Prod
# 2026-08-10: 7303 of 21603 aliases are coord-less keys, 6047 of them carry no city token
# at all — i.e. a globally unique 'street + number' that ANY city's card can hit.
# The address token still wins when present (it describes THIS card; the sweep city
# describes the batch).
_resolved_city = None
if lat is None and lon is None:
_resolved_city = resolve_city_token(norm_addr) or (normalize_address(city) or None)
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
# Известный потолок правки, названный числом (прод 2026-08-10, 35 домов со
# «сшитыми» городами по метке listings.city):
# • 30 из 35 — приходящая карточка областная, алиас принадлежит дому другого
# города → страж срабатывает;
# • 5 из 35 — приходящая карточка ЕКБ, а алиас завёл областной дом. Тут страж
# молчит: города владельца алиаса мы не знаем (в house_address_aliases его
# нет). Апгрейд — city-ключ у алиаса, но это миграция + перекладка 7303
# бескоординатных ключей, и до неё нужен журнал слияний (#2690 п.1).
# • посёлки внутри ЕКБ-развёртки (Кедровка, Б. Седельниково, Решёты — 12-17 км
# разброса) этим признаком НЕ ловятся вовсе: у них тот же город-цель
# «Екатеринбург». Гранулярность независимого наблюдения — город, не населённый
# пункт; это ограничение данных, а не недоделка стража.
if _skip_oblast_alias:
logger.info(
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
"house tier2a/2b skip: coord-less non-ЕКБ city %r (sweep_city=%r) na=%r src=%s",
_resolved_city,
city,
norm_addr,
ext_source,
)

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