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
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:
commit
87258075a2
317 changed files with 41573 additions and 7642 deletions
|
|
@ -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 → НЕ триггерит деплой
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ cd frontend && npm install --legacy-peer-deps --no-audit --no-fund
|
|||
- Pre-push check: `git diff main..HEAD -- frontend/package.json frontend/package-lock.json` — если только один из двух тронут → STOP, regen lock.
|
||||
- Imports без deps entry (TypeScript авто-resolve через transitive) — **latent bomb** до first `npm ci`.
|
||||
- Reference incident: PR #344 (2026-05-17) добавил `lucide-react` без regen lockfile → deploy #135 fail → P0 hotfix PR #345 (commit `6ee20294f2`).
|
||||
- **То же правило для `tradein-mvp/frontend/`** (#2770): там теперь тоже tracked `package-lock.json` + `npm ci` в Dockerfile и в `ci-tradein.yml`. До #2770 лока не было вовсе (лежал `pnpm-lock.yaml`, из которого никто не ставил), и состав зависимостей прод-образа определялся датой сборки.
|
||||
|
||||
## Prettier / lint
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,43 @@ paths:
|
|||
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout
|
||||
|
||||
-- DDL здесь (idempotent)
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## lock_timeout при блокирующем DDL (обязательно)
|
||||
|
||||
Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) /
|
||||
`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';`
|
||||
сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml`
|
||||
на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL).
|
||||
|
||||
**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX`
|
||||
на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической
|
||||
psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за
|
||||
ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только
|
||||
ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный
|
||||
отказ, повторить позже) вместо тихой очереди перед приложением.
|
||||
|
||||
**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена
|
||||
мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду,
|
||||
поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит
|
||||
очередь запросов приложения.
|
||||
|
||||
**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует):
|
||||
`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через
|
||||
VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение,
|
||||
оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально
|
||||
в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и
|
||||
обрежет `CONCURRENTLY` ниже по файлу.
|
||||
|
||||
Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в
|
||||
`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX
|
||||
CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий.
|
||||
|
||||
## Idempotency (обязательно)
|
||||
|
||||
- `CREATE TABLE IF NOT EXISTS`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
|
|
@ -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 "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
25
Caddyfile
25
Caddyfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
backend/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
.coverage
|
||||
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
|
||||
coverage.xml
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -146,9 +146,6 @@ class Settings(BaseSettings):
|
|||
# Path to a pre-captured Playwright storage_state.json (committed in repo,
|
||||
# used by worker to skip cold-start WAF challenge).
|
||||
scrape_kn_state_path: str = "data/playwright_state.json"
|
||||
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
|
||||
# Empty string = endpoint disabled.
|
||||
scrape_admin_token: str = ""
|
||||
|
||||
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
|
||||
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
"""Shared FastAPI dependencies."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def verify_admin_token(
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
) -> None:
|
||||
"""Verify admin token header. Raises 503 if not configured, 401 if invalid or missing."""
|
||||
if not settings.scrape_admin_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
|
||||
)
|
||||
if x_admin_token != settings.scrape_admin_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
|
||||
|
||||
|
||||
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]
|
||||
|
|
@ -48,7 +48,7 @@ from app.core import auth_db
|
|||
from app.core.audit_middleware import audit_log_middleware
|
||||
from app.core.auth import get_role
|
||||
from app.core.config import settings
|
||||
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||
from app.observability.sentry_scrub import scrub_event
|
||||
from app.services.auth_session import resolve_session_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers):
|
|||
# (middleware, маршруты) видели активный client с самого старта процесса.
|
||||
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
|
||||
if settings.glitchtip_dsn:
|
||||
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review):
|
||||
# Starlette-интеграция кладёт request.data на transaction-scope так же, как
|
||||
# на error-scope, поэтому голый scrub_sensitive_query (только URL) на
|
||||
# before_send_transaction оставлял бы PII-канал открытым при любом
|
||||
# glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring).
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
environment=settings.environment,
|
||||
|
|
@ -82,8 +87,14 @@ if settings.glitchtip_dsn:
|
|||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||
profiles_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send=scrub_sensitive_query,
|
||||
before_send_transaction=scrub_sensitive_query,
|
||||
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753).
|
||||
# Дефолт SDK — True: при любом исключении кадр несёт значения аргументов
|
||||
# (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event
|
||||
# сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не
|
||||
# дополнительная мера, а условие, без которого скраб не полон.
|
||||
include_local_variables=False,
|
||||
before_send=scrub_event,
|
||||
before_send_transaction=scrub_event,
|
||||
integrations=[
|
||||
StarletteIntegration(),
|
||||
FastApiIntegration(),
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (иначе сеть
|
||||
|
|
|
|||
|
|
@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N
|
|||
["Земля", _fmt_money_signed(financial.get("land_rub"))],
|
||||
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
|
||||
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
|
||||
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
|
||||
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
|
||||
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
|
||||
|
|
|
|||
|
|
@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str:
|
|||
["Земля", _fmt_money_signed(financial.get("land_rub"))],
|
||||
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
|
||||
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
|
||||
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
|
||||
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
|
||||
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
|
||||
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
|
||||
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
|
||||
покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
|
||||
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
|
||||
покрытие domrf↔objective 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) — high≥3 / medium≥2 / 1 → low
|
||||
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример).
|
||||
• domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics
|
||||
docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён.
|
||||
• domrf_coverage — имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой
|
||||
из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга
|
||||
domrf↔objective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08:
|
||||
медиана 40%, среднее 31.7%. Низкое покрытие → рынок и конкуренция оценены хуже.
|
||||
• history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий
|
||||
ряд → связь rate↔sales / тренды не установлены.
|
||||
• 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-пример «покрытие domrf↔objective 2.5%»). None → low.
|
||||
#2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие
|
||||
маппинга domrf↔objective, но такого продьюсера НЕТ и не было: слот
|
||||
`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% покрытия domrf↔objective», как было написано здесь
|
||||
раньше, — другая величина другого порядка.
|
||||
|
||||
Ключ фактора НЕ переименован намеренно: его читает фронт
|
||||
(`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: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||
domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1]. Имя ключа
|
||||
историческое — про маппинг domrf↔objective, продьюсера для которого
|
||||
нет и не было (#2464-H, см. _coverage_factor).
|
||||
history_months: глубина ряда (мес).
|
||||
confounded: True, если окно ряда пересекает шок-период (PR2).
|
||||
advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда).
|
||||
|
|
|
|||
|
|
@ -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 здесь НЕ выводим — нет
|
||||
дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и
|
||||
величина у неё другая: не «покрытие маппинга domrf↔objective ~2.5%», как
|
||||
было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective —
|
||||
замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%.
|
||||
|
||||
Порядок веток оставлен: если продьюсер появится, приоритет у него.
|
||||
"""
|
||||
if supply_layers is not None:
|
||||
coverage = supply_layers.get("domrf_coverage")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
|||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
|
||||
from app.core.config import settings
|
||||
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||
from app.observability.sentry_scrub import scrub_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -23,6 +23,11 @@ logger = logging.getLogger(__name__)
|
|||
# чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного
|
||||
# вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента.
|
||||
if settings.glitchtip_dsn:
|
||||
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review,
|
||||
# см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker
|
||||
# вообще не скрабил error-события (тут before_send не было), а
|
||||
# before_send_transaction был на голом scrub_sensitive_query (только URL) —
|
||||
# оба канала пропускали PII.
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
environment=settings.environment,
|
||||
|
|
@ -30,7 +35,12 @@ if settings.glitchtip_dsn:
|
|||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||
profiles_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send_transaction=scrub_sensitive_query,
|
||||
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753) — см.
|
||||
# app/main.py: скраб сверяет ИМЕНА ключей, а имя переменной произвольно.
|
||||
# В воркере вектор шире: задачи держат в кадрах сырые ответы источников.
|
||||
include_local_variables=False,
|
||||
before_send=scrub_event,
|
||||
before_send_transaction=scrub_event,
|
||||
integrations=[
|
||||
CeleryIntegration(monitor_beat_tasks=True),
|
||||
SqlalchemyIntegration(),
|
||||
|
|
|
|||
146
backend/tests/_sentry_wiring_probe.py
Normal file
146
backend/tests/_sentry_wiring_probe.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Проба проводки GlitchTip: запускается ОТДЕЛЬНЫМ процессом из test_sentry_init.py.
|
||||
|
||||
Зачем подпроцесс. `app/main.py` и `app/workers/celery_app.py` зовут
|
||||
`sentry_sdk.init()` на импорте модуля и только при непустом `GLITCHTIP_DSN`. В
|
||||
процессе pytest этот путь недостижим (модуль уже в `sys.modules`, DSN пуст), а
|
||||
если бы и был достижим — глобальный клиент SDK остался бы живым для всех
|
||||
последующих тестов. Отдельный процесс даёт настоящую инициализацию и умирает
|
||||
вместе с ней.
|
||||
|
||||
Наружу не уходит ничего: `capture_envelope` подменяется ДО первого события, а
|
||||
DSN в тесте указывает на несуществующий хост. Значения-маркеры генерируются
|
||||
случайно на каждый запуск — кадр стека несёт не только переменные, но и строки
|
||||
исходника, поэтому литерал в коде пробы сделал бы проверку вечно красной.
|
||||
|
||||
stdout — одна строка JSON: counts / scrub_handler_entries / markers / payloads
|
||||
(тело каждого канала отдельно — см. `main`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import sentry_sdk
|
||||
|
||||
_FAILURE_CAP = 3
|
||||
|
||||
|
||||
def _fresh(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _leaking_event(markers: dict[str, str]) -> dict[str, Any]:
|
||||
"""Событие с ПДн в трёх местах, которые закрывает scrub_event."""
|
||||
return {
|
||||
"message": "sentry-wiring-probe",
|
||||
"level": "error",
|
||||
"request": {
|
||||
"data": {"phone": markers["phone"], "message": markers["free_text"]},
|
||||
"url": f"https://example.invalid/probe?api_key={markers['url_secret']}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main(module: str) -> int:
|
||||
importlib.import_module(module) # ← здесь отрабатывает sentry_sdk.init()
|
||||
|
||||
client = sentry_sdk.get_client()
|
||||
sent: list[str] = []
|
||||
|
||||
def _record(envelope: Any) -> None:
|
||||
buf = io.BytesIO()
|
||||
envelope.serialize_into(buf)
|
||||
sent.append(buf.getvalue().decode("utf-8", "replace"))
|
||||
|
||||
client.transport.capture_envelope = _record # type: ignore[union-attr,method-assign]
|
||||
|
||||
markers = {
|
||||
"phone": _fresh("probe-phone"),
|
||||
"free_text": _fresh("probe-free-text"),
|
||||
"url_secret": _fresh("probe-url-secret"),
|
||||
"local_var": _fresh("probe-local-var"),
|
||||
}
|
||||
|
||||
# 1. Канал ошибок (before_send).
|
||||
sentry_sdk.capture_event(_leaking_event(markers))
|
||||
after_error = len(sent)
|
||||
|
||||
# 2. Канал транзакций (before_send_transaction) — Starlette кладёт
|
||||
# request.data на transaction-scope так же, как на error-scope.
|
||||
transaction = _leaking_event(markers)
|
||||
transaction["type"] = "transaction"
|
||||
transaction["transaction"] = "sentry-wiring-probe-tx"
|
||||
transaction["contexts"] = {"trace": {"trace_id": "0" * 32, "span_id": "0" * 16}}
|
||||
transaction["start_timestamp"] = "2026-01-01T00:00:00.000000Z"
|
||||
transaction["timestamp"] = "2026-01-01T00:00:01.000000Z"
|
||||
transaction["spans"] = []
|
||||
sentry_sdk.capture_event(transaction)
|
||||
after_transaction = len(sent)
|
||||
|
||||
# 3. Локальные переменные кадра стека (include_local_variables). Имя
|
||||
# переменной произвольное — ключевой скраб такое не ловит по построению.
|
||||
def _raise_with_local() -> None:
|
||||
applicant_note = markers["local_var"] # noqa: F841 — ради кадра стека
|
||||
raise RuntimeError("sentry-wiring-probe boom")
|
||||
|
||||
try:
|
||||
_raise_with_local()
|
||||
except RuntimeError:
|
||||
sentry_sdk.capture_exception()
|
||||
after_exception = len(sent)
|
||||
|
||||
# 4. Сбой самого скраба не должен порождать ВТОРОЕ событие: иначе строка
|
||||
# журнала об отказе уходит в мониторинг через LoggingIntegration, снова
|
||||
# попадает в скраб, снова падает — рекурсия (#2753; на коде до фикса
|
||||
# проверено: не завершается, 1000+ вложенных трассировок за минуту).
|
||||
# Считаем ВХОДЫ в обработчик; после _FAILURE_CAP перестаём падать, иначе
|
||||
# проба на сломанном коде висела бы вместо того, чтобы честно покраснеть.
|
||||
from app.observability import sentry_scrub
|
||||
|
||||
original = sentry_scrub.scrub_pii_event
|
||||
entries: list[int] = []
|
||||
|
||||
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||
entries.append(1)
|
||||
if len(entries) > _FAILURE_CAP:
|
||||
return None
|
||||
raise RuntimeError("sentry-wiring-probe scrubber failure")
|
||||
|
||||
sentry_scrub.scrub_pii_event = _boom # type: ignore[assignment]
|
||||
try:
|
||||
# Без маркеров: это событие по замыслу уходит НЕОЧИЩЕННЫМ ("as-is").
|
||||
sentry_sdk.capture_event({"message": "sentry-wiring-probe-failure", "level": "error"})
|
||||
finally:
|
||||
sentry_scrub.scrub_pii_event = original # type: ignore[assignment]
|
||||
after_scrub_failure = len(sent)
|
||||
|
||||
# Тело каждого канала — отдельно: иначе утечка из одного (напр. локальные
|
||||
# переменные шага 3 несут те же маркеры, что тело запроса шага 1) красит
|
||||
# чужую проверку и мешает понять, что именно сломано.
|
||||
bounds = [0, after_error, after_transaction, after_exception, after_scrub_failure]
|
||||
names = ["error", "transaction", "exception", "scrub_failure"]
|
||||
spans = dict(zip(names, itertools.pairwise(bounds), strict=True))
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"counts": {name: end - start for name, (start, end) in spans.items()},
|
||||
"scrub_handler_entries": len(entries),
|
||||
"markers": markers,
|
||||
"payloads": {
|
||||
name: "\n".join(sent[start:end]) for name, (start, end) in spans.items()
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1]))
|
||||
|
|
@ -15,10 +15,6 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from app.main import app
|
||||
|
||||
# Токен для тестов (не реальный)
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
|
||||
|
||||
|
||||
def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None):
|
||||
"""Создать mock db session с преднастроенными ответами."""
|
||||
|
|
@ -97,7 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]:
|
|||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_create_job_pilot_returns_job_id() -> None:
|
||||
"""POST /cadastre/jobs scope=pilot → job_id + targets_total."""
|
||||
quarters_50 = [f"66:41:{i:07d}" for i in range(50)]
|
||||
|
|
@ -114,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None:
|
|||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs",
|
||||
json={"scope": "pilot"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
|
|
@ -127,7 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_create_job_manual_list() -> None:
|
||||
"""POST /cadastre/jobs scope=manual_list с явным списком."""
|
||||
db = _make_mock_db()
|
||||
|
|
@ -146,7 +139,6 @@ def test_create_job_manual_list() -> None:
|
|||
"scope": "manual_list",
|
||||
"quarters": ["66:41:0303161", "66:41:0303162"],
|
||||
},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
|
|
@ -156,7 +148,6 @@ def test_create_job_manual_list() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_create_job_manual_list_empty_quarters_returns_400() -> None:
|
||||
"""scope=manual_list без quarters → 400."""
|
||||
db = _make_mock_db()
|
||||
|
|
@ -170,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None:
|
|||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs",
|
||||
json={"scope": "manual_list"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_list_jobs_returns_list() -> None:
|
||||
"""GET /cadastre/jobs → список jobs."""
|
||||
db = _make_mock_db(job_row=_make_sample_job_row())
|
||||
|
|
@ -194,7 +183,7 @@ def test_list_jobs_returns_list() -> None:
|
|||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert isinstance(body, list)
|
||||
|
|
@ -206,7 +195,6 @@ def test_list_jobs_returns_list() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_get_job_not_found_returns_404() -> None:
|
||||
"""GET /cadastre/jobs/9999 → 404."""
|
||||
db = MagicMock()
|
||||
|
|
@ -220,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None:
|
|||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs/9999")
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_cancel_job_success() -> None:
|
||||
"""POST /cadastre/jobs/42/cancel → {cancelled: true}."""
|
||||
db = MagicMock()
|
||||
|
|
@ -243,7 +230,6 @@ def test_cancel_job_success() -> None:
|
|||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs/42/cancel",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -253,7 +239,6 @@ def test_cancel_job_success() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_cancel_job_not_found_returns_404() -> None:
|
||||
"""POST /cadastre/jobs/9999/cancel когда job не найден → 404."""
|
||||
db = MagicMock()
|
||||
|
|
@ -270,7 +255,6 @@ def test_cancel_job_not_found_returns_404() -> None:
|
|||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs/9999/cancel",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
- валидный запрос без year → scope all_years_2022_2026, task_id в ответе
|
||||
- валидный запрос с year=2026 → scope year_2026
|
||||
- year < 2022 или > 2030 → 422
|
||||
- отсутствие X-Admin-Token → 401/503
|
||||
|
||||
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие
|
||||
X-Admin-Token → 401/503» стояла в этом докстринге, но соответствующего теста в
|
||||
файле нет. Гейт `/api/v1/admin/*` — middleware `rbac_guard` (app/main.py),
|
||||
покрыт tests/test_rbac.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -16,8 +20,6 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from app.main import app
|
||||
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
|
||||
ENDPOINT = "/api/v1/admin/scrape/ekburg-permits"
|
||||
|
||||
|
||||
|
|
@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock:
|
|||
return result
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_trigger_refresh_all_returns_task_id() -> None:
|
||||
"""POST без year → refresh_all queued, scope=all_years_2022_2026."""
|
||||
mock_result = _mock_task("task-all-001")
|
||||
|
|
@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None:
|
|||
):
|
||||
mock_refresh_all.apply_async.return_value = mock_result
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS)
|
||||
response = client.post(ENDPOINT, json={})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None:
|
|||
assert "queued_at" in body
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_trigger_refresh_year_returns_task_id() -> None:
|
||||
"""POST year=2026 → refresh_year queued, scope=year_2026."""
|
||||
mock_result = _mock_task("task-year-002")
|
||||
|
|
@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None:
|
|||
):
|
||||
mock_refresh_year.apply_async.return_value = mock_result
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS)
|
||||
response = client.post(ENDPOINT, json={"year": 2026})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -67,9 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None:
|
|||
|
||||
|
||||
@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999])
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_trigger_invalid_year_returns_422(bad_year: int) -> None:
|
||||
"""year вне диапазона [2022, 2030] → 422 Unprocessable Entity."""
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS)
|
||||
response = client.post(ENDPOINT, json={"year": bad_year})
|
||||
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"
|
||||
|
|
|
|||
|
|
@ -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), НЕ сырой.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "комфорт"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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% покрытия) —
|
||||
|
|
|
|||
|
|
@ -197,32 +197,74 @@ class TestParseBabushkinaBlock:
|
|||
assert site.krt_kind == "жилой застройки"
|
||||
|
||||
|
||||
class TestMultiSiteCountFromDump:
|
||||
"""Проверка количества площадок из реального дампа страницы."""
|
||||
# ── Многоблочная страница: два года, три площадки, повторяющийся номер ────────
|
||||
#
|
||||
# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший
|
||||
# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у
|
||||
# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а
|
||||
# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому
|
||||
# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ.
|
||||
#
|
||||
# Свойство, ради которого они писались, от дампа не зависит и выражается
|
||||
# страницей из блоков, которые уже лежат в этом файле:
|
||||
# • несколько <h2>-секций с годами разбираются в одном проходе;
|
||||
# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ,
|
||||
# поэтому «2.» встречается дважды — и site_name всё равно обязан быть
|
||||
# уникальным (номер сам по себе ключом не является);
|
||||
# • блоки лежат внутри контейнера <div> — это ветка `_extract_krt_section`
|
||||
# через `find_parent`, которой одиночные блоки выше не касаются вовсе.
|
||||
#
|
||||
# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778):
|
||||
# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора
|
||||
# реального документа, синтетической страницей оно не заменяется. На проде
|
||||
# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но
|
||||
# автоматической проверки этого больше нет — она вернётся, когда в репозитории
|
||||
# появится образец страницы.
|
||||
_MULTISITE_PAGE = (
|
||||
"<div class='content'>"
|
||||
+ _BRUSNIKA_BLOCK
|
||||
+ _BABUSHKINA_BLOCK
|
||||
+ textwrap.dedent(
|
||||
"""\
|
||||
<p> <strong>2. КРТ нежилой застройки в районе ул. Краснофлотцев:</strong></p>
|
||||
<p>Площадь территории: 3,65 га;<br />\
|
||||
Градостроительный потенциал: 117 640 кв. м;<br />\
|
||||
Всего многоквартирных домов: 13.</p>
|
||||
"""
|
||||
)
|
||||
+ "</div>"
|
||||
)
|
||||
|
||||
|
||||
class TestMultiSitePage:
|
||||
"""Разбор страницы из нескольких блоков — без локального дампа."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def dump_html(self):
|
||||
"""Загружает локальный дамп страницы.
|
||||
def sites(self):
|
||||
return parse_krt_page(_MULTISITE_PAGE)
|
||||
|
||||
Тест пропускается если файл отсутствует (CI без дампа).
|
||||
"""
|
||||
import os
|
||||
def test_all_three_sites_parsed(self, sites):
|
||||
"""Три блока в двух годовых секциях → три площадки."""
|
||||
assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}"
|
||||
|
||||
dump_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../../../.playwright-mcp/ekb-docs/krt_page_raw.html",
|
||||
)
|
||||
if not os.path.exists(dump_path):
|
||||
pytest.skip("krt_page_raw.html dump not found — skip real-dump test")
|
||||
with open(dump_path, encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
def test_site_count_from_dump(self, dump_html):
|
||||
"""Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15)."""
|
||||
sites = parse_krt_page(dump_html)
|
||||
assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}"
|
||||
|
||||
def test_no_duplicate_site_names(self, dump_html):
|
||||
sites = parse_krt_page(dump_html)
|
||||
def test_no_duplicate_site_names(self, sites):
|
||||
"""Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны."""
|
||||
names = [s.site_name for s in sites]
|
||||
assert len(names) == len(set(names)), "Дубликаты site_name в результате"
|
||||
assert sum(n.startswith("2.") for n in names) == 2, names
|
||||
assert len(names) == len(set(names)), f"Дубликаты site_name: {names}"
|
||||
|
||||
def test_blocks_do_not_leak_into_each_other(self, sites):
|
||||
"""Границы блоков соблюдены: договор и застройщик остались у своей площадки.
|
||||
|
||||
Регресс, который этим ловится: если block_end съезжает, «Брусника» и
|
||||
договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему.
|
||||
"""
|
||||
with_developer = [s for s in sites if s.developer_name]
|
||||
assert len(with_developer) == 1, [s.developer_name for s in sites]
|
||||
assert "Брусника" in with_developer[0].developer_name
|
||||
assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1
|
||||
|
||||
def test_kinds_are_read_per_block(self, sites):
|
||||
"""Третий блок — «нежилой застройки», не должен унаследовать вид соседа."""
|
||||
kinds = sorted(s.krt_kind for s in sites if s.krt_kind)
|
||||
assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds
|
||||
|
|
|
|||
|
|
@ -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 (слой жив, просто пуст)."""
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
backend/tests/test_skip_guard_loud.py
Normal file
44
backend/tests/test_skip_guard_loud.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,10 +17,66 @@
|
|||
#
|
||||
# Postgres + Redis run alongside the app on the same VM (Discovery mode).
|
||||
# Volumes are shared with docker-compose.yml so switching between files preserves data.
|
||||
#
|
||||
# ── logging: journald (#2761) ────────────────────────────────────────────────
|
||||
# До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ
|
||||
# и живёт в /var/lib/docker/containers/<id>/ (умирает вместе с контейнером).
|
||||
# Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН
|
||||
# способ на обе половины, расхождение двух стеков дороже в поддержке.
|
||||
#
|
||||
# Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера):
|
||||
# glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека
|
||||
# postgres 2.2 ← 162 МБ за 74 дня
|
||||
# backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5
|
||||
# ИТОГО ~39 МБ/сутки
|
||||
# Бюджет journald (замер там же, сообщение самого systemd-journald):
|
||||
# "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён
|
||||
# (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G).
|
||||
# Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки
|
||||
# 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины.
|
||||
# Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с
|
||||
# самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс
|
||||
# пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ.
|
||||
#
|
||||
# КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть):
|
||||
# docker logs gendesign-backend-1 # как и раньше — только текущий контейнер
|
||||
# # История через пересоздания: журнал принадлежит root:systemd-journal, а
|
||||
# # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и
|
||||
# # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт
|
||||
# # "No entries". Рабочий однострочник — через docker-группу:
|
||||
# docker run --rm -v /:/host:ro alpine chroot /host sh -c \
|
||||
# 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"'
|
||||
# # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста
|
||||
# # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760).
|
||||
# # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID=<id>
|
||||
# # — так читается лог УЖЕ УДАЛЁННОГО контейнера).
|
||||
# Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl
|
||||
# заработает напрямую (host-config, не этот файл). До этого правка не регрессия.
|
||||
#
|
||||
# ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так
|
||||
# что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так
|
||||
# force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки:
|
||||
# postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка
|
||||
# MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без
|
||||
# ночных прогонов.
|
||||
# Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при
|
||||
# флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker
|
||||
# 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это
|
||||
# host drop-in journald.conf.d, не этот файл.
|
||||
# tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями.
|
||||
#
|
||||
# ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной
|
||||
# разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры
|
||||
# просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size.
|
||||
x-logging: &default-logging
|
||||
driver: journald
|
||||
options:
|
||||
tag: "{{.Name}}"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
|
|
@ -46,6 +102,7 @@ services:
|
|||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
|
@ -54,6 +111,40 @@ services:
|
|||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
# #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ
|
||||
# его достать. До этого redis жил только в gendesign_default, а tradein — в
|
||||
# gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не
|
||||
# резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а
|
||||
# отсутствующая связность (см. #2709).
|
||||
#
|
||||
# Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml
|
||||
# поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES —
|
||||
# ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый
|
||||
# сервис в tradein-compose в этот список не попадает и `--no-deps` его не
|
||||
# подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал
|
||||
# бы в пустоту. Правка того списка = правка deploy-tradein.yml, который
|
||||
# сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком.
|
||||
#
|
||||
# aliases: тот же приём, что уже применён к postgres выше — стабильное имя
|
||||
# gendesign-redis вместо compose-зависимого gendesign-redis-1.
|
||||
# ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется
|
||||
# блок networks:, неявная привязка к default пропадает, и backend/worker/
|
||||
# beat/glitchtip потеряли бы брокер (та же грабля описана у postgres).
|
||||
#
|
||||
# Разделение ключей — по НОМЕРУ БД, инстанс общий:
|
||||
# db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей
|
||||
# db1 — trade-in (SearchCache) ← вводится здесь
|
||||
# db2 — glitchtip (см. REDIS_URL ниже)
|
||||
# Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на
|
||||
# брокере celery вытеснял бы поставленные в очередь таски. Значит tradein
|
||||
# обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если
|
||||
# tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая
|
||||
# celery. Тогда — отдельный инстанс, а не смена политики вытеснения.
|
||||
networks:
|
||||
default: {}
|
||||
shared:
|
||||
aliases:
|
||||
- gendesign-redis
|
||||
|
||||
# OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI
|
||||
# вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze —
|
||||
|
|
@ -76,6 +167,7 @@ services:
|
|||
# отдельно (docs/osrm-routing.md).
|
||||
osrm:
|
||||
image: osrm/osrm-backend:latest
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm
|
||||
volumes:
|
||||
|
|
@ -103,6 +195,7 @@ services:
|
|||
# (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000.
|
||||
osrm-walk:
|
||||
image: osrm/osrm-backend:latest
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
|
||||
volumes:
|
||||
|
|
@ -118,6 +211,7 @@ services:
|
|||
|
||||
backend:
|
||||
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
# .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG).
|
||||
# required: false — compose не падает если файла нет (первый деплой).
|
||||
|
|
@ -153,6 +247,7 @@ services:
|
|||
|
||||
frontend:
|
||||
image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
|
@ -172,6 +267,7 @@ services:
|
|||
worker:
|
||||
# Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium.
|
||||
image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
|
|
@ -201,6 +297,7 @@ services:
|
|||
beat:
|
||||
# Lean backend-образ (без Chromium) — beat только триггерит таски в Redis.
|
||||
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
|
|
@ -219,6 +316,7 @@ services:
|
|||
glitchtip-web:
|
||||
image: glitchtip/glitchtip:6.1.6
|
||||
container_name: glitchtip-web
|
||||
logging: *default-logging
|
||||
# profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`.
|
||||
# Bootstrap script activates the profile after DB + secrets are ready.
|
||||
# On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env.
|
||||
|
|
@ -254,6 +352,7 @@ services:
|
|||
glitchtip-worker:
|
||||
image: glitchtip/glitchtip:6.1.6
|
||||
container_name: glitchtip-worker
|
||||
logging: *default-logging
|
||||
profiles: ["glitchtip"]
|
||||
depends_on:
|
||||
postgres:
|
||||
|
|
@ -272,6 +371,7 @@ services:
|
|||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
|
|
@ -310,6 +410,7 @@ services:
|
|||
# deploy.yml запускает: docker compose build glitchtip-auth-forwarder
|
||||
build: ./ops/glitchtip-auth-forwarder
|
||||
container_name: gendesign-auth-forwarder
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GLITCHTIP_DSN: ${GLITCHTIP_DSN}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
| `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) |
|
||||
| `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** |
|
||||
| `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** |
|
||||
| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) |
|
||||
| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — |
|
||||
|
||||
### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`)
|
||||
|
||||
|
|
@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`):
|
|||
|
||||
---
|
||||
|
||||
## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
|
||||
## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
|
||||
|
||||
Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
|
||||
**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436
|
||||
(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth.
|
||||
Поле оставлено в `core/deps.py` только для быстрого rollback.
|
||||
Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
|
||||
**Ротировать нечего: переменной больше нет.**
|
||||
|
||||
**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока
|
||||
`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F**
|
||||
(процедура: `sed` в `backend/.env.runtime` → `up -d --force-recreate --no-deps backend beat`,
|
||||
downtime отсутствует). Фактический прод-прогон ротации — операционное действие
|
||||
(Anton), не выполняется в рамках этого PR.
|
||||
История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран
|
||||
со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и
|
||||
dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до
|
||||
#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему
|
||||
репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте —
|
||||
в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно
|
||||
означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть
|
||||
хранение поля не экономило ничего. Удалено вместе с `core/deps.py`,
|
||||
`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах.
|
||||
|
||||
Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**:
|
||||
1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only`
|
||||
для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста).
|
||||
2. Caddy basic_auth на весь `gendsgn.ru` (PR #426).
|
||||
|
||||
Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно:
|
||||
`Settings.model_config` — `extra="ignore"`, лишняя переменная безвредна.
|
||||
|
||||
`JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует** —
|
||||
добавить в реестр (класс **F**) при внедрении JWT-аутентификации.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
|
|||
import { LandTab } from "@/components/site-finder/LandTab";
|
||||
import { MarketTab } from "@/components/site-finder/MarketTab";
|
||||
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
||||
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||
import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||
import { useCustomPois } from "@/hooks/useCustomPois";
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
POI_DEFAULT_WEIGHTS,
|
||||
type PoiCategoryKey,
|
||||
} from "@/lib/api/weightProfiles";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
|
||||
// SiteMap imports Leaflet which requires browser APIs — load without SSR
|
||||
const SiteMap = dynamic(
|
||||
|
|
@ -129,16 +130,16 @@ function SiteFinderContent() {
|
|||
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
|
||||
const weightsChangeInitializedRef = useRef(false);
|
||||
|
||||
const [profileUserId, setProfileUserId] = useState<string>(() =>
|
||||
typeof window === "undefined"
|
||||
? ""
|
||||
: (localStorage.getItem("admin_user_id") ?? ""),
|
||||
);
|
||||
const [adminToken] = useState<string>(() =>
|
||||
typeof window === "undefined"
|
||||
? ""
|
||||
: (localStorage.getItem("admin_token") ?? ""),
|
||||
);
|
||||
// Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения
|
||||
// из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`,
|
||||
// который сервер не читает с #437) — оба недостижимы без DevTools.
|
||||
//
|
||||
// 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без
|
||||
// `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом
|
||||
// `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без
|
||||
// user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль
|
||||
// молча не применялся бы, а UI показывал бы его ползунки.
|
||||
const profileUserId = useMe().data?.username ?? "";
|
||||
// Lazy init: считаем initialTab один раз на mount (useState всё равно
|
||||
// игнорирует initializer после первого render — не тратим CPU).
|
||||
const [tab, setTabState] = useState<TabId>(() => {
|
||||
|
|
@ -192,12 +193,7 @@ function SiteFinderContent() {
|
|||
setIsochrones(undefined);
|
||||
mutate({
|
||||
cad: currentData.cad_num,
|
||||
options:
|
||||
profileId != null
|
||||
? { profileId }
|
||||
: currentProfileUserId
|
||||
? { profileUserId: currentProfileUserId, weights }
|
||||
: { weights },
|
||||
options: buildAnalyzeOptions(weights, profileId, currentProfileUserId),
|
||||
});
|
||||
// mutate is stable from useMutation — safe to omit from deps.
|
||||
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
|
||||
|
|
@ -216,12 +212,11 @@ function SiteFinderContent() {
|
|||
// slider values are always respected even without a saved profile (#201).
|
||||
mutate({
|
||||
cad: cadNum,
|
||||
options:
|
||||
activeProfileId != null
|
||||
? { profileId: activeProfileId }
|
||||
: profileUserId
|
||||
? { profileUserId, weights: currentWeights }
|
||||
: { weights: currentWeights },
|
||||
options: buildAnalyzeOptions(
|
||||
currentWeights,
|
||||
activeProfileId,
|
||||
profileUserId,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -329,46 +324,9 @@ function SiteFinderContent() {
|
|||
|
||||
{/* Weight profile panel — collapsible, below header */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
|
||||
{!!adminToken && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
||||
>
|
||||
User ID (для профилей):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileUserId}
|
||||
placeholder="user-abc"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
fontSize: 12,
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
width: 180,
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setProfileUserId(e.target.value);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("admin_user_id", e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<WeightProfilePanel
|
||||
currentWeights={currentWeights}
|
||||
onWeightsChange={handleWeightsChange}
|
||||
userId={profileUserId || undefined}
|
||||
adminToken={adminToken || undefined}
|
||||
/>
|
||||
{/* Recalculation indicator — shown while re-analyze is in-flight after
|
||||
weights change (data already loaded, pendingWeightsChange set). */}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,8 +10,8 @@
|
|||
/* ===================== SCOPED TOKENS (dark) ===================== */
|
||||
.pticaRoot[data-theme="dark"] {
|
||||
--font-ui:
|
||||
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI",
|
||||
system-ui, sans-serif;
|
||||
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui,
|
||||
sans-serif;
|
||||
--font-mono:
|
||||
var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace,
|
||||
monospace;
|
||||
|
|
@ -907,31 +907,6 @@
|
|||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ===================== PLACEHOLDER PANEL ===================== */
|
||||
.placeholderPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
min-height: 240px;
|
||||
border-style: dashed;
|
||||
}
|
||||
.placeholderPanel .soon {
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-soft);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.placeholderPanel p {
|
||||
font-size: 10px;
|
||||
color: var(--text-soft);
|
||||
margin: 0;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* ===================== STATE SCREENS ===================== */
|
||||
.stateScreen {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -526,14 +526,15 @@ function VariantPanel({
|
|||
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
||||
: ""}
|
||||
НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК
|
||||
РФ); НДС начисляется только на паркинг (нежилые машиноместа).
|
||||
Входной НДС по строительству уже учтён в себестоимости. Налог на
|
||||
прибыль — 25% (с 2025 года). Цена продажи жилья —{" "}
|
||||
РФ); НДС начисляется на нежилое — паркинг (машиноместа) и
|
||||
коммерцию 1-го этажа. Входной НДС по строительству уже учтён в
|
||||
себестоимости. Налог на прибыль — 25% (с 2025 года). Цена продажи
|
||||
жилья —{" "}
|
||||
{financial.price_is_calibrated
|
||||
? `калибрована по рынку (${priceSourceCaption(financial)})`
|
||||
: "норматив класса (нет рыночных данных по участку)"}
|
||||
; себестоимость СМР и цена паркинга — нормативные ориентиры.
|
||||
Коммерческие и офисные площади не учитываются.
|
||||
; себестоимость СМР, цена паркинга и цена нежилого — нормативные
|
||||
ориентиры.
|
||||
{financial.financing_enabled && financial.financing_is_simplified
|
||||
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
|
||||
"кредитом по ставке-нормативу, проценты капитализируются, " +
|
||||
|
|
@ -656,7 +657,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
|||
value={formatMoneyCompact(financial.gross_margin_rub)}
|
||||
/>
|
||||
<CascadeRow
|
||||
label="НДС (паркинг)"
|
||||
label="НДС (паркинг + коммерция)"
|
||||
value={formatMoneyCompact(financial.vat_rub)}
|
||||
/>
|
||||
<CascadeRow
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
// ScoreCard is superseded by the tabbed dashboard in page.tsx.
|
||||
// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab.
|
||||
// File kept to avoid breaking any external imports; exports an empty stub.
|
||||
|
||||
export {};
|
||||
|
|
@ -3,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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* #2782 — CRUD профилей весов был недостижим никому без DevTools.
|
||||
*
|
||||
* Панель отпирала сохранение/загрузку профилей значением `admin_token` из
|
||||
* localStorage. Записывать его было нечем: инпут удалили в #442. Сервер этот
|
||||
* заголовок к тому моменту уже не читал (#437, остатки убраны в #2775) — то есть
|
||||
* фича стояла за признаком, который ничего не решал.
|
||||
*
|
||||
* Тесты ниже пиннят три вещи, каждая из которых на старом коде красная:
|
||||
* 1) панель берёт владельца профилей из /api/v1/me и включает CRUD без пропсов;
|
||||
* 2) в запросы CRUD не уходит X-Admin-Token;
|
||||
* 3) `profileId` не уходит в /analyze без `profileUserId` (иначе бэкенд молча
|
||||
* считает по системным весам, рапортуя source="profile").
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WeightProfilePanel } from "../WeightProfilePanel";
|
||||
import { buildAnalyzeOptions } from "@/hooks/useSiteAnalysis";
|
||||
import { POI_DEFAULT_WEIGHTS } from "@/lib/api/weightProfiles";
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const PROFILE = {
|
||||
id: 1,
|
||||
user_id: "admin",
|
||||
profile_name: "Мой профиль",
|
||||
weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 },
|
||||
is_default: true,
|
||||
description: null,
|
||||
created_at: "2026-05-15T05:30:51Z",
|
||||
updated_at: "2026-05-15T05:30:51Z",
|
||||
};
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WeightProfilePanel
|
||||
currentWeights={{ ...POI_DEFAULT_WEIGHTS }}
|
||||
onWeightsChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */
|
||||
async function openPanel() {
|
||||
const { default: userEvent } = await import("@testing-library/user-event");
|
||||
await userEvent.setup().click(screen.getByText("POI Веса"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const url = typeof input === "string" ? input : String(input);
|
||||
if (url.includes("/api/v1/me")) {
|
||||
return jsonResponse({
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
allowed_paths: ["/**"],
|
||||
deny_paths: [],
|
||||
});
|
||||
}
|
||||
if (url.includes("/weight-profiles")) {
|
||||
return jsonResponse([PROFILE]);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => {
|
||||
it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => {
|
||||
renderPanel();
|
||||
await openPanel();
|
||||
|
||||
// Профили грузятся по user_id из /me, без ручного ввода и без токена.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText("Профиль:")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => {
|
||||
renderPanel();
|
||||
await openPanel();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByText(/Admin Token/i)).toBeNull();
|
||||
expect(screen.queryByText(/Укажите User ID/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => {
|
||||
renderPanel();
|
||||
await openPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
const crudCall = fetchMock.mock.calls.find(([input]) =>
|
||||
String(input).includes("/weight-profiles"),
|
||||
);
|
||||
expect(crudCall).toBeTruthy();
|
||||
const headers = new Headers(
|
||||
(crudCall?.[1] as RequestInit | undefined)?.headers,
|
||||
);
|
||||
expect(headers.has("X-Admin-Token")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => {
|
||||
const weights = { ...POI_DEFAULT_WEIGHTS };
|
||||
|
||||
it("выбранный профиль уходит вместе с profileUserId", () => {
|
||||
expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({
|
||||
profileId: 7,
|
||||
profileUserId: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => {
|
||||
const options = buildAnalyzeOptions(weights, 7, "");
|
||||
expect(options.profileId).toBeUndefined();
|
||||
expect(options.weights).toEqual(weights);
|
||||
});
|
||||
|
||||
it("без профиля уходят inline-веса и владелец для default-профиля", () => {
|
||||
expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({
|
||||
profileUserId: "admin",
|
||||
weights,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface AnalysisBreadcrumbProps {
|
||||
cadNum: string;
|
||||
}
|
||||
|
||||
export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
flexWrap: "wrap",
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* SiteFinder root */}
|
||||
<Link
|
||||
href="/site-finder"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--fg-secondary)",
|
||||
textDecoration: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
SiteFinder
|
||||
</Link>
|
||||
|
||||
<ChevronRight
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Cad number */}
|
||||
<Link
|
||||
href={`/site-finder?selected=${encodeURIComponent(cadNum)}`}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--fg-secondary)",
|
||||
textDecoration: "none",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 260,
|
||||
}}
|
||||
title={cadNum}
|
||||
>
|
||||
{cadNum}
|
||||
</Link>
|
||||
|
||||
<ChevronRight
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Current page */}
|
||||
<span
|
||||
aria-current="page"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Анализ
|
||||
</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SubSection {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
id: string;
|
||||
label: string;
|
||||
sub?: SubSection[];
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
{ id: "section-1", label: "1. Объект" },
|
||||
{ id: "section-2", label: "2. Земля и риски" },
|
||||
{
|
||||
id: "section-3",
|
||||
label: "3. Рынок",
|
||||
sub: [
|
||||
{ id: "section-3-1", label: "3.1 Настройки выборки" },
|
||||
{ id: "section-3-2", label: "3.2 Планировки" },
|
||||
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
|
||||
],
|
||||
},
|
||||
{ id: "section-4", label: "4. Оценка" },
|
||||
{ id: "section-5", label: "5. Атмосфера" },
|
||||
{
|
||||
id: "section-6",
|
||||
label: "6. Прогноз",
|
||||
sub: [
|
||||
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
||||
{ id: "section-6-2", label: "6.2 Сценарии" },
|
||||
{ id: "section-6-3", label: "6.3 Уверенность" },
|
||||
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
||||
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
|
||||
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// All section IDs in scroll order (for IntersectionObserver)
|
||||
const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) =>
|
||||
s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id],
|
||||
);
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AnalysisSidebar() {
|
||||
const [activeId, setActiveId] = useState<string>(ALL_SECTION_IDS[0]);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
// Scrollspy via IntersectionObserver
|
||||
useEffect(() => {
|
||||
const candidates = ALL_SECTION_IDS.map((id) =>
|
||||
document.getElementById(id),
|
||||
).filter((el): el is HTMLElement => el !== null);
|
||||
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
// Track which sections are visible; pick topmost visible one
|
||||
const visible = new Set<string>();
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
visible.add(entry.target.id);
|
||||
} else {
|
||||
visible.delete(entry.target.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Pick the topmost section that is currently visible
|
||||
const next = ALL_SECTION_IDS.find((id) => visible.has(id));
|
||||
if (next) setActiveId(next);
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
// Trigger when section top enters top 60% of viewport
|
||||
rootMargin: "-8px 0px -40% 0px",
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
candidates.forEach((el) => observerRef.current!.observe(el));
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleAnchorClick(
|
||||
e: React.MouseEvent<HTMLAnchorElement>,
|
||||
targetId: string,
|
||||
) {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(targetId);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
setActiveId(targetId);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
width: 240,
|
||||
flexShrink: 0,
|
||||
background: "var(--bg-card)",
|
||||
borderRight: "1px solid var(--border-card)",
|
||||
padding: "16px 12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
position: "sticky",
|
||||
top: 56,
|
||||
height: "calc(100vh - 56px)",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Section label */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: "var(--fg-tertiary)",
|
||||
margin: "0 0 8px",
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
Навигация
|
||||
</p>
|
||||
|
||||
{/* Nav items */}
|
||||
{NAV_SECTIONS.map((section) => {
|
||||
const isParentActive =
|
||||
activeId === section.id ||
|
||||
section.sub?.some((s) => s.id === activeId);
|
||||
|
||||
return (
|
||||
<div key={section.id}>
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
onClick={(e) => handleAnchorClick(e, section.id)}
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "7px 10px",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: isParentActive ? 600 : 400,
|
||||
color: isParentActive ? "var(--accent)" : "var(--fg-secondary)",
|
||||
background: isParentActive
|
||||
? "var(--accent-soft)"
|
||||
: "transparent",
|
||||
textDecoration: "none",
|
||||
transition: "background 100ms, color 100ms",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</a>
|
||||
|
||||
{/* Sub-sections */}
|
||||
{section.sub && (
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 12,
|
||||
marginTop: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{section.sub.map((sub) => {
|
||||
const isSubActive = activeId === sub.id;
|
||||
return (
|
||||
<a
|
||||
key={sub.id}
|
||||
href={`#${sub.id}`}
|
||||
onClick={(e) => handleAnchorClick(e, sub.id)}
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "5px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: isSubActive ? 600 : 400,
|
||||
color: isSubActive
|
||||
? "var(--accent)"
|
||||
: "var(--fg-tertiary)",
|
||||
background: isSubActive
|
||||
? "var(--accent-soft)"
|
||||
: "transparent",
|
||||
textDecoration: "none",
|
||||
transition: "background 100ms, color 100ms",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{sub.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Sources footer */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
paddingTop: 16,
|
||||
borderTop: "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: "var(--fg-tertiary)",
|
||||
margin: "0 0 6px",
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
Источники
|
||||
</p>
|
||||
{[
|
||||
{ label: "Росреестр / ЕГРН", href: "https://rosreestr.gov.ru" },
|
||||
{ label: "НСПД", href: "https://nspd.gov.ru" },
|
||||
{ label: "2ГИС / OSM", href: "https://2gis.ru" },
|
||||
].map(({ label, href }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "4px 4px",
|
||||
fontSize: 11,
|
||||
color: "var(--fg-tertiary)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={10} strokeWidth={1.5} />
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b,
|
||||
* epic #1953).
|
||||
*
|
||||
* Driven by the interactive 3D MassingScene: every time the user drags the
|
||||
* этажность / секций sliders, Section7Concept maps the scene's `computeModel`
|
||||
* result + the analysis context into a `MassingProgram` and hands it here via
|
||||
* `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and
|
||||
* render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость /
|
||||
* прибыль / ROI).
|
||||
*
|
||||
* Robustness:
|
||||
* • debounce — slider drags fire many programs; only the settled one is sent.
|
||||
* • latest-wins — an in-flight request is superseded by a newer one via a
|
||||
* monotonic request id; a stale response is dropped, never overwriting a
|
||||
* fresher result (mutateAsync + id guard, no UI flicker from out-of-order).
|
||||
* • last-good — on a failed recompute we keep the last successful values and
|
||||
* show a subtle inline note rather than blanking the panel.
|
||||
* • skeleton — a plain grey fade KPI grid while the FIRST recompute is in
|
||||
* flight (no shimmer, per ui-conventions).
|
||||
*
|
||||
* Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but
|
||||
* this strip uses the light KPI tokens via the shared KpiCard.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||
import { Section } from "@/components/analytics/Section";
|
||||
import {
|
||||
priceSourceCaption,
|
||||
useRecomputeMassing,
|
||||
type FinancialModel,
|
||||
type MassingProgram,
|
||||
type MassingRecomputeOutput,
|
||||
type Teap,
|
||||
} from "@/lib/concept-api";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ─────────
|
||||
|
||||
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
||||
|
||||
/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
|
||||
function formatMoneyCompact(rub: number): string {
|
||||
const abs = Math.abs(rub);
|
||||
if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
|
||||
if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
|
||||
return `${nf.format(Math.round(rub))} ₽`;
|
||||
}
|
||||
|
||||
function formatInt(n: number): string {
|
||||
return nf.format(Math.round(n));
|
||||
}
|
||||
|
||||
function formatPct(fraction: number): string {
|
||||
return `${(fraction * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatFar(far: number): string {
|
||||
return far.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// ── KPI grid ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface KpiGridProps {
|
||||
teap: Teap;
|
||||
financial: FinancialModel;
|
||||
/** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */
|
||||
farTarget: number;
|
||||
/** True → факт-КСИТ превышает регламентный потолок (model.over). */
|
||||
ksitOver: boolean;
|
||||
/** Dim the strip while a fresher recompute is in flight (last-good values). */
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
function KpiGrid({
|
||||
teap,
|
||||
financial,
|
||||
farTarget,
|
||||
ksitOver,
|
||||
stale,
|
||||
}: KpiGridProps) {
|
||||
const netPositive =
|
||||
financial.net_profit_rub > 0
|
||||
? true
|
||||
: financial.net_profit_rub < 0
|
||||
? false
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity: stale ? 0.55 : 1,
|
||||
transition: "opacity 150ms linear",
|
||||
}}
|
||||
>
|
||||
{/* ТЭП */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<KpiCard
|
||||
label="Общая площадь (GFA)"
|
||||
value={formatInt(teap.total_floor_area_sqm)}
|
||||
unit="м²"
|
||||
hint="Поэтажная площадь всех корпусов = пятно застройки × этажность."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Продаваемая площадь"
|
||||
value={formatInt(teap.residential_area_sqm)}
|
||||
unit="м²"
|
||||
hint="Жилая к продаже = (GFA − нежилой 1-й этаж) × коэффициент эффективности класса."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Квартир"
|
||||
value={formatInt(teap.apartments_count)}
|
||||
unit="шт"
|
||||
hint="Продаваемая площадь ÷ средняя площадь квартиры класса."
|
||||
/>
|
||||
<KpiCard
|
||||
label="КСИТ — факт / цель"
|
||||
value={`${formatFar(teap.density)} / ${formatFar(farTarget)}`}
|
||||
delta={{
|
||||
value: ksitOver
|
||||
? "Факт превышает регламентный потолок"
|
||||
: "В пределах регламента",
|
||||
positive: ksitOver ? false : true,
|
||||
}}
|
||||
hint="КСИТ (коэффициент строительного использования) = надземная GFA ÷ площадь участка. Цель — предельный max_far по регламенту НСПД."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Финмодель */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<KpiCard
|
||||
label="Выручка (GDV)"
|
||||
value={formatMoneyCompact(financial.revenue_rub)}
|
||||
hint="Продаваемая площадь × цена продажи м² (+ машиноместа и нежилой 1-й этаж по ценам класса)."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Себестоимость"
|
||||
value={formatMoneyCompact(financial.cost_rub)}
|
||||
hint="Строительство (GFA × удельная по классу) + ПИР, сети, услуги застройщика, непредвиденные, маркетинг + стоимость земли."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Чистая прибыль"
|
||||
value={formatMoneyCompact(financial.net_profit_rub)}
|
||||
delta={{
|
||||
value:
|
||||
netPositive === true
|
||||
? "Положительная (после НДС и налога на прибыль)"
|
||||
: netPositive === false
|
||||
? "Отрицательная (после НДС и налога на прибыль)"
|
||||
: "Нулевая",
|
||||
positive: netPositive,
|
||||
}}
|
||||
hint="Выручка − себестоимость − НДС на нежилое − налог на прибыль 25%. Жильё по ДДУ от НДС освобождено."
|
||||
/>
|
||||
<KpiCard
|
||||
label="ROI на затраты"
|
||||
value={formatPct(financial.roi)}
|
||||
delta={{
|
||||
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
|
||||
positive: null,
|
||||
}}
|
||||
hint="Чистая прибыль ÷ себестоимость. Маржа считается от выручки."
|
||||
/>
|
||||
<KpiCard
|
||||
label="NPV (DCF)"
|
||||
value={formatMoneyCompact(financial.npv_rub)}
|
||||
delta={{
|
||||
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
|
||||
positive:
|
||||
financial.npv_rub > 0
|
||||
? true
|
||||
: financial.npv_rub < 0
|
||||
? false
|
||||
: null,
|
||||
}}
|
||||
hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct(
|
||||
financial.discount_rate_used,
|
||||
)} годовых).`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="IRR (DCF, годовой)"
|
||||
value={formatPct(financial.irr)}
|
||||
delta={{
|
||||
value: financial.irr_is_proxy
|
||||
? "Оценочный (вырожденный поток, не DCF)"
|
||||
: "Дисконтированный денежный поток",
|
||||
positive: financial.irr_is_proxy
|
||||
? null
|
||||
: financial.irr > financial.discount_rate_used
|
||||
? true
|
||||
: false,
|
||||
}}
|
||||
hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Цена продажи жилья"
|
||||
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
|
||||
delta={{ value: priceSourceCaption(financial), positive: null }}
|
||||
hint="Цена, заложенная в выручку: медиана объявлений Objective по району (источник указан выше)."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skeleton (grey fade, no shimmer — ui-conventions) ──────────────────────────
|
||||
|
||||
function SkeletonGrid() {
|
||||
const cells = Array.from({ length: 7 });
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{cells.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 92,
|
||||
background: "var(--bg-card-alt)",
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The current massing program (Σ footprint × floors + context), mapped by
|
||||
* Section7Concept from the 3D scene's computeModel result. `null` until the
|
||||
* scene has fired its first onModelChange (or when geometry is missing).
|
||||
*/
|
||||
program: MassingProgram | null;
|
||||
/** Регламентная КСИТ-цель (max_far) — for the факт/цель comparison + over-cap. */
|
||||
farTarget: number;
|
||||
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
|
||||
ksitOver: boolean;
|
||||
/**
|
||||
* True when the parcel is regulatorily constrained for МКД (gate-blocked /
|
||||
* non-residential / ЗОУИТ-СЗЗ) — drives the honest «условный расчёт» caveat
|
||||
* below the strip alongside the negative-economics case.
|
||||
*/
|
||||
gateConstrained?: boolean;
|
||||
}
|
||||
|
||||
export function MassingEconomics({
|
||||
program,
|
||||
farTarget,
|
||||
ksitOver,
|
||||
gateConstrained = false,
|
||||
}: Props) {
|
||||
const recompute = useRecomputeMassing();
|
||||
|
||||
// Last successful result kept locally so a failed/stale recompute never blanks
|
||||
// the panel (last-good values stay on screen).
|
||||
const [result, setResult] = useState<MassingRecomputeOutput | null>(null);
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
// Monotonic request id → latest-wins: a response is applied only if it belongs
|
||||
// to the most recently issued request, so out-of-order arrivals are dropped.
|
||||
const reqIdRef = useRef(0);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Stable JSON key so we only recompute when the program actually changes
|
||||
// (not on every parent re-render that hands an equal-but-new object).
|
||||
const programKey = program ? JSON.stringify(program) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!program) return;
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const id = ++reqIdRef.current;
|
||||
recompute
|
||||
.mutateAsync(program)
|
||||
.then((out) => {
|
||||
if (id !== reqIdRef.current) return; // stale — a newer request won.
|
||||
setResult(out);
|
||||
setErrored(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (id !== reqIdRef.current) return; // stale failure — ignore.
|
||||
setErrored(true); // keep last-good `result`.
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// recompute is a stable mutation object; we key off the serialized program.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [programKey]);
|
||||
|
||||
// No program yet → nothing to show (parent gates this on geometry anyway).
|
||||
if (!program) return null;
|
||||
|
||||
// First recompute in flight, no last-good value yet → skeleton.
|
||||
if (!result) {
|
||||
return (
|
||||
<Section
|
||||
title="Экономика по 3D-модели"
|
||||
subtitle="Пересчёт ТЭП и финмодели по текущей массе застройки (этажность / секции) из 3D-модели слева."
|
||||
>
|
||||
<SkeletonGrid />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// A fresher request is in flight over the last-good values.
|
||||
const stale = recompute.isPending;
|
||||
|
||||
// Honest «условный расчёт» caveat: when the economics turn negative OR the
|
||||
// parcel is regulatorily constrained (gate-blocked / ЗОУИТ-СЗЗ / нежилое),
|
||||
// we say so plainly rather than presenting the figures as a viable project.
|
||||
const economicsNegative =
|
||||
result.financial.net_profit_rub < 0 || result.financial.npv_rub < 0;
|
||||
const showConditionalNote = economicsNegative || gateConstrained;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Экономика по 3D-модели"
|
||||
subtitle="Пересчитывается вживую при изменении этажности / секций в 3D-модели слева. Цена продажи — из оценки участка, без рыночного DB-запроса; ТЭП синтезируется из массинг-программы."
|
||||
>
|
||||
<KpiGrid
|
||||
teap={result.teap}
|
||||
financial={result.financial}
|
||||
farTarget={farTarget}
|
||||
ksitOver={ksitOver}
|
||||
stale={stale}
|
||||
/>
|
||||
|
||||
{showConditionalNote ? (
|
||||
<p
|
||||
role="note"
|
||||
style={{
|
||||
margin: "12px 0 0",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
lineHeight: "16px",
|
||||
color: "var(--warn)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
style={{ flexShrink: 0, marginTop: 1 }}
|
||||
/>
|
||||
<span>
|
||||
Расчёт условный: участок ограничен регламентом (см. блокеры выше)
|
||||
и/или экономика отрицательна при текущих вводных. Измените
|
||||
этажность, число секций или класс — модель пересчитается.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{errored ? (
|
||||
<p
|
||||
role="status"
|
||||
style={{
|
||||
margin: "12px 0 0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--warn)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
Не удалось пересчитать экономику по последнему изменению — показаны
|
||||
предыдущие значения. Измените параметры ещё раз для повторного
|
||||
расчёта.
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Building2 } from "lucide-react";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getStoredOrgId(): string | null {
|
||||
// Guard against SSR — localStorage not available on server
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return localStorage.getItem("gd_org_id");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function orgInitials(orgId: string): string {
|
||||
// Build a 2-letter monogram from org ID string
|
||||
const parts = orgId
|
||||
.toUpperCase()
|
||||
.replace(/[^A-ZА-Я0-9]/gu, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return "??";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2);
|
||||
return parts[0][0] + parts[1][0];
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function UserAvatar() {
|
||||
const [orgId, setOrgId] = useState<string | null>(null);
|
||||
|
||||
// Hydration-safe: read localStorage after mount
|
||||
useEffect(() => {
|
||||
setOrgId(getStoredOrgId());
|
||||
}, []);
|
||||
|
||||
const displayLabel = orgId ?? "Demo Org";
|
||||
const initials = orgId ? orgInitials(orgId) : "DO";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={displayLabel}
|
||||
>
|
||||
{/* Avatar circle */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
background: "var(--accent-soft)",
|
||||
border: "1px solid var(--border-card)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{orgId ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--accent)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
) : (
|
||||
<Building2
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
style={{ color: "var(--accent)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Org name — hidden on narrow viewports via maxWidth trick */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "var(--fg-secondary)",
|
||||
maxWidth: 140,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PticaPlaceholderPanel — honest "В разработке" panel for cockpit tabs/sections
|
||||
* that aren't wired in INCREMENT 1 (Scenarios / Reports / Compare).
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function PticaPlaceholderPanel({ label, hint }: Props) {
|
||||
return (
|
||||
<div className={`${styles.panel} ${styles.placeholderPanel}`}>
|
||||
<div className={styles.soon}>{label}</div>
|
||||
{hint && <p>{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
|
|||
// /fetch-status every 2s, re-triggers analyze когда status=ready.
|
||||
|
||||
export type FetchStatus =
|
||||
| "ready"
|
||||
| "fetching"
|
||||
| "failed"
|
||||
| "not_in_nspd"
|
||||
| "invalid_format";
|
||||
"ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
|
||||
|
||||
export interface FetchStatusResponse {
|
||||
status: FetchStatus;
|
||||
|
|
@ -52,6 +48,34 @@ export interface AnalyzeOptions {
|
|||
weights?: Record<string, number> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать options для POST /analyze из состояния панели весов POI.
|
||||
*
|
||||
* 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
|
||||
* стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
|
||||
* profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
|
||||
* not None` не выполняется, и веса тихо падают на системные. Ответ при этом
|
||||
* рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
|
||||
* #2782: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из
|
||||
* профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
|
||||
*
|
||||
* Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline
|
||||
* `weights`. Они всегда равны ползункам панели, так что расхождения нет.
|
||||
*/
|
||||
export function buildAnalyzeOptions(
|
||||
weights: Record<string, number>,
|
||||
profileId: number | null,
|
||||
profileUserId: string,
|
||||
): AnalyzeOptions {
|
||||
if (profileId != null && profileUserId) {
|
||||
return { profileId, profileUserId };
|
||||
}
|
||||
if (profileUserId) {
|
||||
return { profileUserId, weights };
|
||||
}
|
||||
return { weights };
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook для analyze flow с graceful on-demand fetch fallback.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 — хуки вернутся из истории (мертвее они там не станут).
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ export interface MassingRecomputeOutput {
|
|||
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
|
||||
*
|
||||
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
|
||||
* with latest-wins sequencing handled by the caller (see MassingEconomics).
|
||||
* with latest-wins sequencing handled by the caller.
|
||||
*/
|
||||
export function useRecomputeMassing() {
|
||||
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* и иметь один безопасный канал кодирования кадастрового номера.
|
||||
*
|
||||
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
|
||||
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
|
||||
* NspdOpportunityBlock, ParcelDrawer.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
102
ops/docker-prune.sh
Executable 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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
77
ops/glitchtip-auth-forwarder/test_forwarder.py
Normal file
77
ops/glitchtip-auth-forwarder/test_forwarder.py
Normal 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
0
ops/restore.sh
Normal file → Executable file
242
scripts/check-migration-lock-timeout.py
Normal file
242
scripts/check-migration-lock-timeout.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752).
|
||||
|
||||
ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала
|
||||
на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за
|
||||
чужой аналитической psql-сессией; вторая попытка деплоя — ещё 16 минут. Опасность
|
||||
не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
|
||||
запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за
|
||||
ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих
|
||||
data/sql — то есть следующий блокирующий DDL повторил бы это.
|
||||
|
||||
`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный
|
||||
CREATE INDEX он не оборвёт, а очередь — не соберёт. Срабатывание = красный деплой
|
||||
(ON_ERROR_STOP=on) вместо тихой очереди перед приложением.
|
||||
|
||||
ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант
|
||||
«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4:
|
||||
`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW
|
||||
lock_timeout` → 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX
|
||||
CONCURRENTLY` — тот ждёт завершения параллельных транзакций через VirtualXactLock,
|
||||
и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная
|
||||
сессия просто держала открытую транзакцию (ACCESS SHARE — с CIC вообще не
|
||||
конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение
|
||||
изготавливало бы ровно ту аварию, от которой заведена проверка невалидных
|
||||
индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят
|
||||
противоположной политики, поэтому granularity — файл, а не раннер.
|
||||
|
||||
ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца
|
||||
сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL`
|
||||
молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента.
|
||||
|
||||
ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы
|
||||
задним числом не переписываются. Гейт смотрит только вперёд.
|
||||
|
||||
Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# каталог миграций -> минимальный NN, с которого правило обязательно.
|
||||
# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая).
|
||||
SQL_DIRS: dict[str, int] = {
|
||||
"data/sql": 189,
|
||||
"tradein-mvp/backend/data/sql": 250,
|
||||
}
|
||||
|
||||
# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE,
|
||||
# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать
|
||||
# в очередь и увести за собой запросы приложения.
|
||||
BLOCKING = re.compile(
|
||||
r"\b(?:"
|
||||
r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|"
|
||||
r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|"
|
||||
r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|"
|
||||
r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL"
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку).
|
||||
# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE
|
||||
# по-прежнему обязан прикрыть свой ALTER.
|
||||
CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE)
|
||||
|
||||
BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE)
|
||||
SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE)
|
||||
SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE)
|
||||
NN_PREFIX = re.compile(r"^(\d+)")
|
||||
|
||||
|
||||
def strip_noise(sql: str) -> str:
|
||||
"""Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на
|
||||
пробелы (сохраняя длину и переводы строк — номера строк не съезжают).
|
||||
|
||||
Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...'
|
||||
легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу.
|
||||
Тела $$...$$ (DO-блоки) НЕ затираются — там живёт исполняемый DDL.
|
||||
"""
|
||||
out: list[str] = []
|
||||
i, n = 0, len(sql)
|
||||
while i < n:
|
||||
ch = sql[i]
|
||||
nxt = sql[i + 1] if i + 1 < n else ""
|
||||
if ch == "-" and nxt == "-":
|
||||
while i < n and sql[i] != "\n":
|
||||
out.append(" ")
|
||||
i += 1
|
||||
elif ch == "/" and nxt == "*":
|
||||
depth = 1 # в PostgreSQL блочные комментарии вложенные
|
||||
out.append(" ")
|
||||
i += 2
|
||||
while i < n and depth:
|
||||
if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*":
|
||||
depth += 1
|
||||
out.append(" ")
|
||||
i += 2
|
||||
elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/":
|
||||
depth -= 1
|
||||
out.append(" ")
|
||||
i += 2
|
||||
else:
|
||||
out.append("\n" if sql[i] == "\n" else " ")
|
||||
i += 1
|
||||
elif ch == "'":
|
||||
out.append("'")
|
||||
i += 1
|
||||
while i < n:
|
||||
if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
|
||||
out.append(" ")
|
||||
i += 2
|
||||
continue
|
||||
if sql[i] == "'":
|
||||
break
|
||||
out.append("\n" if sql[i] == "\n" else " ")
|
||||
i += 1
|
||||
if i < n:
|
||||
out.append("'")
|
||||
i += 1
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def scan(sql: str) -> list[str]:
|
||||
"""-> список претензий к файлу; пустой список = файл в порядке."""
|
||||
clean = strip_noise(sql)
|
||||
statements = clean.split(";")
|
||||
|
||||
first_blocking: int | None = None
|
||||
blocking_text = ""
|
||||
for idx, stmt in enumerate(statements):
|
||||
if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt):
|
||||
first_blocking = idx
|
||||
blocking_text = " ".join(stmt.split())[:80]
|
||||
break
|
||||
if first_blocking is None:
|
||||
return []
|
||||
|
||||
set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None)
|
||||
if set_local is None:
|
||||
if any(SET_BARE_LT.search(s) for s in statements):
|
||||
return [
|
||||
f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). "
|
||||
"Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY "
|
||||
"в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN."
|
||||
]
|
||||
return [
|
||||
f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой "
|
||||
"строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет "
|
||||
"в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)."
|
||||
]
|
||||
|
||||
problems: list[str] = []
|
||||
if not any(BEGIN_STMT.search(s) for s in statements[:set_local]):
|
||||
problems.append(
|
||||
"`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). "
|
||||
"Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)."
|
||||
)
|
||||
if set_local > first_blocking:
|
||||
problems.append(
|
||||
f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — "
|
||||
"к моменту DDL он ещё не действует. Подними его сразу под BEGIN."
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
|
||||
assert scan(ok) == [], scan(ok)
|
||||
|
||||
# красное: ровно случай 250 до фикса
|
||||
bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
|
||||
assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0]
|
||||
assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n")
|
||||
assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n")
|
||||
assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n")
|
||||
assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n")
|
||||
|
||||
# красное: правильная строка, но в местах, где она не действует
|
||||
assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0]
|
||||
late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n"
|
||||
assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late))
|
||||
bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
|
||||
assert "без LOCAL" in scan(bare)[0]
|
||||
|
||||
# зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC)
|
||||
assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == []
|
||||
assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == []
|
||||
assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == []
|
||||
# ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL
|
||||
mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
|
||||
assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле"
|
||||
mixed_ok = (
|
||||
"CREATE INDEX CONCURRENTLY i ON t (c);\n"
|
||||
"BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
|
||||
)
|
||||
assert scan(mixed_ok) == [], scan(mixed_ok)
|
||||
|
||||
# зелёное: DDL, которого нет — он в комментарии или в строковом литерале
|
||||
assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == []
|
||||
assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == []
|
||||
assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == []
|
||||
assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == []
|
||||
assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == []
|
||||
# зелёное: не-DDL миграции (backfill/seed) правила не касаются
|
||||
assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == []
|
||||
assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == []
|
||||
print("selftest OK")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--selftest" in sys.argv:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
failed = False
|
||||
checked = 0
|
||||
for dirname, min_nn in SQL_DIRS.items():
|
||||
sql_dir = Path(dirname)
|
||||
if not sql_dir.is_dir():
|
||||
print(f"::error::{sql_dir} не найден — запускать из корня репозитория")
|
||||
return 1
|
||||
for path in sorted(sql_dir.glob("*.sql")):
|
||||
m = NN_PREFIX.match(path.name)
|
||||
if not m or int(m.group(1)) < min_nn:
|
||||
continue # применено на проде до внедрения гейта — не переписываем
|
||||
checked += 1
|
||||
for problem in scan(path.read_text(encoding="utf-8")):
|
||||
failed = True
|
||||
print(f"::error file={path}::{problem}")
|
||||
if failed:
|
||||
return 1
|
||||
print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
125
scripts/check-workflow-ports.py
Normal file
125
scripts/check-workflow-ports.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Гейт: сервис-контейнер сборки не должен публиковать порт, занятый на VPS (#2757).
|
||||
|
||||
ПОЧЕМУ. Forgejo-раннер запускает и job, и сервис-контейнеры с `--network host`
|
||||
(проверено: `docker inspect FORGEJO-ACTIONS-TASK-... -f {{.HostConfig.NetworkMode}}`
|
||||
→ `host`). На том же хосте слушают боевые сервисы. Поэтому обычный приём
|
||||
`services: postgres` + публикация `5432:5432` не поднимает тестовую БД, а тихо
|
||||
уводит job В ПРОД: занять порт сервис-контейнер не может, а `localhost:5432` из
|
||||
job'а — это боевой Postgres. В #2745 отказ спас только несовпадение пароля.
|
||||
|
||||
Список занятых портов — не догадки, а замер `ss -ltnp` на bot-server 2026-08-06.
|
||||
Опровергнутая по ходу предпосылка issue: Redis / CouchDB / GlitchTip НА ХОСТЕ НЕ
|
||||
СЛУШАЮТ (в `docker ps` у них `6379/tcp`, `5984/tcp`, `8000/tcp` без `->`, они
|
||||
живут в bridge-сетях) — сервис-контейнер с их портами ни с чем не столкнётся,
|
||||
и запрещать их значило бы ронять сборку на ровном месте.
|
||||
|
||||
Правильный способ дать сборке БД — как в ci-tradein.yml: явный `docker run`
|
||||
в bridge-сети БЕЗ публикации порта + строка подключения по IP контейнера.
|
||||
|
||||
Запуск: python3 scripts/check-workflow-ports.py [--selftest]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# host-порт -> кто его уже занял (ss -ltnp на bot-server, 2026-08-06)
|
||||
RESERVED: dict[int, str] = {
|
||||
22: "sshd хоста",
|
||||
53: "systemd-resolved (127.0.0.53)",
|
||||
80: "gendesign-caddy-1 (0.0.0.0:80)",
|
||||
443: "gendesign-caddy-1 (0.0.0.0:443)",
|
||||
2222: "forgejo, git-over-ssh (0.0.0.0:2222)",
|
||||
3000: "gendesign-frontend-1 (127.0.0.1:3000)",
|
||||
5432: "gendesign-postgres-1 — БОЕВАЯ БД (127.0.0.1:5432)",
|
||||
8000: "gendesign-backend-1 — боевой API (127.0.0.1:8000)",
|
||||
}
|
||||
|
||||
WORKFLOW_DIR = Path(".forgejo/workflows")
|
||||
|
||||
# "8080:80", "127.0.0.1:5432:5432", "5432:5432/tcp" -> (host_port, container_port)
|
||||
PORT_PAIR = re.compile(r"(?:(?:\d{1,3}\.){3}\d{1,3}:)?(\d{1,5}):(\d{1,5})(?:/(?:tcp|udp))?")
|
||||
PUBLISH_FLAG = re.compile(r"(?:^|\s)(?:-p|--publish)[=\s]+(\S+)")
|
||||
|
||||
|
||||
def scan(text: str) -> list[tuple[int, str, int]]:
|
||||
"""-> [(номер строки, строка, занятый host-порт)] для публикаций в ports:/-p."""
|
||||
hits: list[tuple[int, str, int]] = []
|
||||
in_ports = False
|
||||
ports_indent = 0
|
||||
|
||||
for lineno, raw in enumerate(text.splitlines(), 1):
|
||||
stripped = raw.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue # комментарий ничего не запускает (в них живут анти-примеры)
|
||||
indent = len(raw) - len(raw.lstrip())
|
||||
|
||||
if in_ports and (not stripped.startswith("- ") or indent <= ports_indent):
|
||||
in_ports = False
|
||||
|
||||
candidates: list[str] = []
|
||||
if stripped.startswith("ports:"):
|
||||
rest = stripped[len("ports:") :].strip()
|
||||
if rest:
|
||||
candidates.append(rest) # инлайн-форма: ports: ["5432:5432"]
|
||||
else:
|
||||
in_ports, ports_indent = True, indent
|
||||
elif in_ports:
|
||||
candidates.append(stripped)
|
||||
candidates += PUBLISH_FLAG.findall(raw) # docker run -p / --publish
|
||||
|
||||
for candidate in candidates:
|
||||
for host_port, _container_port in PORT_PAIR.findall(candidate):
|
||||
if int(host_port) in RESERVED:
|
||||
hits.append((lineno, stripped, int(host_port)))
|
||||
return hits
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
assert scan(" ports:\n - 5432:5432\n") == [(2, "- 5432:5432", 5432)]
|
||||
assert scan(' ports: ["127.0.0.1:8000:8000"]\n')[0][2] == 8000
|
||||
assert scan(" docker run -p 3000:3000 nginx\n")[0][2] == 3000
|
||||
assert scan(" docker run --publish=443:443 nginx\n")[0][2] == 443
|
||||
# безопасные формы и посторонние двоеточия — не должны ронять сборку
|
||||
assert scan(" ports:\n - 5432\n") == []
|
||||
assert scan(" ports:\n - 15432:5432\n") == []
|
||||
assert scan(" image: postgis/postgis:16-3.4\n") == []
|
||||
assert scan(' run: journalctl --since "2026-08-07 22:00"\n') == []
|
||||
assert scan(" # ports:\n # - 5432:5432\n") == []
|
||||
assert scan(" env:\n DATABASE_URL: pg://u:p@localhost:5432/test\n") == []
|
||||
# выход из блока ports: по отступу
|
||||
assert scan(" ports:\n - 9999:9999\n image: x\n - 80:80\n") == []
|
||||
print("selftest OK")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--selftest" in sys.argv:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
if not WORKFLOW_DIR.is_dir():
|
||||
print(f"::error::{WORKFLOW_DIR} не найден — запускать из корня репозитория")
|
||||
return 1
|
||||
|
||||
failed = False
|
||||
for path in sorted(WORKFLOW_DIR.glob("*.yml")):
|
||||
for lineno, line, port in scan(path.read_text(encoding="utf-8")):
|
||||
failed = True
|
||||
print(
|
||||
f"::error file={path},line={lineno}::публикация порта {port} — он занят "
|
||||
f"на VPS ({RESERVED[port]}). Раннер работает в сети хоста: контейнер "
|
||||
f"порт не займёт, а job уйдёт в этот прод-сервис (#2757). Поднимай "
|
||||
f"сервис через `docker run` в bridge-сети БЕЗ публикации и ходи по IP "
|
||||
f"контейнера — образец в .forgejo/workflows/ci-tradein.yml. || {line}"
|
||||
)
|
||||
if failed:
|
||||
return 1
|
||||
print("✓ ни один workflow не публикует занятый на VPS порт")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -5,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
|
||||
|
||||
|
|
|
|||
5
tradein-mvp/.gitignore
vendored
5
tradein-mvp/.gitignore
vendored
|
|
@ -15,7 +15,10 @@ frontend/node_modules/
|
|||
frontend/.next/
|
||||
frontend/out/
|
||||
frontend/dist/
|
||||
frontend/package-lock.json
|
||||
# frontend/package-lock.json — TRACKED с #2770: Dockerfile и CI ставят `npm ci`,
|
||||
# которому лок обязателен. Правишь frontend/package.json — регенерируй лок
|
||||
# (`npm install --legacy-peer-deps --no-audit --no-fund`) в том же PR, иначе
|
||||
# `npm ci` роняет сборку образа и весь deploy откатывается.
|
||||
frontend/next-env.d.ts
|
||||
|
||||
# Docker
|
||||
|
|
|
|||
46
tradein-mvp/CHANGELOG.md
Normal file
46
tradein-mvp/CHANGELOG.md
Normal 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
1
tradein-mvp/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
2.1.0
|
||||
|
|
@ -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, а некоторые
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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-ФЗ"
|
||||
"Политикой обработки персональных данных"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
20
tradein-mvp/backend/app/api/v1/version.py
Normal file
20
tradein-mvp/backend/app/api/v1/version.py
Normal 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}
|
||||
|
|
@ -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 в таске) —
|
||||
|
|
|
|||
|
|
@ -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) перед
|
||||
|
|
|
|||
83
tradein-mvp/backend/app/core/version.py
Normal file
83
tradein-mvp/backend/app/core/version.py
Normal 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()}"
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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). Раньше здесь везде
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
6→4pt, padding-top 8→6pt, line-height мета/wordmark строк 1.35→1.15,
|
||||
градиент-разделитель margin 6pt 0→3pt 0 — экономия ~13pt), И минимально
|
||||
поднять @page margin-bottom (19mm→21mm, +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%» на обложке), а не хардкод «10–18%»,
|
||||
|
|
@ -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 19mm→21mm (#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; }}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (loser→keeper). 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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue