diff --git a/.claude/rules/deploy.md b/.claude/rules/deploy.md index c01e72de..6b0077bb 100644 --- a/.claude/rules/deploy.md +++ b/.claude/rules/deploy.md @@ -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 → НЕ триггерит деплой diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 522cba14..0fdc2732 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -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 @@ -221,7 +221,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 — тоже, а каталог diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 53e1d59b..eccedb9e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -142,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 @@ -233,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) — @@ -246,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 diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index ee062b18..640d12ff 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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: @@ -200,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 @@ -211,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 @@ -233,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 @@ -246,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 @@ -273,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 @@ -287,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] @@ -821,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 "✓ деплой прошёл успешно" diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index f267444a..4485dd82 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -20,6 +20,11 @@ on: # деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы # до прода до следующего чужого коммита в backend/. - "ops/db-bootstrap/**" + # То же самое, ровно тот же класс бага (#2887): скрипт запускается на VM + # по cron из /opt/gendesign/ops/, куда попадает только через `git reset --hard` + # шага деплоя. Без этой строки правка скрипта лежала бы в main, а cron месяцами + # исполнял бы старую версию — молча и без единого сигнала. + - "ops/docker-prune.sh" workflow_dispatch: concurrency: @@ -71,10 +76,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 @@ -86,6 +130,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 @@ -102,10 +193,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 @@ -117,6 +245,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 @@ -133,10 +300,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 @@ -150,6 +354,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] @@ -467,8 +712,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 "✓ деплой прошёл успешно" diff --git a/Caddyfile b/Caddyfile index 1e6a0e25..8624f14f 100644 --- a/Caddyfile +++ b/Caddyfile @@ -251,6 +251,31 @@ meraocenka.ru { } } + # Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ + # самих документов (оферта ссылается на meraocenka.ru/refund, политика + # возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому + # обязаны резолвиться сами по себе, а не только длинным + # /trade-in/mera-public/. Обратное направление тоже рабочее: длинный + # путь ловит 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 diff --git a/backend/.gitignore b/backend/.gitignore index 6350e986..b073c123 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,3 @@ .coverage +# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит. +coverage.xml diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 74bfb5c9..3a8a233a 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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, diff --git a/backend/app/main.py b/backend/app/main.py index e0ac46cb..5f6507ed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -508,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") diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index 905d15de..8d89b0da 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -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 (иначе сеть diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 4ef5b203..3e4aaec3 100644 --- a/backend/app/services/forecasting/confidence_engine.py +++ b/backend/app/services/forecasting/confidence_engine.py @@ -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; почти всегда). diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index 33c85dd5..9a5d6817 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -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") diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index 9742dd43..95a9019b 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -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. diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 9c6b73e9..aaee692b 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -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, diff --git a/backend/app/services/site_finder/eesk_reserve_loader.py b/backend/app/services/site_finder/eesk_reserve_loader.py index c396321b..937e3b29 100644 --- a/backend/app/services/site_finder/eesk_reserve_loader.py +++ b/backend/app/services/site_finder/eesk_reserve_loader.py @@ -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 diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index a13585fb..644ebe1f 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -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, diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 08a253d1..7639c02d 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -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 diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index ff2d812b..5c9eaa82 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -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"}, } diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py index d4fd67be..055a2d34 100644 --- a/backend/tests/api/v1/test_analyze_competitors_status.py +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -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), НЕ сырой. diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index ad7a2248..cc61f0ef 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -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() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a3266cb6..5276e7b2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/integration/test_analyze_parcels_sql.py b/backend/tests/integration/test_analyze_parcels_sql.py index 3b2e8fff..b50d0577 100644 --- a/backend/tests/integration/test_analyze_parcels_sql.py +++ b/backend/tests/integration/test_analyze_parcels_sql.py @@ -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 AS (`` или ``, 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": "комфорт"}, + ) diff --git a/backend/tests/scrapers/test_nspd_grid_walk.py b/backend/tests/scrapers/test_nspd_grid_walk.py index 377ba20c..26d8e625 100644 --- a/backend/tests/scrapers/test_nspd_grid_walk.py +++ b/backend/tests/scrapers/test_nspd_grid_walk.py @@ -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"}) diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py index 49c48a8d..b946e28f 100644 --- a/backend/tests/services/forecasting/test_confidence_engine.py +++ b/backend/tests/services/forecasting/test_confidence_engine.py @@ -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% покрытия) — diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index bea84372..d1af98da 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -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 (слой жив, просто пуст).""" diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index 02ca21bc..470eef48 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -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 diff --git a/backend/tests/test_eesk_reserve_loader.py b/backend/tests/test_eesk_reserve_loader.py index df4dff70..3a1c9032 100644 --- a/backend/tests/test_eesk_reserve_loader.py +++ b/backend/tests/test_eesk_reserve_loader.py @@ -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 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index c432abcf..a62f2567 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -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" diff --git a/backend/tests/test_skip_guard_loud.py b/backend/tests/test_skip_guard_loud.py new file mode 100644 index 00000000..6f83d126 --- /dev/null +++ b/backend/tests/test_skip_guard_loud.py @@ -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 diff --git a/backend/tests/test_weight_profiles.py b/backend/tests/test_weight_profiles.py index 9cbb97c0..87b62883 100644 --- a/backend/tests/test_weight_profiles.py +++ b/backend/tests/test_weight_profiles.py @@ -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 diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 03be7b0d..41cd3e4d 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -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 | null>(null); + + return ( + + + + ); +} + +function AnalysisPageBody({ + cad, + appliedWeights, + onWeightsApply, +}: Props & { + appliedWeights: Record | null; + onWeightsApply: (weights: Record) => void; +}) { const [horizon, setHorizon] = useState(12); const queryClient = useQueryClient(); @@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) { {/* ── Группа «Стройка и рынок» ──────────────────────────────── */} - {/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */} - + {/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1 + поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор + пересчитывается по ползункам во ВСЕХ секциях (#2790). */} + {/* 5. Атмосфера — IMPLEMENTED in A11 */} diff --git a/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx new file mode 100644 index 00000000..4cc7e35e --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx @@ -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 | undefined> = []; + +const fetchMock = vi.fn(); + +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) + : 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( + + + , + ); +} + +/** Ползунок конкретной категории по подписи строки в панели весов. */ +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; + expect(applied.park).toBe(3); + expect(applied.tram_stop).toBe(-2); + // Нетронутые категории уходят как есть — бэкенд мержит поверх системных, + // но панель отправляет полный набор, чтобы ответ совпадал с ползунками. + expect(applied.school).toBe(1.5); + }); +}); diff --git a/frontend/src/components/site-finder/WeightProfilePanel.tsx b/frontend/src/components/site-finder/WeightProfilePanel.tsx index 60fcbe5b..5d5731f5 100644 --- a/frontend/src/components/site-finder/WeightProfilePanel.tsx +++ b/frontend/src/components/site-finder/WeightProfilePanel.tsx @@ -10,6 +10,7 @@ import { POI_LABELS, POI_WEIGHT_MAX, POI_WEIGHT_MIN, + SYSTEM_PROFILE_USER_ID, useCreateProfile, useWeightProfiles, type PoiCategoryKey, @@ -110,7 +111,18 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { } 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 () => { @@ -258,6 +270,7 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { {profiles.map((p) => ( ))} diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx index 670fb483..6c616440 100644 --- a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx +++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx @@ -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 | null; + /** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */ + onWeightsApply: (weights: Record) => 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 | null; + onWeightsApply: (weights: Record) => void; }) { - const [weights, setWeights] = useState>( - () => ({ ...POI_DEFAULT_WEIGHTS }), - ); - function toggleChip(key: keyof Omit) { onFiltersChange({ ...filters, [key]: !filters[key] }); } - function handleWeightsChange( - newWeights: Record, - _profileId: number | null, - ) { - setWeights(newWeights); - } - const chips: Array<{ key: keyof Omit; label: string; @@ -136,8 +133,8 @@ function Section31Settings({ margin: "4px 0 0", }} > - Фильтры применяются к конкурентам локально — без повторного запроса к - бэкенду + Радиус и фильтры применяются к конкурентам локально. Веса POI — + пересчёт анализа на бэкенде по кнопке «Применить»

@@ -259,8 +256,8 @@ function Section31Settings({ Профиль весов POI @@ -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({ radiusKm: 2, onlyUnderConstruction: false, @@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { {/* Sub-sections */}
- + {/* Competitor table — moved before 3.2/3.3 for context */} {filteredCompetitors.length > 0 && ( diff --git a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts index f84f2033..81f0e074 100644 --- a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts +++ b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts @@ -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; diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts index 4aa28bd7..1534e8ce 100644 --- a/frontend/src/lib/api/weightProfiles.ts +++ b/frontend/src/lib/api/weightProfiles.ts @@ -27,14 +27,18 @@ export interface WeightProfileCreate { description?: string | null; } -export interface WeightProfileUpdate { - profile_name?: string; - weights?: Record; - 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. @@ -103,13 +107,20 @@ const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles"; // ── Hooks ───────────────────────────────────────────────────────────────────── -/** List all weight profiles for a given user_id. */ +/** + * Профили пользователя + системные пресеты (#2790). + * + * `include_system=true` домешивает в конец списка три общих пресета (Эконом / + * Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`). + * Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в + * проде с 16.05.2026 и не были видны никому. + */ export function useWeightProfiles(userId: string) { return useQuery({ queryKey: ["weight-profiles", userId], queryFn: () => apiFetch( - `${BASE_PATH}?user_id=${encodeURIComponent(userId)}`, + `${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`, ), enabled: !!userId, }); @@ -132,35 +143,9 @@ export function useCreateProfile() { }); } -/** Update an existing weight profile by id. */ -export function useUpdateProfile(userId: string, profileId: number) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { - method: "PUT", - 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) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (profileId) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { method: "DELETE" }, - ), - 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 — хуки вернутся из истории (мертвее они там не станут). diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index ca64a76d..ebd0542b 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -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 | 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; } diff --git a/ops/docker-prune.sh b/ops/docker-prune.sh new file mode 100755 index 00000000..31ca32d1 --- /dev/null +++ b/ops/docker-prune.sh @@ -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 diff --git a/ops/glitchtip-auth-forwarder/forwarder.py b/ops/glitchtip-auth-forwarder/forwarder.py index 8fc3c5b4..79c782f3 100644 --- a/ops/glitchtip-auth-forwarder/forwarder.py +++ b/ops/glitchtip-auth-forwarder/forwarder.py @@ -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, ) diff --git a/ops/glitchtip-auth-forwarder/test_forwarder.py b/ops/glitchtip-auth-forwarder/test_forwarder.py new file mode 100644 index 00000000..a9c089b3 --- /dev/null +++ b/ops/glitchtip-auth-forwarder/test_forwarder.py @@ -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"}) diff --git a/ops/restore.sh b/ops/restore.sh old mode 100644 new mode 100755 diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index 6af9f3f9..535381e4 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -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 diff --git a/tradein-mvp/CHANGELOG.md b/tradein-mvp/CHANGELOG.md new file mode 100644 index 00000000..0e70377d --- /dev/null +++ b/tradein-mvp/CHANGELOG.md @@ -0,0 +1,46 @@ +# История версий «МЕРА» + +Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и +[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно +`## ` (машинно читается страницей истории версий). + +## 2.1.0 — 2026-08-10 + +Первая версия с явным версионированием. Номер продолжает ряд, который до этого +показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние +отчёты. + +### Добавлено + +- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и + реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень + уверенности в оценке. +- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав + аналогов и сделок, формирование выкупной стоимости. +- Аналитика по дому — история размещений объявлений и продаж в доме. +- История прошлых оценок в личном кабинете, автодополнение адреса при поиске. +- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история). +- Чат поддержки на сайте, в том числе без входа в личный кабинет. +- Публичный лендинг «МЕРА». +- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта + страница истории версий. + +### Изменено + +- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более + раннего технического макета. + +### Исправлено + +- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе + комнатности не было варианта «Студия», из-за чего для студии подбирались + однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась. +- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор + автоматически расширяется (студии, срок объявлений, новостройки, радиус), + а над результатом показывается предупреждение о сниженной точности и о том, + какие параметры пришлось расширить. +- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части + адресов улица не распознавалась, и разделы оставались пустыми. +- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов. +- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки + на обложке, некорректные допущения о сроке экспозиции. diff --git a/tradein-mvp/VERSION b/tradein-mvp/VERSION new file mode 100644 index 00000000..7ec1d6db --- /dev/null +++ b/tradein-mvp/VERSION @@ -0,0 +1 @@ +2.1.0 diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile index 4958161c..24d65162 100644 --- a/tradein-mvp/backend/Dockerfile +++ b/tradein-mvp/backend/Dockerfile @@ -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, а некоторые diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 86a5539a..aa251ffe 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -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": , "reason": } """ 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-сессию. @@ -2288,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]: @@ -2415,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(), diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index 6ac5ba07..a0eb307a 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -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-ФЗ" + "Политикой обработки персональных данных" ) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index aa34440c..2e4b391e 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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/`, 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, @@ -2242,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, + ) diff --git a/tradein-mvp/backend/app/api/v1/version.py b/tradein-mvp/backend/app/api/v1/version.py new file mode 100644 index 00000000..86729727 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/version.py @@ -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} diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 1702d860..8d6cf935 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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 в таске) — diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index e8c5fc04..0dd654ff 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -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) перед diff --git a/tradein-mvp/backend/app/core/version.py b/tradein-mvp/backend/app/core/version.py new file mode 100644 index 00000000..44101a3a --- /dev/null +++ b/tradein-mvp/backend/app/core/version.py @@ -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()}" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 099f5fa6..9fa819c4 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -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,28 +66,40 @@ 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, scrub_payment_request_body + from app.observability.sentry_scrub import ( + redact_telegram_bot_token, + scrub_payment_request_body, + stabilize_retry_error_fingerprint, + ) def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None: - """Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction - (#tgsupport-web, PR-D2) — см. app/tgbot_main.py._before_send (идентичная - композиция, тот же риск: теперь этот процесс тоже держит TelegramClient в - стек-фреймах при ошибке sendMessage, а include_local_variables=False ниже — - первый рубеж защиты). + """Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction + + RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) — + см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага, + тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит + TelegramClient в стек-фреймах при ошибке sendMessage, а + include_local_variables=False ниже — первый рубеж защиты. PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные — режет `request.data` целиком только для `/payments/*`, остальные пути (extra/contexts/traceback) по-прежнему проходят ключ-based scrub и token-redaction. Тот же обработчик передан ОБОИМ каналам ниже (before_send и before_send_transaction) — вчерашний баг в Птице закрыл - только error-канал, transaction-канал остался вообще без обработчика.""" + только error-канал, transaction-канал остался вообще без обработчика. + + RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/* + (suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см. + sentry_scrub.stabilize_retry_error_fingerprint.""" scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type] if scrubbed is None: return None scrubbed = scrub_pii_event(scrubbed, 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, @@ -224,6 +237,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"]) @@ -231,6 +264,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"]) diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py index 2e820673..0920486e 100644 --- a/tradein-mvp/backend/app/observability/sentry_scrub.py +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -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). @@ -105,6 +106,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).""" @@ -119,32 +145,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`, тот же @@ -152,16 +180,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 @@ -170,7 +203,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 @@ -236,3 +270,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[]`) — +# 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 diff --git a/tradein-mvp/backend/app/scheduler_main.py b/tradein-mvp/backend/app/scheduler_main.py index 2134d959..210b30fa 100644 --- a/tradein-mvp/backend/app/scheduler_main.py +++ b/tradein-mvp/backend/app/scheduler_main.py @@ -44,19 +44,35 @@ 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_payment_request_body, scrub_pii_event + from app.observability.sentry_scrub import ( + scrub_payment_request_body, + scrub_pii_event, + stabilize_retry_error_fingerprint, + ) - def _before_send(event: object, hint: dict[str, object]) -> object: + def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg] """PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event сегодня), но payments_confirm/payments_reconcile (PR-E, тот же `tradein-scraper` контейнер) будут звать Т-Банк API отсюда — belt-and- suspenders на случай, если платёжные данные когда-нибудь попадут в `request`/`extra`. Тот же обработчик на оба канала ниже — см. - app/main.py._before_send (идентичный мотив, не дублировать без причины).""" + app/main.py._before_send (идентичный мотив, не дублировать без причины). + + PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут + следом за платёжным body-wipe: этот процесс гоняет + `geocode_missing_listings` (ночной batch, сотни адресов за прогон) — + @retry-декорированные Nominatim-хелперы (app/services/geocoder.py) на + исчерпанных ретраях исторически плодили по отдельному GlitchTip issue + на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future). + См. sentry_scrub docstring. + """ scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type] if scrubbed is None: return None - return scrub_pii_event(scrubbed, hint) # type: ignore[arg-type] + scrubbed = scrub_pii_event(scrubbed, hint) + if scrubbed is None: + return None + return stabilize_retry_error_fingerprint(scrubbed, hint) sentry_sdk.init( dsn=settings.glitchtip_dsn, diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index ad4811d0..8f245bb0 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/app/services/cian_price_history.py b/tradein-mvp/backend/app/services/cian_price_history.py index fdf1518f..0e23310e 100644 --- a/tradein-mvp/backend/app/services/cian_price_history.py +++ b/tradein-mvp/backend/app/services/cian_price_history.py @@ -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", diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py index 6e227881..f40378e2 100644 --- a/tradein-mvp/backend/app/services/cian_session.py +++ b/tradein-mvp/backend/app/services/cian_session.py @@ -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). Раньше здесь везде diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py index 8a5b9c9f..1f68abf7 100644 --- a/tradein-mvp/backend/app/services/dadata.py +++ b/tradein-mvp/backend/app/services/dadata.py @@ -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. " diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index eeced17f..c41dbc19 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import ( estimate_via_cian_valuation, ) from scraper_kit.providers.yandex.valuation import ( - ValuationHouseMeta, YandexValuationResult, YandexValuationScraper, ) @@ -185,6 +184,75 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3 # ИТОГОВОЙ выборке как headline-источнику. HEADLINE_LISTINGS_MIN_N = 5 +# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live +# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4 +# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34; +# в радиусе 2 км rooms=0 17-29 м² — 327 активных лотов, rooms=1 — всего 10). +# Продукт: НИКОГДА не отказывать в оценке. Если после существующего 4-шагового +# каскада (tier0-когорта → без когорты → radius=fallback → area ±25%) выборка +# всё ещё < HEADLINE_LISTINGS_MIN_N — estimate_quality() продолжает ослаблять +# параметры подбора (см. #oblast-F блок там), от наименее к наиболее +# искажающему: (a) смежность комнатности, (b) свежесть объявлений, (c) сегмент +# (допустить новостройки), (d) радиус. Каждый применённый шаг попадает в +# AggregatedEstimate.relaxations (честный дисклеймер для пользователя) — гейт +# #oblast-E при этом больше НЕ обнуляет медиану (см. _price_from_inputs), а +# только помечает результат как низконадёжный. +RELAX_ROOMS_ADJACENT_DELTA = 1 # #oblast-F (a): rooms>=2 → BETWEEN rooms-1 AND rooms+1 +LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 дней +RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000) +RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум + +# #2632: подпись радиусного ослабления — ЕДИНСТВЕННОЕ место, где число +# фактического радиуса попадает в persisted-состояние (колонка relaxations). +# Отсюда же его читает GET-rehydrate (radius_from_relaxations) — иначе строка и +# парсер разъедутся, и карта опять начнёт рисовать не тот круг. +RADIUS_RELAXATION_PREFIX = "радиус расширен до " + + +def radius_relaxation_label(radius_m: int) -> str: + """RU-подпись шага (d) каскада #oblast-F для AggregatedEstimate.relaxations.""" + return f"{RADIUS_RELAXATION_PREFIX}{radius_m} м" + + +def radius_from_relaxations(relaxations: list[str] | None) -> int | None: + """Обратный разбор подписи: фактический радиус (м) или None, если его нет. + + Нужен на GET-rehydrate: сам search_radius_m колонкой не персистится, но + подпись каскада — да (миграция 255), и в ней лежит ровно то число, по + которому реально отбирались аналоги. + """ + for label in relaxations or []: + if label.startswith(RADIUS_RELAXATION_PREFIX): + digits = label[len(RADIUS_RELAXATION_PREFIX) :].removesuffix(" м").strip() + if digits.isdigit(): + return int(digits) + return None + + +def rehydrate_search_radius_m( + relaxations: list[str] | None, analog_distances_m: list[int | None] +) -> int | None: + """Фактический радиус подбора для СОХРАНЁННОЙ оценки (#2632). + + Колонки под search_radius_m нет, поэтому восстанавливаем из того, что + персистится: + 1) подпись каскада «радиус расширен до N м» — точное значение, то же, + что вернул POST (строки, созданные с 2026-08-10, миграция 255); + 2) иначе — размах сохранённых аналогов, но не меньше DEFAULT_RADIUS_M: + круг обязан вмещать пины, которые сам же и показывает (тот же приём + уже применён во v2 SourcesMap — там радиус круга = max distance_m). + None — расстояний нет вовсе (старые/anchor-строки): вызывающий честно + падает обратно на выбор пользователя, как и до этой правки. + """ + labelled = radius_from_relaxations(relaxations) + if labelled is not None: + return labelled + known = [d for d in analog_distances_m if d is not None] + if not known: + return None + return max(DEFAULT_RADIUS_M, max(known)) + + # #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals. # Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm² # to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ). @@ -419,6 +487,32 @@ def _repair_coefficient(repair_state: str | None) -> float: return _REPAIR_COEF.get(repair_state, 1.0) +# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state. +_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v} + + +def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float: + """#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а. + + Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом + (`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана + к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать + и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента + с «требует ремонта» медиана −6%, якорь нет, порог `median×1.15` фактически падает до + ×1.081, а сработавший blend возвращает половину этих −6% обратно вверх. + + Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная + эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ + применил к медиане, — чинится расхождение базисов, а не калибровка. + + Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение). + """ + base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or "")) + if base <= 0: + return 1.0 + return _repair_coefficient(target_repair) / base + + # ── Asking→sold correction ratio lookup (#648 Stage 3) ────────────────────── # Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент # ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.72–0.93). Estimator @@ -892,13 +986,13 @@ def _save_yandex_history_items( Batch semantics: single try/except; on any failure the batch rolls back. - #2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по - пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но - без единого объявления в истории, до записи панорамы не доходила — на проде это - 1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена - переноса: match_or_create_house теперь вызывается и для таких страниц (может - СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на - остальных 90% — новых сущностей класс не появляется, появляется недостающая доля. + #2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше + возврат стоял первым, и страница, отрисованная идеально, но без единого объявления + в истории, дома не заводила — на проде это 1519 оценок против 1360 домов с + историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака + «панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам + match_or_create_house, то есть дом и строка house_sources для таких страниц. Это + тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%. """ # Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16] # — stable across re-runs, distinguishes pages for different addresses. @@ -935,9 +1029,6 @@ def _save_yandex_history_items( result.address, ) - # Наблюдение о доме не зависит от того, есть ли на странице история объявлений. - _save_yandex_house_panorama(db, house_id, result.house) - if not result.history_items: return 0 @@ -1022,58 +1113,13 @@ def _save_yandex_history_items( return 0 -# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в -# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но -# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь — -# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету. -# -# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text -# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн, -# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет». -# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность -# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL. -def _save_yandex_house_panorama( - db: Session, - house_id: int | None, - meta: ValuationHouseMeta, -) -> None: - """Пишет houses.has_panorama по разобранной мете yandex_valuation. - - No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт - выше). Best-effort: ошибка логируется и глотается — оценка не должна падать из-за - справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает - только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история. - """ - if house_id is None: - return - if meta.year_built is None and meta.total_floors is None: - logger.debug( - "yandex_valuation: has_panorama не пишем для house_id=%s — " - "страница не подтверждена (нет ни года, ни этажности)", - house_id, - ) - return - try: - with db.begin_nested(): - db.execute( - text( - """ - UPDATE houses - SET has_panorama = CAST(:panorama AS boolean) - WHERE id = CAST(:hid AS bigint) - AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean) - """ - ), - {"hid": house_id, "panorama": meta.has_panorama}, - ) - db.commit() - except Exception as e: - logger.warning( - "yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s", - house_id, - e, - ) - db.rollback() +# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses +# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог +# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за +# 24.05–12.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200, +# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах, +# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова +# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего. # ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── @@ -1089,14 +1135,39 @@ def _fetch_house_imv_anchor( """Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`. В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand - скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по - house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем - запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе - самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии - house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress). + скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку: + WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort: + None при любой ошибке / отсутствии house_id / пустой таблице — estimator + продолжает на гео-tier'ах (no regress). + + СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована + ~2951 домов, fresh» — неправда по обоим пунктам, #2674): + 2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) — + поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из + них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после + фикса параметров 2026-08-06, всего 47. + Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic' + и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по + объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217 + (46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла + «панель» вместо «монолита»). + + ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка): + · Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка + находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у + 224 из 224, то есть просто выключил бы механизм. + · Массовая переоценка — не чинит корень и упирается в очередь: 1628 из + 2633 домов уже в imv_status='ok', а батч берёт только pending/ + transient_error, где сейчас 7144 дома при темпе ~25/прогон. + · Корень — не свежесть, а разные базисы ремонта; починено в #2677 + (`_anchor_repair_factor`), см. ниже. Returns dict {recommended_price, lower_price, higher_price, market_count, - rooms, area_m2} или None. + rooms, area_m2, renovation_type} или None. + + #2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito. + Без него якорь нельзя привести к базису ремонта оцениваемой квартиры + (см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op. """ if target_house_id is None: return None @@ -1106,7 +1177,7 @@ def _fetch_house_imv_anchor( text( """ SELECT recommended_price, lower_price, higher_price, - market_count, rooms, area_m2 + market_count, rooms, area_m2, renovation_type FROM house_imv_evaluations WHERE house_id = CAST(:hid AS bigint) AND recommended_price > 0 @@ -1288,6 +1359,80 @@ def _lookup_quarter_indexes( return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows} +# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов, +# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились — +# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр +# от DaData по адресу, а не KNN-подсказка), 2026-08-12: +# порог ответ выдан чужой квартал точность покрытие +# 15 м 475 26 94.5% 19.8% +# 25 м 748 59 92.1% 31.2% +# 50 м 1341 271 79.8% 55.9% +# 100 м 2090 644 69.2% 87.2% +# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане +# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м: +# точность 92%, и это ровно тот компромисс, который видно в замере, а не +# заимствованная у соседнего модуля константа. +_TARGET_QUARTER_RADIUS_M = 25.0 +# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на +# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже. +_TARGET_QUARTER_DEG_GATE = 0.0015 + + +def _lookup_target_quarter_by_coords( + db: Session, + lat: float | None, + lon: float | None, +) -> str | None: + """Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м). + + Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на + проде (замер 2026-08-12) — то есть свой кадастр цели известен почти никогда. + Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number` + (tasks/cadastral_geo_match) — здесь он применён к координатам САМОЙ цели, а не + к координатам аналога. + + Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен + только 3-й компонент кадастрового номера (квартал), а он у соседних зданий + одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не + полный кадастр, чтобы вызывающий код не принял его за кадастр дома. + + None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет + или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M. + """ + if lat is None or lon is None: + return None + try: + row = db.execute( + text( + """ + WITH p AS ( + SELECT ST_SetSRID( + ST_MakePoint(CAST(:lon AS double precision), + CAST(:lat AS double precision)), 4326) AS g + ) + SELECT cb.cad_num + FROM cad_buildings_local cb, p + WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision)) + AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision) + ORDER BY cb.geom <-> p.g + LIMIT 1 + """ + ), + { + "lat": lat, + "lon": lon, + "deg": _TARGET_QUARTER_DEG_GATE, + "radius": _TARGET_QUARTER_RADIUS_M, + }, + ).first() + except Exception as exc: + logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc) + return None + if row is None: + return None + return _quarter_from_cadastre(str(row[0])) + + def _apply_quarter_index( *, base_median_ppm2: float, @@ -1422,7 +1567,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: """#794: monthly {period_month: index_value} for region from sber_price_index. Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error. - #audit-5a: если latest месяц серии старее sber_index_max_age_days → warning. + + #2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest + периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по + построению: period_month — метка ПЕРВОГО числа месяца (≥30 суток уже на + закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых + наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени — + нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip + (event_level=ERROR). Свежесть теперь мерит ОДНО место — tasks/sber_freshness_monitor, + и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт), + а не календарь. """ for dash in SBER_COEFF_DASHBOARDS: try: @@ -1450,21 +1604,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows} if not series: continue - # #audit-5a: data-age guard — предупреждаем о stale СберИндексе. - latest = max(series) - today = datetime.now(tz=UTC).date() - age_days = (today - latest).days - if age_days > settings.sber_index_max_age_days: - logger.warning( - "sber_index stale #audit-5a: latest=%s age=%d days" - " (> sber_index_max_age_days=%d) region=%s dash=%s" - " — time-adjustment may be outdated", - latest.isoformat(), - age_days, - settings.sber_index_max_age_days, - region, - dash, - ) return series return {} @@ -1629,6 +1768,10 @@ def _fetch_dkp_corridor( series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION) adjusted: list[float] = [] factors_applied: list[float] = [] + # #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted — + # `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы + # заявленную свежесть коридора, не участвуя в его границах. + latest_deal: date | None = None for r in rows: ppm2 = r["price_per_m2"] if not ppm2: @@ -1640,6 +1783,8 @@ def _fetch_dkp_corridor( factor = _sber_time_factor(series, deal_month) adjusted.append(float(ppm2) * factor) factors_applied.append(factor) + if dd is not None and (latest_deal is None or dd > latest_deal): + latest_deal = dd ppm2_values = sorted(adjusted) # #oblast-D widen: a single street in a small non-EKB town can easily have @@ -1692,6 +1837,7 @@ def _fetch_dkp_corridor( city_rows = [] city_adjusted: list[float] = [] + city_latest: date | None = None for r in city_rows: ppm2 = r["price_per_m2"] if not ppm2: @@ -1702,6 +1848,8 @@ def _fetch_dkp_corridor( deal_month = date(dd.year, dd.month, 1) factor = _sber_time_factor(series, deal_month) city_adjusted.append(float(ppm2) * factor) + if dd is not None and (city_latest is None or dd > city_latest): + city_latest = dd if len(city_adjusted) > len(ppm2_values): logger.info( @@ -1712,6 +1860,10 @@ def _fetch_dkp_corridor( city, ) ppm2_values = sorted(city_adjusted) + # #2846: коридор теперь описывает city-выборку — и возраст обязан + # переехать вместе с числами, иначе подпись осталась бы от street- + # выборки, которую на витрине уже никто не видит. + latest_deal = city_latest if not ppm2_values: return None @@ -1734,6 +1886,7 @@ def _fetch_dkp_corridor( "median_ppm2": int(_percentile(ppm2_values, 0.5)), "high_ppm2": int(_percentile(ppm2_values, 0.90)), "period_months": period_months, + "latest_deal_date": latest_deal, } @@ -2562,12 +2715,28 @@ class PricingResult: # headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV # радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных). cv: float | None = None - # #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N - # (headline suppressed, listings_clean deliberately left intact — see gate - # comment above). Caller uses this to also keep the thin listings out of the - # display `analogs` cards when no anchor overrides the headline. 0 = either - # sufficient listings were used, or genuinely zero were found. + # #oblast-E/#oblast-F: >0 когда n листингов было найдено но ниже + # HEADLINE_LISTINGS_MIN_N. С #oblast-F (2026-08-10) больше НЕ обнуляет + # headline/listings_clean — median/n_analogs остаются реальными, поле лишь + # маркирует «низкая надёжность» (confidence='low' + честный explanation, + # см. gate comment ниже). 0 = либо выборка была достаточной, либо аналогов + # вообще не нашлось. listings_headline_thin_n: int = 0 + # #oblast-E (restored priority, product correction 2026-08-10): True когда + # headline построен из #oblast-D deals-corridor ИМЕННО потому, что базовая + # выборка листингов была тонкой (0 < n < HEADLINE_LISTINGS_MIN_N) И доступен + # достаточно надёжный ДКП-коридор (см. deals-headline-fallback блок ниже). + # Caller (estimate_quality) читает это чтобы (a) добавить relaxation-подпись + # «оценка по сделкам — мало объявлений рядом», (b) закэпить reliability на + # 'low' (не выше). False во всех остальных случаях, включая genuinely-zero + # listings deals-fallback (тот же блок, но без тонкой выборки позади). + deals_headline_due_to_thin_listings: bool = False + + +def _analog_word_dative(n: int) -> str: + """Дательный падеж существительного «аналог» для confidence_explanation + тонкой (#oblast-E) выборки — «построена по N аналогу/аналогам».""" + return "аналогу" if n == 1 else "аналогам" def _price_from_inputs( @@ -2592,10 +2761,17 @@ def _price_from_inputs( ratio_resolver: Callable[[float | None], tuple[float | None, str | None]], quarter_index_lookup: Callable[[str], tuple[float, int] | None], quarter_indexes_lookup: Callable[[list[str]], dict[str, float]], + # Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется + # ровно в одном месте, блок #764, и только ради 3-го компонента (квартала). + # Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели. target_house_cadnum: str | None, dadata_coarse: bool, geo: GeocodeResult, dadata_qc_geo: int | None, + # #2632: фактический радиус подбора (после каскада #oblast-F) — чтобы + # объяснение называло тот радиус, по которому реально искали. None → + # прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест). + search_radius_m: int | None = None, ) -> PricingResult: """Deterministic pricing orchestration — pure, synchronous, zero I/O. @@ -2646,45 +2822,82 @@ def _price_from_inputs( n_analogs = 0 cv = None - # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above). - # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust - # as a market median — suppress the AGGREGATE (median/range/n_analogs/cv) - # exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/ - # insufficient_data chain below all take the already-honest zero-analogs - # path automatically (no new branches there). `listings_clean` itself is - # deliberately LEFT INTACT (not cleared) — the same-building anchor's own - # ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell - # "genuinely zero nearby listings" from "some nearby listings, just too few - # to trust as THIS estimate's headline" — those are different confidence - # signals and clearing the list here would conflate them. The caller - # (estimate_quality) uses `listings_headline_thin_n` on the returned - # PricingResult to also keep suppressed listings out of the display - # `analogs` cards when no anchor overrides the headline (n_analogs - # invariant: cards shown ⊆ what n_analogs counts). + # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above) + # — priority RESTORED 2026-08-10 (product correction on top of #oblast-F): + # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find, but not trustworthy + # enough to headline on their OWN — a more reliable source should win when + # one exists. Two sub-cases: + # (i) a usable ДКП deals corridor is available (same threshold the + # #oblast-D deals-headline-fallback block below itself requires, + # DEALS_HEADLINE_FALLBACK_MIN_N deals with a positive median) → the + # listings AGGREGATE is suppressed to zero here so that block takes + # over the headline, EXACTLY like original #oblast-E. This is the + # Серов repro this gate exists for: n=3 listings must not outrank a + # 54-deal corridor. `listings_clean` stays intact (never cleared) — + # both for the anchor ghost-anchor guard (#1871) AND so + # estimate_quality() still surfaces these listings as display + # `analogs` cards even though they no longer drive n_analogs/median. + # (ii) no usable corridor → #oblast-F (never-block, 2026-08-10): keep the + # real thin median rather than refusing outright. By the time + # control reaches this function, estimate_quality() has already run + # the #oblast-F relaxation cascade (room-adjacency / freshness / + # novostroyki / radius) trying to grow the sample past the + # threshold — `listings` here is whatever that cascade could find. + # NOTE: `gate_ceded_to_deals` (local, this function only) is DIFFERENT from + # the `deals_headline_due_to_thin_listings` PricingResult field set later — + # this one fires as soon as the gate DECIDES to cede (used below to skip + # the repair-coefficient/explanation blocks safely, regardless of whether + # anchor later overrides); the field fires only once the #oblast-D + # deals-headline-fallback block ACTUALLY builds the headline from deals + # (anchor may still override in between — see that block). listings_headline_thin_n = 0 + gate_ceded_to_deals = False + # Outward PricingResult field — set True below, ONLY inside the actual + # #oblast-D deals-headline-fallback block, once it fires for THIS reason. + deals_headline_due_to_thin_listings = False if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N: listings_headline_thin_n = n_analogs - logger.info( - "headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing " - "listings-derived median (falling back to anchor/deals/insufficient_data)", - n_analogs, - HEADLINE_LISTINGS_MIN_N, + dkp_corridor_usable = ( + dkp_raw is not None + and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N + and dkp_raw.get("median_ppm2", 0) > 0 ) - median_ppm2 = 0.0 - q1_ppm2 = 0.0 - q3_ppm2 = 0.0 - median_price = 0 - range_low = 0 - range_high = 0 - n_analogs = 0 - cv = None + if dkp_corridor_usable: + gate_ceded_to_deals = True + logger.info( + "headline sufficiency gate #oblast-E: n=%d < %d listings, usable ДКП " + "corridor (n=%s) available — suppressing listings-derived median, " + "ceding headline to deals/anchor chain", + n_analogs, + HEADLINE_LISTINGS_MIN_N, + dkp_raw.get("count", 0) if dkp_raw else None, + ) + median_ppm2 = 0.0 + q1_ppm2 = 0.0 + q3_ppm2 = 0.0 + median_price = 0 + range_low = 0 + range_high = 0 + n_analogs = 0 + cv = None + else: + logger.info( + "headline sufficiency note #oblast-E: n=%d < %d listings, no usable " + "ДКП corridor — keeping real median, flagged low-reliability " + "(#oblast-F: never suppressed to zero without a fallback source)", + n_analogs, + HEADLINE_LISTINGS_MIN_N, + ) - # 4b. Repair coefficient — skipped when the headline was thin-suppressed - # above (median_price is already 0; applying a coefficient would leave it - # 0 but still emit a misleading "adjusted for repair state" note). + # 4b. Repair coefficient — applies to any real (non-zero) median, INCLUDING + # thin-but-kept (#oblast-F case ii) samples — a repair-state adjustment is + # meaningful there. Skipped when the gate ceded the headline to deals + # (gate_ceded_to_deals — median_price is already 0 above; applying a + # coefficient would leave it 0 but still emit a misleading "adjusted for + # repair state" note, same reasoning original #oblast-E used). repair_coef = _repair_coefficient(repair_state) repair_note = "" - if listings_clean and not listings_headline_thin_n and repair_coef != 1.0: + if listings_clean and not gate_ceded_to_deals and repair_coef != 1.0: median_price = int(median_price * repair_coef) range_low = int(range_low * repair_coef) range_high = int(range_high * repair_coef) @@ -2724,20 +2937,22 @@ def _price_from_inputs( fallback_used, area_widened, listings=listings_clean, + radius_used_m=search_radius_m, ) - # #oblast-E: honest override — _compute_confidence's generic "не найдено - # аналогов" is FALSE here (we DID find listings_headline_thin_n of them, - # just too few to trust). Stays the final explanation unless a later block - # (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest - # reasoning — both of those already check truthy `explanation` and either - # replace it (anchor) or append a construction-method clause that reads - # this same thin-count (deals-fallback), so no contradiction either way. - if listings_headline_thin_n: + # #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of + # the gate above (real thin median kept, no usable deals corridor to cede + # to). Case (i) (gate_ceded_to_deals) must NOT set this text — the + # deals-headline-fallback block below writes its OWN "built from + # Rosreestr deals" explanation; setting this first would leave a + # contradictory "Оценка построена по N аналогам" sentence stapled in front + # of it. Stays the final explanation unless a later block (anchor / + # #oblast-D deals-fallback) overwrites it with its OWN honest reasoning. + if listings_headline_thin_n and not gate_ceded_to_deals: confidence = "low" explanation = ( - f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., " - f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по " - "такой маленькой выборке слишком чувствительна к случайным лотам." + f"Оценка построена по {listings_headline_thin_n} " + f"{_analog_word_dative(listings_headline_thin_n)} — выборка мала, " + "точность снижена." ) # Tier note — информируем пользователя о качестве house-match @@ -2901,6 +3116,7 @@ def _price_from_inputs( anchor_total: int | None = None anchor_higher: int | None = None anchor_label: str | None = None + anchor_repair_note: str = "" if imv_anchor is not None and imv_anchor.get("recommended_price"): anchor_total = int(imv_anchor["recommended_price"]) anchor_higher = ( @@ -2919,6 +3135,22 @@ def _price_from_inputs( _imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold ), ) + # #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его + # не правим), а в blend уходит якорь, приведённый к базису ремонта target'а — + # тому же, в котором median_price уже посчитан выше (repair_coef). + _rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state) + if _rep_k != 1.0: + anchor_total = round(anchor_total * _rep_k) + anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None + anchor_repair_note = ", приведённой к состоянию ремонта квартиры" + logger.info( + "imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f" + " → anchor %d", + imv_anchor.get("renovation_type"), + repair_state, + _rep_k, + anchor_total, + ) elif imv_eval is not None and imv_eval.recommended_price: anchor_total = int(imv_eval.recommended_price) anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None @@ -2968,8 +3200,8 @@ def _price_from_inputs( median_price = new_median median_ppm2 = new_ppm2 explanation = (explanation or "") + ( - f" Оценка скорректирована по {anchor_label} " - f"({anchor_used / 1_000_000:.1f} млн ₽)." + f" Оценка скорректирована по {anchor_label}" + f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)." ) sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"}) # Диапазон расширяем даже если медиану не двигали. @@ -3000,13 +3232,16 @@ def _price_from_inputs( and median_price > 0 and area_m2 ): + # target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по + # координатам цели (_lookup_target_quarter_by_coords, вызывающий код). + # Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» — + # снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал + # цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м + # от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза + # по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой). + # Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя + # заменить чужим кварталом, он и есть предмет поправки. target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum) - if target_quarter is None: - for lot in listings_clean: - cq = _quarter_from_cadastre(lot.get("building_cadastral_number")) - if cq is not None: - target_quarter = cq - break if target_quarter is not None: qindex_result = quarter_index_lookup(target_quarter) @@ -3316,10 +3551,16 @@ def _price_from_inputs( n_analogs = 0 confidence = "low" cv = None - # #oblast-E: differentiate "genuinely zero listings" (unchanged wording) - # from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" — - # the latter must NOT claim "рядом нет объявлений" (false, contradicts the - # thin-sufficiency explanation already set above this block). + # #oblast-E (priority restored 2026-08-10): differentiate "genuinely + # zero listings" from "found some but below HEADLINE_LISTINGS_MIN_N, + # ceded to the deals corridor" (gate above, case i) — the latter must + # NOT claim "рядом нет объявлений" (false — some WERE found, just not + # trusted as headline on their own). `deals_headline_due_to_thin_ + # listings` (returned on PricingResult) tells estimate_quality() this + # was the thin-cession path specifically, so it can (a) append the + # "оценка по сделкам — мало объявлений рядом" relaxation label, (b) + # cap reliability at 'low' — a real deals corridor is a real signal, + # just not a listings-comp one. no_listings_clause = ( f" Из {listings_headline_thin_n} найденных объявлений недостаточно для " "надёжной медианы —" @@ -3331,9 +3572,11 @@ def _price_from_inputs( f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.)," " точность ориентировочная." ) + if listings_headline_thin_n: + deals_headline_due_to_thin_listings = True logger.info( "deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline" - " (listings=0 [thin_suppressed=%d], anchor=None)", + " (listings=0 [thin_ceded=%d], anchor=None)", int(median_ppm2), dkp_raw["count"], listings_headline_thin_n, @@ -3469,6 +3712,7 @@ def _price_from_inputs( listings_clean=listings_clean, cv=cv, listings_headline_thin_n=listings_headline_thin_n, + deals_headline_due_to_thin_listings=deals_headline_due_to_thin_listings, ) @@ -3730,6 +3974,9 @@ async def estimate_quality( house_type=target_house_type, total_floors=payload.total_floors, ) + # #oblast-F: True only when there WAS a cohort (year_built) filter to drop — + # surfaced later as the "снят фильтр по году постройки" relaxation label. + cohort_dropped = cohort_range is not None and len(listings_tier0) < MIN_ANALOGS_TIER_0 area_widened = False if len(listings) < 5: @@ -3778,6 +4025,146 @@ async def estimate_quality( analog_tier = analog_tier_wa search_radius_m = fallback_radius_m + # ── #oblast-F: relaxation cascade (never-block estimate, product decision + # 2026-08-10) ────────────────────────────────────────────────────────── + # Product requirement: NEVER refuse an estimate outright. If the 4-tier + # cascade above still leaves the sample thinner than HEADLINE_LISTINGS_MIN_N, + # keep loosening search criteria — least → most distorting — until either + # the sample clears the threshold or we run out of steps. Every step that + # ACTUALLY grew the sample is recorded in `relaxations` (RU labels, surfaced + # via AggregatedEstimate.relaxations + appended to confidence_explanation + # below) so a low-reliability estimate honestly explains why it stretched + # the search. Each step carries FORWARD the relaxations already applied by + # earlier steps (cumulative widening), not just its own single criterion. + relaxations: list[str] = [] + if cohort_dropped: + relaxations.append("снят фильтр по году постройки") + + cur_rooms_min: int | None = None + cur_rooms_max: int | None = None + cur_fresh_days = LISTINGS_FRESH_DAYS + cur_allow_novostroyki = False + cur_area_tolerance = 0.25 if area_widened else AREA_TOLERANCE + + async def _try_relax( + *, + rooms_min: int | None, + rooms_max: int | None, + fresh_days: int, + allow_novostroyki: bool, + radius_m: int, + area_tolerance: float, + ) -> tuple[list[dict[str, Any]], str] | None: + """Один шаг каскада #oblast-F. Возвращает (listings, tier) только если + кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана + (ничего реально не выиграла) и вызывающий её не применяет.""" + candidate, _, tier = await asyncio.to_thread( + _fetch_analogs, + db, + lat=geo.lat, + lon=geo.lon, + rooms=payload.rooms, + rooms_min=rooms_min, + rooms_max=rooms_max, + area=payload.area_m2, + radius_m=radius_m, + area_tolerance=area_tolerance, + fresh_days=fresh_days, + allow_novostroyki=allow_novostroyki, + full_address=geo.full_address, + target_house_id=target_house_id, + year_built=target_year, + house_type=target_house_type, + total_floors=payload.total_floors, + ) + if len(candidate) > len(listings): + return candidate, tier + return None + + # (a) room-count adjacency — самое дешёвое искажение: студия↔1-комн для + # rooms<=1 (live repro: Академика Парина 46/5, rooms=1 давал n=4, rooms=0 + # тем же адресом — n=34), иначе комнатность ±RELAX_ROOMS_ADJACENT_DELTA. + if len(listings) < HEADLINE_LISTINGS_MIN_N: + if payload.rooms <= 1: + try_rooms_min, try_rooms_max, rooms_label = 0, 1, "учтены студии" + else: + try_rooms_min = payload.rooms - RELAX_ROOMS_ADJACENT_DELTA + try_rooms_max = payload.rooms + RELAX_ROOMS_ADJACENT_DELTA + rooms_label = "комнатность ±1" + rooms_result = await _try_relax( + rooms_min=try_rooms_min, + rooms_max=try_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=cur_allow_novostroyki, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if rooms_result is not None: + listings, analog_tier = rooms_result + cur_rooms_min, cur_rooms_max = try_rooms_min, try_rooms_max + relaxations.append(rooms_label) + + # (b) свежесть объявлений: LISTINGS_FRESH_DAYS (14) → LISTINGS_FRESH_DAYS_RELAXED (60). + if len(listings) < HEADLINE_LISTINGS_MIN_N: + fresh_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=LISTINGS_FRESH_DAYS_RELAXED, + allow_novostroyki=cur_allow_novostroyki, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if fresh_result is not None: + listings, analog_tier = fresh_result + cur_fresh_days = LISTINGS_FRESH_DAYS_RELAXED + relaxations.append("объявления за 60 дней") + + # (c) снять guard listing_segment — допустить новостройки в comp-пул. + if len(listings) < HEADLINE_LISTINGS_MIN_N: + novo_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=True, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if novo_result is not None: + listings, analog_tier = novo_result + cur_allow_novostroyki = True + relaxations.append("учтены новостройки") + + # (d) радиус → max(текущий, RELAX_RADIUS_STEP1_M), затем → RELAX_RADIUS_STEP2_M. + # Пропускается, когда пользователь явно зафиксировал radius_m — тот же + # контракт, что и у существующего radius-fallback выше (#2044: сервер не + # авто-расширяет поиск за пределы выбранного пользователем радиуса). + if len(listings) < HEADLINE_LISTINGS_MIN_N and payload.radius_m is None: + for relax_radius in (max(search_radius_m, RELAX_RADIUS_STEP1_M), RELAX_RADIUS_STEP2_M): + if relax_radius <= search_radius_m: + continue + radius_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=cur_allow_novostroyki, + radius_m=relax_radius, + area_tolerance=cur_area_tolerance, + ) + if radius_result is not None: + listings, analog_tier = radius_result + search_radius_m = relax_radius + fallback_used = True + if len(listings) >= HEADLINE_LISTINGS_MIN_N: + break + + # Area/radius relaxations derived from FINAL state (covers both the + # pre-existing Tier B/C radius/area widening above AND step (d) here) — + # a single check avoids double-labelling the same underlying widening. + if area_widened: + relaxations.append("площадь ±25%") + if search_radius_m > base_radius_m: + relaxations.append(radius_relaxation_label(search_radius_m)) + # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы # corridor_high был доступен для Tier C-гейта и soft-клампа headline. @@ -3920,6 +4307,22 @@ async def estimate_quality( # ── Coarse-geo signals ──────────────────────────────────────────────────── dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2 + # ── #764: квартал ЦЕЛИ для квартального индекса ────────────────────────── + # Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) → + # квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога + # сюда больше не попадает вообще (см. блок #764 в _price_from_inputs). + # Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на + # точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config. + target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None + if ( + settings.estimate_quarter_from_coords_enabled + and not target_quarter_cadnum + and geo is not None + ): + target_quarter_cadnum = await asyncio.to_thread( + _lookup_target_quarter_by_coords, db, geo.lat, geo.lon + ) + # ── DB-callable wrappers injected into pure pricing ─────────────────────── def _ratio_resolver( appm2: float | None, @@ -3964,10 +4367,11 @@ async def estimate_quality( ratio_resolver=_ratio_resolver, quarter_index_lookup=_qi_lookup, quarter_indexes_lookup=_qis_lookup, - target_house_cadnum=dadata.house_cadnum if dadata else None, + target_house_cadnum=target_quarter_cadnum, dadata_coarse=dadata_coarse, geo=geo, dadata_qc_geo=dadata.qc_geo if dadata else None, + search_radius_m=search_radius_m, ) # Unpack pricing result @@ -3990,7 +4394,73 @@ async def estimate_quality( ratio_basis = pr.ratio_basis listings_clean = pr.listings_clean cv = pr.cv - listings_headline_thin_n = pr.listings_headline_thin_n + + # #oblast-E (priority restored 2026-08-10): headline ceded to the ДКП deals + # corridor because the base listings sample was thin — a real signal (real + # Rosreestr deals), just not a listings-comp one. Recorded as its own + # relaxation label (distinct from the #oblast-F cascade labels above, which + # describe attempts to grow the LISTINGS sample — this describes switching + # sources entirely). + if pr.deals_headline_due_to_thin_listings: + relaxations.append("оценка по сделкам — мало объявлений рядом") + + # #oblast-F: reliability tier derived from the FINAL n_analogs (post anchor/ + # deals-fallback override above) — independent of `confidence` (older + # unique-address/IQR metric, see AggregatedEstimate docstring). If the + # #oblast-F cascade had to relax anything to get here, an otherwise-"ok" + # sample is downgraded to "low" — the raw count looks fine, but it only + # exists because we widened the search past the user's exact criteria. + if n_analogs >= 8: + reliability: Literal["ok", "low", "very_low"] = "ok" + elif n_analogs >= 3: + reliability = "low" + else: + reliability = "very_low" + if pr.deals_headline_due_to_thin_listings: + # #oblast-E: n_analogs is 0 here (listings-comp count, honestly zero — + # the headline came from deals instead), which would otherwise bucket + # to 'very_low'. Pin to 'low' instead: a 54-deal Rosreestr corridor is + # a real, meaningful signal — "не выше low" (product spec), not + # "почти нет сигнала" (what 'very_low' would imply here). + reliability = "low" + elif relaxations and reliability == "ok": + reliability = "low" + + # #conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11): + # `confidence` (unique-address/IQR metric, _compute_confidence above) and + # `reliability` (n_analogs-bucket + #oblast-F relaxation-cascade metric, + # just computed) used to be fully independent — the cascade's room- + # adjacency/freshness/novostroyki steps never set `fallback_used`, so + # _compute_confidence stayed blind to them and kept scoring a wide, + # tight-IQR (post-relaxation) sample as "high", while `reliability` + # honestly read "low" from the SAME relaxations. Live prod repro: n=39, + # confidence='high', reliability='low', 3 relaxations applied — client + # saw a "высокая уверенность" badge next to a "точность снижена" banner + # simultaneously. Fix applied at ONE point, after BOTH values are final + # (NOT spread across the cascade steps above) — see + # _cap_confidence_by_reliability docstring for the exact rule. + original_confidence = confidence + confidence = _cap_confidence_by_reliability(confidence, reliability) + confidence_downgraded_by_reliability = confidence != original_confidence + + if relaxations: + relaxations_detail = "Применены послабления подбора: " + ", ".join(relaxations) + "." + if confidence_downgraded_by_reliability: + # #conf-cap: explanation must LEAD with the accuracy-reduced summary + # when confidence was actually pulled down by the cap above — the + # old ordering opened with "Найдено 39 аналогов ... разброс ±6%" + # (reads as a confident, unqualified headline) and only tacked the + # relaxations sentence on at the very end, which itself contradicted + # the (now-capped) confidence badge just as much as the raw scores did. + explanation = ( + "Оценка построена с расширенными параметрами подбора — точность " + "снижена. " + (explanation or "") + " " + relaxations_detail + ) + else: + # No visible confidence change (e.g. it was already 'low'/'medium' + # pre-cap, or reliability=='ok' downgraded nothing) — keep the + # existing tail-append shape, unaffected by #conf-cap. + explanation = (explanation or "") + " " + relaxations_detail # 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals. # Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков @@ -4026,14 +4496,6 @@ async def estimate_quality( # иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому # набору (или = None при пустом listings_clean, хотя у комплов данные есть). metadata_lots = display_pool - elif listings_headline_thin_n: - # #oblast-E: headline was suppressed (thin radius sample, no anchor to - # take over) — do NOT surface those same listings as display cards - # either, else `analogs` would show N cards while n_analogs==0 (broken - # invariant, same dishonesty this gate exists to remove). Degrades to - # the exact same empty-display state as "genuinely zero listings". - analogs_lots = [] - metadata_lots = [] else: # display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что # дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs. @@ -4088,6 +4550,7 @@ async def estimate_quality( expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis, + relaxations, reliability, created_by, expires_at, consent, client_ip, consent_policy_version, consent_text_snapshot @@ -4109,6 +4572,7 @@ async def estimate_quality( :expected_sold_price, :expected_sold_range_low, :expected_sold_range_high, :expected_sold_per_m2, :asking_to_sold_ratio, :ratio_basis, + CAST(:relaxations_json AS jsonb), :reliability, :created_by, :expires_at, :consent, CAST(:client_ip AS inet), :consent_policy_version, @@ -4162,6 +4626,12 @@ async def estimate_quality( "expected_sold_per_m2": expected_sold_per_m2, "asking_to_sold_ratio": asking_to_sold_ratio, "ratio_basis": ratio_basis, + # #oblast-F / GET-rehydrate (PR #2823 open follow-up): персистим + # relaxations/reliability вместе с median — раньше эти поля жили + # только в POST-ответе, и красный баннер «точность снижена» + # пропадал при открытии оценки по сохранённой ссылке (?id=). + "relaxations_json": json.dumps(relaxations, ensure_ascii=False), + "reliability": reliability, "created_by": created_by, "expires_at": expires_at, **_estimate_consent_persist_fields(require_consent, client_ip), @@ -4292,6 +4762,7 @@ async def estimate_quality( target_lat=geo.lat, target_lon=geo.lon, target_city_ambiguous=geo.city_ambiguous, + target_address_refined=geo.address_refined, sources_used=sources_used, data_freshness_minutes=freshness_min, last_scraped_at=last_scraped_at, @@ -4341,10 +4812,11 @@ async def estimate_quality( metro_nearest=(dadata.metro if dadata and dadata.metro else []), address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None), analog_tier=api_analog_tier, # type: ignore[arg-type] - # #2632: фактический радиус отбора listings-аналогов (см. search_radius_m - # def выше) — может отличаться от payload.radius_m (выбор пользователя), - # когда сервер сам расширил поиск. None только у _empty_estimate (поиск - # аналогов вообще не выполнялся). + # #2632: обе величины рядом — с чего поиск начали (выбор пользователя или + # DEFAULT_RADIUS_M) и на чём закончили. Расходятся, когда сервер сам + # расширил поиск; подменять одно другим нельзя — это ровно тот дефект + # метки, что чинили в #2811. None у _empty_estimate (поиск не выполнялся). + requested_radius_m=base_radius_m, search_radius_m=search_radius_m, premium_building=premium_building, premium_building_median_ppm2=premium_building_median_ppm2, @@ -4355,6 +4827,11 @@ async def estimate_quality( cv=cv, source_counts=source_counts, created_at=now, + # #oblast-F (never-block relaxation cascade) — применённые ослабления + # подбора + производная надёжность выборки (см. reliability computation + # above, независимо от `confidence`). + relaxations=relaxations, + reliability=reliability, ) @@ -4760,11 +5237,27 @@ def _extract_short_addr(full_address: str | None) -> str | None: # Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе. # Работает для FORWARD и REVERSE форматов Nominatim. +# #pdf-honesty/#oblast-E-follow-up (live-prod fix 2026-08-10): точка после +# сокращений (ул., пр., пер., ш., наб., пл., мкр.) сделана ОПЦИОНАЛЬНОЙ +# (`\.?`) — DaData (основной источник адресов, дом-уровень геокодинга) отдаёт +# формат БЕЗ точки: "ул Академика Парина", а не "ул. Академика Парина". Старый +# regex требовал точку строго → keyword не матчился НИ НА ОДНОМ DaData-адресе +# → street-deals/sales-vs-listings блоки молчали (WARNING "could not extract +# street") на КАЖДОМ запросе с DaData-геокодингом, не только на репро-адресе. +# Порядок альтернатив принципиален: `ул\.?` идёт ПЕРЕД полным словом `улица` — +# но это безопасно за счёт backtracking Python `re` (NFA, не POSIX longest- +# match): если `ул\.?` матчит только "ул" из "улица" и последующий `\s+` +# после этого не находит пробел (следующий символ — "и"), движок +# откатывается и пробует СЛЕДУЮЩУЮ альтернативу — "улица" — которая матчит +# полностью. Проверено на "ул. X" / "ул X" / "улица X" — все три дают +# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная +# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже +# покрывает оба варианта (с точкой и без). _STREET_KW_RE = re.compile( r"(? NOW() - (:fresh_days || ' days')::interval @@ -4956,8 +5452,13 @@ _COMMON_WHERE = """ -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает -- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента — - -- это вторичка или неклассифицированный объект). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki + -- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) — + -- дефолт False сохраняет канон-guard byte-identical. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled, -- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒ -- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает @@ -5012,6 +5513,14 @@ def _fetch_analogs( cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive target_house_id: int | None = None, # #6: canonical house for same-building Tier S + # #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты + # byte-identical старому поведению (exact rooms match / 14 дней / без + # новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО + # когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring. + rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms + rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms + fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED + allow_novostroyki: bool = False, # #oblast-F (c) ) -> tuple[list[dict[str, Any]], bool, str]: """SELECT аналогов — трёхуровневый house-match (S → H → W). @@ -5052,21 +5561,29 @@ def _fetch_analogs( """ area_min = area * (1 - area_tolerance) area_max = area * (1 + area_tolerance) + # #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms), + # byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a + # widened range only past HEADLINE_LISTINGS_MIN_N thinness. + eff_rooms_min = rooms if rooms_min is None else rooms_min + eff_rooms_max = rooms if rooms_max is None else rooms_max # #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL # (безвредно без фильтра); статический фрагмент управляет только применением # `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3 # bind-параметры не задействованы, инъекции нет. dup_filter = "AND rn_dup = 1" base_params: dict[str, Any] = { - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, "cohort_year_max": cohort_year_max, # #2012: is_apartments hard-filter — see _COMMON_WHERE comment above. "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, + # #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above. + "allow_novostroyki": allow_novostroyki, } # ── Tier S (canonical): same building via house_id_fk ───────────────────── @@ -5391,7 +5908,8 @@ def _fetch_analogs( FROM listings WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) AND (geo_precision IS DISTINCT FROM 'city') - AND rooms = :rooms + -- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же. + AND rooms BETWEEN :rooms_min AND :rooms_max AND area_m2 BETWEEN :area_min AND :area_max AND is_active = true AND scraped_at > NOW() - (:fresh_days || ' days')::interval @@ -5408,7 +5926,11 @@ def _fetch_analogs( ) -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий -- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению). AND ( @@ -5450,16 +5972,18 @@ def _fetch_analogs( "lat": lat, "lon": lon, "radius": radius_m, - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "target_year": year_built, "target_house_type": house_type, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, # NEW "cohort_year_max": cohort_year_max, # NEW "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012 + "allow_novostroyki": allow_novostroyki, # #oblast-F (c) }, ) .mappings() @@ -6285,6 +6809,52 @@ def _downgrade_confidence(confidence: str) -> str: return {"high": "medium", "medium": "low", "low": "low"}.get(confidence, confidence) +_CONFIDENCE_ORDER: dict[str, int] = {"low": 0, "medium": 1, "high": 2} + + +def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str: + """#conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11). + + `confidence` (this function's sibling `_compute_confidence` — unique- + address/IQR metric) and `reliability` (estimate_quality — n_analogs-bucket + + #oblast-F relaxation-cascade metric) used to be computed fully + independently. The #oblast-F cascade's room-adjacency/freshness/ + novostroyki steps never set `fallback_used`, so `_compute_confidence` + stayed blind to them and could keep scoring a post-relaxation sample + (wide, tight-IQR) as "high" while `reliability` — which DOES see the + relaxations — honestly read "low". Live prod repro: n_analogs=39, + confidence='high', reliability='low', relaxations=[3 items] — a client + could see a "высокая уверенность" badge next to a "точность снижена" + banner on the SAME estimate. + + Rule (applied ONCE, by the caller, after both values are final — NOT + spread across the relaxation cascade): + reliability == 'very_low' → confidence forced to 'low'. + reliability == 'low' → confidence capped at 'medium' (high→medium; + medium/low pass through unchanged). + reliability == 'ok' → confidence untouched (unaffected — this is + the common, unrelaxed case; byte-identical + to pre-#conf-cap behaviour). + + Never RAISES confidence — only caps/lowers it. Unknown confidence/ + reliability values pass through unchanged (defensive, mirrors + _downgrade_confidence above). + """ + if reliability == "very_low": + return "low" + if reliability == "low": + current = _CONFIDENCE_ORDER.get(confidence) + cap = _CONFIDENCE_ORDER["medium"] + if current is not None and current > cap: + return "medium" + return confidence + + +def _radius_ru(radius_m: int) -> str: + """«2 км» / «5 км» / «1500 м» — целые километры словом, остальное метрами.""" + return f"{radius_m // 1000} км" if radius_m % 1000 == 0 else f"{radius_m} м" + + def _compute_confidence( n_analogs: int, median_ppm2: float, @@ -6293,6 +6863,7 @@ def _compute_confidence( fallback_radius_used: bool, area_widened: bool = False, listings: list[dict] | None = None, + radius_used_m: int | None = None, ) -> tuple[str, str]: """Confidence + explanation string. @@ -6326,7 +6897,12 @@ def _compute_confidence( iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0 notes = [] if fallback_radius_used: - notes.append("расширили радиус до 2 км") + # #2632: НАЗЫВАЕМ фактический радиус. До этого здесь было прибито «2 км» + # — константа старого 1км→2км fallback'а, а каскад #oblast-F уходит до + # 3/5 км: текст утверждал 2 км там, где поиск реально шёл по 5 км + # (прод, 2026-08-11: relaxations «радиус расширен до 5000 м», самый + # дальний аналог 4381 м, объяснение — «расширили радиус до 2 км»). + notes.append(f"расширили радиус до {_radius_ru(radius_used_m or FALLBACK_RADIUS_M)}") if area_widened: notes.append("расширили допуск по площади до ±25%") fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else "" @@ -6510,6 +7086,7 @@ def _empty_estimate( confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, + relaxations, reliability, created_by, expires_at, consent, client_ip, consent_policy_version, consent_text_snapshot @@ -6522,6 +7099,11 @@ def _empty_estimate( 'low', :explanation, 0, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, + -- #oblast-F: поиск аналогов вообще не выполнялся (geocode failed / + -- no coords) — каскад послаблений не запускался, relaxations честно + -- пуст; reliability='very_low' зеркалит то, что возвращает Python + -- ниже (см. AggregatedEstimate(..., reliability="very_low")). + '[]'::jsonb, 'very_low', :created_by, :expires_at, :consent, CAST(:client_ip AS inet), :consent_policy_version, @@ -6570,4 +7152,7 @@ def _empty_estimate( # Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна. address_precision=None, analog_tier=None, # нет данных при empty estimate + # #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся + # (geocode failed / no coords), а не просто "мало нашлось". + reliability="very_low", ) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 425dda5f..ef896560 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -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'
' + f"{_html.escape(product_version_line())}
" + ) + return ( + f"
" f'
' + f'padding-bottom:6pt;margin-bottom:3pt;">' f"{mark_html}" f'{meta_html}' f"
" + f"{version_html}" + f"
" ) +# Блок 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""" -
+
{note_html} -
+ color:{_MUTED_2};line-height:1.15;">
ОТЧЁТ {_html.escape(report_num)} ДАТА {report_date.strftime("%d.%m.%Y")} {valid_until_html}
-
@@ -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()} - - {_REPORT_ENGINE_VERSION}
+
+ {_html.escape(_PDF_135FZ_FOOTER_NOTICE)} +
""" @@ -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'Ссылка доступна до' + f'' + f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}" + 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 {_mono(today.strftime("%d.%m.%Y"))} Срок действия данных до {_mono(expires.strftime("%d.%m.%Y"))} + {retain_until_row} Адрес{address} Год постройки {year_label} @@ -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'

' + "Оценка построена по зарегистрированным сделкам Росреестра — подходящих " + "объявлений поблизости почти нет. Объявления ниже приведены справочно, " + "для наглядности рынка.

" + ) + + +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""" +
+ Точность оценки снижена. + {detail} — данные ниже приведены с этой оговоркой. +
+""" + + # ── 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"""
@@ -1306,6 +1436,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra Количество объявлений по аналогичным объектам {_mono(f"{n_total} шт.")} + {deals_sourced_note}
Источники данных
{sources_html}
@@ -1330,6 +1461,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra + {reliability_note}

Диапазон цен в объявлениях

@@ -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) - - + {f''' + ''' + if deals_as_of else ""}
Количество сделок по аналогичном объектам {_mono(f"{n_deals} шт.")}
Период сделок - {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}
Сделки{_mono(deals_as_of)}
Источники данных
@@ -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; }} }} diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index f3cdb12e..2d3c7096 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -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 +# (`` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). 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. diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index 0f244b97..511386a1 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -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 = , 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() diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index edb02137..7dd6f1b9 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -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, ) diff --git a/tradein-mvp/backend/app/services/matching/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py index 89d71531..2e56f1eb 100644 --- a/tradein-mvp/backend/app/services/matching/conflict_resolution.py +++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py @@ -71,7 +71,11 @@ 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"], diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 413b82ca..1effa723 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -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, ) diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 29b6aaab..6c2513ad 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -217,7 +217,9 @@ async def _job_deactivate_stale( ) -> None: from app.core.config import settings as _settings from app.tasks.deactivate_stale_avito import ( + CAP_MULT, DEFAULT_MIN_CONFIRMATIONS, + DEFAULT_REVISIT_FLOOR_QUANTILE, deactivate_stale_listings, ) @@ -229,6 +231,22 @@ async def _job_deactivate_stale( # получает страховочный порог, а не «деактивируй вслепую». Посчитанные по # источнику пороги приходят из default_params (миграция 219). min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS) + # Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию: + # незасеянное расписание не должно снимать объявления по порогу ниже собственного + # хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0. + revisit_floor_quantile: float = params.get( + "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE + ) + # Пустой (NULL) listing_segment -- легаси-строки до миграции 011 + жертвы + # отсутствующего COALESCE в ON CONFLICT (base.py upsert никогда не перезаписывает + # listing_segment на повторном скрейпе). Отдельный явный предикат IS NULL, а не + # элемент :segments (ANY(...) никогда не матчит NULL) -- см. deactivate_stale_avito.py. + null_segment_only: bool = params.get("null_segment_only", False) + # Потолок эффективного TTL (см. CAP_MULT в deactivate_stale_avito.py) — множитель, + # а не голая константа: источник с непропорционально длинным хвостом переобхода + # относительно своего ttl_days переопределяет его через default_params (ключ + # "cap_mult"), не трогая дефолт для остальных источников. + cap_mult: float = params.get("cap_mult", CAP_MULT) loop = asyncio.get_event_loop() await loop.run_in_executor( @@ -241,6 +259,9 @@ async def _job_deactivate_stale( segments=segments, staleness_column=staleness_column, min_confirmations=min_confirmations, + revisit_floor_quantile=revisit_floor_quantile, + null_segment_only=null_segment_only, + cap_mult=cap_mult, ), ) @@ -439,6 +460,12 @@ async def _job_house_imv_backfill( # алерт — но пустая очередь при ежедневном расписании это и правда сигнал. "total_seen": result.checked, "new_count": result.saved, + # #2674: из скольких слотов пакета взяты дома на ПОВТОР (transient_error) + # и сколько домов ушло в no_params до пакета одним запросом. Без этих + # двух счётчиков в scrape_runs.counters проверить, что застрявшие + # действительно возвращаются в очередь, можно только по houses. + "retried": result.retried, + "premarked": result.premarked, } # Честный статус (#2674, тот же класс, что #2670/#2657): успех — это # «сделали то, что собирались», а не «не поймали известное исключение». diff --git a/tradein-mvp/backend/app/services/proxy_egress.py b/tradein-mvp/backend/app/services/proxy_egress.py new file mode 100644 index 00000000..8227a40a --- /dev/null +++ b/tradein-mvp/backend/app/services/proxy_egress.py @@ -0,0 +1,334 @@ +"""Резолвер egress-прокси по источнику для ad-hoc сессий вне scrape_run (#2825). + +ПРОБЛЕМА (доказана на проде 2026-08-10): `settings.scraper_proxy_url` (и его алиасы +`cian_proxy_url`/`yandex_proxy_url`, все три — прямая проекция ENV `SCRAPER_PROXY_URL`, +см. `app.core.config`) был ЕДИНСТВЕННЫМ egress для всех curl_cffi/httpx-сессий, которые +строятся напрямую в `app/services/*` и `app/tasks/*` МИМО `app.services.proxy_pool` / +`scraper_kit`-оркестрации. При этом `scrape_proxy_source_bans` (миграция 210, #2600 п.2) +аккуратно вела учёт банов по паре «узел × источник» — но эти прямые сессии её никогда +не читали и месяц ходили через узел, забаненный и Avito, и Cian. + +ЧТО ЭТОТ МОДУЛЬ НЕ ДЕЛАЕТ: не берёт lease. `app.services.proxy_pool.acquire()` уже +реализует pick-с-учётом-банов, но с полной lease-семантикой (leased_by/release/ +reap_stale_leases) — она рассчитана на долгоживущие `scrape_run`/`BrowserFetcher`-сессии +(см. `RealProxyProvider` в `app.services.scraper_adapters`). Вызывающие здесь — короткие +одноразовые fetch'и (проверка cookies, одна detail-страница) без run_id и без +гарантированного `release` на каждом пути выхода; занимать под них lease значило бы +дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже — +ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок +и без мутаций. + +ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS +(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now()) +в scrape_proxy_source_bans для ЭТОГО source — это по-прежнему жёсткий фильтр, не +влияющий на порядок. Порядок среди прошедших фильтр (замер 2026-08-10, #2825 доп.): +сначала узлы БЕЗ ИСТОРИИ банов по этому source, затем по возрастанию ban_count — +даже если сама строка бана истекла (banned_until <= now()), её ban_count всё равно +учитывается, ведь строка НЕ удаляется сразу (purge только через 7 суток чистой +работы, см. 210-я миграция) и остаётся памятью «этот узел здесь уже банился N раз». +Внутри равного ban_count — прежние критерии без изменений: меньший consecutive_fails, +при равенстве — более свежий last_ok_at (NULLS LAST). Так хронически банящийся узел +(здоров по health-check, но регулярно ловит 403 от конкретной площадки) не всплывает +первым сразу после истечения TTL — свежий healthcheck сам по себе больше не решает. +Не изобретаем ротацию/балансировку: это резолвер «дай рабочий прокси прямо сейчас», +не lease-менеджер. + +FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review этой правки): пул и статичный +`SCRAPER_PROXY_URL` — РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет": + + 1. Пул ПУСТ (в `scrape_proxies` вообще нет строк — dev/staging без БД-пула, легитимный + сценарий). Тогда fallback на `settings.scraper_proxy_url` ЛЕГИТИМЕН — пула для этого + окружения попросту не существует, идти больше некуда. `logger.warning`. + 2. Пул НЕ пуст, но НИ ОДИН узел не прошёл фильтр для source (все забанены ИМЕННО для + этого источника / нездоровы / выключены). Здесь fallback на `SCRAPER_PROXY_URL` + ЗАПРЕЩЁН: инцидент 2026-08-10 — это ровно случай (2), узел статичной переменной был + тем же самым забаненным узлом, что и в пуле, «резервный» путь тихо возвращал систему + к первопричине. `resolve_proxy_url` в этом случае бросает `ProxyPoolExhaustedError` — + вызывающий обязан явно отказаться от запроса (`logger.error`), а не соскользнуть на + env в обход учёта банов. + +НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials — url +несёт логин/пароль, в лог никогда не идёт целиком), id узла и ban_count по этому +source (0, если истории нет) — чтобы по логу было видно, что узел с историей банов +выбран осознанно (пул исчерпан по чистым узлам), а не тихо; при legit-fallback — +warning с текстом «пуст» (сценарий 1); при exhaustion — error с разбивкой +banned_for_source/unhealthy_or_disabled (сценарий 2) — тексты НАМЕРЕННО разные, чтобы +их нельзя было спутать в логах/алертах. + +psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from urllib.parse import urlsplit + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings as _settings +from app.core.db import SessionLocal as _SessionLocal +from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS + +logger = logging.getLogger(__name__) + +__all__ = ["ProxyPoolExhaustedError", "resolve_proxy_url", "resolve_proxy_url_sync"] + + +class ProxyPoolExhaustedError(RuntimeError): + """Пул `scrape_proxies` НЕ пуст, но ни один узел не прошёл фильтр для `source` + (все забанены именно для этого источника / нездоровы / выключены). + + Fail-closed (#2616): вызывающий обязан явно отказаться от запроса (пропустить run + с понятным логом), а НЕ уйти в обход пула через статичный + `settings.scraper_proxy_url` — тот самый узел мог быть источником текущего + инцидента (см. module docstring, сценарий 2). + """ + + def __init__( + self, + source: str, + *, + pool_total: int, + banned_for_source: int, + unhealthy_or_disabled: int, + ) -> None: + self.source = source + self.pool_total = pool_total + self.banned_for_source = banned_for_source + self.unhealthy_or_disabled = unhealthy_or_disabled + super().__init__( + f"proxy pool exhausted for source={source!r}: pool_total={pool_total} " + f"banned_for_source={banned_for_source} unhealthy_or_disabled={unhealthy_or_disabled}" + ) + + +@dataclass(frozen=True) +class _Candidate: + id: int + url: str + label: str | None + ban_count: int + """ban_count по scrape_proxy_source_bans ДЛЯ ЭТОГО source (0, если строки нет — + узел ни разу не банился этой площадкой). Учитывает и истёкшие строки бана + (banned_until <= now(), но ещё не спурженные) — см. докстринг модуля.""" + + +def _safe_label(proxy_id: int, label: str | None, url: str) -> str: + """host:port для логов — НИКОГДА не credentials из url (userinfo).""" + if label: + return label + try: + parts = urlsplit(url) + host = parts.hostname or "?" + return f"{host}:{parts.port}" if parts.port else host + except ValueError: + return f"proxy#{proxy_id}" + + +def _pick_candidate(db: Session, source: str) -> _Candidate | None: + """READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел. + + LEFT JOIN (не EXISTS) на scrape_proxy_source_bans — нужен сам ban_count для + ранжирования, а не только факт активного бана. Активный бан (banned_until > now()) + по-прежнему полный фильтр в WHERE, это НЕ меняется; но истёкшая (и ещё не + спурженная) строка бана остаётся в ORDER BY как история — см. докстринг модуля. + COALESCE(b.ban_count, 0) — узел без единой строки истории по source ранжируется + как ban_count=0, естественно раньше любого узла с реальной историей банов. + """ + row = ( + db.execute( + text( + """ + SELECT sp.id, sp.url, sp.label, COALESCE(b.ban_count, 0) AS ban_count + FROM scrape_proxies AS sp + LEFT JOIN scrape_proxy_source_bans AS b + ON b.proxy_id = sp.id + AND b.source = CAST(:source AS text) + WHERE sp.enabled + AND sp.consecutive_fails < CAST(:max_fails AS integer) + AND (b.banned_until IS NULL OR b.banned_until <= now()) + ORDER BY COALESCE(b.ban_count, 0) ASC, + sp.consecutive_fails ASC, + sp.last_ok_at DESC NULLS LAST, + sp.id + LIMIT 1 + """ + ), + {"max_fails": MAX_CONSECUTIVE_FAILS, "source": source}, + ) + .mappings() + .fetchone() + ) + # Чистое чтение без блокировок — ничего не коммитим/не откатываем намеренно, + # оставляем управление транзакцией вызывающему коду (тот же db может быть в + # середине более широкой операции). + if row is None: + return None + return _Candidate( + id=int(row["id"]), + url=str(row["url"]), + label=row["label"], + # .get(..., 0) — не .__getitem__: production-SELECT ВСЕГДА проецирует + # ban_count (см. запрос выше), но нулевой default защищает от полного KeyError + # у сторонних fake-db в других test-модулях (напр. test_2830_pool_bypass_tails), + # которые мокают этот же db.execute() урезанным dict без нового столбца. + ban_count=int(row.get("ban_count", 0)), + ) + + +@dataclass(frozen=True) +class _ExhaustionDiag: + """Разбивка причин "кандидата нет" — ТОЛЬКО когда пул реально не пуст (сценарий 2 + в докстринге модуля). Используется исключительно для diagnostic-лога/исключения.""" + + pool_total: int + banned_for_source: int + unhealthy_or_disabled: int + + +def _diagnose_no_candidate(db: Session, source: str) -> _ExhaustionDiag: + """Отдельный запрос, вызывается ТОЛЬКО когда основной SELECT кандидата вернул + пусто — не платим за агрегаты в happy-path (кандидат найден с первого запроса).""" + row = ( + db.execute( + text( + """ + SELECT + count(*) AS pool_total, + count(*) FILTER ( + WHERE NOT enabled + OR consecutive_fails >= CAST(:max_fails AS integer) + ) AS unhealthy_or_disabled, + count(*) FILTER ( + WHERE enabled + AND consecutive_fails < CAST(:max_fails AS integer) + AND EXISTS ( + SELECT 1 + FROM scrape_proxy_source_bans b + WHERE b.proxy_id = scrape_proxies.id + AND b.source = CAST(:source AS text) + AND b.banned_until > now() + ) + ) AS banned_for_source + FROM scrape_proxies + """ + ), + {"max_fails": MAX_CONSECUTIVE_FAILS, "source": source}, + ) + .mappings() + .fetchone() + ) + if row is None: # pragma: no cover — count(*) всегда возвращает строку + return _ExhaustionDiag(pool_total=0, banned_for_source=0, unhealthy_or_disabled=0) + return _ExhaustionDiag( + pool_total=int(row["pool_total"]), + banned_for_source=int(row["banned_for_source"]), + unhealthy_or_disabled=int(row["unhealthy_or_disabled"]), + ) + + +def resolve_proxy_url(db: Session, source: str) -> str | None: + """Egress-URL для source (avito/cian/yandex/domclick) — пул с учётом банов пары + «узел × источник». См. докстринг модуля за разбором двух РАЗНЫХ исходов + "кандидата нет": + + - пул пуст (0 строк в `scrape_proxies`) → fallback на + `settings.scraper_proxy_url`, `logger.warning`, легитимный dev/staging-сценарий; + - пул не пуст, все отсеяны (баны/health/disabled) → `ProxyPoolExhaustedError` + (`logger.error`), fail-closed — БЕЗ прохода через статичный env. + + БД пула недоступна (connection error и т.п., напр. dev-окружение без поднятой БД) + — трактуем КАК пустой пул (не можем подтвердить exhaustion — небезопасно поднимать + error/исключение по неполным данным), `logger.warning` + explicit (не silent + failure). Отличается от сценария exhaustion: там мы ТОЧНО знаем, что узлы есть и + все отсеяны; здесь мы вообще ничего не знаем о пуле. + """ + try: + candidate = _pick_candidate(db, source) + except Exception: + logger.warning( + "proxy_egress: source=%s -- пул scrape_proxies недоступен (ошибка БД), " + "лечим как пустой пул (fallback на статичный SCRAPER_PROXY_URL)", + source, + exc_info=True, + ) + try: + # Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG: + # "current transaction is aborted") — если db переживёт этот вызов + # (долгоживущая caller-сессия, напр. avito_detail_backfill/ + # yandex_detail_backfill), последующие запросы на ней иначе все падали + # бы с той же ошибкой, маскируя реальную причину. + db.rollback() + except Exception: + logger.warning( + "proxy_egress: source=%s -- rollback после сбоя пула тоже не удался", + source, + exc_info=True, + ) + return _settings.scraper_proxy_url + + if candidate is not None: + logger.info( + "proxy_egress: source=%s -> pool proxy id=%d (%s) ban_count=%d", + source, + candidate.id, + _safe_label(candidate.id, candidate.label, candidate.url), + candidate.ban_count, + ) + return candidate.url + + diag = _diagnose_no_candidate(db, source) + + if diag.pool_total == 0: + # Сценарий 1: пул для этого окружения попросту не сконфигурирован + # (dev/staging без БД-пула) — легитимный fallback. + fallback = _settings.scraper_proxy_url + if fallback: + logger.warning( + "proxy_egress: source=%s -- пул scrape_proxies ПУСТ (0 записей), " + "окружение без БД-пула -- идём через статичный SCRAPER_PROXY_URL " + "(fallback)", + source, + ) + else: + logger.warning( + "proxy_egress: source=%s -- пул scrape_proxies пуст и SCRAPER_PROXY_URL " + "не задан, идём прямым подключением без прокси", + source, + ) + return fallback + + # Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного + # здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env. + logger.error( + "proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН " + "не прошёл фильтр для этого источника (banned_for_source=%d, " + "unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через " + "статичный SCRAPER_PROXY_URL (тот самый узел мог быть источником инцидента)", + source, + diag.pool_total, + diag.banned_for_source, + diag.unhealthy_or_disabled, + ) + raise ProxyPoolExhaustedError( + source, + pool_total=diag.pool_total, + banned_for_source=diag.banned_for_source, + unhealthy_or_disabled=diag.unhealthy_or_disabled, + ) + + +def resolve_proxy_url_sync(source: str) -> str | None: + """Как `resolve_proxy_url`, но сама открывает короткую `SessionLocal()` — для + вызывающих без готового `db` в сигнатуре (напр. `cian_session.verify_session`). + + `ProxyPoolExhaustedError` из `resolve_proxy_url` пробрасывается как есть (fail-closed) — + вызывающий обязан явно её поймать и решить, как деградировать (см. call site'ы). + """ + db = _SessionLocal() + try: + return resolve_proxy_url(db, source) + finally: + db.close() diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index ed40dc63..8ed2176e 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08): «Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже. +Проба на ПАРУ «узел × источник» (#2800, продолжение #2723): + - #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом + для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв» + вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500 + NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и + свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен». + - Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может + достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки + (apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а + `bff-search-web.domclick.ru`, куда ходит сбор, — 500). + - Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой + сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый + отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки. + Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt + площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать + дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»). + - Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение + «браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО + креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла. + Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками + вернули то самое схлопывание диагнозов. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -125,6 +147,7 @@ __all__ = [ "mark_banned", "mark_browser_health", "mark_health", + "mark_source_probe", "reap_stale_leases", "release", "run_proxy_healthcheck", @@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360 # намеренно не обновляется (см. mark_browser_health). BROWSER_UNFIT_THRESHOLD = 2 +# ── проба на пару «узел × источник» (#2800) ────────────────────────────────── +# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт): +# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито; +# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть +# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch; +# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов +# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at). +# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при +# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было. +# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и +# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором +# acquire снова выдаёт узел, не спросив. +# +# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её +# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная +# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка +# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка +# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре. +_PROBE_BAN_REASON = "probe:browser" + # deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для # mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что # в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не @@ -228,14 +271,21 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах чужой affinity (#2600). - Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см. - 173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1), - намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого - одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex, - domclick останется без прокси вообще — хуже, чем голодание исходного источника, - которое фикс призван устранить. Кандидат участвует в fallback, только если его - affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) — - т.е. выдача не обнулит доступность выделенной affinity целиком. + Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если + fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже, + чем голодание исходного источника, которое фикс призван устранить. Кандидат + участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ + enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной + affinity целиком. + + Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql — + единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR + банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до + рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а + Авито/Яндекс через него работают — резервация держала узел за источником, которому + он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде + сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и + следующий выделенный узел должен получить её сразу, а не после повторного разбора. ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у (scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито, @@ -675,9 +725,43 @@ def mark_browser_health( return "fail" -def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: +def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str: """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. + Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары + принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего + узла) | "missing" (нет такого proxy_id). + + `reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по + умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы — + _PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому + clear_source_bans умеет фильтровать по ней (`only_reason`). + + ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026: + пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через + ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр + «снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ: + присвоенная строка становится «своей», и следующая успешная проба снимает ею бан, + который поставил боевой сбор по настоящему отказу площадки. Плюс теряется + происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба + не смогла») — разные факты с разными последствиями (ровно ловушка #2764), а ban_count + начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых + пары с 6 ч до 12 ч). + + Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет), + ИЛИ она уже наша (та же метка — обычная эскалация), ИЛИ мы боевой сбор (`live_reason`). + Иначе — ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно» + только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен + УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит — делать нечего. + + АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее + (площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится + ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой — и её же зелёный + robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально + и хуже. Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного); + обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об + эскалации боевых банов пары. + Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация — транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно @@ -736,6 +820,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает (падает как обычно) — caller решает, ловить или нет. """ + # Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё + # (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ"). + live_reason = f"banned:{source}" + effective_reason = reason or live_reason # Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг # "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции. db.execute( @@ -807,13 +895,20 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: ) AS integer)), reason = CAST(:reason AS text), updated_at = now() + -- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет), + -- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее + -- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803). + WHERE scrape_proxy_source_bans.banned_until <= now() + OR scrape_proxy_source_bans.reason = CAST(:reason AS text) + OR CAST(:reason AS text) = CAST(:live_reason AS text) RETURNING ban_count, banned_until """ ), { "proxy_id": proxy_id, "source": source, - "reason": f"banned:{source}", + "reason": effective_reason, + "live_reason": live_reason, "base_hours": SOURCE_BAN_BASE_HOURS, "max_hours": SOURCE_BAN_MAX_HOURS, "max_fails": MAX_CONSECUTIVE_FAILS, @@ -833,10 +928,40 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: row["banned_until"], row["ban_count"], ) - return + return "banned" + + # 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита + # последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога + # (на решение уже не влияет), но диагноз должен называть то, что произошло. + holder = ( + db.execute( + text( + """ + SELECT reason, banned_until + FROM scrape_proxy_source_bans + WHERE proxy_id = CAST(:proxy_id AS bigint) + AND source = CAST(:source AS text) + AND banned_until > now() + """ + ), + {"proxy_id": proxy_id, "source": source}, + ) + .mappings() + .fetchone() + ) + if holder is not None and holder["reason"] != effective_reason: + logger.info( + "proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт " + "%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, " + "иначе проба присвоила бы чужой бан и потом сняла бы его как свой)", + proxy_id, + source, + holder["reason"], + holder["banned_until"], + effective_reason, + ) + return "deferred" - # 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем - # текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет). current = ( db.execute( text( @@ -849,17 +974,25 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: ) if current is None: logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id) - else: - logger.warning( - "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " - "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " - "источнику (голодание хуже, чем работа через забаненный узел).", - proxy_id, - source, - ) + return "missing" + logger.warning( + "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " + "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " + "источнику (голодание хуже, чем работа через забаненный узел).", + proxy_id, + source, + ) + return "protected" -def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int: +def clear_source_bans( + db: Session, + proxy_id: int, + *, + source: str | None = None, + reason: str, + only_reason: str | None = None, +) -> int: """Снять баны узла по источникам (#2600 п.2). Returns число снятых строк. ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через @@ -882,6 +1015,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, SOURCE_BAN_BASE_HOURS. `reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»). + + `only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые + написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое + свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан, + распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация + IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной + целиком. None — снимать всё, как и раньше. """ rows = db.execute( text( @@ -889,10 +1029,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, DELETE FROM scrape_proxy_source_bans WHERE proxy_id = CAST(:proxy_id AS bigint) AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text)) + AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text)) RETURNING source """ ), - {"proxy_id": proxy_id, "source": source}, + {"proxy_id": proxy_id, "source": source, "only_reason": only_reason}, ).fetchall() db.commit() if rows: @@ -906,6 +1047,79 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, return len(rows) +def mark_source_probe( + db: Session, + proxy_id: int, + *, + source: str, + ok: bool, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800). + + Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или + не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает + `acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим. + + КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница + другая — здесь судится ПАРА, а не узел): + - "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored". + Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз); + - "proxy" — через этот узел до площадки не доходит транспорт + (NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары; + - "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку + (200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары. + Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для + ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя. + + Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный + боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить + дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная + половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не + присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь + присвоенная строка уже «своя». + + Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего + своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не + пишется (голодание хуже работы через плохой узел). + + Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned` + ("banned" | "deferred" | "protected" | "missing") — счётчик пар считает баном + только реально записанный бан. + """ + if ok: + cleared = clear_source_bans( + db, + proxy_id, + source=source, + reason=f"browser probe OK for source={source} ({detail})", + only_reason=_PROBE_BAN_REASON, + ) + return "cleared" if cleared else "ok" + + if fail_kind not in ("proxy", "page"): + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре " + "(fail_kind=%s): %s — вердикт не пишем", + proxy_id, + source, + fail_kind, + detail, + ) + return "ignored" + + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан " + "пары, узел остаётся первосортным для остальных площадок (#2800)", + proxy_id, + source, + fail_kind, + detail, + ) + return mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON) + + def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: """Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release). @@ -969,8 +1183,39 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No return False, None, None, "other" -async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str: - """Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health. +def _probe_sources_for(affinity: str) -> list[str]: + """Источники, которым узел с такой affinity МОЖЕТ достаться (#2800). + + Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`. + Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику, + которой никто не воспользуется. + + ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих + свободных нет) — такая пара останется без вердикта и решится как раньше, по факту + прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же + только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос + бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4. + """ + from scraper_kit.browser_fetcher import PROBE_SOURCES + + return [s for s in PROBE_SOURCES if affinity in (s, "any")] + + +async def _run_pair_probes( + db: Session, proxy_id: int, url: str, kind: str, affinity: str +) -> tuple[str, dict[str, int]]: + """Крест «этот узел × каждая его площадка» + запись вердиктов (#2800). + + Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам). + + Два уровня вердикта, и они не пересекаются: + - ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке + отдельно, это то, что читает `acquire(source)`; + - УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по + итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел + работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки + узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов + появилось бы новое. Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не @@ -978,18 +1223,60 @@ async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> """ from scraper_kit.browser_fetcher import probe_proxy_via_browser - try: - ok, fail_kind, detail = await probe_proxy_via_browser( - _settings.browser_http_endpoint, url, proxy_kind=kind + counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0} + fail_kinds: list[str] = [] + any_ok = False + last_detail = "" + + for source in _probe_sources_for(affinity): + try: + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + if not ok and fail_kind == "proxy": + # Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает + # флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор + # идёт по уже поднятому браузеру с тем же прокси — секунды, и только + # на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью + # (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания. + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + except Exception: + logger.warning( + "proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан", + proxy_id, + source, + exc_info=True, + ) + continue + + counters["pair_checked"] += 1 + last_detail = detail + if ok: + any_ok = True + else: + fail_kinds.append(fail_kind or "other") + outcome = mark_source_probe( + db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail ) - except Exception: - logger.warning( - "proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан", - proxy_id, - exc_info=True, - ) - return "ignored" - return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail) + if outcome == "banned": + counters["pair_banned"] += 1 + elif outcome == "cleared": + counters["pair_cleared"] += 1 + + if counters["pair_checked"] == 0: + return "ignored", counters # крест не состоялся — узел не судим + + if any_ok: + return mark_browser_health(db, proxy_id, True, detail=last_detail), counters + # Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ — + # транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле». + node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0] + return ( + mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail), + counters, + ) def _mask(url: str) -> str: @@ -1025,18 +1312,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад (см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count). - БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером - дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же - тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на - robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и - никогда не смешивается с consecutive_fails/enabled. Гейт — settings. - use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба - измеряла бы то, чем никто не пользуется. + БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не + проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ + САЙДКАР — по одной навигации на каждую площадку, которую этот узел может + обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси, + потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в + scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные + browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт — + settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и + проба измеряла бы то, чем никто не пользуется. Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters {reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok, - browser_unfit, browser_refit}. + browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}. """ reaped = reap_stale_leases(db) @@ -1044,7 +1333,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: db.execute( text( """ - SELECT id, url, kind, enabled, disabled_reason, + SELECT id, url, kind, enabled, disabled_reason, provider_affinity, (browser_check_at IS NULL OR browser_check_at < now() - make_interval( mins => CAST(:browser_probe_minutes AS integer) @@ -1075,6 +1364,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: browser_ok = 0 browser_unfit = 0 browser_refit = 0 + pair_checked = 0 + pair_banned = 0 + pair_cleared = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) @@ -1104,8 +1396,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: # вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет # не тот узел», из-за которого #2723 и появилась. if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser: - outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"])) + outcome, pair_counters = await _run_pair_probes( + db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"]) + ) browser_checked += 1 + pair_checked += pair_counters["pair_checked"] + pair_banned += pair_counters["pair_banned"] + pair_cleared += pair_counters["pair_cleared"] if outcome in ("ok", "refit"): browser_ok += 1 if outcome == "refit": @@ -1135,7 +1432,8 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: logger.info( "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d " - "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d", + "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d " + "pair_checked=%d pair_banned=%d pair_cleared=%d", reaped, checked, ok_count, @@ -1146,6 +1444,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: browser_ok, browser_unfit, browser_refit, + pair_checked, + pair_banned, + pair_cleared, ) return { "reaped": reaped, @@ -1160,4 +1461,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: "browser_ok": browser_ok, "browser_unfit": browser_unfit, "browser_refit": browser_refit, + # Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и + # pair_banned=2 одновременно — это не противоречие, а точный диагноз + # «браузер через узел работает, но две площадки его не пускают». + "pair_checked": pair_checked, + "pair_banned": pair_banned, + "pair_cleared": pair_cleared, } diff --git a/tradein-mvp/backend/app/services/sber_index.py b/tradein-mvp/backend/app/services/sber_index.py index 7a4d646c..8331b2d2 100644 --- a/tradein-mvp/backend/app/services/sber_index.py +++ b/tradein-mvp/backend/app/services/sber_index.py @@ -303,6 +303,18 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st #1348: blocking psycopg work — must run via asyncio.to_thread, never directly on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard). + + #2846: `fetched_at` НЕ переписывается при конфликте. Забор идёт ВСЕЙ серией + (limit=1000&offset=0, отсечки по периоду нет), поэтому `fetched_at = now()` в + DO UPDATE ставил одну и ту же метку всем строкам ряда — на проде все 639 строк + несли время последнего прогона, включая период 2017-01. Как признак свежести + колонка была пуста. Теперь она означает «когда мы ВПЕРВЫЕ увидели этот период», + то есть по ней измеряется ТАКТ ПУБЛИКАЦИИ источника (min(fetched_at) по новым + периодам). Ретроспективу это не возвращает: у 639 уже лежащих строк метка + 2026-08-06 и она останется — такт публикации до этого PR невосстановим. + Времени последней ЗАГРУЗКИ колонка больше не хранит; оно и не нужно — + scrape_runs(source='sber_index_pull') хранит его точнее (с errors/upserted), + и именно оттуда его берёт tasks/sber_freshness_monitor. """ for city_label, period_month, segment, dash, value in rows_to_upsert: db.execute( @@ -323,8 +335,8 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st ON CONFLICT (city, period_month, dashboard) DO UPDATE SET index_value_rub_m2 = EXCLUDED.index_value_rub_m2, - segment = EXCLUDED.segment, - fetched_at = now() + segment = EXCLUDED.segment + -- fetched_at НЕ трогаем (#2846): она = «впервые увидели период». """ ), { diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index c83f178e..6fca052d 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -143,6 +143,28 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше # сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов # реально дали ноль. +# succeeded — yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich +# (65 прогонов/90д, единственные два писателя ключа на проде, +# проверено 2026-08-15). НЕ 'rows_inserted': тот ключ пишет ЕЩЁ и +# rosreestr_dkp_import (67 прогонов/90д) — у него rows_inserted=0 в +# 66 из 67 это ЗДОРОВЫЙ ответ догнавшего инкрементального импорта +# (rows_fetched=rows_skipped=96974, last_id не двигается неделями), +# а не отказ; если бы 'rows_inserted' попал в этот список, сторож +# зачитывал бы этот здоровый ноль как измеренный провал и копил бы +# практически непрерываемый стрик (rosreestr_dkp_import не +# прерывается другим статусом — импорт либо 'done', либо не бежал). +# НЕ 'processed' по той же причине с другой стороны: это счётчик +# ПОПЫТОК (у newbuilding_enrich processed==attempted==limit даже +# когда succeeded меньше — прод-факт 09.08: processed=25 succeeded=14, +# 44% отказов замаскировались бы под measured-25) — сторож нулевого +# результата на нём молчал бы ровно там, где должен сработать, а на +# будущем опустении очереди домов (cian_houses_pending) создал бы +# свой вечный ложный zero-стрик. 'succeeded' у yandex_newbuilding_sweep +# численно совпадает с 'rows_inserted' на всех 42/42 прод-прогонах — +# замена не теряет исходную цель (десять прогонов подряд 26.07-10.08, +# все 'done', succeeded=0 rows_inserted=0 failed_resolve=4-5 — раньше +# ни total_seen/lots_fetched/unique_fetched не было, и +# _run_result_count всегда возвращал None (honest-run-status)). # Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650 # прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь # (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched @@ -150,7 +172,12 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ # (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их # словарь, а честно признаёт, что мерить нечем — см. _run_result_count. -_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched") +_RESULT_COUNTER_KEYS = ( + "total_seen", + "lots_fetched", + "unique_fetched", + "succeeded", +) def _run_result_count(counters: Mapping[str, Any] | None) -> int | None: @@ -179,6 +206,166 @@ def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> Non ) +def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None: + """Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625). + + Возвращает текст причины (для error) либо None, если прогон таким не является. + + Третий исход, у которого не было терминального статуса. Развёртка различает: + 1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу → + `mark_banned` в самих sweep'ах (#2642, cian/yandex); + 2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений → + `done` с нулём, это здоровый результат (в Серове реально 10 объявлений); + 3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как + что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает + НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки), + и статуса ему никто не выдал — прогон уходил в `done`. + + Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров: + `errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что + отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ + доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус + 'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается + по умолчанию). + + Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала — + `errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор). + + Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов + (yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с, + таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5; + yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса). + НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной + пустоты (errors_count=0) — они остаются 'done'. + """ + anchors = _pick_int(counters, "anchors_total") + errors = _pick_int(counters, "errors_count") + if not anchors or anchors <= 0 or errors is None or errors < anchors: + return None + if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает + return None + return ( + f"sweep-honest-status: отказом кончились все {anchors} якорей прогона " + f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ " + f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или " + f"блокировкой площадки — статус 'failed' без диагноза (#2625)" + ) + + +# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило. +# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд +# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу +# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90 +# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не +# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд. +_PHASE_MIN_ATTEMPTS = 3 + + +def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: + """Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None. + + Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит + случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на + сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ. + + Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50, + detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала + HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового + прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ + подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`). + + Признак — собственная бухгалтерия фазы: `_failed == _attempted` при + `attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ + `X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это + ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя + настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv. + + Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому + 'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'. + + Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по + боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed' + 42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не + знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 — + это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов + отказали обе фазы (detail и houses) сразу, и они посчитались дважды. + """ + for key in sorted(counters): + if not key.endswith("_attempted"): + continue + phase = key[: -len("_attempted")] + attempted = _pick_int(counters, key) + failed = _pick_int(counters, f"{phase}_failed") + if attempted is None or failed is None: + continue + if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted: + return ( + f"phase-honest-status: фаза '{phase}' отказала полностью — " + f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы " + f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. " + f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус " + f"'failed' без диагноза (#2700)" + ) + return None + + +# honest-run-status (2026-08-15): доля отказов, которая обесценивает формально ненулевой +# сбор. Прод-факт avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6, +# "blocked":1} — 89% попыток отказали, а mark_backfill_finished всё равно звал mark_done, +# потому что "produced != 0" (6 обогащено). Ни _sweep_run_did_nothing (нужны +# anchors_total/errors_count, у backfill'ов их нет), ни _phase_totally_failed (нужна пара +# "_attempted"/"_failed" — здесь голые "attempted"/"failed" без фазового +# префикса, `"attempted".endswith("_attempted")` не матчит) эту форму counters не ловят — +# обе проверки написаны под СВОИ формы, а не под backfill'овскую. +# +# Порог 'failed' — половина и больше отказов: сбор для практических целей провалился, +# даже если несколько записей всё же обогатились. Порог 'partial' НЕ заведён отдельным +# статусом scrape_runs.status — это потребовало бы миграции (DROP+ADD CHECK constraint, +# 051_scrape_runs_extend.sql) и обучило бы новому значению ещё 4 места (Literal-фильтр +# admin API, хардкод статусов фронта, оба IN-списка сторожей) — тот же класс "оборванной +# проводки", из-за которого заведён #2686/ban_kind. Вместо статуса — тот же диагноз, что и +# у ban_kind: causa в тексте `error`, терминальный статус один ('failed'). 0.15..0.5 — +# та же 'failed', но с другой формулировкой причины ("деградировал", не "провалился"), чтобы +# оператор видел разницу читая error, не только status. +FAILED_RATIO_FAILED_THRESHOLD = 0.5 +FAILED_RATIO_DEGRADED_THRESHOLD = 0.15 +# Минимум попыток, при котором доля вообще что-то значит — иначе 1 отказ из 2 (=0.5) +# палит статус на шуме единичного случая. То же рассуждение и то же число, что у +# _PHASE_MIN_ATTEMPTS (см. выше). +_FAILED_RATIO_MIN_ATTEMPTS = _PHASE_MIN_ATTEMPTS + + +def _failed_ratio_too_high(counters: Mapping[str, Any]) -> str | None: + """Прогон, у которого доля отказов слишком велика, даже если что-то собрано. + + Возвращает текст причины (для error) либо None. Читает ГОЛЫЕ ключи "attempted"/ + "failed" (без фазового префикса) — сейчас это словарь только у четырёх + detail-backfill'ов (avito/yandex/domclick/newbuilding_enrich), все идут через + mark_backfill_finished → mark_done. `attempted < _FAILED_RATIO_MIN_ATTEMPTS` или + отсутствие любого из ключей → None (нечем/не о чём судить — счётчики либо не + заполнены, либо принадлежат другому источнику со своим словарём). + + Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому + 'failed' без диагноза, как и у #2625/#2700/#2764. + """ + attempted = _pick_int(counters, "attempted") + failed = _pick_int(counters, "failed") + if attempted is None or failed is None or attempted < _FAILED_RATIO_MIN_ATTEMPTS: + return None + ratio = failed / max(attempted, 1) + if ratio >= FAILED_RATIO_FAILED_THRESHOLD: + verb = "провалился" + elif ratio >= FAILED_RATIO_DEGRADED_THRESHOLD: + verb = "деградировал" + else: + return None + return ( + f"failed-ratio-honest-status: сбор {verb} — {failed} из {attempted} попыток " + f"отказали (доля {ratio:.0%}); формально ненулевой результат этого не искупает. " + f"Причина НЕ установлена — статус 'failed' без диагноза" + ) + + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -189,13 +376,24 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926). Приоритет ключей: - - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched) - - new_count ← 'new_count' (если уже есть) иначе 'lots_inserted' + - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched / + succeeded) + - new_count ← 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted' + (первый присутствующий). 'saved_inserted' — full-load'ы (cian/avito/yandex, + CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала + new_count=0 у трёх подряд cian_full_load при реально сохранённых + saved_inserted=482/214/239 (honest-run-status) — ключ 'new_count'/'lots_inserted' + у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ, + которым yandex_newbuilding_sweep и rosreestr_dkp_import сообщают число upsert'ов; + здесь (для витринной колонки new_count) это безопасно — в отличие от + _RESULT_COUNTER_KEYS этот список не участвует в подсчёте zero-result-стрика. Возвращает (total_seen, new_count); None для ключа, которого нет в counters — тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE). """ - return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted") + return _run_result_count(counters), _pick_int( + counters, "new_count", "lots_inserted", "saved_inserted", "rows_inserted" + ) def _alert_if_consecutive_failures(db: Session, source: str) -> None: @@ -445,7 +643,37 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки — иначе admin/observability показывает 0 (audit #1926). + + #2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился + каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а + не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка: + страж, который надо не забыть позвать, — это тот же дефект оборванной проводки, + из-за которого задача и появилась. + + #2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка + целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано + ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет». + + honest-run-status: там же — отказ называть успехом прогон с высокой долей отказов, + даже если собрано > 0 (см. _failed_ratio_too_high). Отличие от #2625/#2700: те два + смотрят на «всё или ничего» (все якоря / вся фаза), этот — на ДОЛЮ отказов у + detail-backfill'ов, где ни один из первых двух признаков не матчит форму counters. """ + did_nothing = _sweep_run_did_nothing(counters) + if did_nothing is not None: + logger.error("%s run_id=%d", did_nothing, run_id) + mark_failed(db, run_id, did_nothing, counters) + return + phase_dead = _phase_totally_failed(counters) + if phase_dead is not None: + logger.error("%s run_id=%d", phase_dead, run_id) + mark_failed(db, run_id, phase_dead, counters) + return + ratio_bad = _failed_ratio_too_high(counters) + if ratio_bad is not None: + logger.error("%s run_id=%d", ratio_bad, run_id) + mark_failed(db, run_id, ratio_bad, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text( diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 2192cd8c..01bf055a 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -67,6 +67,7 @@ class RealMatcherAdapter: 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]: # house_id is None when the matcher refuses a numberless address without a # cadastral number (method 'no_house_number', P1). Callers must tolerate None. @@ -80,6 +81,7 @@ class RealMatcherAdapter: year_built=year_built, building_cadastral_number=building_cadastral_number, source_url=source_url, + city=city, ) def upsert_listing_source( diff --git a/tradein-mvp/backend/app/services/yandex_address_backfill.py b/tradein-mvp/backend/app/services/yandex_address_backfill.py index 1b5984f3..b4a660c5 100644 --- a/tradein-mvp/backend/app/services/yandex_address_backfill.py +++ b/tradein-mvp/backend/app/services/yandex_address_backfill.py @@ -111,7 +111,7 @@ async def backfill_yandex_addresses( Returns: YandexAddressBackfillResult with checked/saved/skipped/errors counters. """ - from app.core.config import settings + from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url result = YandexAddressBackfillResult() t0 = time.time() @@ -130,7 +130,23 @@ async def backfill_yandex_addresses( request_delay_sec, ) - _proxy_url = settings.scraper_proxy_url + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если пул + # пуст (легитимный dev/staging-сценарий). + try: + _proxy_url = resolve_proxy_url(db, "yandex") + except ProxyPoolExhaustedError as exc: + # Fail-closed (#2616, #2825): пул не пуст, но все узлы забанены для yandex/ + # нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module + # docstring). Явный пропуск run'а вместо слепого прохода через egress, который + # мог быть источником текущего инцидента. + logger.error( + "yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run " + "пропущен, ни один листинг не обработан", + exc, + ) + result.duration_sec = time.time() - t0 + return result _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None async with AsyncSession( diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index d206d869..d572c1bd 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -70,6 +70,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.shutdown import shutdown_requested from app.services import scrape_runs as runs_mod +from app.services.proxy_egress import resolve_proxy_url from app.services.scraper_adapters import RealScraperConfig # #2397 Part D1 (#2330 закрыт): _AVITO_WARM_SEARCH_URL/build_warmed_session больше @@ -108,9 +109,16 @@ __all__ = [ # матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ # матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный # слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан. +# +# #262 wave 2: avito_slug — Optional в CityLocation (не у каждого oblast-города +# подтверждён). Города без avito_slug пропускаем целиком — у них НЕТ avito_city_ +# sweep schedule (262_), значит НЕТ и avito-листингов с их URL; паттерн для них +# был бы либо мёртвым, либо (что хуже) построен из city_slug вместо реального +# avito URL-сегмента и создал бы ложный LIKE-матч. _OBLAST_AVITO_URL_PATTERNS = tuple( "%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%" for loc in CITY_LOCATIONS.values() + if loc.avito_slug is not None ) @@ -268,12 +276,19 @@ async def run_avito_detail_backfill( elif not use_curl: # curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False): # строим shared сессию через auv, как делает run_avito_city_sweep (kit). + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только + # если пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все + # забанены/нездоровы для avito -- resolve_proxy_url бросает + # ProxyPoolExhaustedError (fail-closed, #2616): НАРОЧНО не ловим здесь -- + # штатный except Exception ниже (mark_failed + logger.exception + raise) + # уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен. own_session = True session = AsyncSession( impersonate="chrome120", timeout=25, headers=DOCUMENT_HEADERS, - proxies=http_proxies(settings.scraper_proxy_url), + proxies=http_proxies(resolve_proxy_url(db, "avito")), ) scraper._cffi = session diff --git a/tradein-mvp/backend/app/tasks/cian_history_backfill.py b/tradein-mvp/backend/app/tasks/cian_history_backfill.py index b6c5907b..2cb69680 100644 --- a/tradein-mvp/backend/app/tasks/cian_history_backfill.py +++ b/tradein-mvp/backend/app/tasks/cian_history_backfill.py @@ -43,7 +43,11 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings -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__) @@ -230,7 +234,13 @@ async def backfill_cian_history( enrichment = None try: - enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig()) + # proxy_provider (#2767): тот же сожжённый env-узел бил и сюда — + # это второй вызывающий fetch_newbuilding, чинить надо оба. + enrichment = await fetch_newbuilding( + zhk_url, + config=RealScraperConfig(), + proxy_provider=RealProxyProvider(), + ) except Exception as exc: logger.warning( "cian_newbuilding fetch failed for house_id=%s url=%s: %s", diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index 1f800160..d36ba452 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -6,8 +6,28 @@ Ключевые решения: - Cian/Yandex не поддерживают full-coverage sweep -> паушальный TTL сломает живой инвентарь. DECISION: для yandex/cian деактивировать ТОЛЬКО listing_segment='vtorichka', - TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем. + TTL=30. novostroyki (9659 активных первичных строк) не трогаем. + ИЗВЕСТНЫЙ ПРОБЕЛ (ревью TTL-CAP круг 2, 2026-08-15): этот скоуп уже, чем множество + реально протухших строк -- живой замер на проде даёт cian/novostroyki 9 483 активных + строки старше 60 суток, ни одна из них не деактивируется НИ ОДНОЙ джобой (внутри + скоупа cian/vtorichka и yandex/vtorichka таких строк 0). Потолок cap_mult (см. + CAP_MULT ниже) этот пробел не закрывает и закрыть не может -- он сжимает пул ВНУТРИ + скоупа джобы, а не расширяет сам скоуп. NULL-сегмент (тот же замер круга 2 давал + cian/NULL 211, yandex/NULL 523 строки старше 60 суток) закрыт отдельно ниже + (null_segment_only, миграция 266) -- novostroyki-часть пробела остаётся: расширение + скоупа туда отдельная задача (нужно сперва выяснить, поддерживает ли cian/yandex + full-coverage sweep для novostroyki СЕЙЧАС, иначе паушальный TTL повторит инцидент, + ради которого этот DECISION и принят) и намеренно НЕ входит в TTL-CAP. - avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений. +- NULL-сегмент (легаси-строки до миграции 011 + жертвы бага в ON CONFLICT -- upsert + никогда не пишет listing_segment повторно, поэтому раз рождённая NULL-строка сама + себя не чинит даже при живой ежедневной досдаче) деактивируется ОТДЕЛЬНОЙ джобой per + source (null_segment_only=True, миграция 266): явный `listing_segment IS NULL` + предикат, а не ANY(:segments) -- этот оператор NULL никогда не матчит. Гейт + здоровья/пол переобхода для этой джобы выключены (min_confirmations=0, + revisit_floor_quantile=0) -- население нерепрезентативно мало (единицы подтверждений + в сутки против сотен-тысяч у обычного vtorichka-среза), калиброванный под vtorichka + порог держал бы джобу вечно skipped_unhealthy. - Строки НЕ удаляются -- история нужна для бэктеста (#667). - #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом 'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка @@ -24,6 +44,7 @@ TTL для avito берётся из settings.avito_stale_ttl_days (env AVITO_ST from __future__ import annotations import logging +from math import ceil from typing import Any from sqlalchemy import text @@ -126,15 +147,205 @@ DEFAULT_MIN_CONFIRMATIONS = 500 _CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))" +# NULL-сегмент: `= ANY(...)` НИКОГДА не матчит NULL (SQL, не баг), поэтому +# для null_segment_only-режима нужен отдельный явный предикат IS NULL, а не элемент +# в :segments. См. _build_null_segment_sql ниже -- тот же принцип для самого UPDATE. +_CONFIRMATIONS_NULL_SEGMENT_FILTER = "\n AND listing_segment IS NULL" -def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any: + +# ── Пол TTL по измеренному циклу переобхода (#2659) ─────────────────────────── +# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на +# вопрос, из-за которого заведён #2659: «а достаточно ли ttl_days, чтобы молчание +# означало снятие?». Пока свип возвращается к строке реже, чем раз в ttl_days, +# TTL меряет НАШУ выборку, а не жизнь объявления, — и источник при этом полностью +# здоров, так что гейт молчит. +# +# ЗАМЕР НА ПРОДЕ 2026-08-09, из-за которого этот пол существует. +# С момента деплоя гейта (06.08) TTL снял 1 028 строк; 127 из них (12.4%) УЖЕ снова +# активны — свип нашёл их живыми через 1-3 суток и вернул сам (upsert в +# scraper_kit/base.py ставит is_active = true). В единственном городе с настоящим +# покрытием доля ложных снятий 100%: +# cian Екатеринбург 103 снято → 103 снова активны +# yandex Екатеринбург 24 снято → 24 снова активны +# cian/yandex без города 901 снято → 0 вернулись (их свип не обходит вовсе) +# Возраст на момент снятия у всех 127: 29.9..30.3 суток при TTL=30 — то есть TTL +# срабатывал ровно на границе, а свип возвращался к строке на 31-34-е сутки. +# +# ПОЧЕМУ ЭТО НЕ ЛЕЧИТСЯ НОВОЙ КОНСТАНТОЙ. Разрывы переобхода, суток +# (listing_source_snapshots, 40 суток, посчитано по срезу TTL-джобы): +# источник/сегмент p90 p99 TTL сейчас TTL/p99 +# domklik vtorichka 1.9 3.1 14 4.5 ← сплошное суточное покрытие +# cian vtorichka 10.9 26.6 30 1.1 +# yandex vtorichka 5.7 43.0 30 0.7 +# avito vtorichka 29.1 42.1 10 0.24 ← отсюда 9 033 строки +# Домклик — контрольная группа: при почти полном суточном обходе TTL=14 лежит в +# 4.5 раза выше хвоста, и снятие у него действительно означает снятие. У остальных +# трёх порог ниже собственного хвоста обхода — руками подобранное число и есть +# корень #2659, поэтому чинить его вторым руками подобранным числом бессмысленно. +# +# ЧТО МЕРЯЕМ ВМЕСТО КОНСТАНТЫ: факт, а не оценку. «Какой самый большой возраст, при +# котором свип за последнее окно ДОКАЗАЛ, что объявление живо» — то есть насколько +# старую строку он только что нашёл на площадке. Если свип буквально вчера вернул к +# жизни строку, молчавшую 40 суток, то 30 суток молчания не доказывают ничего. +# Пол = квантиль этого распределения, эффективный TTL = max(ttl_days, пол). +# +# Считается по ТОМУ ЖЕ срезу (source + segments) и по ТОЙ ЖЕ колонке свежести, что +# и UPDATE. Предыдущее наблюдение берётся из listing_source_snapshots — единственной +# истории свежести, что у нас есть; расхождение listings. и +# listing_sources.last_seen_at замерено на проде и не превышает 0.5 суток в среднем +# (максимум 0), что на шкале 30-70 суток шум. +# +# КВАНТИЛЬ — калибровочная ручка, не догма. 0.99 подобран по требованию «пол обязан +# накрыть 127 доказанных ложных снятий», у которых возраст был 29.9..30.3: замер +# того же запроса на проде даёт 34.0 для cian/vtorichka и 74.3 для yandex/vtorichka. +# Ниже 0.99 опускать нельзя без нового замера. Ручка живёт в default_params +# расписания (revisit_floor_quantile), 0 -> пол выключен. +# +# ПОБОЧНЫЙ ЭФФЕКТ, КОТОРЫЙ ЗДЕСЬ НАМЕРЕННЫЙ: после провала сбора хвост разрывов +# распухает (свип разгребает завал и находит очень старые строки), пол поднимается, +# и деактивация замирает сама — без отдельного детектора банов. Когда завал разобран, +# хвост схлопывается и пол опускается обратно. Это ровно то поведение, которого +# issue просил от «гейта по банам», но выраженное через результат, а не через причину. +# +# ПОТОЛОК: пол не может превысить глубину истории снимков. Если снимок за нужную +# дату не писался (дыры на проде есть — 30.07, 01.08), берётся ближайший более +# ранний; при полном отсутствии снимков пол не считается и TTL остаётся как задан. +DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 + +_REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))" +_REVISIT_FLOOR_NULL_SEGMENT_FILTER = "\n AND l.listing_segment IS NULL" + + +# ── Потолок эффективного TTL (положительная обратная связь пола, найдено 2026-08-15) ── +# У пола выше нет верхней границы: max(ttl_days, пол) может расти неограниченно. +# ЗАМЕР НА ПРОДЕ (уточнён 2026-08-15 после разбора): у yandex counters держали +# ttl_days_effective 75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 — +# пол реально разгонялся без верхней границы, и потолок закрывает именно это. +# ЧЕГО ПОТОЛОК НЕ ДЕЛАЕТ: он НЕ сжимает пул «активных». Замер показал 0 +# деактивируемых строк на всех четырёх джобах и до, и после калибровки. Цифра +# «23 687 из 44 744 не подтверждались >7 суток» относится ко ВСЕМ источникам +# сразу, и две трети её — новостройки, которых оценщик не берёт. У avito +# просроченных ноль. Раздутый пул, влияющий на оценку, лежит в строках с ПУСТЫМ +# сегментом и чинится отдельной джобой, не этим потолком. +# +# МЕХАНИЗМ ПЕТЛИ: медленный обход поднимает пол (он же квантиль разрывов переобхода) +# -> высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться +# свежий обход -> пул «активных» раздувается «протухшими» строками -> следующий замер +# пола на том же раздутом пуле оказывается ещё выше. Без верхней границы это не +# самокорректирующийся пол, а положительная обратная связь. +# +# CAP_MULT = 2 -- эффективный TTL не может превысить удвоенный заданный оператором +# ttl_days. Пол по-прежнему может его поднять (ради #2659 -- см. комментарий выше: +# ложные снятия при неполном покрытии обхода), но не бесконечно. Почему именно 2, а +# не 3 или 1.5: вдвое — это ещё «мы искренне не уверены, что молчание значит +# снятие», не «источник вообще умер». Дальнейший рост пола сигнализирует не о +# медленном, но живом обходе, а о мёртвом источнике -- для ЭТОГО случая уже есть +# отдельный гейт по здоровью (min_confirmations) выше в этой же функции, который +# выключает деактивацию целиком, а не растягивает TTL до бесконечности. Калибровочная +# ручка, не догма -- при новом замере можно пересмотреть, как и revisit_floor_quantile. +# +# ПОЧЕМУ MULT, А НЕ ФИКСИРОВАННОЕ ЧИСЛО СУТОК -- И ГДЕ ЭТА ФОРМА ЛОМАЕТСЯ. Множитель +# от ttl_days даёт разный АБСОЛЮТНЫЙ потолок на разных источниках: cian/yandex +# (ttl=30) -> 60 суток, avito (ttl=10) -> 20 суток, domklik (ttl=14) -> 28 суток. Это +# ломается ровно там, где абсолютный хвост переобхода источника НЕ пропорционален его +# ttl_days. Замер (_REVISIT_TAIL, 40 суток): avito p99 = 42.1 сут -- ВЫШЕ его же +# потолка 20. То есть для avito дефолтный CAP_MULT=2 может резать ttl ниже +# собственного хвоста обхода -- ровно тот false-kill, ради которого пол вообще +# заведён (см. комментарий выше). domklik (потолок 28 при хвосте 3.1) разрыва не +# имеет -- множитель 2 для него калиброван верно. +# +# YANDEX -- ТА ЖЕ ДЫРА, НАЙДЕНА ПОЗЖЕ (ревью круга 3, 2026-08-15). Строка выше до +# этой правки утверждала, что cian/yandex с потолком 60 тоже в порядке -- это было +# верно для cian (live-пол сейчас 31.1), но НЕ для yandex: ЖИВЫЕ полы из +# scrape_runs.counters (deactivate_stale_yandex, 2026-08-10..08-15) -- 75/75/75/39/ +# 52/54, а прямой live-замер той же percentile_disc(0.99)-формулы сегодня даёт 79.2 +# (n=1961 подтверждений за 3 суток). И то, и другое ВЫШЕ потолка 60 -- тот же +# false-kill класс, что у avito, статический p99=43.0 (_REVISIT_TAIL) для yandex +# устарел и вводит в заблуждение. cap_mult для yandex откалиброван отдельной +# миграцией (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90) -- +# см. её комментарий про то, почему это НЕ меняет число деактивированных строк +# следующим прогоном (0 активных строк источника старше 39 суток на момент замера). +# +# ПОЭТОМУ cap_mult -- параметр функции (как revisit_floor_quantile, min_confirmations), +# не голая константа: default = CAP_MULT для источников, где 2x достаточно (cian, +# domklik), но расписание может переопределить через default_params (JSON-колонка +# scrape_schedules, ключ "cap_mult") для источника с непропорционально длинным +# хвостом -- см. миграции для avito (cap_mult=6, потолок 60, с запасом выше +# статического p99=42.1 и живого прод-пика 52, замеренного 2026-08-10..12) и yandex +# (cap_mult=3, потолок 90, с запасом выше живого пола 79.2, замеренного 2026-08-15). +CAP_MULT = 2 + + +def _build_revisit_floor_sql( + staleness_column: str, *, with_segments: bool, null_segment_only: bool = False +) -> Any: + """Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива. + + Пара «предыдущее наблюдение (снимок) → текущее наблюдение (listings)» даёт + разрыв переобхода в сутках; берём его квантиль по срезу source+segments. + Только строки, у которых свежесть реально сдвинулась, — то есть выжившие, + а не «мы к ним не приходили». + + null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments) + (ANY никогда не матчит NULL). На практике для null_segment_only-джобы этот запрос + не строится вовсе (revisit_floor_quantile=0 -- см. модульный докстринг), но вариант + нужен для корректности, если порог когда-нибудь включат. + + staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings. + Значения — param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type). + """ + if null_segment_only: + segment_filter = _REVISIT_FLOOR_NULL_SEGMENT_FILTER + elif with_segments: + segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER + else: + segment_filter = "" + return text( + f""" + SELECT percentile_disc(CAST(:revisit_quantile AS double precision)) + WITHIN GROUP ( + ORDER BY EXTRACT(epoch FROM (l.{staleness_column} - prev.last_seen_at)) + / 86400.0 + ) + FROM listings l + JOIN listing_sources ls + ON ls.listing_id = l.id + AND ls.ext_source = l.source + JOIN listing_source_snapshots prev + ON prev.listing_source_id = ls.id + AND prev.snapshot_date = ( + SELECT max(snapshot_date) + FROM listing_source_snapshots + WHERE snapshot_date + <= CURRENT_DATE - CAST(:health_window_days AS integer) + ) + WHERE l.source = :listing_source + AND l.{staleness_column} + > NOW() - CAST(:health_window_days || ' days' AS interval) + AND l.{staleness_column} > prev.last_seen_at{segment_filter} + """ + ) + + +def _build_confirmations_sql( + staleness_column: str, *, with_segments: bool, null_segment_only: bool = False +) -> Any: """SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE. + null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments). + Для null_segment_only-джобы min_confirmations=0 по умолчанию (см. модульный + докстринг), так что на практике этот путь не строится -- оставлен для корректности. + staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings. Значения (:listing_source, :health_window_days, :segments) — param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type). """ - segment_filter = _CONFIRMATIONS_SEGMENT_FILTER if with_segments else "" + if null_segment_only: + segment_filter = _CONFIRMATIONS_NULL_SEGMENT_FILTER + elif with_segments: + segment_filter = _CONFIRMATIONS_SEGMENT_FILTER + else: + segment_filter = "" return text( f""" SELECT count(*) @@ -190,6 +401,33 @@ def _build_segments_sql(staleness_column: str) -> Any: ) +def _build_null_segment_sql(staleness_column: str) -> Any: + """UPDATE строго по listing_segment IS NULL (null_segment_only=True). + + НЕ переиспользует _build_segments_sql: `= ANY(CAST(:segments AS text[]))` никогда + не матчит NULL (SQL-семантика, не баг -- та же ловушка задокументирована выше у + novostroyki-гарда), поэтому NULL-сегмент не выразить через список segments и нужен + отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' -- + их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах. + + staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе. + """ + return text( + f""" + WITH stale AS ( + UPDATE listings + SET is_active = false + WHERE source = :listing_source + AND is_active = true + AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + AND listing_segment IS NULL + RETURNING id, price_rub + ) + {_STALE_SNAPSHOT_TAIL} + """ + ) + + # Дефолтные (last_seen_at) варианты SQL -- сохранены как модульные константы для # обратной совместимости (тесты читают .text, product_handlers/scheduler не менялись). _DEACTIVATE_SQL_ALL_SEGMENTS = _build_all_segments_sql("last_seen_at") @@ -209,6 +447,9 @@ def deactivate_stale_listings( staleness_column: str = "last_seen_at", min_confirmations: int = 0, health_window_days: int = _HEALTH_WINDOW_DAYS, + revisit_floor_quantile: float = 0.0, + null_segment_only: bool = False, + cap_mult: float = CAP_MULT, ) -> dict[str, int]: """Пометить is_active=false объявления, чья свежесть старше ttl_days дней. @@ -218,6 +459,7 @@ def deactivate_stale_listings( ttl_days: количество дней TTL; объявления старше этого порога деактивируются. segments: если задан -- деактивировать только объявления с указанными listing_segment значениями. None -> все сегменты (поведение avito по умолчанию). + Несовместимо с null_segment_only=True (см. ниже). staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist {"last_seen_at", "scraped_at"} — иначе ValueError ДО любого SQL. Дефолт last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch @@ -229,6 +471,27 @@ def deactivate_stale_listings( вызывают старые тесты и совместимая обёртка); реальные значения приходят из default_params расписания, см. миграцию 219 и комментарий выше. health_window_days: окно подтверждений для гейта, суток. Дефолт 3. + revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659). + Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой; + эффективный TTL = min(max(ttl_days, этот пол), ttl_days * cap_mult) -- + пол поднимает TTL, но не выше потолка. 0 -> пол выключен (так + вызывают старые тесты и совместимая обёртка), рабочее значение — + DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше. + null_segment_only: True -> WHERE фильтрует `listing_segment IS NULL` вместо + ANY(:segments). Требует segments=None (иначе ValueError -- смешивать + бессмысленно, это два непересекающихся среза). Для этого среза гейт/пол + обычно держат выключенными (min_confirmations=0, revisit_floor_quantile=0, + см. миграцию 266 и модульный докстринг) -- население слишком мало для + откалиброванных под полноценный vtorichka-свип порогов. + cap_mult: множитель потолка эффективного TTL (см. комментарий у модульной + константы CAP_MULT). Дефолт -- сама CAP_MULT=2, но параметр, а НЕ голая + константа: источник с непропорционально длинным хвостом переобхода + относительно своего ttl_days (avito: p99=42.1 при ttl=10 -> дефолтный + потолок 20 режет ниже хвоста) может переопределить его через + default_params расписания (ключ "cap_mult"), не трогая остальные + источники. Итоговый потолок = ttl_days * cap_mult. Применяется и к + null_segment_only-джобе, но там гейт/пол выключены (см. выше), так что + на практике не участвует. Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots @@ -236,14 +499,59 @@ def deactivate_stale_listings( Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками). Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N, - "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. + "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял + TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. Если пол + упёрся в потолок cap_mult: дополнительно {"ttl_floor_capped": 1, + "ttl_days_floor_raw": N} -- N это то, во что пол поднял бы TTL БЕЗ потолка. Raises: - ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, - никакой интерполяции пользовательского ввода в запрос). + ValueError: если staleness_column не входит в whitelist, ИЛИ ttl_days <= 0 + (проверка ДО SQL, никакой интерполяции пользовательского ввода в запрос; + ttl_days<=0 в WHERE-условии last_seen_at < NOW() - INTERVAL 'N days' + матчит практически весь активный пул -- без явного guard'а потолок + (ttl_days * cap_mult <= 0) к тому же перебивал бы пол в формуле min(), + снимая защиту, которую max(ttl_days, floor) давал раньше), ИЛИ cap_mult < 1 + (тот же класс дыры, но со стороны потолка, а не пола: cap_mult приходит из + jsonb default_params расписания -- ЕДИНСТВЕННЫЙ запланированный способ его + задать, т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода. cap_mult=0 + даёт capped=0 -> effective_ttl_days=0 -> UPDATE снимает практически весь + активный пул источника; cap_mult<1 (например 0.5) опускает потолок НИЖЕ + заданного оператором ttl_days -- прямое нарушение инварианта «потолок не + может понизить TTL ниже настроенного», который проверяет + test_cap_never_lowers_ttl_below_configured_value), ИЛИ ttl_days/cap_mult -- + bool (найдено ревью круга 3, 2026-08-15: `cap_mult < 1` пропускает `True` -- + `bool` наследует `int`, `True < 1` ложно, а `ttl_days * True` == `ttl_days`, + то есть потолок = сам ttl_days и пол молча отключается, никакого ValueError. + jsonb `true`/`false` вместо числа -- ровно та опечатка в расписании, ради + которой оба guard'а вообще написаны, поэтому bool отклоняется явной + type-проверкой ДО числового сравнения для обоих параметров), ЛИБО если + заданы одновременно null_segment_only=True и segments (взаимоисключающие + срезы -- IS NULL и ANY(:segments) не композируются). """ counters: dict[str, int] = {"deactivated": 0} try: + # bool -- подкласс int в Python, поэтому `True < 1` (False) и `False <= 0` + # (True) НЕ ловят опечатку `"ttl_days": true` / `"cap_mult": true` в jsonb: + # `ttl_days * True` == `ttl_days`, `cap_mult=True` даёт потолок == ttl_days и + # молча отключает пол (см. Raises выше). Проверка типа -- ДО числового + # сравнения, иначе bool проскакивает мимо него необнаруженным. + if isinstance(ttl_days, bool): + raise ValueError(f"ttl_days must be a number, not bool: {ttl_days!r}") + if ttl_days <= 0: + raise ValueError(f"ttl_days must be positive, got {ttl_days!r}") + + # Тот же класс дыры, что и ttl_days<=0 выше, только со стороны потолка: + # cap_mult < 1 может опустить потолок (ttl_days * cap_mult) НИЖЕ заданного + # ttl_days, а cap_mult <= 0 -- сделать капнутый потолок <= 0 и победить пол + # в min() молча (ровно та дыра, ради которой заведён guard выше). Единственный + # запланированный способ задать cap_mult -- вписать его руками в jsonb + # default_params расписания (см. миграцию для avito), т.е. именно там опечатка + # 0 / 0.5 вместо 6 -- реальный риск, а не гипотетика. + if isinstance(cap_mult, bool): + raise ValueError(f"cap_mult must be a number, not bool: {cap_mult!r}") + if cap_mult < 1: + raise ValueError(f"cap_mult must be >= 1, got {cap_mult!r}") + # Whitelist-проверка ДО построения/выполнения SQL: только после неё имя колонки # интерполируется f-string'ом. Значения по-прежнему идут через param-binding. # Внутри try -> невалидная колонка финализирует run как failed (mark_failed), @@ -253,6 +561,11 @@ def deactivate_stale_listings( f"invalid staleness_column={staleness_column!r}; " f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}" ) + # null_segment_only + segments одновременно -- неоднозначный запрос: + # IS NULL и ANY(:segments) -- разные, непересекающиеся предикаты, а не + # композиция. Явный ValueError лучше молчаливого выбора одного из двух. + if null_segment_only and segments is not None: + raise ValueError("null_segment_only=True несовместимо с заданным segments") # Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима # на практике (вернуть «живость» может только повторный сбор), поэтому @@ -266,7 +579,11 @@ def deactivate_stale_listings( health_params["segments"] = segments confirmations = ( db.execute( - _build_confirmations_sql(staleness_column, with_segments=segments is not None), + _build_confirmations_sql( + staleness_column, + with_segments=segments is not None, + null_segment_only=null_segment_only, + ), health_params, ).scalar() or 0 @@ -281,25 +598,110 @@ def deactivate_stale_listings( logger.warning( "deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — " "подтверждений за %d сут %d < порога %d " - "(segments=%r, staleness_column=%s); ни одна строка не тронута", + "(segments=%r, null_segment_only=%s, staleness_column=%s); " + "ни одна строка не тронута", listing_source, run_id, health_window_days, confirmations, min_confirmations, segments, + null_segment_only, staleness_column, ) return counters - # segments is None -> все сегменты (поведение avito). segments=[...] -> только - # перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список [] - # означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0), - # а НЕ "все сегменты" — иначе случайный [] стёр бы весь источник. - if segments is not None: + # Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же + # срезу. Поднимает порог (max), но не выше потолка cap_mult * ttl_days (min) — + # см. комментарий у CAP_MULT про петлю с положительной обратной связью и про + # то, почему cap_mult -- параметр, а не голая константа. + effective_ttl_days = ttl_days + if revisit_floor_quantile > 0: + floor_params: dict[str, Any] = { + "listing_source": listing_source, + "health_window_days": health_window_days, + "revisit_quantile": revisit_floor_quantile, + } + if segments is not None: + floor_params["segments"] = segments + floor_days = db.execute( + _build_revisit_floor_sql( + staleness_column, + with_segments=segments is not None, + null_segment_only=null_segment_only, + ), + floor_params, + ).scalar() + # NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках). + # Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего. + if floor_days is not None: + counters["revisit_floor_days"] = ceil(float(floor_days)) + # Пол поднимает TTL (max), потолок cap_mult его не пускает выше + # ttl_days * cap_mult (min) — без этого пол растёт без ограничения + # (см. комментарий у CAP_MULT). capped_ttl_days может быть float, + # если cap_mult переопределён нецелым значением из default_params — + # effective_ttl_days приводим к int (UPDATE ждёт целые сутки). + raw_effective_ttl_days = max(ttl_days, counters["revisit_floor_days"]) + capped_ttl_days = ttl_days * cap_mult + effective_ttl_days = int(min(raw_effective_ttl_days, capped_ttl_days)) + counters["ttl_days_effective"] = effective_ttl_days + + if raw_effective_ttl_days > capped_ttl_days: + # Пол упёрся в потолок -- оба числа в counters (не только в логе), + # чтобы это было видно в витрине прогонов, а не только в логах. + # 1, а не True -- counters типизирован dict[str, int] (тот же + # идиом, что skipped_unhealthy выше). + counters["ttl_floor_capped"] = 1 + counters["ttl_days_floor_raw"] = raw_effective_ttl_days + logger.warning( + "deactivate_stale source=%s run_id=%d TTL пол упёрся в потолок " + "cap_mult=%s: пол поднял бы TTL до %d сут, потолок ограничивает " + "заданные %d сут значением %d (квантиль %.3f, segments=%r) — " + "растущий без ограничения пол это петля с положительной обратной " + "связью, см. комментарий у CAP_MULT", + listing_source, + run_id, + cap_mult, + raw_effective_ttl_days, + ttl_days, + effective_ttl_days, + revisit_floor_quantile, + segments, + ) + elif effective_ttl_days > ttl_days: + logger.warning( + "deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: " + "свип за %d сут доказал живой строку, молчавшую %d сут " + "(квантиль %.3f, segments=%r) — при ttl_days=%d снятие означало бы " + "«мы не дошли», а не «объявление снято»", + listing_source, + run_id, + ttl_days, + effective_ttl_days, + health_window_days, + counters["revisit_floor_days"], + revisit_floor_quantile, + segments, + ttl_days, + ) + + # null_segment_only -> IS NULL, отдельный явный предикат (ANY(:segments) + # никогда не матчит NULL). segments is None -> все сегменты (поведение avito). + # segments=[...] -> только перечисленные сегменты. Используем `is not None` + # (НЕ truthy): пустой список [] означает "ни один сегмент" (= ANY(ARRAY[]) + # ничего не матчит, деактивирует 0), а НЕ "все сегменты" — иначе случайный [] + # стёр бы весь источник. + if null_segment_only: params: dict[str, Any] = { "listing_source": listing_source, - "ttl_days": ttl_days, + "ttl_days": effective_ttl_days, + "run_id": run_id, + } + result = db.execute(_build_null_segment_sql(staleness_column), params) + elif segments is not None: + params = { + "listing_source": listing_source, + "ttl_days": effective_ttl_days, "segments": segments, "run_id": run_id, } @@ -307,7 +709,7 @@ def deactivate_stale_listings( else: params = { "listing_source": listing_source, - "ttl_days": ttl_days, + "ttl_days": effective_ttl_days, "run_id": run_id, } result = db.execute(_build_all_segments_sql(staleness_column), params) @@ -318,12 +720,15 @@ def deactivate_stale_listings( runs_mod.mark_done(db, run_id, counters) logger.info( "deactivate_stale source=%s run_id=%d done: deactivated=%d " - "(ttl_days=%d, segments=%r, staleness_column=%s)", + "(ttl_days=%d эффективный, задан %d, segments=%r, null_segment_only=%s, " + "staleness_column=%s)", listing_source, run_id, counters["deactivated"], + effective_ttl_days, ttl_days, segments, + null_segment_only, staleness_column, ) return counters diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 0e304651..00d91cf9 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -17,8 +17,9 @@ Both are wired together in the debug endpoint `POST /scrape/domclick/debug/detai wiring into the production scheduled orchestrator (previously only reachable manually). Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher -per run (async context manager, source="domclick" -- dedicated residential proxy pool, -see 173_scrape_proxies_add_domclick_affinity.sql) + cookies loaded ONCE via +per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула; +выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800), +на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via domclick_session.load_session(db) and threaded into every fetch_detail() call. NAMING TRAP (verified live against prod DB 2026-07-04, do NOT "fix" this anywhere): @@ -40,9 +41,15 @@ Exception triad differs from Avito: Статус такого прогона — 'banned' (#2674, см. runs.mark_backfill_finished): блок это external constraint, не наш баг, но и НЕ успех — раньше здесь стоял mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными. - No IP-rotation/cooldown recovery step exists here - (DomClick uses one dedicated residential proxy, not a rotating pool) -- an - aborted run simply retries the remaining backlog next window. + No IP-rotation/cooldown recovery step exists here -- an aborted run simply + retries the remaining backlog next window. + УСТАРЕВШЕЕ ОБОСНОВАНИЕ, снято 13.08: здесь стояло «DomClick uses one dedicated + residential proxy, not a rotating pool». Это перестало быть правдой на миграции + 253 (#2800), снявшей резервацию узла; сегодня узлов четыре и все общие. То есть + отсутствие ротации больше НЕ следует из «ротировать нечего» — это просто + непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой + комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по + длительности до блока 111-332 с, получает свой бюджет). ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py index 93dd3113..bf71333d 100644 --- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py @@ -109,10 +109,17 @@ class NewbuildingEnrichBackfillResult: failed_fetch: int = 0 # fetch returned None / raised failed_save: int = 0 # save raised after a good fetch - # Row-level deltas (how much actually landed). - price_dynamics_rows: int = 0 - reliability_rows: int = 0 - review_rows: int = 0 + # Сколько РЕАЛЬНО записано, по словам самих писателей (#2807). Раньше здесь стоял + # прирост COUNT(*) по таблице до/после сохранения — то есть «выросла ли таблица», а + # не «сколько записали»: при ON CONFLICT DO UPDATE обновление даёт ноль, а у + # reliability ноль давал ещё и _dedup_reliability, схлопывающий дубль сразу после + # вставки. Ключи переименованы намеренно: у price_dynamics_rows/reliability_rows/ + # review_rows в истории прогонов старый смысл, и молча поменять его под тем же + # именем — ровно тот дефект, ради которого правка и делается. + price_dynamics_inserted: int = 0 # новых точек динамики цен + price_dynamics_updated: int = 0 # существующих точек переписано свежей ценой + reliability_inserted: int = 0 # строк house_reliability_checks вставлено + review_upserted: int = 0 # отзывов записано (вставка+обновление, ключ ext_review_id) duration_sec: float = field(default=0.0) @@ -399,9 +406,18 @@ async def backfill_newbuilding_enrichment( save_newbuilding_enrichment, ) - from app.services.scraper_adapters import RealScraperConfig + from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig scraper_config = RealScraperConfig() + # #2767: обогащение было ЕДИНСТВЕННЫМ cian-путём мимо пула прокси — весь сбор шёл + # через env-узел сайдкара, и когда Циан забанил его exit-IP, 8 суток по 25 попыток + # уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠ + # «включить пул»: реально пул задействуется, только если включён + # config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding). + # #2830: тот же провайдер уходит и в resolve-ногу (curl_cffi, флаг + # use_proxy_pool_curl) — #2767 починил только fetch, а резолв ЖК-url остался на + # статичном cian_proxy_url, то есть на второй ноге той же цепочки. + proxy_provider = RealProxyProvider() result = NewbuildingEnrichBackfillResult() t0 = time.time() @@ -478,7 +494,9 @@ async def backfill_newbuilding_enrichment( continue try: - resolved = await resolve_cian_zhk_url_via_search(nb_id, config=scraper_config) + resolved = await resolve_cian_zhk_url_via_search( + nb_id, config=scraper_config, proxy_provider=proxy_provider + ) except Exception as exc: # defensive — resolver already catches internally logger.warning( "zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc @@ -525,7 +543,9 @@ async def backfill_newbuilding_enrichment( # ── Fetch (network; anti-bot surface) ────────────────────────────── enrichment = None try: - enrichment = await fetch_newbuilding(zhk_url, config=scraper_config) + enrichment = await fetch_newbuilding( + zhk_url, config=scraper_config, proxy_provider=proxy_provider + ) except Exception as exc: logger.warning( "newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc @@ -535,8 +555,12 @@ async def backfill_newbuilding_enrichment( continue if enrichment is None: + # Без «(captcha / parse miss?)» (#2767): догадка автора кода в тексте лога + # читается дальше как факт и один раз уже увела диагноз не туда. Причина + # печатается строкой ВЫШЕ, в самом месте отказа (html_len + antibot_markers). logger.warning( - "newbuilding fetch returned None house_id=%s url=%s (captcha / parse miss?)", + "newbuilding fetch returned None house_id=%s url=%s — причина в строке " + "'initialState extraction failed' выше", house_id, zhk_url, ) @@ -545,15 +569,15 @@ async def backfill_newbuilding_enrichment( continue # ── Save under a SAVEPOINT so one bad house can't poison the batch ── - # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally, - # so we snapshot the row counts BEFORE and recompute the delta AFTER its commit - # rather than relying on the nested transaction staying open. - pd_before, rc_before, rv_before = _house_enrichment_counts(db, house_id) + # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally. + # COUNT(*) до сохранения нужен ТОЛЬКО для had_reliability (дедуп ниже): сколько + # записано, теперь сообщают сами писатели, а не разница COUNT'ов (#2807). + _, rc_before, _ = _house_enrichment_counts(db, house_id) try: had_reliability = rc_before > 0 # 1) price_dynamics + reliability + houses UPDATE (existing, commits inside). - save_newbuilding_enrichment(db, house_id, enrichment) + saved = save_newbuilding_enrichment(db, house_id, enrichment) # 2) reviews — added here (save_newbuilding_enrichment skips them). # SAVEPOINT around the review write so a malformed review can't lose the @@ -589,16 +613,18 @@ async def backfill_newbuilding_enrichment( sp.rollback() logger.warning("reliability dedup failed house_id=%s: %s", house_id, dexc) - pd_after, rc_after, rv_after = _house_enrichment_counts(db, house_id) - result.price_dynamics_rows += max(0, pd_after - pd_before) - result.reliability_rows += max(0, rc_after - rc_before) - result.review_rows += max(0, rv_after - rv_before) + result.price_dynamics_inserted += saved.price_inserted + result.price_dynamics_updated += saved.price_updated + result.reliability_inserted += saved.reliability_inserted + result.review_upserted += review_written result.succeeded += 1 logger.info( - "enriched house_id=%s: +pd=%d +reliability=%d +reviews=%d (parsed reviews=%d)", + "enriched house_id=%s: динамика цен +%d новых / %d обновлено, " + "reliability +%d, отзывов записано %d (распознано %d)", house_id, - max(0, pd_after - pd_before), - max(0, rc_after - rc_before), + saved.price_inserted, + saved.price_updated, + saved.reliability_inserted, review_written, len(enrichment.reviews), ) @@ -617,8 +643,8 @@ async def backfill_newbuilding_enrichment( result.duration_sec = time.time() - t0 logger.info( "newbuilding-enrich backfill done: processed=%d ok=%d skip=%d resolved=%d " - "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d " - "| %.1fs", + "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / " + "%d обновлено, reliability +%d, отзывов %d | %.1fs", result.processed, result.succeeded, result.skipped_already_enriched, @@ -626,9 +652,10 @@ async def backfill_newbuilding_enrichment( result.failed_resolve, result.failed_fetch, result.failed_save, - result.price_dynamics_rows, - result.reliability_rows, - result.review_rows, + result.price_dynamics_inserted, + result.price_dynamics_updated, + result.reliability_inserted, + result.review_upserted, result.duration_sec, ) return result @@ -775,8 +802,8 @@ async def run_newbuilding_enrich( ) logger.info( "scheduler: newbuilding_enrich run_id=%d finished — processed=%d ok=%d skip=%d " - "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d " - "| pending=%d %.1fs", + "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / " + "%d обновлено, reliability +%d, отзывов %d | pending=%d %.1fs", run_id, result.processed, result.succeeded, @@ -784,9 +811,10 @@ async def run_newbuilding_enrich( result.failed_resolve, result.failed_fetch, result.failed_save, - result.price_dynamics_rows, - result.reliability_rows, - result.review_rows, + result.price_dynamics_inserted, + result.price_dynamics_updated, + result.reliability_inserted, + result.review_upserted, result.cian_houses_pending, result.duration_sec, ) diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py index 420911ec..4dcda362 100644 --- a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -54,6 +54,21 @@ BATCHING (не единый DELETE по всей таблице): don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier committed batches deleted (correct, not rolled back) and mark_failed records the partial counters reached so far. + +Payments retention (PR #2754): the `created_by IS NULL` population above is EXACTLY + the future paying-customer population -- the owner sells this report to + individuals for money, and a paid row must outlive the 24h `expires_at` link TTL + (a separate column, `retain_until`, set by the -- separate, not-yet-existing -- + payment fulfillment code to now() + settings.trade_in_paid_retention_days, NOT + a change to `expires_at` itself). Two independent safeguards were added to + `_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS payments) + plus a pre-flight count in `purge_expired_trade_in_data` that refuses to run at + all if it finds an ANOMALOUS paid candidate -- see the SQL constants and + `_preflight_paid_candidates` below for the mechanics (deep-review finding + 2026-08-06 MEDIUM on PR #2754: the pre-flight predicate itself must ALSO carry + `retain_until IS NULL`, otherwise a perfectly healthy paid row trips it and + wedges the job permanently -- see that function's docstring). No payment code + lives in this file. """ from __future__ import annotations @@ -74,6 +89,26 @@ logger = logging.getLogger(__name__) # remainder simply drains on the next nightly run (idempotent, no data loss risk). _DEFAULT_MAX_BATCHES = 20 +# +# Payments retention (2026-08-06, PR #2754): два независимых предохранителя +# добавлены к тому же предикату ПЕРЕД тем, как платёжный код появился в +# проекте (мина уже была заряжена: без них джоба удаляла бы будущих платящих +# клиентов). Отдельная колонка retain_until (не подъём expires_at) — потому +# что expires_at глобальный TTL расчёта на ВСЕ строки (включая неоплаченные) +# и печатается в PDF/UI как «актуальность расчёта»; поднять его до года +# означало бы одновременно нарушить минимизацию ПДн по 152-ФЗ и соврать в +# документе клиента про срок актуальности цифры: +# 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная +# строка (retain_until IS NOT NULL, migration 240) не удаляется джобой +# В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем +# через год после первой продажи. `retain_until` ставится сервисным +# кодом платёжного контура (ещё не существует в этом PR) на now() + +# settings.trade_in_paid_retention_days. +# 2. `NOT EXISTS (payments)` — независимая страховка на случай, если выдача +# забыла проставить retain_until (баг/гонка/ручной INSERT): строка, +# которой коснулись деньги, переживёт джобу даже без корректного (1). +# `payments` создана migration 233 (payments_estimate_idx — дешёвый терм). +# См. также _preflight_paid_candidates ниже — та же логика ДО первого батча. _DELETE_EXPIRED_ESTIMATES_SQL = text( """ DELETE FROM trade_in_estimates @@ -81,12 +116,39 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text( SELECT id FROM trade_in_estimates WHERE expires_at < NOW() AND created_by IS NULL + AND retain_until IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payments p WHERE p.estimate_id = trade_in_estimates.id + ) ORDER BY expires_at LIMIT CAST(:batch_size AS int) ) """ ) +# Pre-flight (см. _preflight_paid_candidates). deep-review finding 2026-08-06 +# MEDIUM (PR #2754): первая редакция считала по БАЗОВОМУ предикату БЕЗ +# retain_until вообще -- а это ловит и штатно-здоровые оплаченные строки +# (retain_until проставлен, есть payments) точно так же, как настоящую +# аномалию (retain_until НЕ проставлен, но payments есть) -- джоба вставала +# на первой же честной продаже и больше никогда не запускалась (вместе с ней +# вставало и удаление лидов, вызываемое из той же функции ПОСЛЕ этой +# проверки -- 180-дневный purge по 152-ФЗ тоже переставал бы работать). +# Правильная форма: базовый предикат AND "новый предохранитель НЕ сработал +# бы" (retain_until IS NULL) AND "признак аномалии" (payments всё же есть). +# Здоровая оплаченная строка (retain_until IS NOT NULL) исключается ЭТИМ +# термом -- она и так под DELETE не попадает (см. safeguard 1 выше), тревогу +# поднимать не должна. +_PREFLIGHT_PAID_CANDIDATES_SQL = text( + """ + SELECT count(*) FROM trade_in_estimates e + WHERE e.expires_at < NOW() + AND e.created_by IS NULL + AND e.retain_until IS NULL + AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id) + """ +) + _DELETE_EXPIRED_LEADS_SQL = text( """ DELETE FROM trade_in_leads @@ -135,6 +197,25 @@ def _drain_expired( break # caught up -- fewer expired rows left than one batch +def _preflight_paid_candidates(db: Session) -> int: + """Safety gate: count ANOMALOUS purge-candidates -- base predicate, retain_until + IS NULL (safeguard 1 did NOT protect the row), AND a payments row exists anyway. + + Runs BEFORE any DELETE batch. A non-zero result means fulfillment failed to set + `retain_until` on a row money actually touched (bug/race/manual INSERT) -- this + run must not delete anything; see `purge_expired_trade_in_data` below, which + aborts before the first batch when this returns non-zero. + + MUST include `retain_until IS NULL` (deep-review finding 2026-08-06 MEDIUM, PR + #2754): a healthy paid row (retain_until set, has a payments row) is the EXPECTED + steady state one day after every sale -- without this term it counts as a "paid + candidate" too, so the very first successful sale permanently wedges this job + (mark_failed, zero deletions, forever -- and since leads purge runs from the same + function AFTER this check, the unrelated 180-day lead retention would also stop). + """ + return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one() + + def purge_expired_trade_in_data( db: Session, run_id: int, @@ -148,10 +229,32 @@ def purge_expired_trade_in_data( deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed). Returns {"estimates_deleted": N, "leads_deleted": M}. + + Payments retention pre-flight (see `_preflight_paid_candidates`): if any + purge-candidate estimate has `retain_until IS NULL` AND a `payments` row (the + ANOMALY -- fulfillment failed to set retain_until on a row money touched), the + run aborts BEFORE the first DELETE batch (estimates OR leads) -- zero rows + deleted, `mark_failed` records why. Healthy paid rows (retain_until set) do NOT + trip this -- they never matched the check to begin with. This is deliberately + checked outside the `try` below so it can never be caught and silently + re-reported as a generic mid-run failure -- it is a distinct, actionable + pre-condition failure. """ batch_size = batch_size or settings.trade_in_purge_batch_size max_batches = max_batches or _DEFAULT_MAX_BATCHES counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0} + + paid_candidates = _preflight_paid_candidates(db) + if paid_candidates: + error = ( + f"pre-flight abort: {paid_candidates} purge-candidate trade_in_estimates " + "row(s) have retain_until IS NULL but a matching payments row -- " + "refusing to run, zero rows deleted" + ) + logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error) + runs_mod.mark_failed(db, run_id, error, counters) + raise RuntimeError(error) + try: _drain_expired( db, diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index 487179d5..cde46a41 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -1,56 +1,68 @@ -"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1. +"""Монитор ОТСТАВАНИЯ ЗАГРУЗКИ СберИндекса (не календарного возраста периода). -Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет -СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning, -когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба -`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует -данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит -(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness -видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок. +ЧТО БЫЛО НЕ ТАК (замер на проде 2026-08-12, #2846). -Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и -поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага -публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. +Монитор мерил `now() - max(period_month)` и алертил при возрасте > 60 суток +(sber_index_max_age_days 35 + lag_allowance 25). Такой возраст НЕДОСТИЖИМО МАЛ по +построению: `period_month` — метка ПЕРВОГО числа месяца, поэтому на закрытии месяца +возрасту уже ≥30; плюс собственный лаг публикации источника. За 31 сутки прямых +наблюдений монитора (07-13 … 08-12, scrape_runs.counters) возраст лежал в 46..76 и +НИ РАЗУ не опускался ниже 46. Порог 35 у оценщика был истинным 100% времени — ноль бит. -#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с -LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING -событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его -застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor) -писал ERROR с самого начала — расходилась только эта джоба. +Порог 60 у монитора не лучше: он лежит ВНУТРИ рабочего диапазона, поэтому монитор +мерил не источник, а нашу же пилу. Миграция 212 (такт 28 → 7) обещала потолок +возраста ≈46+7=53 < 60. Прод это ОПРОВЕРГ: 2026-08-12 возраст 72 при ПОЛНОМ прогоне +загрузки шестидневной давности (08-06, errors=0, upserted=639) — источник просто не +опубликовал июль. Двенадцать суток подряд (08-01 … 08-12) монитор писал ERROR при +исправной загрузке. Потолок 53 держится, только если источник публикует строго +помесячно; он не публикует. -ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов -монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего -собственного такта загрузки: - 13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65 - 17.07 alert=0 age 46 latest=июнь (день загрузки) -Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же -считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок -46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать -такое до ERROR без починки такта значило бы завести ежедневное ложное событие на -две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ -такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает -«источник/загрузка встали», а не «мы давно не ходили». +ЧТО МЕРИМ ТЕПЕРЬ. Загрузчик тянет ВСЮ серию (limit=1000&offset=0, отсечки по периоду +нет), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) РАВЕН +максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» — это вопрос +«давно ли был последний ЗАВЕДОМО ПОЛНЫЙ прогон», и он не зависит от возраста периода: -Порог алерта (документирование выбора): - Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д). - Монитор: age > sber_index_max_age_days + lag_allowance. - lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг - публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл - ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток. - Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста - ~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл - поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри - рабочего диапазона — если такт загрузки будут менять, пересчитай потолок - (пол + interval_days) и сверь с 60. + последний полный прогон свежий → наш max == max источника → источник не публиковал, + молчание ПРАВИЛЬНОЕ (возраст = лаг источника); + последний полный прогон старый → мы не забрали → тревога про ЗАГРУЗЧИК. -Задача синхронная (DB-only, один SELECT max(period_month)) — запускается -kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в -run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ -функция evaluate_sber_freshness() (frozen-now тестируется без БД). +ЛОВУШКА: `status='done'` НЕ означает успех — прогон id=37 (2026-05-31) имеет +{errors: 9, upserted: 0} и статус done. Успех = errors=0 AND upserted>0 (все 9 серий +3 табло × 3 региона прошли: errors — счётчик по всему прогону). -Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи -достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего -оценивать). +ПОРОГ — не круглое число, а такт самой загрузки: `scrape_schedules.default_params +.interval_days` для sber_index_pull, ЧИТАЕТСЯ ИЗ ТОЙ ЖЕ СТРОКИ, по которой планировщик +запускает прогон (orchestration/scheduler.py::_defer_next_run_at). Разъехаться с +тактом порог не может: поменяли такт — порог поехал следом. Тревога после +MISSED_PULL_CYCLES=2 пропущенных тактов: один пропуск (сдвиг окна, разовый сбой сети) +поглощается, два подряд означают, что загрузка встала. При нынешнем такте 7 это 14 +суток; на прод-истории такое состояние ДОСТИЖИМО — разрывы между полными прогонами +были 14.8 и 20 суток (05-31→06-15 и 07-17→08-06). + +ПО ТАБЛО, А НЕ ПО max() ВСЕЙ ТАБЛИЦЫ. Оценщик берёт ПЕРВОЕ НЕПУСТОЕ табло из +estimator.SBER_COEFF_DASHBOARDS; у real_estate_deals latest=2026-06, у +dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблице маскирует +отставшее табло, поэтому монитор идёт тем же порядком, что и оценщик, и берёт ту же +серию — список импортируется из estimator, дублировать его тут нельзя. + +ЧЕГО ЭТОТ МОНИТОР НЕ ЛОВИТ (осознанно, #2846). Если источник ЗАМОЛЧИТ НАВСЕГДА, а +загрузка останется исправной — монитор промолчит: по нашим данным «источник не +публиковал 2 месяца» неотличимо от «источник публикует раз в 2 месяца». Такт +публикации источника ретроспективно невосстановим — его затёр апсерт +(sber_index.py ставил fetched_at=now() всем строкам серии). С этого PR fetched_at +не переписывается при конфликте и означает «когда мы ВПЕРВЫЕ увидели этот период», +т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал» +имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026). + +ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с +LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще. + +Задача синхронная (DB-only) — запускается kit-scheduler'ом через +product_handlers._job_sber_freshness_monitor в run_in_executor. Вердикт считает +ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now, тестируется без БД). + +Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы). mark_failed +только если у оценщика вообще нет серии (нечего оценивать). """ from __future__ import annotations @@ -62,153 +74,239 @@ from datetime import UTC, date, datetime from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings from app.services import scrape_runs as runs_mod +from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION logger = logging.getLogger(__name__) __all__ = [ - "DEFAULT_LAG_ALLOWANCE_DAYS", + "DEFAULT_PULL_INTERVAL_DAYS", + "MISSED_PULL_CYCLES", + "SBER_FRESHNESS_PULL_SOURCE", "SberFreshnessVerdict", "check_sber_freshness", "evaluate_sber_freshness", ] -# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate -# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days. -DEFAULT_LAG_ALLOWANCE_DAYS = 25 +# Джоба-загрузчик, чей такт и успешность мы и мониторим. +SBER_FRESHNESS_PULL_SOURCE = "sber_index_pull" -# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями -# sber_price_index для областного уровня. -SBER_MONITOR_CITY = "Свердловская область" +# Сколько тактов загрузки подряд можно пропустить до тревоги. 1 = разовый сбой/сдвиг +# окна (поглощаем), 2 = загрузка встала (алерт). +MISSED_PULL_CYCLES = 2 -_LATEST_SBER_PERIOD_SQL = text(""" +# Фолбэк, если в scrape_schedules нет строки/ключа interval_days (миграция 212 ставит 7). +DEFAULT_PULL_INTERVAL_DAYS = 7 + +_LATEST_PERIOD_SQL = text(""" SELECT max(period_month) AS latest FROM sber_price_index WHERE city = CAST(:city AS text) + AND dashboard = CAST(:dash AS text) -- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка -- (новостройки) = направленно неверная коррекция. Зеркалит фильтр -- estimator._load_sber_index_series. AND (segment IS NULL OR segment ILIKE '%вторичн%') """) +# Последний ЗАВЕДОМО ПОЛНЫЙ прогон загрузчика. status='done' сюда не входит намеренно: +# прогон id=37 имеет done при {errors: 9, upserted: 0}. Сравнения — jsonb-ные, без +# CAST(... AS int): counters других источников планировщик может отфильтровать позже +# каста, а не раньше, и нечисловое значение уронило бы запрос. Для jsonb-чисел +# оператор > численный. +_LAST_COMPLETE_PULL_SQL = text(""" + SELECT max(finished_at) AS last_pull + FROM scrape_runs + WHERE source = CAST(:src AS text) + AND counters @> CAST('{"errors": 0}' AS jsonb) + AND counters -> 'upserted' > CAST('0' AS jsonb) +""") + +# Такт загрузки — из той же строки, по которой планировщик считает next_run_at. +_PULL_INTERVAL_SQL = text(""" + SELECT default_params ->> 'interval_days' AS interval_days + FROM scrape_schedules + WHERE source = CAST(:src AS text) +""") + @dataclass(frozen=True) class SberFreshnessVerdict: - """Вердикт свежести СберИндекса по max(period_month).""" + """Вердикт: отстала ли ЗАГРУЗКА СберИндекса от собственного такта.""" latest_period: date - age_days: int + age_days: int # наблюдение (лаг публикации источника), НЕ критерий тревоги + pull_lag_days: int # суток с последнего полного прогона; -1 = полных прогонов не было + max_pull_lag_days: int # порог = MISSED_PULL_CYCLES × такт загрузки stale: bool def evaluate_sber_freshness( latest_period: date, now: datetime, - max_age_days: int, + *, + last_complete_pull_at: datetime | None, + pull_interval_days: int, ) -> SberFreshnessVerdict: - """Чистая логика: устарел ли latest период СберИндекса. + """Чистая логика: отстала ли загрузка от собственного такта. - stale = age_days > max_age_days, где age_days = now.date() - latest_period. - `max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance), - вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД. + stale = полных прогонов не было ВООБЩЕ, либо последний старше + MISSED_PULL_CYCLES × pull_interval_days. Возраст периода считается и кладётся в + вердикт как НАБЛЮДЕНИЕ, но на вердикт не влияет: после полного прогона наш + max(period_month) равен максимуму источника по построению, и его возраст — это + лаг ПУБЛИКАЦИИ, на который мы повлиять не можем. """ age_days = (now.date() - latest_period).days - stale = age_days > max_age_days + max_pull_lag_days = MISSED_PULL_CYCLES * pull_interval_days + if last_complete_pull_at is None: + return SberFreshnessVerdict(latest_period, age_days, -1, max_pull_lag_days, True) + pull_lag_days = (now - last_complete_pull_at).days return SberFreshnessVerdict( latest_period=latest_period, age_days=age_days, - stale=stale, + pull_lag_days=pull_lag_days, + max_pull_lag_days=max_pull_lag_days, + stale=pull_lag_days > max_pull_lag_days, ) +def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None: + """Табло, которое возьмёт оценщик, и его latest период. + + Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло + из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло. + """ + for dash in SBER_COEFF_DASHBOARDS: + row = db.execute( + _LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash} + ).first() + latest = row.latest if row is not None else None + if latest is not None: + return dash, latest + return None + + +def _pull_interval_days(db: Session) -> int: + """Такт загрузчика из scrape_schedules (фолбэк DEFAULT_PULL_INTERVAL_DAYS).""" + row = db.execute(_PULL_INTERVAL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}).first() + raw = row.interval_days if row is not None else None + try: + return int(raw) if raw is not None else DEFAULT_PULL_INTERVAL_DAYS + except (TypeError, ValueError): + logger.warning( + "sber freshness: interval_days=%r в scrape_schedules нечисловой — беру %d", + raw, + DEFAULT_PULL_INTERVAL_DAYS, + ) + return DEFAULT_PULL_INTERVAL_DAYS + + def check_sber_freshness( db: Session, run_id: int, params: dict | None = None, # type: ignore[type-arg] now: datetime | None = None, ) -> dict[str, int]: - """Проверить свежесть СберИндекса по max(period_month) и алертить при staleness. + """Проверить, не отстала ли загрузка СберИндекса, и алертить при отставании. Sync (вызывается scheduler-триггером в executor, как check_deals_freshness). - Читает один SELECT max(period_month) вторичного сегмента по региону, считает - вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops) - и финализирует run. + Читает: latest период табло оценщика, время последнего ПОЛНОГО прогона + sber_index_pull, такт загрузки из scrape_schedules. Вердикт — чистой функцией. - Params (default_params jsonb): - lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25). - `now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC). + `params` больше ничего не настраивает: порог берётся из такта самой загрузки + (унаследованный default_params.lag_allowance_days=25 монитора игнорируется — + он кодировал мёртвый календарный порог). `now` инъектируется в тестах. - Returns counters {latest_year, latest_month, age_days, alert}. - mark_failed только если sber_price_index пуста/недоступна (нечего оценивать); + Returns counters {latest_year, latest_month, age_days, pull_lag_days, + max_pull_lag_days, alert}. + mark_failed только если у оценщика нет серии вообще (нечего оценивать); при алерте прогон помечается done (это монитор, не сбой джобы). """ - params = params or {} now = now or datetime.now(UTC) counters: dict[str, int] = { "latest_year": 0, "latest_month": 0, "age_days": 0, + "pull_lag_days": -1, + "max_pull_lag_days": 0, "alert": 0, } try: runs_mod.update_heartbeat(db, run_id, counters) - row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first() - latest: date | None = row.latest if row is not None else None - if latest is None: + found = _load_estimator_dashboard(db) + if found is None: # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой, # а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд), # а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке. logger.error( - "sber freshness: sber_price_index пуст/недоступен для region=%s " - "(вторичка) — оценить свежесть нельзя", - SBER_MONITOR_CITY, + "sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк " + "для region=%s (вторичка); оценить нечего", + list(SBER_COEFF_DASHBOARDS), + SBER_TIME_ADJUST_REGION, ) runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters) return counters - lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS)) - max_age_days = settings.sber_index_max_age_days + lag_days - verdict = evaluate_sber_freshness(latest, now, max_age_days) + dashboard, latest = found + last_pull_row = db.execute( + _LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE} + ).first() + last_complete_pull_at = last_pull_row.last_pull if last_pull_row is not None else None + verdict = evaluate_sber_freshness( + latest, + now, + last_complete_pull_at=last_complete_pull_at, + pull_interval_days=_pull_interval_days(db), + ) counters = { "latest_year": latest.year, "latest_month": latest.month, "age_days": verdict.age_days, + "pull_lag_days": verdict.pull_lag_days, + "max_pull_lag_days": verdict.max_pull_lag_days, "alert": int(verdict.stale), } if verdict.stale: - # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) — - # 9 срабатываний на проде дали ноль событий. См. докстринг модуля. + # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR). logger.error( - "sber freshness: max(period_month)=%s устарел на %d дней " - "(> порога %d = sber_index_max_age_days %d + lag %d); " - "СберИндекс time-adjustment ДКП-сделок мог отстать — " - "проверь sber_index_pull и доступность новых периодов источника", + "sber freshness: загрузка СберИндекса отстала — последний ПОЛНЫЙ прогон " + "%s (%s суток назад, порог %d = %d такта × %d суток; " + "status='done' с errors>0 за успех НЕ считается). " + "Наш max(period_month)=%s (табло %s) мог разойтись с источником — " + "проверь sber_index_pull: планировщик, сеть, /api/sowa 404", + last_complete_pull_at.isoformat() if last_complete_pull_at else "НИ РАЗУ", + verdict.pull_lag_days if verdict.pull_lag_days >= 0 else "∞", + verdict.max_pull_lag_days, + MISSED_PULL_CYCLES, + verdict.max_pull_lag_days // MISSED_PULL_CYCLES, latest, - verdict.age_days, - max_age_days, - settings.sber_index_max_age_days, - lag_days, + dashboard, ) else: logger.info( - "sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) " - "region=%s — алерта нет", + "sber freshness: загрузка в такте — последний полный прогон %d суток назад " + "(≤ порога %d). max(period_month)=%s (табло %s, возраст %d суток) равен " + "максимуму источника по построению: возраст = лаг ПУБЛИКАЦИИ источника, " + "не наше отставание — алерта нет", + verdict.pull_lag_days, + verdict.max_pull_lag_days, latest, + dashboard, verdict.age_days, - max_age_days, - SBER_MONITOR_CITY, ) runs_mod.mark_done(db, run_id, counters) logger.info( - "check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d", + "check_sber_freshness run_id=%d done: latest=%s dash=%s alert=%d " + "pull_lag_days=%d age_days=%d", run_id, latest, + dashboard, counters["alert"], + counters["pull_lag_days"], counters["age_days"], ) return counters diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 6538c7c8..48771f0b 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе ведёт на сайт застройщика, а не на realty.yandex.ru/offer//. Парсер отвергает такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон -целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может -разобрать, а размер отброшенного видно в counters.unenrichable_pending. +целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать. + +Но «не по тому URL» ≠ «нечего обогащать» (разобрано 2026-08-12, см. комментарий +у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по +собранному из него каноническому URL страница отдаётся и парсится. Поэтому в +очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены: +url_from_offer_id — сколько ждёт починки адреса, unenrichable_pending — сколько +не адресуемо вообще (ни offer-URL, ни числового source_id). Why curl_cffi and not YandexDetailScraper.fetch_detail: fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS @@ -45,12 +51,14 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings from app.services import scrape_runs as runs_mod +from app.services.proxy_egress import resolve_proxy_url logger = logging.getLogger(__name__) __all__ = [ + "CANONICAL_URL_SQL", + "OFFER_ID_PATTERN", "OFFER_URL_PATTERN", "YandexDetailBackfillResult", "run_yandex_detail_backfill", @@ -63,8 +71,7 @@ __all__ = [ # # Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют # source_url на сайт застройщика (macroserver.ru, prospect-federation.ru, -# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из -# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer//. +# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. # # Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один # застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон: @@ -72,6 +79,39 @@ __all__ = [ # Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим. OFFER_URL_PATTERN = "/offer/[0-9]+" +# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ──────────────── +# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на +# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде: +# +# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная; +# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк, +# вставленных после самой свежей его строки, id 2583989), и выйти из него +# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое +# множество и обязано быть константой — вопрос был не «почему не растёт», а +# «правда ли они непригодны». +# +# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId +# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из +# него собирается — это инвариант #2235 (`_canonical_source_url` в +# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164 +# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси, +# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая +# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php. +# +# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни +# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235 +# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ +# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной +# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес, +# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки. +# +# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой +# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией: +# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят +# на сайты застройщиков вместо Яндекса. +OFFER_ID_PATTERN = "^[0-9]+$" +CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'" + @dataclass class YandexDetailBackfillResult: @@ -80,6 +120,11 @@ class YandexDetailBackfillResult: attempted: int = 0 enriched: int = 0 failed: int = 0 + # Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из + # source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к + # прогону; замер на месте = очередь снова читается не тем признаком. + url_from_offer_id: int = 0 + # Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id. unenrichable_pending: int = 0 duration_sec: float = field(default=0.0) @@ -88,6 +133,7 @@ class YandexDetailBackfillResult: "attempted": self.attempted, "enriched": self.enriched, "failed": self.failed, + "url_from_offer_id": self.url_from_offer_id, "unenrichable_pending": self.unenrichable_pending, "duration_sec": int(self.duration_sec), } @@ -132,51 +178,86 @@ async def run_yandex_detail_backfill( # SNAPSHOT: single SELECT at start -- NOT re-selected in loop. # Priority: is_active DESC (active first), scraped_at DESC (newest first). - # Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает - # (см. комментарий у константы): в очередь не берём то, что заведомо - # непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером. + # В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо + # сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес + # собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире + # этого условия — гарантированный parse→None пачкой и обрыв по брейкеру. snapshot = ( db.execute( text( - """ - SELECT id, source_url + f""" + SELECT id, + CASE + WHEN source_url ~ CAST(:offer_url_pattern AS text) + THEN source_url + ELSE {CANONICAL_URL_SQL} + END AS source_url FROM listings WHERE source = 'yandex' AND detail_enriched_at IS NULL - AND source_url IS NOT NULL - AND source_url ~ CAST(:offer_url_pattern AS text) + AND ( + ( + source_url IS NOT NULL + AND source_url ~ CAST(:offer_url_pattern AS text) + ) + OR source_id ~ CAST(:offer_id_pattern AS text) + ) ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST LIMIT CAST(:batch_size AS int) """ + # f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля, + # не пользовательский ввод. Всё изменяемое — bind-параметры. ), - {"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN}, + { + "batch_size": batch_size, + "offer_url_pattern": OFFER_URL_PATTERN, + "offer_id_pattern": OFFER_ID_PATTERN, + }, ) .mappings() .all() ) # Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено - # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). - counters.unenrichable_pending = int( - db.execute( - text( - """ - SELECT count(*) - FROM listings - WHERE source = 'yandex' - AND detail_enriched_at IS NULL - AND source_url IS NOT NULL - AND source_url !~ CAST(:offer_url_pattern AS text) - """ - ), - {"offer_url_pattern": OFFER_URL_PATTERN}, - ).scalar_one() - ) - if counters.unenrichable_pending: + # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно + # разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как + # «тут делать нечего» и держало 3535 квартир вне обогащения неделю. + pending = db.execute( + text( + """ + SELECT + count(*) FILTER ( + WHERE source_id ~ CAST(:offer_id_pattern AS text) + ) AS url_from_offer_id, + count(*) FILTER ( + WHERE source_id IS NULL + OR source_id !~ CAST(:offer_id_pattern AS text) + ) AS unenrichable_pending + FROM listings + WHERE source = 'yandex' + AND detail_enriched_at IS NULL + AND ( + source_url IS NULL + OR source_url !~ CAST(:offer_url_pattern AS text) + ) + """ + ), + {"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN}, + ).one() + counters.url_from_offer_id = int(pending.url_from_offer_id) + counters.unenrichable_pending = int(pending.unenrichable_pending) + if counters.url_from_offer_id: logger.info( - "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: " - "source_url ведёт не на карточку Яндекса (%s), парсер их отвергает " - "до сети", + "yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый " + "source_url не ведёт на карточку Яндекса; адресуем их по offerId из " + "source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)", + run_id, + counters.url_from_offer_id, + ) + if counters.unenrichable_pending: + logger.warning( + "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: нет ни " + "offer-URL (%s), ни числового source_id — адресовать их нечем", run_id, counters.unenrichable_pending, OFFER_URL_PATTERN, @@ -203,8 +284,15 @@ async def run_yandex_detail_backfill( max_consecutive_blocks, ) - # Build proxies dict once — mirrors yandex_address_backfill.py - _proxy = settings.scraper_proxy_url + # Build proxies dict once — mirrors yandex_address_backfill.py. + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если + # пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/ + # нездоровы для yandex -- resolve_proxy_url бросает ProxyPoolExhaustedError + # (fail-closed, #2616): НАРОЧНО не ловим здесь -- штатный except Exception ниже + # (mark_failed + logger.exception + raise) уже даёт явную деградацию run'а с + # понятным логом, отдельный catch не нужен. + _proxy = resolve_proxy_url(db, "yandex") _proxies = {"http": _proxy, "https": _proxy} if _proxy else None consecutive_none = 0 diff --git a/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql new file mode 100644 index 00000000..e9b0cb67 --- /dev/null +++ b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql @@ -0,0 +1,90 @@ +-- 240_trade_in_estimates_retain_until.sql +-- Платёжный контур МЕРЫ, ретеншен (PR #2754): «оплаченное живёт год, purge +-- его не трогает». Владелец продаёт отчёт физлицу за 150 ₽ — отчёт должен +-- жить год на нашей стороне, а не 24ч (см. WHY ниже). +-- Номер сверен по `forgejo/main` и всем открытым PR-веткам ДВАЖДЫ: сначала +-- как 234 (последняя занятая на момент ветвления была 233_payments.sql), но +-- main уехал вперёд и 234 занял `234_scrape_runs_ban_kind_unknown.sql` +-- (коммит 0de22f4b) — переименовано в 240 (main max на момент повторной +-- сверки — 239, с дырами 235-237; max+1 безопаснее дыр). Урок пятый за +-- сутки: сверять номер нужно не только перед первым коммитом, а прямо перед +-- пушем/мержем — main не стоит на месте. +-- +-- ── WHY ────────────────────────────────────────────────────────────────────── +-- purge_expired_trade_in_data (migration 231, seeded enabled=false) удаляет +-- строки `WHERE expires_at < NOW() AND created_by IS NULL` — это ровно +-- популяция будущих платящих физлиц (анонимные B2C-оценки). Владелец продаёт +-- отчёт физлицу за 150 ₽: скачанный файл у клиента бессрочно, но ссылка/строка +-- на нашей стороне обязана жить дольше 24-часового TTL расчёта — иначе первый +-- же прогон purge-джобы после запуска продаж физически и безвозвратно удалит +-- уже оплаченное (PDF нигде не хранится, рендерится на лету). +-- +-- `expires_at` НЕ трогаем ни на йоту: это единая глобальная настройка +-- (`trade_in_estimate_retention_hours`), она же — печатаемая в PDF/UI дата +-- «ДЕЙСТВИТЕЛЕН ДО» (актуальность РАСЧЁТА, а не срок жизни строки), и от неё +-- зависит вычисление даты расчёта во фронте (`mappers.ts` fmtDateShift(-24)). +-- Поднять её до года означало бы: (а) дать год хранения ВСЕМ строкам, включая +-- неоплаченные адреса физлиц — прямое нарушение минимизации по 152-ФЗ; +-- (б) напечатать в PDF клиента, что расчёт актуален год. +-- +-- ── WHAT ───────────────────────────────────────────────────────────────────── +-- Новая, независимая колонка retain_until — срок жизни ДОСТУПА/СТРОКИ: +-- NULL = неоплаченная строка, поведение (чтение/PDF/purge) бит-в-бит текущее. +-- Бэкфилла нет — все 1058 существующих строк остаются NULL, ничего не меняется +-- для уже созданных оценок (весь B2B pilot-трафик в их числе). +-- При оплате (платёжный код — отдельный PR, здесь его нет) сервисный слой +-- проставит retain_until = now() + trade_in_paid_retention_days (config.py). +-- +-- Частичный индекс покрывает predicate purge-джобы (migration 231, +-- `_DELETE_EXPIRED_ESTIMATES_SQL`) уже С УЧЁТОМ нового терма retain_until — +-- заведён вместе с колонкой, а не отдельной миграцией, чтобы purge не начал +-- жить без него хотя бы один деплой. +-- +-- ── IDEMPOTENCY ────────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- Ничего не удаляет, не бэкфиллит, DDL-only (доли секунды на 1058 строках). +-- +-- Dependencies: 001_trade_in_estimates.sql, 233_payments.sql (индекс исключает +-- строки со строкой в payments опосредованно через predicate purge-джобы, +-- сама таблица payments здесь не читается). +-- Apply after: 233_payments.sql. +-- +-- ── lock_timeout — выставлен, хотя гейт (scripts/check-migration-lock-timeout.py) +-- этот файл не проверяет ──────────────────────────────────────────────────── +-- Порог гейта для tradein (NN >= 250) — артефакт: назначен по номеру аварийной +-- миграции 250, которую затем сняли с деплоя (#2792, 29f10002). Фактический +-- максимум применённого на main — 239, то есть НИ ОДНА миграция в диапазоне +-- 240-249 (этот файл включительно) гейтом не проверяется вообще — "проверено +-- новых миграций: 0" в логе означает "не проверено ни одного файла", а не +-- "все чисты". Сама функция scan() внутри гейта, если прогнать её без +-- порогового отсечения, помечает ALTER TABLE ниже как блокирующий DDL без +-- lock_timeout. `trade_in_estimates` — самая горячая таблица стека (история, +-- история сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались +-- открытые транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка +-- встаёт в очередь ПЕРЕД новыми запросами — за ней начинают ждать обычные +-- SELECT приложения (см. `sql.md` § lock_timeout). На 1058 строках сам DDL +-- мгновенный — риск не в исполнении, а в ожидании чужой блокировки. Красный +-- деплой по таймауту — осознанно принятый в проекте размен (честный отказ +-- лучше тихой очереди перед приложением). НЕ убирать как "гейт же не просит". + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS retain_until timestamptz; + +COMMENT ON COLUMN trade_in_estimates.retain_until IS + 'До какого момента строку НЕЛЬЗЯ удалять и ссылка обязана открываться ' + '(оплаченный доступ). Семантика expires_at не меняется: это дата ' + 'актуальности РАСЧЁТА (24ч), она печатается в PDF. NULL = неоплачено, ' + 'поведение бит-в-бит текущее. Задаётся сервисным кодом платёжного контура ' + '(отдельный PR) на now() + trade_in_paid_retention_days (config.py).'; + +-- Частичный индекс под predicate purge-джобы (app/tasks/purge_expired_trade_in_data.py): +-- WHERE created_by IS NULL AND retain_until IS NULL AND expires_at < NOW(). +CREATE INDEX IF NOT EXISTS trade_in_estimates_purge_idx + ON trade_in_estimates (expires_at) + WHERE created_by IS NULL AND retain_until IS NULL; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql new file mode 100644 index 00000000..d725963d --- /dev/null +++ b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql @@ -0,0 +1,140 @@ +-- 250_drop_duplicate_expires_at_index.sql +-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at). +-- Возврат после #2792 (снятие с деплоя) — теперь с lock_timeout, см. #2793/#2791. +-- +-- WHY: +-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09) +-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль +-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql. +-- +-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени): +-- name indkey indclass indoption indcollation pred am +-- trade_in_estimates_expires_idx 22 3127 0 0 — btree +-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree +-- Совпадает всё: колонка, класс операторов, направление сортировки, +-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация, +-- отсутствие частичного предиката, метод доступа. Ни один не привязан к +-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них +-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует +-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты +-- FDW-пользователю). Гранты живут на таблице, не на индексе. +-- +-- ── Почему у «нулевого» дубля появились сканы ──────────────────────────────── +-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у +-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый +-- +0, новый +4). Замер 2026-08-09 16:54 UTC подтверждает картину ещё через +-- двое суток: новый 21, старый ВСЁ ЕЩЁ 234. То есть планировщик перевёл на +-- новый весь живой трафик, и это устойчивое состояние, а не переходное. +-- +-- Причина не семантическая, а физическая: индексы идентичны, но новый +-- собран позже с нуля и плотнее упакован — relpages 5 против 6 у старого, +-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск +-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost, +-- и при прочих равных выигрывает. Никакого нового запроса не появилось: +-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого, +-- 0.95 у нового) — это один и тот же класс точечных lookup'ов, просто +-- переехавший на более свежий индекс. Со временем новый забронзовеет так же +-- и они поменялись бы местами обратно. +-- +-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ──────────────────────────── +-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу +-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE +-- делал бы full scan». На проде это НЕ так. Фактический план боевого +-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод +-- 2026-08-07, перепроверено 2026-08-09 — план тот же): +-- Limit → Sort (Sort Key: expires_at) +-- → Bitmap Heap Scan Filter: (expires_at < now()) +-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at +-- Index Cond: (created_by IS NULL) +-- Задача purge ограничена `AND created_by IS NULL` (134 строки из 1061), и +-- планировщик берёт именно этот, более селективный индекс, а expires_at +-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не +-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён +-- под конкретный запрос» не существует — запрос его не использует. +-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму +-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с +-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД. +-- +-- ── Планы ДО и ПОСЛЕ ───────────────────────────────────────────────────────── +-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе: +-- меняется только имя индекса в строке плана и cost на одну страницу спуска. +-- ДО (прод, 2026-08-09 16:54 UTC): +-- Limit (cost=0.28..58.98 rows=100 width=24) +-- → Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..623.07) +-- Index Cond: (expires_at < now()) +-- ПОСЛЕ ожидается тот же узел с именем trade_in_estimates_expires_idx и +-- cost, отличающимся на спуск по одной лишней странице. Проверено на чистом +-- PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым +-- перекосом плотности: +-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84) +-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30) +-- Форма плана, Index Cond и Filter идентичны; отличается только имя. +-- +-- ── Стоимость блокировки и почему здесь SET LOCAL lock_timeout ────────────── +-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. УДЕРЖАНИЕ здесь +-- дёшево: trade_in_estimates — 1061 строка, heap 1856 kB, сносимый индекс +-- 40 kB; DROP INDEX ничего не переписывает (удаление строк каталога плюс +-- unlink файла, единицы миллисекунд). +-- +-- Дорого — ОЖИДАНИЕ выдачи лока, и это уже случилось. 2026-08-07 первая +-- редакция этого файла (без строки ниже) ждала ACCESS EXCLUSIVE 29 минут за +-- чужой аналитической psql-сессией (`CREATE TEMP TABLE tmp_res AS ...`, +-- pid 83256), вторая попытка — ещё 16. Четыре прогона деплоя красные, +-- четыре смерженных PR не доехали до прода; ждущий ACCESS EXCLUSIVE встаёт +-- в очередь ПЕРЕД новыми запросами, поэтому за ним начали ждать и обычные +-- SELECT приложения. Файл сняли с деплоя (#2792), конвенцию закрепили +-- (#2791: гейт scripts/check-migration-lock-timeout.py + .claude/rules/sql.md). +-- +-- Значение 5 s: снизу ограничено deadlock_timeout (на проде 1 s — сверено +-- 2026-08-09) — автоотмена мешающего autovacuum срабатывает только после +-- того, как ждущий отстоял эту секунду, поэтому 1-2 s гонялись бы с рутинным +-- autovacuum. Сверху — потолок простоя очереди приложения; против +-- наблюдённых 1740 s это в 348 раз меньше. На работу ПОД локом значение не +-- влияет вообще. +-- +-- Срабатывание таймаута = красный деплой через 5 секунд с `canceling +-- statement due to lock timeout` вместо получасовой очереди. Это ожидаемое +-- поведение, а не авария: миграция не помечается применённой, повторить +-- позже. CONCURRENTLY здесь не нужен и был бы хуже: он не может выполняться +-- внутри блока транзакции, а значит файл пришлось бы оставить без +-- BEGIN/COMMIT (см. разбор механики раннера в +-- 225_listing_source_snapshots_run_id_idx.sql). +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE. +-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет. +-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы +-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует). +-- +-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс), +-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль). +-- Deploy order: standalone. Ничего не ждёт и никого не блокирует. +-- +-- Критерий «таблица тиха» (записан ДО, выполнен 2026-08-09 16:54 UTC): +-- SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation +-- WHERE c.relname='trade_in_estimates' AND l.pid <> pg_backend_pid(); → 0 +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. EXPLAIN того же запроса показывает Index Scan using +-- trade_in_estimates_expires_idx — детерминированная проверка, доступна +-- сразу. +-- 3. pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx уходит с +-- 234. NB: наблюдаемый темп ~7 сканов/сутки (21 скан за трое суток у +-- дубля), поэтому «в течение часа» — недостаточное окно; честный срок +-- подтверждения ~сутки. Если через сутки счётчик всё ещё 234, значит +-- трафик ушёл в Seq Scan — это опровергло бы разбор выше и требовало бы +-- отката (вернуть индекс: CREATE INDEX CONCURRENTLY). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; + +COMMENT ON INDEX trade_in_estimates_expires_idx IS + 'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752/#2793). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/251_listings_drop_ceiling_height.sql b/tradein-mvp/backend/data/sql/251_listings_drop_ceiling_height.sql new file mode 100644 index 00000000..df6fcdb1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/251_listings_drop_ceiling_height.sql @@ -0,0 +1,109 @@ +-- 251_listings_drop_ceiling_height.sql +-- Issue #2699 (хвост) — снос DEPRECATED-колонки listings.ceiling_height. +-- +-- Dependencies: 019_listings_alter_cian.sql (завела колонку, numeric(3,2)), +-- 238_listings_ceiling_height_unify.sql (перенесла значения в +-- канон ceiling_height_m и пометила эту колонку DEPRECATED). +-- Apply after: 240_trade_in_estimates_retain_until.sql +-- Deploy order: код УЖЕ впереди схемы — писатели сняты PR #2779 (07.08) и с тех +-- пор на проде. Это тот случай, когда «код первый» правилен: DROP COLUMN +-- безопасен только после того, как ни один живой writer/reader колонки не +-- остался. Обратный порядок (снести колонку, потом деплоить код) уронил бы +-- скрейпинг. +-- +-- ── ПОЧЕМУ ЭТО ОТДЕЛЬНЫЙ ФАЙЛ, А НЕ ЧАСТЬ 238 ─────────────────────────────── +-- 238 намеренно оставила колонку: «сначала прод должен подтвердить, что колонку +-- никто не пишет и не читает. Снос — отдельным шагом». Подтверждение получено, +-- ниже — числа. +-- +-- ── КРИТЕРИЙ «ПИСАТЕЛЕЙ НЕТ» (записан ДО, а не после) ─────────────────────── +-- count(ceiling_height) обязан остаться 8552 (8554 из #2699 минус 2 мусорных +-- значения cian, обнулённых шагом 1 миграции 238). +-- 2026-08-07 (сразу после 238): 8552 +-- 2026-08-09 16:54 UTC: 8552 +-- 2026-08-09 16:57 UTC: 8552 +-- Двое суток без единой записи. Контроль того, что замер не «мёртвый» (БД жива, +-- скрейпинг идёт, просто пишет в канон): за те же 2.5 минуты между двумя +-- замерами count(ceiling_height_m) вырос 16133 → 16147, а last_seen_at строк с +-- непустым ceiling_height обновлялся в ту же минуту, что и замер. То есть UPDATE +-- по этим строкам идут прямо сейчас и НЕ трогают сносимую колонку — это сильнее, +-- чем «два дня тишины». +-- +-- ── ПОТРЕБИТЕЛИ: сверка на origin/main перед сносом ───────────────────────── +-- `git grep -n 'ceiling_height\b' origin/main -- '*.py' '*.ts' '*.tsx' '*.sql'` +-- минус вхождения ceiling_height_m: ни одного обращения к КОЛОНКЕ не осталось. +-- Что попало в выдачу и почему это не потребители: +-- - имена полей Python-датаклассов enrichment'ов (CianEnrichment.ceiling_height, +-- YandexEnrichment.ceiling_height, YandexValuation...) — атрибуты объектов, +-- не колонки; +-- - `CAST(:ceiling_height AS numeric)` в yandex/detail.py:600 — ИМЯ БИНД- +-- ПАРАМЕТРА, а присваивается он колонке ceiling_height_m (соседняя строка); +-- то же в cian/detail.py (`:ch`) и base.py (`:ceiling_height_m`); +-- - комментарии/докстринги с историей #2699 и тесты, которые как раз +-- УТВЕРЖДАЮТ отсутствие колонки в SQL писателей +-- (tests/test_ceiling_height_unify_2699.py, test_scraper_admin_apis.py); +-- - 019/238 — сами миграции, их переписывать нельзя и не нужно. +-- Фронтовых (.ts/.tsx) вхождений нет вообще. +-- +-- ── ЗАВИСИМОСТИ В СХЕМЕ: проверено на проде 2026-08-09, все нули ──────────── +-- pg_depend по атрибуту listings.ceiling_height ................ 0 объектов +-- вьюхи/матвьюхи с 'ceiling' в определении ..................... 0 +-- индексы listings с 'ceiling' в indexdef ...................... 0 +-- CHECK/constraint с 'ceiling' ................................. 0 +-- функции и процедуры с 'ceiling_height' в теле ................ 0 +-- тела обоих триггеров listings (price_change, set_geom) ....... не упоминают +-- pg_publication_rel по listings (column list ломает DROP) ..... 0 (публикаций в БД нет) +-- foreign tables НА listings в gendesign-БД (FDW-читатель) ..... 0 +-- То есть CASCADE не нужен — и не должен появиться: в этом продукте +-- `DROP ... CASCADE` уже терял гранты FDW-пользователю (инцидент C3). +-- +-- ── ПОТЕРИ ДАННЫХ НЕТ (проверено, а не предположено) ──────────────────────── +-- строк, где ceiling_height IS NOT NULL AND ceiling_height_m IS NULL ..... 0 +-- строк, где заполнены обе ............................................ 8552 +-- из них расходятся значения .............................................. 0 +-- Всё содержимое сносимой колонки присутствует в каноне до последнего знака. +-- ceiling_height_m на момент написания: 16 147 непустых (после 238 было 15 591 — +-- канон растёт, то есть живой). +-- +-- ── СТОИМОСТЬ БЛОКИРОВКИ И ПОЧЕМУ SET LOCAL lock_timeout ─────────────────── +-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на listings. УДЕРЖАНИЕ +-- дёшево и не зависит от размера таблицы: PostgreSQL не переписывает heap, а +-- помечает атрибут attisdropped в каталоге (в listings уже 4 таких «пенька» от +-- прошлых сносов при 92 живых колонках) — единицы миллисекунд. +-- +-- Дорого ОЖИДАНИЕ выдачи лока, и цена здесь выше, чем у 250: listings — 19 GB, +-- 97 540 строк, по ней постоянно идёт скрейпинг (в т.ч. длинные проходы вроде +-- avito_full_load). Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми +-- запросами, поэтому за ним начнут ждать обычные SELECT/UPDATE приложения — +-- ровно то, что 2026-08-07 положило деплой на 29 минут (#2791, #2792). +-- Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено deadlock_timeout = +-- 1 s на проде, сверху — потолок простоя очереди приложения; на работу ПОД +-- локом не влияет). Срабатывание = честный красный деплой через 5 секунд, +-- миграция не помечается применённой, повторить в окно потише. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP COLUMN IF EXISTS — безопасный re-run. +-- - Без CASCADE: зависимых объектов нет (см. выше), а CASCADE молча снёс бы +-- то, что появится позже. +-- - Одна DDL-операция внутри BEGIN/COMMIT. +-- - Откат: колонку вернуть можно (ALTER TABLE ... ADD COLUMN), но данные в неё +-- не восстановятся — они и не нужны, дубликат канона (0 расхождений). +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. information_schema.columns по listings: ceiling_height отсутствует, +-- ceiling_height_m на месте и count(ceiling_height_m) >= 16 147. +-- 3. Скрейпинг продолжает писать: count(ceiling_height_m) растёт после сноса. + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE listings DROP COLUMN IF EXISTS ceiling_height; + +COMMENT ON COLUMN listings.ceiling_height_m IS + 'Высота потолков, метры. ЕДИНСТВЕННАЯ колонка этого признака (#2699): дубль listings.ceiling_height (019) снесён миграцией 251 после того, как 238 перенесла в неё значения. Пишут все источники через scraper_kit.ceiling_height.plausible_ceiling_m (гейт правдоподобия 2.0-6.0 м). Читает estimator (comp-scoring #2012).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql new file mode 100644 index 00000000..b1f63939 --- /dev/null +++ b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql @@ -0,0 +1,52 @@ +-- 253_scrape_proxy_domclick_affinity_release.sql +-- Снять с узла резервацию provider_affinity='domclick' (#2800). +-- +-- WHY (замер, не рассуждение — живая проба 09.08.2026, тракт сайдкар+camoufox, +-- POST /fetch на robots.txt рабочего хоста каждой площадки): +-- +-- узел | affinity | avito | ekb.cian.ru | realty.yandex.ru | bff-search-web.domclick.ru +-- -----+----------+-------+--------------------+------------------+--------------------------- +-- 1 | domclick | 200 | 200 «Ошибка — Циан»| 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY +-- 9 | any | 200 | 200 | 200 | 200 +-- 10 | any | 200 | 200 | 200 | 200 +-- 11 | any | 200 | 200 | 200 | 200 +-- +-- Узел, закреплённый 173-й миграцией СПЕЦИАЛЬНО за Домкликом, до рабочего хоста +-- Домклика не доходит вообще (NS_ERROR_PROXY_BAD_GATEWAY на bff-search-web — +-- именно туда ходит боевой сбор, см. providers/domclick/serp.py::_BFF_BASE), при +-- этом Авито и Яндекс через него отвечают штатно. Резервация даёт ровно обратный +-- эффект задуманному: единственный источник, которому узел ГОДЕН НЕ БЫЛ, держал его +-- за собой, а два источника, которым он годен, его не видели — +-- acquire('avito'|'yandex') отбирает по provider_affinity IN (source,'any'), а +-- fallback этот узел не берёт (защита последнего узла выделенной affinity). +-- +-- 'any', а НЕ enabled=false: узел жив для двух площадок из четырёх, выключать его +-- целиком — терять четверть и без того дефицитного пула (#2638). +-- +-- WHAT: +-- provider_affinity='domclick' → 'any' для узлов, у которых affinity именно такая. +-- CHECK-констрейнт (173) не трогаем: значение 'domclick' остаётся допустимым, если +-- в пуле появится узел, который до Домклика реально доходит. +-- +-- ЧТО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ (граница честная): +-- Она НЕ чинит Домклик. acquire('domclick') и до неё видел все четыре узла +-- (affinity IN ('domclick','any')), т.е. шанс вытянуть узел 1 и потратить первый +-- бакет впустую был и остаётся 1/4 — закрывает это проба по паре «узел × источник» +-- (#2800 часть B), а не смена affinity. Здесь снимается только резервация. +-- +-- IDEMPOTENCY / SAFETY: +-- Один UPDATE в транзакции; повторный прогон не находит строк (no-op) — auto-apply +-- strict на деплое это требует. Блокирующего DDL нет (см. +-- scripts/check-migration-lock-timeout.py: правило про ALTER/DROP/CREATE INDEX), +-- UPDATE берёт row-lock на единичные строки. +-- +-- Dependencies: 157_scrape_proxies.sql, 173_scrape_proxies_add_domclick_affinity.sql + +BEGIN; + +UPDATE scrape_proxies +SET provider_affinity = 'any', + updated_at = now() +WHERE provider_affinity = 'domclick'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql new file mode 100644 index 00000000..61120223 --- /dev/null +++ b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql @@ -0,0 +1,155 @@ +-- 254_listings_backfill_avito_rating_glued_address.sql +-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814). +-- +-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же

+-- в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4 +-- отзыва». Парсер починен в #2815 (merged, прод-verified 2026-08-10 09:57 UTC), но +-- УЖЕ ЗАПИСАННЫЕ строки сами не вылечатся: апсерт пишет +-- `address = COALESCE(listings.address, EXCLUDED.address)` (base.py:614) — при +-- конфликте адрес осознанно НЕ перезаписывается (#2777: свежий сырой адрес от +-- площадки откатил бы чистку миграций 062/108/124). Эта миграция — единственный +-- путь, которым старые строки могут стать чистыми. +-- +-- ЗАМЕР НА ПРОДЕ 2026-08-10, после деплоя #2815 (не «по релиз-метке», а по данным): +-- +-- класс адреса (source='avito', is_active) строк с координатами +-- ------------------------------------------ ------ -------------- +-- чистый 7892 6111 (77.4%) +-- загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%) +-- NULL 360 0 (0.0%) +-- +-- 1123 не изменились после деплоя парсера — ни одной из этих строк свип не касался +-- с 09:57 (max(last_seen_at) = 2026-08-09 16:53), и не коснётся с толком: COALESCE. +-- Все 1123 — source='avito', все is_active. Других источников с таким хвостом нет. +-- Цена простоя: строка без geom молча выпадает из радиусного отбора аналогов +-- (Tier W, ST_DWithin — NULL не проходит предикат и нигде не считается). +-- +-- ПРАВИЛО РЕЗКИ — ДОСЛОВНО ПАРСЕРНОЕ, не изобретённое здесь. +-- providers/avito/serp.py: _NOT_ADDRESS_TAIL_RE = re.compile( +-- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I) +-- _clean_address: split(maxsplit=1)[0] → _deglue_house_marker → strip(" ,.\n\t") +-- → `return cleaned or None`. +-- Ниже — тот же альтернатив-набор, флаг 'i' = flags=re.I, `.*$` + regexp_replace = +-- взять текст ДО первого совпадения (обе реализации leftmost), тот же набор символов +-- в trim, NULLIF(...,'') = `or None`. +-- Ключевая тонкость (#1773): резать по «·» можно ТОЛЬКО когда за ней идёт ЦИФРА. +-- За буквой идёт район — «улица Бебеля, 138 · р-н Железнодорожный», и этот хвост +-- сохраняется намеренно. На проде таких строк 296, и они обязаны остаться целыми +-- (проверено в dry-run: 296 до = 296 после). +-- _deglue_house_marker в SQL НЕ повторяется — замерено, что он здесь no-op: после +-- резки хвоста ни одна из 1123 строк не содержит слипшегося «29р-н» (0 совпадений +-- паттерном _DEGLUE_RE). Повторять в SQL лукахеды ради нуля строк незачем. +-- +-- ПАРИТЕТ ПРОВЕРЕН ТЕМ ЖЕ КОДОМ, А НЕ ПО ГЛАЗАМ. Все 1123 сырых адреса выгружены с +-- прода и прогнаны через ЖИВОЙ парсер в боевом контейнере: +-- docker exec tradein-scraper python /tmp/m2814-parity.py +-- → rows=1123 mismatches=0 +-- т.е. SQL-выражение ниже даёт побайтово то же, что `_clean_address` в проде. +-- +-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-10): +-- UPDATE 1123 · осталось загрязнённых 0 · районных «·» сохранено 296/296 +-- ул. Ткачей,17·5,0 · 4 отзыва → ул. Ткачей,17 +-- ул. Свердлова,32Б·4,2 · 5 отзывов → ул. Свердлова,32Б +-- ул. Щорса,103·4,3 · 15 отзывов → ул. Щорса,103 +-- Уральская ул.,5·4,8 · 15 отзывов → Уральская ул.,5 +-- Селькоровская ул.,60·5,0 · 3 отзыва → Селькоровская ул.,60 +-- ул. Азина,22/2·4,6 · 17 отзывов → ул. Азина,22/2 +-- ул. 8 Марта,204Г/2·4,3 · 3 отзыва → ул. 8 Марта,204Г/2 +-- жилой район Сортировочный, мкр-н Старая Сортировка, Кунарская ул.,14к2·4,3 · 6 отзывов +-- → жилой район Сортировочный, мкр-н Старая +-- Сортировка, Кунарская ул.,14к2 +-- мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов +-- → мкр-н Широкая Речка, ул. Анатолия Муранова,18 +-- ·3,1 · 11 отзывов → NULL (id 10377315, ровно 1 строка: адрес +-- состоял ИЗ рейтинга целиком. Парсер на такой строке возвращает None — здесь то +-- же самое через NULLIF. Оставлять «·3,1 · 11 отзывов» в колонке хуже пустоты: +-- NULL апсерт теперь ДОзаполняет (#2777), мусор — нет.) +-- +-- ОБРАТИМОСТЬ — без новой таблицы и без новой колонки: прежнее значение УЖЕ хранится. +-- `listings.raw_payload->>'address'` пишется скрейпером на INSERT и НЕ входит в +-- `ON CONFLICT DO UPDATE SET` (проверено по base.py: raw_payload отсутствует в SET) — +-- т.е. переживает любой свип. Замерено на проде: у 1123 из 1123 строк +-- raw_payload->>'address' = address ПОБАЙТОВО, NULL-ов нет ни одного. +-- Откат (idempotent, безопасен к повторному запуску): +-- +-- UPDATE listings +-- SET address = raw_payload->>'address' +-- WHERE source = 'avito' +-- AND raw_payload->>'address' ~ '·\s*\d' +-- AND address IS NOT DISTINCT FROM NULLIF(trim(both E' ,.\n\t' FROM +-- regexp_replace(raw_payload->>'address', +-- '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')), ''); +-- +-- Предикат самоидентифицирующий, список id хранить не нужно, и это ПРОВЕРЕНО, а не +-- предположено. В dry-run (BEGIN…ROLLBACK) после UPDATE он дал по всей таблице ровно +-- 1123 совпадения, все 1123 — наши; restored = before побайтово у 1123 из 1123. +-- Ложных срабатываний нет и на строках-соседях: есть 10 строк, где raw_payload грязный, +-- а address уже чистый (их адрес позже перезаписал avito_detail полным «Свердловская +-- обл., Первоуральск, …») — второе условие их не берёт (замерено: 0), и это ПРАВИЛЬНО: +-- возвращать рейтинг поверх нормализованного адреса не надо. Со временем предикат сам +-- перестаёт брать строки, у которых address улучшил detail-путь, — откат не деградирует +-- в порчу. +-- `geocode_tried_at` откатывать нечего: это метка backoff'а, не данные. +-- +-- ПОЧЕМУ geocode_tried_at = NULL. Очередь geocode_missing_listings отбирает по +-- `geocode_tried_at IS NULL OR < NOW() - 7 days`, и метка привязана к ТЕКСТУ +-- (address, city). У 711 из 1123 строк она стоит (у 370 — свежее 7 суток) — но стоит +-- она на СТАРОМ, заведомо негеокодируемом тексте. После смены текста она смысла не +-- имеет и лишь держала бы вычищенный адрес вне очереди до 7 суток. Сброс — это не +-- «попробовать ещё раз то же самое», а «текст другой». Побочный расход честно измерен: +-- 19 пар из 854 имеют соседа, которому геокодер отказал за последние 7 суток, т.е. до +-- 19 лишних запросов к Nominatim — цена ниже, чем неделя ожидания у 370 строк. +-- +-- ЧТО БУДЕТ ДАЛЬШЕ (и чего НЕ будет). Чистый адрес координат сам не даёт. После миграции +-- 1122 строки (854 уникальные пары address+city; 1123-я — та самая NULL) попадают в +-- выборку geocode_missing_listings: `lat IS NULL AND is_active AND address IS NOT NULL +-- AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR < 7 days)`. Очередь +-- станет 1938 строк / 1370 пар против 1569 / 1241 сейчас (+369 строк: 753 из 1123 уже +-- стояли в ней СО СВОИМ ГРЯЗНЫМ адресом и жгли бюджет Nominatim впустую — этот расход +-- миграция тоже снимает). Расписание: enabled, окно 0-23 UTC, batch_size=200, +-- budget_sec=1800, ближайший next_run_at = 2026-08-10 17:45 UTC. +-- Гарантированный низ (замер по живому geocode_cache тем же ключом, что строит +-- `_cache_key`): 138 из 854 пар уже лежат в кэше с координатами и не истекли → 245 +-- строк получат geom мгновенно, без единого внешнего запроса. Остальное — как повезёт +-- тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов давали 17-53% +-- успеха на адрес, гадать точнее не буду. +-- +-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО: +-- * не трогает COALESCE в апсерте — поведение осознанное (#2777); +-- * не трогает 360 строк с address IS NULL — их #2777 ДОзаполняет сам на ближайшем +-- свипе (замерено: пустых строк '' среди них 0, все именно NULL); +-- * не переносит координаты с соседних строк того же адреса. Такая возможность есть +-- (789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city), +-- но у 88 из 548 донорских пар соседи расходятся между собой больше чем на 50 м, у +-- 32 — больше 250 м, худший разброс 15 км. Выбирать победителя между ними — это +-- новая политика, а не бэкфилл; отдельным решением, не тихо здесь. +-- +-- Dependencies: 002_core_tables.sql (listings), 089_listings_geo_precision.sql +-- (geocode_tried_at). Триггер listings_set_geom_trg тут не участвует: он BEFORE +-- INSERT OR UPDATE OF lat, lon — эта миграция координат не пишет. +-- Идемпотентность: по построению. Второй прогон видит 0 строк с '·<цифра>' и не делает +-- ничего (WHERE самоисчерпывающийся). Новые вставки чисты с #2815. +-- lock_timeout: блокирующего DDL здесь нет, но UPDATE по «горячей» listings берёт +-- ROW EXCLUSIVE, и ждать его выдачи за чужой ACCESS EXCLUSIVE сессией — ровно та +-- очередь перед приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт +-- громко (ON_ERROR_STOP=on), чем встанет тихо. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +UPDATE listings +SET address = NULLIF( + trim(both E' ,.\n\t' FROM + regexp_replace( + address, + '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', + '', + 'i' + )), + ''), + geocode_tried_at = NULL +WHERE source = 'avito' + AND address ~ '·\s*\d'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql new file mode 100644 index 00000000..de19a1f4 --- /dev/null +++ b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql @@ -0,0 +1,77 @@ +-- 255_trade_in_estimates_revival_relaxations.sql +-- Номер сверен по `ls data/sql | sort` (max applied = 254) непосредственно +-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap +-- (108_*/084_* уже дублировались в прошлом). +-- +-- ── Incident 2026-08-10: «мёртвые» сохранённые оценки ─────────────────────── +-- Заказчик открыл сохранённую ссылку /trade-in/v2?id=... и увидел «НЕДОСТАТОЧНО +-- ДАННЫХ»: запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR +-- #2823/#2825) и лежит в БД с median_price=0/NULL, хотя тот же адрес и +-- параметры сейчас честно считаются (4 031 157 ₽ / 39 аналогов). 117 из 1071 +-- строк trade_in_estimates находятся в этом состоянии (29 за последние 30 +-- дней). GET /api/v1/trade-in/estimate/{id} (app/api/v1/trade_in.py:: +-- _try_revive_dead_estimate) теперь пересчитывает такую строку на месте и +-- пишет результат В ТУ ЖЕ строку (id/ссылка не меняются) — этому нужны две +-- новые колонки: +-- +-- revival_attempted_at — throttle: не пересчитывать чаще одного раза в N +-- минут (settings.trade_in_revival_throttle_minutes, default 10) на одну +-- строку. Заявка на пересчёт — атомарный conditional +-- `UPDATE ... WHERE revival_attempted_at IS NULL OR ... < NOW() - N min +-- RETURNING id` (тот же паттерн, что account_quota.increment, #747) — +-- защищает и от шторма повторных попыток на мёртвый адрес, и от гонки +-- двух параллельных GET (второй просто теряет заявку и отдаёт то, что +-- есть, без 500). +-- +-- ── relaxations / reliability (открытый хвост PR #2823, найден post-deploy +-- 2026-08-10 — см. fixes/Fix_Mera_Studio_Not_Estimated_Never_Block_Aug10) ── +-- Обе колонки УЖЕ вычисляются в estimator.estimate_quality() и уходят в POST- +-- ответ (AggregatedEstimate.relaxations/reliability), но раньше НЕ +-- персистились — на GET-rehydrate (открытие сохранённой ссылки) красный +-- баннер «точность снижена» пропадал, хотя цена по-прежнему построена на +-- расширенной/тонкой выборке. Теперь пишутся при каждом (re)compute (основной +-- INSERT в estimate_quality() + этот revival-путь) и читаются на GET. +-- +-- ── IDEMPOTENCY ─────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: все существующие +-- строки получают DEFAULT (reliability='ok', relaxations='[]', revival_ +-- attempted_at=NULL) — честно отражает то, что для них каскад послаблений +-- никогда не считался (записи ДО #oblast-F) и revival ещё не запускался. +-- +-- Dependencies: 001_trade_in_estimates.sql. Apply after: 254_*. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS relaxations jsonb NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS reliability text NOT NULL DEFAULT 'ok' + CHECK (reliability IN ('ok', 'low', 'very_low')); + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS revival_attempted_at timestamptz; + +COMMENT ON COLUMN trade_in_estimates.relaxations IS + 'RU-подписи применённых послаблений подбора (estimator.py #oblast-F cascade) ' + '— персистится, чтобы GET-rehydrate (?id=) мог восстановить дисклеймер ' + '«точность снижена». [] = базовой выборки хватило / запись создана до ' + '#oblast-F (без бэкфилла).'; + +COMMENT ON COLUMN trade_in_estimates.reliability IS + 'Надёжность итоговой выборки (ok|low|very_low), производная от n_analogs + ' + 'relaxations (estimator.py::estimate_quality) — персистится для GET-rehydrate ' + 'красного баннера. Default ok = запись создана до #oblast-F (без бэкфилла).'; + +COMMENT ON COLUMN trade_in_estimates.revival_attempted_at IS + 'Момент последней попытки пересчитать «мёртвую» (median_price<=0/NULL) ' + 'строку на GET /estimate/{id} (incident 2026-08-10, app/api/v1/trade_in.py::' + '_try_revive_dead_estimate). Throttle: не пересчитывать чаще одного раза в ' + 'settings.trade_in_revival_throttle_minutes на одну строку — атомарный ' + 'conditional UPDATE...RETURNING (см. модульный докстринг). NULL = либо ' + 'строка живая (median_price>0) и revival никогда не запускался, либо ' + 'запись создана до этой фичи.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql new file mode 100644 index 00000000..499f2f79 --- /dev/null +++ b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql @@ -0,0 +1,48 @@ +-- 256_trade_in_estimates_revival_completed_at.sql +-- Номер сверен по `ls data/sql | sort` (max applied = 255) непосредственно +-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap +-- (108_*/084_* уже дублировались в прошлом). +-- +-- ── fix/tradein-created (2026-08-11): created_at перезаписывался revival'ом ── +-- `_try_revive_dead_estimate` (app/api/v1/trade_in.py, migration 255) писал +-- пересчитанные поля в ИСХОДНУЮ строку trade_in_estimates, и вместе с ними — +-- `created_at`, скопированный из временной строки (estimate_quality() ставит +-- туда NOW() на момент пересчёта). Факт с прода: оценка +-- ff421062-cc38-4c4c-ad2e-0cfac52d14ff создана 2026-08-10 12:54:47, после +-- revival'а на GET её created_at стал 2026-08-11 04:30:03 — «дата обращения» +-- клиента (печатается в /history и в схеме AggregatedEstimate.created_at, +-- см. app/schemas/trade_in.py:317-318 «для метки «отчёт от DD.MM» в UI») +-- подменилась датой служебного пересчёта. Заодно ломался ORDER BY created_at +-- DESC в GET /history — оживлённая старая заявка выпрыгивала в начало списка. +-- +-- Фикс (app/api/v1/trade_in.py): created_at исключён из UPDATE SET revival'а, +-- исходная дата больше не трогается. Момент, когда revival РЕАЛЬНО пересчитал +-- строку (не просто "попытался" — revival_attempted_at из 255 ставится на +-- claim'е ДО вызова estimate_quality(), в том числе при throttle-проигрыше и +-- при неудачном пересчёте), нужен для аудита отдельно — новая колонка. +-- +-- ── IDEMPOTENCY ─────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: NULL = либо +-- строка живая и revival никогда успешно не пересчитывал, либо запись +-- создана до этой колонки. +-- +-- Dependencies: 255_trade_in_estimates_revival_relaxations.sql. Apply after: 255_*. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS revival_completed_at timestamptz; + +COMMENT ON COLUMN trade_in_estimates.revival_completed_at IS + 'Момент УСПЕШНОГО пересчёта «мёртвой» (median_price<=0/NULL) строки ' + 'revival''ом (app/api/v1/trade_in.py::_try_revive_dead_estimate) — ' + 'выставляется, когда пересчёт реально записал новые значения в строку. ' + 'Отличается от revival_attempted_at (255): тот ставится на claim''е ДО ' + 'вызова estimate_quality() и фиксирует ЛЮБУЮ попытку (включая throttled-' + 'проигрыш гонки и неудачный пересчёт), этот — только успех. created_at ' + 'строки при этом НЕ меняется (исходная дата обращения клиента, печатается ' + 'в /history) — см. fix/tradein-created 2026-08-11.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql new file mode 100644 index 00000000..05f29a7c --- /dev/null +++ b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql @@ -0,0 +1,150 @@ +-- 257_listings_backfill_yandex_source_url.sql +-- Разовое лечение source_url у yandex-строк, чей адрес ведёт на сайт застройщика (#2838). +-- +-- WHY. `source_url` пишется ТОЛЬКО при вставке: его нет ни в `ON CONFLICT DO UPDATE`, +-- ни в reconcile-UPDATE (`scraper_kit/base.py`). Поэтому починка продюсера (#2235, +-- `_canonical_source_url` в providers/yandex/serp.py) вылечила только НОВЫЕ строки, +-- а миграция 164 — только те легаси, чей URL ДЕЛИЛИ несколько строк (её CTE `shared` +-- искал дубли URL, а не непарсимость адреса). Строки с УНИКАЛЬНОЙ ссылкой на карточку +-- застройщика не попали ни туда, ни туда и носят адрес, замороженный в момент вставки. +-- Цена простоя: `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/` +-- и без него возвращает None ещё ДО обращения к HTML — такие строки не обогащаются +-- никогда, а `yandex_address_backfill` вдобавок ходит по ним на чужие сайты. +-- PR #2838 научил ОЧЕРЕДЬ адресовать их по source_id; колонку чинит эта миграция. +-- +-- ЗАМЕР НА ПРОДЕ 2026-08-12 (SELECT-only, не «по описанию из issue»): +-- +-- source='yandex' AND source_url !~ '/offer/[0-9]+' строк +-- ------------------------------------------------------- ----- +-- всего 3535 +-- из них source_id ~ '^[0-9]+$' (адрес восстановим) 3535 +-- из них source_id NULL/нечисловой (нечем адресовать) 0 +-- из них source_url IS NULL 0 +-- из них is_active 3522 +-- +-- хосты: macroserver.ru 912, macro.sbercrm.com 440, akademicheskiy.org 356, +-- na100.pro 331, strana.com 318, ten-stroy.ru 189, ecologica.ru 167, +-- xn--b1agbiqxpe4gxa.xn--p1ai 145, sinara-development.ru 114, +-- ekaterinburg.razum.life 111, www.lsr.ru 81, samolet.ru 68, хвост. +-- +-- Множество ЗАМКНУТО (важно: значит список ниже не устареет между PR и деплоем): +-- самая свежая его строка — id 2583989, после неё вставлено 6892 yandex-строк, +-- и НИ ОДНА в множество не попала — продюсер после #2235 таких адресов не пишет. +-- Множество может только уменьшаться (удаление строк), не расти. +-- +-- ФОРМА АДРЕСА — ДОСЛОВНО ПРОДЮСЕРНАЯ, не изобретённая здесь. +-- scraper_kit/providers/yandex/serp.py::_canonical_source_url: +-- return f"https://realty.yandex.ru/offer/{offer_id}/" # ветка «url не ведёт на realty.yandex» +-- тот же литерал живёт в app/tasks/yandex_detail_backfill.py::CANONICAL_URL_SQL +-- "'https://realty.yandex.ru/offer/' || source_id || '/'" +-- и та же формула стоит в миграции 164. Ниже — она же, посимвольно; +-- tests/test_migration_257_yandex_source_url_backfill.py держит это сцепление +-- (сравнивает выражение из ЭТОГО файла с CANONICAL_URL_SQL, который, в свою +-- очередь, уже сверен с продюсером в test_yandex_detail_backfill.py). +-- Условия отбора — те же строковые константы OFFER_URL_PATTERN ('/offer/[0-9]+') +-- и OFFER_ID_PATTERN ('^[0-9]+$'), которыми очередь #2838 отбирает эти же строки. +-- +-- КОЛЛИЗИЙ НЕТ — ПРОВЕРЕНО, А НЕ ЗАЯВЛЕНО: +-- * новый URL, уже занятый ДРУГОЙ строкой listings (любой источник): 0; +-- * два кандидата с одинаковым новым URL внутри самого множества: 0 +-- (source_id уникален по constraint 133_listings_uq_source_source_id.sql); +-- * после UPDATE в dry-run дублей source_url среди ВСЕХ yandex-строк: 0. +-- +-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-12, тем же телом, что ниже): +-- UPDATE 3535 · осталось непарсимых 0 · дублей source_url у yandex 0 +-- счётчики очереди #2838 после: url_from_offer_id 3535 → 0, unenrichable_pending 0 +-- yandex_address_backfill (кандидаты 5545): с непарсимым URL 1777 → 0 +-- +-- было → стало (10 строк, взяты по id DESC): +-- 2583989 https://ekaterinburg.razum.life/flats/7228451 → .../offer/7087563582288224501/ +-- 2583986 https://sinara-development.ru/#/macrocatalog/… → .../offer/6990986462977811151/ +-- 2583940 https://www.an-nks.ru/catalog/38/4241/ → .../offer/7567121745684380093/ +-- 2583938 https://ten-stroy.ru/parametric/osnovinskiye-… → .../offer/5227777077487552091/ +-- 2583931 https://ekaterinburg.razum.life/flats/7225097 → .../offer/7087563582288131414/ +-- 2583929 https://samolet.ru/ekaterinburg/project/payer/… → .../offer/1827858605736006765/ +-- 2583923 https://samolet.ru/ekaterinburg/project/auruum/… → .../offer/2812449412758148821/ +-- 2583917 http://na100.pro/go.php?link=uRy09YqcU9pqegrRc… → .../offer/895871493295352384/ +-- 2583891 https://macroserver.ru/id/8783797/ → .../offer/6378643964567459685/ +-- 2583878 https://strana.com/ekb/uralskij-sad/flats/14986370→ .../offer/6591508026346911121/ +-- (префикс «стало» везде один: https://realty.yandex.ru/offer//) +-- Живая проба прод-трактом 2026-08-12 (тот же прокси, curl_cffi chrome120, тот же +-- parse) по таким восстановленным адресам: 6 из 6 — HTTP 200 и parse OK. +-- +-- ОБРАТИМОСТЬ — ТАБЛИЦА, А НЕ ПРЕДИКАТ, И ВОТ ПОЧЕМУ (проверено, а не предположено). +-- Ход «прежнее значение уже где-то лежит» (как в 254, где им был +-- raw_payload->>'address') здесь НЕ работает: +-- * listings.raw_payload ключа 'url' НЕ содержит: 0 из 3535. Ключи там +-- ceiling_height, kitchen_area_m2, offer_id, page_param, raw_building_type, +-- site_name — адреса нет ни под одним именем; +-- * listings.house_url / newbuilding_url у всех 3535 = NULL; +-- * listing_sources.source_url (тоже insert-only: в его ON CONFLICT DO UPDATE +-- source_url отсутствует) хранит прежний адрес у 3529 из 3535 — но восстановить +-- ПО НЕМУ нельзя точно: самоидентифицирующий предикат «ls.source_url не +-- realty.yandex» берёт 4832 строки, из которых наши только 3529; сузив его +-- уникальностью URL, всё равно получаем 3529 наших + 9 чужих (это строки, +-- чей listings.source_url канонизировала ещё 164 — вернуть им URL застройщика +-- значило бы отменить чужую починку). Плюс 6 наших строк не покрыты вовсе +-- (у 3 нет строки в listing_sources, у 3 там уже канонический адрес). +-- Поэтому прежние значения сохраняются ЯВНО и поимённо — таблица ниже. Откат: +-- +-- UPDATE listings l +-- SET source_url = b.old_source_url +-- FROM yandex_source_url_backfill_257 b +-- WHERE l.id = b.listing_id +-- AND l.source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/'; +-- +-- (второе условие — чтобы откат не затирал адрес, который к тому моменту записал +-- кто-то другой; повторный прогон отката безвреден). Таблица маленькая +-- (3535 строк) и одноразовая: когда откат больше не нужен, её можно просто +-- удалить — на приложение она не влияет, читателей у неё нет. +-- +-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО: +-- * не трогает `ON CONFLICT DO UPDATE` / reconcile в scraper_kit/base.py — +-- дописывание source_url в апсерт это отдельное решение (прецедент #2818: там +-- COALESCE в апсерте так же намеренно не трогали); +-- * не трогает listing_sources.source_url — читателей у колонки нет (grep по +-- app/: единственное обращение — тот самый INSERT), а в ней остаётся живая +-- история того, что отдал gate-API; +-- * не трогает строки с source_url IS NULL — их 0, а не «на всякий случай» +-- (`!~` на NULL даёт NULL, такие строки предикат и так не берёт); +-- * не гасит и не удаляет ни одной строки: меняется ровно одна колонка. +-- +-- Dependencies: 002_core_tables.sql (listings), 133_listings_uq_source_source_id.sql +-- (уникальность source_id, на ней держится «коллизий 0»), 164 (та же формула). +-- Идемпотентность: по построению. Второй прогон видит 0 строк с непарсимым URL → +-- UPDATE и INSERT берут пустое множество; CREATE TABLE IF NOT EXISTS + ON CONFLICT +-- DO NOTHING делают повтор безопасным и при частичном откате. +-- lock_timeout: блокирующего DDL здесь нет (гейт check-migration-lock-timeout.py +-- про CREATE TABLE молчит), но UPDATE по «горячей» listings берёт ROW EXCLUSIVE, и +-- ждать его выдачи за чужой ACCESS EXCLUSIVE-сессией — ровно та очередь перед +-- приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт громко. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE IF NOT EXISTS yandex_source_url_backfill_257 ( + listing_id bigint PRIMARY KEY, + old_source_url text NOT NULL, + changed_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE yandex_source_url_backfill_257 IS + 'Прежние (застройщицкие) listings.source_url, переписанные миграцией 257 (#2838). ' + 'Только для отката; читателей в приложении нет, удаляется без последствий.'; + +INSERT INTO yandex_source_url_backfill_257 (listing_id, old_source_url) +SELECT id, source_url + FROM listings + WHERE source = 'yandex' + AND source_url !~ '/offer/[0-9]+' + AND source_id ~ '^[0-9]+$' +ON CONFLICT (listing_id) DO NOTHING; + +UPDATE listings + SET source_url = 'https://realty.yandex.ru/offer/' || source_id || '/' + WHERE source = 'yandex' + AND source_url !~ '/offer/[0-9]+' + AND source_id ~ '^[0-9]+$'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql new file mode 100644 index 00000000..12f72b4b --- /dev/null +++ b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql @@ -0,0 +1,41 @@ +-- 258_houses_imv_transient_attempts.sql +-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674). +-- +-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь +-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию +-- 'pending'), и за всю историю (41 прогон, 26.06–11.08) ни один не был запущен +-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из +-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All +-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой +-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе +-- 108 из 108 POST /fetch-json = 200). +-- +-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически +-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из +-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт +-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд. +-- +-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error', +-- прежние разрезы по imv_status/imv_error_reason работают как работали, а +-- «застряли окончательно» — это +-- SELECT count(*) FROM houses +-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3; +-- +-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по +-- imv_status IN ('pending','transient_error') с сортировкой по +-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на +-- выборке в тысячи строк. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE houses + ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN houses.imv_transient_attempts IS + 'Сколько раз подряд домовая IMV-оценка падала в transient_error. ' + 'Растёт только на transient_error, обнуляется успехом. ' + '>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql new file mode 100644 index 00000000..16312e53 --- /dev/null +++ b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql @@ -0,0 +1,145 @@ +-- 259_data_quality_drop_pct_cadastr.sql +-- Purpose (#2674, третий показатель того же класса): убрать v_data_quality.pct_cadastr. +-- +-- 214 убрала outliers_flagged, 216 — price_disagreements_count по одному доводу: ноль, +-- гарантированный устройством системы, читается как «проверили — чисто», хотя честно он +-- означает «мы это не считаем». pct_cadastr — третий такой же, поэтому и действие то же: +-- не переключать источник, а снять показатель. +-- +-- ── ЧИСЛА С ПРОДА (2026-08-13, точный count) ──────────────────────────────── +-- v_data_quality.pct_cadastr .................... 0.000000000000000000000000 +-- знаменатель витрины (listings_active) ......... 45 198 (в listings всего 99 304) +-- listings.cadastral_number IS NOT NULL ......... 0 из 99 304 (и 0 из 45 198 активных) +-- deals.cadastral_number ........................ 0 из 96 974 +-- houses.cadastral_number (DaData) .............. 2 648 из 9 468 ← ДРУГОЙ объект +-- listings.building_cadastral_number ............ 30 970 из 99 304 ← ДРУГОЙ объект +-- +-- ── ЭТО НЕ ДЕФЕКТ ИЗМЕРИТЕЛЯ (контроль на здоровом образце в тех же данных) ── +-- Тот же CTE active_listings и тот же шаблон `count(*) WHERE IS NOT NULL * 100.0 +-- / NULLIF(count(*), 0)` в соседних строках витрины даёт 95.61% (pct_geocoded), 39.82% +-- (pct_description), 65.10% (pct_year_built). Ровно 0% — про колонку, а не про арифметику. +-- +-- ── ПОЧЕМУ НОЛЬ СТРУКТУРНЫЙ ───────────────────────────────────────────────── +-- listings.cadastral_number — кадастр КВАРТИРЫ. Единственное место в коде, которое его +-- вообще читает, — providers/cian/serp.py:886 (`offer.get("cadastralNumber")`); в парсерах +-- avito/yandex/domclick/n1 слов cadastr/kadastr нет ни разу, то есть для ЧЕТЫРЁХ площадок +-- из пяти ноль гарантирован НАШИМ кодом и о предметной области не говорит ничего. Пусто +-- при этом везде, где мы этот номер храним (три таблицы выше) — то же уже записано в +-- app/services/matching/houses.py: «площадки кадастр не отдают». +-- +-- ── ПОЧЕМУ НЕЛЬЗЯ «ПОЧИНИТЬ ОДНОЙ СТРОКОЙ», ПЕРЕКЛЮЧИВ НА СОСЕДНЮЮ КОЛОНКУ ── +-- Напрашивается считать по listings.building_cadastral_number (31.19% всего, 29.47% у +-- активных). Под подписью «доля объявлений с кадастром» это НОВАЯ ложь вместо старой: +-- * это кадастр ЗДАНИЯ, и в listings у него РОВНО ОДИН писатель — наш ночной KNN ≤50 м +-- по локальному зеркалу ЕГРН (tasks/cadastral_geo_match.py:161; проверено `git grep` +-- по origin/main: других INSERT/UPDATE этой колонки нет). Он не «тот же кадастр из +-- другого места», а наша производная; +-- * #2674 замерил ключ как неинъективный (656 из 3 260 значений накрывают >1 здание ГАР, +-- 20.1%; 751 из 2 864 зданий получают >1 значение, 26.2%) и прямо запретил считать его +-- идентичностью здания; +-- * разброс по площадкам среди активных геокодированных (cian 33.8%, yandex 20.3%, +-- avito 42.0%, domclick 49.7%) — про точность НАШИХ координат и охват зеркала по ЕКБ, +-- а не про качество объявления. +-- Переименовать подпись мало: честное имя было бы «доля объявлений, которым ночной KNN +-- подобрал здание в 50 м» — это другой показатель, и заводить его надо отдельно и +-- осознанно, а не под видом починки этого. Авторитетный кадастр здания у нас есть — +-- houses.cadastral_number из DaData (2 648/9 468 домов), но он про ДОМА, а витрина считает +-- ОБЪЯВЛЕНИЯ; подставить его в эту строку — снова назвать одно другим. +-- +-- ── ЦЕНА ПРАВКИ ──────────────────────────────────────────────────────────── +-- Читателей у витрины в коде нет (grep по /app/app в живом backend-контейнере пуст; +-- /api/v1/admin/scraper/data-quality считает свои метрики сам и кадастр не показывает +-- вовсе) — это ручной psql-снимок. Зависимых объектов у view тоже нет (pg_depend по +-- 'v_data_quality'::regclass, прод 13.08: 0 строк), поэтому CASCADE не нужен и не должен +-- появиться: в этом продукте `DROP ... CASCADE` уже терял гранты FDW-пользователю (C3). +-- +-- ── ПОРЯДОК И БЛОКИРОВКА ─────────────────────────────────────────────────── +-- CREATE OR REPLACE VIEW колонку УДАЛИТЬ не может → DROP VIEW → CREATE VIEW (тот же +-- порядок, что 214/216). DROP VIEW берёт ACCESS EXCLUSIVE, поэтому `SET LOCAL +-- lock_timeout` (см. scripts/check-migration-lock-timeout.py). В отличие от 222, которая +-- обошлась CREATE OR REPLACE, здесь COMMENT ON VIEW надо выставить ЗАНОВО: DROP уносит +-- комментарий вместе с объектом. +-- +-- Тело SELECT скопировано из 222_db_audit_cleanup.sql (последний DDL; сверено с живым +-- pg_get_viewdef на проде 13.08 — совпадает) минус строка pct_cadastr. Из CTE убран +-- ставший ненужным cadastral_number: 222 завела явный список колонок ровно затем, чтобы +-- view не держал column-level зависимость на то, чего не показывает. +-- +-- Dependencies: 216_dead_code_sweep.sql (текст COMMENT ON VIEW), 222_db_audit_cleanup.sql +-- (последний DDL v_data_quality). +-- Apply after: 258_houses_imv_transient_attempts.sql +-- Идемпотентно: DROP VIEW IF EXISTS + CREATE VIEW + COMMENT — повторный прогон даёт тот +-- же результат. + +BEGIN; + +-- Ждём лок не дольше 5 s: сам DROP мгновенный, но ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами (#2791/#2792). +SET LOCAL lock_timeout = '5s'; + +DROP VIEW IF EXISTS v_data_quality; + +-- DDL идентичен 222, минус строка pct_cadastr и минус cadastral_number в CTE. +CREATE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT id, lat, description, house_id_fk, is_active + FROM listings + WHERE is_active = true +) +SELECT + (SELECT count(*) FROM houses) AS houses_total, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2 + ) sub) AS houses_2plus_sources, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3 + ) sub) AS houses_3plus_sources, + (SELECT count(*) FROM active_listings) AS listings_active, + (SELECT count(*) FROM ( + SELECT listing_id FROM listing_sources + WHERE listing_id IN (SELECT id FROM active_listings) + GROUP BY listing_id HAVING count(*) >= 2 + ) sub) AS listings_dedup_2sources, + (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded, + (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description, + (SELECT count(*) FROM active_listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.year_built IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago; + +-- Текст 216 + абзац про pct_cadastr. Выставляем заново, потому что DROP VIEW выше унёс +-- прежний комментарий вместе с объектом. +COMMENT ON VIEW v_data_quality IS + 'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): ' + '/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. ' + '#2674: price_disagreements_count убран — у всех 89 699 объявлений ровно один ' + 'источник, поэтому показатель структурно не мог быть ненулевым и ноль читался как ' + '«расхождений нет» вместо «мы не сравниваем». listings_dedup_2sources оставлен ' + 'намеренно: он ту же пустоту называет своим именем («объявлений с 2+ источниками»), ' + 'ноль в нём — честный ответ, а не мнимое благополучие. ' + '#2674 (мигр. 259): pct_cadastr убран по тому же доводу — считал ' + 'listings.cadastral_number (кадастр КВАРТИРЫ), а его не отдаёт ни одна площадка: ' + '0 из 99 304 объявлений, 0 из 96 974 deals, единственный читающий его парсер — ' + 'cian/serp.py. Показатель НЕ переведён на listings.building_cadastral_number: та ' + 'колонка — кадастр ЗДАНИЯ и на 100% производная нашего ночного KNN ≤50 м ' + '(tasks/cadastral_geo_match.py), неинъективного как ключ здания (#2674: 20.1% ' + 'значений накрывают >1 здание ГАР); под подписью «доля объявлений с кадастром» она ' + 'мерила бы покрытие нашего геокодера, а не качество объявлений.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql new file mode 100644 index 00000000..c53c6ce5 --- /dev/null +++ b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql @@ -0,0 +1,179 @@ +-- 260_houses_drop_has_panorama.sql +-- Issue #2674 (хвост) — снос houses.has_panorama: признака НЕТ в предметной области. +-- +-- Dependencies: 031_houses_alter_yandex.sql (завела колонку), +-- 154_market_contract_views.sql (внесла её в публичный контракт +-- market.v_houses), 155_reader_grants_to_contract_views.sql (грант +-- gendesign_reader на этот view). +-- Apply after: 258_houses_imv_transient_attempts.sql +-- Deploy order: код УЖЕ впереди схемы — писатель (_save_yandex_house_panorama), +-- парсер (ValuationHouseMeta.has_panorama) и правило разрешения конфликтов +-- (HOUSE_FIELD_PRIORITY) сняты тем же PR, что несёт этот файл. Обратный порядок +-- (снести колонку, оставить писателя) давал бы падающий UPDATE на каждой оценке +-- yandex_valuation — молча проглоченный, но с WARNING в логах. +-- +-- ── ЧТО ЗА НОЛЬ И ПОЧЕМУ ЭТО НЕ ДЕФЕКТ ────────────────────────────────────── +-- Колонка заполнялась `"Панорама" in body_text` по тексту страницы оценки Яндекса. +-- external_valuations (source='yandex_valuation', raw_payload->'house'), 24.05–12.08.2026: +-- страниц ............................................................. 1536 +-- has_panorama = true .................................................... 0 +-- has_panorama = false ................................................ 1536 +-- ключ отсутствует ....................................................... 0 +-- houses: 9468 строк, has_panorama непустых 12, из них true 0. +-- +-- Это НЕ «метка переехала» и НЕ «путь записи оборван». Живая проверка боевым трактом +-- 13.08.2026 (curl_cffi impersonate=chrome120 + прод-прокси, RealScraperConfig — тот же +-- клиент, что у estimator.py; только чтение) взяла три адреса Екатеринбурга, все HTTP 200: +-- Советская 51 ...... HTML 1 191 929 б — мета разобралась: 1974 г., 9 эт., панель, +-- 2,50 м потолки, 46 объектов +-- Парина 46/5 ....... HTML 1 185 458 б — 2020 г., 18 эт. +-- Сурикова 47 ....... 1977 г., 5 эт., кирпич, 184 объекта +-- Вхождений «анорам» (без учёта регистра) в ПОЛНОМ HTML: 0, 0, 0. Равно как panorama / +-- 3D-тур / Виртуальн / Street — 0. Переехать в атрибут, data-*, JSON-стейт или иную +-- вёрстку метка не могла: её нет в документе целиком. Словарь удобств дома на странице: +-- «Дом 1974 года · 9 этажей · Панельное здание · 2,50 м потолки · Газ · Лифт · +-- Мусоропровод», причём с ЯВНЫМИ отрицаниями («Лифт отсутствует», «Мусоропровода нет») — +-- будь панорама признаком дома, она печаталась бы в этом ряду и в отрицательной форме. +-- +-- Ноль был механически гарантирован самим кодом и о предметной области не говорил +-- ничего, кроме одного: измерять нечего. Третий вид нуля — НЕПРИМЕНИМО, лечится +-- удалением, а не починкой разбора. +-- +-- ОГОВОРКА ЧЕСТНОСТИ: сырой HTML прошлых сборов не хранится (raw_payload держит только +-- body_len/items_count), поэтому «метка была и исчезла в мае» доказательно не +-- опровергается. Но и положительных за всё окно 1536 страниц ноль — в измеренной +-- истории её тоже не было. +-- +-- ── ГЛАВНАЯ ЦЕНА: ЛОМАЕМ ПУБЛИЧНЫЙ КОНТРАКТ ──────────────────────────────── +-- has_panorama входит в market.v_houses (154), где сказано прямым текстом: «adding a +-- column later is backward compatible, renaming/removing one is not». Это осознанное +-- ломающее изменение контракта, а не недосмотр. Основание — консьюмер колонку не +-- читает: `git grep has_panorama` вне tradein-mvp пуст (в т.ч. +-- backend/app/services/etl/newbuilding_crossload.py, единственный живой читатель +-- контракта, #976/#2130). Держать в публичном обещании поле, которое всегда false и +-- никогда не станет ничем другим, — обещать данные, которых не существует. +-- +-- CREATE OR REPLACE VIEW удалить колонку не умеет, поэтому view пересоздаётся: +-- DROP VIEW → DROP COLUMN → CREATE VIEW. Порядок обязателен ещё и потому, что +-- DROP COLUMN без CASCADE упрётся в зависимость view (проверено на проде: единственный +-- зависимый объект — market.v_houses). CASCADE НЕ используем — он снёс бы и то, что +-- появится позже, без единого слова в логе. +-- +-- ГРАНТЫ ТЕРЯЮТСЯ ПРИ DROP VIEW (это уже кусало: C3, FDW-гранты после DROP ... CASCADE). +-- На проде на market.v_houses висит GRANT SELECT для gendesign_reader (155) — он +-- восстанавливается ниже явно, тем же стейтментом, что и в 155. Без этой строки +-- внешний ETL получил бы permission denied на следующем же прогоне. +-- +-- ── СТОИМОСТЬ БЛОКИРОВКИ И SET LOCAL lock_timeout ────────────────────────── +-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на houses. Удержание дёшево и не +-- зависит от размера: PostgreSQL не переписывает heap, а помечает attisdropped в +-- каталоге — единицы миллисекунд на 9468 строк. Дорого ОЖИДАНИЕ выдачи лока: ждущий +-- ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, и за ним начинают ждать +-- обычные SELECT приложения — ровно то, что 2026-08-07 положило деплой на 29 минут +-- (#2791, #2792). Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено +-- deadlock_timeout = 1 s на проде; на работу ПОД локом не влияет). Срабатывание = +-- честный красный деплой через 5 секунд, миграция не помечается применённой. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP VIEW IF EXISTS + DROP COLUMN IF EXISTS + CREATE VIEW после DROP — +-- безопасный re-run. +-- - Без CASCADE. +-- - Откат: колонку вернуть можно (ALTER TABLE houses ADD COLUMN has_panorama boolean), +-- данные не восстановятся — восстанавливать нечего, все 12 непустых значений false. +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. information_schema.columns по houses: has_panorama отсутствует. +-- 3. market.v_houses существует, has_panorama в нём нет, остальные 59 колонок на +-- месте и в том же порядке (прод до правки: 60), SELECT count(*) отдаёт 9468+ строк. +-- 4. information_schema.role_table_grants: gendesign_reader снова имеет SELECT на +-- market.v_houses. + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +DROP VIEW IF EXISTS market.v_houses; + +ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama; + +-- Пересоздание контракта БЕЗ has_panorama. Список колонок — копия 154 минус одна +-- строка; он и есть обещание стабильности, поэтому выписан явно, без SELECT *. +CREATE VIEW market.v_houses AS +SELECT + id, + source, + ext_house_id, + url, + slug, + address, + full_address, + short_address, + lat, + lon, + geom, + year_built, + house_type, + house_class, + material_walls, + material_floors, + series_name, + total_floors, + total_units, + entrances, + flat_count, + is_emergency, + passenger_elevators, + cargo_elevators, + has_concierge, + closed_yard, + has_playground, + hot_water, + heat_supply_type, + gas_supply_type, + overlap_type, + parking_type, + infrastructure_summary, + infrastructure_walk_distance, + developer_name, + developer_key, + management_company_id, + rating, + reviews_count, + rating_score, + rating_string, + transport_accessibility_rate, + advantages, + banks, + builders, + houses_by_turn, + corpus_count, + commission_year, + commission_month, + total_area_ha, + cadastral_number, + house_fias_id, + yandex_jk_id, + yandex_jk_slug, + cian_internal_house_id, + cian_zhk_url, + raw_payload, + first_seen_at, + last_scraped_at +FROM public.houses; + +COMMENT ON VIEW market.v_houses IS + 'Stable public contract over public.houses (#2130). Explicit column list is the ' + 'stability promise — do not SELECT * against the base table from external ' + 'consumers. raw_payload is included because it is read today by gendesign ETL ' + '#976 (newbuilding_crossload.py); scraper-internal QC/status/validated_at ' + 'bookkeeping columns are intentionally excluded. #2674 (хвост): has_panorama ' + 'убрана из контракта вместе с колонкой — ломающее изменение, принятое осознанно ' + '(0 true из 1536 страниц, признака нет на площадке, читателей вне tradein нет).'; + +-- DROP VIEW уничтожил гранты — восстанавливаем ровно то, что дала 155. +GRANT SELECT ON market.v_houses TO gendesign_reader; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql new file mode 100644 index 00000000..751f9e36 --- /dev/null +++ b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql @@ -0,0 +1,270 @@ +-- 261_listings_search_mv_drop_placeholder_columns.sql +-- Issue #2857 (эпик #2674) — снос трёх колонок-заглушек из listings_search_mv: +-- distance_to_metro_m, last_price_change, photos_count. +-- +-- Dependencies: 050_search_optimization.sql (завела витрину и 6 индексов), +-- 094_cadastral_unify.sql (последняя пересоздала витрину; её текст +-- и есть текущее прод-определение, сверено с pg_matviews 13.08.2026 — +-- расхождений нет), 088_scrape_schedules_seed_search_matview_refresh.sql +-- (суточный REFRESH ... CONCURRENTLY). +-- Apply after: 260_houses_drop_has_panorama.sql +-- Deploy order: схема и код независимы — у трёх колонок НЕТ читателей, поэтому +-- правки кода этот PR не несёт и порядок «миграция ↔ образ» безразличен. +-- +-- ── ЧТО ЗА НОЛЬ ──────────────────────────────────────────────────────────── +-- Не потеря данных и не оборванный писатель: NULL прописан в самом определении +-- витрины литералом. Четвёртый вид нуля — ОБЕЩАНИЕ В КОНТРАКТЕ БЕЗ РЕАЛИЗАЦИИ: +-- имена зарезервировали в 050, реализацию не подключили никогда. +-- +-- pg_stats по listings_search_mv, 13.08.2026 (45 310 строк): +-- null_frac = 1.0 у 5 колонок: cadastral_number, district, +-- distance_to_metro_m, last_price_change, photos_count. +-- Сносим три. После применения колонок с null_frac = 1.0 останется 2 +-- (cadastral_number — живая колонка с писателем, просто площадки её не отдают, +-- см. 216/search_query.py; district — вынесен решением владельца, ниже). +-- +-- ЧИТАТЕЛЕЙ НОЛЬ — перепроверено на origin/main, не по памяти: +-- `git grep -E "distance_to_metro_m|last_price_change|photos_count" origin/main` +-- даёт 8 строк, и все 8 — сами файлы 050 и 094 (объявление + комментарий над ним). +-- Ни бэкенда, ни фронта, ни тестов, ни скриптов. Отдельно проверено, что колонки +-- не уезжают в ответ через звёздочку: `SELECT *` из listings_search_mv в репозитории +-- НЕТ ни одного (единственный читатель — services/search_query.py, там явный +-- список из 27 имён), и SQLAlchemy-рефлексии витрины тоже нет. +-- +-- DISTRICT НЕ ТРОГАЕМ, хотя он такой же пустой. Он доехал дальше всех: его тянет +-- services/search_query.py:138 и объявляет schemas/search_response.py:44 +-- (`district: str | None`), то есть API его ОТДАЁТ — всегда null. Снос = ломающее +-- изменение контракта, решение владельца, вынесено отдельным пунктом в #2857. +-- Здесь он воспроизводится байт-в-байт (`NULL::text AS district`). +-- +-- ── ПОЧЕМУ DROP + CREATE, А НЕ ALTER ─────────────────────────────────────── +-- Материализованному представлению нельзя удалить колонку: ALTER MATERIALIZED VIEW +-- такой формы не имеет, а ALTER TABLE ... DROP COLUMN на relkind='m' отказывает. +-- Единственный путь — пересоздание, как в 094. +-- +-- БЕЗ CASCADE. Зависимых объектов на проде ноль (проверено через pg_depend/pg_rewrite +-- 13.08.2026: 0 строк). Если зависимость появится до применения — DROP упрётся и +-- деплой честно покраснеет; CASCADE снёс бы её молча. +-- +-- ── ГРАНТЫ: ЛОВУШКА, КОТОРАЯ ЗДЕСЬ НЕ СРАБАТЫВАЕТ, НО ПРИКРЫТА ───────────── +-- DROP уносит ACL вместе с объектом — это уже кусало (C3, FDW-гранты после +-- DROP ... CASCADE; 260 восстанавливала GRANT SELECT для gendesign_reader вручную). +-- На listings_search_mv восстанавливать сегодня НЕЧЕГО, и это измерено, а не +-- предположено: +-- pg_class.relacl = {tradein=arwdDxt/tradein} — только владелец, ни одного +-- стороннего grantee; column-level грантов нет; pg_default_acl пуст. +-- (information_schema.role_table_grants по витрине пуст ВСЕГДА и ничего не +-- доказывает: information_schema не показывает материализованные представления +-- в принципе — смотреть надо relacl. Это и есть тот источник, где ловушку легко +-- проглядеть.) +-- Для сравнения: gendesign_reader имеет SELECT на listings и offer_price_history — +-- на витрину ему не давали. +-- Тем не менее ACL снимается и переигрывается ниже автоматически: между написанием +-- файла и его применением на проде может пройти неделя, и ручной слепок к тому +-- моменту протухнет молча. Снимок берётся в той же транзакции, что и DROP, поэтому +-- врать не может. +-- +-- ── ИНДЕКСЫ ──────────────────────────────────────────────────────────────── +-- Пересоздаются все 6 (прод, 13.08.2026 — совпадают с 050/094 один в один). +-- UNIQUE listings_search_mv_id_idx (listing_id) обязателен: без него суточный +-- REFRESH MATERIALIZED VIEW CONCURRENTLY (app/tasks/refresh_search_matview.py, +-- расписание refresh_search_matview 03:00-04:00 UTC) упадёт с +-- «cannot refresh materialized view concurrently ... no unique index». +-- +-- ── ЦЕНА ПЕРЕСОЗДАНИЯ И БЛОКИРОВКА ───────────────────────────────────────── +-- Транзакция держит ACCESS EXCLUSIVE на витрине от DROP до COMMIT, т.е. читатели +-- ждут всё построение. Замер на проде (EXPLAIN ANALYZE тела витрины, 13.08.2026): +-- сам SELECT 6.6 s на прогретом кэше; плюс 6 индексов (GIN tsv 19 МБ, GIN trgm +-- 17 МБ, остальные мелочь) при maintenance_work_mem = 64 МБ — ориентир 30-60 s +-- на всю транзакцию. Для сравнения, суточный CONCURRENTLY-рефреш укладывается в +-- 9-17 s, но он делает вдвое больше работы (строит + сливает). +-- Простой READ-трафика приемлем: за всё время жизни БД (pg_stat_database.stats_reset +-- пуст, т.е. счётчики ни разу не сбрасывались) витрина видела 225 seq_scan и +-- 63 idx_scan — а суточный CONCURRENTLY-рефреш сам по себе даёт по seq_scan в день. +-- То есть /api/v1/search к ней практически не ходит, и трюк «собрать под временным +-- именем + переименовать» (12 лишних строк ради миллисекунд вместо минуты) не нужен. +-- +-- SET LOCAL lock_timeout = '5s' — ограничивает ОЖИДАНИЕ выдачи лока, не работу под +-- ним (см. .claude/rules/sql.md § lock_timeout). Ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами. Отдельный реальный конфликт здесь: если деплой +-- попадёт в окно 03:00-04:00 UTC, DROP столкнётся с REFRESH ... CONCURRENTLY → +-- честный красный деплой через 5 s, миграция не помечается применённой, повторный +-- деплой пройдёт. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP MATERIALIZED VIEW IF EXISTS + CREATE — повторный прогон приводит к тому +-- же состоянию (ценой ещё одного построения). Индексы создаются на заведомо +-- новом объекте, поэтому без IF NOT EXISTS (как в 050/094). +-- - Данных не теряем: витрина целиком выводима из listings/houses/listing_sources. +-- - Откат: вернуть три строки `NULL::...` в определение и пересоздать тем же +-- способом. Восстанавливать нечего — значений не существовало. +-- +-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения): +-- 1. Строка `261_listings_search_mv_drop_placeholder_columns.sql` в +-- _schema_migrations (а не «деплой зелёный»). +-- 2. Колонок в витрине 31 (было 34); distance_to_metro_m / last_price_change / +-- photos_count отсутствуют; district на месте, тип text. +-- 3. pg_matviews.definition не содержит подстроки 'distance_to_metro_m'. +-- 4. Индексов 6, среди них UNIQUE listings_search_mv_id_idx. +-- 5. pg_class.relacl витрины эквивалентен доприменительному (сегодня — владелец +-- и никого больше). +-- 6. SELECT count(*) FROM listings_search_mv отдаёт 40k+ строк. +-- 7. Следующий ночной refresh_search_matview завершается status='done' +-- (доказательство, что CONCURRENTLY не потерял UNIQUE-индекс). +-- 8. Ответ /api/v1/search по-прежнему содержит ключ district (и не содержит +-- удалённых — их там и не было). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование — в шапке. +SET LOCAL lock_timeout = '5s'; + +-- ── 1. Снимок ACL ДО сноса ───────────────────────────────────────────────── +-- aclexplode(NULL) даёт 0 строк — на витрине без явного ACL блок просто пуст. +-- Владельца исключаем: CREATE вернёт его права сам. +-- Колоночные гранты (pg_attribute.attacl) снимаются ОТДЕЛЬНОЙ веткой: они живут +-- не в relacl, и первая редакция этого файла их молча теряла — поймано прогоном +-- на одноразовой БД, а не рассуждением. +CREATE TEMP TABLE _mv2857_acl ON COMMIT DROP AS +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee, + a.privilege_type, + a.is_grantable, + NULL::text AS column_name +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +CROSS JOIN LATERAL aclexplode(c.relacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner +UNION ALL +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END, + a.privilege_type, + a.is_grantable, + quote_ident(att.attname) +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped +CROSS JOIN LATERAL aclexplode(att.attacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner; + +-- ── 2. Пересоздание витрины без трёх заглушек ────────────────────────────── +DROP MATERIALIZED VIEW IF EXISTS listings_search_mv; + +CREATE MATERIALIZED VIEW listings_search_mv AS +SELECT + l.id AS listing_id, + l.source, + l.source_url, + l.address, + l.geom, + l.lat, + l.lon AS lng, + l.rooms, + l.area_m2 AS total_area, + l.floor, + l.total_floors, + l.price_rub, + l.price_per_m2, + l.cadastral_number, + l.is_active, + l.scraped_at, + -- House denorm + h.id AS house_id, + h.year_built, + h.house_class, + h.developer_name, + h.rating AS house_rating, + h.reviews_count AS house_ratings_count, + -- Cross-source aggregates + (SELECT count(*) FROM listing_sources ls WHERE ls.listing_id = l.id) AS source_count, + (SELECT array_agg(DISTINCT ext_source) FROM listing_sources ls WHERE ls.listing_id = l.id) AS sources, + (SELECT bool_or(ext_source = 'avito') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_avito, + (SELECT bool_or(ext_source = 'cian') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_cian, + (SELECT bool_or(ext_source = 'yandex_realty') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_yandex, + -- Price percentile within house + (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY ll.price_per_m2) + FROM listings ll + WHERE ll.house_id_fk = l.house_id_fk AND ll.is_active = true) AS house_median_ppm2, + -- Заглушка, оставленная СОЗНАТЕЛЬНО: district доезжает до схемы ответа API + -- (schemas/search_response.py), снос — ломающее изменение контракта, решение + -- владельца (#2857). Соседние distance_to_metro_m / last_price_change / + -- photos_count сняты здесь: у них не было ни одного читателя. + NULL::text AS district, + -- Trigram-ready columns + l.address AS address_trgm, + -- Aggregated tsv (description + address + developer_name) + to_tsvector('russian', + coalesce(l.description, '') || ' ' || + coalesce(l.address, '') || ' ' || + coalesce(h.developer_name, '') + ) AS tsv +FROM listings l +LEFT JOIN houses h ON h.id = l.house_id_fk +WHERE l.is_active = true + AND COALESCE(l.canonical, true) = true; + +-- ── 3. Те же 6 индексов (050/094) ────────────────────────────────────────── +-- UNIQUE — обязателен для REFRESH ... CONCURRENTLY, см. шапку. +CREATE UNIQUE INDEX listings_search_mv_id_idx + ON listings_search_mv (listing_id); + +CREATE INDEX listings_search_mv_geom_idx + ON listings_search_mv USING GIST (geom); + +CREATE INDEX listings_search_mv_filters_idx + ON listings_search_mv (rooms, price_rub, total_area, scraped_at DESC); + +CREATE INDEX listings_search_mv_address_trgm_idx + ON listings_search_mv USING GIN (address_trgm gin_trgm_ops); + +CREATE INDEX listings_search_mv_tsv_idx + ON listings_search_mv USING GIN (tsv); + +CREATE INDEX listings_search_mv_sources_idx + ON listings_search_mv (has_avito, has_cian, has_yandex); + +-- ── 4. Возврат грантов, снятых в п.1 ─────────────────────────────────────── +-- Пусто, если сторонних grantee не было (сегодня — так). privilege_type приходит +-- из системного каталога, поэтому подставляется как есть. +-- Если у кого-то окажется колоночный грант ИМЕННО на снесённую колонку — GRANT +-- упадёт на несуществующем имени, и это правильно: такой грант означает читателя, +-- которого мы не нашли, и деплой обязан покраснеть, а не молча снести колонку. +DO $$ +DECLARE + r record; +BEGIN + FOR r IN SELECT grantee, privilege_type, is_grantable, column_name FROM _mv2857_acl LOOP + EXECUTE format( + 'GRANT %s%s ON TABLE public.listings_search_mv TO %s%s', + r.privilege_type, + CASE WHEN r.column_name IS NULL THEN '' ELSE ' (' || r.column_name || ')' END, + r.grantee, + CASE WHEN r.is_grantable THEN ' WITH GRANT OPTION' ELSE '' END + ); + RAISE NOTICE 'listings_search_mv: возвращён GRANT % % для %', + r.privilege_type, coalesce('(' || r.column_name || ')', 'на витрину'), r.grantee; + END LOOP; +END +$$; + +-- ── 5. Статистика сразу, а не «когда-нибудь придёт autoanalyze» ──────────── +-- Иначе планировщик до первого автоанализа работает по пустым оценкам, а критерий +-- приёмки по pg_stats нечем проверить. +ANALYZE listings_search_mv; + +COMMENT ON MATERIALIZED VIEW listings_search_mv IS + 'Витрина поиска (/api/v1/search, 050/094). #2857: сняты три колонки-заглушки ' + 'distance_to_metro_m / last_price_change / photos_count — литеральный NULL в ' + 'определении, ноль читателей во всём репозитории. district оставлен намеренно: ' + 'он объявлен в schemas/search_response.py, его снос — ломающее изменение ' + 'контракта API и решение владельца. Единственный читатель витрины — ' + 'services/search_query.py с ЯВНЫМ списком колонок; SELECT * по ней запрещён ' + 'по той же причине, что и по market.v_houses.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql b/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql new file mode 100644 index 00000000..e03493d7 --- /dev/null +++ b/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql @@ -0,0 +1,951 @@ +-- 262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql +-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — WAVE 2: +-- avito/cian/yandex city-sweep за пределами Екатеринбурга для оставшихся 40 городов +-- области (wave 1 — 179_scrape_schedules_seed_oblast_city_sweeps.sql, 5 городов: +-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/verkhnyaya_pyshma/serov). Объявления +-- по области сейчас 3229 против 20111 по ЕКБ — wave 2 заводит оставшийся охват +-- Свердловской обл. Domclick (BFF, city_id-based) — отдельный rollout, сюда НЕ входит. +-- +-- Координаты городов (lat/lon/название) — проверены на проде (геокодер + независимая +-- сверка медианой координат сделок Росреестра по городу, exclusion в радиусе 12км от +-- ЕКБ). CITY_ANCHORS-записи для всех 40 slug'ов — тот же PR, +-- packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py. +-- +-- БИСЕРТЬ ИСКЛЮЧЕНА ЦЕЛИКОМ (не 41, а 40 городов): у Циана её нет вообще — поиск на +-- любой запрос ("Бисерть", "пгт Бисерть") отдаёт Сысерть id=176028. Это пгт, а не +-- город области — не заводится ни в CITY_ANCHORS, ни здесь. +-- +-- ═══ ГЛАВНОЕ ОТЛИЧИЕ ОТ ПЕРВОЙ ВЕРСИИ ЭТОГО ФАЙЛА ═══ +-- Первая версия (до ревью) заводила 41 город × 3 источника = 123 строки для ВСЕХ +-- источников сразу, планируя добыть provider-идентификаторы (avito_slug/cian_region_id/ +-- yandex_rgid) ПОСЛЕ. Это оказалось бы РОВНО тем самым багом, о котором предупреждала её +-- же шапка: без подтверждённого идентификатора run_avito_city_sweep/run_yandex_city_sweep +-- падают на ЕКБ-дефолт (region_id/rgid Екатеринбурга) — развёртка "включена", но реально +-- собирает ЕКБ под меткой чужого города, порча данных под видом покрытия. +-- +-- Идентификаторы теперь ДОБЫТЫ И ВАЛИДИРОВАНЫ (см. CITY_LOCATIONS-коммент в pipeline.py: +-- cian_id — api.cian.ru/geo-suggest/v1/suggest; yandex_rgid — realty.yandex.ru/gate/ +-- region_suggest/suggest; avito_slug — живой GET avito.ru//kvartiry; все три метода +-- валидированы 5/5 на wave-1 городах с уже известными значениями). Но НЕ у каждого города +-- подтверждены ВСЕ ТРИ идентификатора. Правило этой миграции: **строка заводится ТОЛЬКО +-- там, где идентификатор подтверждён**. Развёртка, которая молча соберёт Екатеринбург, +-- хуже отсутствующей — недостающие источники НЕ заводим вовсе (а не заводим с заглушкой/ +-- fallback). +-- +-- Дополнительный defensive guard в коде (тот же PR, pipeline.py): если КОГДА-ЛИБО +-- run_avito_city_sweep/run_yandex_city_sweep будет вызван с city_slug, у которого в +-- CITY_LOCATIONS известный город, но конкретный provider-идентификатор всё ещё None — +-- функция явно падает `ValueError` (НЕ молчаливый ЕКБ-дефолт). При штатной эксплуатации +-- этой миграции (schedule заводится только при подтверждённом идентификаторе) этот +-- ValueError сработать не должен — он ловит будущий рассинхрон данных, не текущий. +-- +-- ИТОГО 102 строки (не 123): +-- cian_city_sweep_* — 40 строк (cian_region_id подтверждён у ВСЕХ 40 городов). +-- yandex_city_sweep_* — 39 строк (ВСЕ, КРОМЕ mikhaylovsk — Михайловск Нижнесергинского +-- р-на ОТСУТСТВУЕТ в гео-базе Яндекс.Недвижимости вообще: единственный "Михайловск" +-- там — ставропольский, rgid 586221, подставлять чужой регион нельзя. Это +-- подтверждённое ОТСУТСТВИЕ данных у источника, не "не проверили" — довести +-- нечем, ждать нечего). +-- avito_city_sweep_* — 23 строки. avito_slug НЕ подтверждён для 17 городов: +-- revda, polevskoy, berezovskiy, zarechny, kachkanar, sredneuralsk, degtyarsk, +-- artemovskiy, kamyshlov, sukhoy_log, kushva, karpinsk, nizhnyaya_tura, +-- nizhnie_sergi, lesnoy, verkhoturye, mikhaylovsk. +-- Причина по каждому — либо чистый 404 на опробованных вариантах slug'а (omonym- +-- коллизия с городом в другом регионе — нужна avito-специфичная дизамбигуация, +-- которой в проверке не делали), либо 403/429 из-за исчерпания пула прокси во +-- время проверки (кандидат НЕ опровергнут, просто НЕ подтверждён — это единственная +-- категория из трёх, которую стоит ПЕРЕПРОВЕРИТЬ на свежем пуле и добрать отдельной +-- миграцией; остальные — city_rgid mikhaylovsk и omonym-404 avito — подтверждённое +-- отсутствие/коллизия, довести нечем). +-- +-- !!! DORMANT BY DESIGN !!! Все 102 строки ship enabled = false. Оператор включает +-- ВРУЧНУЮ по одному городу за раз (как в wave 1), волнами после деплоя: +-- UPDATE scrape_schedules SET enabled = true WHERE source = 'cian_city_sweep_revda'; +-- Capability уже полностью wired — тот же механизм, что и wave 1 (pipeline.CITY_ANCHORS/ +-- get_city_anchors, scheduler._job_{avito,cian,yandex}_city_sweep читают +-- default_params->>'city', wildcard-registry "*_city_sweep_*" в +-- scraper_kit.orchestration.scheduler._default_kit_handlers) — код скраперов/хендлеров +-- НЕ меняется (кроме defensive-guard в pipeline.py выше, не меняющего штатный путь). +-- +-- default_params — за основу взяты прод-дефолты enabled-городов wave 1 (см. 179_ + +-- 206_), с тремя отличиями: +-- 1. radius_m = 3000 у avito/cian (было 1500 в 179_) — один anchor на город должен +-- покрыть город целиком; сама 179_ предупреждала, что 1500м мало для городов +-- крупнее одного круга. yandex — 25000 как есть (gate-API город скоупит city_rgid, +-- lat/lon/radius_m игнорирует целиком, см. run_yandex_city_sweep docstring — +-- radius_m там мёртвый default). +-- 2. detail_top_n = 0 у avito (было 20 в 179_) — Avito detail-страницы сейчас отдают +-- HTTP 439 firewall независимо от IP (issue #2827). Обречённые detail-запросы на +-- 23 подтверждённых города только приблизят бан общего прокси-пула зря — не тратим +-- их, пока #2827 не починен. cian detail_top_n = 10 — оставлен как в 179_. +-- 3. interval_days = 3 у всех трёх источников — тот же такт, на который migration 206_ +-- перевела wave-1 15 job'ов после замера (daily избыточен, независимая проверка по +-- listings_snapshots показала ~0.02-0.15%/сутки волатильности цены). +-- +-- window_start_hour/window_end_hour (UTC, 1-часовые окна): 24 часа в сутках, 102 новые +-- строки — полная уникальность окна на строку математически невозможна для cian/yandex +-- (40 и 39 > 16-18 свободных часов), возможна для avito (23 <= 18). Тот же round-robin +-- scheme, что в первой версии файла (координаты НЕ пересчитывались — просто отфильтрован +-- набор строк по подтверждённым идентификаторам, часы у оставшихся ГОРОДОВ не менялись): +-- окна исключают ПОЛНОСТЬЮ (а) EKB-окна (avito 6-7, cian 2-5, yandex 16-17) и (б) окна +-- wave-1 179_ (avito {0,1,5,7,8}, cian {9,10,11,12,13}, yandex {14,15,17,18,19}); внутри +-- оставшихся свободных часов round-robin по городам в исходном 41-городском TSV-порядке +-- (novouralsk..bisert, bisert выброшен целиком), затем строка эмитится, только если +-- источник подтверждён для этого города. Итоговый максимум коллизий ОДНОГО источника в +-- одном часе: avito <= 2, cian <= 3, yandex <= 3 (ниже, чем было бы при полных 41 — +-- меньше строк на источник). Разные провайдеры МОГУТ делить час — не ограничивалось (см. +-- 179_/206_ — proxy-pool уже не единственный узел). +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 179_ (wave 1, +-- CITY_ANCHORS-механизм и wildcard resolve_handler — не переопределяются здесь). +-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален +-- по построению (40 городов × подтверждённые источники, ни один не пересекается с +-- wave-1 5 городами). + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +-- ── avito_city_sweep_ — ТОЛЬКО 23 города с подтверждённым avito_slug +-- (radius_m 3000, detail_top_n 0 — issue #2827, enrich_houses true, +-- pages_per_anchor 3, request_delay_sec 7, interval_days 3) ────────────── +( + 'avito_city_sweep_novouralsk', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'avito_city_sweep_asbest', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'avito_city_sweep_bogdanovich', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'avito_city_sweep_irbit', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'avito_city_sweep_krasnoufimsk', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'avito_city_sweep_krasnoturinsk', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'avito_city_sweep_severouralsk', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'avito_city_sweep_ivdel', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'avito_city_sweep_tavda', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'avito_city_sweep_turinsk', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'avito_city_sweep_sysert', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'avito_city_sweep_verkhnyaya_salda', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'avito_city_sweep_nizhnyaya_salda', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'avito_city_sweep_nevyansk', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'avito_city_sweep_alapaevsk', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'avito_city_sweep_krasnouralsk', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'avito_city_sweep_verkhniy_tagil', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'avito_city_sweep_rezh', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'avito_city_sweep_aramil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'avito_city_sweep_volchansk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'avito_city_sweep_verkhnyaya_tura', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'avito_city_sweep_talitsa', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'avito_city_sweep_novaya_lyalya', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +), +-- ── cian_city_sweep_ — ВСЕ 40 городов (cian_id подтверждён у всех) +-- (radius_m 3000, detail_top_n 10, enrich_houses true, pages_per_anchor 3, +-- request_delay_sec 5, interval_days 3) ───────────────────────────────── +( + 'cian_city_sweep_novouralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'cian_city_sweep_revda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "revda"}'::jsonb +), +( + 'cian_city_sweep_polevskoy', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "polevskoy"}'::jsonb +), +( + 'cian_city_sweep_asbest', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'cian_city_sweep_bogdanovich', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'cian_city_sweep_irbit', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'cian_city_sweep_krasnoufimsk', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'cian_city_sweep_berezovskiy', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "berezovskiy"}'::jsonb +), +( + 'cian_city_sweep_zarechny', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "zarechny"}'::jsonb +), +( + 'cian_city_sweep_kachkanar', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kachkanar"}'::jsonb +), +( + 'cian_city_sweep_krasnoturinsk', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'cian_city_sweep_severouralsk', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'cian_city_sweep_ivdel', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'cian_city_sweep_tavda', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'cian_city_sweep_turinsk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'cian_city_sweep_sysert', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'cian_city_sweep_sredneuralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sredneuralsk"}'::jsonb +), +( + 'cian_city_sweep_degtyarsk', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "degtyarsk"}'::jsonb +), +( + 'cian_city_sweep_verkhnyaya_salda', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'cian_city_sweep_nizhnyaya_salda', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'cian_city_sweep_nevyansk', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'cian_city_sweep_artemovskiy', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "artemovskiy"}'::jsonb +), +( + 'cian_city_sweep_kamyshlov', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kamyshlov"}'::jsonb +), +( + 'cian_city_sweep_alapaevsk', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'cian_city_sweep_sukhoy_log', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sukhoy_log"}'::jsonb +), +( + 'cian_city_sweep_kushva', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kushva"}'::jsonb +), +( + 'cian_city_sweep_krasnouralsk', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'cian_city_sweep_karpinsk', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "karpinsk"}'::jsonb +), +( + 'cian_city_sweep_nizhnyaya_tura', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb +), +( + 'cian_city_sweep_verkhniy_tagil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'cian_city_sweep_nizhnie_sergi', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb +), +( + 'cian_city_sweep_lesnoy', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "lesnoy"}'::jsonb +), +( + 'cian_city_sweep_rezh', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'cian_city_sweep_aramil', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'cian_city_sweep_volchansk', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'cian_city_sweep_verkhnyaya_tura', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'cian_city_sweep_mikhaylovsk', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "mikhaylovsk"}'::jsonb +), +( + 'cian_city_sweep_verkhoturye', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhoturye"}'::jsonb +), +( + 'cian_city_sweep_talitsa', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'cian_city_sweep_novaya_lyalya', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +), +-- ── yandex_city_sweep_ — 39 городов (ВСЕ, КРОМЕ mikhaylovsk — города +-- нет в гео-базе Яндекса вообще) (radius_m 25000, pages_per_anchor 3, +-- request_delay_sec 9, interval_days 3) ─────────────────────────────── +( + 'yandex_city_sweep_novouralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'yandex_city_sweep_revda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "revda"}'::jsonb +), +( + 'yandex_city_sweep_polevskoy', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "polevskoy"}'::jsonb +), +( + 'yandex_city_sweep_asbest', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'yandex_city_sweep_bogdanovich', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'yandex_city_sweep_irbit', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'yandex_city_sweep_krasnoufimsk', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'yandex_city_sweep_berezovskiy', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "berezovskiy"}'::jsonb +), +( + 'yandex_city_sweep_zarechny', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "zarechny"}'::jsonb +), +( + 'yandex_city_sweep_kachkanar', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kachkanar"}'::jsonb +), +( + 'yandex_city_sweep_krasnoturinsk', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'yandex_city_sweep_severouralsk', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'yandex_city_sweep_ivdel', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'yandex_city_sweep_tavda', + false, + 13, + 14, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'yandex_city_sweep_turinsk', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'yandex_city_sweep_sysert', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'yandex_city_sweep_sredneuralsk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sredneuralsk"}'::jsonb +), +( + 'yandex_city_sweep_degtyarsk', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "degtyarsk"}'::jsonb +), +( + 'yandex_city_sweep_verkhnyaya_salda', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'yandex_city_sweep_nizhnyaya_salda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'yandex_city_sweep_nevyansk', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'yandex_city_sweep_artemovskiy', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "artemovskiy"}'::jsonb +), +( + 'yandex_city_sweep_kamyshlov', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kamyshlov"}'::jsonb +), +( + 'yandex_city_sweep_alapaevsk', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'yandex_city_sweep_sukhoy_log', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sukhoy_log"}'::jsonb +), +( + 'yandex_city_sweep_kushva', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kushva"}'::jsonb +), +( + 'yandex_city_sweep_krasnouralsk', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'yandex_city_sweep_karpinsk', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "karpinsk"}'::jsonb +), +( + 'yandex_city_sweep_nizhnyaya_tura', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb +), +( + 'yandex_city_sweep_verkhniy_tagil', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'yandex_city_sweep_nizhnie_sergi', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb +), +( + 'yandex_city_sweep_lesnoy', + false, + 13, + 14, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "lesnoy"}'::jsonb +), +( + 'yandex_city_sweep_rezh', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'yandex_city_sweep_aramil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'yandex_city_sweep_volchansk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'yandex_city_sweep_verkhnyaya_tura', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'yandex_city_sweep_verkhoturye', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhoturye"}'::jsonb +), +( + 'yandex_city_sweep_talitsa', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'yandex_city_sweep_novaya_lyalya', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (заменяет cron-script setup). Источники перечислены в ' + 'tests/test_scraper_kit_scheduler_parity.py::_PRODUCT_SOURCES и в сид-миграциях ' + 'data/sql/*scrape_schedules*seed*.sql. Последний добавленный: 102 wave-2 oblast ' + 'city-sweep source''ы (40 городов, только подтверждённые provider-id: ' + 'cian x40 / yandex x39 (без mikhaylovsk) / avito x23, #262 — все enabled=false, ' + 'defensive ValueError guard в pipeline.py против молчаливого ЕКБ-fallback).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql b/tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql new file mode 100644 index 00000000..5f1078ed --- /dev/null +++ b/tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql @@ -0,0 +1,52 @@ +-- 263_scrape_schedules_wave2_cian_newbuilding_only_false.sql +-- Дописывает "newbuilding_only": false в default_params 40 cian-строк wave 2 (262_). +-- +-- ПОЧЕМУ. Прогон первого включённого города области показал, что sweep отрабатывает +-- «успешно», но не сохраняет НИЧЕГО: +-- +-- cian-sweep run_id=3884 anchor Новоуральск центр: +-- SERP fetched=84 nb_kept=0 dropped_secondary=84 ins=0 upd=0 +-- cian-sweep run_id=3884 done: anchors=1/1 lots=84 (ins=0/upd=0) ... errors=0 +-- +-- 84 лота найдено и все 84 отброшено как вторичка, статус прогона при этом done. +-- +-- Причина: scraper_kit.orchestration.scheduler (_job_cian_city_sweep) читает +-- newbuilding_only=bool(params.get("newbuilding_only", True)) +-- то есть дефолт — True. Сид 179_ (wave 1) ключ проставляет явно (false), а 262_ +-- (wave 2) его потерял. Мера оценивает ВТОРИЧКУ — estimator отбирает аналоги с +-- (listing_segment IS NULL OR listing_segment = 'vtorichka'), — поэтому режим +-- «только новостройки» для этих строк бессмыслен: сбор идёт, данные выбрасываются. +-- +-- ЗАТРАГИВАЕТ ТОЛЬКО cian. У avito/yandex такого параметра нет ни в 179_, ни в 262_ +-- (проверено сравнением default_params wave-1 и wave-2 на проде) — их не трогаем. +-- +-- ПОБОЧНАЯ НАХОДКА: под гейт попадает 41 строка, а не 40. Лишняя — +-- `cian_city_sweep_verkhnyaya_pyshma` из wave 1, ВКЛЮЧЁННАЯ и работающая в проде: +-- 179_ проставил newbuilding_only не всем своим городам. Последствия на живых данных: +-- +-- Верхняя Пышма (ключа нет): cian 184 активных → вторички 3, новостроек 181 +-- Первоуральск (ключ есть): cian 336 активных → вторички 308 +-- +-- То есть по Верхней Пышме Циан давал оценщику 3 пригодных объявления вместо ~300 — +-- сбор шёл, статус зелёный, данные молча выбрасывались. Эта миграция чинит и её. + + +-- +-- Идемпотентность: WHERE-гейт `NOT (default_params ? 'newbuilding_only')` — миграция +-- дописывает ключ только там, где его нет. Повторный прогон — no-op, и она никогда +-- не перезатрёт значение, выставленное позже вручную оператором. +-- +-- ЗАВИСИМОСТИ: 262_ (сами строки), 052_scrape_schedules.sql (таблица). + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +UPDATE scrape_schedules +SET default_params = default_params || '{"newbuilding_only": false}'::jsonb, + updated_at = NOW() +WHERE source LIKE 'cian\_city\_sweep\_%' + AND default_params ? 'city' + AND NOT (default_params ? 'newbuilding_only'); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql b/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql new file mode 100644 index 00000000..fc0a1273 --- /dev/null +++ b/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql @@ -0,0 +1,83 @@ +-- 264_deactivate_stale_avito_cap_mult.sql +-- Калибрует потолок эффективного TTL (cap_mult) для avito (#TTL-CAP, 2026-08-15). +-- +-- ЗАЧЕМ. Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py) +-- поднимает эффективный TTL через max(ttl_days, пол) без верхней границы -- на проде +-- это оказалось петлёй с положительной обратной связью: медленный обход поднимает +-- пол, высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает +-- вернуться свежий обход, пул «активных» раздувается протухшими строками. ВАЖНАЯ +-- ОГОВОРКА (перепроверено 2026-08-15): цифра «23 687 из 44 744» -- это ВСЕ источники +-- вместе, и две трети её -- новостройки, которые оценщик не берёт вообще. У самого +-- avito просроченных строк НОЛЬ (8 663 активных, максимальный возраст 10 суток) -- +-- его деактивация работает исправно. Этот потолок существует не ради сжатия пула +-- (он деактивирует 0 строк, замерено), а как защита от опечатки в расписании и от +-- будущего разгона пола. Потолок cap_mult ограничивает пол сверху: эффективный TTL не +-- может превысить ttl_days * cap_mult (код -- app/tasks/deactivate_stale_avito.py, +-- CAP_MULT). +-- +-- ПОЧЕМУ ИМЕННО AVITO. Дефолт CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных +-- источниках (множитель от ttl_days), и ломается там, где хвост переобхода +-- источника НЕ пропорционален его ttl_days. Таблица ниже -- ЖИВЫЕ полы из +-- scrape_runs.counters (ttl_days_effective/revisit_floor_days по каждой job'е за +-- 2026-08-10..08-15, ПЕРЕСЧИТАНО ревью круга 3 2026-08-15 -- прежняя версия таблицы +-- брала статический p99 из _REVISIT_TAIL (40-суточный замер на более раннюю дату) +-- и по нему ошибочно утверждала «yandex 43.0 -> потолок 60, запас есть»; live-полы +-- показывают обратное, см. ниже), а не по статической константе: +-- источник/сегмент живой пол (6 прогонов) ttl_days потолок cap_mult=2 +-- domklik vtorichka 23/24/25/skip/skip/skip 14 28 (запас есть) +-- cian vtorichka 34/34/37/27/27/32 30 60 (запас есть) +-- yandex vtorichka 75/75/75/39/52/54 30 60 (ХВОСТ ВЫШЕ) +-- avito все сегменты 52/52/52/7/8/9 10 20 (ХВОСТ ВЫШЕ) +-- У avito p99=42.1 суток (_REVISIT_TAIL) и живой пик 52 -- ВЫШЕ его же дефолтного +-- потолка 20: дефолтный cap_mult=2 может резать пол ниже собственного хвоста +-- обхода, то есть ровно тот false-kill, ради которого пол вообще заведён. +-- +-- YANDEX -- ТА ЖЕ ДЫРА, что и у avito, но найдена ПОЗЖЕ (при первой версии этой +-- миграции статический p99=43.0 ошибочно считался достаточным запасом). Живой пол +-- yandex/vtorichka держится 39-75 суток шесть прогонов подряд, а прямой live-замер +-- 2026-08-15 (та же percentile_disc(0.99)-формула, что и в проде) даёт 79.2 суток +-- (n=1961 подтверждений за 3 суток) -- выше потолка 60 при дефолтном cap_mult=2. +-- Калибровка yandex вынесена в ОТДЕЛЬНУЮ миграцию +-- (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90), не сюда -- +-- эта миграция специфична для avito по имени и назначению, смешивать источники в +-- одном файле хуже для git-истории калибровок. cian и domklik разрыва не имеют, +-- дефолт cap_mult=2 для них по-прежнему калиброван верно, эта миграция их не трогает. +-- +-- ЧИСЛЕННЫЙ ЭФФЕКТ (обе миграции, 264+265, live-замер 2026-08-15): на пул активных +-- строк не влияет ни у одного из четырёх источников -- next-run deactivated=0 что до, +-- что после калибровки. У avito и cian живой пол (12/32 суток) уже ниже потолка -- +-- калибровка cap_mult просто не участвует в min(). У yandex 0 активных строк старше +-- 39 суток вообще (весь "просроченный" хвост младше того возраста, где потолок +-- 60 vs 90 может разойтись), поэтому даже БЕЗ калибровки (дефолт cap_mult=2, +-- потолок 60 < живой пол 79.2) next-run deactivated тоже 0 -- калибровка убирает +-- будущий риск (потолок бы капал ttl_days_effective 79->60 в counters и резал бы +-- ниже собственного хвоста обхода, как только появятся строки в возрастной полосе +-- 60-90 суток), а не текущее число. domklik заблокирован гейтом здоровья +-- (confirmations 94 < min_confirmations 200) -- до потолка/пола дело не доходит. +-- +-- ПОЧЕМУ 6. Потолок 60 = 10 * 6 -- тот же порядок, что у cian (60, дефолт cap_mult=2), +-- с запасом выше и статического p99=42.1 (_REVISIT_TAIL, tests/test_deactivate_stale_revisit_floor.py), +-- и живого прод-пика: floor=52 три прогона подряд 2026-08-10..08-12 +-- (scrape_runs.counters, status=done, confirmations 6934..7138, гейт здоровья +-- пропустил). Без этой калибровки в проде остаётся дефолт cap_mult=2 (потолок 20) +-- -- именно тот случай, для которого потолок и его собственная калибровочная ручка +-- заведены, но не применены к единственному источнику, ради которого ручка сделана. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 219 (тот же +-- приём -- UPDATE default_params через jsonb ?, min_confirmations). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет. +-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где +-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором +-- значение. Снять/поднять потолок вручную: cap_mult в default_params +-- (deactivate_stale_avito), 1 -> потолок = сам ttl_days (см. guard cap_mult < 1 +-- в deactivate_stale_listings -- ниже 1 отклоняется до любого SQL). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('cap_mult', 6), + updated_at = NOW() +WHERE source = 'deactivate_stale_avito' + AND NOT default_params ? 'cap_mult'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/265_deactivate_stale_yandex_cap_mult.sql b/tradein-mvp/backend/data/sql/265_deactivate_stale_yandex_cap_mult.sql new file mode 100644 index 00000000..7fa1acb6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/265_deactivate_stale_yandex_cap_mult.sql @@ -0,0 +1,57 @@ +-- 265_deactivate_stale_yandex_cap_mult.sql +-- Калибрует потолок эффективного TTL (cap_mult) для yandex (#TTL-CAP круг 3, 2026-08-15). +-- +-- ЗАЧЕМ. Та же дыра, что закрыта для avito миграцией +-- 264_deactivate_stale_avito_cap_mult.sql (см. её комментарий про механизм петли), +-- но обнаружена на yandex позже: первая версия 264 утверждала, что дефолтный +-- CAP_MULT=2 (потолок 60 при ttl_days=30) для yandex "калиброван верно" на +-- основании статического p99=43.0 (_REVISIT_TAIL, замер на более раннюю дату). +-- +-- ЖИВОЙ ЗАМЕР, из-за которого миграция существует. scrape_runs.counters +-- (deactivate_stale_yandex, 2026-08-10..08-15) держал ttl_days_effective 75/75/75/ +-- 39/52/54 шесть прогонов подряд при deactivated=0 -- то есть пол ВСЕ ЭТИ ДНИ был +-- выше потолка 60. Прямой live-замер той же percentile_disc(0.99)-формулы, что и в +-- коде (app/tasks/deactivate_stale_avito.py, _build_revisit_floor_sql), 2026-08-15 +-- даёт 79.2 суток (n=1961 подтверждений за окно 3 суток). Оба замера выше потолка +-- 60 -- ровно тот false-kill, ради которого пол #2659 вообще заведён: без калибровки +-- потолок капал бы ttl_days_effective yandex до 60 в counters уже сегодня и резал бы +-- ниже собственного хвоста обхода, как только в пуле появятся строки возрастом +-- 60-90 суток (сейчас таких 0 -- см. ЧИСЛЕННЫЙ ЭФФЕКТ ниже). +-- +-- ПОЧЕМУ 3. Потолок 90 = 30 * 3 -- запас ~14% над живым пиком 79.2, той же +-- пропорции, что и у avito (потолок 60 против пика 52 -- запас ~15%, см. 264). +-- Меньший cap_mult=2 (потолок 60) уже сейчас ниже пика 79.2. Больший cap_mult +-- намеренно не берём -- дальнейший рост пола означает не "медленный, но живой +-- обход", а кандидата в mёртвый источник, для которого есть отдельный гейт +-- здоровья (min_confirmations), а не растягивание потолка до бесконечности (см. +-- комментарий у CAP_MULT в deactivate_stale_avito.py). +-- +-- ЧИСЛЕННЫЙ ЭФФЕКТ (live-замер 2026-08-15): 0 активных строк yandex/vtorichka +-- старше 39 суток вообще (запрос: count(*) FROM listings WHERE source='yandex' AND +-- listing_segment='vtorichka' AND is_active=true AND last_seen_at < NOW() - +-- INTERVAL 'N days', N=39/52/54/60/75/79 -- везде 0). Next-run deactivated=0 что +-- при дефолтном cap_mult=2 (потолок 60, капает пол), что при cap_mult=3 из этой +-- миграции (потолок 90, не капает) -- эта миграция убирает БУДУЩИЙ риск +-- false-kill при появлении строк в полосе 60-90 суток, а не текущее число +-- деактиваций. Ветка #TTL-CAP не сжимает пул ни у одного из четырёх источников -- +-- см. 264 для остальных трёх. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 219 (тот же +-- приём -- UPDATE default_params через jsonb ?, min_confirmations), 264 (тот же +-- приём для avito, cap_mult -- параметр deactivate_stale_listings). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет. +-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где +-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором +-- значение. Снять/поднять потолок вручную: cap_mult в default_params +-- (deactivate_stale_yandex), 1 -> потолок = сам ttl_days (см. guard cap_mult < 1 +-- в deactivate_stale_listings -- ниже 1 отклоняется до любого SQL). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('cap_mult', 3), + updated_at = NOW() +WHERE source = 'deactivate_stale_yandex' + AND NOT default_params ? 'cap_mult'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/266_seed_deactivate_stale_null_segment_yandex_cian.sql b/tradein-mvp/backend/data/sql/266_seed_deactivate_stale_null_segment_yandex_cian.sql new file mode 100644 index 00000000..63851298 --- /dev/null +++ b/tradein-mvp/backend/data/sql/266_seed_deactivate_stale_null_segment_yandex_cian.sql @@ -0,0 +1,109 @@ +-- 266_seed_deactivate_stale_null_segment_yandex_cian.sql +-- Деактивация протухших yandex/cian объявлений с ПУСТЫМ listing_segment. +-- +-- Замер на проде 2026-08-15 (is_active=true, listing_segment IS NULL): +-- source | активных | старше 30 сут | макс возраст +-- yandex | 544 | 533 | 86.3 сут +-- cian | 224 | 211 | 86.3 сут +-- 97% / 94% этих строк протухли, вплоть до 86 суток. При этом estimator их +-- ИСПОЛЬЗУЕТ как comps без freshness-фильтра (Tier A "тот же дом" / Tier C +-- micro-radius в app/services/estimator.py фильтруют только is_active=true, +-- без scraped_at-фильтра свежести — в отличие от Tier S/H, у которых он есть). +-- +-- ПОЧЕМУ NULL, А НЕ ANY(:segments). deactivate_stale_yandex / deactivate_stale_cian +-- (миграция 115) уже деактивируют segments=['vtorichka'] — пустой сегмент они НЕ видят: +-- `listing_segment = ANY(CAST(:segments AS text[]))` в SQL никогда не матчит NULL +-- (задокументировано в 115 у novostroyki-гарда). Нужен отдельный явный предикат +-- IS NULL — app/tasks/deactivate_stale_avito.py получил kwarg null_segment_only=True, +-- строящий `... AND listing_segment IS NULL` вместо ANY(:segments). +-- +-- ПОЧЕМУ ОТДЕЛЬНАЯ ДЖОБА, А НЕ РАСШИРЕНИЕ deactivate_stale_yandex/_cian. Гейт +-- здоровья сбора (#2659, migration 219) и пол переобхода (#2659) откалиброваны под +-- полноценный vtorichka-свип (сотни-тысячи подтверждений в сутки, см. 219). У +-- NULL-сегмента подтверждений на 2-3 порядка меньше (замер того же дня: 9 cian + +-- 4 yandex строк с last_seen_at < 7 суток) — с общим min_confirmations джоба +-- вечно давала бы skipped_unhealthy и никогда не деактивировала бы ни строки. +-- Отдельная джоба с собственными (выключенными) порогами не трогает работающие +-- deactivate_stale_yandex/_cian и их пол/гейт. +-- +-- НЕ ЗАТРАГИВАЕТ novostroyki: null_segment_only-предикат — строго `IS NULL`, ни +-- 'novostroyki', ни 'vtorichka' в него не попадают ни при каких условиях (в отличие +-- от паушального TTL по всему source, который снёс бы все ~22,5к первичных строк). +-- +-- TTL=60 суток — консервативный, обоснование числом: +-- Строки этого среза по определению не переобходятся систематически (иначе у них +-- был бы сегмент — свежий обход cian/yandex SERP всегда вычисляет listing_segment +-- детерминированно, см. providers/cian/serp.py:955-958, providers/yandex/serp.py:177). +-- Значит «пол переобхода» (revisit_floor, #2659) здесь измерять нечем: он квантиль +-- разрывов НАБЛЮДАЕМОГО повторного обхода, а для строки вне скоупа обхода такого +-- ряда нет — вычислять его было бы фикцией. Поэтому revisit_floor_quantile=0 явно +-- (выключен), а весь запас закладываем в сам TTL: +-- deactivate_stale_avito.py документирует измеренные p99 разрывов переобхода +-- vtorichka (тот же тип строк, тот же source, разница только в сегменте): +-- cian/vtorichka p99 = 26.6 сут +-- yandex/vtorichka p99 = 43.0 сут +-- TTL=60 даёт запас 2.26x над cian p99 и 1.4x над yandex p99 — комфортный отступ +-- без специального замера под null-сегмент (население слишком мало для устойчивого +-- перцентиля). При этом бимодальность выборки (замер 2026-08-15: gt30d/gt45d/gt60d +-- почти не меняются — 211/211/211 cian, 533/525/523 yandex) означает, что более +-- консервативный TTL стоит ПОЧТИ НИЧЕГО в охвате: первый прогон снимет 734 из 768 +-- строк (95.6%) вместо 744 при TTL=30 — разница 10 строк, зато вдвое больший +-- защитный запас над измеренным хвостом обхода. +-- +-- min_confirmations=0, revisit_floor_quantile=0 — оба гейта ВЫКЛЮЧЕНЫ явно (не через +-- умолчание product_handlers.py, которое иначе подставило бы DEFAULT_MIN_CONFIRMATIONS +-- = 500 и DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 — оба откалиброваны под другую шкалу +-- популяции и держали бы эту джобу в вечном skipped_unhealthy, см. выше). +-- +-- Schedule window 07:00-08:00 UTC — тот же слот, что и deactivate_stale_yandex/_cian +-- (migration 115) и deactivate_stale_domklik/_n1 (migration 160): после ночных sweep'ов +-- (02:00-05:00 UTC), так что реально переобойдённые строки не деактивируются. +-- +-- next_run_at bootstrapped на завтра 07:00 UTC — тот же паттерн, что 090/115/160, +-- чтобы не сработать сразу на деплое. +-- +-- Идемпотентно: ON CONFLICT (source) DO NOTHING — безопасно при повторном применении. +-- +-- Dependencies: +-- 052_scrape_schedules.sql (таблица + UNIQUE(source)). +-- listings.listing_segment (011_listings_alter.sql). +-- 115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql (соседние джобы, тот же слот). +-- app/tasks/deactivate_stale_avito.py — null_segment_only kwarg. +-- app/services/product_handlers.py — _job_deactivate_stale читает null_segment_only +-- из default_params и пробрасывает в deactivate_stale_listings. +-- +-- Deploy order: применять ПОСЛЕ деплоя backend-кода (null_segment_only kwarg), иначе +-- первый прогон свалится с TypeError на неизвестный параметр default_params. + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'deactivate_stale_yandex_null_segment', + true, -- SAFE: pure internal DB UPDATE, no ext calls + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"listing_source":"yandex","ttl_days":60,"null_segment_only":true,' + '"min_confirmations":0,"revisit_floor_quantile":0}'::jsonb +), +( + 'deactivate_stale_cian_null_segment', + true, -- SAFE: pure internal DB UPDATE, no ext calls + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"listing_source":"cian","ttl_days":60,"null_segment_only":true,' + '"min_confirmations":0,"revisit_floor_quantile":0}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index bbb3f066..276864a0 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -241,3 +241,17 @@ 225_listing_source_snapshots_run_id_idx.sql 233_payments.sql 234_scrape_runs_ban_kind_unknown.sql +240_trade_in_estimates_retain_until.sql +250_drop_duplicate_expires_at_index.sql +251_listings_drop_ceiling_height.sql +254_listings_backfill_avito_rating_glued_address.sql +257_listings_backfill_yandex_source_url.sql +258_houses_imv_transient_attempts.sql +259_data_quality_drop_pct_cadastr.sql +260_houses_drop_has_panorama.sql +261_listings_search_mv_drop_placeholder_columns.sql +262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql +263_scrape_schedules_wave2_cian_newbuilding_only_false.sql +264_deactivate_stale_avito_cap_mult.sql +265_deactivate_stale_yandex_cap_mult.sql +266_seed_deactivate_stale_null_segment_yandex_cian.sql diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index a040e084..b11e827e 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -8,7 +8,7 @@ }, "low": { "coverage_pct": 82.09, - "mape_pct": 13.2, + "mape_pct": 12.67, "n": 276, "n_covered": 220 }, @@ -26,22 +26,22 @@ ], "expected_sold": { "overall": { - "mape_pct": 13.18, - "median_bias_pct": -3.71, + "mape_pct": 12.63, + "median_bias_pct": -3.74, "n": 269, "n_no_analogs": 0, - "p25_pct": -16.11, - "p75_pct": 8.92 + "p25_pct": -15.17, + "p75_pct": 8.67 }, "per_rooms": { "0": { "label": "студия", - "mape_pct": 19.38, - "median_bias_pct": 18.1, + "mape_pct": 16.96, + "median_bias_pct": 16.96, "n": 35, "n_no_analogs": 0, "p25_pct": 1.5, - "p75_pct": 34.32 + "p75_pct": 38.82 }, "1": { "label": "1к", @@ -50,50 +50,50 @@ "n": 93, "n_no_analogs": 0, "p25_pct": -14.43, - "p75_pct": 6.98 + "p75_pct": 8.15 }, "2": { "label": "2к", - "mape_pct": 18.26, - "median_bias_pct": -12.2, + "mape_pct": 17.39, + "median_bias_pct": -11.71, "n": 74, "n_no_analogs": 0, - "p25_pct": -24.35, + "p25_pct": -23.07, "p75_pct": -0.36 }, "3": { "label": "3к", - "mape_pct": 9.34, - "median_bias_pct": -3.08, + "mape_pct": 7.79, + "median_bias_pct": -4.05, "n": 43, "n_no_analogs": 0, "p25_pct": -10.26, - "p75_pct": 4.68 + "p75_pct": 3.82 }, "4": { "label": "4+", - "mape_pct": 16.3, - "median_bias_pct": 3.34, + "mape_pct": 16.38, + "median_bias_pct": -1.32, "n": 24, "n_no_analogs": 0, "p25_pct": -14.91, - "p75_pct": 15.11 + "p75_pct": 15.41 } }, "per_segment": { "бизнес": { - "mape_pct": 14.65, + "mape_pct": 13.65, "median_bias_pct": -10.54, "n": 46, - "p25_pct": -22.93, + "p25_pct": -27.15, "p75_pct": -1.31 }, "комфорт": { - "mape_pct": 11.8, - "median_bias_pct": -4.61, + "mape_pct": 10.14, + "median_bias_pct": -5.01, "n": 101, - "p25_pct": -17.07, - "p75_pct": 6.04 + "p25_pct": -15.49, + "p75_pct": 4.37 }, "премиум": { "mape_pct": 68.92, @@ -103,11 +103,11 @@ "p75_pct": -68.92 }, "эконом": { - "mape_pct": 13.71, - "median_bias_pct": 2.54, + "mape_pct": 14.2, + "median_bias_pct": 3.33, "n": 115, - "p25_pct": -9.8, - "p75_pct": 25.5 + "p25_pct": -8.69, + "p75_pct": 27.3 }, "элит": { "mape_pct": 33.2, diff --git a/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html new file mode 100644 index 00000000..3fd69b35 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html @@ -0,0 +1 @@ +Ошибка - ЦианISIDPlhwqgqcowpM8uZOWWzawmWX3JbKNOWV5y8jbBk1QTMxXSbNTCYzv2eY56%2BzgvWctsm1nNNBInjiTIE4wTKyIXqNmtYlOzmhaTK60Tn8LgFw465ooSTZRlZbWNAVRqsSkSbwX3cLXr%2BXama%2BvH5PpteyqjVoMEnZZ9fy7U9Mq6lNhNqPwTg6YASdghUaijPGTLJGRj1xv2zyp3nGfDDlmBEufGFsQSmWpaquZAFvWbMSIVagrgmWV%2FpwZY%2B05W7zk%2B48X94xHfjeo42qibiytt3w1NTImtWX6RAUPZOEWRMJX%2FDAhDnp6PpcNPWJMlIzO54LIEgTLTbPMeT8Zcz8bbz8bbz8bbz8bcxqGIlysW6SCSEHnI3OmWtyI1qNpU%2BaVl4fL4yPXGUHMlzpFO8g7x3nL5nBgMCqbNNM489eVZ8CQV07DTtFVFNVZlZbprTPdHkXK8dpDGUHO2YoUriDzZ%2FPcifCVNlgCap0b6RzwJbvsziCR09wO2bk%2B2Fh0BtH1djPV2M9XYz1djPV2MtsmyQM5f7xfaMjZdAbCdhzBtLjB7V9hsvkNjwjKUT8C9Zw3rBwyJ6wsNWNoze1g2nkHtUNoereRt7INYvs9XYwnf%2FAM6pU5R2c0UDE8gAHwP%2FAIr%2FxAA4EQABAwEFBAgFAgcBAAAAAAABAAIRECExQVGRAxIgYQQiUHGBobHRMDKSwdJA8BMjM0JgguHx%2F9oACAEDAQk%2FAPgbQSMJtW01sTgRy7OMNF5z5BWoJxgYK%2FEdmXus1oN5y2e6cx%2F2aXO7MuF6vCvx76f22I%2FK5vjJ7MxCwVrcimhs440F8Hs0dceYqLMEI7OHeEOqELB2g2Ozf%2FV8hN2QlGQUBC3UIPDu7qIgRhwRu4LdjQ13Ykwt0jzrgmhrcAb%2FABujuW7GivoC52QtPeYuHNbuyH1fiulR3NP5KH8gI%2B5Tf4buZ9wKxHNbqvqBGMrdKEOx4jACsYLh9zQ9QrIff4vOuayqOoNZ9qZ0vRlxvKEqE2EIcLjzXzM%2BDnw3L%2BmDfmeAzFnxedc1lQwSChAGFM6P3T3LbDQo2Zoq0H1WC%2BY%2FBz4f9jlyq0gH4%2FOuayV6Mk%2FuPBCwIRLQVnQwBem7ozdf4DEeK6S7ws9Cukv%2Bo%2B66Q8xhvO96Z1dDsDetsNFtm6LbN0Tt5wvMRTPgM7S7uqIi6ULCjIFLyhB4mzPC2RbwDqAWnxQIGHMZoWBXDzRuaAhNqvRkNN2EhCSiGhbb96LbQB97BhmihZQyBI0KEpiYmIQUEIqf5jssOavNBZhKuoIeL7L62u9RwCQaGokGhoJFLWnDgzKNGx3Zmhmgkug2YQZBPIkRQ2UAA5c6PKedU86oyeC%2BLFa5xv8Aag6oVgHBdiOeYpejaQOHaBhzIn1BXSR9I%2FFdJH0j8V0mQCLA0W%2BQptA05kT6grpE8g0ew4L65mtxV62IIzk%2ByY1nMkzpulHecbzn7dws4OXHlUWLrNOcSPRCLOESCpJm0EjyuQ63C2R3hM9EzzCZ5hM8wmeiEFBYpkjvCZ5j3QgCo3gbcL1s9SPdC1XH9yjvDIkSPRMKYVbOATZaeYTIH21oJB7ls%2FT3Qg1E6LZye8e6EH9C4hPOpTzqU86lPOpTzqUZKyo4jxTifHgcQnnWpTynnWjiE4mjiE86ngMJ51P6hknkmGf8M%2F%2FZ" alt="logo">

Обнаружен подозрительный трафик

Просмотрите возможные причины ошибки по ссылке ниже. Если ошибка повторяется, напишите в службу поддержки или на почту support@cian.ru.

Укажите в письме эти данные:

  • IP-адрес: 46.8.110.92
  • ID запроса: 9f4b43ee-bd7e-4b91-acde-639dac97a366
  • Код страницы: cian_waf_block
" +) + + +@pytest.mark.asyncio +async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, caplog) -> None: + """Блок → `report_ban`, пока lease ещё жив. + + Без этого пул продолжает выдавать сожжённый узел, и следующий дом идёт с того же + IP — ровно то, что происходило 200 попыток подряд. + """ + fetcher = _spy_fetcher(waf_html) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"): + result = await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + assert result is None + fetcher.report_ban.assert_called_once() + assert "cian_waf_block" in fetcher.report_ban.call_args.args[0] + # Бан репортится ДО выхода из контекста — иначе lease уже отпущен и вызов no-op. + assert fetcher.__aexit__.await_count == 1 + assert "cian_waf_block" in "\n".join(r.getMessage() for r in caplog.records) + + +@pytest.mark.asyncio +async def test_parsed_page_never_bans_the_node_even_with_captcha_word(monkeypatch) -> None: + """Разобранная страница НЕ банит узел, даже если на ней написано «captcha». + + Живая регрессия 2026-08-09: первая версия этой правки звала `_blocked_by` на КАЖДОМ + ответе, и за первые 17 секунд прогона забанила два ЗДОРОВЫХ узла (9 и 10) на + страницах, которые успешно разобрались — обычная карточка ЖК грузит скрипт + SmartCaptcha, подстрока там есть всегда. Признак «есть подпись защиты» осмыслен + только вместе с «состояния нет»: список маркеров создавался объяснять УЖЕ + случившийся отказ разбора, а не служить самостоятельным детектором. + """ + fetcher = _spy_fetcher(_HEALTHY_CARD_WITH_CAPTCHA_SCRIPT) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + result = await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + # Предпосылка теста: страница действительно и разбирается, и содержит слово-ловушку. + assert result is not None + assert result.cian_internal_house_id == 108855 + assert "captcha" in _HEALTHY_CARD_WITH_CAPTCHA_SCRIPT + fetcher.report_ban.assert_not_called() + + +@pytest.mark.asyncio +async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) -> None: + """Провод целиком: provider из задачи → build_browser_fetcher → пул. + + Красный на старом коде дважды: аргумента `proxy_provider` там не было, и фабрика + для этого провайдера не вызывалась вовсе (BrowserFetcher конструировался вручную, + без пула). + """ + factory = MagicMock(return_value=_spy_fetcher(waf_html)) + monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_browser_fetcher", factory) + provider = object() + config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000") + + await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", config=config, proxy_provider=provider + ) + + factory.assert_called_once_with(config, "cian", proxy_provider=provider) + + +# ── новый фронт Циана: тот же ключ под другим именем MFE ────────────────────── + +# Форма живой страницы zhk-kosmos-ekb-i.cian.ru (2026-08-09): состояние лежит под +# 'newbuilding-card-desktop-fichering-frontend', СТАРОГО имени на странице нет вовсе. +_FICHERING_CARD = ( + "" +) + + +@pytest.mark.asyncio +async def test_state_is_found_under_the_renamed_fichering_mfe(monkeypatch) -> None: + """ЖК на новом фронте Циана разбирается — вместе с графиком цен. + + Именно из-за этого имени `houses_price_dynamics` стояла с 2026-07-26: страница + приходила целиком, состояние в ней было, но под другим MFE — и отказ выглядел как + «разметка изменилась», хотя форма состояния та же. + """ + fetcher = _spy_fetcher(_FICHERING_CARD) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + result = await fetch_newbuilding( + "https://zhk-kosmos-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + assert result is not None + assert result.cian_internal_house_id == 3235089 + assert result.name == "Космос" + # График — тот самый, ради которого задача и существует. + assert [p["month_date"] for p in result.realty_valuation_chart] == [ + "2026-06-01", + "2026-07-01", + ] + assert [p["price_per_sqm"] for p in result.realty_valuation_chart] == [4100000.0, 4250000.0] + fetcher.report_ban.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py index 3ca02ab6..fea61b82 100644 --- a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py +++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py @@ -53,15 +53,19 @@ def test_describe_parse_miss_reports_size_and_no_markers() -> None: def test_describe_parse_miss_names_found_markers() -> None: - """Заглушка: маркер назван, но диагноз не выносится — только перечисление найденного.""" + """Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного. + + Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть + на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после + того, как забанила здоровые узлы (#2767). + """ described = _describe_parse_miss("
Вы не робот?
") - assert "captcha" in described assert "вы не робот" in described assert "html_len=" in described def _spy_browser_fetcher(html: str) -> MagicMock: - """BrowserFetcher-подмена: async-CM, чей .fetch() отдаёт заданный html.""" + """Подмена фабрики фетчера: async-CM, чей .fetch() отдаёт заданный html.""" fetcher = MagicMock() fetcher.fetch = AsyncMock(return_value=html) fetcher.__aenter__ = AsyncMock(return_value=fetcher) @@ -71,17 +75,24 @@ def _spy_browser_fetcher(html: str) -> MagicMock: @pytest.mark.asyncio async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None: - """Отказ разбора обязан писать РАЗМЕР полученной страницы — то самое число, которое - разделяет «изменилась разметка» и «защитная заглушка» (иначе log бесполезен).""" + """Отказ разбора обязан писать РАЗМЕР полученной страницы. + + Размер один гипотезы уже НЕ разделяет (страница блока Циана весит 374 КБ — см. + test_2767_cian_waf_block.py), но остаётся обязательным фактом в строке отказа. + """ shell = "Доступ ограничен" # короткая заглушка monkeypatch.setattr( - "scraper_kit.providers.cian.newbuilding.BrowserFetcher", _spy_browser_fetcher(shell) + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + _spy_browser_fetcher(shell), ) with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"): result = await fetch_newbuilding( "https://zhk-tihiy-centr-ekb-i.cian.ru", - config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + config=SimpleNamespace( + browser_http_endpoint="http://tradein-browser:3000", + use_proxy_pool_browser=False, + ), ) assert result is None @@ -174,7 +185,7 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None: _stub_backfill( monkeypatch, NewbuildingEnrichBackfillResult( - processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_rows=7 + processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_inserted=7 ), ) calls = _stub_finalisers(monkeypatch) @@ -185,5 +196,5 @@ async def test_partial_success_keeps_rich_counters(monkeypatch) -> None: assert counters["attempted"] == 10 assert counters["enriched"] == 3 assert counters["failed"] == 7 - assert counters["price_dynamics_rows"] == 7 # исходные счётчики на месте + assert counters["price_dynamics_inserted"] == 7 # исходные счётчики на месте assert counters["succeeded"] == 3 diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py new file mode 100644 index 00000000..7fa59d45 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -0,0 +1,541 @@ +"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ. + +Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь — +верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный +ответ означал «узел годен для Авито», а читался как «узел годен вообще». + +Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация): + + узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru + -----+----------+-------+---------------------+--------------+--------------------------- + 1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY + 10 | any | 200 | 200 | 200 | 200 + +Что сторожится (каждый тест ниже падает на коде до фикса): + + 1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для + Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')` + выдавал его как ни в чём не бывало. + 2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен + (`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной). + 3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех. + 4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и + забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что + «дешёвая ipify стирает браузерный вердикт» в #2723). + 5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой + вердикт #2723 сохранён). + 6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс). + 7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +import scraper_kit.browser_fetcher as bf + +from app.services import proxy_pool +from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release +from tests.services.test_proxy_pool import FakeSession, _proxy + +# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным. +_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = { + ("http://u:p@h1:8080", "domclick"): ( + False, + "proxy", + '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', + ), +} + + +def _patch_probes( + monkeypatch: pytest.MonkeyPatch, + matrix: dict[tuple[str, str], tuple[bool, str | None, str]], + calls: list[tuple[str, str]] | None = None, + *, + default: tuple[bool, str | None, str] = (True, None, "html_len=16477"), +) -> None: + """ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник).""" + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _fake_browser( + endpoint: str, proxy_url: str, **kw: Any + ) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + if calls is not None: + calls.append((proxy_url, source)) + return matrix.get((proxy_url, source), default) + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser) + + +# ── 1. главное: вердикт разведён по источникам ─────────────────────────────── + + +async def test_node_dead_for_domclick_is_not_issued_to_domclick( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём. + + Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при + `browser_fail_streak=0` и свежей пробе. + """ + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + # Домклику достаётся только исправный узел… + first = acquire(db, "domclick") # type: ignore[arg-type] + assert first is not None and first.id == 2 + + # …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить + # заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой + # заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0. + assert acquire(db, "domclick") is None, ( # type: ignore[arg-type] + "Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY" + ) + + # Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла. + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None and lease.id == 1 + + ban = db._ban(1, "domclick") + assert ban is not None + assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба" + assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None + assert counters["pair_banned"] == 1 + + +async def test_one_dead_platform_does_not_stain_the_node_globally( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает.""" + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + row = db._by_id(1) + assert row["browser_unfit_since"] is None + assert row["browser_fail_streak"] == 0 + assert row["enabled"] is True and row["consecutive_fails"] == 0 + + +async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел).""" + dead_everywhere = { + ("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable") + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, dead_everywhere) + db = FakeSession([_proxy(1), _proxy(2)]) + + for _ in range(BROWSER_UNFIT_THRESHOLD): + db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._by_id(1)["browser_unfit_since"] is not None + assert db._by_id(2)["browser_unfit_since"] is None + + +# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ──────────────────────────────── + + +@pytest.mark.parametrize( + ("source", "must_contain", "path_suffix"), + [ + ("avito", "www.avito.ru", "/robots.txt"), + ("cian", "ekb.cian.ru", "/robots.txt"), + ("yandex", "realty.yandex.ru", "/robots.txt"), + # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, + # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. + # + # #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*, + # robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4 + # в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по + # нему зелёная ровно тогда, когда свип получает блок. + ("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"), + ], +) +async def test_probe_asks_the_working_host_of_each_source( + monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str +) -> None: + seen: dict[str, Any] = {} + # Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» — + # 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа. + _body = ( + '{"result":{"snippetsCount":678}}' if source == "domclick" else "
User-agent: *
" + ) + + class _Resp: + status_code = 200 + text = "{}" + + @staticmethod + def json() -> dict[str, str]: + return {"html": _body} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + seen["payload"] = json + return _Resp() + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, _fail_kind, _detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source=source + ) + + assert ok is True + assert must_contain in seen["payload"]["url"] + # #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это + # боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть + # нагрузка на площадку остаётся минимальной, а защита — той же, что у работы. + assert path_suffix in seen["payload"]["url"] + # Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии. + assert seen["payload"]["source"] == "generic" + + +async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы.""" + stub = "Ошибка - Циан…" + + class _Resp: + status_code = 200 + text = "{}" + + @staticmethod + def json() -> dict[str, str]: + return {"html": stub} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + return _Resp() + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, fail_kind, detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source="cian" + ) + + assert ok is False + # Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP. + assert fail_kind == "page" + # #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от + # площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика). + # Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили. + assert "no marker 'User-agent'" in detail + + +async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя.""" + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._ban(1, "cian") is not None + assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём + + +# ── 4. проба снимает только свою строку ────────────────────────────────────── + + +async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором. + + robots.txt площадка отдаёт и забаненному IP — разрешить дешёвому успеху гасить + дорогой вердикт значило бы повторить #2723 на паре. + """ + later = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": later, + "ban_count": 1, + "reason": "banned:avito", # распознан боевым сбором (капча/QRATOR) + }, + { + "proxy_id": 1, + "source": "cian", + "banned_until": later, + "ban_count": 1, + "reason": "probe:browser", # прошлый вердикт самой пробы + }, + ], + ) + _patch_probes(monkeypatch, {}) # все площадки отвечают + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + avito = db._ban(1, "avito") + assert avito is not None, "чужой бан проба снимать не имеет права" + assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать" + assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять" + assert counters["pair_cleared"] == 1 + + +# ── 4b. …и не присваивает чужую (дефект #2803, реализовался на проде) ───────── + + +async def test_probe_does_not_steal_a_live_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Упавшая проба НЕ переписывает активный бан, поставленный боевым сбором. + + Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21` после + упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. Фильтр «снимаю только + своё» при этом цел, но защищать перестаёт: присвоенная строка уже «своя», и + следующая успешная проба сняла бы ею бан, который площадка поставила по-настоящему. + Плюс сама метка перестаёт быть свидетельством («нас отбили» неотличимо от «мы не + смогли», #2764), а ban_count складывает события разного рода в одну эскалацию — + отдых пары вырос с 6 ч до 12 ч. + """ + until = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": until, + "ban_count": 1, + "reason": "banned:cian", # боевой сбор: Циан отдал заглушку + } + ], + ) + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + ban = db._ban(1, "cian") + assert ban is not None + assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора" + assert ban["ban_count"] == 1, "два события разного рода посчитаны одной эскалацией" + assert ban["banned_until"] == until, "чужой срок проба не пересчитывает (может и укоротить)" + assert counters["pair_banned"] == 0, "счётчик не должен объявлять баном то, чего не записал" + + +async def test_live_ban_takes_over_the_probe_row(monkeypatch: pytest.MonkeyPatch) -> None: + """Зеркало намеренно НЕ симметрично: боевой сбор строку пробы перехватывает. + + Его вердикт сильнее — площадка отбила нас именно сейчас, — пара остаётся забаненной, + а метка становится точнее. Если запретить и ему, строка останется за пробой, и её же + зелёный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально. + """ + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": datetime.now(UTC) + timedelta(hours=6), + "ban_count": 1, + "reason": "probe:browser", + } + ], + ) + + proxy_pool.mark_banned(db, 1, source="cian") # type: ignore[arg-type] + + assert db._ban(1, "cian")["reason"] == "banned:cian" + + # …и с этой минуты зелёная проба его не снимет — ради чего перехват и нужен. + _patch_probes(monkeypatch, {}) + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert db._ban(1, "cian") is not None + + +# ── 5-6. чужие отказы ──────────────────────────────────────────────────────── + + +async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + """Лежащий сайдкар не должен забанить разом все пары (#2686-класс).""" + down = { + (f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError") + for pid in (1, 2) + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, down) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db.bans == [] + assert db._by_id(1)["browser_unfit_since"] is None + + +# ── 7. цена такта ──────────────────────────────────────────────────────────── + + +async def test_probe_cost_is_nodes_times_servable_sources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Крест ограничен источниками, которым узел вообще может достаться. + + Диагностика не должна превращаться в нагрузку: узел с выделенной affinity + спрашивает ОДНУ площадку, 'any' — все четыре. На проде это 4 узла × 4 источника + = 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на + площадку в сутки против ~1000 боевых /fetch. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, {}, calls) + db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [ + "avito", + "cian", + "domclick", + "yandex", + ] + assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"] + assert len(calls) == 5 + + +async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None: + """Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки. + + Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из + теста выше не меняется, пока всё зелено. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, _LIVE_MATRIX, calls) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert calls.count(("http://u:p@h1:8080", "domclick")) == 2 + assert calls.count(("http://u:p@h1:8080", "avito")) == 1 + + +async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """Один провал, второй заход зелёный → бан пары не пишется.""" + attempts: dict[str, int] = {} + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + key = f"{proxy_url}|{source}" + attempts[key] = attempts.get(key, 0) + 1 + if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1: + return False, "proxy", "503 browser unavailable" + return True, None, "html_len=150" + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db._ban(1, "domclick") is None + lease = acquire(db, "domclick") # type: ignore[arg-type] + assert lease is not None + release(db, lease.id) # type: ignore[arg-type] + + +# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ── + + +@pytest.mark.parametrize( + ("api_body", "expect_ok"), + [ + # Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом. + # На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True, + # то есть «пара здорова» ровно там, где свип получает блок. Красный на main. + ("Ошибкаqrator captcha", False), + # Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность + # ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай + # и провалила бы этот, то есть тест не может позеленеть от глухого отказа. + # На origin/main этот случай тоже красный, но по другой причине — там проба + # спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА. + ('{"result":{"snippetsCount":678}}', True), + ], +) +async def test_domclick_probe_sees_the_block_that_robots_txt_hides( + monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool +) -> None: + """Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок. + + Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` → 05:02 свип + `QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и + 5 банов на все четыре площадки при ежедневном блоке Домклика. + """ + seen: dict[str, Any] = {} + + class _Resp: + status_code = 200 + text = "{}" + + def __init__(self, body: str) -> None: + self._body = body + + def json(self) -> dict[str, str]: + return {"html": self._body} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + seen["payload"] = json + asked = str(json["url"]) + # Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт. + if asked.endswith("/robots.txt"): + return _Resp("
User-agent: *
") + return _Resp(api_body) + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, fail_kind, _detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source="domclick" + ) + + assert ok is expect_ok + if not expect_ok: + # Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy". + assert fail_kind == "page" + # Спрашивали именно боевой путь, а не robots.txt того же хоста. + assert "/api/offers/" in seen["payload"]["url"] diff --git a/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py new file mode 100644 index 00000000..69b93713 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py @@ -0,0 +1,193 @@ +"""#2807: счётчик мерил прирост таблицы, а читался как «сколько записали». + +`newbuilding_enrich_backfill` считал свою работу разницей `COUNT(*)` до и после +сохранения. Вставка в houses_price_dynamics идёт `ON CONFLICT … DO UPDATE`, поэтому +обновление существующей точки давало ноль. Прод 10.08: прогон 3578 отчитался +`price_dynamics_rows: 0`, обновив за своё окно **64 строки по 10 домам** — те самые, +что вставил прогон 3563 накануне (у него в тех же counters стояло 64). Ноль читался +как «динамика цен снова не пишется». + +Соседние счётчики врали в том же месте по своим причинам: `reliability_rows` обнулял +`_dedup_reliability`, схлопывающий строку сразу после вставки, а `review_rows` +игнорировал число, которое `_save_cian_reviews` УЖЕ возвращал, в пользу разницы COUNT'ов. + +Фальсификация (см. прогон в PR): на коде до правки `test_second_pass_reports_updates` +даёт `price_dynamics_rows == 0` при 64 переписанных точках — ровно прод-симптом. + +Отдельно проверяется, что правка НЕ ослабила сторожа нулевого результата: он смотрит на +`attempted`/`enriched`/`gone`/`blocked` (#2695), а не на счётчики записи, и прогон, +который ничего не обогатил, обязан остаться 'failed' при любых числах в `*_written`. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +sys.modules.setdefault("weasyprint", MagicMock()) + +from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 + NewbuildingSaveCounts, + save_newbuilding_enrichment, +) + +from app.services import scrape_runs as runs_mod # noqa: E402 +from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402 + NewbuildingEnrichBackfillResult, +) + +# Прод-масштаб прогона 3578: 10 домов × 64/10 точек. Держим ровно 64, чтобы число в +# тесте совпадало с числом в задаче. +PROD_POINTS = 64 + + +class _UpsertDB: + """Сессия, у которой houses_price_dynamics уже населена (второй проход). + + `RETURNING (xmax = 0)` возвращает False на конфликте — это и есть «обновили». + """ + + def __init__(self, *, already_present: bool) -> None: + self.already_present = already_present + self.price_writes = 0 + self.reliability_writes = 0 + self.committed = False + + def execute(self, statement, params=None): + sql = str(statement) + res = MagicMock() + if "INSERT INTO houses_price_dynamics" in sql: + self.price_writes += 1 + assert "RETURNING (xmax = 0)" in sql, "писатель обязан различать вставку и update" + res.fetchone.return_value = (not self.already_present,) + return res + if "INSERT INTO house_reliability_checks" in sql: + self.reliability_writes += 1 + res.fetchone.return_value = None + return res + + def commit(self) -> None: + self.committed = True + + +def _enrichment(points: int): + from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment + + return NewbuildingEnrichment( + cian_internal_house_id=1, + cian_zhk_url="https://zhk-x.cian.ru/", + name="ЖК Тест", + realty_valuation_chart=[ + { + "month_date": f"2026-{(i % 12) + 1:02d}-01", + "room_count": "all", + "prices_type": "price", + "period": "halfYear", + "price_per_sqm": 150000.0 + i, + } + for i in range(points) + ], + reliability_checks=[{"check_name": "Надёжный застройщик", "check_status": "reliable"}], + reviews=[], + ) + + +# ── 1. Писатель различает вставку и обновление ─────────────────────────────── + + +def test_first_pass_reports_inserts() -> None: + db = _UpsertDB(already_present=False) + counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS)) + assert counts.price_inserted == PROD_POINTS + assert counts.price_updated == 0 + assert counts.reliability_inserted == 1 + assert db.price_writes == PROD_POINTS + + +def test_second_pass_reports_updates() -> None: + """Прод-симптом: те же 64 точки, ничего нового — но записаны все 64. + + До правки этот прогон отчитывался нулём по всем трём счётчикам. + """ + db = _UpsertDB(already_present=True) + counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS)) + assert counts.price_inserted == 0 + assert counts.price_updated == PROD_POINTS + assert counts.price_written == PROD_POINTS + assert db.price_writes == PROD_POINTS + + +def test_nothing_to_write_stays_zero() -> None: + """Встречная проверка: пустой график — ноль и во «вставлено», и в «обновлено».""" + db = _UpsertDB(already_present=True) + counts = save_newbuilding_enrichment(db, 42, _enrichment(0)) + assert (counts.price_inserted, counts.price_updated, counts.price_written) == (0, 0, 0) + + +def test_points_without_price_are_not_counted_as_written() -> None: + """Точка без price_per_sqm пропускается писателем — и не попадает в счёт.""" + enrichment = _enrichment(2) + enrichment.realty_valuation_chart[0]["price_per_sqm"] = None + db = _UpsertDB(already_present=False) + counts = save_newbuilding_enrichment(db, 42, enrichment) + assert counts.price_written == 1 + assert db.price_writes == 1 + + +# ── 2. Сторож нулевого результата не ослаблен ──────────────────────────────── + + +def _finalize(counters: dict[str, int]) -> str: + """Прогнать counters через боевой финализатор и вернуть выбранный статус.""" + chosen: dict[str, str] = {} + with ( + patch.object(runs_mod, "mark_done", lambda *a, **k: chosen.setdefault("s", "done")), + patch.object(runs_mod, "mark_failed", lambda *a, **k: chosen.setdefault("s", "failed")), + patch.object(runs_mod, "mark_banned", lambda *a, **k: chosen.setdefault("s", "banned")), + ): + runs_mod.mark_backfill_finished(MagicMock(), 1, counters, source="newbuilding_enrich") + return chosen["s"] + + +def test_watchdog_still_fails_a_run_that_enriched_nothing() -> None: + """Прогон без обогащений остаётся 'failed', сколько бы записей ни насчитали. + + Числа записи в решение сторожа не входят вовсе — он судит по attempted/enriched. + Если бы входили, честный счётчик «обновлено» превратил бы холостой прогон в успех. + """ + result = NewbuildingEnrichBackfillResult( + processed=25, + succeeded=0, + failed_fetch=25, + price_dynamics_updated=PROD_POINTS, # «что-то писали» — но никого не обогатили + ) + assert _finalize(result.to_backfill_counters()) == "failed" + + +def test_watchdog_verdict_ignores_the_new_keys() -> None: + """Явно: добавление/убирание новых ключей не двигает вердикт ни в одну сторону.""" + base = {"attempted": 25, "enriched": 3, "failed": 22} + assert _finalize(dict(base)) == "done" + assert _finalize({**base, "price_dynamics_inserted": 0, "price_dynamics_updated": 0}) == "done" + zero = {"attempted": 25, "enriched": 0, "failed": 25} + assert _finalize(dict(zero)) == "failed" + assert _finalize({**zero, "price_dynamics_updated": 999}) == "failed" + + +def test_counters_carry_both_numbers_into_the_run() -> None: + """В scrape_runs.counters уезжают ОБА числа — ноль одного больше не читается как ноль.""" + counters = NewbuildingEnrichBackfillResult( + processed=25, succeeded=25, price_dynamics_updated=PROD_POINTS + ).to_backfill_counters() + assert counters["price_dynamics_inserted"] == 0 + assert counters["price_dynamics_updated"] == PROD_POINTS + # Старые имена не должны остаться: у них в истории прогонов другой смысл. + assert "price_dynamics_rows" not in counters + assert "reliability_rows" not in counters + assert "review_rows" not in counters + + +def test_save_counts_written_is_the_sum() -> None: + assert NewbuildingSaveCounts(price_inserted=3, price_updated=4).price_written == 7 diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py new file mode 100644 index 00000000..a6fb3820 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py @@ -0,0 +1,340 @@ +"""#2830: хвосты инцидента 2026-08-10 — пути, которые ещё ходили мимо пула прокси. + +Корень (issue #2830, разбор владельца): `SCRAPER_PROXY_URL` — один статичный узел, +общий для всех источников и НЕ знающий про `scrape_proxy_source_bans`. Три места +продолжали брать egress именно оттуда: + + 1. `cian_price_history` (ручка POST /admin/scrape/cian-price-history); + 2. `_provider_proxy_url` / GET /admin/scraper/health — ops-панель показывала статичный + узел, пока трафик уже выбирался пулом (#2825/#2831); + 3. `resolve_cian_zhk_url_via_search` — вторая нога обогащения ЖК: #2767 перевёл на пул + только `fetch_newbuilding`, резолв ЖК-url остался на `config.cian_proxy_url`. + +Тесты проверяют ПОВЕДЕНИЕ пула (какой узел взят и какой вердикт вернулся), а не наличие +kwarg'а в вызове. Красные на старом коде: + + * (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не + сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»: + `USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`, + где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`). + * (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел + параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан» + выражения не имел вообще. + * (3) `resolve_cian_zhk_url_via_search` на 403 возвращал None: узел получал + `mark_health(ok=True)` и оставался в выдаче Циану (механика #2700/#2821). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.cian_exceptions import CianBlockedError +from scraper_kit.contracts import ProxyLease +from scraper_kit.providers.cian import detail as cian_detail +from scraper_kit.providers.cian import newbuilding as cian_newbuilding +from scraper_kit.proxy_errors import NoProxyAvailableError + +# Узел 9 (asocks-mobile-1) — тот, что 2026-08-10 отдавал Циану 200, пока статичный +# SCRAPER_PROXY_URL смотрел на забаненный узел 1 (asocks-residential-1). +_LEASE = ProxyLease(id=9, url="http://user:pass@pool-node-9:10313", kind="http", rotate_url=None) + + +class _SpyProvider: + """ProxyProvider-заглушка: запоминает вердикты, которые путь вернул пулу.""" + + def __init__(self, lease: ProxyLease | None = _LEASE) -> None: + self._lease = lease + self.acquire_calls: list[str] = [] + self.mark_health_calls: list[tuple[int, bool]] = [] + self.mark_banned_calls: list[tuple[int, str]] = [] + self.release_calls: list[int] = [] + + def acquire(self, provider: str) -> ProxyLease | None: + self.acquire_calls.append(provider) + return self._lease + + def release(self, lease: ProxyLease) -> None: + self.release_calls.append(lease.id) + + def mark_health( + self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None + ) -> None: + self.mark_health_calls.append((lease.id, ok)) + + def mark_banned(self, lease: ProxyLease, *, source: str) -> None: + self.mark_banned_calls.append((lease.id, source)) + + +class _SpyPool: + """Заглушка МОДУЛЯ `app.services.proxy_pool` — подставляется ПОД `RealProxyProvider`. + + Так тест краснеет на поведении (пул не получил вердикт), а не на отсутствии нового + имени в модуле: старый код проходит ровно тот же путь, просто ничего пулу не говорит. + """ + + def __init__(self, lease: Any = _LEASE) -> None: + self._lease = lease + self.acquire_calls: list[str] = [] + self.mark_health_calls: list[tuple[int, bool]] = [] + self.mark_banned_calls: list[tuple[int, str]] = [] + self.release_calls: list[int] = [] + + def acquire(self, db: Any, provider: str, **kw: Any) -> Any: + self.acquire_calls.append(provider) + return self._lease + + def release(self, db: Any, lease_id: int) -> None: + self.release_calls.append(lease_id) + + def mark_health(self, db: Any, lease_id: int, ok: bool, **kw: Any) -> None: + self.mark_health_calls.append((lease_id, ok)) + + def mark_banned(self, db: Any, lease_id: int, *, source: str) -> None: + self.mark_banned_calls.append((lease_id, source)) + + +def _session_returning(status_code: int, text: str = "") -> MagicMock: + session = MagicMock() + session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text)) + session.close = AsyncMock() + return session + + +# ── 1. cian_price_history: узел из пула + вердикт обратно ──────────────────── + + +def _price_history_db(n_listings: int) -> MagicMock: + """Фейковая сессия: SELECT листингов, дальше COUNT'ы (до fetch дело не дойдёт).""" + rows = [ + {"id": 100 + i, "source_url": f"https://ekb.cian.ru/sale/flat/{100 + i}/"} + for i in range(n_listings) + ] + listings_result = MagicMock() + listings_result.mappings.return_value.all.return_value = rows + db = MagicMock() + db.execute.return_value = listings_result + return db + + +async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any: + from app.services import scraper_adapters + from app.services.cian_price_history import backfill_cian_price_history + + with ( + patch.object( + cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code) + ), + patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0), + patch.object(scraper_adapters, "_proxy_pool", pool), + patch.object(scraper_adapters, "_SessionLocal", MagicMock()), + ): + return await backfill_cian_price_history(_price_history_db(n), batch_size=n) + + +@pytest.mark.asyncio +async def test_price_history_takes_pool_node_despite_flag_off() -> None: + """Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend). + + Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного + pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал. + """ + from app.core.config import settings + + assert ( + settings.use_proxy_pool_curl is False + ), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL" + pool = _SpyPool() + await _run_price_history(pool, status_code=200) + assert pool.acquire_calls == ["cian"] + assert pool.release_calls == [9] # lease не течёт + + +@pytest.mark.asyncio +async def test_price_history_403_bans_the_node_for_cian() -> None: + """403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = [].""" + pool = _SpyPool() + result = await _run_price_history(pool, status_code=403) + assert pool.mark_banned_calls == [(9, "cian")] + assert pool.mark_health_calls == [(9, False)] + assert result.errors == 1 # прогон честен: отказ посчитан + + +@pytest.mark.asyncio +async def test_price_history_404_does_not_ban_the_node() -> None: + """Снятое объявление — не бан площадки: чужой вердикт узлу не присваиваем (#2805).""" + pool = _SpyPool() + await _run_price_history(pool, status_code=404) + assert pool.mark_banned_calls == [] + assert pool.mark_health_calls == [(9, True)] + + +@pytest.mark.asyncio +async def test_price_history_aborts_batch_when_pool_empty() -> None: + """Пул пуст в проде → отказ ДО HTTP и разрыв батча, а не 3 попытки подряд.""" + from app.core.config import settings + + pool = _SpyPool(lease=None) + # Прод-признак читает `curl_proxy_url` через config.environment (#2616 шаг 1): + # только в проде пустой пул = отказ, в dev это легитимный env/direct-путь. + with patch.object(settings, "environment", "production"): + result = await _run_price_history(pool, status_code=200, n=3) + assert pool.acquire_calls == ["cian"], "после отказа пула батч продолжаться не должен" + assert result.errors == 1 + assert result.checked == 3 # выбрали 3, но оборвались на первом + + +def test_no_proxy_available_is_not_a_platform_ban() -> None: + """Наш отказ инфраструктуры не должен читаться как бан площадки (#2616).""" + from scraper_kit.proxy_errors import ProxyBanError + + assert not issubclass(NoProxyAvailableError, ProxyBanError) + + +# ── 2. /admin/scraper/health: показывает узел, который реально получит трафик ── + + +def _health_db(*, candidate: dict[str, Any] | None, diag: dict[str, int]) -> MagicMock: + """Сессия для `proxy_egress`: SELECT кандидата, при пустом — SELECT-разбивка причин.""" + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + row = diag if "count(*)" in str(stmt).lower() else candidate + res = MagicMock() + res.mappings.return_value.fetchone.return_value = row + return res + + db = MagicMock() + db.execute.side_effect = _execute + return db + + +def _get_health(db: MagicMock) -> dict[str, Any]: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + def _fake_db() -> Any: + yield db + + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + app.dependency_overrides[get_db] = _fake_db + + async def _no_browser() -> Any: + return admin_module.BrowserHealth(reachable=False, browsers={}) + + async def _no_ip(proxy_url: str | None) -> str | None: + return None + + with ( + patch.object(admin_module, "_probe_browser_health", _no_browser), + patch.object(admin_module, "_probe_current_ip", _no_ip), + # Статичный env-узел ОТЛИЧАЕТСЯ от пулового — иначе тест не различит источники. + patch.object( + admin_module.settings.__class__, + "scraper_proxy_url", + property(lambda _self: "http://u:p@static-env-node:10423"), + ), + ): + r = TestClient(app).get("/api/v1/admin/scraper/health") + assert r.status_code == 200 + return {p["source"]: p for p in r.json()["providers"]} + + +def test_health_shows_pool_node_not_static_env() -> None: + """Панель показывает узел, который получит трафик, а не SCRAPER_PROXY_URL. + + Красный на старом коде поведенчески: возвращался `static-env-node` — ровно то + расхождение «панель показывает один узел, трафик идёт через другой», из-за + которого инцидент 2026-08-10 месяц оставался невидимым. + """ + db = _health_db( + candidate={"id": 9, "url": _LEASE.url, "label": "asocks-mobile-1"}, + diag={"pool_total": 4, "banned_for_source": 0, "unhealthy_or_disabled": 0}, + ) + by_source = _get_health(db) + assert {p["proxy_host"] for p in by_source.values()} == {"pool-node-9"} + assert by_source["cian"]["proxy_port"] == 10313 + + +def test_health_shows_nothing_when_pool_exhausted_for_source() -> None: + """Все узлы забанены для источника → «—», а не статичный env-узел. + + Зелёная строка на месте отказа хуже пустой: она читается как «прокси есть». + """ + db = _health_db( + candidate=None, + diag={"pool_total": 4, "banned_for_source": 4, "unhealthy_or_disabled": 0}, + ) + by_source = _get_health(db) + assert by_source["cian"]["proxy_host"] is None + assert by_source["cian"]["proxy_port"] is None + + +# ── 3. resolve ЖК-url: вторая нога обогащения (#2767 чинил только первую) ───── + + +@dataclass +class _FakeConfig: + use_proxy_pool_curl: bool = True + cian_proxy_url: str | None = "http://static-env-node:10423" + environment: str = "production" + + +async def _resolve(status_code: int, spy: _SpyProvider, html: str = "") -> Any: + with patch.object( + cian_newbuilding, + "build_curl_cffi_session", + return_value=_session_returning(status_code, html), + ): + return await cian_newbuilding.resolve_cian_zhk_url_via_search( + 48853, config=_FakeConfig(), proxy_provider=spy + ) + + +@pytest.mark.asyncio +async def test_zhk_resolve_uses_pool_node() -> None: + """Резолв ЖК-url берёт узел из пула, а не статичный cian_proxy_url.""" + spy = _SpyProvider() + html = '

ЖК

' + assert await _resolve(200, spy, html) == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" + assert spy.acquire_calls == ["cian"] + assert spy.mark_health_calls == [(9, True)] + assert spy.release_calls == [9] + + +@pytest.mark.asyncio +async def test_zhk_resolve_403_reaches_the_pool() -> None: + """Красный на старом коде: 403 гасился в `return None`, узел оставался в выдаче.""" + spy = _SpyProvider() + with pytest.raises(CianBlockedError): + await _resolve(403, spy) + assert spy.mark_banned_calls == [(9, "cian")] + assert spy.mark_health_calls == [(9, False)] + assert spy.release_calls == [9] + + +@pytest.mark.asyncio +async def test_zhk_resolve_404_is_a_soft_miss_not_a_ban() -> None: + """404 cat.php — не бан: здоровый узел за отсутствующий ЖК не наказываем.""" + spy = _SpyProvider() + assert await _resolve(404, spy) is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(9, True)] + + +@pytest.mark.asyncio +async def test_zhk_resolve_empty_serp_is_a_soft_miss_not_a_ban() -> None: + """200 без слага (дрейф вёрстки / пустая выдача) — тоже не повод банить узел.""" + spy = _SpyProvider() + assert await _resolve(200, spy, "ничего") is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(9, True)] diff --git a/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py new file mode 100644 index 00000000..9df5b583 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py @@ -0,0 +1,145 @@ +"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674). + +`listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо +в определении: district, distance_to_metro_m, last_price_change, photos_count. +Это не потеря данных и не оборванный писатель — имена зарезервировали, реализацию +не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты) +и они сняты миграцией 261; district оставлен намеренно — он объявлен в +schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее +изменение контракта (решение владельца, вынесено отдельно в #2857). + +Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её +актуального определения (самый старший NN среди файлов, создающих витрину) и +смотрит на состав списка. Переформатирование SQL, перестановка строк или смена +`NULL::int` на `NULL::integer` тест не трогают; возврат колонки — краснит. + +Без БД и сети: миграции читаются как текст, разбираются в структуру. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" + +MV = "listings_search_mv" +# Сняты 261: ноль читателей во всём репозитории на момент сноса. +DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count") + + +def _strip_sql_comments(sql: str) -> str: + sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL) + return re.sub(r"--[^\n]*", "", sql) + + +def _latest_definition() -> str: + """Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид.""" + creators = [ + p + for p in SQL_DIR.glob("*.sql") + if re.search( + rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b", + _strip_sql_comments(p.read_text("utf-8")), + re.I, + ) + ] + assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда" + return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8") + + +def mv_columns() -> list[str]: + """Имена колонок витрины в порядке объявления. + + Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым + той же глубины, имя колонки — последний идентификатор элемента (алиас после + `AS` либо хвост `l.foo`). + """ + sql = _strip_sql_comments(_latest_definition()) + body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1] + + depth, items, cur = 0, [], [] + for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I): + t = token.group(0) + if t == "(": + depth += 1 + elif t == ")": + depth -= 1 + elif depth == 0 and t == ",": + items.append("".join(cur)) + cur = [] + continue + elif depth == 0 and t.upper() == "FROM": + break + cur.append(t) + items.append("".join(cur)) + + return [item.split()[-1].split(".")[-1] for item in items if item.split()] + + +def test_placeholder_columns_are_gone_from_the_matview() -> None: + """Red => витрина снова обещает поля, которых не заполняет (#2857). + + Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел + после возврата колонки — сначала заведи писателя, потом колонку, а не наоборот. + """ + cols = mv_columns() + still_there = [c for c in DROPPED if c in cols] + assert not still_there, ( + f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя " + "читается снаружи как «данные есть, просто у этого объекта пусто» — это " + "хуже мёртвого кода, потому что видно в контракте." + ) + + +def test_district_is_deliberately_kept() -> None: + """Red => district снесли заодно, а он в схеме ответа API. + + schemas/search_response.py объявляет `district: str | None`, services/search_query.py + его тянет — снос ломает контракт /api/v1/search. Это решение владельца (#2857), + а не побочный эффект уборки соседних заглушек. Убирать — вместе со схемой ответа. + """ + assert "district" in mv_columns(), ( + f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: " + "ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой." + ) + + +def test_search_api_selects_only_columns_the_matview_has() -> None: + """Настоящий инвариант: то, что просит API, витрина обязана иметь. + + Именно эта проверка отличает «список колонок» от «поиска подстроки»: она + краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх + известных именах. + """ + from app.schemas.search import SearchParams + from app.services.search_query import build_search_query + + sql, _ = build_search_query(SearchParams()) + selected = [ + c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip() + ] + missing = [c for c in selected if c not in mv_columns()] + assert not missing, ( + f"services/search_query.py просит у {MV} колонки, которых в её определении нет: " + f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из " + "schemas/search_response.py." + ) + + +def test_unique_index_for_concurrent_refresh_survives_recreation() -> None: + """Red => ночной REFRESH ... CONCURRENTLY упадёт. + + app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание + refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает + «cannot refresh materialized view concurrently ... no unique index» — а витрина, + которую пересоздали и забыли проиндексировать, молчит до самой ночи. + """ + sql = _strip_sql_comments(_latest_definition()) + assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), ( + f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — " + "REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен." + ) diff --git a/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py new file mode 100644 index 00000000..cc0ba37a --- /dev/null +++ b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py @@ -0,0 +1,295 @@ +"""#930 добивка: планировщик не подхватывал чекпоинт оборванного прогона. + +#930 сделал обе половины механизма — запись точки (`counters.done_buckets`, per-bucket +heartbeat) и её чтение (`run_*_full_load(resume_run_id=...)`, skip-set в SERP-слое), — +но единственным входом оставил админку. У avito full-load админского эндпоинта нет +вовсе, а планировщик передавал `resume_run_id=None` ЛИТЕРАЛОМ (scheduler.py 708/728/799 +на origin/main). То есть боевой путь возобновления не существовал ни одного дня. + +Цена на проде (замер 2026-08-12, 90 суток, read-only): 433 корзины в 30 оборванных +прогонах с ЖИВОЙ незабранной точкой — avito_full_load 242, cian_full_load 134, +avito_full_load_exhaustive 57. Прогон 3547 (09.08, убит деплоем на третьем часу, 35 из +84 корзин дерева) лежит до сих пор и будет подхвачен расписанием 139 16.08. + +Красный прогон на origin/main: + 1. `test_scheduler_hands_checkpoint_to_pipeline` — планировщик отдаёт в пайплайн + resume_run_id=None вместо id прошлого прогона (AssertionError на 3 источниках); + 2. `test_partial_bucket_is_not_complete` — бакет с выпавшей страницей приезжает в + on_bucket неотличимым от целого (у колбэка нет аргумента полноты вообще); + 3. `test_pipeline_keeps_partial_bucket_out_of_checkpoint` — TypeError: `_on_bucket` + на main принимает два аргумента, признаку полноты некуда приехать. +Тесты ладдера (`_resume_decision`) на main падают с AttributeError — функции нет. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import scheduler as sched +from scraper_kit.orchestration.pipeline import run_avito_full_load +from scraper_kit.providers.avito.serp import AvitoScraper + +PFX = "scraper_kit.orchestration.pipeline" + +# Прод-слепок расписания 139 (avito_full_load_exhaustive) на 2026-08-12: params прогона +# 3547 совпадают с default_params расписания байт-в-байт — это и есть «то же задание». +_PARAMS = { + "concurrency": 1, + "interval_days": 7, + "secondary_only": True, + "request_delay_sec": 7.0, + "price_cap_per_bucket": 1400, +} + + +def _candidate(**over: Any) -> SimpleNamespace: + """Строка-кандидат из _RESUME_CANDIDATE_SQL: прогон 3547 как он лежит на проде.""" + base = { + "prev_id": 3547, + "prev_status": "cancelled", + "prev_counters": { + "unique_fetched": 5496, + "done_buckets": [f"room_1_komn:{i}:0" for i in range(35)], + }, + "same_params": True, + "age_h": 164.6, # 6.86 суток — столько будет точке 3547 к подхвату 16.08 + "interval_days": "7", + } + base.update(over) + return SimpleNamespace(**base) + + +class _FakeDb: + """Двойник сессии: отдаёт ОДНУ строку-кандидата на любой SELECT, глотает UPDATE.""" + + def __init__(self, row: Any) -> None: + self.row = row + self.written: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: # update_heartbeat пишет вердикт + self.written.append(params) + return MagicMock() + return MagicMock(fetchone=lambda: self.row) + + def commit(self) -> None: + pass + + +# ── 1. Главное: планировщик обязан отдать точку в пайплайн ─────────────────── + + +@pytest.mark.parametrize( + ("job", "pipeline_fn"), + [ + (sched._job_avito_full_load, "run_avito_full_load"), + (sched._job_avito_full_load_exhaustive, "run_avito_full_load"), + (sched._job_cian_full_load, "run_cian_full_load"), + ], +) +async def test_scheduler_hands_checkpoint_to_pipeline(job: Any, pipeline_fn: str) -> None: + """Оборванный прогон с валидной точкой → новый прогон продолжает его, а не с нуля. + + Падает на origin/main: планировщик передаёт литеральный None — 433 корзины за 90 + суток перебирались заново, включая 35 корзин прогона 3547. + """ + db = _FakeDb(_candidate()) + captured: dict[str, Any] = {} + + async def _spy(*_a: Any, **kw: Any) -> None: + captured.update(kw) + + with patch.object(sched, pipeline_fn, _spy): + await job(db, 4000, dict(_PARAMS), MagicMock()) + + assert captured["resume_run_id"] == 3547 + + +async def test_verdict_lands_in_counters_of_new_run() -> None: + """Подхватили или нет — видно В СЧЁТЧИКАХ прогона, а не только в docker-логах. + + Логи теряются при редеплое (контейнер tradein-scraper пересоздаётся), поэтому + молчаливый отказ подхватить неотличим от отсутствия правки. + """ + db = _FakeDb(_candidate(prev_status="zombie")) + with patch.object(sched, "run_avito_full_load", AsyncMock()): + await sched._job_avito_full_load(db, 4000, dict(_PARAMS), MagicMock()) + + assert db.written, "вердикт о подхвате не записан в counters нового прогона" + written = db.written[-1]["counters"] + assert '"resume_reason": "status_zombie"' in written + assert '"resume_candidate": 3547' in written + + +# ── 2. Ладдер отказов: у каждого нуля своя причина ─────────────────────────── + + +@pytest.mark.parametrize( + ("row", "reason"), + [ + (None, "no_prev_run"), + (_candidate(prev_status="done"), "status_done"), + (_candidate(prev_status="zombie"), "status_zombie"), + (_candidate(same_params=False), "params_changed"), + (_candidate(prev_counters={"unique_fetched": 2977}), "no_checkpoint"), + (_candidate(age_h=200.0), "checkpoint_stale"), + (_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}), "chain_limit"), + ], +) +def test_resume_refusals_are_named(row: Any, reason: str) -> None: + """«Не подхватили» — это семь РАЗНЫХ фактов, и в counters они различимы.""" + resume_id, verdict = sched._resume_decision(row) + assert resume_id is None + assert verdict["resume_reason"] == reason + assert verdict["resume_from"] is None + + +def test_resume_chain_is_bounded() -> None: + """Цепочка возобновлений считается и упирается в потолок, а не тянется вечно. + + Потолок выведен из STALE_DIGEST_INTERVAL_FACTOR (см. scheduler.py): полный обход + обязан начаться раньше, чем сводка объявит источник просроченным. + """ + assert sched._MAX_RESUME_CHAIN == sched.STALE_DIGEST_INTERVAL_FACTOR - 1 + _id, first = sched._resume_decision(_candidate()) + assert first["resume_chain"] == 1 + _id2, second = sched._resume_decision( + _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 1}) + ) + assert second["resume_chain"] == sched._MAX_RESUME_CHAIN + third_id, third = sched._resume_decision( + _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}) + ) + assert third_id is None and third["resume_reason"] == "chain_limit" + + +def test_stale_threshold_follows_the_source_tick() -> None: + """Срок годности точки считается от такта ИСТОЧНИКА, а не общей константой. + + cian ходит раз в 3 суток, avito — раз в 7; одна и та же точка возрастом 100 ч для + первого просрочена, для второго свежая. Плюс сутки — сетка запуска (см. + _resume_decision): 164.6 ч прогона 3547 при такте 7 суток обязаны пройти, иначе + точку отвергал бы jitter расписания, а пропущенный цикл (13 суток) — нет. + """ + assert sched._resume_decision(_candidate(age_h=100.0, interval_days="3"))[0] is None + assert sched._resume_decision(_candidate(age_h=100.0, interval_days="7"))[0] == 3547 + assert sched._resume_decision(_candidate(age_h=164.6, interval_days="7"))[0] == 3547 + assert sched._resume_decision(_candidate(age_h=13 * 24.0, interval_days="7"))[0] is None + + +# ── 3. Недособранный бакет не имеет права попасть в чекпоинт ───────────────── + + +def _serp_config() -> SimpleNamespace: + return SimpleNamespace( + scraper_fetch_mode="curl_cffi", + browser_http_endpoint="http://browser.test/fetch", + scraper_proxy_url=None, + avito_proxy_max_rotations=0, + avito_serp_ok_not_banned=True, + avito_proxy_rotate_settle_s=0.0, + proxy_rotate_attempts=1, + proxy_rotate_attempt_timeout_s=1.0, + scraper_skip_seen_today=False, + ) + + +@pytest.mark.parametrize( + ("page2_html", "expected_complete"), + [(None, False), ("", True)], +) +async def test_partial_bucket_is_not_complete( + page2_html: str | None, expected_complete: bool +) -> None: + """Страница 2 из 3 выпала → бакет НЕ «сделан»; все три пришли → «сделан». + + Контрольная половина обязательна: реализация «всегда False» тоже прошла бы + одностороннюю проверку, но убила бы возобновление целиком. + + Падает на origin/main: `on_bucket` вызывается двумя аргументами, признака полноты + в протоколе нет — частичный бакет неотличим от целого и попадает в done_buckets. + """ + scraper = AvitoScraper(_serp_config()) + scraper.request_delay_sec = 0.0 + calls: list[tuple[str, bool]] = [] + + def _on_bucket(key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg] + calls.append((key, complete)) + + async def _fetch_page(_self: Any, _slug: str, page: int, *_a: Any, **_k: Any) -> str | None: + return page2_html if page == 2 else f"" + + with ( + patch.object(AvitoScraper, "_fetch_rooms_page_html", _fetch_page), + patch.object( + AvitoScraper, + "_parse_html", + lambda _self, html, **_k: [MagicMock(source_id=html, listing_segment="secondary")], + ), + ): + await scraper._paginate_leaf_bucket( + room_slug="kvartiry_1_komnatnye", + room_label="room_1_komn", + lo=0, + hi=3999999, + html="", + max_pages=3, + seen={}, + price_cap_per_bucket=1400, + max_pages_per_bucket=100, + concurrency=2, + secondary_only=False, + on_bucket=_on_bucket, + skip_buckets=None, + expected_total=3 * 50, + ) + + assert [c[1] for c in calls] == [expected_complete] + + +async def test_pipeline_keeps_partial_bucket_out_of_checkpoint() -> None: + """Пайплайн: лоты частичного бакета СОХРАНЕНЫ, но в чекпоинт он не попал. + + Именно здесь «видимая потеря» (перескрап) не превращается в «невидимую»: пропустить + частичный бакет на следующем прогоне значит не перечитать его страницы уже никогда. + """ + finals: list[dict[str, Any]] = [] + + class _Recorder: + def is_cancelled(self, *_a: Any, **_k: Any) -> bool: + return False + + def update_heartbeat(self, *_a: Any, **_k: Any) -> None: + pass + + def mark_done(self, _db: Any, _rid: int, counters: dict[str, Any]) -> None: + finals.append(dict(counters)) + + async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None: + on_bucket("room_1_komn:0:3999999", [MagicMock(source_id="a1")], True) + on_bucket("room_1_komn:4000000:4999999", [MagicMock(source_id="a2")], False) + + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_all_secondary = _fetch + + with ( + patch(f"{PFX}.AvitoScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))), + patch(f"{PFX}.runs", _Recorder()), + ): + counters = await run_avito_full_load( + MagicMock(), run_id=1, config=_serp_config(), matcher=MagicMock() + ) + + assert finals[0]["done_buckets"] == ["room_1_komn:0:3999999"] + assert finals[0]["partial_buckets"] == 1 + assert counters.unique_fetched == 2, "лоты частичного бакета обязаны быть сохранены" diff --git a/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py new file mode 100644 index 00000000..884a7c8a --- /dev/null +++ b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py @@ -0,0 +1,290 @@ +"""Offline tests для Cian cookie-session admin-эндпоинтов (#инцидент 2026-08-10). + +cian_session_svc.verify_session() возвращает 5 разных исходов (state dict / None / +VERIFY_BAN_SENTINEL / VERIFY_SOURCE_UNAVAILABLE_SENTINEL / VERIFY_MARKUP_CHANGED_SENTINEL) +и раньше admin.py проверял только `if state is None`, из-за чего реальный бан по IP +(403) и недоступность источника (5xx) выглядели как "куки протухли" — человек в +момент инцидента перезаливал заведомо валидные куки вместо починки egress/прокси. + +Покрытие 3 эндпоинтов (db/verify_session/BrowserFetcher мокаются, NO live network/DB), +зеркалит паттерн test_domclick_admin_apis.py (dependency_overrides[get_db] + TestClient): + - POST /api/v1/admin/scrape/cian/upload-cookies + - POST /api/v1/admin/scrape/cian/auto-login + - GET /api/v1/admin/scrape/cian/test-auth +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.services.cian_session import ( + VERIFY_BAN_SENTINEL, + VERIFY_MARKUP_CHANGED_SENTINEL, + VERIFY_SOURCE_UNAVAILABLE_SENTINEL, +) + + +@pytest.fixture +def client() -> TestClient: + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + + def fake_db(): + yield MagicMock() + + app.dependency_overrides[get_db] = fake_db + return TestClient(app) + + +_UPLOAD_URL = "/api/v1/admin/scrape/cian/upload-cookies" +_AUTOLOGIN_URL = "/api/v1/admin/scrape/cian/auto-login" +_TEST_AUTH_URL = "/api/v1/admin/scrape/cian/test-auth" + +_RAW_COOKIES = {"DMIR_AUTH": "abc", "_CIAN_GK": "def"} +_AUTHENTICATED_STATE = {"user": {"isAuthenticated": True, "userId": 102963817}} +_AUTHENTICATED_STATE_NO_USERID = {"user": {"isAuthenticated": True}} + + +# ── POST /scrape/cian/upload-cookies — пять исходов verify_session ──────────── + + +def test_upload_cookies_ban_returns_503(client: TestClient) -> None: + """403 TLS/bot ban → 503, текст говорит чинить прокси, НЕ перезаливать куки.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_BAN_SENTINEL), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 503 + detail = resp.json()["detail"] + assert "заблокировал" in detail + assert "прокси" in detail or "egress" in detail + assert "бесполезно" in detail + # Текст обязан называть ЖИВУЮ переменную: инцидент 2026-08-10 — правка мёртвой + # CIAN_PROXY_URL не давала эффекта, реальный egress задаётся SCRAPER_PROXY_URL. + assert "SCRAPER_PROXY_URL" in detail + + +def test_upload_cookies_source_unavailable_returns_503(client: TestClient) -> None: + """5xx/сеть → 503, текст говорит повторить позже, куки не трогать.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 503 + detail = resp.json()["detail"] + assert "недоступен" in detail + assert "позже" in detail + + +def test_upload_cookies_markup_changed_returns_500(client: TestClient) -> None: + """200 но auth-state не распарсился → 500, текст указывает на фикс парсера.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 500 + detail = resp.json()["detail"] + assert "вёрстк" in detail + assert "перезалив" in detail + + +def test_upload_cookies_expired_returns_401(client: TestClient) -> None: + """None → 401, текст просит перезалить куки (единственный случай re-upload).""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=None), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 401 + assert "перезалейте" in resp.json()["detail"] + + +def test_upload_cookies_success_missing_userid_returns_400(client: TestClient) -> None: + """Успешный state, но без userId → 400 с уточнением что куки тут ни при чём.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE_NO_USERID), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 400 + detail = resp.json()["detail"] + assert "userId" in detail + assert "куки тут ни при" in detail + + +def test_upload_cookies_success_saves_and_returns_200(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE), + ), + patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save, + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["userId"] == 102963817 + mock_save.assert_called_once() + _, kwargs = mock_save.call_args + assert kwargs["account_user_id"] == 102963817 + + +# ── POST /scrape/cian/auto-login — тот же маппинг после успешного browser-login ── + + +def _mock_browser_fetcher(raw_cookies: dict[str, str]) -> MagicMock: + fetcher = AsyncMock() + fetcher.__aenter__ = AsyncMock(return_value=fetcher) + fetcher.__aexit__ = AsyncMock(return_value=None) + fetcher.login = AsyncMock(return_value=raw_cookies) + return fetcher + + +def test_auto_login_ban_returns_503(client: TestClient) -> None: + """Browser login прошёл, но verify_session ловит 403 ban → 503, не "куки протухли".""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"), + patch("app.api.v1.admin.settings.cian_login_password", "secret"), + patch( + "app.api.v1.admin.BrowserFetcher", + return_value=_mock_browser_fetcher(_RAW_COOKIES), + ), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_BAN_SENTINEL), + ), + ): + resp = client.post(_AUTOLOGIN_URL) + assert resp.status_code == 503 + assert "заблокировал" in resp.json()["detail"] + + +def test_auto_login_success_saves_and_returns_200(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"), + patch("app.api.v1.admin.settings.cian_login_password", "secret"), + patch( + "app.api.v1.admin.BrowserFetcher", + return_value=_mock_browser_fetcher(_RAW_COOKIES), + ), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE), + ), + patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save, + ): + resp = client.post(_AUTOLOGIN_URL) + assert resp.status_code == 200 + assert resp.json()["userId"] == 102963817 + mock_save.assert_called_once() + + +# ── GET /scrape/cian/test-auth — reason различает все пять исходов ──────────── + + +def test_test_auth_ban_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_BAN_SENTINEL), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + body = resp.json() + assert body["authenticated"] is False + assert body["reason"] == "banned_403" + + +def test_test_auth_source_unavailable_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + assert resp.json()["reason"] == "source_unavailable" + + +def test_test_auth_markup_changed_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + assert resp.json()["reason"] == "markup_changed" + + +def test_test_auth_expired_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=None), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + assert resp.json()["reason"] == "session_expired_or_invalid" + + +def test_test_auth_success(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + body = resp.json() + assert body["authenticated"] is True + assert body["userId"] == 102963817 + assert body["reason"] is None diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py index ae084d93..22324746 100644 --- a/tradein-mvp/backend/tests/test_alerts_become_events.py +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -102,14 +102,32 @@ def test_harness_itself_drops_warnings() -> None: class _FakeMonitorDB: - """Session-мок мониторов свежести: один SELECT max(...).""" + """Session-мок мониторов свежести. - def __init__(self, latest: date | None) -> None: + Монитор сделок спрашивает только max(...). Монитор СберИндекса (#2846) спрашивает + ещё время последнего ПОЛНОГО прогона загрузки и её такт — именно они, а не + календарный возраст периода, решают, быть ли тревоге. + """ + + def __init__( + self, + latest: date | None, + last_pull: datetime | None = datetime(2026, 8, 6, 5, 0, tzinfo=UTC), + interval_days: str = "7", + ) -> None: self._latest = latest + self._last_pull = last_pull + self._interval_days = interval_days def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + sql = str(stmt) result = MagicMock() - result.first.return_value = MagicMock(latest=self._latest) + if "scrape_runs" in sql: + result.first.return_value = MagicMock(last_pull=self._last_pull) + elif "scrape_schedules" in sql: + result.first.return_value = MagicMock(interval_days=self._interval_days) + else: + result.first.return_value = MagicMock(latest=self._latest) return result def rollback(self) -> None: @@ -122,10 +140,13 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None: monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None) -def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: - """Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие.""" +def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Загрузка встала (полный прогон 20 суток назад при такте 7) → событие. + + 20 суток — реальный разрыв прод-истории между полными прогонами 07-17 и 08-06. + """ _patch_runs(monkeypatch, sber_mon) - db = _FakeMonitorDB(date(2026, 6, 1)) + db = _FakeMonitorDB(date(2026, 6, 1), last_pull=datetime(2026, 7, 17, 5, 38, tzinfo=UTC)) with glitchtip_events() as events: out = sber_mon.check_sber_freshness( db, # type: ignore[arg-type] @@ -136,19 +157,24 @@ def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: assert out["alert"] == 1 assert any( "sber freshness" in t for t in event_texts(events) - ), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает" + ), "отставание загрузки не стало событием — WARNING до GlitchTip не долетает" -def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: - """Свежие данные — ни одного события (иначе алерт-усталость).""" +def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод 2026-08-12: период старый (72 суток), но загрузка в такте — событий нет. + + Это ровно то состояние, в котором main двенадцатые сутки подряд писал ERROR: + возраст там был лагом ПУБЛИКАЦИИ Сбера, а не нашим отставанием. Алерт-усталость + от таких событий и делает настоящий отказ незаметным. + """ _patch_runs(monkeypatch, sber_mon) - db = _FakeMonitorDB(date(2026, 6, 1)) + db = _FakeMonitorDB(date(2026, 6, 1)) # last_pull = 2026-08-06 (полный прогон) with glitchtip_events() as events: out = sber_mon.check_sber_freshness( db, # type: ignore[arg-type] run_id=2, params={}, - now=datetime(2026, 6, 20, tzinfo=UTC), + now=datetime(2026, 8, 12, 19, 6, tzinfo=UTC), ) assert out["alert"] == 0 assert event_texts(events) == [] @@ -165,7 +191,7 @@ def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None params={}, now=datetime(2026, 8, 6, tzinfo=UTC), ) - assert any("sber_price_index пуст" in t for t in event_texts(events)) + assert any("у оценщика нет серии" in t for t in event_texts(events)) def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tradein-mvp/backend/tests/test_backfill_honest_status.py b/tradein-mvp/backend/tests/test_backfill_honest_status.py index ad72db34..a885b74e 100644 --- a/tradein-mvp/backend/tests/test_backfill_honest_status.py +++ b/tradein-mvp/backend/tests/test_backfill_honest_status.py @@ -5,7 +5,17 @@ 1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30 (494 попытки → 0 обогащено, 63 блока, 431 fail — и все 30 'done'). -Проверяем ровно ветвление mark_backfill_finished — БД замокана. +Проверяем ровно ветвление mark_backfill_finished — БД замокана (mark_done/mark_failed/ +mark_banned здесь fake-заглушки, регистрирующие ТОЛЬКО факт вызова). Это значит: кейсы +ниже с высокой долей отказов (attempted=50, failed=38 или 36 — 76%/72%), ожидающие +'done', проверяют лишь то, КАКОЙ финализатор ВЫБРАЛ mark_backfill_finished (#2674: +"обогатили хоть что-то — успех"), а НЕ то, что реально запишет в БД mark_done. С +honest-run-status (2026-08-15) mark_done САМ переквалифицирует такой прогон в 'failed' +через _failed_ratio_too_high (доля отказов >= 0.5) — реальный терминальный статус +для этих двух кейсов на проде теперь 'failed', не 'done'. Это намеренно проверяется +отдельно, БЕЗ мока mark_done, в tests/test_honest_run_status_failed_ratio.py +(test_prod_fact_avito_15_08_no_longer_done и соседние) — не читай эти два кейса как +"76%/72% отказов = 'done' в проде". """ from __future__ import annotations @@ -59,9 +69,15 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st ({"attempted": 5, "enriched": 0, "failed": 5}, False, "failed"), # Кандидатов не было — честная пустота, это успех. ({"attempted": 0, "enriched": 0, "blocked": 0, "failed": 0}, False, "done"), - # Частичный прогон: обогатили хоть что-то → успех. + # Частичный прогон: обогатили хоть что-то → mark_backfill_finished ВЫБИРАЕТ + # mark_done как финализатор (#2674). 76% отказов (38 из 50) — здесь mark_done + # замокан, поэтому статус остаётся 'done'; в реальном mark_done с + # honest-run-status (2026-08-15) это переквалифицируется в 'failed' + # (_failed_ratio_too_high, доля >= 0.5) — см. докстринг модуля. ({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"), - # Блоки были, но прогон доработал и обогатил — не бан. + # Блоки были, но прогон доработал и обогатил — mark_backfill_finished выбирает + # НЕ 'banned'. 72% отказов (36 из 50) — та же оговорка: реальный mark_done + # переквалифицирует в 'failed', см. докстринг модуля выше. ({"attempted": 50, "enriched": 12, "blocked": 2, "failed": 36}, False, "done"), # Блок оборвал прогон, хотя часть успели обогатить — работа не доделана. ({"attempted": 50, "enriched": 12, "blocked": 5, "failed": 33}, True, "banned"), diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py index 5ac78324..8a386aa4 100644 --- a/tradein-mvp/backend/tests/test_backfill_wave2.py +++ b/tradein-mvp/backend/tests/test_backfill_wave2.py @@ -500,7 +500,11 @@ async def test_backfill_house_imv_ok_path(): patch("app.services.house_imv_backfill.save_imv_result") as mock_save, ): mock_mappings = MagicMock() - mock_mappings.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + mock_mappings.all.side_effect = [[], houses] mock_db.execute.return_value.mappings.return_value = mock_mappings result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0) @@ -539,7 +543,11 @@ async def test_backfill_house_imv_no_params(): patch("app.services.house_imv_backfill._mark_status") as mock_mark, ): mock_mappings = MagicMock() - mock_mappings.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + mock_mappings.all.side_effect = [[], houses] mock_db.execute.return_value.mappings.return_value = mock_mappings result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0) @@ -593,7 +601,11 @@ async def test_backfill_house_imv_not_found(): patch("app.services.house_imv_backfill._mark_status") as mock_mark, ): mock_mappings = MagicMock() - mock_mappings.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + mock_mappings.all.side_effect = [[], houses] mock_db.execute.return_value.mappings.return_value = mock_mappings result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0) diff --git a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py index 1c44a0e6..1210e5ec 100644 --- a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py +++ b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py @@ -57,8 +57,9 @@ def _mock_db_bti( address: str | None = "Екатеринбург, улица Малышева, 51", lat: float = 56.83, lon: float = 60.6, + city: str | None = "Екатеринбург", ) -> MagicMock: - """Mock db: SELECT address/lat/lon FROM listings → mappings().first() dict.""" + """Mock db: SELECT address/city/lat/lon FROM listings → mappings().first() dict.""" db = MagicMock() @contextmanager @@ -72,9 +73,10 @@ def _mock_db_bti( mock_result = MagicMock() mock_result.fetchone.return_value = None mock_result.scalar_one_or_none.return_value = None - if "SELECT address, lat, lon FROM listings" in sql_str: + if "SELECT address, city, lat, lon FROM listings" in sql_str: mock_result.mappings.return_value.first.return_value = { "address": address, + "city": city, "lat": lat, "lon": lon, } @@ -110,6 +112,9 @@ def test_bti_data_present_updates_house_columns_via_coalesce(): assert kwargs["ext_source"] == "cian_bti" assert kwargs["ext_id"] == "123" assert kwargs["address"] == "Екатеринбург, улица Малышева, 51" + # #2777: город-цель развёртки (listings.city) — независимое от адреса наблюдение; + # без него бескоординатная BTI-карточка матчится в одноимённый дом другого города. + assert kwargs["city"] == "Екатеринбург" call = _bti_update_call(db) assert call is not None, "UPDATE houses с BTI-полями не найден" diff --git a/tradein-mvp/backend/tests/test_cian_session.py b/tradein-mvp/backend/tests/test_cian_session.py index ee4e50e6..4468e8ed 100644 --- a/tradein-mvp/backend/tests/test_cian_session.py +++ b/tradein-mvp/backend/tests/test_cian_session.py @@ -32,6 +32,21 @@ def mock_db() -> MagicMock: return db +@pytest.fixture(autouse=True) +def _mock_resolve_proxy_url_sync(monkeypatch: pytest.MonkeyPatch) -> None: + """#2825: verify_session больше не читает settings.cian_proxy_url напрямую, а + зовёт resolve_proxy_url_sync("cian") (пул scrape_proxies + fallback внутри + app.services.proxy_egress, отдельно покрыт tests/services/test_proxy_egress.py). + Без мока это реальный SessionLocal() -> живая (падающая в test-окружении) БД, + из-за чего verify_session уходил в generic except ДО session.get и все + verify_session-тесты ниже ловили не то, что проверяют. Мокаем на уровне модуля, + чтобы не трогать каждый тест по отдельности.""" + monkeypatch.setattr( + "app.services.cian_session.resolve_proxy_url_sync", + lambda source: "http://test-proxy.local:8080", + ) + + # --------------------------------------------------------------------------- # CIAN_REQUIRED_COOKIES # --------------------------------------------------------------------------- @@ -424,6 +439,40 @@ async def test_verify_session_network_error_returns_source_unavailable_sentinel( assert result is not None +@pytest.mark.asyncio +async def test_verify_session_pool_exhausted_returns_source_unavailable_with_error_log( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """#2825 fail-closed (#2616): пул scrape_proxies исчерпан для cian (все узлы + забанены/нездоровы) — session.get НЕ вызывается (никуда не ходим без egress), + возвращается VERIFY_SOURCE_UNAVAILABLE_SENTINEL, но с ERROR-логом (не warning, + отдельным от обычного network-error пути) — явная деградация, а не проглатывание.""" + from app.services.proxy_egress import ProxyPoolExhaustedError + + def _raise(source: str) -> str | None: + raise ProxyPoolExhaustedError( + source, pool_total=2, banned_for_source=2, unhealthy_or_disabled=0 + ) + + monkeypatch.setattr("app.services.cian_session.resolve_proxy_url_sync", _raise) + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.get = AsyncMock(return_value=_make_cffi_resp(200)) + + with ( + patch("app.services.cian_session.AsyncSession", return_value=mock_session), + caplog.at_level("WARNING"), + ): + result = await verify_session({"DMIR_AUTH": "x"}) + + assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL + mock_session.get.assert_not_called() + errors = [rec for rec in caplog.records if rec.levelname == "ERROR"] + assert any("пул прокси исчерпан" in rec.message.lower() for rec in errors) + + @pytest.mark.asyncio async def test_verify_session_uses_chrome120_impersonate() -> None: """curl_cffi AsyncSession must be constructed with impersonate='chrome120'.""" diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 3f132e1f..e138a150 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -31,20 +31,41 @@ def test_ekb_anchors_count() -> None: def test_resolve_city_name_known_oblast_slugs() -> None: - """Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя.""" - from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name + """Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя. - expected = { + #262 wave 2: CITY_LOCATIONS выросла с 5 (wave 1) до 45 (wave 1 + 40 wave-2 + городов) — вместо хардкода полного списка (дублировал бы CITY_DISPLAY_NAMES и + ломался при каждом новом городе) проверяем структурный инвариант: CITY_DISPLAY_ + NAMES обязан покрывать РОВНО те же slug'и, что CITY_LOCATIONS (иначе oblast-город + бы тихо получил ЕКБ-дефолт вместо своего имени) + spot-check wave-1 (не тронуты + этим PR) и по одному wave-2 city из каждой tier-группы (все три id / только + cian+yandex / только cian). + """ + from scraper_kit.orchestration.pipeline import ( + CITY_DISPLAY_NAMES, + CITY_LOCATIONS, + resolve_city_name, + ) + + wave1_expected = { "nizhniy_tagil": "Нижний Тагил", "kamensk_uralskiy": "Каменск-Уральский", "pervouralsk": "Первоуральск", "verkhnyaya_pyshma": "Верхняя Пышма", "serov": "Серов", } - # CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS - # (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени). - assert set(expected) == set(CITY_LOCATIONS) - for slug, name in expected.items(): + assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) + assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items() + for slug, name in wave1_expected.items(): + assert resolve_city_name(slug) == name + # wave-2 spot-check: novouralsk (avito+cian+yandex), revda (cian+yandex, avito + # НЕ подтверждён), mikhaylovsk (только cian, yandex отсутствует у источника). + for slug, name in { + "novouralsk": "Новоуральск", + "revda": "Ревда", + "mikhaylovsk": "Михайловск", + }.items(): + assert slug in CITY_LOCATIONS assert resolve_city_name(slug) == name @@ -62,6 +83,56 @@ def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None: assert resolve_city_name("nonexistent_city") == "Екатеринбург" +# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный +# ЕКБ-fallback) ─────────────────────────────────────────────────────────────────── + + +async def test_run_avito_city_sweep_raises_on_known_city_without_avito_slug() -> None: + """revda — известный CITY_LOCATIONS город, но avito_slug=None (не подтверждён). + + run_avito_city_sweep обязан упасть ДО любого сетевого/DB похода — иначе + `_city_seg()` молча взяла бы 'ekaterinburg' и sweep собрал бы ЕКБ под меткой + 'revda'. Raise происходит в самом начале функции (до await) — MagicMock() для + config/matcher/enrichment безопасен, до них не доходит.""" + from scraper_kit.orchestration.pipeline import run_avito_city_sweep + + with pytest.raises(ValueError, match="revda"): + await run_avito_city_sweep( + MagicMock(), + run_id=1, + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + city_slug="revda", + ) + + +async def test_run_yandex_city_sweep_raises_on_known_city_without_yandex_rgid() -> None: + """mikhaylovsk — известный CITY_LOCATIONS город, но yandex_rgid=None (город + отсутствует в гео-базе Яндекса вообще). run_yandex_city_sweep обязан упасть ДО + любого сетевого/DB похода — иначе YandexRealtyScraper(city_rgid=None) молча + взял бы ЕКБ rgid.""" + from scraper_kit.orchestration.pipeline import run_yandex_city_sweep + + with pytest.raises(ValueError, match="mikhaylovsk"): + await run_yandex_city_sweep( + MagicMock(), + run_id=1, + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + city_slug="mikhaylovsk", + ) + + +async def test_run_avito_city_sweep_does_not_raise_for_ekb_none_city_slug() -> None: + """city_slug=None (ЕКБ-путь, back-compat) НЕ должен затронуть новый guard — guard + условие `_loc is not None`, а get_city_location(None) возвращает None.""" + from scraper_kit.orchestration.pipeline import get_city_location + + assert get_city_location(None) is None + + # ── CitySweepCounters ─────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py index b87af5ae..b82bda69 100644 --- a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py +++ b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py @@ -15,8 +15,19 @@ WHAT: LeadForm.tsx (regex, no JSX parser needed -- there is exactly one in the file today) and assert it matches _CONSENT_TEXT_SNAPSHOT byte-for- byte after whitespace normalisation (JSX text nodes wrap across source - lines; the DOM-rendered text collapses that to single spaces). If someone - edits ONE side without the other, this test fails. + lines; the DOM-rendered text collapses that to single spaces). The label + now wraps a `` ("Политикой обработки персональных данных" is a + clickable link to the actual policy document, RKN/owner requirement -- + the extractor strips JSX tags AND `{" "}` expression-spacers, keeping only + the human-readable text, so the comparison stays a FLAT string on both + sides). If someone edits ONE side without the other, this test fails. + + A second test (`test_consent_policy_version_matches_privacy_approval_date`) + guards the OTHER half of the same drift class found during triage: nothing + was checking that _CONSENT_POLICY_VERSION actually points at the privacy + policy edition it claims to (PRIVACY_APPROVAL in mera-public/content.ts). + Bumping the policy text without bumping the version tag (or vice versa) + would silently mislabel every lead's proof-of-consent snapshot. NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet @@ -34,25 +45,71 @@ from pathlib import Path os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +_REPO_ROOT = Path(__file__).resolve().parents[2] + _FRONTEND_LEAD_FORM = ( - Path(__file__).resolve().parents[2] - / "frontend" - / "src" - / "components" - / "trade-in" - / "v2" - / "LeadForm.tsx" + _REPO_ROOT / "frontend" / "src" / "components" / "trade-in" / "v2" / "LeadForm.tsx" ) +_FRONTEND_LEGAL_CONTENT = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts" + +# Родительный падеж месяцев, как их пишет владелец в content.ts ("13 августа 2026 г."). +_RU_MONTHS_GENITIVE = { + "января": 1, + "февраля": 2, + "марта": 3, + "апреля": 4, + "мая": 5, + "июня": 6, + "июля": 7, + "августа": 8, + "сентября": 9, + "октября": 10, + "ноября": 11, + "декабря": 12, +} + def _extract_span_text(tsx_source: str) -> str: """Pull the text content of the (single) ... in LeadForm.tsx, whitespace-normalised the same way a browser collapses JSX text-node whitespace when rendering (multiple lines/indentation -> single spaces). + + The span may contain nested JSX markup (e.g. a wrapping part of the + label, and a `{" "}` expression-spacer forcing a real space between a text + node and the link on the next source line -- plain JSX whitespace between + a text node and a tag on separate lines collapses to NOTHING, not a + space, so LeadForm.tsx needs that explicit spacer for correct rendering). + Both are stripped here so the comparison is against the flat, human- + readable text a user actually sees -- not the markup. """ - match = re.search(r"\s*(.*?)\s*", tsx_source, re.DOTALL) + match = re.search(r"(.*?)", tsx_source, re.DOTALL) assert match is not None, "no found in LeadForm.tsx -- consent label markup changed" - return re.sub(r"\s+", " ", match.group(1)).strip() + inner = match.group(1) + inner = re.sub(r"\{\s*[\"']\s*[\"']\s*\}", " ", inner) # {" "} spacer -> real space + inner = re.sub(r"\{/\*.*?\*/\}", " ", inner, flags=re.DOTALL) # JSX comments + inner = re.sub(r"<[^>]+>", "", inner) # strip remaining JSX tags (e.g. , ) + return re.sub(r"\s+", " ", inner).strip() + + +def _extract_privacy_approval_iso_date(content_ts_source: str) -> str: + """Pull the "DD YYYY" date out of PRIVACY_APPROVAL in + mera-public/content.ts and return it as an ISO "YYYY-MM-DD" string. + + PRIVACY_APPROVAL ("приказом директора № 1 от 13 августа 2026 г.") is the + order that approves the actual privacy-policy EDITION the consent + checkbox links to (/mera-public/privacy) -- it is the correct source of + truth for _CONSENT_POLICY_VERSION, as opposed to LEGAL_DOCS_REVISION + (which dates the offer + refund-policy documents, a different pair). + """ + match = re.search(r'PRIVACY_APPROVAL\s*=\s*"([^"]+)"', content_ts_source) + assert match is not None, "PRIVACY_APPROVAL constant not found in mera-public/content.ts" + date_match = re.search(r"(\d{1,2})\s+([а-яё]+)\s+(\d{4})", match.group(1)) + assert date_match is not None, f"no RU date found in PRIVACY_APPROVAL: {match.group(1)!r}" + day, month_name, year = date_match.groups() + month = _RU_MONTHS_GENITIVE.get(month_name) + assert month is not None, f"unknown RU month name in PRIVACY_APPROVAL: {month_name!r}" + return f"{year}-{month:02d}-{int(day):02d}" def test_frontend_lead_form_exists() -> None: @@ -78,6 +135,29 @@ def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None: ) +def test_consent_policy_version_matches_privacy_approval_date() -> None: + """Guards the other half of the same drift class as the test above: + _CONSENT_POLICY_VERSION must point at the privacy-policy EDITION it + claims to (PRIVACY_APPROVAL in mera-public/content.ts), not just be some + unrelated date bumped by hand. A silent mismatch here would mislabel + every lead's proof-of-consent snapshot with the wrong policy edition.""" + from app.api.v1.lead import _CONSENT_POLICY_VERSION + + assert _FRONTEND_LEGAL_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_LEGAL_CONTENT}" + expected_version = _extract_privacy_approval_iso_date( + _FRONTEND_LEGAL_CONTENT.read_text(encoding="utf-8") + ) + + assert _CONSENT_POLICY_VERSION == expected_version, ( + "app/api/v1/lead.py._CONSENT_POLICY_VERSION does not match the privacy-policy " + "edition date derived from PRIVACY_APPROVAL in frontend/src/app/mera-public/" + "content.ts. Bump _CONSENT_POLICY_VERSION to the new edition date whenever " + "PRIVACY_APPROVAL changes (or vice versa).\n" + f" _CONSENT_POLICY_VERSION: {_CONSENT_POLICY_VERSION!r}\n" + f" PRIVACY_APPROVAL date: {expected_version!r}" + ) + + def test_extract_span_text_helper_is_whitespace_insensitive() -> None: """Sanity check on the extraction helper itself, independent of the real file.""" sample = """ @@ -87,3 +167,28 @@ def test_extract_span_text_helper_is_whitespace_insensitive() -> None: """ assert _extract_span_text(sample) == "Line one Line two" + + +def test_extract_span_text_helper_strips_nested_link_and_spacer() -> None: + """Sanity check: a wrapping part of the label (plus the {" "} + spacer JSX needs to force a real space before it) must collapse to plain + text, exactly like a browser renders it -- this is the shape LeadForm.tsx + actually uses today for the policy-document link.""" + sample = """ + + Согласен(-на) на обработку персональных данных в соответствии с{" "} + + Политикой обработки персональных данных + + + """ + assert _extract_span_text(sample) == ( + "Согласен(-на) на обработку персональных данных в соответствии с " + "Политикой обработки персональных данных" + ) + + +def test_extract_privacy_approval_iso_date_helper() -> None: + """Sanity check on the RU-date extraction helper, independent of the real file.""" + sample = 'export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г.";' + assert _extract_privacy_approval_iso_date(sample) == "2026-08-13" diff --git a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py new file mode 100644 index 00000000..65d85921 --- /dev/null +++ b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py @@ -0,0 +1,656 @@ +"""Tests for POST /api/v1/trade-in/coverage (issue #2894). + +Бесплатная проба покрытия для публичного лэндинга «МЕРА» — до оплаты человек +видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без +единой рублёвой цифры в ответе. Covers: + - пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов + - пустая когорта (n=0) → not_covered даже в поддерживаемом городе; threshold + принудительно 0 в этом случае (nit-fix, повторная проверка #2894) + - в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы) + - MAJOR-1 (независимый ревью #2894): когорта пробы — sync с + estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city', + price_rub > 0), не шире когорты платного эстиматора + - MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке, + выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану — запинено ЖИВЫМ SQL + (см. test_max_age_outlier_excluded_from_median_live), не только подстрокой + - Повторная проверка #2894 (2026-08): город резолвится ИСКЛЮЧИТЕЛЬНО по + lat/lon (ближайший центроид), НЕ по моде `listings.city` (город + свип-контекста скрейпера, не адреса объявления) и НЕ по `city_hint` + (непроверенный клиентский вход) — см. app.api.v1.trade_in._resolve_coverage_city +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +# psycopg v3 driver required; stub DATABASE_URL before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import +# (trade_in.py imports generate_trade_in_pdf at module load). +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +@pytest.fixture() +def trade_in_app() -> FastAPI: + """Minimal FastAPI app mounting only the trade-in router with DB overridden.""" + from app.api.v1 import trade_in as trade_in_module + from app.core.db import get_db + + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + + def _override_db(): + yield MagicMock() + + application.dependency_overrides[get_db] = _override_db + return application + + +def _row( + n_listings: int, + median_age_days: float | None, + n_with_age: int | None = None, +) -> dict: + """Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone(). + + n_with_age по умолчанию = n_listings, если не задан явно (большинство старых + тестов не проверяют MAJOR-2 отдельно — сохраняем их поведение). + + Повторная проверка #2894: строка больше не несёт cohort_city — город + резолвится по lat/lon запроса, не по SQL-агрегату (см. модуль-докстринг). + """ + return { + "n_listings": n_listings, + "median_age_days": median_age_days, + "n_with_age": n_with_age if n_with_age is not None else n_listings, + } + + +def _db_mock_returning(row: dict | None) -> MagicMock: + """DB session mock — coverage_probe reads db.execute(...).mappings().fetchone().""" + db = MagicMock() + mapping_result = MagicMock() + mapping_result.fetchone.return_value = row + execute_result = MagicMock() + execute_result.mappings.return_value = mapping_result + db.execute.return_value = execute_result + return db + + +def _override(app: FastAPI, db: MagicMock) -> None: + from app.core.db import get_db + + app.dependency_overrides[get_db] = lambda: (yield db) + + +# Екатеринбург — совпадает (с точностью до сотен метров) с центроидом +# _CITY_CENTROIDS_DEG["Екатеринбург"], поэтому дефолтный payload детерминированно +# резолвится в зелёный город без доп. настройки координат в каждом тесте. +_BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0} + +# Координаты других городов из COVERAGE_GREEN/YELLOW_CITIES (те же значения, что +# _CITY_CENTROIDS_DEG в trade_in.py) — используются, когда тесту нужен НЕ ЕКБ. +_NIZHNY_TAGIL = {"lat": 57.9099, "lon": 59.9819} +_REVDA = {"lat": 56.7986, "lon": 59.9298} +_BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034} + +# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил) +# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно +# "город не определён", без совпадения ни с одним из 8 центроидов. +_FAR_AWAY_CITY = {"lat": 59.6047, "lon": 60.1970} + + +# ── Response schema: NO price anywhere (issue #2894 hard rule) ──────────────── + +_PRICE_LIKE_SUBSTRINGS = ("price", "cena", "цена", "rub", "₽", "cost") + + +def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None: + """Regression guard: response schema must never grow a price-shaped field.""" + from app.schemas.trade_in import CoverageProbeResponse + + field_names = set(CoverageProbeResponse.model_fields.keys()) + offending = [f for f in field_names if any(sub in f.lower() for sub in _PRICE_LIKE_SUBSTRINGS)] + assert not offending, f"CoverageProbeResponse must not carry price fields: {offending}" + + +def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None: + """Same guard but on a live serialized response (belt-and-suspenders).""" + db = _db_mock_returning(_row(10, 21.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + assert resp.status_code == 200 + data = resp.json() + offending = [k for k in data if any(sub in k.lower() for sub in _PRICE_LIKE_SUBSTRINGS)] + assert not offending, f"response body must not carry price fields: {offending} in {data}" + + +# ── Thresholds: green city ───────────────────────────────────────────────────── + + +def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None: + """Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok.""" + db = _db_mock_returning(_row(8, 15.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["n_listings"] == 8 + assert data["threshold"] == 8 + assert data["city"] == "Екатеринбург" + assert data["radius_m"] == 1000 + assert data["median_listing_age_days"] == 15 + assert data["n_with_age"] == 8 + + +def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None: + """Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered.""" + db = _db_mock_returning(_row(7, 10.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + data = resp.json() + assert data["status"] == "thin" + assert data["n_listings"] == 7 + assert data["threshold"] == 8 + + +# ── Thresholds: yellow city ───────────────────────────────────────────────────── + + +def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None: + """Нижний Тагил (жёлтый, порог 12) — n=12 → ok. Город резолвится из lat/lon.""" + db = _db_mock_returning(_row(12, 30.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NIZHNY_TAGIL}) + data = resp.json() + assert data["status"] == "ok" + assert data["threshold"] == 12 + assert data["city"] == "Нижний Тагил" + + +def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None: + """Ревда, n=11 (< порог 12) → thin.""" + db = _db_mock_returning(_row(11, 40.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_REVDA}) + data = resp.json() + assert data["status"] == "thin" + assert data["threshold"] == 12 + + +# ── City outside all centroids → always not_covered ───────────────────────────── + + +def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None: + """Точка вне 25-км радиуса всех центроидов → not_covered независимо от n_listings + (даже n=500).""" + db = _db_mock_returning(_row(500, 5.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY}) + data = resp.json() + assert data["status"] == "not_covered" + assert data["threshold"] == 0 + assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует + assert data["city"] == "" # город не определён — не эхуется сырой строкой + + +# ── Empty cohort ────────────────────────────────────────────────────────────── + + +def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None: + """n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее. + + Nit-fix (повторная проверка #2894): threshold обязан быть 0, а не реальным + порогом города (8) — при not_covered threshold "неприменим" по докстрингу + CoverageProbeResponse, независимо от ПРИЧИНЫ not_covered. + """ + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + data = resp.json() + assert data["status"] == "not_covered" + assert data["n_listings"] == 0 + assert data["median_listing_age_days"] is None + assert data["n_with_age"] == 0 + assert data["city"] == "Екатеринбург" # город резолвится по координатам всегда + assert data["threshold"] == 0 # nit: не 8, хотя город поддерживаемый + + +def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None: + """DB возвращает None (defensive — count(*) агрегат всегда даёт строку, но + coverage_probe обязан не падать, даже если mock/driver вернул пусто).""" + db = _db_mock_returning(None) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "not_covered" + assert data["n_listings"] == 0 + assert data["median_listing_age_days"] is None + assert data["n_with_age"] == 0 + assert data["threshold"] == 0 + + +# ── Город резолвится ТОЛЬКО по координатам — не по listings.city, не по city_hint ── + + +def test_city_resolved_from_coordinates_not_cohort_mode(trade_in_app: FastAPI) -> None: + """Точка в Берёзовском → city='Берёзовский' (а не 'Екатеринбург'). + + Регресс на прод-замер (повторная проверка #2894): в радиусе 1000м вокруг + Берёзовского 90/90 строк listings имеют city='Екатеринбург' (город + свип-контекста скрейпера, миграция 196) — старая логика (мода когорты) + отдала бы 'Екатеринбург'. Ручка больше НЕ читает cohort city из SQL вовсе. + """ + db = _db_mock_returning(_row(8, 5.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY}) + data = resp.json() + assert data["city"] == "Берёзовский" + assert data["status"] == "ok" + assert data["threshold"] == 8 + + +def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None: + """Точка за пределами 25 км от всех 8 центроидов → not_covered, city="".""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY}) + data = resp.json() + assert data["status"] == "not_covered" + assert data["city"] == "" + assert data["threshold"] == 0 + + +def test_city_hint_does_not_change_threshold_or_status(trade_in_app: FastAPI) -> None: + """city_hint — чисто информационное поле (повторная проверка #2894): точка в + Берёзовском + city_hint='Екатеринбург' обязана резолвиться в Берёзовский + (threshold=8, зелёный порог — оба города зелёные, поэтому дополнительно + проверяем n=8 → ok именно для Берёзовского, а не подмену клиентом города). + """ + db_with_hint = _db_mock_returning(_row(8, 5.0)) + _override(trade_in_app, db_with_hint) + client = TestClient(trade_in_app) + resp_with_hint = client.post( + "/api/v1/trade-in/coverage", + json={**_BASE_PAYLOAD, **_BEREZOVSKY, "city_hint": "Екатеринбург"}, + ) + + db_without_hint = _db_mock_returning(_row(8, 5.0)) + _override(trade_in_app, db_without_hint) + resp_without_hint = client.post( + "/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY} + ) + + data_with, data_without = resp_with_hint.json(), resp_without_hint.json() + assert data_with["city"] == data_without["city"] == "Берёзовский" + assert data_with["threshold"] == data_without["threshold"] == 8 + assert data_with["status"] == data_without["status"] == "ok" + + +# ── MAJOR-2: median age — n_with_age threshold + outlier clamp ───────────────── + + +def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None: + """n_with_age=2 (< COVERAGE_MIN_AGE_SAMPLES=5) → median_listing_age_days null, + даже если SQL посчитал percentile — "медиана" по 1-2 объявлениям не медиана.""" + from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES + + assert COVERAGE_MIN_AGE_SAMPLES == 5 + db = _db_mock_returning(_row(20, 40.0, n_with_age=2)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + data = resp.json() + assert data["n_listings"] == 20 # когорта покрытия не урезается возрастным фильтром + assert data["n_with_age"] == 2 + assert data["median_listing_age_days"] is None + + +def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None: + """n_with_age=5 (== порог) → median_listing_age_days отдаётся.""" + db = _db_mock_returning(_row(20, 40.0, n_with_age=5)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + data = resp.json() + assert data["n_with_age"] == 5 + assert data["median_listing_age_days"] == 40 + + +def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None: + """COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые + объявления) отсекаются percentile_cont FILTER на стороне БД, не в Python. + + Слабая (текстовая) проверка — подстрока встречается в SQL ДВАЖДЫ (count и + percentile_cont), поэтому `assert "..." in sql_text` одна ловит только + "убрали оба FILTER", не "убрали один из двух". Реальный поведенческий пин — + test_max_age_outlier_excluded_from_median_live ниже (живой Postgres). + """ + from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS + + assert COVERAGE_MAX_AGE_DAYS == 365 + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + call_args = db.execute.call_args + params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {}) + assert params["max_age_days"] == 365 + + sql_text = str(call_args[0][0]) + # count==2: и в count(*) FILTER, и в percentile_cont(...) FILTER — обе нужны, + # чтобы n_with_age и median_listing_age_days считались по ОДНОМУ и тому же + # предикату (иначе честный n_with_age маскирует нечестный медианный расчёт). + assert sql_text.count("days_on_market <= :max_age_days") == 2 + + +# ── DB dedup / cap params passed through ──────────────────────────────────────── + + +def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None: + """SQL params: radius=1000 (строго), area ±15%, rooms exact.""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + assert db.execute.called + call_args = db.execute.call_args + params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {}) + assert params["radius"] == 1000 + assert params["rooms"] == 2 + assert params["area_min"] == pytest.approx(50.0 * 0.85) + assert params["area_max"] == pytest.approx(50.0 * 1.15) + assert params["fresh_days"] == 14 + + +# ── MAJOR-1: cohort predicates — sync с estimator._COMMON_WHERE / Tier W ──────── +# +# Прямая регрессия из независимого ревью #2894: без этих трёх предикатов проба +# отвечает "ok" в точках, где платный эстиматор (radius Tier W, тот же 1000м) +# реально видит 0 — потому что вся когорта состоит из новостроек / city-centroid +# листингов, которые estimator._COMMON_WHERE / Tier W уже отсекают. Тест ловит +# случайное удаление ЛЮБОГО из трёх предикатов на уровне сгенерированного SQL — +# без живой БД, как и остальные тесты этого файла (см. test_gar_flats_loader.py +# для опционального real-Postgres-варианта аналогичной проверки в этом репо). + + +def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None: + """Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932).""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + sql_text = str(db.execute.call_args[0][0]) + assert "listing_segment IS NULL OR listing_segment = 'vtorichka'" in sql_text + + +def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> None: + """geo_precision != 'city' — sync с estimator Tier W (5910/5945-5948, #769 Part E).""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + sql_text = str(db.execute.call_args[0][0]) + assert "geo_precision IS DISTINCT FROM 'city'" in sql_text + + +def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None: + """price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916).""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + sql_text = str(db.execute.call_args[0][0]) + assert "price_rub > 0" in sql_text + + +# ── Live-DB tests (self-skip без реальной Postgres+PostGIS) ──────────────────── +# +# Опциональные тесты против настоящего Postgres (тот же паттерн self-skip, что +# test_gar_flats_loader.py::_live_session) — требуют TEST_DATABASE_URL/ +# DATABASE_URL, указывающий на реальную БД (не дефолтный localhost:5432/test- +# заглушку); иначе skip. В CI (ci-tradein.yml) этот DSN всегда живой Postgres+ +# PostGIS-контейнер. +# +# Fix (повторная проверка #2894): раньше `_live_session()` вызывался И в +# `pytest.mark.skipif(...)` (на этапе СБОРА тестов — соединение открывалось и +# никогда не закрывалось, при реальном DSN это утечка на КАЖДЫЙ импорт файла), +# И повторно внутри тела единственного live-теста. Теперь доступность БД +# проверяется отдельной дешёвой функцией с явным закрытием соединения +# (`_live_db_available`), а сама Session выдаётся pytest-фикстурой +# (`live_session`) с гарантированным close() в finally, а не ручным вызовом. + + +def _live_db_available() -> bool: + """Дешёвая проверка доступности live-Postgres — соединение открывается и + СРАЗУ закрывается (`with engine.connect()`), никакого висящего ORM Session. + + Используется только в `pytest.mark.skipif(...)`, который вычисляется на + этапе сбора тестов — до фикстур. + """ + try: + from sqlalchemy import create_engine + from sqlalchemy import text as sa_text + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return False + engine = create_engine(dsn, future=True) + try: + with engine.connect() as conn: + conn.execute(sa_text("SELECT 1")) + return True + finally: + engine.dispose() + except Exception: + return False + + +@pytest.fixture() +def live_session(): # type: ignore[no-untyped-def] + """Session для live-Postgres тестов — гарантированно закрывается после теста + (rollback + close + dispose в finally), в отличие от прежнего ручного вызова + `_live_session()` внутри тела каждого теста.""" + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + engine = create_engine(dsn, future=True) + session_factory = sessionmaker(bind=engine, future=True) + session = session_factory() + try: + yield session + finally: + session.rollback() + session.close() + engine.dispose() + + +# Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют +# тестовую когорту от прод-данных без нужды в COMMIT/rollback гимнастики поверх +# чужой транзакции. +_LIVE_LAT, _LIVE_LON = 1.111, 2.222 + + +@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД") +def test_major1_cohort_excludes_novostroyki_and_city_precision_live(live_session) -> None: # type: ignore[no-untyped-def] + from sqlalchemy import text as sa_text + + from app.api.v1.trade_in import coverage_probe + from app.schemas.trade_in import CoverageProbeInput + + db = live_session + rows = [ + # (source_url suffix, listing_segment, geo_precision, price_rub) — все + # остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW(). + ("ok-vtorichka", None, None, 5_000_000), # counted + ("bad-novostroyka", "novostroyki", None, 5_000_000), # excluded + ("bad-city-precision", None, "city", 5_000_000), # excluded + ("bad-zero-price", None, None, 0), # excluded + ] + for suffix, segment, geo_precision, price in rows: + url = f"https://test.invalid/coverage-major1-{suffix}" + db.execute( + sa_text( + """ + INSERT INTO listings + (source, source_url, source_id, dedup_hash, address, lat, lon, + rooms, area_m2, price_rub, is_active, scraped_at, + listing_segment, geo_precision) + VALUES + ('test', :url, :url, :url, 'test addr', :lat, :lon, + 2, 50.0, :price, true, NOW(), :segment, :geo_precision) + """ + ), + { + "url": url, + "lat": _LIVE_LAT, + "lon": _LIVE_LON, + "price": price, + "segment": segment, + "geo_precision": geo_precision, + }, + ) + + result = coverage_probe( + CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db + ) + # Только первая (ok-vtorichka) строка должна попадать в когорту — + # каждая следующая вставка не должна сдвигать счётчик. + assert result.n_listings == 1, ( + f"predicate regression: n_listings={result.n_listings} after inserting " + f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} " + f"price={price}) — expected still 1 (only ok-vtorichka counted)" + ) + + +@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД") +def test_max_age_outlier_excluded_from_median_live(live_session) -> None: # type: ignore[no-untyped-def] + """MAJOR-2 поведенческий пин (повторная проверка #2894). + + Текстовый тест (test_max_age_outlier_days_passed_to_sql) проверял, что + подстрока `days_on_market <= :max_age_days` встречается в SQL — но она там + ДВАЖДЫ (count и percentile_cont), и мутация «убрать FILTER у + percentile_cont, оставив у count» проходила зелёной: n_with_age (из count) + оставался честным, а percentile_cont без FILTER считал медиану по ВСЕМ + days_on_market, включая выбросы. + + Вставляет когорту из 5 "нормальных" объявлений (days_on_market + 4/6/8/10/12, честная медиана — 8) и один выброс (days_on_market=4000, + > COVERAGE_MAX_AGE_DAYS=365). Проверяет, что после вставки выброса + n_with_age и median_listing_age_days НЕ меняются (выброс попадает только + в n_listings) — с правильными двумя FILTER это так; без FILTER у + percentile_cont медиана сдвинулась бы 8 → 9 (percentile_cont(0.5) по + [4,6,8,10,12,4000] = среднее 3-го и 4-го отсортированных значений = 9). + """ + from sqlalchemy import text as sa_text + + from app.api.v1.trade_in import coverage_probe + from app.schemas.trade_in import CoverageProbeInput + + db = live_session + normal_ages = [4, 6, 8, 10, 12] + for i, age in enumerate(normal_ages): + url = f"https://test.invalid/coverage-major2-normal-{i}" + db.execute( + sa_text( + """ + INSERT INTO listings + (source, source_url, source_id, dedup_hash, address, lat, lon, + rooms, area_m2, price_rub, is_active, scraped_at, days_on_market) + VALUES + ('test', :url, :url, :url, :addr, :lat, :lon, + 2, 50.0, 5000000, true, NOW(), :age) + """ + ), + { + "url": url, + "addr": f"test addr coverage-major2-{i}", + "lat": _LIVE_LAT, + "lon": _LIVE_LON, + "age": age, + }, + ) + + result = coverage_probe( + CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db + ) + assert result.n_listings == 5 + assert result.n_with_age == 5 + assert result.median_listing_age_days == 8 + + outlier_url = "https://test.invalid/coverage-major2-outlier" + db.execute( + sa_text( + """ + INSERT INTO listings + (source, source_url, source_id, dedup_hash, address, lat, lon, + rooms, area_m2, price_rub, is_active, scraped_at, days_on_market) + VALUES + ('test', :url, :url, :url, 'test addr coverage-major2-outlier', :lat, :lon, + 2, 50.0, 5000000, true, NOW(), 4000) + """ + ), + {"url": outlier_url, "lat": _LIVE_LAT, "lon": _LIVE_LON}, + ) + + result_with_outlier = coverage_probe( + CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db + ) + assert result_with_outlier.n_listings == 6 # выброс всё же попадает в n_listings + assert result_with_outlier.n_with_age == 5, ( + f"MAJOR-2 regression: outlier (days_on_market=4000 > MAX=365) leaked into " + f"n_with_age={result_with_outlier.n_with_age} — count(*) FILTER пропал/сломан" + ) + assert result_with_outlier.median_listing_age_days == 8, ( + f"MAJOR-2 regression: median_listing_age_days=" + f"{result_with_outlier.median_listing_age_days} shifted by outlier — " + f"percentile_cont(...) FILTER пропал (мутация «убрать FILTER у " + f"percentile_cont, оставив у count»)" + ) diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_listings.py b/tradein-mvp/backend/tests/test_deactivate_stale_listings.py index f41dc200..a236a419 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_listings.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_listings.py @@ -424,3 +424,184 @@ def test_migration_160_is_transactional() -> None: def test_migration_160_no_psycopg_trap() -> None: sql = _MIGRATION_160.read_text("utf-8") assert not re.search(r":\w+::", sql) + + +# ── null_segment_only (пустой listing_segment yandex/cian, никогда не переобходится) ── + + +def test_null_segment_sql_uses_is_null_not_any() -> None: + sql = str(task_mod._build_null_segment_sql("last_seen_at").text) + assert "listing_segment IS NULL" in sql + assert "ANY(CAST(:segments AS text[]))" not in sql + assert ":segments" not in sql + + +def test_null_segment_sql_filters_is_active_and_source() -> None: + sql = str(task_mod._build_null_segment_sql("last_seen_at").text) + assert "is_active = true" in sql + assert ":listing_source" in sql + assert "SET is_active = false" in sql + assert "DELETE" not in sql.upper() + + +def test_null_segment_sql_no_psycopg_trap() -> None: + sql = str(task_mod._build_null_segment_sql("last_seen_at").text) + assert not re.search(r":\w+::", sql) + assert "CAST(:ttl_days || ' days' AS interval)" in sql + + +def test_null_segment_only_and_segments_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """null_segment_only=True + segments заданы -- неоднозначный запрос, ValueError.""" + failed: dict[str, Any] = {} + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err), + ) + db = _FakeDB(rowcount=0) + with pytest.raises(ValueError, match="null_segment_only"): + task_mod.deactivate_stale_listings( + db, + run_id=20, + listing_source="cian", + ttl_days=60, + segments=["vtorichka"], + null_segment_only=True, + ) # type: ignore[arg-type] + assert db.executed == [] + assert failed["run_id"] == 20 + + +def test_null_segment_only_deactivates_via_is_null(monkeypatch: pytest.MonkeyPatch) -> None: + marked: dict[str, Any] = {} + monkeypatch.setattr( + task_mod.runs_mod, + "mark_done", + lambda _db, run_id, counters: marked.update(run_id=run_id, counters=dict(counters)), + ) + monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None) + db = _FakeDB(rowcount=211) + out = task_mod.deactivate_stale_listings( + db, + run_id=21, + listing_source="cian", + ttl_days=60, + null_segment_only=True, + ) # type: ignore[arg-type] + assert out == {"deactivated": 211} + assert db.committed is True + stmt, params = db.executed[0] + sql = _sql_text(stmt) + assert "listing_segment IS NULL" in sql + assert params is not None + assert "segments" not in params + assert params["listing_source"] == "cian" + assert params["ttl_days"] == 60 + assert marked["counters"] == {"deactivated": 211} + + +def test_null_segment_only_confirmations_sql_uses_is_null() -> None: + sql = str( + task_mod._build_confirmations_sql( + "last_seen_at", with_segments=False, null_segment_only=True + ).text + ) + assert "listing_segment IS NULL" in sql + assert ":segments" not in sql + + +def test_null_segment_only_revisit_floor_sql_uses_is_null() -> None: + sql = str( + task_mod._build_revisit_floor_sql( + "last_seen_at", with_segments=False, null_segment_only=True + ).text + ) + assert "l.listing_segment IS NULL" in sql + assert ":segments" not in sql + + +def test_null_segment_only_default_is_false(monkeypatch: pytest.MonkeyPatch) -> None: + """Обратная совместимость: старые вызовы без null_segment_only ведут себя как раньше.""" + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None) + db = _FakeDB(rowcount=3) + task_mod.deactivate_stale_listings( + db, run_id=22, listing_source="avito", ttl_days=10, segments=None + ) # type: ignore[arg-type] + stmt, _params = db.executed[0] + sql = _sql_text(stmt) + assert "listing_segment IS NULL" not in sql + + +# ── Migration 266 (deactivate_stale_yandex_null_segment / _cian_null_segment) ─────── +# Renumbered 264 -> 266 (collision with forgejo/main's 264_deactivate_stale_avito_cap_mult.sql +# / 265_deactivate_stale_yandex_cap_mult.sql, merged после того как эта ветка забрала 264). + +_MIGRATION_266 = _SQL_DIR / "266_seed_deactivate_stale_null_segment_yandex_cian.sql" + + +def test_migration_266_exists() -> None: + assert _MIGRATION_266.is_file(), f"missing migration: {_MIGRATION_266}" + + +def test_migration_266_seeds_yandex_and_cian_null_segment() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert "'deactivate_stale_yandex_null_segment'" in sql + assert "'deactivate_stale_cian_null_segment'" in sql + + +def test_migration_266_null_segment_only_true() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert '"null_segment_only":true' in sql + + +def test_migration_266_ttl_60_days() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert '"ttl_days":60' in sql + + +def test_migration_266_gates_disabled() -> None: + """min_confirmations/revisit_floor_quantile выключены явно -- население слишком + мало для порогов, откалиброванных под полноценный vtorichka-свип (см. файл).""" + sql = _MIGRATION_266.read_text("utf-8") + assert '"min_confirmations":0' in sql + assert '"revisit_floor_quantile":0' in sql + + +def test_migration_266_is_idempotent() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert "ON CONFLICT (source) DO NOTHING" in sql + + +def test_migration_266_is_transactional() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_266_enabled_true() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert "true" in sql + + +def test_migration_266_window_7_to_8_utc() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert re.search(r"\b7\b", sql), "window_start_hour 7 missing" + assert re.search(r"\b8\b", sql), "window_end_hour 8 missing" + + +def test_migration_266_no_psycopg_trap() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +def test_handler_wires_null_segment_only_from_schedule_params() -> None: + """Читаем исходник файлом (как test_handler_wires_revisit_floor_from_schedule_params): + product_handlers тянет scraper_kit, которого в юнит-окружении может не быть.""" + handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py" + src = handlers.read_text("utf-8") + job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0] + flat = " ".join(job.split()) + assert 'params.get("null_segment_only", False)' in flat + assert "null_segment_only=null_segment_only" in job diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py new file mode 100644 index 00000000..fbfc49f5 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py @@ -0,0 +1,444 @@ +"""Пол TTL по измеренному циклу переобхода (#2659). + +Гейт здоровья (#2710) отвечает «источник собирается?». Этот пол отвечает на второй +вопрос issue — «а достаточно ли ttl_days, чтобы молчание означало снятие?». Пока свип +возвращается к строке реже, чем раз в ttl_days, TTL меряет нашу выборку, а не жизнь +объявления, и источник при этом ЗДОРОВ — гейт молчит. + +Ключевой тест — test_effective_ttl_covers_every_proven_false_kill: он проигрывает +РЕАЛЬНЫЙ прод-случай (127 строк, снятых на возрасте 29.9..30.3 суток при TTL=30 и +доказанно вернувшихся живыми) и требует, чтобы эффективный TTL накрыл каждую. На +старом коде — без пола — эффективный TTL остаётся 30, и тест падает на всех срезах. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import deactivate_stale_avito as task_mod + +# ── Прод-замер 2026-08-09 (read-only), из-за которого пол существует ─────────── +# С деплоя гейта 06.08 TTL снял 1 028 строк, 127 уже снова активны (12.4%). +# В Екатеринбурге — единственном городе с настоящим покрытием — доля ложных 100%. +_FALSE_KILLS_BY_CITY: dict[str, tuple[int, int]] = { # срез -> (снято, снова активны) + "cian/Екатеринбург": (103, 103), + "yandex/Екатеринбург": (24, 24), + "cian/без города": (560, 0), + "yandex/без города": (341, 0), +} +# Возраст строки на момент снятия у всех 127 доказанно ложных снятий, суток. +_FALSE_KILL_AGE_MIN = 29.9 +_FALSE_KILL_AGE_MAX = 30.3 + +# Пол, который отдаёт ТОТ ЖЕ запрос на проде (percentile_disc 0.99, окно 3 суток, +# срез = срез TTL-джобы). Ключ -> (listing_source, segments, ttl_days сейчас, пол). +_PROD_FLOORS: dict[str, tuple[str, list[str] | None, int, float]] = { + "cian/vtorichka": ("cian", ["vtorichka"], 30, 34.0), + "yandex/vtorichka": ("yandex", ["vtorichka"], 30, 74.3), + "avito/все сегменты": ("avito", None, 10, 69.7), +} + +# Разрывы переобхода по срезу TTL-джобы (listing_source_snapshots, 40 суток): +# источник -> (p90, p99, ttl_days сейчас). Домклик — контрольная группа: почти +# полное суточное покрытие, TTL лежит в 4.5 раза выше хвоста, и снятие у него +# действительно означает снятие. +_REVISIT_TAIL: dict[str, tuple[float, float, int]] = { + "domklik/vtorichka": (1.9, 3.1, 14), + "cian/vtorichka": (10.9, 26.6, 30), + "yandex/vtorichka": (5.7, 43.0, 30), + "avito/vtorichka": (29.1, 42.1, 10), +} + + +# ── Фейковая сессия ─────────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None: + self.rowcount = rowcount + self._scalar = scalar_value + + def scalar(self) -> Any: + return self._scalar + + +class _FakeDB: + """Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount.""" + + def __init__( + self, + *, + floor_days: float | None, + confirmations: int = 10_000, + rowcount: int = 137, + ) -> None: + self._floor = floor_days + self._confirmations = confirmations + self._rowcount = rowcount + self.executed: list[tuple[str, dict[str, Any] | None]] = [] + self.committed = False + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt.text) + self.executed.append((sql, params)) + if "percentile_disc" in sql: + return _FakeResult(scalar_value=self._floor) + if "SELECT count(*)" in sql: + return _FakeResult(scalar_value=self._confirmations) + return _FakeResult(rowcount=self._rowcount) + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: + self.rolled_back = True + + @property + def floor_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "percentile_disc" in e[0]), ("", None)) + + @property + def update_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None)) + + +def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]: + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None) + return task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 1, + listing_source=kwargs.pop("listing_source", "cian"), + ttl_days=kwargs.pop("ttl_days", 30), + **kwargs, + ) + + +# ── Исторический случай: 127 доказанных ложных снятий ───────────────────────── + + +def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одно из 127 доказанно ложных снятий (cian/yandex) не должно повториться. + + Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть + строго выше этого возраста на cian/yandex-срезах — иначе следующий прогон + снимет ту же строку снова. avito из этого цикла исключён намеренно: 127 + доказанных ложных снятий (_FALSE_KILLS_BY_CITY) измерены только по cian/yandex, + у avito другой сценарий и своя проверка ниже + (test_avito_prod_floor_is_capped_by_calibrated_cap_mult) -- калибровка cap_mult=6 + для avito (миграция 264_deactivate_stale_avito_cap_mult.sql) пиннится ТАМ, а не + здесь, чтобы не смешивать два разных замера под одним порогом + _FALSE_KILL_AGE_MAX, который к avito не относится. + """ + for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items(): + if source == "avito": + continue + db = _FakeDB(floor_days=floor) + out = _run( + db, + monkeypatch, + listing_source=source, + ttl_days=ttl_days, + segments=segments, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + ) + effective = out["ttl_days_effective"] + assert effective > _FALSE_KILL_AGE_MAX, ( + f"{slice_name}: эффективный TTL {effective} не накрывает возраст ложного " + f"снятия {_FALSE_KILL_AGE_MAX} — 127 строк снимутся снова" + ) + _, update_params = db.update_query + assert update_params is not None + assert ( + update_params["ttl_days"] == effective + ), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен" + + +def _read_cap_mult_from_migration(filename: str, *, source: str) -> int: + """Читает cap_mult из UPDATE default_params миграции -- НЕ хардкодит дубль в тесте. + + Найдено ревью круга 3 2026-08-15: раньше тест ниже принимал cap_mult=6 как + аргумент напрямую, захардкоженный прямо в теле теста. Мутация значения в + 264_deactivate_stale_avito_cap_mult.sql (6 -> 2) НЕ трогала вход теста вовсе -- + набор оставался зелёным при любом реальном значении в миграции, то есть + калибровка нигде не была пином, только упоминанием в комментарии. Здесь + значение читается ИЗ ФАЙЛА миграции regex'ом, а ожидаемый результат + (ttl_days_effective, ttl_floor_capped) остаётся зафиксированным числом в самом + тесте -- так дрейф калибровки в миграции ломает тест, как и задумано. + """ + migration = Path(__file__).resolve().parents[1] / "data" / "sql" / filename + src = migration.read_text("utf-8") + # Порядок в файле -- jsonb_build_object('cap_mult', N) в SET, ЗАТЕМ WHERE source + # = '' ниже (см. 264/265_*.sql). DOTALL матчит перевод строки между ними; + # source в regex -- страховка от чтения не того UPDATE, если файл когда-нибудь + # станет мульти-source (сейчас в каждом файле ровно один UPDATE). + match = re.search( + r"jsonb_build_object\('cap_mult',\s*(\d+)\).*?WHERE\s+source\s*=\s*'" + + re.escape(source) + + r"'", + src, + re.DOTALL, + ) + assert match is not None, ( + f"{filename} сменил формат UPDATE default_params для source={source!r} -- " + "обнови regex в _read_cap_mult_from_migration" + ) + return int(match.group(1)) + + +def test_avito_prod_floor_is_capped_by_calibrated_cap_mult(monkeypatch: pytest.MonkeyPatch) -> None: + """Пиннит калибровку cap_mult=6 для avito (миграция + 264_deactivate_stale_avito_cap_mult.sql) на измеренном прод-поле _PROD_FLOORS + ("avito/все сегменты" = 69.7, замер 2026-08-09). + + cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции (regex), не хардкодится + здесь: дрейф калибровки в 264_*.sql (например 6 -> 2) меняет вход, но НЕ + ожидаемый результат ниже (60/70) -- эти числа пинят калибровку саму по себе, + поэтому дрейф ломает тест, как и задумано (см. _read_cap_mult_from_migration). + + С дефолтным cap_mult=2 потолок avito (20 сут) РЕЖЕТ ниже собственного хвоста + переобхода p99=42.1 (_REVISIT_TAIL) -- ровно тот false-kill, ради которого пол + заведён. С калиброванным cap_mult=6 потолок 60 сут -- выше и p99=42.1, и живого + прод-пика 52 (замер 08-10..08-12), и этого гипотетического замера 69.7 (капается + ровно на 60, не пропускается как есть). + """ + calibrated_cap_mult = _read_cap_mult_from_migration( + "264_deactivate_stale_avito_cap_mult.sql", source="deactivate_stale_avito" + ) + source, segments, ttl_days, floor = _PROD_FLOORS["avito/все сегменты"] + db = _FakeDB(floor_days=floor) + out = _run( + db, + monkeypatch, + listing_source=source, + ttl_days=ttl_days, + segments=segments, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + cap_mult=calibrated_cap_mult, + ) + assert out["ttl_days_effective"] == 60, "cap_mult из миграции 264 обязан дать потолок 60" + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 70, "ceil(69.7) == 70 -- пол считается по real-числу" + + +def test_yandex_prod_floor_is_not_capped_by_calibrated_cap_mult( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Пиннит калибровку cap_mult=3 для yandex (миграция + 265_deactivate_stale_yandex_cap_mult.sql, найдено ревью круга 3 2026-08-15) на + измеренном прод-поле _PROD_FLOORS ("yandex/vtorichka" = 74.3). + + cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции 265 (тот же приём, что и у + avito выше): дрейф калибровки в 265_*.sql ломает тест. + + С дефолтным cap_mult=2 потолок yandex (60 сут) РЕЖЕТ живой пол (75-79 сут, + scrape_runs.counters 08-10..08-15 и live-замер 08-15) -- та же дыра, что у + avito, найдена позже (первая версия 264 ошибочно считала yandex безопасным по + устаревшему статическому p99=43.0). С калиброванным cap_mult=3 потолок 90 сут + выше живого пика 79.2 -- пол 74.3 из этого теста НЕ капается, эффективный TTL + равен сырому полу (75, ceil(74.3)). + """ + calibrated_cap_mult = _read_cap_mult_from_migration( + "265_deactivate_stale_yandex_cap_mult.sql", source="deactivate_stale_yandex" + ) + source, segments, ttl_days, floor = _PROD_FLOORS["yandex/vtorichka"] + db = _FakeDB(floor_days=floor) + out = _run( + db, + monkeypatch, + listing_source=source, + ttl_days=ttl_days, + segments=segments, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + cap_mult=calibrated_cap_mult, + ) + assert out["ttl_days_effective"] == 75, "ceil(74.3) == 75, потолок 90 не должен резать" + assert "ttl_floor_capped" not in out, "потолок 90 выше живого пола 74.3 -- капать нечего" + + +def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше.""" + assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX + for source, _segments, ttl_days, _floor in _PROD_FLOORS.values(): + if source in ("cian", "yandex"): + assert ttl_days == 30, f"{source}: прод-TTL разошёлся с замером" + + +def test_false_kill_rate_is_total_where_coverage_is_real() -> None: + """В городе с настоящим покрытием ложны ВСЕ снятия — это и есть корень #2659.""" + for slice_name in ("cian/Екатеринбург", "yandex/Екатеринбург"): + killed, returned = _FALSE_KILLS_BY_CITY[slice_name] + assert returned == killed, f"{slice_name}: замер разошёлся" + total_killed = sum(k for k, _ in _FALSE_KILLS_BY_CITY.values()) + total_returned = sum(r for _, r in _FALSE_KILLS_BY_CITY.values()) + assert total_killed == 1028 + assert total_returned == 127 + + +def test_control_group_domklik_needs_no_floor() -> None: + """Домклик — контроль: при почти полном суточном обходе TTL с запасом выше хвоста. + + Если бы пол был нужен всем подряд, он был бы нужен и источнику со сплошным + покрытием. Ему не нужен — значит меряем именно покрытие, а не «TTL маловат». + + Разделяет источники ЗАПАС над хвостом, а не сам факт превышения: у домклика + TTL/p99 = 4.5, у остальных трёх 0.24..1.13, то есть порог сидит вплотную к + хвосту или внутри него. Именно у самого «благополучного» из трёх (cian, 1.13) + и случились 103 доказанно ложных снятия: p99 запаса не даёт, снимает 1% живых. + """ + _p90, p99, ttl = _REVISIT_TAIL["domklik/vtorichka"] + assert ttl / p99 > 4, "домклик перестал быть контрольной группой — перемерить" + for name, (_p90, p99, ttl) in _REVISIT_TAIL.items(): + if name.startswith("domklik"): + continue + assert ttl / p99 < 1.5, f"{name}: TTL отошёл от хвоста обхода, замер устарел" + + +# ── Контракт пола ───────────────────────────────────────────────────────────── + + +def test_floor_never_lowers_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол поднимает порог и только. Здоровый источник с коротким циклом не даёт + поводу снять больше, чем задано оператором.""" + db = _FakeDB(floor_days=3.1) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 30 + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 30 + + +def test_floor_is_rounded_up_not_down(monkeypatch: pytest.MonkeyPatch) -> None: + """34.0 суток разрыва -> TTL 34, а 34.2 -> 35: округление в сторону осторожности.""" + db = _FakeDB(floor_days=34.2) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert out["revisit_floor_days"] == 35 + assert out["ttl_days_effective"] == 35 + + +def test_floor_disabled_keeps_old_behaviour(monkeypatch: pytest.MonkeyPatch) -> None: + """quantile=0 -> ни одного лишнего запроса, поведение как до правки.""" + db = _FakeDB(floor_days=74.3) + out = _run(db, monkeypatch, ttl_days=30) + assert out == {"deactivated": 137} + assert len(db.executed) == 1 + assert "percentile_disc" not in db.executed[0][0] + + +def test_missing_snapshot_history_leaves_ttl_as_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """NULL (истории снимков за окно нет) -> пол не выдумывается, TTL как задан.""" + db = _FakeDB(floor_days=None) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert "revisit_floor_days" not in out + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 30 + + +def test_floor_runs_before_any_write(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол считается ДО UPDATE: снятое объявление возвращает только повторный сбор.""" + db = _FakeDB(floor_days=74.3) + _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + kinds = ["floor" if "percentile_disc" in sql else "update" for sql, _ in db.executed] + assert kinds.index("floor") < kinds.index("update") + + +def test_floor_measures_same_slice_as_update(monkeypatch: pytest.MonkeyPatch) -> None: + """Срез пола совпадает со срезом UPDATE: тот же source и те же сегменты.""" + db = _FakeDB(floor_days=74.3) + _run( + db, + monkeypatch, + listing_source="yandex", + segments=["vtorichka"], + revisit_floor_quantile=0.99, + ) + floor_sql, floor_params = db.floor_query + assert "ANY(CAST(:segments AS text[]))" in floor_sql + assert floor_params is not None + assert floor_params["segments"] == ["vtorichka"] + assert floor_params["listing_source"] == "yandex" + + +def test_floor_uses_same_staleness_column_as_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """domklik считает свежесть по scraped_at (#2204) — пол обязан мерить ту же колонку, + иначе bulk-touch по last_seen_at показал бы цикл обхода там, где сбора нет.""" + db = _FakeDB(floor_days=20.0) + _run( + db, + monkeypatch, + listing_source="domklik", + ttl_days=14, + staleness_column="scraped_at", + revisit_floor_quantile=0.99, + ) + floor_sql, _ = db.floor_query + assert "l.scraped_at" in floor_sql + assert "l.last_seen_at" not in floor_sql + + +def test_floor_rejects_invalid_staleness_column(monkeypatch: pytest.MonkeyPatch) -> None: + """Whitelist колонки работает и на пути пола — интерполяции чужого имени нет.""" + db = _FakeDB(floor_days=20.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, staleness_column="is_active", revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_health_gate_still_wins_over_floor(monkeypatch: pytest.MonkeyPatch) -> None: + """Нездоровый источник блокируется гейтом ДО того, как считается пол: + лишний тяжёлый запрос по мёртвому источнику не нужен.""" + db = _FakeDB(floor_days=74.3, confirmations=10) + out = _run( + db, + monkeypatch, + min_confirmations=500, + revisit_floor_quantile=0.99, + ) + assert out["skipped_unhealthy"] == 1 + assert db.floor_query[0] == "" + assert db.update_query[0] == "" + + +def test_floor_sql_is_psycopg_v3_safe() -> None: + sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=True).text) + assert "CAST(:revisit_quantile AS double precision)" in sql + assert "CAST(:health_window_days AS integer)" in sql + assert not re.search(r":\w+::", sql) + assert "UPDATE" not in sql.upper() + assert "DELETE" not in sql.upper() + + +def test_floor_only_counts_rows_the_sweep_actually_refound() -> None: + """Пол меряет выживших, а не «мы к ним не приходили»: свежесть обязана СДВИНУТЬСЯ + относительно предыдущего снимка, иначе разрыв нулевой и хвост схлопнется в ноль.""" + sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False).text) + assert "l.last_seen_at > prev.last_seen_at" in sql + + +def test_default_quantile_is_high_enough_for_the_prod_case() -> None: + """Ниже 0.99 опускать нельзя без нового замера: именно на 0.99 прод-запрос даёт + 34.0 для cian/vtorichka, что накрывает возраст ложных снятий 30.3.""" + assert task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE >= 0.99 + assert _PROD_FLOORS["cian/vtorichka"][3] > _FALSE_KILL_AGE_MAX + + +def test_handler_wires_revisit_floor_from_schedule_params() -> None: + """Читаем исходник файлом: product_handlers тянет scraper_kit, которого в + юнит-окружении может не быть, а проверяем мы проводку, а не импорт.""" + handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py" + src = handlers.read_text("utf-8") + job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0] + flat = " ".join(job.split()) + assert 'params.get( "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE )' in flat + assert "revisit_floor_quantile=revisit_floor_quantile" in job diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py new file mode 100644 index 00000000..ddbaa559 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py @@ -0,0 +1,443 @@ +"""Потолок эффективного TTL деактивации (найдено на проде 2026-08-15). + +Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py) поднимает +эффективный TTL через max(ttl_days, пол) без верхней границы. На проде это оказалось +петлёй с положительной обратной связью: медленный обход поднимает пол, высокий пол +продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться свежий обход, пул +«активных» раздувается протухшими строками. У yandex ttl_days_effective держали +75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 -- это и есть разгон пола, +ради которого потолок написан. Цифру «23 687 из 44 744» из исходного разбора сюда НЕ +переносим: она про все источники сразу, две трети её -- новостройки вне выборки +оценщика, а у самого avito просроченных строк ноль (уточнено 2026-08-15). + +Этот файл проверяет CAP_MULT -- потолок, не пускающий эффективный TTL выше +ttl_days * CAP_MULT, независимо от того, насколько высоко посчитанный пол. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import deactivate_stale_avito as task_mod + +# ── Фейковая сессия (тот же контракт, что в test_deactivate_stale_revisit_floor.py) ── + + +class _FakeResult: + def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None: + self.rowcount = rowcount + self._scalar = scalar_value + + def scalar(self) -> Any: + return self._scalar + + +class _FakeDB: + """Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount.""" + + def __init__( + self, + *, + floor_days: float | None, + confirmations: int = 10_000, + rowcount: int = 137, + ) -> None: + self._floor = floor_days + self._confirmations = confirmations + self._rowcount = rowcount + self.executed: list[tuple[str, dict[str, Any] | None]] = [] + self.committed = False + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt.text) + self.executed.append((sql, params)) + if "percentile_disc" in sql: + return _FakeResult(scalar_value=self._floor) + if "SELECT count(*)" in sql: + return _FakeResult(scalar_value=self._confirmations) + return _FakeResult(rowcount=self._rowcount) + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: + self.rolled_back = True + + @property + def update_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None)) + + +def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]: + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None) + return task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 1, + listing_source=kwargs.pop("listing_source", "avito"), + ttl_days=kwargs.pop("ttl_days", 10), + **kwargs, + ) + + +# ── Контракт из задачи ───────────────────────────────────────────────────────── + + +def test_high_floor_is_capped_at_double_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """revisit_floor=75, ttl_days=10 -> итог 20 (потолок 2x), НЕ 75.""" + db = _FakeDB(floor_days=75.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 20 + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 75 + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 20, "UPDATE обязан получить капнутый TTL, не сырой пол" + + +def test_low_floor_leaves_ttl_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: + """revisit_floor=5, ttl_days=10 -> итог 10 (пол ниже заданного TTL, max() его не поднимает).""" + db = _FakeDB(floor_days=5.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 + assert "ttl_floor_capped" not in out + assert "ttl_days_floor_raw" not in out + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 10 + + +# ── Контракт потолка ──────────────────────────────────────────────────────────── + + +def test_cap_mult_is_named_module_constant_equal_two() -> None: + assert task_mod.CAP_MULT == 2 + + +def test_floor_between_ttl_and_cap_is_not_flagged_capped(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол поднял TTL, но не дотянулся до потолка -- capped-флаг НЕ выставляется.""" + db = _FakeDB(floor_days=15.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 15 + assert "ttl_floor_capped" not in out + + +def test_floor_exactly_at_cap_boundary_is_not_flagged_capped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Пол ровно на потолке (2x ttl) -- это ещё "поднят до потолка", не "срезан выше него". + + Формула -- min(raw, cap): при raw == cap срезания не происходит (raw > cap ложно), + капнутый флаг предназначен сигналить именно "потолок реально что-то отрезал". + """ + db = _FakeDB(floor_days=20.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 20 + assert "ttl_floor_capped" not in out + + +def test_cap_logs_warning_containing_both_numbers( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """WARNING при срезании содержит и сырой пол, и капнутый результат -- не только counters.""" + db = _FakeDB(floor_days=75.0) + with caplog.at_level("WARNING", logger=task_mod.logger.name): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + messages = " ".join(r.getMessage() for r in caplog.records) + assert "75" in messages, "лог обязан называть сырой пол" + assert "20" in messages, "лог обязан называть итоговый (капнутый) TTL" + + +def test_cap_never_lowers_ttl_below_configured_value(monkeypatch: pytest.MonkeyPatch) -> None: + """Потолок -- верхняя граница, не альтернативный источник истины: заданный TTL + (10) остаётся нижней границей независимо от того, насколько низко ушёл пол.""" + db = _FakeDB(floor_days=1.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 + + +# ── avito self-descend (52 -> ... -> 10) не должен ломаться потолком ──────────── + + +def test_avito_high_transient_floor_is_capped_not_left_unbounded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Наблюдённый на проде транзиентный пик avito (счётчики видели ttl_days_effective=52) + теперь капается на 2x ttl=20, а не пропускается в UPDATE как есть.""" + db = _FakeDB(floor_days=52.0) + out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 20 + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 52 + + +def test_avito_recovered_low_floor_still_reaches_configured_ttl( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """После восстановления обхода (пол опустился ниже ttl_days=10, как на проде 52->10) + потолок не мешает нормальному пути -- эффективный TTL просто равен заданному.""" + db = _FakeDB(floor_days=9.0) + out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 + assert "ttl_floor_capped" not in out + + +def test_avito_floor_above_ttl_but_under_cap_passes_through_uncapped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Промежуточная точка того же самопонижения (пол между ttl и потолком, например 18) + поднимает TTL как раньше -- потолок не мешает нормальному постепенному пути.""" + db = _FakeDB(floor_days=18.0) + out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 18 + assert "ttl_floor_capped" not in out + + +# ── cap_mult конфигурируем per-source (найдено ревью 2026-08-15) ──────────────── +# Дефолтный CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных источниках +# (cian/yandex 60 сут, avito 20 сут), а хвост переобхода не пропорционален +# ttl_days: avito p99=42.1 -- выше его же дефолтного потолка 20. cap_mult -- ручка +# для конкретно такого источника, без изменения дефолта для остальных. + + +def test_cap_mult_defaults_to_module_constant_when_not_overridden( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Без явного cap_mult поведение не меняется: потолок = ttl_days * CAP_MULT (2).""" + db = _FakeDB(floor_days=75.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 * task_mod.CAP_MULT + + +def test_cap_mult_override_raises_the_ceiling_for_a_long_tailed_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """avito p99=42.1: cap_mult=6 (потолок 60) больше не режет пол ниже хвоста обхода, + в отличие от дефолтного cap_mult=2 (потолок 20).""" + db = _FakeDB(floor_days=45.0) + out = _run( + db, + monkeypatch, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=6, + ) + assert out["ttl_days_effective"] == 45 + assert "ttl_floor_capped" not in out + + +def test_cap_mult_override_still_caps_when_floor_exceeds_the_wider_ceiling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """cap_mult поднимает потолок, но не убирает его -- пол выше 60 всё равно срезается.""" + db = _FakeDB(floor_days=90.0) + out = _run( + db, + monkeypatch, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=6, + ) + assert out["ttl_days_effective"] == 60 + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 90 + + +def test_cap_mult_is_threaded_into_update_params(monkeypatch: pytest.MonkeyPatch) -> None: + """Капнутый по override'нутому потолку TTL реально уходит в UPDATE, не только считается.""" + db = _FakeDB(floor_days=90.0) + _run( + db, + monkeypatch, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=6, + ) + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 60 + + +# ── ttl_days <= 0 (LOW из ревью 2026-08-15) ────────────────────────────────────── +# До потолка max(ttl_days, floor) прикрывал ttl_days<=0, если пол посчитан и +# положителен. С потолком min(raw, ttl_days * cap_mult) при ttl_days<=0 капнутый +# потолок тоже <= 0 и побеждает в min() -- защита пола пропадает молча. Явный guard +# ловит это ДО любого SQL, тем же путём, что и невалидный staleness_column. + + +def test_ttl_days_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=75.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=0, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_ttl_days_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=75.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=-5, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_ttl_days_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Тот же контракт, что и невалидный staleness_column: run помечается failed, + а не остаётся 'running'.""" + marked_failed: list[Any] = [] + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)), + ) + db = _FakeDB(floor_days=75.0) + with pytest.raises(ValueError): + task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 7, + listing_source="avito", + ttl_days=0, + ) + assert len(marked_failed) == 1 + assert marked_failed[0][0] == 7 + + +# ── cap_mult < 1 (HIGH из ревью круга 2, 2026-08-15) ───────────────────────────── +# Тот же класс дыры, что и ttl_days<=0 выше, но со стороны потолка: cap_mult -- ЕДИНСТВЕННЫЙ +# запланированный способ его задать -- руками вписать в jsonb default_params расписания +# (см. миграцию для avito), т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода. +# cap_mult=0 -> capped=0 -> effective_ttl_days=0 -> UPDATE снимает весь активный пул +# источника молча. cap_mult<1 (например 0.5) опускает потолок НИЖЕ заданного оператором +# ttl_days -- прямое нарушение инварианта, который проверяет +# test_cap_never_lowers_ttl_below_configured_value для пола, но не было проверено для +# потолка при некорректном cap_mult. + + +def test_cap_mult_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0) + assert db.executed == [] + + +def test_cap_mult_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=-2) + assert db.executed == [] + + +def test_cap_mult_below_one_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """cap_mult=0.5 опустил бы потолок НИЖЕ заданного ttl_days -- та самая инверсия, + которую тест test_cap_never_lowers_ttl_below_configured_value гарантирует для пола.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0.5) + assert db.executed == [] + + +def test_cap_mult_non_numeric_fails_safe_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """Опечатка в jsonb default_params (строка вместо числа) не должна молча пройти + в SQL -- TypeError из сравнения `cap_mult < 1` ловится тем же except Exception, + что и ValueError-гварды, и маршрутизируется через mark_failed. Никакого SQL не + исполняется, ни один active-лот не тронут.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(TypeError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult="6") + assert db.executed == [] + + +# ── cap_mult / ttl_days -- bool (найдено ревью круга 3, 2026-08-15) ───────────── +# bool -- подкласс int в Python: `True < 1` ложно, `True <= 0` ложно. Числовые +# guard'ы выше (`cap_mult < 1`, `ttl_days <= 0`) поэтому НЕ ловят jsonb `true` в +# default_params расписания -- ровно тот класс опечатки, ради которого guard'ы +# вообще написаны. `cap_mult=True` даёт потолок == ttl_days (ttl_days * True == +# ttl_days) -- пол молча отключается без единого ValueError. `ttl_days=True` даёт +# ttl_days == 1 -- TTL молча меняется на 1 сутки. Явная type-проверка ловит оба +# ДО числового сравнения и ДО любого SQL. + + +def test_cap_mult_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """cap_mult=True: `True < 1` ложно -- без явной type-проверки потолок = ttl_days + (пол молча отключается) вместо ValueError. Воспроизведено на HEAD ветки.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=True) + assert db.executed == [] + + +def test_cap_mult_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """cap_mult=False уже ловится `cap_mult < 1` (False == 0), но type-guard идёт + первым -- проверяем, что путь всё равно ValueError, а не иной exception.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=False) + assert db.executed == [] + + +def test_ttl_days_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """ttl_days=True: `True <= 0` ложно -- без явной type-проверки TTL молча + становится 1 сутки (True ведёт себя как int 1) вместо ValueError.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=True, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_ttl_days_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """ttl_days=False уже ловится `ttl_days <= 0` (False == 0), но type-guard идёт + первым -- проверяем, что путь всё равно ValueError.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=False, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_cap_mult_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Тот же контракт, что и ttl_days<=0: run помечается failed, а не остаётся 'running', + и НИ ОДНА строка не деактивируется (в отличие от воспроизведённого на HEAD дефекта, где + cap_mult=0 давало effective_ttl_days=0 и снимало весь активный пул источника).""" + marked_failed: list[Any] = [] + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)), + ) + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 9, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=0, + ) + assert len(marked_failed) == 1 + assert marked_failed[0][0] == 9 + assert db.executed == [] + + +# ── проводка cap_mult в product_handlers ───────────────────────────────────────── + + +def test_handler_wires_cap_mult_from_schedule_params() -> None: + """Тот же приём, что test_handler_wires_revisit_floor_from_schedule_params: + читаем исходник файлом (product_handlers тянет scraper_kit, которого в юнит- + окружении может не быть) и проверяем именно проводку default_params -> вызов.""" + handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py" + src = handlers.read_text("utf-8") + job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0] + flat = " ".join(job.split()) + assert 'params.get("cap_mult", CAP_MULT)' in flat + assert "cap_mult=cap_mult" in job diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py index bdb6f976..5e6e0129 100644 --- a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -3,8 +3,6 @@ Каждая правка эпика — тест, который краснеет без неё: подключено: - - houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница - не подтверждена — иначе false «не смотрели» выдаётся за false «посмотрели»); - domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules — именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным; - filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash. @@ -15,7 +13,11 @@ читателя и писателя; - listings.merged_into, house_sources.raw_payload — колонки без писателя; - v_data_quality.price_disagreements_count — показатель, который не мог быть - ненулевым. + ненулевым; + - houses.has_panorama (хвост, мигр. 259) — признак, которого нет на площадке. + Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя; + подключённый писатель за 7 дней записал 12 значений, все false. Разница между + «проводка оборвана» и «мерить нечего» видна не по коду, а по площадке. задокументировано: - BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт — @@ -29,19 +31,13 @@ from __future__ import annotations import os import re from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from scraper_kit.providers.cian.valuation import _parse_valuation_state -from scraper_kit.providers.yandex.valuation import ( - ValuationHistoryItem, - ValuationHouseMeta, - YandexValuationResult, -) from app.services import estimator -from app.services.estimator import _save_yandex_history_items REPO_ROOT = Path(__file__).resolve().parents[3] TRADEIN = REPO_ROOT / "tradein-mvp" @@ -50,118 +46,7 @@ MIGRATION = SQL_DIR / "216_dead_code_sweep.sql" # ───────────────────────────────────────────────────────────────────────────── -# Подключено 1/3: houses.has_panorama -# ───────────────────────────────────────────────────────────────────────────── - - -def _result_with_meta(meta: ValuationHouseMeta) -> YandexValuationResult: - return YandexValuationResult( - address="Екатеринбург, ул. Куйбышева, 106", - offer_category="APARTMENT", - offer_type="SELL", - page=1, - source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test", - house=meta, - history_items=[ValuationHistoryItem(area_m2=50.0, rooms=2, floor=5, start_price=9_000_000)], - ) - - -def _panorama_updates(db: MagicMock) -> list[dict]: - """Параметры всех db.execute, которые обновляли houses.has_panorama.""" - found = [] - for call in db.execute.call_args_list: - sql = str(call.args[0]) - if "has_panorama" in sql and "UPDATE houses" in sql: - found.append(call.args[1]) - return found - - -def test_has_panorama_written_when_page_rendered() -> None: - """Разобранный флаг доезжает до houses — до #2674 он не доезжал ни одной строкой.""" - db = MagicMock() - result = _result_with_meta( - ValuationHouseMeta(year_built=2010, total_floors=16, has_panorama=True) - ) - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(99, 0.9, "fp"), - ): - _save_yandex_history_items(db, result) - - updates = _panorama_updates(db) - assert updates, "houses.has_panorama не записан — вернулась исходная болячка #2674" - assert updates[0] == {"hid": 99, "panorama": True} - - -def test_has_panorama_false_written_when_page_rendered() -> None: - """Отсутствие метки на ОТРИСОВАННОЙ странице — тоже наблюдение, пишем false.""" - db = MagicMock() - result = _result_with_meta( - ValuationHouseMeta(year_built=1998, total_floors=9, has_panorama=False) - ) - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(7, 0.9, "fp"), - ): - _save_yandex_history_items(db, result) - - assert _panorama_updates(db) == [{"hid": 7, "panorama": False}] - - -def test_has_panorama_written_when_page_has_no_history() -> None: - """Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде. - - Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие - страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение - о доме к наличию объявлений отношения не имеет. - """ - db = MagicMock() - result = _result_with_meta( - ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True) - ) - result.history_items = [] - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(42, 0.9, "fp"), - ): - assert _save_yandex_history_items(db, result) == 0 - - assert _panorama_updates(db) == [{"hid": 42, "panorama": True}] - - -def test_has_panorama_not_written_when_page_unconfirmed() -> None: - """Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false.""" - db = MagicMock() - result = _result_with_meta(ValuationHouseMeta(has_panorama=False)) - - with patch( - "app.services.estimator.match_or_create_house", - return_value=(5, 0.9, "fp"), - ): - _save_yandex_history_items(db, result) - - assert _panorama_updates(db) == [], "false записан там, где мы ничего не наблюдали" - - -def test_has_panorama_not_written_without_house_id() -> None: - """Дом не сматчился → писать некуда, но и падать нельзя.""" - db = MagicMock() - result = _result_with_meta(ValuationHouseMeta(year_built=2010, total_floors=16)) - - with patch( - "app.services.estimator.match_or_create_house", - side_effect=RuntimeError("no house"), - ): - _save_yandex_history_items(db, result) - - assert _panorama_updates(db) == [] - - -# ───────────────────────────────────────────────────────────────────────────── -# Подключено 2/3: загрузчик ДОМ.РФ — оборванная проводка +# Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка # ───────────────────────────────────────────────────────────────────────────── @@ -217,7 +102,7 @@ def test_domrf_handler_reuses_loader_functions() -> None: # ───────────────────────────────────────────────────────────────────────────── -# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали +# Подключено 2/2: filters_hash лежит на уровень выше, чем его читали # ───────────────────────────────────────────────────────────────────────────── @@ -285,6 +170,10 @@ def test_dead_names_absent_from_live_code() -> None: "asking_to_sold_ratios_tiered", "asking_to_sold_tier_bounds", "price_disagreements_count", + # Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета + # источников. Гейт краснеет на любом из трёх, а не только на одном (первая + # редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет). + "has_panorama", ] offenders: list[str] = [] for path in _live_python_sources(): @@ -332,6 +221,29 @@ def test_migration_drops_exactly_what_was_declared_dead() -> None: assert "COMMENT ON VIEW v_price_divergence" in sql +def test_has_panorama_dropped_together_with_the_contract_column() -> None: + """Снос колонки обязан пересобрать market.v_houses и вернуть ему грант. + + has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку + не удаляет — значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три + части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен. + """ + sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8") + assert "DROP VIEW IF EXISTS market.v_houses" in sql + assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql + assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока" + + view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0] + assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой" + # Контракт — обещание стабильности: пересборка не должна заодно потерять соседей. + for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"): + assert kept in view_ddl, f"{kept} пропала из контракта при пересборке" + assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, ( + "DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит " + "permission denied на следующем прогоне" + ) + + def test_price_divergence_is_documented_as_structurally_empty() -> None: """Оставленный задел обязан говорить, чем он НЕ является сегодня.""" sql = MIGRATION.read_text(encoding="utf-8") diff --git a/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py new file mode 100644 index 00000000..85ed4502 --- /dev/null +++ b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py @@ -0,0 +1,208 @@ +"""#2846 — возраст выборки ДКП-коридора обязан доезжать до ответа. + +Плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» несла count/low/median/high и +period_months — ОКНО ПОИСКА. Возраста самих сделок в схеме не было вовсе, и +слово «ФАКТИЧЕСКИЕ» читалось как «недавние». + +Замер прода 2026-08-12 (docker exec tradein-postgres psql -U tradein -d tradein): + + deals source='rosreestr': 96 974 строки, max(deal_date) = 2026-01-01 + (223 дня назад), 9 различных deal_date, day-of-month = 1 у 100% строк, + месяцы ровно {01, 04, 07, 10} → deal_date это метка КВАРТАЛЬНОЙ пачки. + + Реплей выборок 881 реальной оценки за 90 суток (улица+город+rooms+area±15% + +окно 12 мес + ppm²-банды, включая city-wide widen): + свежайшая I кв. 2026 — 654 (74.2%) + коридора нет вовсе — 165 (18.7%) + свежайшая IV кв. 2025 — 62 ( 7.0%) + → у 8.7% выборок с коридором свежайшая сделка на КВАРТАЛ старше общего + максимума таблицы, т.е. max по всей таблице был бы враньём в их пользу. + +Красный прогон на origin/main: тесты падают на отсутствии latest_deal_date в +dict/схеме (AttributeError / KeyError), а не на ожидании, списанном с настройки. +""" + +from __future__ import annotations + +import os +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.schemas.trade_in import AnalogLot, DkpCorridor +from app.services.estimator import _fetch_dkp_corridor + +_ADDRESS = "Екатеринбург, ул. Гоголя, 18" + + +def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]: + return {"price_per_m2": ppm2, "deal_date": deal_date} + + +def _db(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None) -> Any: + """Session-двойник: 1-й execute — СберИндекс, 2-й — улица, 3-й — city-widen. + + Порядок жёстко следует _fetch_dkp_corridor: сначала street-выборка, затем + _load_sber_index_series, затем (опционально) city-wide widen. + """ + db = MagicMock() + calls: list[Any] = [] + + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + result = MagicMock() + calls.append(query) + sql = str(query) + if "sber" in sql.lower(): + result.mappings.return_value.all.return_value = [] + elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql: + result.mappings.return_value.all.return_value = street_rows + elif "deal_city_price_bands" in sql: + result.mappings.return_value.all.return_value = city_rows or [] + else: + result.mappings.return_value.all.return_value = [] + return result + + db.execute.side_effect = _execute + return db + + +def _fetch(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None): # type: ignore[no-untyped-def] + return _fetch_dkp_corridor( + _db(street_rows, city_rows), + address=_ADDRESS, + rooms=2, + area=60.0, + city="екатеринбург" if city_rows is None else "нижний тагил", + ) + + +# ── КРАСНОЕ ЯДРО: возраст доезжает до схемы ───────────────────────────────── + + +def test_latest_deal_date_reaches_the_response_schema() -> None: + """Главный красный: DkpCorridor обязан НЕСТИ дату свежайшей сделки. + + На origin/main падает AttributeError — поля в схеме нет, а Pydantic v2 + молча игнорирует лишний kwarg, так что «оно как-нибудь пролезет» — нет. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(210_000, date(2026, 1, 1))]) + assert raw is not None + corridor = DkpCorridor(**raw) + assert corridor.latest_deal_date == date(2026, 1, 1) + + +def test_age_is_taken_from_the_selected_sample_not_the_table_max() -> None: + """Выборка кончается IV кв. 2025 → подпись обязана назвать IV кв. 2025. + + Это те 7.0% реальных оценок из шапки модуля. Общий максимум таблицы + (2026-01-01) для них — чужая, более свежая дата. + """ + raw = _fetch([_deal(190_000, date(2025, 7, 1)), _deal(195_000, date(2025, 10, 1))]) + assert raw is not None + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +def test_deal_without_price_does_not_donate_its_freshness() -> None: + """Строка без ppm² не входит в границы коридора — не входит и в его возраст. + + Иначе самая свежая, но выброшенная сделка омолаживала бы подпись, не + участвуя ни в одном из чисел под ней. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(None, date(2026, 1, 1))]) + assert raw is not None + assert raw["count"] == 1 + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +def test_city_wide_widen_moves_the_age_together_with_the_numbers() -> None: + """#oblast-D widen: числа переехали на city-выборку — дата обязана тоже. + + Street-выборка тут СВЕЖЕЕ (I кв. 2026) и её дата осталась бы приятнее для + глаза; но на экране после widen'а стоят city-числа, которые кончаются + III кв. 2025. Подпись описывает то, что показано. + """ + street = [_deal(200_000, date(2026, 1, 1))] + city = [_deal(150_000, date(2025, 4, 1)), _deal(160_000, date(2025, 7, 1))] + raw = _fetch(street, city) + assert raw is not None + assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)" + assert raw["latest_deal_date"] == date(2025, 7, 1) + + +def test_no_deals_means_no_corridor_and_nothing_to_date() -> None: + """count=0 → коридора нет вовсе; новое поле не создаёт повода что-то рисовать.""" + assert _fetch([]) is None + + +def test_period_months_is_not_a_freshness_claim() -> None: + """Регресс-якорь: окно поиска и возраст данных — РАЗНЫЕ числа. + + Ровно та подмена, из-за которой v2 отказался печатать «за N месяцев» + (mappers.ts) — а v1 печатал. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1))]) + assert raw is not None + assert raw["period_months"] == 12 + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +# ── PDF: то же обещание, та же подпись ────────────────────────────────────── + + +def _estimate_with_deals(dates: list[date]) -> Any: + est = MagicMock() + est.actual_deals = [ + AnalogLot( + address="Екатеринбург, ул. Гоголя", + area_m2=60.0, + rooms=2, + floor=None, + total_floors=None, + price_rub=12_000_000, + price_per_m2=200_000, + listing_date=d, + days_on_market=None, + date_precision="quarter", + source="rosreestr", + ) + for d in dates + ] + return est + + +def test_pdf_deals_page_dates_the_deals_it_shows() -> None: + """PDF §03 «ФАКТИЧЕСКИЕ СДЕЛКИ» — то же обещание, что и плитка. + + До правки страница печатала «Период сделок: 08.2025 – 08.2026», где правый + конец = сегодня: окно поиска, выданное за период данных. + """ + from app.services.exporters.trade_in_pdf import deals_as_of_label + + assert deals_as_of_label(_estimate_with_deals([date(2025, 10, 1), date(2026, 1, 1)])) == ( + "по I кв. 2026" + ) + assert deals_as_of_label(_estimate_with_deals([])) is None + + +def test_quarter_label_matches_every_deal_date_present_on_prod() -> None: + """Все 9 живых deal_date проекции 2026-08-12 → корректная римская метка. + + Список не выдуман: это ровно `SELECT DISTINCT deal_date FROM deals`. + """ + from app.services.exporters.trade_in_pdf import deals_as_of_label + + expected = { + date(2026, 1, 1): "по I кв. 2026", + date(2025, 10, 1): "по IV кв. 2025", + date(2025, 7, 1): "по III кв. 2025", + date(2025, 4, 1): "по II кв. 2025", + date(2025, 1, 1): "по I кв. 2025", + date(2024, 10, 1): "по IV кв. 2024", + date(2024, 7, 1): "по III кв. 2024", + date(2024, 4, 1): "по II кв. 2024", + date(2024, 1, 1): "по I кв. 2024", + } + for d, label in expected.items(): + assert deals_as_of_label(_estimate_with_deals([d])) == label diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index e84f377e..39f869f3 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -120,7 +120,7 @@ async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.Monkey fake_fetcher = _FakeFetcher() - def _fake_build_browser_fetcher(config: object, source: str) -> _FakeFetcher: + def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher: assert source == "domclick" return fake_fetcher diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 40234a3e..099d39d0 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -51,8 +51,19 @@ def trade_in_app() -> FastAPI: return application -def _make_estimate_row(created_by: str | None) -> SimpleNamespace: - """A trade_in_estimates row with the full column set the endpoints read.""" +def _make_estimate_row(created_by: str | None, retain_until: object = None) -> SimpleNamespace: + """A trade_in_estimates row with the full column set the endpoints read. + + retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every + row that existed before that migration; explicit param lets retention-gate + tests (see test_estimate_retention_gate.py) construct a paid row. + + relaxations/reliability (migration 255) default to the schema defaults + ('[]' / 'ok') -- matches every pre-migration row (no backfill). Revival + scenarios (a "dead" median_price<=0/NULL row) are covered separately in + test_estimate_revival.py with their own dedicated row builders, since this + fixture's downstream tests here all assume a "live" estimate. + """ from datetime import UTC, datetime, timedelta return SimpleNamespace( @@ -69,6 +80,7 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: sources_used=["avito"], data_freshness_minutes=10, expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=retain_until, address="ул. Тестовая, 1", lat=56.8, lon=60.6, @@ -93,6 +105,8 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: ratio_basis="per_rooms", created_by=created_by, created_at=datetime.now(tz=UTC), + relaxations=[], + reliability="ok", ) @@ -149,6 +163,10 @@ def _stub_precision_and_pdf(): # #2087 (M1): GET-rehydrate derives canonical sources_used via the shared # helper. Empty analogs + no valuation flags → [] (real behaviour). _canonical_sources=lambda *a, **k: [], + # #2632: GET-rehydrate реконструирует фактический радиус подбора. У этих + # фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее + # поведение (см. tests/test_estimator_search_radius_2632.py). + rehydrate_search_radius_m=lambda *a, **k: None, ) real_estimator = sys.modules.get("app.services.estimator") sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment] @@ -597,3 +615,116 @@ def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI) headers={"X-Authenticated-User": "attacker"}, ) assert resp.status_code == 404 + + +# ── Payments retention: retention gate unification (retain_until, PR #2754) ── + + +def test_estimate_readable_sql_uses_disjunction() -> None: + """Single definition — OR retain_until, not a hand-copied expression.""" + from app.api.v1.trade_in import ESTIMATE_READABLE_SQL + + assert "expires_at > NOW()" in ESTIMATE_READABLE_SQL + assert "retain_until > NOW()" in ESTIMATE_READABLE_SQL + assert " OR " in ESTIMATE_READABLE_SQL + + +def test_get_estimate_sql_built_from_shared_constant() -> None: + """GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a + hand-copied literal — regression guard against the two gates drifting apart + again (that's exactly what happened before this PR: 404 here, 410 in /pdf).""" + import inspect + + from app.api.v1.trade_in import get_estimate + + src = inspect.getsource(get_estimate) + assert "ESTIMATE_READABLE_SQL" in src + assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant" + assert "retain_until" in src, "SELECT must also fetch retain_until" + + +def test_estimate_pdf_select_includes_retain_until_column() -> None: + import inspect + + from app.api.v1.trade_in import estimate_pdf + + assert "retain_until" in inspect.getsource(estimate_pdf) + + +@pytest.mark.parametrize( + ("expires_delta_hours", "retain_delta_days", "expected"), + [ + (12, None, True), # not expired, unpaid — current B2B/B2C behaviour, unchanged + (-1, None, False), # expired, unpaid — current behaviour (404/410), unchanged + (-1, 365, True), # expired but PAID — new: readable + (12, 365, True), # not expired AND paid — readable + (-1, -1, False), # expired, and the (hypothetical) retain_until also in the past + ], +) +def test_estimate_readable_truth_table( + expires_delta_hours: int, retain_delta_days: int | None, expected: bool +) -> None: + from datetime import UTC, datetime, timedelta + + from app.api.v1.trade_in import estimate_readable + + expires_at = datetime.now(tz=UTC) + timedelta(hours=expires_delta_hours) + retain_until = ( + datetime.now(tz=UTC) + timedelta(days=retain_delta_days) + if retain_delta_days is not None + else None + ) + assert estimate_readable(expires_at, retain_until) is expected + + +def test_pdf_expired_but_paid_returns_200(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until in the future → PDF still downloads + (200). Exactly the scenario PR-D1 exists for: a paid report must outlive + the 24h expires_at link TTL.""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = datetime.now(tz=UTC) + timedelta(days=300) + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/pdf" + + +def test_pdf_expired_unpaid_returns_410_without_ttl_text(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until NULL (unpaid, unchanged behaviour) → + 410, and the detail text no longer claims a specific '24h TTL' (would be a + lie now that retain_until exists for paid rows).""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 410 + assert resp.json()["detail"] == "estimate expired" + assert "24h" not in resp.json()["detail"] + assert "TTL" not in resp.json()["detail"] + + +def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI) -> None: + """Response schema exposes retain_until (nullable) — schemas/trade_in.py.""" + row = _make_estimate_row(created_by="kopylov") # retain_until defaults to None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["retain_until"] is None diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py new file mode 100644 index 00000000..d41f2e4d --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimate_revival.py @@ -0,0 +1,602 @@ +"""Tests for GET /estimate/{id} revival of "dead" (median_price<=0/NULL) rows. + +Incident 2026-08-10: a customer opened a saved estimate link and saw +«НЕДОСТАТОЧНО ДАННЫХ» — the row was created BEFORE the estimator fix +(#oblast-E/#oblast-F, PR #2823/#2825) and is permanently stuck at +median_price=0, even though the same address/params now compute a real +price. app.api.v1.trade_in::_try_revive_dead_estimate recomputes such a row +in place (same id/link) via the same estimate_quality() path as POST +/estimate. Also covers migration 255 (relaxations/reliability persistence). + +Реальная БД не нужна: DB + get_role + estimate_quality мокируются, mirroring +test_estimate_idor.py's approach (self-contained, no cross-file fixture +imports — this repo has no precedent for importing fixtures across +tests/test_*.py modules, only from tests/support/). + +DB mock dispatches by SQL substring (not call-position): get_estimate's +existing rehydrate path calls the real (non-stubbed) _resolve_target_house_id +helper, which itself fires 1-2 incidental `SELECT id FROM houses` queries +whenever the revival attempt does NOT short-circuit with an early return — +hand-counting positional side_effect entries around that would be brittle. +""" + +from __future__ import annotations + +import inspect +import os +import sys +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +# psycopg v3 driver required; stub DATABASE_URL before any app import +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import. +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222" +_TEMP_ID = "33333333-3333-3333-3333-333333333333" + + +@pytest.fixture(autouse=True) +def _restore_get_role(): + """Restore app.core.auth.get_role after each test (mirror test_estimate_idor).""" + from app.core import auth as auth_mod + + original = auth_mod.get_role + yield + auth_mod.get_role = original + + +@pytest.fixture() +def trade_in_app() -> FastAPI: + """Minimal FastAPI app mounting only the trade-in router.""" + from app.api.v1 import trade_in as trade_in_module + + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + return application + + +def _make_dead_row( + *, + median_price: int | None = 0, + house_type: str | None = None, + repair_state: str | None = None, + relaxations: list[str] | None = None, + reliability: str = "ok", +) -> SimpleNamespace: + """A trade_in_estimates row in the "dead" state (incident 2026-08-10). + + house_type/repair_state default to None (valid TradeInEstimateInput input — + most revival tests need a row that round-trips through pydantic cleanly); + a dedicated test passes legacy Russian literal values to exercise the + "invalid persisted value" graceful-degrade path. + """ + return SimpleNamespace( + id=_ESTIMATE_ID, + median_price=median_price, + range_low=0, + range_high=0, + median_price_per_m2=0, + confidence="low", + confidence_explanation="Рядом найдено недостаточно объявлений (4 шт.)", + n_analogs=0, + analogs=[], + actual_deals=[], + sources_used=[], + data_freshness_minutes=None, + expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=None, + address="г Екатеринбург, ул Академика Парина, д 46, к 5", + lat=56.8519, + lon=60.6122, + area_m2=23.1, + rooms=1, + floor=5, + total_floors=16, + year_built=2018, + house_type=house_type, + repair_state=repair_state, + has_balcony=None, + canonical_address=None, + house_cadnum=None, + house_fias_id=None, + dadata_qc_geo=None, + dadata_metro=[], + expected_sold_price=None, + expected_sold_range_low=None, + expected_sold_range_high=None, + expected_sold_per_m2=None, + asking_to_sold_ratio=None, + ratio_basis=None, + created_by="kopylov", + created_at=datetime(2026, 5, 29, tzinfo=UTC), + relaxations=relaxations or [], + reliability=reliability, + ) + + +def _make_live_row( + *, relaxations: list[str] | None = None, reliability: str = "ok" +) -> SimpleNamespace: + """A "live" row (median_price>0) — revival must never touch it.""" + return SimpleNamespace( + id=_ESTIMATE_ID, + median_price=4_031_157, + range_low=3_700_000, + range_high=4_300_000, + median_price_per_m2=174_000, + confidence="medium", + confidence_explanation="Найдено 39 аналогов", + n_analogs=39, + analogs=[], + actual_deals=[], + sources_used=["avito", "rosreestr"], + data_freshness_minutes=15, + expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=None, + address="г Екатеринбург, ул Академика Парина, д 46, к 5", + lat=56.8519, + lon=60.6122, + area_m2=23.1, + rooms=1, + floor=5, + total_floors=16, + year_built=2018, + house_type=None, + repair_state=None, + has_balcony=None, + canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5", + house_cadnum=None, + house_fias_id=None, + dadata_qc_geo=0, + dadata_metro=[], + expected_sold_price=None, + expected_sold_range_low=None, + expected_sold_range_high=None, + expected_sold_per_m2=None, + asking_to_sold_ratio=None, + ratio_basis=None, + created_by="kopylov", + created_at=datetime.now(tz=UTC), + relaxations=relaxations or [], + reliability=reliability, + ) + + +def _fake_revived_result(**overrides): + """A canned AggregatedEstimate mimicking a successful estimate_quality() call.""" + from app.schemas.trade_in import AggregatedEstimate + + defaults = dict( + estimate_id=_TEMP_ID, + median_price_rub=4_031_157, + range_low_rub=3_700_000, + range_high_rub=4_300_000, + median_price_per_m2=174_000, + confidence="medium", + confidence_explanation="Найдено 39 аналогов", + n_analogs=39, + period_months=12, + analogs=[], + actual_deals=[], + expires_at=datetime.now(tz=UTC) + timedelta(hours=24), + target_address="г Екатеринбург, ул Академика Парина, д 46, к 5", + target_lat=56.8519, + target_lon=60.6122, + sources_used=["avito", "rosreestr"], + data_freshness_minutes=15, + canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5", + relaxations=["снят фильтр по году постройки", "учтены студии"], + reliability="low", + created_at=datetime.now(tz=UTC), + ) + defaults.update(overrides) + return AggregatedEstimate(**defaults) + + +def _dispatch_db(row: object, claim_result: object = None) -> MagicMock: + """DB session mock dispatching fetchone() results by SQL substring. + + - initial GET SELECT ("SELECT id, median_price ...") -> row + - revival claim UPDATE ("SET revival_attempted_at") -> claim_result + - everything else (houses lookup, persist UPDATE, DELETE, + avito_imv UPDATE — none of which .fetchone() in real code + except the two above, but MagicMock tolerates the unused + call either way) -> None + + call_args_list still records every call in order regardless of dispatch, + so tests can assert on it directly (grep by substring) without needing to + hand-count incidental queries fired by _resolve_target_house_id. + """ + db = MagicMock() + + def _execute(clause, params=None, *_a, **_k): + sql = getattr(clause, "text", str(clause)) + result = MagicMock() + if "SET revival_attempted_at" in sql: + result.fetchone.return_value = claim_result + elif "SELECT id, median_price" in sql: + result.fetchone.return_value = row + else: + result.fetchone.return_value = None + return result + + db.execute.side_effect = _execute + return db + + +def _calls_containing(db: MagicMock, needle: str) -> list: + return [c for c in db.execute.call_args_list if needle in getattr(c.args[0], "text", "")] + + +def _client_with(app: FastAPI, db_mock: MagicMock, role: str = "pilot") -> TestClient: + from app.core.db import get_db + + def _override_db(): + yield db_mock + + app.dependency_overrides[get_db] = _override_db + auth_mod = sys.modules["app.core.auth"] + auth_mod.get_role = lambda _u: role # type: ignore[assignment] + return TestClient(app) + + +@pytest.fixture() +def _estimator_stub(): + """Replaces app.services.estimator with a SimpleNamespace stub. + + Mirrors test_estimate_idor.py::_stub_precision_and_pdf, plus an + `estimate_quality` async attribute (revival's own lazy import target). + Individual tests overwrite `estimate_quality` per-scenario. + """ + real_estimator = sys.modules.get("app.services.estimator") + + async def _default_estimate_quality(*_a, **_k): # pragma: no cover — overridden per test + raise AssertionError("estimate_quality stub not configured for this test") + + stub = SimpleNamespace( + _qc_geo_to_precision=lambda _qc: None, + _fetch_price_trend=lambda *a, **k: None, + _fetch_dkp_corridor=lambda *a, **k: None, + _fetch_house_imv_anchor=lambda *a, **k: None, + _resolve_target_city=lambda *a, **k: None, + _cv_from_ppm2=lambda *a, **k: None, + _source_counts=lambda *a, **k: {}, + _canonical_sources=lambda *a, **k: [], + # #2632: GET-rehydrate реконструирует фактический радиус подбора. У строк + # этой фикстуры analogs пусты → None и есть настоящее поведение + # (см. tests/test_estimator_search_radius_2632.py). + rehydrate_search_radius_m=lambda *a, **k: None, + estimate_quality=_default_estimate_quality, + ) + sys.modules["app.services.estimator"] = stub # type: ignore[assignment] + yield stub + if real_estimator is not None: + sys.modules["app.services.estimator"] = real_estimator + else: + sys.modules.pop("app.services.estimator", None) + + +# ── Revival success ─────────────────────────────────────────────────────── + + +def test_dead_row_revives_and_updates_db( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Dead row (median_price=0) → recomputed, written to the SAME id, returned fresh. + + Success path returns early (before the pre-existing rehydrate block), so + the call count is exactly the 5 documented in _try_revive_dead_estimate's + docstring: claim, avito_imv relink, persist UPDATE, DELETE temp — plus the + initial SELECT. + """ + + async def _fake_estimate_quality(payload, db, **kwargs): + assert payload.address.startswith("г Екатеринбург") + assert payload.rooms == 1 + assert payload.radius_m is None # never persisted — default cascade + return _fake_revived_result() + + _estimator_stub.estimate_quality = _fake_estimate_quality + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + # id/link contract: response carries the ORIGINAL id, not the temp uuid + # estimate_quality() minted internally. + assert body["estimate_id"] == _ESTIMATE_ID + assert body["median_price_rub"] == 4_031_157 + assert body["n_analogs"] == 39 + assert body["insufficient_data"] is False + assert body["reliability"] == "low" + assert "снят фильтр по году постройки" in body["relaxations"] + + assert len(db.execute.call_args_list) == 5 + persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET") + assert len(persist_calls) == 1 + persist_params = persist_calls[0].args[1] + assert persist_params["id"] == _ESTIMATE_ID + assert persist_params["median_price"] == 4_031_157 + assert persist_params["reliability"] == "low" + delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates") + assert len(delete_calls) == 1 + assert delete_calls[0].args[1]["id"] == _TEMP_ID + + +def test_dead_row_revival_preserves_created_at( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """#incident-2026-08-11: created_at is the client's ORIGINAL request date + (printed on /history, see app/schemas/trade_in.py:317-318) — revival must + not clobber it with the throwaway temp row's NOW(). Regression for the + prod incident (estimate ff421062-...: created_at jumped from the original + 2026-08-10 12:54:47 to the revival moment 2026-08-11 04:30:03). + + Also asserts: (a) the persist UPDATE never sets created_at at all — the + fix removes the column from SET, it doesn't just overwrite it with the + right value; (b) migration 256's revival_completed_at IS stamped, as the + separate "when did revival last succeed" audit trail; (c) the JSON + response mirrors the original created_at, not the temp result's. + """ + _original_created_at = datetime(2026, 5, 29, tzinfo=UTC) + + async def _fake_estimate_quality(payload, db, **kwargs): + # The temp row estimate_quality() mints internally always carries + # NOW() as its created_at — deliberately far from the original, so a + # regression (copying result.created_at through) is unmissable. + return _fake_revived_result(created_at=datetime(2026, 8, 11, 4, 30, 3, tzinfo=UTC)) + + _estimator_stub.estimate_quality = _fake_estimate_quality + + row = _make_dead_row() + row.created_at = _original_created_at + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["created_at"] == "2026-05-29T00:00:00Z" + + persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET") + assert len(persist_calls) == 1 + persist_sql = persist_calls[0].args[0].text + persist_params = persist_calls[0].args[1] + assert "created_at" not in persist_sql + assert "created_at" not in persist_params + assert "revival_completed_at = NOW()" in persist_sql + # Input-snapshot / TTL columns (what the client originally asked for and + # for how long the row is retained) are likewise not recompute outputs — + # untouched by the revival persist UPDATE. + # NB: "address" is checked via persist_params only (not persist_sql) — + # canonical_address (a legitimate recompute output) ends in "address =", + # which would false-positive a substring check against the raw SQL text. + for protected in ("expires_at", "retain_until", "created_by"): + assert f"{protected} =" not in persist_sql + assert protected not in persist_params + assert "address" not in persist_params + + +# ── Live row is never touched ──────────────────────────────────────────── + + +def test_live_row_never_triggers_revival( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """median_price>0 → revival branch skipped entirely: estimate_quality is + never called and no claim UPDATE fires — the saved price is untouched.""" + + async def _must_not_be_called(*_a, **_k): + raise AssertionError("estimate_quality must not be called for a live row") + + _estimator_stub.estimate_quality = _must_not_be_called + + row = _make_live_row() + db = _dispatch_db(row) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["median_price_rub"] == 4_031_157 + assert _calls_containing(db, "SET revival_attempted_at") == [] + + +# ── Throttle / anti-storm ──────────────────────────────────────────────── + + +def test_dead_row_throttled_does_not_recompute( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Claim UPDATE returns no row (recent attempt / lost race) → no recompute, + honest still-dead response, no 500, exactly one claim attempt (no retry + loop within the same request).""" + + async def _must_not_be_called(*_a, **_k): + raise AssertionError("estimate_quality must not be called when throttled") + + _estimator_stub.estimate_quality = _must_not_be_called + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=None) # throttled: WHERE matched nothing + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["insufficient_data"] is True + assert body["median_price_rub"] == 0 + assert len(_calls_containing(db, "SET revival_attempted_at")) == 1 + + +# ── Recompute error degrades gracefully (no 500) ───────────────────────── + + +def test_revival_recompute_exception_falls_back_without_500( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + async def _raises(*_a, **_k): + raise RuntimeError("geocode timeout") + + _estimator_stub.estimate_quality = _raises + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["insufficient_data"] is True + # exception happened AFTER the claim — throttle was still recorded once. + assert len(_calls_containing(db, "SET revival_attempted_at")) == 1 + # ...but nothing was written back to the row (no persist UPDATE fired). + assert _calls_containing(db, "UPDATE trade_in_estimates SET") == [] + + +def test_revival_still_zero_falls_back_without_500( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Recompute runs but still finds nothing (median_price_rub=0) — honest + insufficient_data, temp throwaway row cleaned up, no crash.""" + + async def _still_empty(*_a, **_k): + return _fake_revived_result( + median_price_rub=0, + range_low_rub=0, + range_high_rub=0, + median_price_per_m2=0, + n_analogs=0, + confidence="low", + ) + + _estimator_stub.estimate_quality = _still_empty + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["insufficient_data"] is True + delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates") + assert len(delete_calls) == 1 + assert delete_calls[0].args[1]["id"] == _TEMP_ID + # the still-dead ORIGINAL row was never overwritten with (fresh) zeros. + assert _calls_containing(db, "UPDATE trade_in_estimates SET") == [] + + +def test_revival_invalid_persisted_house_type_falls_back_gracefully( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Legacy row with a house_type outside the current Literal set — payload + reconstruction itself raises (pydantic ValidationError), caught the same + way as any other recompute failure. No 500.""" + + async def _must_not_be_called(*_a, **_k): + raise AssertionError("estimate_quality must not be reached — payload build fails first") + + _estimator_stub.estimate_quality = _must_not_be_called + + row = _make_dead_row(house_type="монолит", repair_state="хороший") + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["insufficient_data"] is True + + +# ── relaxations/reliability round-trip (migration 255) ─────────────────── + + +def test_relaxations_reliability_roundtrip_on_get( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """A live row with persisted relaxations/reliability surfaces them + byte-for-byte on GET — the red "точность снижена" banner survives + reopening a saved link (previously always reset to ok/[]).""" + row = _make_live_row( + relaxations=["радиус расширен до 2000 м", "площадь ±25%"], reliability="low" + ) + db = _dispatch_db(row) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["reliability"] == "low" + assert body["relaxations"] == ["радиус расширен до 2000 м", "площадь ±25%"] + + +def test_get_estimate_defaults_relaxations_reliability_when_row_lacks_columns( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Defensive getattr fallback: a row/mock without relaxations/reliability + attrs (e.g. a stale test double) degrades to schema defaults, not a crash.""" + row = _make_live_row() + del row.relaxations + del row.reliability + db = _dispatch_db(row) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["reliability"] == "ok" + assert body["relaxations"] == [] + + +# ── estimator.py persists relaxations/reliability on (re)compute ───────── + + +def test_estimate_quality_insert_persists_relaxations_reliability() -> None: + """Source guard: the main POST-path INSERT must write relaxations/ + reliability, not just return them in the response — regression guard + against the exact gap this migration closes (PR #2823 open follow-up).""" + from app.services import estimator + + src = inspect.getsource(estimator.estimate_quality) + assert "relaxations_json" in src + assert '"reliability": reliability' in src + + +def test_empty_estimate_persists_relaxations_reliability() -> None: + """_empty_estimate's INSERT must also set reliability='very_low' (mirrors + the Python object it returns) rather than silently defaulting to 'ok'.""" + from app.services import estimator + + src = inspect.getsource(estimator._empty_estimate) + assert "relaxations, reliability" in src + assert "'very_low'" in src diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py index 6935c5fb..f6cb2430 100644 --- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -331,18 +331,24 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None: # --------------------------------------------------------------------------- -# Fix 5a — sber staleness warning +# Fix 5a — per-estimate sber staleness warning УДАЛЁН (#2846) +# +# Guard сравнивал возраст latest периода с sber_index_max_age_days=35. Такой +# возраст недостижим по построению (period_month — метка первого числа + лаг +# публикации ⇒ пол 46 суток), поэтому на проде warning писался у КАЖДОЙ оценки +# и не нёс ни бита. Пара прежних тестов зеленела только на фикстуре с «свежим» +# месяцем, которого в реальной серии не бывает. Свежесть теперь мерит одно место — +# tasks/sber_freshness_monitor, по отставанию ЗАГРУЗКИ. # --------------------------------------------------------------------------- -def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None: - """_load_sber_index_series логирует warning при stale серии.""" +def test_fix5a_no_per_estimate_staleness_warning(caplog: pytest.LogCaptureFixture) -> None: + """Серия отдаётся как есть; календарного warning'а в горячем пути больше нет.""" import logging from app.services.estimator import _load_sber_index_series - # Серия с единственным месяцем 2 года назад - stale_month = date(2024, 1, 1) + stale_month = date(2024, 1, 1) # два года назад — прежний guard тут кричал mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.all.return_value = [ {"period_month": stale_month, "index_value_rub_m2": 100_000.0} @@ -351,33 +357,9 @@ def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None with caplog.at_level(logging.WARNING, logger="app.services.estimator"): series = _load_sber_index_series(mock_db, region="Свердловская область") - assert len(series) == 1 - assert stale_month in series - # Warning о staleness должен быть залогирован + assert series == {stale_month: 100_000.0} stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] - assert stale_msgs, f"Ожидали warning о stale sber, caplog: {caplog.text}" - - -def test_fix5a_fresh_sber_no_warning(caplog: pytest.LogCaptureFixture) -> None: - """_load_sber_index_series НЕ логирует warning при свежей серии.""" - import logging - - from app.services.estimator import _load_sber_index_series - - # Текущий месяц (age=0..30 дней — точно свежее 35-дневного порога). - today = datetime.now(tz=UTC).date() - fresh_month = today.replace(day=1) # 1-е число ТЕКУЩЕГО месяца - mock_db = MagicMock() - mock_db.execute.return_value.mappings.return_value.all.return_value = [ - {"period_month": fresh_month, "index_value_rub_m2": 128_000.0} - ] - - with caplog.at_level(logging.WARNING, logger="app.services.estimator"): - series = _load_sber_index_series(mock_db, region="Свердловская область") - - assert len(series) == 1 - stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] - assert not stale_msgs, f"Не ожидали stale warning для свежей серии, caplog: {caplog.text}" + assert not stale_msgs, f"per-estimate guard вернулся, caplog: {caplog.text}" # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py new file mode 100644 index 00000000..16815488 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py @@ -0,0 +1,263 @@ +"""#2827 — confidence↔reliability consistency (live-prod smoke, 2026-08-11). + +`confidence` (`_compute_confidence` — unique-address/IQR metric) and +`reliability` (`estimate_quality` — n_analogs-bucket + #oblast-F relaxation- +cascade metric, see #2823) were computed fully independently. The cascade's +room-adjacency/freshness/novostroyki steps never set `fallback_used`, so +`_compute_confidence` stayed blind to them and could keep scoring a wide, +tight-IQR (post-relaxation) sample as "high", while `reliability` — which DOES +see the relaxations — honestly read "low". + +Live prod repro (customer address, rooms=1, 23.1 m², radius=2000): + n_analogs: 39 + confidence: high + reliability: low + relaxations: ['снят фильтр по году постройки', 'учтены студии', 'площадь ±25%'] + +A client could see a "высокая уверенность" badge next to a "точность снижена" +banner on the SAME estimate — exactly the class of contradiction cleaned up +from the PDF/counters earlier in this cycle (#2824-adjacent work), now leaking +through the two headline confidence signals themselves. + +Fix: `_cap_confidence_by_reliability` (estimator.py), applied ONCE in +`estimate_quality` right after both `confidence` and `reliability` are final +— NOT spread across the #oblast-F cascade steps. Rule: + reliability == 'very_low' → confidence forced to 'low' + reliability == 'low' → confidence capped at 'medium' + reliability == 'ok' → confidence untouched (common case, unaffected) + +Two layers: + 1. `_cap_confidence_by_reliability` direct unit tests — the 3 rules in + isolation, no DB/estimate_quality overhead. + 2. `estimate_quality` integration tests — the prod repro shape (confidence + downgraded + confidence_explanation restructured to LEAD with the + accuracy-reduced summary) and the byte-identical-when-unrelaxed control. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.estimator import _cap_confidence_by_reliability +from app.services.geocoder import GeocodeResult + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 1 — `_cap_confidence_by_reliability` direct unit tests +# ───────────────────────────────────────────────────────────────────────────── + + +def test_very_low_reliability_forces_confidence_low() -> None: + """reliability == 'very_low' → confidence forced to 'low', regardless of + what _compute_confidence originally scored.""" + for original in ("high", "medium", "low"): + assert ( + _cap_confidence_by_reliability(original, "very_low") == "low" + ), f"original={original!r} must be forced to 'low' under very_low reliability" + + +def test_low_reliability_caps_confidence_at_medium() -> None: + """reliability == 'low' → confidence capped at 'medium': 'high' is pulled + down to 'medium'; 'medium'/'low' pass through unchanged (already <= cap).""" + assert _cap_confidence_by_reliability("high", "low") == "medium" + assert _cap_confidence_by_reliability("medium", "low") == "medium" + assert _cap_confidence_by_reliability("low", "low") == "low" + + +def test_ok_reliability_leaves_confidence_untouched() -> None: + """reliability == 'ok' — the common, unrelaxed case — must NOT change + confidence at all (byte-identical to pre-#2827 behaviour).""" + for original in ("high", "medium", "low"): + assert _cap_confidence_by_reliability(original, "ok") == original + + +def test_cap_never_raises_confidence() -> None: + """Sanity: the cap only lowers/holds — never raises 'low' to something + higher under any reliability value.""" + for reliability in ("ok", "low", "very_low"): + assert _cap_confidence_by_reliability("low", reliability) == "low" + + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path) +# ───────────────────────────────────────────────────────────────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Академика Парина, 46/5", + provider="nominatim", + ) + + +def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 23.1) -> dict[str, Any]: + return { + "source": "avito", + "source_url": f"https://avito.ru/offer/{address}", + "address": address, + "lat": 56.838, + "lon": 60.595, + "rooms": 1, + "area_m2": area_m2, + "floor": 5, + "total_floors": 9, + "price_rub": price_per_m2 * area_m2, + "price_per_m2": price_per_m2, + "listing_date": datetime(2026, 5, 1), + "days_on_market": 10, + "photo_urls": [], + "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), + "distance_m": 150.0, + "relevance_score": 0.1, + } + + +def _tight_price_pool(n: int, base_ppm2: float = 195_000.0) -> list[dict[str, Any]]: + """n listings, distinct addresses, tightly clustered price/m² (~±2%) — + engineered to clear _compute_confidence's 'high' bar (unique_addr_count>=7 + AND iqr_pct<0.15) once assembled, exactly like the live prod repro (n=39, + confidence='high' pre-fix).""" + return [ + _make_listing( + price_per_m2=base_ppm2 + (i - n / 2) * 200, + address=f"ул. Академика Парина, {i + 1}", + ) + for i in range(n) + ] + + +def _payload_with_year() -> Any: + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="ЕКБ, ул. Академика Парина, 46/5", + area_m2=23.1, + rooms=1, + floor=5, + total_floors=9, + year_built=2010, + city_hint="Екатеринбург", + radius_m=2000, + ) + + +def _run_estimate( + *, + payload: Any, + fetch_analogs_side_effect: Any, + dkp_raw: dict[str, Any] | None = None, +) -> Any: + from app.services.estimator import estimate_quality + + db = MagicMock() + geo = _geo() + + async def _run() -> Any: + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + side_effect=fetch_analogs_side_effect, + ), + patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + ): + return await estimate_quality(payload, db) + + return anyio.run(_run) + + +def test_e2e_prod_repro_high_confidence_low_reliability_gets_capped() -> None: + """Live prod repro shape: cohort (year_built) filter gets dropped by the + Tier-0 cascade (empty cohort-call → fallback), landing on a 39-listing, + tight-price, 39-unique-address pool — a healthy sample that + `_compute_confidence` would score 'high' on its own. Because a relaxation + WAS applied to get there, `reliability` reads 'low' — the cap must pull + `confidence` down to 'medium' (not leave it at the contradictory 'high'), + and `confidence_explanation` must LEAD with the accuracy-reduced summary.""" + pool = _tight_price_pool(39) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + # Tier 0 (with cohort filter) → empty, forces the "drop cohort" fallback. + if kwargs.get("cohort_year_min") is not None: + return [], False, "W" + # Every subsequent (no-cohort) call → the full healthy pool. + return list(pool), False, "W" + + est = _run_estimate(payload=_payload_with_year(), fetch_analogs_side_effect=_fetch_analogs_stub) + + assert est.n_analogs == 39 + assert est.reliability == "low" + assert "снят фильтр по году постройки" in est.relaxations + assert est.confidence == "medium", ( + f"confidence={est.confidence!r} must be capped to 'medium' under reliability='low' " + "(was 'high' pre-#2827 — the exact prod contradiction)" + ) + explanation = est.confidence_explanation + assert explanation is not None + assert explanation.startswith( + "Оценка построена с расширенными параметрами подбора — точность снижена." + ), f"explanation must LEAD with the accuracy-reduced summary, got: {explanation!r}" + assert "Найдено 39 аналогов" in explanation, "original detail must be preserved" + assert "Применены послабления подбора: снят фильтр по году постройки." in explanation + + +def test_e2e_unrelaxed_estimate_is_byte_identical_control() -> None: + """#3 (task spec): no relaxations, reliability=='ok' → confidence and + confidence_explanation must be UNCHANGED by #2827 — this is the common + path most estimates take, and it must not regress.""" + pool = _tight_price_pool(39) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + # No cohort filter this time (payload has no year_built) — Tier 0 is + # skipped outright (cohort_range is None), so no relaxation fires. + return list(pool), False, "W" + + from app.schemas.trade_in import TradeInEstimateInput + + payload = TradeInEstimateInput( + address="ЕКБ, ул. Академика Парина, 46/5", + area_m2=23.1, + rooms=1, + floor=5, + total_floors=9, + city_hint="Екатеринбург", + ) + est = _run_estimate(payload=payload, fetch_analogs_side_effect=_fetch_analogs_stub) + + assert est.n_analogs == 39 + assert est.relaxations == [] + assert est.reliability == "ok" + assert est.confidence == "high", "unrelaxed healthy sample must keep its real confidence" + assert est.confidence_explanation is not None + assert not est.confidence_explanation.startswith("Оценка построена с расширенными параметрами") + assert "Применены послабления подбора" not in est.confidence_explanation + assert est.confidence_explanation.startswith("Найдено 39 аналогов"), ( + "unrelaxed explanation must keep its original (pre-#2827) leading sentence, got: " + f"{est.confidence_explanation!r}" + ) diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py index 3ae13ef7..a4033a0f 100644 --- a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py +++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py @@ -1,22 +1,55 @@ -"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02). +"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority +RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product +decision, 2026-08-10). -Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings → -headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring -street in the same town swung ±66% on 1-2 different random listings. Каменск- -Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match -either, with no honest refusal surfaced. Первоуральск (0 listings) already fell -back to the (pre-existing) ДКП deals-headline fallback correctly — this gate -routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME, -already-tested path instead of trusting a 1-4-lot median as the headline. +History: + 1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1) + listings sample to a literal zero, forcing the anchor/#oblast-D-deals- + fallback/insufficient_data chain to take over — motivated by a live + Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of + 54 126 ₽/м²). + 2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE — + a thin sample always kept its own median, even when a much more reliable + deals corridor was available. That accidentally REOPENED the exact Серов + bug #oblast-E existed to close. + 3. #oblast-E priority RESTORED (2026-08-10, same day, product correction): + "никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3 + объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in + `_price_from_inputs`'s gate: + - n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected). + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor + exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) → + listings aggregate suppressed to zero, headline ceded to the + #oblast-D deals-headline-fallback chain (original #oblast-E + behaviour, restored). `PricingResult.deals_headline_due_to_thin_ + listings=True` — estimate_quality() adds relaxation label "оценка по + сделкам — мало объявлений рядом" and caps reliability at 'low'. + Listings display cards are NOT hidden (unlike original #oblast-E) — + `listings_clean` stays intact and estimate_quality() still surfaces + them as context even though they no longer drive n_analogs/median. + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor → + #oblast-F: keep the real thin median (never refuse outright). + Real refusal ("недостаточно данных") now happens ONLY at genuine n=0 + (no listings AND no usable anchor/deals) — the never-block requirement + with an honest, priority-ordered source selection. + +`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F +relaxation cascade (room-adjacency / freshness / novostroyki / radius, see +estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in +Layer 1) ever runs the 3-way gate above — `listings` here is whatever that +cascade could find. Two layers: 1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) — - boundary behaviour of the gate itself. + boundary behaviour of the gate itself: the 3-way rule, low-reliability + wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_ + thin_listings bookkeeping. 2. `estimate_quality` integration tests — proves the money-path invariants - that matter to a caller: literal 0 never leaks as a "confident" price, - display `analogs` cards never outnumber what `n_analogs` claims, and the - explanation text describes what actually happened (not a stock "аналогов - не найдено" when some WERE found, just too few). + that matter to a caller: thin+usable-deals routes to the deals corridor + (Серов repro), thin+no-deals keeps its own median, display `analogs` + cards are shown either way, and the #oblast-F room-adjacency relaxation + (studio↔1-комн) actually grows a thin sample and is reported via + `AggregatedEstimate.relaxations` / `reliability`. """ from __future__ import annotations @@ -103,15 +136,14 @@ def test_threshold_is_five_not_lower() -> None: assert HEADLINE_LISTINGS_MIN_N == 5 -def test_four_listings_below_threshold_suppressed_no_fallback() -> None: - """n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state, - NOT the naive median of 4 listings.""" +def test_four_listings_below_threshold_kept_not_suppressed() -> None: + """#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision + 2026-08-10 — never zero out a thin-but-real sample), just flagged low.""" pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0])) - assert pr.median_ppm2 == 0.0 - assert pr.median_price == 0 - assert pr.n_analogs == 0 - assert pr.range_low == 0 - assert pr.range_high == 0 + assert pr.median_ppm2 == 215_000.0 + assert pr.n_analogs == 4 + assert pr.median_price == round(215_000.0 * 45.0) + assert pr.confidence == "low" def test_five_listings_at_threshold_not_suppressed() -> None: @@ -122,18 +154,21 @@ def test_five_listings_at_threshold_not_suppressed() -> None: assert pr.median_price == round(210_000.0 * 45.0) -def test_one_listing_below_threshold_suppressed() -> None: - """n=1 — the sharpest form of the Серов bug (a single random lot deciding - the whole headline) — must be suppressed exactly like n=4.""" +def test_one_listing_below_threshold_kept_not_suppressed() -> None: + """#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot) + median rather than being zeroed; confidence stays 'low'.""" pr = _call(listings=_lots([200_000.0])) - assert pr.median_ppm2 == 0.0 - assert pr.n_analogs == 0 + assert pr.median_ppm2 == 200_000.0 + assert pr.n_analogs == 1 + assert pr.confidence == "low" def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: - """n=3 listings (thin) + a usable ДКП corridor → headline comes from the - deal corridor median, NOT the 3-listing median (live Серов repro: 3 - listings gave 42 391 vs the honest ДКП-based ~54 126).""" + """#oblast-E priority RESTORED (2026-08-10 product correction): a thin + (n=3) listings sample must NOT outrank a usable ДКП deals corridor — this + is the exact live Серов repro #oblast-E exists for (3 noisy listings gave + 42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline + comes from the deal corridor median, NOT the 3-listing median.""" dkp_raw = { "count": 54, "low_ppm2": 44_000, @@ -151,12 +186,23 @@ def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: ) assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline" assert pr.confidence == "low" + assert pr.deals_headline_due_to_thin_listings is True + assert pr.listings_clean, "listings_clean must stay intact — display cards still show them" + # #4: explanation must not falsely claim "рядом нет объявлений" (some WERE + # found, just ceded priority to the more reliable deals corridor) and must + # NOT also carry the separate "Оценка построена по N аналогам" thin-kept + # wording (that phrasing is reserved for the no-usable-corridor branch). + assert pr.explanation is not None + assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "сделкам росреестра" in pr.explanation.lower() + assert "оценка построена по 3" not in pr.explanation.lower() -def test_thin_sample_with_insufficient_deals_stays_zero() -> None: +def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None: """n=3 listings (thin) + a ДКП corridor that is ITSELF too thin - (< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero, - not a fabricated number from either side.""" + (< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so + #oblast-F's never-block rule applies: the real listings median is kept + rather than refusing (neither source alone would justify a hard zero).""" dkp_raw = { "count": 1, "low_ppm2": 40_000, @@ -165,49 +211,51 @@ def test_thin_sample_with_insufficient_deals_stays_zero() -> None: "period_months": 12, } pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw) - assert pr.median_ppm2 == 0.0 - assert pr.median_price == 0 - assert pr.n_analogs == 0 + assert pr.median_ppm2 == 42_391.0 + assert pr.n_analogs == 3 + assert pr.deals_headline_due_to_thin_listings is False -def test_thin_sample_explanation_is_honest_about_count() -> None: - """The explanation for a thin-but-nonzero sample must say HOW MANY listings - were found (not the generic 'ничего не найдено' text used for a genuine - zero-listing case) — #4 in the task: explanation must match reality.""" +def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None: + """#4 (task spec): the explanation for a thin-but-real sample must read as + "small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для + оценки по рынку" copy, and NOT the generic zero-analogs text.""" pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2 assert pr.explanation is not None assert "2" in pr.explanation - assert "недостаточно" in pr.explanation.lower() - # Must NOT reuse the "nothing found at all" copy — 2 listings WERE found. + assert "выборка мала" in pr.explanation.lower() + assert "точность снижена" in pr.explanation.lower() + assert "минимум для оценки по рынку" not in pr.explanation.lower() assert "не найдено аналогов" not in pr.explanation.lower() -def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None: - """#4: once the ДКП fallback fires for a thin (not zero) sample, the - explanation must not falsely claim 'рядом нет объявлений' — some WERE - found, just not enough to trust.""" +def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None: + """Control: the #oblast-D deals-headline-fallback path is UNCHANGED for + GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin + case, not the true-zero case, which still needs a fallback source.""" dkp_raw = { - "count": 20, - "low_ppm2": 40_000, - "median_ppm2": 60_000, - "high_ppm2": 80_000, + "count": 54, + "low_ppm2": 44_000, + "median_ppm2": 65_957, + "high_ppm2": 89_000, "period_months": 12, } - pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw) + pr = _call(listings=[], dkp_raw=dkp_raw) + assert pr.median_ppm2 == 65_957.0 + assert pr.n_analogs == 0 + assert pr.confidence == "low" assert pr.explanation is not None - assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "рядом нет актуальных объявлений" in pr.explanation.lower() assert "сделкам росреестра" in pr.explanation.lower() -def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None: - """Regression guard: the gate must suppress the AGGREGATE (median/n_analogs) - without clearing `listings_clean` itself — the same-building anchor's own - ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell - "genuinely zero nearby listings" from "some nearby, just too few to trust - as headline", and conflating the two was caught regressing - test_estimator_split_corridor_1871.py during this change.""" +def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None: + """listings_clean stays intact (unchanged invariant — same-building anchor's + ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is + the REAL count (not zeroed) while listings_headline_thin_n still marks the + sample as thin for the low-reliability note upstream.""" pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0])) - assert pr.n_analogs == 0 + assert pr.n_analogs == 3 assert len(pr.listings_clean) == 3 assert pr.listings_headline_thin_n == 3 @@ -219,6 +267,42 @@ def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None: assert pr.listings_headline_thin_n == 0 +def test_repair_coefficient_now_applies_to_thin_sample() -> None: + """#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a + thin sample because the headline was already zeroed (applying it would be a + no-op). Now that the real median is kept, the coefficient must apply.""" + pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin + pr = _price_from_inputs( + listings=_lots([200_000.0, 210_000.0]), + area_m2=45.0, + rooms=2, + repair_state="excellent", + floor=5, + total_floors=9, + target_year=None, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=[], + anchor_tier_fetched=None, + dkp_raw=None, + imv_anchor=None, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=lambda _appm2: (None, None), + quarter_index_lookup=lambda q: None, + quarter_indexes_lookup=lambda qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + assert ( + pr.median_price != pr_no_repair.median_price + ), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample" + + # ───────────────────────────────────────────────────────────────────────────── # Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path) # ───────────────────────────────────────────────────────────────────────────── @@ -261,24 +345,31 @@ def _serov_payload() -> Any: def _run_estimate( *, - analogs: list[dict[str, Any]], + analogs: list[dict[str, Any]] | None = None, dkp_raw: dict[str, Any] | None, + fetch_analogs_side_effect: Any = None, + payload: Any = None, + geo: GeocodeResult | None = None, ) -> Any: from app.services.estimator import estimate_quality db = MagicMock() - payload = _serov_payload() + payload = payload or _serov_payload() + geo = geo or _geo() + + fetch_analogs_kwargs: dict[str, Any] = ( + {"side_effect": fetch_analogs_side_effect} + if fetch_analogs_side_effect is not None + else {"return_value": (list(analogs or []), False, "W")} + ) async def _run() -> Any: with ( - patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), - patch( - "app.services.estimator._fetch_analogs", - return_value=(list(analogs), False, "W"), - ), + patch("app.services.estimator._fetch_analogs", **fetch_analogs_kwargs), patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)), patch("app.services.estimator._fetch_deals", return_value=[]), patch( @@ -301,40 +392,47 @@ def _run_estimate( return anyio.run(_run) -def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None: - """Каменск-Уральский-style repro: thin listings, no usable ДКП corridor — - median_price_rub must be 0 AND insufficient_data must be True TOGETHER - (the AggregatedEstimate.insufficient_data computed_field invariant that - stops a literal 0 ₽ reaching the user as a confident number).""" +def test_e2e_thin_sample_no_relaxation_help_keeps_real_median() -> None: + """#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always + returns the SAME 2 listings regardless of relaxation params (none of them + help) — median_price_rub must be the REAL non-zero 2-listing median, + insufficient_data False, n_analogs=2, confidence='low', reliability + 'very_low' (n<3), relaxations empty (nothing actually helped).""" analogs = [ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), ] est = _run_estimate(analogs=analogs, dkp_raw=None) - assert est.median_price_rub == 0 - assert est.insufficient_data is True - assert est.n_analogs == 0 + assert est.median_price_rub == round(205_000.0 * 45.0) + assert est.insufficient_data is False + assert est.n_analogs == 2 assert est.confidence == "low" + assert est.relaxations == [] + assert est.reliability == "very_low" -def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None: - """The 2 thin listings must NOT be surfaced as `analogs` display cards while - n_analogs reports 0 — that would be the same dishonesty (confident-looking - UI) this whole gate exists to remove.""" +def test_e2e_thin_sample_display_cards_match_n_analogs() -> None: + """#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin + sample — they must match n_analogs exactly (both = 2), never hidden.""" analogs = [ _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), ] est = _run_estimate(analogs=analogs, dkp_raw=None) - assert est.n_analogs == 0 - assert est.analogs == [] + assert est.n_analogs == 2 + assert len(est.analogs) == 2 def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: - """Live Серов repro (n=3 scraped listings, wide ДКП corridor available): - headline must come from the deal corridor, not the noisy 3-listing median, - and the estimate must be honestly non-'insufficient' (a real number, low - confidence, deals-sourced).""" + """Live Серов repro (n=3 scraped listings, wide ДКП corridor available) — + #oblast-E priority RESTORED: headline must come from the deal corridor, + not the noisy 3-listing median. Also proves the #4 task-spec requirements + layered on top of the restored priority: the estimate is honestly non- + 'insufficient' (a real number, low confidence), reliability is capped at + 'low' (not 'very_low' — a 54-deal corridor is real signal), the + relaxation label names the source switch, AND the 3 thin listings are + still shown as display cards (not discarded) even though they no longer + drive n_analogs/median.""" analogs = [ _make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"), _make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"), @@ -354,12 +452,15 @@ def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: assert est.confidence == "low" assert est.confidence_explanation is not None assert "сделкам росреестра" in est.confidence_explanation.lower() + assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'" + assert "оценка по сделкам — мало объявлений рядом" in est.relaxations + assert len(est.analogs) == 3, "thin listings must still surface as display cards" def test_e2e_sufficient_five_analogs_unaffected_control() -> None: """Control (mirrors the Екатеринбург prod check in the PR): a sample that clears the threshold is priced exactly as before — headline is the real - listings median, all 5 analogs counted.""" + listings median, all 5 analogs counted, no relaxations needed.""" analogs = [ _make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"), _make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"), @@ -371,3 +472,78 @@ def test_e2e_sufficient_five_analogs_unaffected_control() -> None: assert est.median_price_per_m2 == 210_000 assert est.n_analogs == 5 assert est.insufficient_data is False + assert est.relaxations == [] + assert est.reliability == "low" # n=5 falls in the 3..7 bucket + + +def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None: + """#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin + sample (studio-adjacent building, live prod repro Академика Парина 46/5) → + cascade retries with rooms IN (0,1) and finds a trustworthy sample there. + Asserts: studios pulled in, `relaxations` names it, real non-zero median, + reliability downgraded to 'low' (thin base sample).""" + from app.schemas.trade_in import TradeInEstimateInput + + exact_rooms1 = [ + _make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0), + _make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0), + ] + studio_pool = [ + *exact_rooms1, + _make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0), + _make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0), + _make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0), + ] + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1: + return list(studio_pool), False, "W" + return list(exact_rooms1), False, "W" + + geo = GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5", + provider="nominatim", + ) + payload = TradeInEstimateInput( + address="ЕКБ, ул. Парина, 46/5", + area_m2=23.1, + rooms=1, + ) + + est = _run_estimate( + dkp_raw=None, + fetch_analogs_side_effect=_fetch_analogs_stub, + payload=payload, + geo=geo, + ) + + assert "учтены студии" in est.relaxations + assert est.median_price_rub > 0 + assert est.reliability == "low" + assert est.n_analogs == 5 + + +def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None: + """#oblast-F step (d) contract: when the user explicitly picked radius_m + (#2044), the cascade must NOT auto-expand past it — mirrors the existing + radius-fallback contract above (no auto-expansion beyond user's choice).""" + from app.schemas.trade_in import TradeInEstimateInput + + thin = [ + _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), + _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), + ] + payload = TradeInEstimateInput( + address="Серов, ул. Ленина, 5", + area_m2=45.0, + rooms=2, + floor=5, + total_floors=9, + city_hint="Серов", + radius_m=1500, + ) + est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload) + assert not any("радиус расширен" in r for r in est.relaxations) + assert est.search_radius_m == 1500 diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py new file mode 100644 index 00000000..d92a9ab9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py @@ -0,0 +1,216 @@ +"""Квартал ЦЕЛИ для индекса #764 берётся у цели, а не у аналога. + +Дефект (прод, замер 2026-08-12): `dadata.house_cadnum` пуст в 15 из 15 применений +квартального индекса, и `target_quarter` молча брался из кадастра ПЕРВОГО аналога с +кадастром. Аналог-донор стоял в 201-4087 м от цели; индексы кварталов на такой +дистанции расходятся в 1.15-1.23 раза по медиане и в 1.6-1.8 по p90 — то есть +«индекс квартала цели» на витрине был индексом квартала соседа, и он двигал цену +(применённые на проде множители: 0.60 ×3, 0.73 ×2, 0.76 ×2, 0.79 ×2, 0.80, 1.03 ×3, +1.08, 1.09). + +Паттерн: os.environ.setdefault перед импортом (как test_estimator_quarter_index.py). +Фикстуры лотов/payload переиспользуются оттуда же; harness здесь свой, потому что +тестам нужны РАЗНЫЕ индексы у разных кварталов (общий harness отдаёт один индекс +всем кварталам → factor всегда 1.0, подмена квартала невидима) и контроль над +координатным резолвом квартала цели. +""" + +from __future__ import annotations + +import os +from contextlib import ExitStack +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import anyio + +from tests.test_estimator_quarter_index import ( + _AREA, + _BASE_PPM2, + _make_fake_dadata, + _make_fake_geo_qi, + _make_listing_qi, + _make_payload_qi, +) + +_TARGET_Q = "66:41:0204016" +_NEIGH_CHEAP_Q = "66:41:0601022" +_NEIGH_RICH_Q = "66:41:0601033" +_INDEXES = {_TARGET_Q: 1.2, _NEIGH_CHEAP_Q: 0.8, _NEIGH_RICH_Q: 1.6} + +_UNPATCHED = object() # sentinel: координатный резолвер не подменяем + + +def _run_estimate( + *, + analogs: list[dict[str, Any]], + dadata_cadnum: str | None, + coords_quarter: Any = _UNPATCHED, + coords_enabled: bool = True, +): + """estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES. + + coords_quarter: + * `_UNPATCHED` (default) — координатный резолвер НЕ подменяется, db-мок + отдаёт «зданий рядом нет» (`.first() → None`), т.е. свой квартал цели + неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) — + старый код подставит квартал аналога, на чём тест и падает. + * str / None — явная подмена резолвера. + + coords_enabled — значение `estimate_quarter_from_coords_enabled` на время + прогона (в проде флаг ВЫКЛЮЧЕН, см. config); тесты координатной половины + включают его явно. + """ + from app.services.estimator import estimate_quality, settings + + db = MagicMock() + # «Ни одного здания в радиусе порога»: единственный db.execute(...).first() на + # пути оценки (всё остальное I/O застаблено) — координатный резолв квартала. + db.execute.return_value.first.return_value = None + + dadata_obj = _make_fake_dadata(dadata_cadnum) if dadata_cadnum is not None else None + + def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals): + idx = _INDEXES.get(quarter_cad_number) + return (idx, 30) if idx is not None else None + + def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals): + return {q: _INDEXES[q] for q in quarter_cad_numbers if q in _INDEXES} + + async def _run(): + stubs = [ + patch( + "app.services.estimator.geocode", + new=AsyncMock(return_value=_make_fake_geo_qi()), + ), + patch( + "app.services.estimator.dadata_clean_address", + new=AsyncMock(return_value=dadata_obj), + ), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(analogs), False, "W"), + ), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._fetch_dkp_corridor", return_value=None), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=None), + patch("app.services.estimator._lookup_quarter_index", side_effect=_fake_lookup_index), + patch( + "app.services.estimator._lookup_quarter_indexes", + side_effect=_fake_lookup_indexes, + ), + ] + if coords_quarter is not _UNPATCHED: + stubs.append( + patch( + "app.services.estimator._lookup_target_quarter_by_coords", + return_value=coords_quarter, + ) + ) + stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled)) + with ExitStack() as stack: + for stub in stubs: + stack.enter_context(stub) + return await estimate_quality(_make_payload_qi(), db) + + return anyio.run(_run) + + +def _analogs_two_quarters() -> list[dict[str, Any]]: + """5 аналогов в дешёвом квартале + 5 в дорогом, одинаковая цена ₽/м². + + Первым в listings_clean идёт дешёвый — именно его кадастр старый код брал за + кадастр ЦЕЛИ. avg_analog_index = (0.8 + 1.6) / 2 = 1.2 при равных весах. + Разные этажи обязательны: физический ключ _dedup_cross_source иначе схлопнет + все лоты в один. + """ + cheap = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i) + for i in range(5) + ] + rich = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_RICH_Q}:{200 + i}", floor=8 + i) + for i in range(5) + ] + return cheap + rich + + +def test_neighbour_cadastre_no_longer_sets_target_quarter() -> None: + """Свой квартал цели неизвестен → поправки нет (а не «квартал первого аналога»). + + КРАСНЫЙ на старом коде: там target_quarter = 66:41:0601022 (дешёвый сосед, + index 0.8), avg = 1.2 → factor 0.667 → медиана падает на треть, и пользователю + пишется «Учтена локация квартала (индекс цен квартала ×0.67)». + """ + est = _run_estimate(analogs=_analogs_two_quarters(), dadata_cadnum=None) + + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала" not in (est.confidence_explanation or "") + + +def test_target_quarter_from_own_coords_drives_correction() -> None: + """Квартал цели снят по ЕЁ координатам → поправка считается от него. + + index цели 1.2 / avg аналогов 1.2 → factor 1.00: цена не двигается, потому что + уровень аналогов и уровень цели совпали. Старый код взял бы 0.8 у соседа и + ×0.67 без всяких данных о цели. + """ + est = _run_estimate( + analogs=_analogs_two_quarters(), + dadata_cadnum=None, + coords_quarter=_TARGET_Q, + ) + + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала ×1.00" in (est.confidence_explanation or "") + + +def test_own_cadastre_wins_over_coords() -> None: + """Свой кадастр DaData приоритетнее координатного резолва — резолв не зовётся.""" + analogs_cheap = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i) + for i in range(5) + ] + with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy: + est = _run_estimate(analogs=analogs_cheap, dadata_cadnum=f"{_TARGET_Q}:350") + + assert spy.call_count == 0 + # target 1.2 / avg 0.8 = 1.5 + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5) + + +def test_coords_resolver_dormant_by_default() -> None: + """Флаг выключен (прод-дефолт) → координатный резолв не зовётся вообще. + + Поставляемое поведение = снятие фолбэка на кадастр аналога, и только оно: + точность самого резолва измерена (92% на 25 м), а эффект поправки на ЦЕНУ — + нет, поэтому включение — отдельное решение (см. config, критерий и дата). + """ + with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy: + est = _run_estimate( + analogs=_analogs_two_quarters(), + dadata_cadnum=None, + coords_quarter=_UNPATCHED, + coords_enabled=False, + ) + + assert spy.call_count == 0 + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала" not in (est.confidence_explanation or "") diff --git a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py new file mode 100644 index 00000000..5b824132 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py @@ -0,0 +1,250 @@ +"""#2632 — ответ несёт ФАКТИЧЕСКИЙ радиус подбора, а не выбор пользователя. + +Прод, 2026-08-11 (оценка 4c20518f, ул. Советская 51): сохранённая строка несёт +`relaxations = ["радиус расширен до 5000 м"]`, все 10 сохранённых аналогов лежат +дальше километра (самый дальний — 4381 м), а `GET /estimate/{id}` отдавал +`search_radius_m: null` → фронт падал на превью-радиус 1 км и рисовал круг, за +которым остаются его же пины. Там же вторая ложь: `confidence_explanation` +утверждал «расширили радиус до 2 км» — константа старого 1км→2км fallback'а, +хотя каскад #oblast-F ушёл до 5 км. + +Тесты падают на коде до этой правки: helper'ов не существует (ImportError), +а объяснение прибито к «2 км». +""" + +from __future__ import annotations + +import os +import sys +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app.services.estimator import ( # noqa: E402 + DEFAULT_RADIUS_M, + _compute_confidence, + radius_from_relaxations, + radius_relaxation_label, + rehydrate_search_radius_m, +) + +_ESTIMATE_ID = "4c20518f-7d5d-4f9a-9dde-8f65d5ea2202" + + +# ── подпись каскада ↔ разбор: единственный носитель числа в persisted-состоянии ── + + +def test_radius_label_roundtrips() -> None: + assert radius_from_relaxations([radius_relaxation_label(5000)]) == 5000 + assert radius_from_relaxations(["учтены студии", radius_relaxation_label(2000)]) == 2000 + # прод-строка дословно + assert radius_from_relaxations(["радиус расширен до 5000 м"]) == 5000 + + +def test_radius_label_absent_or_malformed_gives_none() -> None: + assert radius_from_relaxations(None) is None + assert radius_from_relaxations([]) is None + assert radius_from_relaxations(["учтены новостройки", "площадь ±25%"]) is None + assert radius_from_relaxations(["радиус расширен до пяти км"]) is None + + +# ── реконструкция радиуса для сохранённой оценки ────────────────────────────── + + +def test_rehydrate_prefers_persisted_label_over_analog_extent() -> None: + """Подпись — точное значение (то же, что вернул POST); размах — лишь оценка снизу.""" + assert rehydrate_search_radius_m(["радиус расширен до 5000 м"], [4381, 1200, None]) == 5000 + + +def test_rehydrate_falls_back_to_analog_extent() -> None: + """Строки до миграции 255: подписи нет, но круг обязан вмещать свои же пины.""" + assert rehydrate_search_radius_m([], [800, 2428, None]) == 2428 + + +def test_rehydrate_never_below_default_radius() -> None: + """Все аналоги рядом → поиск всё равно шёл по базовому радиусу, не по 300 м.""" + assert rehydrate_search_radius_m([], [120, 300]) == DEFAULT_RADIUS_M + + +def test_rehydrate_without_distances_stays_none() -> None: + """Нечего утверждать — фронт честно падает на выбор пользователя (как раньше).""" + assert rehydrate_search_radius_m([], []) is None + assert rehydrate_search_radius_m(None, [None, None]) is None + + +# ── объяснение называет тот радиус, по которому реально искали ──────────────── + + +def _confidence(radius_used_m: int | None) -> str: + _, explanation = _compute_confidence( + 12, + 100_000.0, + 95_000.0, + 105_000.0, + True, # fallback_radius_used + False, + listings=[{"address": f"дом {i}"} for i in range(12)], + radius_used_m=radius_used_m, + ) + return explanation + + +def test_explanation_names_actual_radius_not_hardcoded_2km() -> None: + text = _confidence(5000) + assert "5 км" in text + assert "2 км" not in text + + +def test_explanation_keeps_2km_wording_when_that_is_the_truth() -> None: + assert "2 км" in _confidence(2000) + # не-круглый радиус не притворяется километрами + assert "1500 м" in _confidence(1500) + + +# ── GET /estimate/{id}: сохранённая оценка отдаёт фактический радиус ────────── + + +def _prod_shaped_row() -> SimpleNamespace: + """Форма прод-строки 4c20518f (расширение до 5 км, аналоги дальше километра).""" + analog = { + "address": "Екатеринбург, ул. Советская, 51", + "area_m2": 44.0, + "rooms": 2, + "floor": 3, + "total_floors": 9, + "price_rub": 6_000_000, + "price_per_m2": 136_000, + "listing_date": None, + "days_on_market": None, + "source": "cian", + "distance_m": 4381, + } + return SimpleNamespace( + id=_ESTIMATE_ID, + median_price=6_000_000, + range_low=5_500_000, + range_high=6_500_000, + median_price_per_m2=136_000, + confidence="low", + confidence_explanation="Найдено 42 аналогов", + n_analogs=42, + analogs=[analog, {**analog, "distance_m": 1200}], + actual_deals=[], + sources_used=["cian"], + data_freshness_minutes=15, + expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=None, + address="Свердловская область, г. Екатеринбург, ул. Советская, д. 51", + lat=56.8519, + lon=60.6122, + area_m2=44.0, + rooms=2, + floor=3, + total_floors=9, + year_built=1975, + house_type=None, + repair_state=None, + has_balcony=None, + canonical_address=None, + house_cadnum=None, + house_fias_id=None, + dadata_qc_geo=None, + dadata_metro=[], + expected_sold_price=None, + expected_sold_range_low=None, + expected_sold_range_high=None, + expected_sold_per_m2=None, + asking_to_sold_ratio=None, + ratio_basis=None, + created_by="praktika", + created_at=datetime.now(tz=UTC), + relaxations=["радиус расширен до 5000 м"], + reliability="low", + ) + + +@pytest.fixture() +def _light_estimator_stub(): + """Подменяет тяжёлые POST-only helper'ы estimator'а, оставляя РЕАЛЬНУЮ реконструкцию. + + Тот же приём, что в test_estimate_idor / test_estimate_revival (не тащить + геокодер/скрейперы в API-тест), но `rehydrate_search_radius_m` берётся + настоящий — его поведение и проверяем. + """ + real_estimator = sys.modules.get("app.services.estimator") + stub = SimpleNamespace( + _qc_geo_to_precision=lambda _qc: None, + _fetch_price_trend=lambda *a, **k: None, + _fetch_dkp_corridor=lambda *a, **k: None, + _fetch_house_imv_anchor=lambda *a, **k: None, + _resolve_target_city=lambda *a, **k: None, + _cv_from_ppm2=lambda *a, **k: None, + _source_counts=lambda *a, **k: {}, + _canonical_sources=lambda *a, **k: ["cian"], + rehydrate_search_radius_m=rehydrate_search_radius_m, + ) + sys.modules["app.services.estimator"] = stub # type: ignore[assignment] + yield stub + if real_estimator is not None: + sys.modules["app.services.estimator"] = real_estimator + else: + sys.modules.pop("app.services.estimator", None) + + +@pytest.fixture(autouse=True) +def _restore_get_role(): + from app.core import auth as auth_mod + + original = auth_mod.get_role + yield + auth_mod.get_role = original + + +def test_get_estimate_returns_actual_search_radius(_light_estimator_stub: SimpleNamespace) -> None: + """Прод-репро: круг на реоткрытой оценке рисовался по 1 км при поиске в 5 км.""" + from app.api.v1 import trade_in as trade_in_module + from app.core import auth as auth_mod + from app.core.db import get_db + + app = FastAPI() + app.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + + row = _prod_shaped_row() + db = MagicMock() + + def _execute(clause, params=None, *_a, **_k): + sql = getattr(clause, "text", str(clause)) + result = MagicMock() + result.fetchone.return_value = row if "SELECT id, median_price" in sql else None + return result + + db.execute.side_effect = _execute + + def _override_db(): + yield db + + app.dependency_overrides[get_db] = _override_db + auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment] + + resp = TestClient(app).get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "praktika"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["search_radius_m"] == 5000 + # круг обязан вмещать пины, которые сам же показывает + assert body["search_radius_m"] >= max(a["distance_m"] for a in body["analogs"]) + # вход не выдаём за результат: radius_m не персистится — «просили» неизвестно + assert body["requested_radius_m"] is None diff --git a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py index b5d6ebbe..5962af60 100644 --- a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py +++ b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py @@ -178,21 +178,22 @@ def test_save_history_items_inserts_each(): ): saved = _save_yandex_history_items(db, result) assert saved == 2 - # 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE - # houses.has_panorama — считаем именно вставки истории, а не все execute. + # 1 batch INSERT (executemany). Фильтруем по SQL, а не по позиции вызова: + # #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой. rows = _history_rows(db) assert isinstance(rows, list) and len(rows) == 2 - # Два коммита: панорама (до истории) + батч истории. Раньше был один. - assert db.commit.call_count == 2 + # Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе + # с колонкой, хвост #2674, мигр. 259. + assert db.commit.call_count == 1 def test_save_history_items_empty_no_commit(): """Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего. #2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно - отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%). - Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается — - а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает. + отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь + match_or_create_house вызывается до возврата — а записей по-прежнему ноль: + истории нет, вставлять нечего. """ db = MagicMock() result = YandexValuationResult( @@ -246,9 +247,9 @@ def test_save_history_items_ext_id_stable_across_calls(): def test_save_history_items_db_error_rolls_back_batch(): """Any item failing rolls back the whole batch — batch semantics (finding #5). - #2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение - доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает), - а батч истории проходил бы успешно, и тест молча проверял бы не тот путь. + #2674: side_effect адресуем по SQL, а не по позиции вызова. Урок остаётся в силе + и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы + не тот путь, стоит появиться любому новому execute перед вставкой истории. """ db = MagicMock() @@ -266,5 +267,4 @@ def test_save_history_items_db_error_rolls_back_batch(): saved = _save_yandex_history_items(db, result) assert saved == 0 # whole batch rolled back db.rollback.assert_called_once() - # Панорама коммитится отдельно и раньше — её успех не отменяет отката истории. - assert db.commit.call_count == 1 + assert db.commit.call_count == 0 diff --git a/tradein-mvp/backend/tests/test_geocoder_city_substitution.py b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py new file mode 100644 index 00000000..b6626d94 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py @@ -0,0 +1,277 @@ +"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ». + +Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры. +Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не +находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой +«точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк, +2026-08-12): **1251 запись** подпадает под инвариант — 1245 `provider=yandex`, +6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов. + +Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город) +и «Малышева» (улица) структурно неразличимы — оба «слово до запятой». После +ответа различимы: провайдер сам пишет, какой НП он использовал. + +Адреса и full_address в тестах — живые строки из прод-кэша (не выдуманные). +""" + +from __future__ import annotations + +import contextlib +import os +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") + +import pytest + +from app.services.geocoder import ( + GeocodeResult, + GeocodeSuggestion, + _city_substituted, + _claimed_locality, + geocode, +) + +# ── _claimed_locality: какой НП назван в тексте ────────────────────────────── + + +@pytest.mark.parametrize( + "address,expected", + [ + # НП назван явно + ("реж, ленина", "реж"), + ("арамиль, красноармейская ул, 118дк1", "арамиль"), + ("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось + # Уровни выше НП пропускаются, НП находится дальше + ("свердловская обл., г.о. рефтинский", "рефтинский"), + ("россия, свердловская область, реж, ленина 5", "реж"), + # НП НЕ назван — основной трафик формы оценки, инвариант не применяется + ("малышева 30", None), + ("ул. титова/монтерская/смоленская, стр. 2", None), + ("улица декабристов, 16-18литж · р-н октябрьский", None), + ("мкр-н широкая речка, ул. анатолия муранова,18", None), + # Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним + ("малышева, мопра", "малышева"), + ], +) +def test_claimed_locality(address: str, expected: str | None) -> None: + assert _claimed_locality(address) == expected + + +# ── _city_substituted: подмена vs честный ответ ────────────────────────────── + + +def _res(full_address: str, lat: float, lon: float) -> GeocodeResult: + return GeocodeResult( + lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact" + ) + + +@pytest.mark.parametrize( + "address,full_address,lat,lon", + [ + # Живые записи прод-кэша из тела #2590 (все provider=yandex) + ( + "реж, ленина", + "Россия, Свердловская область, Екатеринбург, улица Ленина", + 56.83953, + 60.61244, + ), + ( + "реж, краснофлотцев", + "Россия, Свердловская область, Екатеринбург, улица Краснофлотцев", + 56.88996, + 60.63184, + ), + ( + "арамиль, заводская", + "Россия, Свердловская область, Екатеринбург, Заводская улица", + 56.83043, + 60.55170, + ), + ( + "свердловская обл., г.о. рефтинский", + "Россия, Свердловская область, Екатеринбург", + 56.83744, + 60.59764, + ), + # Города вне списка 37 и без структурной приставки — дыра, оставленная #2589 + ( + "талица, кузнецова", + "Россия, Свердловская область, Екатеринбург, улица Кузнецова", + 56.84226, + 60.64557, + ), + ( + "верхотурье, герцена", + "Россия, Свердловская область, Екатеринбург, улица Герцена", + 56.744397, + 60.677299, + ), + # Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от + # улицы Советской, куда провайдер поставил точку (живая запись прода). + ( + "пос. кедровка, советская ул.,5", + "Россия, Свердловская область, Екатеринбург, Советская улица, 5", + 56.8506, + 60.6282, + ), + ], +) +def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None: + assert _city_substituted(address, _res(full_address, lat, lon)) is True + + +@pytest.mark.parametrize( + "address,full_address,lat,lon", + [ + # Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address. + # Тот же вход, что и в подмене выше: разделяет их не география, а + # сохранность топонима (граница из #2777 — посёлки не ловятся + # «городским» признаком, и здесь ловить их и не нужно). + ( + "пос. кедровка, советская ул.,5", + "5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург", + 56.9902, + 60.7166, + ), + ( + "шувакиш, ленина 4", + "4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург", + 56.9295, + 60.4938, + ), + # НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза + ( + "малышева 30", + "Россия, Свердловская область, Екатеринбург, улица Малышева, 30", + 56.8389, + 60.6057, + ), + # Явный Екатеринбург в адресе — не подмена по определению + ("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686), + ("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943), + # Честный ответ по другому городу области: провайдер назвал НП, координаты + # в его собственном bbox — под инвариант не подпадает + ("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813), + # Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит + # внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено + ( + "режевской, ленина", + "Россия, Свердловская область, Берёзовский, улица Ленина", + 56.9088, + 60.8107, + ), + # Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не + # является подменой города + ( + "малышева, мопра", + "Россия, Свердловская область, Екатеринбург, улица Малышева", + 56.8389, + 60.6057, + ), + # Тот же НП, но за пределами ЕКБ — инвариант молчит + ("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897), + ], +) +def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None: + assert _city_substituted(address, _res(full_address, lat, lon)) is False + + +# ── geocode(): сквозной эффект ─────────────────────────────────────────────── + + +@contextlib.contextmanager +def _tiers(*, cached=None, nominatim=None, geoportal=None): + with contextlib.ExitStack() as stack: + stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached)) + stack.enter_context(patch("app.services.geocoder._cache_put")) + stack.enter_context( + patch("app.services.geocoder._geoportal_house_match", return_value=geoportal) + ) + stack.enter_context( + patch("app.services.geocoder._cadastral_house_match", return_value=None) + ) + stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[])) + stack.enter_context( + patch("app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim)) + ) + stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock())) + yield + + +async def test_geocode_downgrades_substituted_result() -> None: + """«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact. + + Координаты остаются (честная деградация, не отказ), но confidence падает до + `locality` — а это уже рабочий контракт: `estimator._geocode_is_coarse` + включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу + `geo_precision='city'`, что исключает его из пула аналогов. + """ + db = MagicMock() + substituted = GeocodeResult( + lat=56.83953, + lon=60.61244, + full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5", + provider="nominatim", + confidence="exact", + ) + with _tiers(nominatim=substituted): + result = await geocode("Реж, Ленина 5", db) + + assert result is not None + assert result.confidence == "locality" + assert result.city_ambiguous is True + assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены + + +async def test_geocode_poisoned_cache_hit_downgraded() -> None: + """Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже + НЕ как точная — без единого DELETE в `geocode_cache`.""" + db = MagicMock() + poisoned = GeocodeResult( + lat=56.83744, + lon=60.59764, + full_address="Россия, Свердловская область, Екатеринбург", + provider="cache", + confidence="exact", + ) + with _tiers(cached=poisoned): + result = await geocode("Арамиль, Красноармейская ул, 118дк1", db) + + assert result is not None + assert result.confidence == "locality" + assert result.city_ambiguous is True + + +async def test_geocode_ekb_address_unaffected() -> None: + """Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города) + остаётся `exact` — инвариант не трогает основной трафик.""" + db = MagicMock() + hit = GeocodeSuggestion( + label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house" + ) + with _tiers(geoportal=hit): + result = await geocode("Екатеринбург, Малышева 30", db) + + assert result is not None + assert result.confidence == "exact" + assert result.city_ambiguous is False + + +async def test_geocode_settlement_inside_ekb_kept_exact() -> None: + """Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не + отбраковывается — граница, о которой предупреждает #2777.""" + db = MagicMock() + honest = GeocodeResult( + lat=56.9902, + lon=60.7166, + full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург", + provider="nominatim", + confidence="exact", + ) + with _tiers(nominatim=honest): + result = await geocode("пос. Кедровка, Советская ул., 5", db) + + assert result is not None + assert result.confidence == "exact" diff --git a/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py b/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py new file mode 100644 index 00000000..d28941c2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py @@ -0,0 +1,510 @@ +"""Unit tests for the `houses` fallback tier of `geocode()` (#2626). + +Covers: +- `_norm_local_house`: normalization of corpus/slash house-number forms + («49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»). +- `_extract_local_house_token`: pulling the house-number token out of a raw + user address, WITH the corpus/slash suffix that `_parse_street_house`'s + `_HOUSE_NUM` drops. +- `_clean_local_house_street` / `_row_local_house`: extracting a comparable + (street, house) pair out of the free-text `houses.address` column (multiple + scraper source formats — avito/cian/derived/yandex_valuation). +- `_street_tail_matches`: «Онуфриева» finds «Начдива Онуфриева» (ГАР canonical + name), regardless of leading district/city noise. +- `_local_houses_match`: full tier with a mocked DB session — + exact number match, corpus-1 fallback guess («49» → «49к1»), and the + defensive "ambiguous → None" invariant (no guessing on >1 distinct match). +- `geocode()` wiring: local-houses tier is the LAST step, only reached when + cache/geoportal/cadastral/Nominatim all miss, and marks + `GeocodeResult.address_refined=True`. + +Real prod addresses (#2626, lat IS NULL in trade_in_estimates) are used as +regression fixtures: «ул Онуфриева, д 24» → «Начдива Онуфриева, 24к1», +«ул. Хрустальногорская, д. 88/2» → exact match, «ул Крестинского, д 49» → +genuinely ambiguous in prod data (two DIFFERENT buildings both stored as +«Крестинского, 49к1» — must NOT resolve, per the defensive "no guessing" rule). +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +from app.services.geocoder import ( # noqa: E402 + GeocodeSuggestion, + _clean_local_house_street, + _extract_local_house_token, + _local_houses_match, + _norm_local_house, + _row_local_house, + _street_tail_matches, + geocode, +) + +# ── _norm_local_house ──────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("49 к 1", "49к1"), + ("49-к1", "49к1"), + ("49 корпус 1", "49к1"), + ("49 корп. 1", "49к1"), + ("88 / 2", "88/2"), + ("88/2", "88/2"), + ("35А", "35а"), + ("13Б", "13б"), + ("13-б", "13б"), + ("44", "44"), + ], +) +def test_norm_local_house(raw: str, expected: str) -> None: + assert _norm_local_house(raw) == expected + + +# ── _extract_local_house_token ─────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("address", "expected"), + [ + ("ул Крестинского, д 49", "49"), + ("ул. Хрустальногорская, д. 88/2", "88/2"), + ("ул Онуфриева, д 24", "24"), + ("Крестинского 49к1", "49к1"), + ("8 Марта 204", "204"), # digit-leading street name doesn't confuse it + ("Малышева 30", "30"), + # #2626 review R2 #1 — прод-баг: квартира подменяла дом («д 15, кв 11» + # → дом «11», чужое здание). Реальные строки из trade_in_estimates: + ( + "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " + "ул Педагогическая, д 15, кв 11", + "15", + ), + ( + "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " + "ул Педагогическая, д 15, кв 48", + "15", + ), + # корпус ПЕРЕД квартирой — «26 к 1» обязан остаться частью номера дома, + # «кв 41» — уйти: + ( + "620149, Свердловская обл, г Екатеринбург, Ленинский р-н, " + "ул Начдива Онуфриева, д 26 к 1, кв 41", + "26к1", + ), + # подъезд/этаж — тот же класс бага, что и квартира (последнее число в + # строке — не дом): + ( + "Россия, Свердловская область, Екатеринбург, Трамвайный переулок, " + "2к2, подъезд 1, этаж 25, кв. 205", + "2к2", + ), + ], +) +def test_extract_local_house_token(address: str, expected: str) -> None: + assert _extract_local_house_token(address) == expected + + +def test_extract_local_house_token_none_for_garbage() -> None: + assert _extract_local_house_token("") is None + assert _extract_local_house_token("Екатеринбург") is None + + +# ── _clean_local_house_street / _street_tail_matches ──────────────────────── + + +def test_clean_local_house_street_strips_type_regardless_of_position() -> None: + """Тип улицы ДО имени («улица X») и ПОСЛЕ («X ул.») — оба зачищаются.""" + assert _clean_local_house_street("улица Начдива Онуфриева") == "начдива онуфриева" + assert _clean_local_house_street("Хрустальногорская ул.") == "хрустальногорская" + + +def test_street_tail_matches_onufrieva_finds_nachdiva_onufrieva() -> None: + """Ядро #2626: «Онуфриева» (как пишет пользователь) находит «Начдива + Онуфриева» (каноничное имя ГАР, как в houses.address).""" + assert _street_tail_matches("начдива онуфриева", "онуфриева") is True + + +def test_street_tail_matches_exact_equality() -> None: + assert _street_tail_matches("хрустальногорская", "хрустальногорская") is True + + +def test_street_tail_matches_rejects_non_suffix_substring() -> None: + """«Онуфриева» НЕ находит несвязанную улицу, где она — не хвостовое слово.""" + assert _street_tail_matches("онуфриева южная", "онуфриева") is False + + +# ── _row_local_house: разбор houses.address разных форматов источников ────── + + +@pytest.mark.parametrize( + ("row_address", "expected"), + [ + ( + "р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1", + ("р-н чкаловский мкр. ботанический крестинского", "49к1"), + ), + ("Хрустальногорская ул.,88/2", ("хрустальногорская", "88/2")), + ("ул. Начдива Онуфриева,24к2", ("начдива онуфриева", "24к2")), + ( + "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + ("р-н ленинский мкр. юго-западный начдива онуфриева", "24к1"), + ), + ("Крестинского, 44", ("крестинского", "44")), + # house-then-district order («·» separator, no comma before house) — + # match-from-start of the LAST comma-segment still finds the leading token. + ("улица Хрустальногорская, 35к1 · р-н Академический", ("хрустальногорская", "35к1")), + ], +) +def test_row_local_house(row_address: str, expected: tuple[str, str]) -> None: + assert _row_local_house(row_address) == expected + + +def test_row_local_house_none_without_house_segment() -> None: + """Нет запятой (номер дома не отделён сегментом) → None, не гадаем.""" + assert _row_local_house("Крестинского") is None + assert _row_local_house("") is None + + +# ── _local_houses_match: full tier, mocked db ──────────────────────────────── + + +def _make_row(address: str, lat: float, lon: float) -> MagicMock: + row = MagicMock() + row.address = address + row.lat = lat + row.lon = lon + return row + + +def _db_with_rows(rows: list[MagicMock]) -> MagicMock: + db = MagicMock() + db.execute.return_value.fetchall.return_value = rows + return db + + +def test_local_houses_match_exact_house_number() -> None: + """«88/2» точно совпадает с единственной строкой houses — возвращает её координаты.""" + db = _db_with_rows( + [ + _make_row("Хрустальногорская ул.,88", 56.79412, 60.498687), + _make_row("Хрустальногорская ул.,88/2", 56.793218, 60.497106), + ] + ) + + hit = _local_houses_match(db, "хрустальногорская", "88/2") + + assert hit is not None + assert isinstance(hit, GeocodeSuggestion) + assert hit.lat == pytest.approx(56.793218) + assert hit.lon == pytest.approx(60.497106) + assert hit.kind == "house" + + +def test_local_houses_match_street_tail_and_corpus1_guess() -> None: + """«Онуфриева, 24» (без «Начдива», без корпуса), реестр — ЕДИНСТВЕННЫЙ + корпус «24к1» → уверенная догадка (нет sibling-корпусов — не угадайка).""" + db = _db_with_rows( + [ + _make_row( + "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + 56.802928, + 60.551696, + ), + ] + ) + + hit = _local_houses_match(db, "онуфриева", "24") + + assert hit is not None + assert hit.lat == pytest.approx(56.802928) + assert hit.lon == pytest.approx(60.551696) + + +def test_local_houses_match_corpus1_guess_skipped_when_sibling_corpus_exists() -> None: + """#2626 review R2 #3, прод-данные: «Начдива Онуфриева, 24» реально ТРИ + разных здания (24к1/24к2/24к3, 250-400м друг от друга). Догадка «→24к1» + не угадывает конкретное здание среди known-siblings — честный None, не + «уверенный» результат с confidence='exact' на случайно выбранном доме.""" + db = _db_with_rows( + [ + _make_row( + "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + 56.802928, + 60.551696, + ), + _make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391), + _make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283), + ] + ) + + assert _local_houses_match(db, "онуфриева", "24") is None + + +def test_local_houses_match_corpus1_guess_skipped_when_slash_sibling_exists() -> None: + """Sibling-guard ловит не только «кN», но и «/N» вариант того же номера.""" + db = _db_with_rows( + [ + _make_row("улица X, 24к1", 56.80, 60.60), + _make_row("улица X, 24/2", 56.81, 60.61), + ] + ) + + assert _local_houses_match(db, "x", "24") is None + + +def test_local_houses_match_no_corpus1_candidate_returns_none() -> None: + """Только «24к2»/«24к3» в реестре (нет «24к1») → фолбэк НЕ гадает, None.""" + db = _db_with_rows( + [ + _make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391), + _make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283), + ] + ) + + assert _local_houses_match(db, "онуфриева", "24") is None + + +def test_local_houses_match_ambiguous_exact_number_returns_none() -> None: + """Прод-кейс: «Крестинского, 49к1» встречается ДВАЖДЫ с РАЗНЫМИ координатами + (две разные строки houses) — неоднозначность, фолбэк не угадывает, None.""" + db = _db_with_rows( + [ + _make_row( + "р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1", + 56.789895, + 60.632464, + ), + _make_row("Екатеринбург, улица Крестинского, 49к1", 56.7952695, 60.610079), + ] + ) + + assert _local_houses_match(db, "крестинского", "49к1") is None + + +def test_local_houses_match_ambiguous_corpus1_guess_returns_none() -> None: + """«49» → «49к1»-кандидатов больше одного (разные координаты) → None.""" + db = _db_with_rows( + [ + _make_row("улица X, 49к1", 56.80, 60.60), + _make_row("улица X, 49к1", 56.81, 60.61), + ] + ) + + assert _local_houses_match(db, "x", "49") is None + + +def test_local_houses_match_deduplicates_same_building_different_sources() -> None: + """Один и тот же дом, две source-строки (avito+cian) с ПОЧТИ идентичными + координатами — НЕ считается неоднозначностью (дедуп по округлённым coords).""" + db = _db_with_rows( + [ + _make_row("улица X, 49к1", 56.800001, 60.600001), + _make_row("улица X, 49к1", 56.800002, 60.600002), # тот же дом, другой source + ] + ) + + hit = _local_houses_match(db, "x", "49к1") + + assert hit is not None + assert hit.lat == pytest.approx(56.800001) + + +def test_local_houses_match_no_guess_for_non_digit_house() -> None: + """Запрос уже с литерой/корпусом («35к3»), точного совпадения нет — корпус-1 + ДОГАДКА не пробуется (не «35к3к1»), результат None.""" + db = _db_with_rows([_make_row("улица X, 35к4", 56.80, 60.60)]) + + assert _local_houses_match(db, "x", "35к3") is None + + +def test_local_houses_match_returns_none_on_db_error() -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("connection lost") + + assert _local_houses_match(db, "онуфриева", "24") is None + + +# ── bbox guard: `houses` is NOT EKB-only (#2626 review R2 #2) ─────────────── + + +def test_local_houses_match_rejects_row_outside_ekb_bbox() -> None: + """Прод-кейс: «улица Маяковского, 7» в `houses` — это Серов (56.6/60.66 — + ~310км от ЕКБ), не Екатеринбург. `use_local_ekb` в `geocode()` гейтит только + ЗАПРОС пользователя, не координаты строки-источника — bbox-фильтр внутри + `_local_houses_match` обязан отбросить такую строку, а не вернуть её как + confidence='exact' совпадение чужого города.""" + db = _db_with_rows( + [_make_row("улица Маяковского, 7", 59.652903, 60.659674)], # Серов, не ЕКБ + ) + + assert _local_houses_match(db, "маяковского", "7") is None + + +def test_local_houses_match_accepts_row_inside_ekb_bbox_wide() -> None: + """Контроль: легитимная ЕКБ-строка (в т.ч. приграничье, в WIDE, не в TIGHT) + по-прежнему проходит — bbox-фильтр не режет реальные ЕКБ-дома.""" + db = _db_with_rows( + [_make_row("Екатеринбург, улица Маяковского, 8", 56.862701, 60.620274)], + ) + + hit = _local_houses_match(db, "маяковского", "8") + + assert hit is not None + assert hit.lat == pytest.approx(56.862701) + + +# ── deterministic ORDER BY (#2626 review R2 #5) ────────────────────────────── + + +def test_local_houses_match_query_has_deterministic_order_by() -> None: + """Без ORDER BY дедуп по округлённым координатам оставлял бы ПЕРВУЮ строку + в порядке сканирования — недетерминированно между вызовами. SQL обязан + сортировать явно.""" + db = _db_with_rows([]) + + _local_houses_match(db, "x", "1") + + sql_text = str(db.execute.call_args[0][0]) + assert "ORDER BY" in sql_text.upper() + + +# ── geocode() wiring — last-resort tier, sets address_refined ─────────────── + + +async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None: + """Cache/geoportal/cadastral/Nominatim все промахнулись → local-houses тир + вызывается ПОСЛЕДНИМ и помечает результат `address_refined=True`.""" + db = MagicMock() + hit = GeocodeSuggestion( + label="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + full_address="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + lat=56.802928, + lon=60.551696, + kind="house", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=None), + patch("app.services.geocoder._cadastral_house_match", return_value=None), + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder._cache_put") as mock_cache_put, + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "app.services.geocoder._local_houses_match", + return_value=hit, + ) as mock_local, + ): + result = await geocode("ул Онуфриева, д 24", db) + + assert result is not None + assert result.lat == pytest.approx(56.802928) + assert result.confidence == "exact" + assert result.address_refined is True + mock_local.assert_called_once() + # #2626 review R2 #4 — houses-фолбэк дешёвый и менее надёжный источник + # координат, чем geoportal/cadastral/Nominatim — свой результат не кэширует. + mock_cache_put.assert_not_called() + + +async def test_geocode_address_refined_false_when_earlier_tier_hits() -> None: + """geoportal-хит (обычный, точный ввод) НЕ помечается `address_refined` — + флаг честно относится ТОЛЬКО к houses-фолбэку.""" + db = MagicMock() + hit = GeocodeSuggestion( + label="ул. Серова, д. 27, Екатеринбург", + full_address="ул. Серова, д. 27, Екатеринбург", + lat=56.81188, + lon=60.59739, + kind="house", + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=hit), + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._local_houses_match", + ) as mock_local, + ): + result = await geocode("Серова 27", db) + + assert result is not None + assert result.address_refined is False + mock_local.assert_not_called() + + +async def test_geocode_returns_none_when_local_houses_also_misses() -> None: + """Все тиры включая houses-фолбэк промахнулись → honest None (не выдумываем).""" + db = MagicMock() + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=None), + patch("app.services.geocoder._cadastral_house_match", return_value=None), + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=None, + ), + patch("app.services.geocoder._local_houses_match", return_value=None) as mock_local, + ): + result = await geocode("ул Онуфриева, д 24", db) + + assert result is None + mock_local.assert_called_once() + + +async def test_geocode_local_houses_apartment_number_does_not_leak_into_house() -> None: + """End-to-end regression, #2626 review R2 #1: реальный прод-адрес с хвостом + «кв 11» должен резолвиться в дом 15 (`Педагогическая ул.,15`), а НЕ в дом 11 + (`Педагогическая ул.,11` — чужое здание) — `_local_houses_match` не + замокан, проверяем полную цепочку `geocode()` → `_extract_local_house_token` + → SQL-lookup.""" + db = _db_with_rows( + [ + _make_row("Педагогическая ул.,11", 56.835387, 60.654104), + _make_row("Педагогическая ул.,15", 56.835284, 60.655829), + ] + ) + + with ( + patch("app.services.geocoder._cache_get", return_value=None), + patch("app.services.geocoder._geoportal_house_match", return_value=None), + patch("app.services.geocoder._cadastral_house_match", return_value=None), + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder._cache_put") as mock_cache_put, + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=None, + ), + ): + result = await geocode( + "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " + "ул Педагогическая, д 15, кв 11", + db, + ) + + assert result is not None + assert result.lat == pytest.approx(56.835284) + assert result.lon == pytest.approx(60.655829) + assert result.address_refined is True + mock_cache_put.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_health_endpoint.py b/tradein-mvp/backend/tests/test_health_endpoint.py new file mode 100644 index 00000000..be2d7fab --- /dev/null +++ b/tradein-mvp/backend/tests/test_health_endpoint.py @@ -0,0 +1,35 @@ +"""GET/HEAD /health — uptime-monitor honesty (#uptime-honest-green). + +GlitchTip PING-мониторы шлют HEAD (или GET без чтения тела). Голый +`@app.get("/health")` без явного HEAD-хендлера отдаёт 405 на HEAD — Starlette +НЕ добавляет HEAD автоматически к FastAPI `@app.get()` роуту (в отличие от +низкоуровневого `Route(methods=["GET"])`). Прод-симптом: `HEAD /health` → 405, +монитор либо красный по конструкции, либо (при PING без сверки статуса) +зелёный вне зависимости от факта. Тест фиксирует оба метода. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + + +def test_health_get_ok() -> None: + client = TestClient(app) + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + + +def test_health_head_ok_no_body() -> None: + """HEAD /health — то, что реально шлёт uptime-monitor. Должен быть 200, без тела.""" + client = TestClient(app) + resp = client.head("/health") + assert resp.status_code == 200 + assert resp.content == b"" + # RFC 9110 §9.3.2 — HEAD должен вернуть те же заголовки представления + # (Content-Type), что и GET; Content-Length допустимо не совпадать (payload + # header field, MAY быть опущен для HEAD). + assert resp.headers["content-type"] == "application/json" diff --git a/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py new file mode 100644 index 00000000..c32e4609 --- /dev/null +++ b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py @@ -0,0 +1,336 @@ +"""honest-run-status (2026-08-15): статус прогона не должен рапортовать 'done' поверх +провала или нуля. Три прод-факта закрыты этой правкой: + + (a) avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1} + -> status='done' — 89% отказов, статус зелёный. mark_backfill_finished звал + mark_done, потому что produced=6 (>0); ни _sweep_run_did_nothing (нет + anchors_total/errors_count у backfill'ов), ни _phase_totally_failed (ключи + "attempted"/"failed" без фазового префикса) эту форму counters не ловили. + Фикс: _failed_ratio_too_high внутри mark_done. + + (b) yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при + processed=5, succeeded=0, rows_inserted=0, failed_resolve=4-5 — сторож нулевого + результата (_alert_if_consecutive_zero_results) слеп, т.к. _RESULT_COUNTER_KEYS + не знал ни одного ключа этого sweep'а (total_seen/lots_fetched/unique_fetched). + Фикс: _RESULT_COUNTER_KEYS дополнен 'succeeded'. Первая версия правки добавляла + голые 'rows_inserted'/'processed' — ревью нашло, что 'rows_inserted' пишет ЕЩЁ + rosreestr_dkp_import (66/67 прод-прогонов, здоровый ноль догнавшего импорта, а не + отказ) и завёл бы непрерываемый ложный zero-стрик, а 'processed' — счётчик + попыток (==limit даже при частичном провале у newbuilding_enrich) и маскирует + реальные отказы. 'succeeded' пишут только yandex_newbuilding_sweep и + newbuilding_enrich, численно совпадает с прежним 'rows_inserted' на всех + прод-прогонах sweep'а — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured + и test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже. + + (c) admin-витрина показывала new_count=0 у трёх подряд cian_full_load, хотя реально + сохранено saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count', ни + 'lots_inserted'. Фикс: _column_counts дополнен saved_inserted/rows_inserted. + +Проверяем на обоих модулях (kit-копия и app-копия — байт-эквивалентны по докстрингу +runs.py), тем же паттерном, что test_2625_run_that_did_nothing.py. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration import runs as kit_runs + +from app.services import scrape_runs as app_runs + +_MODULES = {"kit": kit_runs, "app": app_runs} + + +def _capture_status(mod: Any, counters: dict[str, Any]) -> list[str]: + """Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов. + + Тот же helper, что в test_2625_run_that_did_nothing.py — читаем СТАТУС В SQL, а не + имя вызванной функции. + """ + statuses: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sql = str(stmt) + for status in ("done", "failed", "banned"): + if f"status = '{status}'" in sql: + statuses.append(status) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + with patch.object(mod, "sentry_sdk", MagicMock()): + mod.mark_done(db, 1, dict(counters)) + return statuses + + +def _capture_backfill_status( + counters: dict[str, Any], *, source: str = "avito_detail_backfill", aborted: bool = False +) -> list[str]: + """Прогнать app_runs.mark_backfill_finished на фейковой сессии (mark_done НЕ мокан — + в отличие от test_backfill_honest_status.py, здесь важно именно его РЕАЛЬНОЕ + поведение: mark_backfill_finished решает вызвать mark_done, а решает ли mark_done + остаться 'done' или сам себя переквалифицировать в 'failed' — предмет этого теста). + + mark_backfill_finished есть только в app_runs (kit-копия его не держит — см. + docstring модуля runs.py, "mark_skipped есть только здесь" — тот же принцип + относится к продуктовым финализаторам detail-backfill'ов). + """ + statuses: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sql = str(stmt) + for status in ("done", "failed", "banned"): + if f"status = '{status}'" in sql: + statuses.append(status) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + with patch.object(app_runs, "sentry_sdk", MagicMock()): + app_runs.mark_backfill_finished( + db, 1, dict(counters), source=source, aborted_by_blocks=aborted + ) + return statuses + + +# ── (a) failed_ratio: прод-факт avito_detail_backfill 15.08 ───────────────────────── + + +def test_prod_fact_avito_15_08_no_longer_done() -> None: + """{"attempted":64,"failed":57,"enriched":6,"blocked":1} — 89% отказов — 'failed', + НЕ 'done'. Красный на старом коде (produced=6 != 0 -> mark_done -> 'done').""" + counters = {"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1} + assert _capture_backfill_status(counters) == ["failed"] + + +def test_prod_fact_avito_reason_names_the_ratio() -> None: + reason = app_runs._failed_ratio_too_high( + {"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1} + ) + assert reason is not None + assert "failed-ratio-honest-status" in reason + assert "57 из 64" in reason + assert "89%" in reason + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize( + ("counters", "flagged", "why"), + [ + ({"attempted": 64, "failed": 57}, True, "прод-факт: 89% отказов"), + ({"attempted": 10, "failed": 5}, True, "ровно порог failed (0.5)"), + ({"attempted": 20, "failed": 3}, True, "ровно порог degraded (0.15)"), + ({"attempted": 20, "failed": 2}, False, "ниже порога degraded (0.10)"), + ({"attempted": 2, "failed": 2}, False, "ratio=1.0, но < _FAILED_RATIO_MIN_ATTEMPTS"), + ({"attempted": 0, "failed": 0}, False, "нет попыток вовсе"), + ({"failed": 5}, False, "нет attempted — чужой словарь"), + ({"attempted": 50}, False, "нет failed — чужой словарь"), + ({}, False, "пустые counters"), + ( + {"anchors_total": 5, "errors_count": 5, "lots_fetched": 0}, + False, + "sweep-словарь (anchors_total), не detail-backfill", + ), + ], +) +def test_failed_ratio_classifier_boundaries( + name: str, counters: dict[str, Any], flagged: bool, why: str +) -> None: + reason = _MODULES[name]._failed_ratio_too_high(counters) + assert (reason is not None) is flagged, why + + +# ── (5) не должен палить прогоны с малой/умеренной долей отказов ──────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_low_failure_ratio_stays_done(name: str) -> None: + """Штатный шум (10% отказов) не становится 'failed' — не каждый отказ диагноз.""" + counters = {"attempted": 50, "enriched": 45, "failed": 5} + assert _capture_status(_MODULES[name], counters) == ["done"] + + +def test_tiny_batch_zero_produced_fails_via_old_rule_not_ratio() -> None: + """2 попытки, обе отказали, produced=0 — доля тут не при чём (attempted < floor + _FAILED_RATIO_MIN_ATTEMPTS, _failed_ratio_too_high вернул бы None); статус всё + равно 'failed', но по СТАРОМУ правилу #2674 (produced==0), внутри + mark_backfill_finished — mark_done/_failed_ratio_too_high тут не вызываются вовсе. + Показывает, что новая проверка не дублирует и не подменяет старую.""" + counters = {"attempted": 2, "enriched": 0, "failed": 2} + assert _capture_backfill_status(counters) == ["failed"] + + +def test_tiny_batch_with_partial_success_stays_done() -> None: + """2 попытки, 1 успех, 1 отказ (ratio=0.5, но attempted < floor=3) — стрик слишком + короткий, чтобы доля что-то значила -> остаётся 'done'.""" + counters = {"attempted": 2, "enriched": 1, "failed": 1} + assert _capture_backfill_status(counters) == ["done"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_honest_empty_sweep_unaffected_by_failed_ratio(name: str) -> None: + """Сознательно спящее расписание (город без новостроек): sweep-словарь без + attempted/failed вовсе -> failed_ratio не о чем судить, честная пустота остаётся + 'done' (см. также test_2625_run_that_did_nothing.py::test_honest_empty_stays_done).""" + counters = {"anchors_total": 1, "errors_count": 0, "lots_fetched": 0} + assert _capture_status(_MODULES[name], counters) == ["done"] + + +# ── (b) _RESULT_COUNTER_KEYS: прод-факт yandex_newbuilding_sweep 26.07-10.08 ───────── + + +def test_prod_fact_yandex_newbuilding_sweep_measured_as_zero() -> None: + """processed=5, succeeded=0, rows_inserted=0, failed_resolve=4 — раньше + _run_result_count возвращал None ("не измерено"); теперь — измеренный 0 (через + 'succeeded', не 'rows_inserted' — см. ниже, почему ключ переигран ревью).""" + counters = { + "total": 309, + "fetchable": 200, + "pending": 50, + "processed": 5, + "skipped_already_enriched": 0, + "succeeded": 0, + "resolved_slug": 1, + "failed_resolve": 4, + "failed_fetch": 0, + "rows_inserted": 0, + "duration_sec": 42.0, + } + assert app_runs._run_result_count(counters) == 0 + assert kit_runs._run_result_count(counters) == 0 + + +def test_succeeded_is_the_measured_key_not_rows_inserted_or_processed() -> None: + """'succeeded' читается как результат; голые 'rows_inserted'/'processed' в + _RESULT_COUNTER_KEYS больше не участвуют (были в первой версии правки, снято + ревью — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured и + test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже).""" + counters = {"processed": 5, "rows_inserted": 0} + assert app_runs._run_result_count(counters) is None + assert kit_runs._run_result_count(counters) is None + + +def test_rosreestr_dkp_import_healthy_zero_stays_unmeasured() -> None: + """Прод-факт rosreestr_dkp_import (2026-08-15, 66 из 67 прогонов за 90д): инкрементальный + импорт догнал источник — rows_fetched==rows_skipped, rows_inserted=0. Это ЗДОРОВЫЙ + ответ (нечего вставлять), а не отказ; словарь не содержит 'succeeded' вовсе. + + Первая версия правки добавляла голый 'rows_inserted' в _RESULT_COUNTER_KEYS — тогда + этот прод-факт читался бы как "измеренный провал" и копил бы практически + непрерываемый zero-стрик (rosreestr_dkp_import не прерывается другим статусом: + он либо 'done' с этим же нулём, либо не бежал). Ревью поймало это до деплоя — + правильный ответ: "не измерено" (None), стрик не копится.""" + counters = { + "last_id": 6829903, + "batches_done": 49, + "rows_errored": 0, + "rows_fetched": 96974, + "rows_skipped": 96974, + "rows_updated": 0, + "rows_inserted": 0, + } + assert app_runs._run_result_count(counters) is None + assert kit_runs._run_result_count(counters) is None + + +def test_newbuilding_enrich_partial_failure_not_masked_by_processed() -> None: + """Прод-факт newbuilding_enrich (09.08): processed=25 (счётчик ПОПЫТОК, ==limit), + succeeded=14 — 44% отказов. Если бы сторож читал 'processed' как результат, партиальный + провал замаскировался бы под measured-25 (сторож нулевого результата промолчал бы + ровно там, где должен был сработать при полном провале). 'succeeded' даёт честные 14.""" + counters = { + "failed": 11, + "enriched": 14, + "attempted": 25, + "processed": 25, + "succeeded": 14, + "failed_fetch": 11, + } + assert app_runs._run_result_count(counters) == 14 + assert kit_runs._run_result_count(counters) == 14 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_result_watchdog_now_fires_for_newbuilding_sweep_streak(name: str) -> None: + """(b) integration: 3 подряд yandex_newbuilding_sweep-подобных 'done' с succeeded=0 + -> алерт срабатывает. До фикса _RESULT_COUNTER_KEYS сторож считал результат "не + измеренным" и молчал бы вечно (см. #2703 в docstring модуля).""" + mod = _MODULES[name] + row = MagicMock() + row.status = "done" + row.counters = {"processed": 5, "succeeded": 0, "rows_inserted": 0, "failed_resolve": 4} + db = MagicMock() + result = MagicMock() + result.fetchall.return_value = [row, row, row] + db.execute.return_value = result + with patch.object(mod, "sentry_sdk") as mock_sentry: + mod._alert_if_consecutive_zero_results(db, "yandex_newbuilding_sweep") + mock_sentry.capture_message.assert_called_once() + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_result_watchdog_silent_on_rosreestr_dkp_import_streak(name: str) -> None: + """Негативный аналог теста выше: та же лестница из 3 подряд 'done', но словарь + rosreestr_dkp_import (нет 'succeeded') -> сторож не считает результат измеренным + и НЕ шлёт алерт — регрессионный тест на замечание ревью (HIGH #1).""" + mod = _MODULES[name] + row = MagicMock() + row.status = "done" + row.counters = { + "last_id": 6829903, + "rows_fetched": 96974, + "rows_skipped": 96974, + "rows_inserted": 0, + } + db = MagicMock() + result = MagicMock() + result.fetchall.return_value = [row, row, row] + db.execute.return_value = result + with patch.object(mod, "sentry_sdk") as mock_sentry: + mod._alert_if_consecutive_zero_results(db, "rosreestr_dkp_import") + mock_sentry.capture_message.assert_not_called() + + +# ── (c) _column_counts: прод-факт cian_full_load new_count=0 при saved_inserted>0 ─── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_prod_fact_cian_full_load_saved_inserted_surfaces_as_new_count(name: str) -> None: + """saved_inserted=482 (прод-факт: три подряд прогона 482/214/239) — new_count + больше не 0, хотя ключей 'new_count'/'lots_inserted' в counters нет вовсе.""" + counters = {"unique_fetched": 1200, "saved_inserted": 482, "saved_updated": 30} + total_seen, new_count = _MODULES[name]._column_counts(counters) + assert total_seen == 1200 + assert new_count == 482 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_yandex_newbuilding_rows_inserted_surfaces_as_new_count(name: str) -> None: + counters = {"rows_inserted": 7} + _, new_count = _MODULES[name]._column_counts(counters) + assert new_count == 7 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_new_count_priority_unchanged_by_new_keys(name: str) -> None: + """'new_count' явный ключ всё ещё побеждает 'lots_inserted'/'saved_inserted' — + расширение списка не меняет приоритет уже существующих ключей.""" + counters = {"new_count": 5, "lots_inserted": 99, "saved_inserted": 1} + _, new_count = _MODULES[name]._column_counts(counters) + assert new_count == 5 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_lots_inserted_still_beats_saved_inserted(name: str) -> None: + """Порядок пикулярно НЕ переставлен для уже существующей пары — 'lots_inserted' + (city/newbuilding-sweep'ы) проверяется раньше 'saved_inserted' (full-load'ы), + т.к. это разные, непересекающиеся семейства источников.""" + counters = {"lots_inserted": 12, "saved_inserted": 999} + _, new_count = _MODULES[name]._column_counts(counters) + assert new_count == 12 diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index 63db7822..4355e323 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -18,6 +18,7 @@ import inspect import os import re from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest @@ -486,9 +487,13 @@ class _FakeDB: mapping_rows: list[_Row], step_rowcount: int = 1, fk_children: dict[str, str] | None = None, + residual: dict[str, int] | None = None, + residual_raises: bool = False, ): self._mapping_rows = mapping_rows self._step_rowcount = step_rowcount + self._residual = residual + self._residual_raises = residual_raises self._mapping_served = False # The catalog the FK-child guard reads; defaults to the real live set. self._fk_children = _FK_CHILDREN if fk_children is None else fk_children @@ -503,6 +508,12 @@ class _FakeDB: return _FakeResult() if "FROM pg_constraint" in sql: return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()]) + if "AS residual_rows" in sql: # residual census (#2690) — read-only, after both passes + if self._residual_raises: + raise RuntimeError("census exploded") + if self._residual is None: + return _FakeResult(rows=[]) + return _FakeResult(rows=[SimpleNamespace(**self._residual)]) if "SELECT loser_id, keeper_id, norm_address" in sql: # The service now runs TWO passes (fias, then canon). Model «fias pass found the # duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards. @@ -550,6 +561,85 @@ def test_dry_run_computes_counts_but_rolls_back() -> None: assert db.rollbacks == 1 +# ── Residual census (#2690 п.2/п.4): остаток фиксируется числом, а не оценкой ── + + +def test_residual_census_reuses_the_canon_mapping_prelude() -> None: + """Перепись остатка обязана считать РОВНО те строки, о которых рассуждает канон-проход. + + Общий `_ranked_cte` — не косметика: собственная копия кластеризации разъехалась бы с + проходом, который она описывает, и разъезд был бы невидим (это тот же класс ошибки, что и + ключ в #2690 — два выражения, похожие друг на друга и не равные). RED до фикса: переписи + нет вовсе. + """ + residual = _flat(str(hdm._RESIDUAL_SQL.text)) + prelude = _flat(hdm._ranked_cte(hdm._CANON_KEY_EXPR)) + assert prelude in residual + assert prelude in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + + +def test_residual_census_keeps_silent_guard_apart_from_rejecting_guard() -> None: + """«Координат нет» и «дальше 250 м» — противоположные факты, в одну сумму их нельзя. + + Первое означает, что страж не смог высказаться (остаток неизвестен), второе — что он + высказался по существу и дома РАЗНЫЕ (это вообще не дубли). Сумма из этих двух читается как + «долг», которого нет. Плюс: перепись НЕ применяет гео-фильтр — иначе она считала бы только + то, что и так слилось. + """ + residual = _flat(str(hdm._RESIDUAL_SQL.text)) + for bucket in ( + "AS residual_rows", + "AS residual_listings", + "AS residual_no_geom", + "AS residual_far", + "AS residual_cross_fias", + "AS residual_mergeable", + ): + assert bucket in residual, bucket + # Гео-страж 250 м здесь — РАЗДЕЛИТЕЛЬ корзин, а не фильтр строк. + assert "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in residual + assert set(hdm.DedupMergeResult().to_counters()) >= { + "residual_rows", + "residual_listings", + "residual_no_geom", + "residual_far", + "residual_cross_fias", + "residual_mergeable", + } + + +def test_residual_counters_reported_even_when_nothing_merged() -> None: + """Прогон, который не слил ничего, — ровно тот, чьему остатку нужно число.""" + db = _FakeDB( + mapping_rows=[], + residual={ + "residual_rows": 963, + "residual_listings": 1765, + "residual_no_geom": 326, + "residual_far": 568, + "residual_cross_fias": 8, + "residual_mergeable": 61, + }, + ) + out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert out["losers_deleted"] == 0 + assert out["residual_rows"] == 963 + assert out["residual_listings"] == 1765 + assert out["residual_no_geom"] == 326 + assert out["residual_far"] == 568 + assert out["residual_cross_fias"] == 8 + assert out["residual_mergeable"] == 61 + + +def test_residual_census_failure_never_breaks_the_merge() -> None: + """Перепись — приборы, слияние — продукт: упавший счётчик не отменяет коммит.""" + db = _FakeDB(mapping_rows=[_Row(2, 1, "ул. мира, 10")], residual_raises=True) + out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert out["losers_deleted"] == 1 + assert out["residual_rows"] == 0 # неизвестно — и это видно, а не выдумано + assert db.commits == 1 + + def test_real_merge_commits() -> None: """dry_run=False with dups → exactly one commit, no rollback.""" rows = [_Row(2, 1, "ул. мира, 10")] diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py index c36aa891..98f057aa 100644 --- a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py +++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py @@ -138,7 +138,11 @@ async def test_unknown_house_type_skips_request_and_marks_house() -> None: patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval, patch.object(hib, "_mark_status") as mock_mark, ): - db.execute.return_value.mappings.return_value.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + db.execute.return_value.mappings.return_value.all.side_effect = [[], houses] result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0) mock_eval.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py new file mode 100644 index 00000000..b414aefc --- /dev/null +++ b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py @@ -0,0 +1,267 @@ +"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную. + +Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон +(`only_status`, дефолт 'pending'). Возможность повтора существовала и была +задокументирована в admin-API, но за всю историю (41 прогон, 26.06–11.08.2026) +её не передал никто: все прогоны ушли с 'pending'. Дома, упавшие по причине, +которой больше нет (503/500 от браузерного сайдкара, #2698), лежали в +'transient_error' без единого шанса вернуться — 1390 штук на 12.08.2026. + +Что проверяем — по существу, а не по настройке: + * дом в 'transient_error' ПОПАДАЕТ в пакет прогона с дефолтными параметрами + (тот же вызов, что делает расписание) — на старом коде он не попадает; + * pending при этом из пакета НЕ вытесняется; + * дом, исчерпавший лимит попыток, в автоматический повтор не берётся, а + ЯВНЫЙ only_status='transient_error' (ручной запрос оператора) берёт и его; + * дом без пригодных параметров помечается no_params БЕЗ запроса к площадке. + +Живой Postgres обязателен: проверяется поведение SQL-выборки, а не то, какие +строки вернул мок. Без БД тест самоскипается (в CI БД есть — ci-tradein.yml +поднимает Postgres и накатывает схему). +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import house_imv_backfill as hib + +# Фикстуры живут в собственном диапазоне id (ср. tests/test_house_dedup_merge.py). +_H_STUCK = 900601 # transient_error, попыток 0 → обязан вернуться в очередь +_H_EXHAUSTED = 900602 # transient_error, попытки исчерпаны → в автоповтор не берём +_H_PENDING = 900603 # pending → не должен быть вытеснен retry-очередью +_H_NO_PARAMS = 900604 # pending без объявлений с rooms+area → no_params без запроса +_IDS = (_H_STUCK, _H_EXHAUSTED, _H_PENDING, _H_NO_PARAMS) + + +def _live_session() -> Any | None: + """Session к достижимому не-заглушечному Postgres, иначе None.""" + try: + from sqlalchemy import create_engine, text + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _seed(db: Any) -> None: + from sqlalchemy import text as _t + + # url/source/ext_house_id — NOT NULL в houses (002_core_tables). + db.execute( + _t( + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, " + " imv_status, imv_transient_attempts, last_imv_attempt_at) VALUES " + "(:s, 'avito', 'EXT-2674-S', 'u', 'тестдом 2674, 1', 56.84, 60.60, " + " 'transient_error', 0, NOW() - INTERVAL '10 days')," + "(:x, 'avito', 'EXT-2674-X', 'u', 'тестдом 2674, 2', 56.84, 60.60, " + " 'transient_error', :cap, NOW() - INTERVAL '10 days')," + "(:p, 'avito', 'EXT-2674-P', 'u', 'тестдом 2674, 3', 56.84, 60.60, " + " 'pending', 0, NULL)," + "(:n, 'avito', 'EXT-2674-N', 'u', 'тестдом 2674, 4', 56.84, 60.60, " + " 'pending', 0, NULL)" + ), + { + "s": _H_STUCK, + "x": _H_EXHAUSTED, + "p": _H_PENDING, + "n": _H_NO_PARAMS, + # Литерал, а не hib._MAX_TRANSIENT_ATTEMPTS: подготовка теста обязана + # работать и на СТАРОМ коде, иначе красный прогон получится про + # отсутствующий атрибут, а не про застрявший дом. Синхронность + # литерала и константы стережёт test_transient_attempts_counter_*. + "cap": 3, + }, + ) + # Объявления с rooms+area+house_type — у всех, КРОМЕ _H_NO_PARAMS. + # source_url / dedup_hash / price_rub — NOT NULL, dedup_hash UNIQUE. + db.execute( + _t( + "INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, " + " house_id_fk, rooms, area_m2, floor, total_floors, house_type) " + "VALUES " + "(:l1, 'avito', 'http://t/2674/1', 'L-2674-1', 'dh-2674-1', 5000000, :s, " + " 2, 52.0, 3, 9, 'brick')," + "(:l2, 'avito', 'http://t/2674/2', 'L-2674-2', 'dh-2674-2', 5000000, :x, " + " 2, 52.0, 3, 9, 'brick')," + "(:l3, 'avito', 'http://t/2674/3', 'L-2674-3', 'dh-2674-3', 5000000, :p, " + " 2, 52.0, 3, 9, 'brick')" + ), + { + "l1": 910601, + "l2": 910602, + "l3": 910603, + "s": _H_STUCK, + "x": _H_EXHAUSTED, + "p": _H_PENDING, + }, + ) + db.commit() + + +def _cleanup(db: Any) -> None: + from sqlalchemy import text as _t + + db.rollback() + db.execute(_t("DELETE FROM listings WHERE id IN (910601, 910602, 910603)")) + db.execute(_t("DELETE FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), {"ids": list(_IDS)}) + db.commit() + db.close() + + +async def _run(db: Any, **kwargs: Any) -> Any: + """Прогнать backfill с замоканным вызовом площадки. + + Мокаем ровно сетевую границу: evaluate_via_imv поднимает «адрес не найден», + поэтому дом уходит в not_found и путь сохранения (три таблицы) не нужен — + проверяем именно ВЫБОРКУ. Флаг браузерного фетчера гасим, чтобы не поднимать + сайдкар. + """ + err = hib.IMVAddressNotFoundError("address not found (test)") + with ( + patch.object(hib.settings, "avito_imv_use_browser_fetcher", False), + patch.object(hib, "evaluate_via_imv", AsyncMock(side_effect=err)), + ): + return await hib.backfill_house_imv(db, request_delay_sec=0.0, **kwargs) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_stuck_transient_house_returns_to_the_queue_by_itself() -> None: + """Прогон с дефолтными параметрами (как у расписания) забирает застрявший дом. + + На старом коде выборка — `WHERE imv_status = :status` с единственным + значением 'pending', поэтому дом остаётся в 'transient_error' навсегда. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + + # Ровно то, что вызывает расписание: batch_size из default_params, статус по умолчанию. + result = await _run(db, batch_size=50) + + picked = { + r.id: r.imv_status + for r in db.execute( + _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), + {"ids": list(_IDS)}, + ) + } + assert picked[_H_STUCK] != "transient_error", ( + "дом с временной ошибкой и непотраченным лимитом обязан попасть в пакет " + f"обычного прогона; фактически статус {picked[_H_STUCK]!r} " + f"(в пакет прогона попало домов: {result.checked})" + ) + # pending не вытеснен повтором. + assert picked[_H_PENDING] != "pending", "pending-дом обязан остаться в том же пакете" + # Исчерпавший лимит в автоповтор не берётся — иначе вечный цикл. + assert picked[_H_EXHAUSTED] == "transient_error" + assert result.retried == 1 + # Дом без объявлений с rooms+area помечен ДО пакета, слот не потрачен. + assert picked[_H_NO_PARAMS] == "no_params" + assert result.premarked == 1 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_explicit_only_status_still_takes_exhausted_houses() -> None: + """Явный only_status — ручной запрос оператора: лимит попыток его не режет.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + + result = await _run(db, batch_size=50, only_status="transient_error") + + statuses = { + r.id: r.imv_status + for r in db.execute( + _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), + {"ids": list(_IDS)}, + ) + } + assert ( + statuses[_H_EXHAUSTED] != "transient_error" + ), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит" + # Автоповтора поверх явного запроса нет: pending не тронут. + assert statuses[_H_PENDING] == "pending" + assert result.retried == 0 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_transient_attempts_counter_only_counts_transient() -> None: + """Счётчик — это «сколько раз подряд упал по временной причине», и он обнуляем. + + Без верхней границы у повтора не было бы выхода; без обнуления успехом дом, + однажды поймавший 3 сетевых сбоя, навсегда терял бы право на повтор. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + assert hib._MAX_TRANSIENT_ATTEMPTS == 3, "литерал 'cap' в _seed рассинхронизирован" + + def _attempts() -> int: + return int( + db.execute( + _t("SELECT imv_transient_attempts FROM houses WHERE id = :i"), + {"i": _H_STUCK}, + ).scalar() + ) + + hib._mark_status(db, _H_STUCK, "transient_error", "boom") + assert _attempts() == 1 + hib._mark_status(db, _H_STUCK, "not_found", "адрес не найден") + assert _attempts() == 1, "нетранзиентные исходы счётчик не двигают" + db.execute( + _t("UPDATE houses SET imv_status='ok', imv_transient_attempts=0 WHERE id = :i"), + {"i": _H_STUCK}, + ) + db.commit() + assert _attempts() == 0 + finally: + _cleanup(db) + + +def test_retry_queue_is_not_bound_to_only_status() -> None: + """Статика (бежит и без БД): retry-выборка не зависит от параметра only_status. + + Ровно этот дефект и застрял на проде: единственный путь к 'transient_error' + шёл через значение, которое никто не передавал. + """ + retry_sql = str(hib._RETRY_QUEUE_SQL.text) + assert "imv_status = 'transient_error'" in retry_sql + assert ":status" not in retry_sql, "retry-очередь обязана быть независимой от only_status" + assert "imv_transient_attempts < :max_attempts" in retry_sql, "у повтора должен быть выход" + # psycopg v3: только CAST(:x AS type), никогда :x::type. + for sql in (retry_sql, str(hib._QUEUE_SQL.text), str(hib._PREMARK_UNUSABLE_SQL.text)): + assert "::" not in sql diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py index f0a0c371..e0066101 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -844,6 +844,111 @@ def test_tier2a_coord_less_bare_street_still_runs_tier2a(): assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) +# --------------------------------------------------------------------------- +# match_or_create_house — sweep-city guard for BARE oblast cards (#2777) +# --------------------------------------------------------------------------- + + +def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups(): + """RED до #2777. Бескоординатная карточка областного формата («ул. Кирова,4» — города + в адресе НЕТ) собрана развёрткой Серова. Ключ Tier-2a без координат вырождается в один + нормализованный адрес, глобально уникальный в house_address_aliases, поэтому карточка + села бы в одноимённый дом другого города (прод: 362.8 км, дом 380628). Признак города у + развёртки ЕСТЬ — он же пишется в listings.city — и теперь доезжает до стража.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss (Tier 1) + # Tier 2a/2b SKIPPED стражем, Tier 3 SKIPPED (нет координат) + {"id": 2777}, # INSERT RETURNING id (New house) + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, "avito", "ext-2777-serov", address="ул. Кирова,4", city="Серов" + ) + assert (house_id, conf, method) == (2777, 1.0, "new") + sqls = _executed_sqls(db) + assert not any( + "fingerprint = :fp" in s for s in sqls + ), "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»" + assert not any("normalized_address = :na" in s for s in sqls) + + +def test_sweep_city_ekb_keeps_tier2a_dedup(): + """Контроль к предыдущему: тот же бескоординатный ключ, но развёртка ЕКБ — страж молчит, + Tier-2a дедуп работает как раньше. Иначе правка ломала бы 31 663 ЕКБ-карточки ради 35.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"house_id": 55}, # Tier 2a fingerprint HIT + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, "avito", "ext-2777-ekb", address="ул. Кирова,4", city="Екатеринбург" + ) + assert (house_id, conf, method) == (55, 0.9, "fingerprint") + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + +def test_sweep_city_ignored_when_card_has_coords(): + """Карточка С координатами стражем города не трогается: у Tier-2a координаты уже в ключе, + у Tier-2b свой гео-страж 3 км. Расширять на неё городской признак — значит ломать + смежные ЕКБ/В.Пышма пары, которые в проде расходятся на 2-8 м (то же здание).""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"house_id": 66}, # Tier 2a fingerprint HIT (координаты в ключе) + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, + "avito", + "ext-2777-coords", + address="ул. Кирова,4", + lat=59.60, + lon=60.58, + city="Верхняя Пышма", + ) + assert (house_id, conf, method) == (66, 0.9, "fingerprint") + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + +def test_sweep_city_guard_covers_cities_outside_the_token_list(): + """Страж не должен зависеть от списка _CITY_TOKENS: развёртка Ревды (её в списке нет) + всё равно не имеет права садиться на чужой алиас. Признак — имя города от развёртки, + а не токен, вычитанный из адреса.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"id": 2778}, # INSERT RETURNING id (New house) — алиасы пропущены + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, _conf, method = match_or_create_house( + db, "avito", "ext-2777-revda", address="ул. Кирова,4", city="Ревда" + ) + assert (house_id, method) == (2778, "new") + assert not any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + # --------------------------------------------------------------------------- # match_or_create_listing — mock DB tier routing # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py index 80bca572..b16be85b 100644 --- a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -107,3 +107,27 @@ def test_house_key_never_accepts_flat_cadastre() -> None: assert ( "cad = building_cadastral_number\n" in src ), "в ключ дома вернулся фолбэк на кадастр квартиры" + + +def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None: + """Страж города бесполезен, пока признак не доезжает до него с настоящего вызова (#2777). + + Тот же класс ошибки, что у `house_fias_id`: параметр в сигнатуре есть, передать его + некому. Здесь граница — `scraper_kit.base._link_listing_to_house`, единственный боевой + вызывающий пути создания домов; город он получает из `save_listings`, который его же + пишет в `listings.city`. + """ + from scraper_kit.base import _link_listing_to_house, save_listings + + assert "city" in _params(match_or_create_house) + + hook_src = inspect.getsource(_link_listing_to_house) + assert "city=city" in hook_src, ( + "_link_listing_to_house перестал передавать город в матчер — страж #2777 снова " + "недостижим, а межгородская склейка молча вернётся" + ) + # save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать + # именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен. + assert "city=lot_city" in inspect.getsource( + save_listings + ), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён" diff --git a/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py new file mode 100644 index 00000000..6a8ce9b3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py @@ -0,0 +1,79 @@ +"""Static guards for migration 257 (#2838): лечение yandex source_url. + +Живой БД у юнит-теста нет, поэтому проверяем то, что ломается тихо и навсегда: + + 1. Форма адреса не разъехалась с продюсером. В файле стоит ДОСЛОВНО + `CANONICAL_URL_SQL` из app/tasks/yandex_detail_backfill.py, а тот уже сверен с + `_canonical_source_url` продюсера в test_yandex_detail_backfill.py. Сцепление + транзитивное: поменяют форму у продюсера — красным станет тот тест, поменяют в + миграции — этот. + 2. Отбор — теми же константами, что у очереди (#2838), а не «похожими» regexp'ами. + 3. Бэкап прежних значений пишется ДО UPDATE. Переставь местами — таблица отката + наполнится уже канонизированными адресами, и откат станет no-op'ом, который + выглядит рабочим. + 4. Недеструктивность и транзакционность: одна колонка, BEGIN/COMMIT, lock_timeout. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from app.tasks.yandex_detail_backfill import ( + CANONICAL_URL_SQL, + OFFER_ID_PATTERN, + OFFER_URL_PATTERN, +) + +_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "data" + / "sql" + / "257_listings_backfill_yandex_source_url.sql" +) + + +def _executable_sql() -> str: + """SQL без `--`-комментариев: шапка объясняет прозой и упоминает всё подряд.""" + lines = [raw.split("--", 1)[0] for raw in _MIGRATION.read_text(encoding="utf-8").splitlines()] + return "\n".join(line for line in lines if line.strip()) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def test_migration_exists() -> None: + assert _MIGRATION.exists(), f"missing migration: {_MIGRATION}" + + +def test_canonical_url_matches_producer_formula() -> None: + """Выражение UPDATE — тот же литерал, что CANONICAL_URL_SQL (и, значит, продюсер).""" + assert _flat(CANONICAL_URL_SQL) in _flat(_executable_sql()) + + +def test_selects_rows_by_the_same_constants_as_the_queue() -> None: + """Отбор — константами очереди #2838, иначе миграция чинит не то множество.""" + sql = _executable_sql() + assert f"'{OFFER_URL_PATTERN}'" in sql + assert f"'{OFFER_ID_PATTERN}'" in sql + assert "source = 'yandex'" in sql + + +def test_backup_is_written_before_the_update() -> None: + sql = _executable_sql() + assert sql.index("INSERT INTO yandex_source_url_backfill_257") < sql.index("UPDATE listings") + + +def test_transactional_and_lock_timeout_after_begin() -> None: + sql = _executable_sql() + assert "BEGIN;" in sql and "COMMIT;" in sql + assert sql.index("BEGIN;") < sql.index("SET LOCAL lock_timeout") + assert sql.index("SET LOCAL lock_timeout") < sql.index("UPDATE listings") + + +def test_touches_only_source_url_and_deletes_nothing() -> None: + sql = _executable_sql().upper() + for forbidden in ("DELETE", "DROP", "TRUNCATE", "IS_ACTIVE"): + assert forbidden not in sql, forbidden + assert sql.count("SET SOURCE_URL") == 1 diff --git a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py new file mode 100644 index 00000000..75da91a6 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py @@ -0,0 +1,452 @@ +"""Static guards for migration 262 (wave 2 — оставшиеся 40 городов Свердловской обл.). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует живой +БД; здесь фиксируем структурные инварианты миграции (транзакционность, отсутствие DDL, +отсутствие psycopg CAST-ловушки, все enabled=false) и, ГЛАВНОЕ, parity-guard между +data/sql (что реально сеется в scrape_schedules) и `scraper_kit.orchestration.pipeline` +(`CITY_ANCHORS`/`CITY_LOCATIONS`, откуда sweep берёт координаты и provider-id по +`default_params->>'city'`): + + - каждый slug из миграций 179_/262_ ОБЯЗАН существовать в CITY_ANCHORS — иначе + `get_city_anchors(slug)` вернёт None и `_job_*_city_sweep` молча упадёт на + EKB_ANCHORS (город "включат", а сборка физически уйдёт в Екатеринбург); + - и наоборот: КАЖДЫЙ ключ CITY_ANCHORS (кроме None-пути ЕКБ) обязан иметь schedule- + строки где-то (179_ ИЛИ 262_); + - #262 ревью (после первой версии файла): строка для (provider, city) существует + ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий provider-идентификатор в CITY_LOCATIONS + подтверждён (не None) — НЕ "каждый город получает все 3 источника". Первая версия + заводила все 123 (41×3) строки, планируя добыть идентификаторы ПОСЛЕ — это был бы + ровно тот силентный ЕКБ-fallback баг, о котором она сама предупреждала. Этот файл + проверяет обратное соответствие в обе стороны для avito_slug/yandex_rgid/ + cian_region_id. + +Это ловит ИМЕННО тот класс регрессии, которого просил избежать заказчик: опечатка в +slug/рассинхрон идентификатора при заведении миграции, из-за которой sweep молча +резолвится на EKB_ANCHORS или ЕКБ region_id/rgid. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from scraper_kit.orchestration.pipeline import CITY_ANCHORS, CITY_LOCATIONS + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_179 = _SQL_DIR / "179_scrape_schedules_seed_oblast_city_sweeps.sql" +_MIGRATION_262 = _SQL_DIR / "262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql" + +_PROVIDERS = ("avito", "cian", "yandex") + +# source-строка внутри VALUES (), напр. " 'avito_city_sweep_nizhniy_tagil',". +# Ограничено 4-пробельным отступом + запятой в конце — не матчит примеры в header- +# комментариях (см. "UPDATE scrape_schedules SET enabled = true WHERE source = '...';"). +_ROW_SOURCE_RE = re.compile(r"^ '(avito|cian|yandex)_city_sweep_([a-z_]+)',$", re.MULTILINE) + +_WAVE1_SLUGS = { + "nizhniy_tagil", + "kamensk_uralskiy", + "pervouralsk", + "verkhnyaya_pyshma", + "serov", +} +_WAVE2_SLUGS = { + "novouralsk", + "revda", + "polevskoy", + "asbest", + "bogdanovich", + "irbit", + "krasnoufimsk", + "berezovskiy", + "zarechny", + "kachkanar", + "krasnoturinsk", + "severouralsk", + "ivdel", + "tavda", + "turinsk", + "sysert", + "sredneuralsk", + "degtyarsk", + "verkhnyaya_salda", + "nizhnyaya_salda", + "nevyansk", + "artemovskiy", + "kamyshlov", + "alapaevsk", + "sukhoy_log", + "kushva", + "krasnouralsk", + "karpinsk", + "nizhnyaya_tura", + "verkhniy_tagil", + "nizhnie_sergi", + "lesnoy", + "rezh", + "aramil", + "volchansk", + "verkhnyaya_tura", + "mikhaylovsk", + "verkhoturye", + "talitsa", + "novaya_lyalya", +} +assert len(_WAVE2_SLUGS) == 40 +assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана) + + +def _sql(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _executable_sql(path: Path) -> str: + """SQL без построчных `--`-комментариев — только исполняемый код.""" + lines = [] + for raw in _sql(path).splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _row_sources(path: Path) -> list[tuple[str, str]]: + """(provider, slug) пар из исполняемых INSERT-строк VALUES (не из header-комментов).""" + return _ROW_SOURCE_RE.findall(_executable_sql(path)) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +# ── existence / basic shape ─────────────────────────────────────────────────── + + +def test_migration_262_exists() -> None: + assert _MIGRATION_262.exists(), f"missing migration: {_MIGRATION_262}" + + +def test_migration_262_is_transactional() -> None: + sql = _sql(_MIGRATION_262) + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_262_no_ddl() -> None: + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "alter table" not in flat + assert "create table" not in flat + assert "drop table" not in flat + assert "truncate" not in flat + + +def test_migration_262_no_psycopg_cast_trap() -> None: + assert not re.search(r":\w+::", _sql(_MIGRATION_262)) + + +def test_migration_262_idempotent_on_conflict_do_nothing() -> None: + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "on conflict (source) do nothing" in flat + # Одна INSERT-инструкция на весь файл (не 102 отдельных INSERT) — ON CONFLICT + # покрывает весь батч разом; повторный прогон всего файла — no-op. + assert flat.count("insert into scrape_schedules") == 1 + + +def test_migration_262_excludes_bisert_entirely() -> None: + """Бисерть — пгт, не город; у Циана её нет вообще (любой запрос отдаёт Сысерть). + + Regression-guard: ни одной строки source LIKE '%bisert%' в исполняемом SQL.""" + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "bisert" not in flat + + +# ── row counts (102 = 23 avito + 40 cian + 39 yandex) ─────────────────────── + + +def test_migration_262_has_exactly_102_rows() -> None: + rows = _row_sources(_MIGRATION_262) + assert len(rows) == 102, f"expected 23 avito + 40 cian + 39 yandex = 102 rows, got {len(rows)}" + + +def test_migration_262_row_counts_per_provider() -> None: + rows = _row_sources(_MIGRATION_262) + by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS} + for provider, slug in rows: + by_provider[provider].add(slug) + assert len(by_provider["avito"]) == 23 + assert len(by_provider["cian"]) == 40 + assert len(by_provider["yandex"]) == 39 + # cian — ровно все 40 wave-2 slug'ов (cian_region_id подтверждён у всех). + assert by_provider["cian"] == _WAVE2_SLUGS + # yandex — все, КРОМЕ mikhaylovsk. + assert by_provider["yandex"] == _WAVE2_SLUGS - {"mikhaylovsk"} + # avito — подмножество (только подтверждённые avito_slug), не пересекается с + # городами, у которых avito_slug=None в CITY_LOCATIONS. + assert by_provider["avito"] <= _WAVE2_SLUGS + + +def test_migration_262_no_duplicate_sources() -> None: + rows = _row_sources(_MIGRATION_262) + sources = [f"{p}_city_sweep_{s}" for p, s in rows] + assert len(sources) == len(set(sources)), "duplicate source в 262_" + + +def test_migration_262_does_not_touch_wave1_cities() -> None: + """Regression-guard: 262_ не должен переопределять/дублировать wave-1 5 городов — + они уже сидированы 179_ и живут (enabled управляется отдельно от них).""" + rows = _row_sources(_MIGRATION_262) + slugs = {slug for _provider, slug in rows} + overlap = slugs & _WAVE1_SLUGS + assert not overlap, f"262_ пересекается с wave-1 городами: {overlap}" + + +def test_migration_262_all_rows_enabled_false() -> None: + """Все 102 строки должны быть enabled=false (dormant by design — оператор включает + волнами вручную). Считаем по числу `false,` сразу после source-литерала.""" + executable = _executable_sql(_MIGRATION_262) + tuples = re.findall( + r"'(?:avito|cian|yandex)_city_sweep_[a-z_]+',\s*\n\s*(true|false),", + executable, + ) + assert len(tuples) == 102 + assert set(tuples) == {"false"}, "найдена строка с enabled=true — нарушение dormant-инварианта" + + +# ── CITY_LOCATIONS parity (ГЛАВНЫЙ regression-guard после ревью) ─────────── + + +def test_migration_262_row_exists_iff_identifier_confirmed() -> None: + """Строка (provider, city) есть в 262_ ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий + provider-идентификатор в CITY_LOCATIONS подтверждён (не None). + + Это единственный источник правды после ревью: НЕ "каждый wave-2 город получает 3 + строки" (так было в отклонённой первой версии — риск silent EKB-fallback), а + "строка существует ровно там, где есть подтверждённый id". + """ + rows = _row_sources(_MIGRATION_262) + by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS} + for provider, slug in rows: + by_provider[provider].add(slug) + + for slug in _WAVE2_SLUGS: + loc = CITY_LOCATIONS[slug] + + has_avito_row = slug in by_provider["avito"] + has_avito_id = loc.avito_slug is not None + assert has_avito_row == has_avito_id, ( + f"{slug}: avito_slug={loc.avito_slug!r} (confirmed={has_avito_id}) но " + f"avito-row-exists={has_avito_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + has_cian_row = slug in by_provider["cian"] + has_cian_id = loc.cian_region_id is not None + assert has_cian_row == has_cian_id, ( + f"{slug}: cian_region_id={loc.cian_region_id!r} (confirmed={has_cian_id}) но " + f"cian-row-exists={has_cian_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + has_yandex_row = slug in by_provider["yandex"] + has_yandex_id = loc.yandex_rgid is not None + assert has_yandex_row == has_yandex_id, ( + f"{slug}: yandex_rgid={loc.yandex_rgid!r} (confirmed={has_yandex_id}) но " + f"yandex-row-exists={has_yandex_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + +def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None: + """Каждый slug из CITY_LOCATIONS с непустым avito_slug имеет avito_city_sweep_ + строку в 262_ (wave-2) или 179_ (wave-1) — и наоборот, каждая avito-строка + принадлежит городу с непустым avito_slug.""" + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "avito"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "avito"} + scheduled_avito = rows_262 | rows_179 + + confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None} + assert scheduled_avito == confirmed_avito, ( + f"missing schedule rows: {confirmed_avito - scheduled_avito}; " + f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}" + ) + + +def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None: + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "yandex"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "yandex"} + scheduled_yandex = rows_262 | rows_179 + + confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None} + assert scheduled_yandex == confirmed_yandex, ( + f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; " + f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}" + ) + + +def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> None: + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "cian"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "cian"} + scheduled_cian = rows_262 | rows_179 + + confirmed_cian = { + slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None + } + assert scheduled_cian == confirmed_cian, ( + f"missing schedule rows: {confirmed_cian - scheduled_cian}; " + f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}" + ) + + +def test_mikhaylovsk_has_only_cian_row() -> None: + """Regression-guard для самого узкого случая: mikhaylovsk — единственный город без + yandex_rgid (город отсутствует в гео-базе Яндекса) и без avito_slug — должен иметь + РОВНО одну строку (cian), НЕ три.""" + rows = _row_sources(_MIGRATION_262) + mikhaylovsk_providers = {p for p, s in rows if s == "mikhaylovsk"} + assert mikhaylovsk_providers == { + "cian" + }, f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}" + + +# ── CITY_ANCHORS parity ───────────────────────────────────────────────────── + + +def test_migration_262_slugs_all_exist_in_city_anchors() -> None: + """Каждый slug из 262_ ОБЯЗАН быть в CITY_ANCHORS — иначе get_city_anchors(slug) + вернёт None и sweep молча резолвится на EKB_ANCHORS (собирает ЕКБ вместо города).""" + rows = _row_sources(_MIGRATION_262) + slugs = {slug for _provider, slug in rows} + missing = slugs - set(CITY_ANCHORS) + assert not missing, f"slugs из 262_ отсутствуют в CITY_ANCHORS: {sorted(missing)}" + + +def test_migration_179_slugs_all_exist_in_city_anchors() -> None: + """Тот же guard для wave-1 (179_) — sanity, что baseline не сломан этим PR.""" + rows = _row_sources(_MIGRATION_179) + slugs = {slug for _provider, slug in rows} + missing = slugs - set(CITY_ANCHORS) + assert not missing, f"slugs из 179_ отсутствуют в CITY_ANCHORS: {sorted(missing)}" + + +def test_city_anchors_has_no_slug_without_schedule_rows() -> None: + """Обратное направление: каждый ключ CITY_ANCHORS обязан иметь schedule-строки в + 179_ ИЛИ 262_ — мёртвая запись без сидов сигнализирует рассинхрон/опечатку.""" + seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | { + slug for _p, slug in _row_sources(_MIGRATION_262) + } + orphaned = set(CITY_ANCHORS) - seeded_slugs + assert ( + not orphaned + ), f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}" + + +def test_city_anchors_wave2_count_and_content() -> None: + """CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 — + ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи).""" + assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS + assert len(CITY_ANCHORS) == 45 + + +def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None: + """Каждая wave-2 запись — ровно 1 anchor (город компактнее ЕКБ, см. коммент в + pipeline.py) с непустой человекочитаемой подписью, заканчивающейся на 'центр'.""" + for slug in _WAVE2_SLUGS: + anchors = CITY_ANCHORS[slug] + assert len(anchors) == 1, f"{slug}: ожидался 1 anchor, получено {len(anchors)}" + lat, lon, label = anchors[0] + assert 55.0 < lat < 62.0, f"{slug}: lat={lat} вне разумного диапазона Свердловской обл." + assert 56.0 < lon < 67.0, f"{slug}: lon={lon} вне разумного диапазона Свердловской обл." + assert label.strip().lower().endswith("центр"), f"{slug}: подпись '{label}' без 'центр'" + + +def test_city_locations_wave2_count_and_content() -> None: + """CITY_LOCATIONS покрывает ровно те же 45 slug'ов, что CITY_ANCHORS (иначе + get_city_location(slug) вернёт None для известного anchor'ами города и сборка + молча уйдёт на ЕКБ region_id/rgid).""" + assert set(CITY_LOCATIONS) == set(CITY_ANCHORS) + + +def test_city_locations_cian_region_id_never_none() -> None: + """cian_region_id подтверждён у ВСЕХ 45 городов (wave 1 + wave 2) — единственное + поле CityLocation, у которого нет Optional-пути.""" + for slug, loc in CITY_LOCATIONS.items(): + assert loc.cian_region_id is not None, f"{slug}: cian_region_id не должен быть None" + + +# ── window scheduling ─────────────────────────────────────────────────────── + +_EKB_OCCUPIED = {"avito": {6}, "cian": {2, 3, 4}, "yandex": {16}} +_WAVE1_OCCUPIED = { + "avito": {0, 1, 5, 7, 8}, + "cian": {9, 10, 11, 12, 13}, + "yandex": {14, 15, 17, 18, 19}, +} +# Математический минимум коллизий одного источника в одном часе (round-robin по +# 23/40/39 городам на 18/16/18 свободных часов соответственно). +_MAX_COLLISIONS = {"avito": 2, "cian": 3, "yandex": 3} + + +def _row_windows(path: Path) -> list[tuple[str, str, int, int]]: + """(provider, slug, window_start_hour, window_end_hour) для каждой VALUES-строки.""" + executable = _executable_sql(path) + pattern = re.compile( + r"'(avito|cian|yandex)_city_sweep_([a-z_]+)',\s*\n\s*(?:true|false),\s*\n\s*(\d+),\s*\n\s*(\d+),", + ) + return [(p, s, int(a), int(b)) for p, s, a, b in pattern.findall(executable)] + + +def test_migration_262_windows_are_one_hour() -> None: + """Окно ровно на час. Час 23 заворачивается в 0 (cross-midnight) — scheduler + это поддерживает явно (`window_end_hour <= window_start_hour` → окно через + полночь, см. scraper_kit.orchestration.scheduler).""" + for provider, slug, start, end in _row_windows(_MIGRATION_262): + expected = (start + 1) % 24 + assert ( + end == expected + ), f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})" + + +def test_migration_262_window_hours_satisfy_db_check_constraint() -> None: + """Оба часа обязаны попадать в 0..23 — иначе миграция не применится вовсе. + + Регресс: первая версия давала start=23, end=24 у пяти строк. Проверка + «end == start + 1» это пропускала, а прод-констрейнт `window_end_range` + (052_scrape_schedules.sql: CHECK (window_end_hour BETWEEN 0 AND 23)) — + нет, и весь INSERT падал в CI на реальном postgres. + """ + for provider, slug, start, end in _row_windows(_MIGRATION_262): + assert 0 <= start <= 23, f"{provider}_{slug}: window_start_hour={start} вне 0..23" + assert 0 <= end <= 23, f"{provider}_{slug}: window_end_hour={end} вне 0..23" + + +def test_migration_262_windows_avoid_ekb_and_wave1_occupied_hours() -> None: + """Ни одна wave-2 строка не наезжает на занятые ЕКБ-окна (avito 6-7, cian 2-5, + yandex 16-17) или на окна wave-1 (179_) — тот же provider.""" + for provider, slug, start, _end in _row_windows(_MIGRATION_262): + occupied = _EKB_OCCUPIED[provider] | _WAVE1_OCCUPIED[provider] + assert start not in occupied, ( + f"{provider}_city_sweep_{slug}: окно start={start} пересекает занятый " + f"ЕКБ/wave-1 час ({sorted(occupied)})" + ) + + +def test_migration_262_same_provider_collisions_within_math_minimum() -> None: + """Коллизии ОДНОГО источника в одном окне не превышают математический минимум + (round-robin по подтверждённым городам на свободные часы), и распределены + равномерно (не сконцентрированы в 1-2 часах).""" + from collections import Counter + + rows = _row_windows(_MIGRATION_262) + for provider in _PROVIDERS: + hours = [start for p, _slug, start, _end in rows if p == provider] + counts = Counter(hours) + assert max(counts.values()) <= _MAX_COLLISIONS[provider], ( + f"{provider}: час {counts.most_common(1)} превышает математический минимум " + f"коллизий ({_MAX_COLLISIONS[provider]})" + ) + + +def test_migration_262_cross_provider_overlap_allowed() -> None: + """Разные провайдеры МОГУТ делить окно в рамках этой миграции (не ограничивается) — + документируем это явно, чтобы будущий рефактор не поломал предположение по ошибке.""" + rows = _row_windows(_MIGRATION_262) + avito_hours = {start for p, _s, start, _e in rows if p == "avito"} + cian_hours = {start for p, _s, start, _e in rows if p == "cian"} + assert avito_hours and cian_hours diff --git a/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py new file mode 100644 index 00000000..434d419f --- /dev/null +++ b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py @@ -0,0 +1,103 @@ +"""Payments retention (PR #2754) — "12 месяцев" text sync guard. + +WHY: + mera-public/content.ts declares itself as the ONE place product promises + live (docstring at the top of that file: "ни одного утверждения, которого + не делает код"). The public retention promise ("оплаченный отчёт хранится + N месяцев") has THREE places it could quietly drift: the backend setting + (`settings.trade_in_paid_retention_days`), the frontend constant + (`PAID_REPORT_RETENTION_MONTHS` in content.ts), and any page that renders + it (today: privacy/page.tsx). Deep-review finding 2026-08-06 MEDIUM on + PR #2754 caught exactly this: a comment claimed the number "reads from the + same setting" while the page actually hardcoded a `12 месяцев` literal -- + a comment cannot fail CI, same lesson as + test_consent_text_frontend_sync.py's _CONSENT_TEXT_SNAPSHOT guard (which + this file mirrors). + +WHAT: + 1. privacy/page.tsx imports PAID_REPORT_RETENTION_MONTHS from content.ts + and does NOT hardcode a "N месяцев" literal of its own. + 2. The frontend months constant and the backend days setting stay within + a sane calendar tolerance of each other (28-31 days per month) -- this + does NOT enforce byte-identity (days and months are different units by + design, see content.ts docstring), only that nobody silently changes + one without the other drifting out of "still honestly ~12 months". +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_FRONTEND_CONTENT = ( + Path(__file__).resolve().parents[2] / "frontend" / "src" / "app" / "mera-public" / "content.ts" +) +_FRONTEND_PRIVACY_PAGE = ( + Path(__file__).resolve().parents[2] + / "frontend" + / "src" + / "app" + / "mera-public" + / "privacy" + / "page.tsx" +) + +_MONTHS_CONST_RE = re.compile(r"PAID_REPORT_RETENTION_MONTHS\s*=\s*(\d+)\s*;") +_LITERAL_MONTHS_RE = re.compile(r'"?\d+ месяцев"?') + + +def _extract_months_constant(content_ts_source: str) -> int: + match = _MONTHS_CONST_RE.search(content_ts_source) + assert match is not None, ( + "PAID_REPORT_RETENTION_MONTHS not found in content.ts -- " + "constant renamed/removed without updating this test" + ) + return int(match.group(1)) + + +def test_frontend_files_exist() -> None: + assert _FRONTEND_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_CONTENT}" + assert _FRONTEND_PRIVACY_PAGE.is_file(), f"missing frontend file: {_FRONTEND_PRIVACY_PAGE}" + + +def test_privacy_page_imports_retention_constant_not_hardcoded() -> None: + """The whole point: FAILS if privacy/page.tsx stops importing the shared + constant and goes back to a hardcoded '12 месяцев' literal (exactly the + drift the deep-review finding caught -- comment said 'reads from content.ts', + code said otherwise).""" + src = _FRONTEND_PRIVACY_PAGE.read_text(encoding="utf-8") + assert "PAID_REPORT_RETENTION_MONTHS" in src, ( + "privacy/page.tsx no longer references PAID_REPORT_RETENTION_MONTHS -- " + "the retention promise must be rendered from the shared content.ts " + "constant, not written out again by hand" + ) + assert re.search(r'from\s+"\.\./content"', src), ( + "privacy/page.tsx must import from '../content' (content.ts), where " + "PAID_REPORT_RETENTION_MONTHS is declared" + ) + literal_hits = _LITERAL_MONTHS_RE.findall(src) + assert not literal_hits, ( + "privacy/page.tsx contains a hardcoded 'N месяцев' literal -- render the " + "PAID_REPORT_RETENTION_MONTHS constant instead: " + f"{literal_hits!r}" + ) + + +def test_backend_days_setting_matches_frontend_months_within_calendar_tolerance() -> None: + """Not byte-identity (days vs months are different units, deliberately -- + see content.ts docstring on PAID_REPORT_RETENTION_MONTHS): just a sanity + bound that `trade_in_paid_retention_days` still honestly rounds to the + number of months the public page promises (28-31 days/month, generous).""" + from app.core.config import settings + + days = settings.trade_in_paid_retention_days + months = _extract_months_constant(_FRONTEND_CONTENT.read_text(encoding="utf-8")) + + assert 28 * months <= days <= 31 * months, ( + f"settings.trade_in_paid_retention_days={days} no longer honestly rounds to " + f"content.ts PAID_REPORT_RETENTION_MONTHS={months} -- update both together " + "(and the offer text, when it exists) so the public promise stays true" + ) diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index a97b017f..7d6d21ed 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -423,6 +423,86 @@ def test_build_listings_page_none_year_built_no_crash() -> None: assert "РЫНОК КВАРТИР" in html +# ── #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10) ──────── +# n_analogs==0 (headline ceded to the ДКП deals corridor, estimator.py +# `deals_headline_due_to_thin_listings`) with a non-empty `analogs` display list +# (thin listings kept as reference cards) used to print "0 шт." above a +# non-empty examples table — a client-visible contradiction that leaked into +# the PDF handed to clients. See _build_listings_page / _deals_sourced_thin_ +# listings_note_html / _reliability_note_html. + + +def test_listings_page_zero_analogs_shown_cards_no_false_zero_count() -> None: + """The exact bug: n_analogs=0 + 3 shown analogs must NOT print '0 шт.' — + falls back to the actually-shown population (3) and adds an honest + deals-sourced footnote.""" + analogs = [ + _analog(address="ул. Льва Толстого, 8А"), + _analog(address="ул. Кирова, 4"), + _analog(address="ул. Льва Толстого, 34"), + ] + est = _estimate(n_analogs=0, analogs=analogs, sources_used=["avito"]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "0 шт." not in html + assert "3 шт." in html + assert "Оценка построена по зарегистрированным сделкам Росреестра" in html + assert "почти нет" in html + + +def test_listings_page_zero_analogs_empty_cards_stays_honest_zero() -> None: + """Control: genuinely zero listings (no cards to show either) — '0 шт.' is + honest here, and the deals-sourced footnote (which explains a MISMATCH) + must NOT appear since there is nothing to reconcile.""" + est = _estimate(n_analogs=0, analogs=[]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "0 шт." in html + assert "Оценка построена по зарегистрированным сделкам Росреестра" not in html + + +def test_listings_page_healthy_sample_keeps_full_n_analogs_not_capped_len() -> None: + """Control/regression guard for the max() choice: a healthy sample where + n_analogs (15) EXCEEDS the capped display list (10, AggregatedEstimate's + own top-10 cap) must keep printing the full honest count (15 шт.), NOT + silently understate it to len(analogs) (10 шт.).""" + analogs = [_analog(address=f"ул. Тест, {i}") for i in range(10)] + est = _estimate(n_analogs=15, analogs=analogs, sources_used=["avito"]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "15 шт." in html + assert "10 шт." not in html + + +def test_listings_page_relaxations_warning_shown_with_labels() -> None: + """relaxations non-empty → warning block present, names the labels, and + reliability != 'ok' — mirrors what the web LowConfidenceBanner already + shows (see AggregatedEstimate docstring).""" + est = _estimate(relaxations=["учтены студии", "радиус расширен до 3000 м"], reliability="low") + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." in html + assert "учтены студии" in html + assert "радиус расширен до 3000 м" in html + + +def test_listings_page_reliability_downgraded_no_relaxations_fallback_text() -> None: + """reliability != 'ok' but relaxations is empty (cascade couldn't grow a + thin sample, estimator.py #oblast-F) → warning block still shown, with a + fallback sentence (not an empty label list).""" + est = _estimate(n_analogs=2, reliability="very_low", relaxations=[]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." in html + assert "небольшой выборке" in html + + +def test_listings_page_no_warning_block_when_ok_and_no_relaxations() -> None: + """Control: the common/unrelaxed case (reliability='ok' default, no + relaxations) — no warning block at all, byte-identical to the report + before these fields existed.""" + est = _estimate() + assert est.reliability == "ok" + assert est.relaxations == [] + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." not in html + + def test_build_deals_page_none_year_built_no_crash() -> None: snap = dict(_SNAPSHOT) snap["year_built"] = None @@ -445,3 +525,36 @@ def test_brand_not_taken_from_query_param_docstring() -> None: assert ( "brand" not in param_names ), "estimate_pdf should NOT have a 'brand' query param after #7 fix" + + +# ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ── + + +def test_cover_no_retain_until_row_when_unpaid() -> None: + """retain_until IS NULL (default, all current traffic) → no 'Ссылка доступна + до' row at all — B2B regression guard, cover renders bit-for-bit as before.""" + est = _estimate() + assert est.retain_until is None + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" not in html + + +def test_cover_renders_retain_until_row_when_paid() -> None: + """retain_until IS NOT NULL → 'Ссылка доступна до ' row present, with + its OWN date (not conflated with 'Срок действия данных' / expires_at).""" + retain = datetime(2027, 8, 6, tzinfo=UTC) + est = _estimate(retain_until=retain) + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" in html + assert "06.08.2027" in html + + +def test_expires_date_unaffected_by_retain_until() -> None: + """«ДЕЙСТВИТЕЛЕН ДО» (running footer, _expires_date) stays wired to + expires_at regardless of retain_until — it is data-actuality, not the + paid-access retention window, and must not move when a report is paid.""" + expires = datetime.now(UTC) + timedelta(hours=24) + est_unpaid = _estimate(expires_at=expires) + est_paid = _estimate(expires_at=expires, retain_until=expires + timedelta(days=365)) + assert mod._expires_date(est_unpaid) == expires.date() + assert mod._expires_date(est_paid) == expires.date() diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index 07a85aa9..a49f1a96 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -7,9 +7,22 @@ Covers app/tasks/purge_expired_trade_in_data.py: - both tables (trade_in_estimates, trade_in_leads) get drained - failure path: rollback + mark_failed with partial counters, exception re-raised - SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap + - Payments retention (PR #2754): retain_until IS NULL + NOT EXISTS(payments) + safeguards on the estimates DELETE, plus a pre-flight that refuses to run + at all if it finds an ANOMALOUS paid purge-candidate (retain_until unset + despite a payments row) -- a healthy paid row (retain_until set) must NOT + trip it, see test_preflight_ignores_healthy_paid_row below. Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched runs_mod.mark_done/mark_failed). + +Payments retention note on _FakeDB: purge_expired_trade_in_data now issues ONE +extra db.execute() call BEFORE any DELETE batch — the pre-flight paid- +candidates count (_PREFLIGHT_PAID_CANDIDATES_SQL). _FakeDB special-cases that +statement by identity and answers it from `preflight_count` (default 0 == +"no anomalous candidates, proceed exactly as before this PR"). Every +pre-existing test's `db.executed` index shifted by +1 to account for this; +`db.commits` is unaffected (the pre-flight is a read, never committed). """ from __future__ import annotations @@ -34,18 +47,30 @@ class _FakeResult: def __init__(self, rowcount: int) -> None: self.rowcount = rowcount + def scalar_one(self) -> int: + """Supports the PR-D1 pre-flight `SELECT count(*) ... .scalar_one()` call.""" + return self.rowcount + class _FakeDB: - """Pops rowcounts in call order -- caller supplies the exact sequence expected.""" + """Pops rowcounts in call order -- caller supplies the exact sequence expected. - def __init__(self, rowcounts: list[int]) -> None: + PR-D1: the pre-flight paid-candidates count is answered separately, from + `preflight_count` (default 0), keyed by statement IDENTITY -- it never + consumes an entry off `rowcounts` (that list is DELETE-batch rowcounts only). + """ + + def __init__(self, rowcounts: list[int], *, preflight_count: int = 0) -> None: self._rowcounts = list(rowcounts) + self.preflight_count = preflight_count self.executed: list[tuple[Any, Any]] = [] self.commits = 0 self.rolled_back = False def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: self.executed.append((stmt, params)) + if stmt is task_mod._PREFLIGHT_PAID_CANDIDATES_SQL: + return _FakeResult(self.preflight_count) return _FakeResult(self._rowcounts.pop(0)) def commit(self) -> None: @@ -83,7 +108,9 @@ def test_stops_when_batch_below_size(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([3, 0]) out = task_mod.purge_expired_trade_in_data(db, run_id=1, batch_size=10, max_batches=20) # type: ignore[arg-type] assert out == {"estimates_deleted": 3, "leads_deleted": 0} - assert len(db.executed) == 2 + # +1 vs pre-PR-D1: db.executed now also holds the pre-flight paid-candidates + # count (call #1), issued before either DELETE batch. + assert len(db.executed) == 3 assert db.commits == 2 assert marked["counters"] == out @@ -94,7 +121,7 @@ def test_loops_until_below_batch_size(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([5, 5, 2, 5, 1]) out = task_mod.purge_expired_trade_in_data(db, run_id=2, batch_size=5, max_batches=20) # type: ignore[arg-type] assert out == {"estimates_deleted": 12, "leads_deleted": 6} - assert len(db.executed) == 5 + assert len(db.executed) == 6 # +1: pre-flight call before the 5 DELETE batches assert db.commits == 5, "each batch must commit independently, not one final commit" @@ -106,14 +133,16 @@ def test_respects_max_batches_cap(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([5, 5, 5, 5, 5, 5]) # exactly max_batches=3 per table, no more out = task_mod.purge_expired_trade_in_data(db, run_id=3, batch_size=5, max_batches=3) # type: ignore[arg-type] assert out == {"estimates_deleted": 15, "leads_deleted": 15} - assert len(db.executed) == 6 # 3 (estimates) + 3 (leads), NOT unbounded + assert len(db.executed) == 7 # pre-flight + 3 (estimates) + 3 (leads), NOT unbounded def test_default_batch_size_and_max_batches_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: _patch_runs(monkeypatch) db = _FakeDB([0, 0]) # first batch already empty on both tables -> stop immediately task_mod.purge_expired_trade_in_data(db, run_id=4) # type: ignore[arg-type] - _stmt, params = db.executed[0] + # db.executed[0] is now the pre-flight call (no batch_size param) -- the + # first DELETE-batch call (with batch_size) shifted to index 1. + _stmt, params = db.executed[1] assert params is not None assert params["batch_size"] == task_mod.settings.trade_in_purge_batch_size @@ -125,8 +154,9 @@ def test_drains_both_tables_in_order(monkeypatch: pytest.MonkeyPatch) -> None: _patch_runs(monkeypatch) db = _FakeDB([0, 0]) task_mod.purge_expired_trade_in_data(db, run_id=5, batch_size=100, max_batches=1) # type: ignore[arg-type] - first_sql = str(getattr(db.executed[0][0], "text", db.executed[0][0])) - second_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + # index 0 is now the pre-flight call; DELETE batches shifted to 1/2. + first_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + second_sql = str(getattr(db.executed[2][0], "text", db.executed[2][0])) assert "trade_in_estimates" in first_sql assert "trade_in_leads" in second_sql @@ -141,6 +171,86 @@ def test_estimates_sql_is_delete_not_update() -> None: assert not re.search(r":\w+::", sql) +# ── Payments retention (PR #2754): two independent purge safeguards ───────── + + +def test_estimates_sql_excludes_retain_until_not_null() -> None: + """Phase 1: exactly `retain_until IS NULL`, never `< NOW()` -- a paid row + (retain_until IS NOT NULL) must never match the DELETE predicate, full stop, + regardless of how far in the past that date eventually sits.""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "retain_until IS NULL" in sql + assert "retain_until <" not in sql, "phase 1 must not weaken to retain_until < NOW()" + + +def test_estimates_sql_has_not_exists_payments_safeguard() -> None: + """Independent safeguard: a row with ANY payments row survives even if + retain_until failed to be set (fulfillment bug/race/manual INSERT).""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "NOT EXISTS" in sql + assert "FROM payments p" in sql + assert "p.estimate_id = trade_in_estimates.id" in sql + + +def test_preflight_sql_requires_retain_until_is_null() -> None: + """Deep-review finding 2026-08-06 MEDIUM (PR #2754): the pre-flight predicate + MUST carry `retain_until IS NULL` -- without it, a perfectly healthy paid row + (retain_until set, has a payments row -- the ORDINARY steady state one day + after every sale) trips the alarm exactly as hard as the real anomaly + (retain_until unset despite a payments row existing), permanently wedging + the job on the very first successful sale (and, since leads purge runs from + the same function AFTER this check, silently stopping 180-day 152-ФЗ lead + retention too). See test_real_preflight_ignores_healthy_paid_row below for + the behavioural proof against a real DB.""" + sql = task_mod._PREFLIGHT_PAID_CANDIDATES_SQL.text + assert "expires_at < NOW()" in sql + assert "created_by IS NULL" in sql + assert "retain_until IS NULL" in sql + assert "EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)" in sql + assert not re.search(r":\w+::", sql) + + +def test_preflight_aborts_before_any_delete_batch(monkeypatch: pytest.MonkeyPatch) -> None: + """Non-zero pre-flight count -> RuntimeError, mark_failed, ZERO DELETE batches + ever issued (only the pre-flight SELECT itself is in db.executed).""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([], preflight_count=2) # rowcounts empty on purpose: must never be popped + with pytest.raises(RuntimeError, match="pre-flight abort"): + task_mod.purge_expired_trade_in_data(db, run_id=42, batch_size=10, max_batches=20) # type: ignore[arg-type] + + assert len(db.executed) == 1, "only the pre-flight SELECT -- no DELETE batch was issued" + assert db.commits == 0 + assert marked["kind"] == "failed" + assert marked["run_id"] == 42 + assert marked["counters"] == {"estimates_deleted": 0, "leads_deleted": 0} + assert "2" in marked["err"] + + +def test_preflight_zero_candidates_proceeds_as_before(monkeypatch: pytest.MonkeyPatch) -> None: + """preflight_count=0 (default) -- the exact pre-PR-D1 behaviour for every + row that exists today (all retain_until IS NULL) -- run proceeds normally.""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) # preflight_count defaults to 0 + out = task_mod.purge_expired_trade_in_data(db, run_id=43, batch_size=10, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 0, "leads_deleted": 0} + assert marked["kind"] == "done" + + +def test_leads_sql_unchanged_by_pr_d1() -> None: + """Snapshot: _DELETE_EXPIRED_LEADS_SQL byte-for-byte unchanged by payments + retention (PR #2754) — leads have their own retention deadline (migration + 231, no created_by/B2B split, no payments concept) and are explicitly out + of scope for the payments-retention safeguards.""" + expected = ( + "\n DELETE FROM trade_in_leads\n WHERE id IN (\n" + " SELECT id FROM trade_in_leads\n" + " WHERE expires_at < NOW()\n" + " ORDER BY expires_at\n" + " LIMIT CAST(:batch_size AS int)\n )\n " + ) + assert task_mod._DELETE_EXPIRED_LEADS_SQL.text == expected + + def test_leads_sql_is_delete_not_update() -> None: sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text assert "DELETE FROM trade_in_leads" in sql @@ -199,13 +309,14 @@ def test_failure_path_rollback_and_mark_failed(monkeypatch: pytest.MonkeyPatch) class _BoomDB(_FakeDB): def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: - # First batch (estimates) succeeds and commits; second call (still - # draining estimates, or first leads call) explodes. - if len(self.executed) >= 1: + # Call #1 (pre-flight, preflight_count=0) and call #2 (first estimates + # batch) succeed and commit; call #3 (still draining estimates, or + # first leads call) explodes. +1 vs pre-PR-D1 to admit the pre-flight. + if len(self.executed) >= 2: raise RuntimeError("db exploded") return super().execute(stmt, params) - db = _BoomDB([5]) # only ONE successful batch before the boom + db = _BoomDB([5]) # only ONE successful DELETE batch before the boom with pytest.raises(RuntimeError, match="db exploded"): task_mod.purge_expired_trade_in_data(db, run_id=6, batch_size=5, max_batches=20) # type: ignore[arg-type] @@ -339,3 +450,160 @@ def test_real_purge_deletes_only_anonymous_expired_estimates() -> None: ) db.commit() db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754, reproduced exactly + against a real DB: a HEALTHY paid row (retain_until set, a payments row + exists) is the ordinary steady state one day after every sale and must NOT + raise the pre-flight count; a row where fulfillment failed to set + retain_until despite a payments row existing is the real ANOMALY and must. + Baseline-delta assertions (not absolute counts) so this is safe to run + against a dev DB that may already contain unrelated rows.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + anomaly_id = uuid4() + healthy_order = f"pytest-healthy-{uuid4().hex[:12]}" + anomaly_order = f"pytest-anomaly-{uuid4().hex[:12]}" + try: + baseline = task_mod._preflight_paid_candidates(db) + + # Healthy: retain_until set (paid, safeguard 1 already protects it) + + # a payments row -- exactly what every successful sale looks like a day + # later. Must NOT move the pre-flight count. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test здоровая оплаченная', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + assert ( + task_mod._preflight_paid_candidates(db) == baseline + ), "healthy paid row (retain_until set) must NOT raise the pre-flight count" + + # Anomaly: retain_until NULL despite a payments row existing -- exactly + # the case the two DELETE safeguards exist for. Must raise by exactly one. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test настоящая аномалия', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NULL)" + ), + {"id": str(anomaly_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": anomaly_order, "id": str(anomaly_id)}, + ) + db.commit() + assert task_mod._preflight_paid_candidates(db) == baseline + 1, ( + "anomaly row (retain_until unset, payments row exists) must raise " + "the pre-flight count by exactly one" + ) + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = ANY(CAST(:orders AS text[]))"), + {"orders": [healthy_order, anomaly_order]}, + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(healthy_id), str(anomaly_id)]}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_purge_not_wedged_by_healthy_paid_row() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754: before the fix, a + healthy paid row anywhere in the table (retain_until set, has a payments + row) permanently wedged the job -- the very first successful sale would + have made every subsequent scheduled run abort in mark_failed with zero + deletions FOREVER, silently taking 180-day leads purge (152-ФЗ) down with + it (leads purge runs from the same function AFTER the pre-flight check). + This proves a real end-to-end run completes normally (mark_done) in the + presence of such a row.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + healthy_order = f"pytest-wedge-{uuid4().hex[:12]}" + try: + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test не блокирует джобу', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + + # Must complete normally -- no RuntimeError, no mark_failed short-circuit + # (would raise before reaching this line if the bug were still present). + result = task_mod.purge_expired_trade_in_data( + db, run_id=999999998, batch_size=100, max_batches=1 + ) + assert set(result) == {"estimates_deleted", "leads_deleted"}, ( + "leads purge must also have run -- it is NOT reachable when the " + "pre-flight wrongly aborts first" + ) + + still_there = db.execute( + _t("SELECT id FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ).fetchone() + assert still_there is not None, "healthy paid row must survive the run untouched" + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = :order_id"), {"order_id": healthy_order} + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ) + db.commit() + db.close() diff --git a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py index 726d991e..fafe6f41 100644 --- a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py +++ b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py @@ -1,13 +1,20 @@ -"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1. +"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846). Покрывает: - 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): - - fresh: age <= max_age_days (алерта нет); - - stale: age > max_age_days (алерт); - - граница порога (== max_age_days → нет алерта; +1 день → алерт). - 2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag). - 3. Свойства миграции 180 (по образцу test_deals_freshness_monitor). - 4. Регистрацию в kit product_handlers (registry остаётся зелёным). + 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте / + загрузка встала / полных прогонов не было вовсе / граница порога. + 2. check_sber_freshness с FakeDB — прод-реплей 2026-08-12 и двусторонность: + при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой. + 3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы + отставшее табло). + 4. Свойства миграций 180/212 + регистрацию в kit product_handlers. + +Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index): + latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток), + dinamika-tsen-obyavlenii = 2026-05-01 (103); + последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639); + такт загрузки interval_days = 7; + прогон id=37 (05-31) — status='done' при {errors: 9, upserted: 0}, за успех НЕ считается. """ from __future__ import annotations @@ -22,7 +29,6 @@ import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.core.config import settings from app.services.product_handlers import build_product_handlers from app.tasks import sber_freshness_monitor as mon @@ -30,69 +36,152 @@ _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" _MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql" _MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql" -# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12). -_MAY_2026 = date(2026, 5, 1) - - -# ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── +# Прод-состояние 2026-08-12. +_JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток +_MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103 +_LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639 +_PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days +_PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера def _now(y: int, m: int, d: int) -> datetime: return datetime(y, m, d, tzinfo=UTC) -def test_fresh_within_max_age() -> None: - """age=30 ≤ max_age_days=60 — свежий, алерта нет.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60) - assert v.stale is False - assert v.age_days == 30 - assert v.latest_period == _MAY_2026 +# ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── -def test_stale_beyond_max_age() -> None: - """Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60) - assert v.stale is True +def test_loader_in_cadence_no_alert_even_at_age_72() -> None: + """Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим. + + После полного прогона наш max(period_month) равен максимуму источника ПО + ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток — лаг ПУБЛИКАЦИИ Сбера, + а не наше отставание. + """ + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) assert v.age_days == 72 + assert v.pull_lag_days == 6 + assert v.stale is False + + +def test_loader_stalled_alerts_at_the_same_age() -> None: + """Тот же возраст периода, но полный прогон 20 суток назад → тревога. + + 20 суток — реальный разрыв прод-истории (07-17 → 08-06) при пороге 2×7=14. + """ + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_now(2026, 7, 23), + pull_interval_days=_PULL_INTERVAL, + ) + assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше + assert v.pull_lag_days == 20 + assert v.max_pull_lag_days == 14 + assert v.stale is True + + +def test_no_complete_pull_ever_alerts() -> None: + """Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=None, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.stale is True + assert v.pull_lag_days == -1 def test_threshold_boundary_exact_no_alert() -> None: - """Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >).""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60) - assert v.age_days == 60 + """Ровно на пороге (2 такта) алерта ещё нет — строгое >.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL), + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.pull_lag_days == 14 assert v.stale is False -def test_threshold_boundary_next_day_alert() -> None: - """Порог + 1 день (age=61) — первый алерт.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60) - assert v.age_days == 61 +def test_threshold_boundary_next_day_alerts() -> None: + """Порог + 1 сутки — первый алерт (два такта подряд пропущены).""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1), + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.pull_lag_days == 15 assert v.stale is True +def test_threshold_follows_pull_cadence() -> None: + """Порог — производная такта загрузки, а не константа: такт 28 → порог 56.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_now(2026, 7, 23), # 20 суток + pull_interval_days=28, + ) + assert v.max_pull_lag_days == 56 + assert v.stale is False # при месячном такте 20 суток — норма + + # ── check_sber_freshness (FakeDB) ───────────────────────────────────────────── class _Row: - def __init__(self, latest: date | None) -> None: - self.latest = latest + def __init__(self, **kw: Any) -> None: + self.__dict__.update(kw) class _FakeResult: - def __init__(self, latest: date | None) -> None: - self._latest = latest + def __init__(self, row: _Row | None) -> None: + self._row = row - def first(self) -> _Row: - return _Row(self._latest) + def first(self) -> _Row | None: + return self._row class _FakeDB: - def __init__(self, latest: date | None) -> None: - self._latest = latest + """Отвечает на три запроса монитора; пригоден и для старой версии кода. + + Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра — на такой + запрос отдаём максимум по всем табло, ровно как это делал бы Postgres. + """ + + def __init__( + self, + latest_by_dash: dict[str, date], + last_pull: datetime | None = _LAST_FULL_PULL, + interval_days: str | None = str(_PULL_INTERVAL), + ) -> None: + self._latest_by_dash = latest_by_dash + self._last_pull = last_pull + self._interval_days = interval_days self.rolled_back = False + self.asked_dashboards: list[str] = [] def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: - return _FakeResult(self._latest) + sql = str(stmt) + params = params or {} + if "scrape_runs" in sql: + return _FakeResult(_Row(last_pull=self._last_pull)) + if "scrape_schedules" in sql: + return _FakeResult(_Row(interval_days=self._interval_days)) + if "dash" in params: + self.asked_dashboards.append(params["dash"]) + return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"]))) + # Старый монитор: max(period_month) по всей таблице. + latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None + return _FakeResult(_Row(latest=latest)) def rollback(self) -> None: self.rolled_back = True @@ -118,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: return calls -def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # @2026-05-31: age=30 ≤ 35+25=60 → нет алерта. - out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type] - assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0} - assert calls["done"] == out - assert calls["failed"] is None +def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB: + """Прод-состояние 2026-08-12 (оба табло вторички).""" + return _FakeDB( + {"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026}, + last_pull=last_pull, + ) -def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None: +def test_prod_replay_healthy_loader_silent_source_no_alert( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина. + + На main монитор мерил календарь (72 > 60) и писал ERROR — двенадцатые сутки + подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации + Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же. + """ calls = _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # @2026-07-12 (прод): age=72 > 60 → алерт. - out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type] - assert out["alert"] == 1 + db = _prod_db() + out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["age_days"] == 72 - assert out["latest_month"] == 5 - # Монитор НЕ падает при алерте — прогон done, а не failed. + assert out["alert"] == 0 assert calls["done"] == out assert calls["failed"] is None -def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: +def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None: + """Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой. + + На main оба состояния дают alert=1 (вердикт зависит только от календаря) — + сторож не умеет зеленеть, что и было исходным дефектом. + """ + _patch_runs(monkeypatch) + healthy = mon.check_sber_freshness( + _prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type] + run_id=2, + params={}, + now=_PROD_NOW, + ) + stalled = mon.check_sber_freshness( + _prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type] + run_id=3, + params={}, + now=_PROD_NOW, + ) + assert healthy["age_days"] == stalled["age_days"] == 72 + assert (healthy["alert"], stalled["alert"]) == (0, 1) + + +def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None: + """Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит. + + Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт — + SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь + ровно то состояние, что дал бы прод с одним лишь id=37. + """ + _patch_runs(monkeypatch) + out = mon.check_sber_freshness( + _prod_db(last_pull=None), # type: ignore[arg-type] + run_id=4, + params={}, + now=_PROD_NOW, + ) + assert out["alert"] == 1 + assert out["pull_lag_days"] == -1 + + +def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None: + """Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице).""" + _patch_runs(monkeypatch) + db = _prod_db() + out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert db.asked_dashboards[0] == "real_estate_deals" + assert (out["latest_year"], out["latest_month"]) == (2026, 6) + + +def test_falls_back_to_next_dashboard_when_first_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Первое табло пусто → берём следующее, как и оценщик.""" + _patch_runs(monkeypatch) + db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026}) + out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["latest_month"] == 5 + assert out["age_days"] == 103 + + +def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одного табло с данными — оценивать нечего, это сбой монитора.""" calls = _patch_runs(monkeypatch) - db = _FakeDB(None) - out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type] + out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["alert"] == 0 assert calls["done"] is None assert calls["failed"] is not None -def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None: +def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + """Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь. + + Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180). + С любым её значением вердикт один и тот же — порог берётся из такта загрузки. + """ _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено. - out = mon.check_sber_freshness( - db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12) - ) # type: ignore[arg-type] - assert out["alert"] == 1 + base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type] + tweaked = mon.check_sber_freshness( + _prod_db(), # type: ignore[arg-type] + run_id=9, + params={"lag_allowance_days": 0}, + now=_PROD_NOW, + ) + assert base["alert"] == tweaked["alert"] == 0 -def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None: - """Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS.""" +def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None: + """Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет.""" _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS - # Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт. - exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold) - out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type] - assert out_exact["age_days"] == threshold - assert out_exact["alert"] == 0 - out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type] - assert out_over["alert"] == 1 + db = _FakeDB( + {"real_estate_deals": _JUN_2026}, + last_pull=_now(2026, 7, 23), # 20 суток + interval_days="28", + ) + out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["max_pull_lag_days"] == 56 + assert out["alert"] == 0 + + +def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем.""" + _patch_runs(monkeypatch) + db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None) + out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS # ── Миграция 180 ────────────────────────────────────────────────────────────── @@ -208,22 +375,18 @@ def test_migration_180_window_9_to_10_utc() -> None: assert re.search(r"\b10\b", sql), "window_end_hour 10 missing" -def test_migration_180_lag_allowance_25() -> None: - sql = _MIGRATION_180.read_text("utf-8") - assert "lag_allowance_days" in sql - assert "25" in sql - - def test_migration_180_no_psycopg_trap() -> None: sql = _MIGRATION_180.read_text("utf-8") assert not re.search(r":\w+::", sql) -# ── Миграция 212: такт загрузки не должен пересекать порог монитора ─────────── +# ── Миграция 212: такт загрузки = источник порога ───────────────────────────── # -# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74 -# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника. -# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора. +# Прежний инвариант («пол возраста + такт < календарного порога монитора») снят +# вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при +# полном прогоне шестидневной давности, потолок 53 держался бы только если бы +# источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода +# не должен расходиться с тактом, который сеет миграция. def test_migration_212_makes_pull_cadence_weekly() -> None: @@ -234,23 +397,22 @@ def test_migration_212_makes_pull_cadence_weekly() -> None: assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type) -def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None: - """Инвариант: пол возраста + такт загрузки < порога монитора. - - Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог = - sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 — запас - 7 суток. При прежних 28: 46+28=74 > 60 — тревога каждый цикл, что и наблюдали. - """ +def test_default_pull_interval_matches_migration_212() -> None: + """Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой.""" interval_days = int( - re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) - ) - observed_floor_days = 46 - threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS - assert observed_floor_days + interval_days < threshold, ( - f"такт {interval_days}д даёт потолок возраста " - f"{observed_floor_days + interval_days}д при пороге {threshold}д — " - "монитор снова будет мерить наш такт, а не застой источника" + re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr] ) + assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days + + +# ── Один порог, а не два ────────────────────────────────────────────────────── + + +def test_no_second_calendar_threshold_in_settings() -> None: + """#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться.""" + from app.core.config import settings + + assert not hasattr(settings, "sber_index_max_age_days") # ── Регистрация в kit registry ───────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index ee2fface..813d78a2 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -156,7 +156,8 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None: with ( patch.object(admin_module, "_probe_browser_health", fake_browser_health), patch.object(admin_module, "_probe_current_ip", fake_current_ip), - patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]), + # #2830: узел резолвится из пула по (db, source), а не из статичного env + patch.object(admin_module, "_provider_proxy_url", lambda _db, s: proxy_urls[s]), patch.object(admin_module.settings, "scraper_fetch_mode", "browser"), ): r = client.get("/api/v1/admin/scraper/health") @@ -207,7 +208,7 @@ def test_health_browser_unreachable(client: TestClient) -> None: with ( patch.object(admin_module.httpx, "AsyncClient", _BoomClient), patch.object(admin_module, "_probe_current_ip", fake_current_ip), - patch.object(admin_module, "_provider_proxy_url", lambda s: None), + patch.object(admin_module, "_provider_proxy_url", lambda _db, _s: None), ): r = client.get("/api/v1/admin/scraper/health") diff --git a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py index 7084968b..43cbe845 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py @@ -236,9 +236,14 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi captured: dict[str, Any] = {} - async def _fake_fetch_newbuilding(zhk_url: str, *, config: Any = None) -> None: + async def _fake_fetch_newbuilding( + zhk_url: str, *, config: Any = None, proxy_provider: Any = None + ) -> None: captured["zhk_url"] = zhk_url captured["endpoint"] = config.browser_http_endpoint if config is not None else None + # #2767: этот путь тоже обязан идти через пул прокси — до правки он, как и + # обогащение, выходил через единственный env-узел сайдкара. + captured["proxy_provider"] = proxy_provider db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [ @@ -258,6 +263,7 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi assert captured["zhk_url"] == "https://zhk-test-ekb-i.cian.ru/" assert captured["endpoint"] == settings.browser_http_endpoint + assert captured["proxy_provider"] is not None async def test_cian_history_backfill_valuations_estimate_gets_config() -> None: diff --git a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py index b372a63e..d2890511 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py @@ -59,11 +59,16 @@ def _spy_browser_fetcher(html: str) -> MagicMock: @pytest.mark.asyncio async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch): - """config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...).""" - spy = _spy_browser_fetcher("") - monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy) + """config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...). - config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT) + С #2767 путь идёт через `build_browser_fetcher` (подключение к пулу прокси), поэтому + подменяется BrowserFetcher В ФАБРИКЕ — сама фабрика при этом настоящая, и тест + проверяет всю цепочку config → фабрика → фетчер, а не контракт мока. + """ + spy = _spy_browser_fetcher("") + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy) + + config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False) await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", config=config) spy.assert_called_once() diff --git a/tradein-mvp/backend/tests/test_sentry_scrub.py b/tradein-mvp/backend/tests/test_sentry_scrub.py index 9ed45ba3..4925c48d 100644 --- a/tradein-mvp/backend/tests/test_sentry_scrub.py +++ b/tradein-mvp/backend/tests/test_sentry_scrub.py @@ -16,6 +16,7 @@ from app.observability.sentry_scrub import ( redact_telegram_bot_token, scrub_payment_request_body, scrub_pii_event, + stabilize_retry_error_fingerprint, ) @@ -413,3 +414,216 @@ def test_composed_before_send_payment_wipe_pii_and_token_together() -> None: assert out["extra"]["client_phone"] == "[REDACTED]" frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"] assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url + + +# ── RetryError fingerprint stabilization (glitchtip-noise, #) ─ +# +# tenacity.RetryError.__str__() тащит repr() последнего Future — memory address +# объекта, случайный на каждый вызов процесса. Раньше (без `reraise=True` в +# app/services/geocoder.py) каждое исчерпание ретраев Nominatim улетало в +# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина +# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере). +# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and- +# suspenders слой для retry-кода БЕЗ reraise=True (напр. scraper_kit — +# geocoder.py `reraise=True` устраняет RetryError на своём пути, но остаётся +# фолбэком общего назначения), и по контракту before_send: 401-класс (RetryError) +# схлопывается ПО ИСТОЧНИКУ (не глобально — review round 2 claim #2: разные +# подсистемы с совпавшим типом причины НЕ сливаются), содержательные категории +# (500-подобный generic Exception, OperationalError) проходят НЕТРОНУТЫМИ. + +from tenacity import RetryError # noqa: E402 + + +def _hint_for(exc: BaseException) -> dict: + """Строит hint в форме, которую sentry_sdk реально передаёт в before_send — + `exc_info = (type, value, traceback)` (contract stabilize_retry_error_fingerprint + полагается именно на эту форму, не на уже сериализованный event dict).""" + return {"exc_info": (type(exc), exc, exc.__traceback__)} + + +def _raise_retry_error_from(cause: BaseException) -> RetryError: + try: + raise cause + except type(cause) as caught: + try: + raise RetryError(None) from caught + except RetryError as retry_exc: + return retry_exc + + +def test_stabilize_retry_error_sets_stable_fingerprint() -> None: + """RetryError коллапсится в persistent issue по (culprit, имени типа причины) — + НЕ по нестабильному str(RetryError) (repr() Future с memory address). Без + `event["logger"]` (напр. capture_exception без LoggingIntegration) culprit + падает на явный "unknown", а не пропадает из fingerprint молча.""" + exc = _raise_retry_error_from(TimeoutError("Nominatim timed out")) + out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc)) + assert out is not None + assert out["fingerprint"] == ["retry-exhausted", "unknown", "TimeoutError"] + + +def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None: + """Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только + culprit (logger-имя модуля) + фиксированное имя типа исключения-причины + (маленький словарь: HTTPStatusError/ConnectTimeout/TimeoutError/...).""" + exc = _raise_retry_error_from( + ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345") + ) + out = stabilize_retry_error_fingerprint({}, _hint_for(exc)) + assert out is not None + fingerprint_text = " ".join(out["fingerprint"]) + assert "95.165.147.218" not in fingerprint_text + assert "12345" not in fingerprint_text + assert out["fingerprint"] == ["retry-exhausted", "unknown", "ValueError"] + + +def test_stabilize_retry_error_fingerprint_uses_logger_as_culprit() -> None: + """`event["logger"]` (sentry_sdk LoggingIntegration ставит его = имя модуля, + вызвавшего logger.exception/.error) идёт в fingerprint как culprit — стабильно + per-модуль, не переменные данные запроса.""" + exc = _raise_retry_error_from(TimeoutError("timed out")) + out = stabilize_retry_error_fingerprint({"logger": "app.services.geocoder"}, _hint_for(exc)) + assert out is not None + assert out["fingerprint"] == ["retry-exhausted", "app.services.geocoder", "TimeoutError"] + + +def test_stabilize_retry_error_fingerprint_does_not_collapse_unrelated_subsystems() -> None: + """Review round 2 claim #2: RetryError с ОДИНАКОВЫМ типом причины из + НЕСВЯЗАННЫХ подсистем (geocoder vs scraper_kit) НЕ должны схлопнуться в один + issue — разные проблемы не сливаются, даже если типы причины совпали.""" + exc_geocoder = _raise_retry_error_from(TimeoutError("nominatim timed out")) + exc_scraper = _raise_retry_error_from(TimeoutError("yandex detail timed out")) + out_geocoder = stabilize_retry_error_fingerprint( + {"logger": "app.services.geocoder"}, _hint_for(exc_geocoder) + ) + out_scraper = stabilize_retry_error_fingerprint( + {"logger": "scraper_kit.providers.yandex.detail"}, _hint_for(exc_scraper) + ) + assert out_geocoder is not None + assert out_scraper is not None + assert out_geocoder["fingerprint"] != out_scraper["fingerprint"] + + +class _DecoyRetryError(Exception): + """Посторонний класс, СЛУЧАЙНО названный так же, как tenacity.RetryError — + но НЕ его подкласс. Строковое сравнение имён (старый баг, review round 2 + claim #4) ложно матчило бы такое; isinstance — нет.""" + + +_DecoyRetryError.__name__ = "RetryError" # type: ignore[misc] + + +def test_stabilize_retry_error_ignores_lookalike_class_by_name() -> None: + """type(exc).__name__ == "RetryError" НЕ должно быть достаточно — только + реальный tenacity.RetryError (или его подкласс) триггерит fingerprint-хук.""" + exc = _DecoyRetryError("unrelated exception, same class __name__ by accident") + event = {"level": "error"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_leaves_operational_error_untouched() -> None: + """401-аналог задачи: OperationalError — содержательная категория (реальный + сбой БД), фильтр её НЕ трогает (см. задачу #4 — не выключить сигнал вместе с + шумом).""" + from sqlalchemy.exc import OperationalError + + exc = OperationalError("SELECT 1", {}, Exception("connection refused")) + event = {"level": "error", "message": "db connection failed"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_leaves_generic_exception_untouched() -> None: + """500-аналог задачи: обычное необработанное исключение (не RetryError) + проходит без изменений.""" + exc = RuntimeError("scraper city-sweep failed") + event = {"level": "error"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_no_exc_info_untouched() -> None: + """capture_message-based события (нет exc_info) — фильтр не трогает, напр. + scrape_runs.py consecutive-failure алерты (content-ful, должны доходить).""" + event = {"level": "error", "message": "Scraper source 'avito' has 5 consecutive failed runs"} + out = stabilize_retry_error_fingerprint(dict(event), {}) + assert out == event + + +def test_stabilize_retry_error_handles_non_dict_event() -> None: + assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type] + + +# ── 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. +# Воспроизведено эмпирически (httpx.Response(403, request=...).raise_for_status()): +# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/ +# search?q=<адрес>&format=json&limit=3'". После `reraise=True` в geocoder.py +# (устраняет RetryError, но НЕ этот текст) именно ЭТА строка становится GlitchTip +# title/value — переменный `q=<адрес>` на каждый вызов воспроизводит тот же +# per-address issue-explosion, который reraise=True должен был устранить, просто +# сменивший класс исключения (RetryError → HTTPStatusError). Тесты бьют по +# `scrub_pii_event` напрямую (композиция, реально применяемая в before_send). + +_NOMINATIM_403_TEMPLATE = ( + "Client error '403 Forbidden' for url " + "'https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=3'\n" + "For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403" +) + + +def _httpx_error_event(message: str) -> dict: + return {"exception": {"values": [{"type": "HTTPStatusError", "value": message}]}} + + +def test_scrub_pii_event_stabilizes_httpx_error_url_query() -> None: + """Query string режется целиком из httpx-style 'for url' сообщения — host+path + остаются стабильными для группировки.""" + encoded_ekb = "%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3" + event = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query=encoded_ekb)) + out = scrub_pii_event(event, {}) + assert out is not None + value = out["exception"]["values"][0]["value"] + assert "search?[REDACTED]'" in value + assert "%D0%95" not in value + assert "nominatim.openstreetmap.org/search" in value # host+path сохранены + + +def test_scrub_pii_event_httpx_url_query_stabilization_collapses_different_addresses() -> None: + """Два РАЗНЫХ адреса (переменная часть query) после редактора дают + ИДЕНТИЧНЫЙ текст сообщения — GlitchTip group-title больше не плодит issue + на каждый адрес (review round 2 claim #1).""" + event_a = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Ленина+1")) + event_b = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Мира+42%2C+кв.+5")) + out_a = scrub_pii_event(event_a, {}) + out_b = scrub_pii_event(event_b, {}) + assert out_a is not None + assert out_b is not None + assert out_a["exception"]["values"][0]["value"] == out_b["exception"]["values"][0]["value"] + + +def test_scrub_pii_event_httpx_url_without_query_untouched() -> None: + """URL без query string (напр. scraper detail page — переменная часть в + ПУТИ, не в query) остаётся нетронутым — regex матчит только `?...`.""" + message = "Client error '404 Not Found' for url 'https://realty.yandex.ru/offer/12345/'" + event = _httpx_error_event(message) + out = scrub_pii_event(event, {}) + assert out is not None + assert out["exception"]["values"][0]["value"] == message + + +def test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched() -> None: + """Regex бьёт только по 'for url \\'...?...\\'' — произвольный текст с `?` и + кавычками не должен ложно матчиться.""" + benign = "Вопрос: 'что такое ЖК \"Солнечный\"?' — уточните адрес" + event = {"extra": {"note": benign}} + out = scrub_pii_event(event, {}) + assert out is not None + assert out["extra"]["note"] == benign diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py index c1ea1249..003883e1 100644 --- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py +++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py @@ -107,6 +107,64 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N assert extract_street_name(address) == expected +@pytest.mark.parametrize( + "address,expected", + [ + # Live-prod repro (2026-08-10): DaData format — abbreviations WITHOUT a + # trailing dot ("ул Академика Парина", not "ул. ..."), plus a leading + # postal index + admin parts ("620105, Свердловская обл, г + # Екатеринбург, Академический р-н, ..."). Old `_STREET_KW_RE` required + # the dot → keyword never matched on ANY DaData address → street-deals + # / sales-vs-listings endpoints silently returned empty for every + # DaData-geocoded request, not just this one. + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "ул Академика Парина, д 46/5", + "Академика Парина", + ), + # Same address, WITH the dot — must give the identical result (dot + # optional, not dot-forbidden). + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "ул. Академика Парина, д 46/5", + "Академика Парина", + ), + # Same address, full word "улица" — the alternation-order/backtracking + # concern: "ул\\.?" must NOT eat the "ул" prefix of "улица" and leave + # "ица ..." behind. + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "улица Академика Парина, д 46/5", + "Академика Парина", + ), + # Without the leading postal index — same admin prefix otherwise. + ( + "Свердловская обл, г Екатеринбург, Академический р-н, ул Академика Парина, д 46/5", + "Академика Парина", + ), + # Bare street+house, no admin prefix at all. + ("ул Академика Парина, д 46/5", "Академика Парина"), + # Other dot-optional abbreviations from _STREET_KW_RE (пр/пер/ш/наб/пл/мкр). + ("г Екатеринбург, пр Ленина, 5", "Ленина"), + ("г Екатеринбург, пер Красный, 4", "Красный"), + ("г Екатеринбург, наб Реки Исеть, 1", "Реки Исеть"), + # "ул. X" / "ул X" / "улица X" must all agree (no dot-optional regression). + ("Екатеринбург, ул. Малышева, 1", "Малышева"), + ("Екатеринбург, ул Малышева, 1", "Малышева"), + ("Екатеринбург, улица Малышева, 1", "Малышева"), + ], +) +def test_extract_street_name_dadata_no_dot_abbreviations( + address: str | None, expected: str | None +) -> None: + """#pdf-honesty/street-deals live-prod fix (2026-08-10): DaData addresses + use dot-less abbreviations ("ул", "пр", "пер", "ш", "наб", "пл", "мкр") + — _STREET_KW_RE must match them exactly like the dotted forms.""" + from app.services.estimator import extract_street_name + + assert extract_street_name(address) == expected + + # ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_version_api.py b/tradein-mvp/backend/tests/test_version_api.py new file mode 100644 index 00000000..767e6b87 --- /dev/null +++ b/tradein-mvp/backend/tests/test_version_api.py @@ -0,0 +1,99 @@ +"""Tests for GET /api/v1/trade-in/version (build metadata) — app/core/version.py + +app/api/v1/version.py. + +Isolated FastAPI app (no full app.main import, no DB) — same pattern as +tests/test_geocode_reverse_api.py: mount only the router under test. +""" + +from __future__ import annotations + +import importlib +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import version as version_module +from app.core import version as version_core + + +@pytest.fixture +def app() -> FastAPI: + application = FastAPI() + application.include_router(version_module.router, prefix="/api/v1/trade-in") + return application + + +# ── GET /api/v1/trade-in/version ───────────────────────────────────────────── + + +def test_version_endpoint_shape(app: FastAPI) -> None: + client = TestClient(app) + r = client.get("/api/v1/trade-in/version") + assert r.status_code == 200 + body = r.json() + assert set(body.keys()) == {"version", "sha", "built_at"} + assert isinstance(body["version"], str) and body["version"] + assert isinstance(body["sha"], str) and body["sha"] + assert isinstance(body["built_at"], str) and body["built_at"] + + +def test_version_endpoint_matches_core_constants(app: FastAPI) -> None: + client = TestClient(app) + body = client.get("/api/v1/trade-in/version").json() + assert body["version"] == version_core.APP_VERSION + assert body["sha"] == version_core.BUILD_SHA + assert body["built_at"] == version_core.BUILD_DATE + + +def test_version_path_is_public_no_auth_required() -> None: + """rbac_guard must let this path through without X-Authenticated-User / + session — see app/core/rbac.py::_PUBLIC_PATHS. Not a secret, no DB call.""" + from app.core.rbac import _PUBLIC_PATHS + + assert "/api/v1/trade-in/version" in _PUBLIC_PATHS + + +# ── app/core/version.py — product_version_line / format_build_date_human ──── + + +def test_product_version_line_format() -> None: + line = version_core.product_version_line("Мера") + assert line.startswith("Мера v") + parts = line.split(" · ") + assert len(parts) == 3, f"expected 'name vX.Y.Z · sha · date', got {line!r}" + + +def test_format_build_date_human_parses_iso_utc() -> None: + assert version_core.format_build_date_human("2026-08-10T12:00:00Z") == "10.08.2026" + + +def test_format_build_date_human_falls_back_on_garbage_without_raising() -> None: + assert version_core.format_build_date_human("not-a-date") == "not-a-date" + + +# ── Fallback when APP_VERSION/BUILD_SHA/BUILD_DATE env vars are absent ────── +# (local `uvicorn` run without a Docker build — see module docstring in +# app/core/version.py). Reloading the module re-executes its module-level +# env reads; nothing here may raise. + + +def test_module_import_falls_back_without_build_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_VERSION", raising=False) + monkeypatch.delenv("BUILD_SHA", raising=False) + monkeypatch.delenv("BUILD_DATE", raising=False) + + reloaded = importlib.reload(version_core) + + assert reloaded.BUILD_SHA == "dev" + assert reloaded.APP_VERSION # non-empty: VERSION file content or "0.0.0" default + assert reloaded.BUILD_DATE.endswith("Z") + # format/product helpers must still work off the fallback values (no crash). + assert reloaded.product_version_line("Мера").startswith("Мера v") + + # Reload once more so any test running later in this process sees a module + # state consistent with whatever env pytest was actually invoked under. + importlib.reload(version_core) diff --git a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py index 44d6ac26..a21a9509 100644 --- a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py +++ b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py @@ -32,8 +32,8 @@ def _history_rows(db) -> list[dict]: """Строки батча house_placement_history из мока сессии. #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция - сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama - перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + сломалась, как только у функции появился второй execute перед вставкой истории. + Фильтруем по SQL: тест переживёт любой новый вызов. """ for call in db.execute.call_args_list: if "INSERT INTO house_placement_history" in str(call.args[0]): @@ -169,10 +169,9 @@ def test_all_invalid_area_returns_zero_no_crash() -> None: assert saved == 0 # db.execute не должен вызываться для пустого rows (нет INSERT) assert _history_rows(db) == [] - # Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись - # houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась - # ли история по площади. - assert db.commit.call_count == 2 + # Commit вызывается один раз (пустой батч истории), rollback — нет. Второй коммит + # (запись houses.has_panorama) ушёл вместе с колонкой — хвост #2674, мигр. 259. + assert db.commit.call_count == 1 db.rollback.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py index 21d6f605..c6196e84 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation.py @@ -1,7 +1,7 @@ """Unit tests for YandexValuationScraper — anonymous house-history scraper. Fixture HTML simulates the Yandex valuation page body text containing: -- House meta block (year, floors, type, ceiling, lift, total objects, panorama) +- House meta block (year, floors, type, ceiling, lift, total objects) - 2-3 historical offer entries with full structure Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг @@ -78,7 +78,6 @@ def test_parse_house_meta_full(): assert meta.ceiling_height == 2.50 assert meta.has_lift is True assert meta.total_objects == 12 - assert meta.has_panorama is False def test_parse_house_meta_no_lift(): @@ -91,10 +90,16 @@ def test_parse_house_meta_no_lift(): assert meta.ceiling_height == 3.0 -def test_parse_house_meta_with_panorama(): - text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание" +def test_parse_house_meta_brick_with_lift(): + """#2674 (хвост): фикстура больше не содержит «Панорама». + + Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором; + зелёный он был по построению. На настоящей странице оценки этого слова нет — + проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов. + Остаток теста осмысленный: кирпич + лифт на короткой мета-строке. + """ + text = "7 объектов Дом 2010 года Лифт Кирпичное здание" meta = YandexValuationScraper._parse_house_meta(text) - assert meta.has_panorama is True assert meta.has_lift is True assert meta.house_type == "brick" @@ -275,7 +280,7 @@ def test_total_floors_extracted_from_dom_meta_not_items(): Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item. """ text = ( - "Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт " + "Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт " "1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже " "2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже" ) diff --git a/tradein-mvp/backend/tests/test_yandex_valuation_save.py b/tradein-mvp/backend/tests/test_yandex_valuation_save.py index 308d4cb1..4b90d5ef 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation_save.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation_save.py @@ -30,8 +30,8 @@ def _history_rows(db) -> list[dict]: """Строки батча house_placement_history из мока сессии. #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция - сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama - перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. + сломалась, как только у функции появился второй execute перед вставкой истории. + Фильтруем по SQL: тест переживёт любой новый вызов. """ for call in db.execute.call_args_list: if "INSERT INTO house_placement_history" in str(call.args[0]): diff --git a/tradein-mvp/frontend/Dockerfile b/tradein-mvp/frontend/Dockerfile index 9abbd35a..5def62b7 100644 --- a/tradein-mvp/frontend/Dockerfile +++ b/tradein-mvp/frontend/Dockerfile @@ -30,6 +30,29 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_ENABLE_PREVIEW="" ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW +# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть +# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические +# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не +# видит tradein-mvp/VERSION (он на уровень выше, вне build context) — источник +# правды читает CI ДО вызова `docker build` (.forgejo/workflows/deploy-tradein.yml, +# job build-frontend) и передаёт сюда готовыми значениями. Пустые дефолты — для +# локальной сборки без CI; фолбэк на "VERSION-файл/dev/дата сборки" делает уже +# frontend-код, потребляющий эти env (Dockerfile сам файл не читает). +ARG NEXT_PUBLIC_APP_VERSION="" +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +ARG NEXT_PUBLIC_BUILD_SHA="" +ENV NEXT_PUBLIC_BUILD_SHA=$NEXT_PUBLIC_BUILD_SHA +ARG NEXT_PUBLIC_BUILD_DATE="" +ENV NEXT_PUBLIC_BUILD_DATE=$NEXT_PUBLIC_BUILD_DATE + +# CHANGELOG.md — источник для /versions (src/lib/changelog.ts). Живёт на +# уровень выше этого build context (tradein-mvp/CHANGELOG.md), поэтому CI +# копирует его СЮДА (tradein-mvp/frontend/CHANGELOG.md) непосредственно +# перед `docker build` (см. .forgejo/workflows/deploy-tradein.yml, job +# build-frontend) — `COPY . .` ниже подхватывает её автоматически вместе с +# остальным контекстом. Локальная сборка без этого шага CI просто не находит +# файл — readChangelog() уже умеет деградировать (пустая история), сам +# Docker-билд при этом не падает (см. glob-COPY в runner stage ниже). COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build @@ -49,6 +72,13 @@ ENV NODE_ENV=production \ COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static +# /versions — Server Component, statically prerendered at `npm run build` +# (see src/app/versions/page.tsx) — CHANGELOG.md's content is already baked +# into .next/standalone above. This is a defensive fallback ONLY, in case that +# page ever stops being static: glob (trailing `*`) makes it a no-op when the +# builder stage doesn't have the file either (local build without the CI +# pre-copy step, see builder stage comment above) — never fails the build. +COPY --from=builder --chown=node:node /app/CHANGELOG.md* ./ USER node EXPOSE 3000 diff --git a/tradein-mvp/frontend/package-lock.json b/tradein-mvp/frontend/package-lock.json index 46e22b55..4cc83b4f 100644 --- a/tradein-mvp/frontend/package-lock.json +++ b/tradein-mvp/frontend/package-lock.json @@ -6343,6 +6343,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -6803,13 +6816,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -7828,19 +7841,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx index 086d4abc..c5d9b6e2 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx @@ -1,15 +1,17 @@ /** * SiteFooter — подвал. Серверный компонент. * - * Что здесь честно ОТСУТСТВУЕТ: - * - Реквизиты юрлица/ИП. В репозитории их нет (поиск по коду, бэкенду и - * разметке не дал ни наименования, ни ИНН/ОГРН), а выдумывать реквизиты - * оператора персональных данных на публичной странице нельзя. Блок - * рендерится, как только `LEGAL_ENTITY` в content.ts перестанет быть null; - * заполнить обязательно до открытия домена наружу — 152-ФЗ требует - * идентифицируемого оператора. - * - E-mail поддержки: реального адреса в коде тоже нет. Единственный - * проверяемый канал — телеграм-бот из `v2/SupportChatContext.tsx`. + * 2026-08-13: блоки реквизитов и почты, которых здесь раньше не было, теперь + * рендерятся — данные появились (`LEGAL_ENTITY`, `SUPPORT_EMAIL` в content.ts), + * и это не косметика: наименование, ИНН, ОГРН и работающий контакт на странице + * с офертой — прямое требование модерации эквайера и условие идентифицируемости + * оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же + * набор рендерят оферта и политика ПДн, и разъехаться они не должны. + * + * Ссылки на документы ведут по ВНУТРЕННИМ путям (`OFFER_PATH` и соседи), а не + * по коротким meraocenka.ru/oferta: короткие адреса существуют только на + * публичном хосте (rewrite в Caddy), а это же поддерево открывается и с + * gendsgn.ru/trade-in/mera-public — там короткая ссылка ушла бы в 404. * * Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без * валидации схемы) и открывается в новой вкладке с rel="noreferrer". @@ -21,8 +23,11 @@ import { safeUrl } from "@/lib/safeUrl"; import { LEGAL_ENTITY, + OFFER_PATH, PRIVACY_PATH, + REFUND_PATH, REGION_NAME, + SUPPORT_EMAIL, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL, } from "../content"; @@ -49,6 +54,12 @@ export function SiteFooter() {

Связаться

+

+ Почта:{" "} + + {SUPPORT_EMAIL} + +

{telegramHref ? (

Поддержка в Telegram:{" "} @@ -61,14 +72,22 @@ export function SiteFooter() { {SUPPORT_TELEGRAM_LABEL}

- ) : ( -

Контакты появятся к запуску.

- )} + ) : null}

Документы

    +
  • + + Публичная оферта + +
  • +
  • + + Политика возврата + +
  • Обработка персональных данных @@ -82,8 +101,8 @@ export function SiteFooter() { © {year} МЕРА {LEGAL_ENTITY && ( - {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn},{" "} - {LEGAL_ENTITY.address} + {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "} + {LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address} )} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx index 4ec23dcc..a4c4d29a 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx @@ -4,6 +4,14 @@ * Логотип НЕ является ссылкой на "/": в next.config.ts стоит redirect "/" → "/v2", * то есть клик по нему выкинул бы публичного посетителя в закрытое B2B-приложение * (и дальше на /login). Пока публичная страница одна, вордмарк — просто текст. + * + * ФОРМУЛИРОВКА ДЕСКРИПТОРА (14.08.2026, юр-документ владельца, блок 3). Строка + * шапки обязана читаться как «МЕРА · Оценка вторичного жилья по рыночным данным · + * <регион>»: слова «по рыночным данным» — то, чем сервис отличается от оценки по + * 135-ФЗ, и юрист попросил, чтобы это стояло в самом заметном дескрипторе, а не + * только в дисклеймерах внизу. «МЕРА» приходит из вордмарка ниже (у него свой + * letter-spacing), поэтому в headerTag её нет — строка складывается из двух узлов. + * Регион — из REGION_NAME, не строкой (правило content.ts). */ import { REGION_NAME } from "../content"; @@ -18,7 +26,7 @@ export function SiteHeader() { МЕРА

- Оценка вторичного жилья · {REGION_NAME} + Оценка вторичного жилья по рыночным данным · {REGION_NAME}

diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx new file mode 100644 index 00000000..a240140b --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx @@ -0,0 +1,154 @@ +/** + * AccuracyV3 — «Точность»: KPI-плитки + таблица сверок прогноза с фактом + * сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент. + * + * KPI-значения — из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это + * плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения + * замером (см. шапку файла). + * + * Таблица сверок — `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было + * заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому + * строки — витринный пример формата отчёта, а не факт. Ровно поэтому подпись + * под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · …» — фраза, + * прямо утверждающая, что за строками стоят настоящие обезличенные сделки. + * Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись + * честно называет таблицу иллюстрацией формата, а вторую половину исходной + * фразы («"выше средней цены" — разница со средней ценой сделки по похожим + * квартирам в районе») сохраняет — она объясняет, ЧТО значит колонка, а не + * заявляет что-то о происхождении данных. + * + * Таблица размечена ARIA role="table/row/columnheader/cell" (а не ): + * на мобильном брейке (см. landing-v3.module.css) она превращается в карточки + * без горизонтального скролла, а implicit table-роли всё равно снимаются + * Safari при смене display у настоящей
— тот же квирк, что уже описан + * в HowItWorks.tsx для списков, только для таблиц его обходят явные роли, а + * не CSS-костыль. + */ + +import { + ACCURACY_PERIOD_LABEL, + ACCURACY_PLACEHOLDER, + PROOF_ROWS_PLACEHOLDER, +} from "../../marketing-v3"; +import styles from "../../landing-v3.module.css"; + +const KPI_TILES = [ + { + value: ACCURACY_PLACEHOLDER.priceError, + label: "средняя ошибка по цене", + note: ACCURACY_PLACEHOLDER.priceErrorNote, + }, + { + value: ACCURACY_PLACEHOLDER.daysError, + label: "точность по сроку продажи", + note: ACCURACY_PLACEHOLDER.daysErrorNote, + }, + { + value: ACCURACY_PLACEHOLDER.coverage, + label: "сделок попали в диапазон", + note: ACCURACY_PLACEHOLDER.coverageNote, + }, +] as const; + +export function AccuracyV3() { + return ( +
+
+
+ +

+ Мы сверяем каждый прогноз с реальной сделкой +

+

+ {`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`} +

+
+ +
    + {KPI_TILES.map((tile) => ( +
  • +
    {tile.value}
    +
    {tile.label}
    +
    {tile.note}
    +
  • + ))} +
  • +
    + {ACCURACY_PLACEHOLDER.checks} +
    +
    проверок за год
    +
    + {ACCURACY_PLACEHOLDER.checksNote} +
    +
  • +
+ +
+
+ Объект + Мера сказала + Факт сделки + Расхождение + + Выше средней цены по району + +
+ {PROOF_ROWS_PLACEHOLDER.map((row) => ( +
+
+ {row.addr} + {row.meta} +
+
+ {row.said} +
+
+ {row.fact} +
+
+ {row.err} +
+
+
+
{row.gain}
+
{row.gainPct}
+
+
+
+ ))} +
+

+ Таблица иллюстрирует формат отчёта, это не список конкретных сделок. + «Выше средней цены» — разница со средней ценой сделки по похожим + квартирам в районе. +

+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx new file mode 100644 index 00000000..7ef9ee8d --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx @@ -0,0 +1,140 @@ +"use client"; + +/** + * CityPicker — выбор города в шапке лэндинга v3 (макет ~ строки 47-73). + * + * Презентационный клиентский остров: сегодня публичный расчёт выключен + * (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь + * город никуда не отправляется — состояние живёт только в этом компоненте и + * показывает посетителю, что сервис знает больше одного города области. + * Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора + * наружу (проп/контекст) — в этом заходе scope ограничен шапкой/подвалом/ + * sticky-баром, самой формы здесь нет. + * + * Список городов и деление на «полное покрытие» / «данных меньше» берутся из + * `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно + * объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса» + * по городам (тысячи объявлений) — мы их не повторяем: таких цифр в коде нет, + * а гадать нельзя (правило честности в content.ts). + */ + +import { useEffect, useId, useRef, useState } from "react"; + +import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry"; + +import { PRIMARY_CITY, REGION_NAME } from "../../content"; +import styles from "../../landing-v3.module.css"; + +export function CityPicker() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [city, setCity] = useState(DEFAULT_CITY.label); + const wrapRef = useRef(null); + const buttonRef = useRef(null); + const panelId = useId(); + + // Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx. + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Escape — закрыть и вернуть фокус на кнопку. + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") { + setOpen(false); + buttonRef.current?.focus(); + } + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + const normalizedQuery = query.trim().toLowerCase(); + const filtered = normalizedQuery + ? OBLAST_CITIES.filter((c) => + c.label.toLowerCase().includes(normalizedQuery), + ) + : OBLAST_CITIES; + + return ( +
+ + + {open ? ( +
+
+ setQuery(e.target.value)} + placeholder="Поиск города" + aria-label="Поиск города" + /> +
+
+ {filtered.length > 0 ? ( + filtered.map((c) => ( + + )) + ) : ( +

+ Города нет в списке. Мы работаем там, где хватает данных по + сделкам — напишите нам, и добавим. +

+ )} +
+
+ ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "} + {REGION_NAME.toUpperCase()} +
+
+ ) : null} +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx new file mode 100644 index 00000000..b7281015 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx @@ -0,0 +1,52 @@ +/** + * CostOfErrorV3 — «Цена ошибки»: три плитки про стоимость неверной цены + * (макет v3, ~строки 376-400, без id). Серверный компонент. + * + * Значения и подписи — из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts), + * заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) — тот же источник + * правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный + * литерал «150». + */ + +import { SERVICE_PRICE_RUB } from "../../content"; +import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3"; +import styles from "../../landing-v3.module.css"; + +export function CostOfErrorV3() { + return ( +
+
+
+ +

+ {`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`} +

+
+ +
    + {COST_OF_ERROR_PLACEHOLDER.map((tile, index) => { + const dark = index === COST_OF_ERROR_PLACEHOLDER.length - 1; + return ( +
  • +
    {tile.value}
    +
    {tile.title}
    +

    + {tile.text} +

    +
  • + ); + })} +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx new file mode 100644 index 00000000..1ff10d5d --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx @@ -0,0 +1,145 @@ +/** + * FooterV3 — подвал лэндинга v3 (макет ~ строки 470-516): четыре колонки + + * нижняя строка с реквизитами. + * + * РЕКВИЗИТЫ И ДОКУМЕНТЫ — НЕ из макета (там маска «ООО «МЕРА» · ИНН + * 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`, + * `SUPPORT_EMAIL`, `OFFER_PATH`, `REFUND_PATH`, `PRIVACY_PATH` — тот же набор, + * что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в + * другой сетке. Документы включают Политику возврата и почту поддержки: + * макет их не показывал, а это обязательные элементы платного контура + * (маркировка эквайера, идентифицируемость оператора по 152-ФЗ). + * + * Способы оплаты (МИР / СБП / VISA / MASTERCARD в макете) НЕ рендерятся: + * приём платежей не подключён (см. TODO у нижней строки) — заявлять способы + * оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе». + * + * «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3): + * раздела нет в коде. «Проверить квартиру» ведёт на #check (форма в Hero на + * этой же странице) — отдельного маршрута отчёта не существует. Оба + * внутренних якоря — через `/mera-public#...`: подвал рендерится и на + * оферте/возврате/ПДн, где голый `#check` никуда не долистал бы. + */ + +import Link from "next/link"; + +import { safeUrl } from "@/lib/safeUrl"; + +import { + LEGAL_ENTITY, + OFFER_PATH, + PRIVACY_PATH, + REFUND_PATH, + REGION_NAME, + SUPPORT_EMAIL, + SUPPORT_TELEGRAM_LABEL, + SUPPORT_TELEGRAM_URL, +} from "../../content"; +import styles from "../../landing-v3.module.css"; + +export function FooterV3() { + const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL); + const year = new Date().getFullYear(); + + return ( +
+
+
+
+
+

+ Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "} + {REGION_NAME}. +

+
+ +
+

Разделы

+
+ + Главная + + + Проверить квартиру + + + Статьи + + + МЕРА для бизнеса + +
+
+ +
+

Связаться

+

+ Почта:{" "} + + {SUPPORT_EMAIL} + +

+ {telegramHref ? ( +

+ Поддержка в Telegram:{" "} + + {SUPPORT_TELEGRAM_LABEL} + +

+ ) : null} +
+ +
+

Документы

+
+ + Публичная оферта + + + Политика возврата + + + Обработка персональных данных + +
+
+
+ +
+
+ © {year} МЕРА + {LEGAL_ENTITY ? ( + + {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "} + {LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address} + + ) : null} + + Оценка носит информационный характер и не является офертой или + отчётом об оценке. + +
+ {/* TODO(платёжный контур): бейджи способов оплаты (МИР / СБП / VISA / + MASTERCARD, как в макете) — рендерить только когда приём оплаты + будет подключён backend'ом. Сегодня `PUBLIC_ESTIMATE_ENABLED = + false`, оплаты нет вовсе, а заявлять способы оплаты, которых нет, + — нарушение ст. 5 ФЗ «О рекламе». Гейт G6, см. + mera-b2c-paid-flow-decision.md. */} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx new file mode 100644 index 00000000..2adf9381 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -0,0 +1,303 @@ +"use client"; + +/** + * FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3, + * `МЕРА для себя v3.dc.html` строки ~129-170). + * + * ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ. Ровно то же, чем ограничена + * `../AddressForm.tsx` в закрытом v2-контуре, и по той же причине: + * `rbac_guard` (backend/app/core/rbac.py) не пускает анонима на + * `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` — их нет в + * `_PUBLIC_PATHS`. Открытие анонимного периметра — отдельный backend-PR, вне + * границ этой задачи. Поэтому здесь: честная валидация 5 обязательных полей + * на клиенте + прямое сообщение «проверка ещё не открыта» вместо макетного + * сценария «спиннер 1100 мс → готовый результат». Тот сценарий на живом + * сайте отдавал бы одинаковые «14 объявлений / 47 дней» на любой введённый + * адрес — не продающая формулировка, а обман конкретного человека (см. шапку + * `../../marketing-v3.ts`). + * + * Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`, + * `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют + * состояния города, которым владеет шапка сайта — вне участка этой задачи + * («первый экран: герой и карточка»), и вносили бы числовые литералы (33%, + * 66%, 100%), которых нет ни в макете-константах, ни в `marketing-v3.ts`. + * + * Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена — см. + * TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`. + */ + +import { useId, useRef, useState } from "react"; +import type { FormEvent } from "react"; + +import { PRIMARY_CITY, PUBLIC_ESTIMATE_ENABLED } from "../../content"; +import styles from "../../landing-v3.module.css"; + +/** Три состояния квартиры — дословно из макета (строка 734 исходника). */ +const CONDITIONS = ["Требует ремонта", "Хорошее", "Отличное"] as const; +type Condition = (typeof CONDITIONS)[number]; + +type FieldName = "address" | "area" | "rooms" | "floor" | "condition"; + +type Feedback = + | { kind: "none" } + | { kind: "error"; field: FieldName; text: string } + | { kind: "info"; title: string; lines: readonly string[] }; + +const NONE: Feedback = { kind: "none" }; + +export function FreeCheckCard() { + const addressId = useId(); + const areaId = useId(); + const roomsId = useId(); + const floorId = useId(); + const conditionLabelId = useId(); + const feedbackId = useId(); + + const [address, setAddress] = useState(""); + const [area, setArea] = useState(""); + const [rooms, setRooms] = useState(""); + const [floor, setFloor] = useState(""); + const [condition, setCondition] = useState(""); + const [feedback, setFeedback] = useState(NONE); + + const addressRef = useRef(null); + const areaRef = useRef(null); + const roomsRef = useRef(null); + const floorRef = useRef(null); + const conditionGroupRef = useRef(null); + + function clearFeedback() { + setFeedback(NONE); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + + const trimmedAddress = address.trim(); + const trimmedArea = area.trim(); + const trimmedRooms = rooms.trim(); + const trimmedFloor = floor.trim(); + + if (!trimmedAddress) { + setFeedback({ + kind: "error", + field: "address", + text: "Укажите адрес — улицу и номер дома.", + }); + addressRef.current?.focus(); + return; + } + + if (!trimmedArea) { + setFeedback({ + kind: "error", + field: "area", + text: "Укажите площадь квартиры в квадратных метрах.", + }); + areaRef.current?.focus(); + return; + } + + if (!trimmedRooms) { + setFeedback({ + kind: "error", + field: "rooms", + text: "Укажите количество комнат.", + }); + roomsRef.current?.focus(); + return; + } + + if (!trimmedFloor) { + setFeedback({ + kind: "error", + field: "floor", + text: "Укажите этаж, например «7/16».", + }); + floorRef.current?.focus(); + return; + } + + if (!condition) { + setFeedback({ + kind: "error", + field: "condition", + text: "Выберите состояние квартиры.", + }); + conditionGroupRef.current?.focus(); + return; + } + + if (PUBLIC_ESTIMATE_ENABLED) { + // TODO(backend-периметр): здесь появится реальный запрос к + // POST /trade-in/coverage. По успешному ответу — переключить карточку в + // ветку результата и отрендерить `FreeResultV3` с полученными + // analogs/days (НЕ константами из marketing-v3.ts). Отдельный PR. + return; + } + + setFeedback({ + kind: "info", + title: "Бесплатную проверку по адресу мы пока не открыли", + lines: [ + `Дом принят: ${trimmedArea} м², ${trimmedRooms}-комн., этаж ${trimmedFloor}, состояние «${condition}». Но онлайн-расчёт ещё не запущен — сервис работает по доступу для партнёров.`, + "Проверка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.", + ], + }); + } + + const errorField = feedback.kind === "error" ? feedback.field : null; + const describedBy = feedback.kind === "none" ? undefined : feedbackId; + + return ( +
+
+ Бесплатная проверка +
+ +
+
+ + +
+ + + + + +
+ +
+ + Состояние + +
+ {CONDITIONS.map((name) => ( + + ))} +
+
+ + +

+ БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · 30 СЕКУНД +

+ + {/* Живая область держим в DOM постоянно — регион, добавленный в + момент ошибки, часть скринридеров не озвучивает (тот же приём, + что в ../AddressForm.tsx). */} +
+ {feedback.kind === "error" && ( +
+

{feedback.text}

+
+ )} + {feedback.kind === "info" && ( +
+

{feedback.title}

+ {feedback.lines.map((line) => ( +

+ {line} +

+ ))} +
+ )} +
+ +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx new file mode 100644 index 00000000..5cbd9c78 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx @@ -0,0 +1,100 @@ +/** + * FreeResultV3 — ветка «результат» карточки «Бесплатная проверка» (макет v3, + * строки ~176-204 `МЕРА для себя v3.dc.html`). + * + * НЕ ВЫЗЫВАЕТСЯ СЕГОДНЯ НИОТКУДА. Компонент собран и типизирован, но нигде не + * рендерится в живом дереве: `FreeCheckCard.tsx` при сабмите (см. TODO рядом с + * `PUBLIC_ESTIMATE_ENABLED` там) всегда остаётся в форме и честно говорит, что + * онлайн-проверки ещё нет — потому что анонимных ручек `/geocode/suggest` и + * `/trade-in/estimate` не существует (см. `../../content.ts`). Как только + * появится `POST /trade-in/coverage`, эту ветку нужно подключить: завести + * `phase`-состояние в `FreeCheckCard`, по успешному ответу переключаться в + * `"result"` и рендерить этот компонент с РЕАЛЬНЫМИ `analogs`/`days` из ответа + * ручки — не константами. + * + * Данные приходят пропсами, а не читаются из `marketing-v3.ts` внутри + * компонента, — вызывающий код сегодня мог бы по ошибке подставить туда + * `FREE_SAMPLE_PLACEHOLDER` для любого адреса, и тогда «14 похожих объявлений» + * увидел бы каждый посетитель независимо от того, что он ввёл. Это уже не + * продающая формулировка, а обман конкретного человека — см. шапку + * `marketing-v3.ts`. Пропсовый контракт держит эту ответственность на + * вызывающей стороне и делает её явной в диффе, когда придёт время подключать + * реальные данные. + * + * Замыленная рублёвая цифра из макета («9,75 млн ₽», `filter: blur`) сюда + * НЕ перенесена вообще — вместо неё абстрактная плашка-скелет без единой + * цифры (см. `.heroResultSkeleton*` в `landing-v3.module.css`). + */ + +import styles from "../../landing-v3.module.css"; + +export interface FreeResultV3Props { + /** Число похожих объявлений — крупная цифра первой плитки, напр. "14". */ + analogs: string; + /** Подпись под числом объявлений. */ + analogsLabel: string; + /** Средний срок продажи — крупная цифра второй плитки, напр. "47 дн.". */ + days: string; + /** Подпись под сроком продажи. */ + daysLabel: string; + /** Клик по «Проверить другую квартиру» — вернуть карточку к форме. */ + onBack: () => void; +} + +export function FreeResultV3({ + analogs, + analogsLabel, + days, + daysLabel, + onBack, +}: FreeResultV3Props) { + return ( +
+

ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ

+ +
+
+
{analogs}
+
{analogsLabel}
+
+
+
{days}
+
{daysLabel}
+
+
+ +
+ {/* Скелет без единой цифры: замыленную сумму из макета намеренно не + переносим, см. JSDoc выше. */} + diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index b55e7e62..7a6b0b9f 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -5,12 +5,17 @@ /** * HeroSummary — Секция 1 «Сводка» из mockup tradein.html. * Показывает медиану + достоверность CV + параметры объекта + 2 ценовых бара (объявления / сделки). + * + * hero-duo__hint под диапазоном сделок несёт юр-требование владельца от + * 14.08.2026 (блок 4.1): SHORT_ESTIMATE_DISCLAIMER — см. `lib/legal-copy.ts`. */ import { useState } from "react"; import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in"; import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { sourceDot, sourceLabel } from "@/lib/source-registry"; +import { dealsAsOfLabel } from "@/lib/rosreestr"; +import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy"; import { HeroTransparency } from "./HeroTransparency"; interface Props { @@ -175,6 +180,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0; const showDkp = !!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0; + // #2846: возраст ОТОБРАННЫХ сделок. Раньше здесь стояло «за {period_months} + // мес» — окно ПОИСКА, выданное за период данных: «12 мес» читалось как «сделки + // по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026. + // null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает. + const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter"); // Progressive enrichment state const needsHouseType = estimate.house_type === null; @@ -185,6 +195,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const [enrichRepairState, setEnrichRepairState] = useState(""); // Фото первого аналога с картинкой — вместо пустого серого плейсхолдера. const heroPhoto = estimate.analogs.find((a) => a.photo_url)?.photo_url ?? null; + // fix (v1 stale-tail) — n_analogs=0 больше не значит «аналогов нет»: бэкенд + // может посчитать headline по зарегистрированным сделкам ДКП, но всё равно + // отдать тонкую выборку объявлений в estimate.analogs (её же показывает + // ListingsCard ниже на этой странице) — «0 аналогов» рядом с видимыми + // карточками было бы прямым противоречием. Тон — как у v2 LowConfidenceBanner. + const analogsCaption = + estimate.n_analogs > 0 || estimate.analogs.length === 0 + ? `${estimate.n_analogs} аналогов` + : "оценка построена по зарегистрированным сделкам"; // Расчёт ширины для price bar (50% = середина): медиана внутри min/max const span = hi - lo; const medianPctRaw = span > 0 ? ((m - lo) / span) * 100 : 50; @@ -292,7 +311,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {heroPhoto ? `фото аналога${estimate.sources_used[0] ? ` · ${sourceLabel(estimate.sources_used[0])}` : ""}` : estimate.sources_used.length > 0 - ? `${sourceLabel(estimate.sources_used[0])} · ${estimate.n_analogs} аналогов` + ? `${sourceLabel(estimate.sources_used[0])} · ${analogsCaption}` : "Нет фото"}
@@ -463,6 +482,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals )}

+ {/* Блок 4.1 (владелец, 14.08.2026) — юр-строка идёт первым + предложением абзаца-дисклеймера, не отдельным блоком. */} + {SHORT_ESTIMATE_DISCLAIMER}{" "} Диапазон отражает разброс цен по рынку, а не погрешность оценки. {/* #2209: честное раскрытие точности — замеренное покрытие бэктестом по реальным ДКП Росреестра (2026-07: n=276, coverage 81.5%). @@ -538,6 +560,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд. + {dkpAsOf ? ` · ${dkpAsOf}` : ""} )} @@ -592,8 +615,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽

- {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП за{" "} - {dkp.period_months} мес + {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП + {dkpAsOf ? ` · ${dkpAsOf}` : ""}
)} diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx index fc2d8906..d4dfd991 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -250,8 +250,15 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { disabled={leadMutation.isPending} /> - Согласен(-на) на обработку персональных данных в соответствии с Федеральным - законом «О персональных данных» № 152-ФЗ + {/* Легаси-дерево, недостижимое с живого /trade-in/v2 (см. LeadForm.tsx + header-комментарий) — текст держим в синхроне с v2-чекбоксом, чтобы + формулировки не разъезжались, если этот компонент когда-нибудь + вернётся в строй. Кликабельную ссылку здесь намеренно не добавляем: + компонент нигде не смонтирован, никакой пользователь его не видит, + и нет теста, который держал бы такую ссылку в актуальном состоянии + (в отличие от LeadForm.tsx — см. test_consent_text_frontend_sync.py). */} + Согласен(-на) на обработку персональных данных в соответствии с Политикой + обработки персональных данных {phoneInvalid && ( diff --git a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx index b78b0f4a..632e3863 100644 --- a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx @@ -118,7 +118,12 @@ export function ListingsCard({ estimate, estimateId }: Props) {
Объявлений по аналогам
- {estimate.n_analogs} + {/* fix (v1 stale-tail) — n_analogs=0 больше не значит "объявлений + нет": бэкенд может посчитать headline по сделкам ДКП, но + всё равно отдать тонкую выборку объявлений в analogs (тот же + массив рендерит таблица ниже, см. `lots`). Показываем реальную + отображаемую популяцию, а не сырой n_analogs, когда он 0. */} + {estimate.n_analogs > 0 ? estimate.n_analogs : lots.length} шт
из {estimate.sources_used.length} источников
@@ -271,8 +276,21 @@ export function ListingsCard({ estimate, estimateId }: Props) {
- Показано {lots.length} из {estimate.n_analogs}{" "} - объявлений · отсортировано по расстоянию + {/* fix (v1 stale-tail) — see count-strip comment above: n_analogs=0 + with a non-empty lots[] is the deals-fallback branch, not "0 + analogs shown". Drop the false "из 0" denominator and disclose + the deals basis instead (same tone as v2 LowConfidenceBanner). */} + {estimate.n_analogs > 0 ? ( + <> + Показано {lots.length} из{" "} + {estimate.n_analogs} объявлений · отсортировано по расстоянию + + ) : ( + <> + Показано {lots.length} объявлений · + оценка построена по зарегистрированным сделкам · отсортировано по расстоянию + + )}
diff --git a/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx new file mode 100644 index 00000000..426e46df --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/VersionFooter.tsx @@ -0,0 +1,80 @@ +"use client"; + +// VersionFooter — small build-version indicator for the МЕРА product, +// showing the REAL deployed version. This is the SINGLE place on /trade-in/v2 +// that renders a version number — `v2/TopNav.tsx` and `v2/Footer.tsx` used to +// each carry their own hardcoded "v2.0.6" literal (`./ui-config`'s `version`) +// next to the МЕРА wordmark; both were removed (three independent "versions" +// on one screen, see PR review) — the wordmark stays in both places, just +// without a number attached. Values here come from build-time +// `NEXT_PUBLIC_*` env vars via `@/lib/buildInfo` — no runtime API call, no +// useEffect fetch. +// +// Mounted in `app/v2/layout.tsx` (not `app/v2/page.tsx` — that file is +// off-limits for this change), right next to ``. +// +// Portaled to document.body — same reasoning/pattern as SupportButton.tsx: +// /v2 renders its HUD inside a fixed-size "artboard" that gets +// `transform: scale(...)` on narrow viewports (app/v2/page.tsx), and a +// `position: fixed` descendant of a transformed ancestor is positioned +// relative to THAT ancestor, not the real viewport corner — portaling +// sidesteps that entirely, exactly like the support button already does. +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import { tokens } from "@/components/trade-in/v2/tokens"; +import { formatVersionLabel } from "@/lib/buildInfo"; + +const styles = ` +.version-footer{opacity:.72;transition:opacity .15s;} +.version-footer:hover{opacity:1;} +.version-footer a{color:${tokens.muted2};text-decoration:underline;text-underline-offset:2px;} +.version-footer a:hover{color:${tokens.ink};} +@media (max-width: 480px){ + .version-footer{left:10px !important;bottom:10px !important;padding:3px 7px !important;font-size:9px !important;gap:6px !important;} +} +`; + +export function VersionFooter() { + // Portal-mount guard (SSR-safe): `document` only exists after mount + // (mirrors SupportButton.tsx / MapPicker.tsx). + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!mounted) return null; + + return createPortal( + <> + +
+ {formatVersionLabel()} + История версий +
+ , + document.body, + ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/DealsCardFloor.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/DealsCardFloor.test.tsx new file mode 100644 index 00000000..77dfffd3 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/DealsCardFloor.test.tsx @@ -0,0 +1,57 @@ +/** + * #2674 — этаж сделки скрывался у ВСЕХ сделок из-за требования второго поля. + * + * Замер на проде 13.08.2026: `deals.floor` заполнен у 96 974 из 96 974, + * `deals.total_floors` — у 0 из 96 974. Открытый набор Росреестра этажности не + * содержит в принципе, то есть это не «данные не доехали», а свойство источника. + * + * Прежнее условие печатало «этаж X/Y» только когда непусты ОБА поля, поэтому + * этаж не показывался никогда. Соседний компонент (BuildingListingsDrawer) уже + * делал правильно — печатал «этаж N», когда этажность неизвестна. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DealsCard } from "../DealsCard"; +import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; + +function makeDeal(overrides: Partial = {}): AnalogLot { + return { + address: "Екатеринбург, Ленина 5", + area_m2: 55.4, + rooms: 2, + floor: 7, + total_floors: null, + price_rub: 6_500_000, + price_per_m2: 117_328, + source: "rosreestr", + deal_date: "2026-01-01", + ...overrides, + } as AnalogLot; +} + +function makeEstimate(deals: AnalogLot[]): AggregatedEstimate { + return { + actual_deals: deals, + period_months: 12, + } as unknown as AggregatedEstimate; +} + +describe("DealsCard — этаж сделки", () => { + it("печатает этаж, когда этажность неизвестна (боевой случай: 100% сделок)", () => { + render(); + // Именно «этаж 7» без дроби — этажности у сделок Росреестра нет никогда. + expect(screen.getByText(/этаж 7/)).toBeTruthy(); + expect(screen.queryByText(/этаж 7\//)).toBeNull(); + }); + + it("печатает дробь, когда этажность известна — прежнее поведение цело", () => { + render(); + expect(screen.getByText(/этаж 7\/9/)).toBeTruthy(); + }); + + it("не печатает ничего, когда неизвестен сам этаж", () => { + render(); + expect(screen.queryByText(/этаж/)).toBeNull(); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx index 9dab743a..3b04d0b7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx @@ -1,10 +1,13 @@ // Report footer for the /trade-in/v2 "МЕРА Оценка" design port. // Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439): // report id / date / valid-until on the left, a decorative centre line, and the -// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`. +// МЕРА wordmark on the right. Static markup, id/date/validUntil via `data`. +// The trailing "v2.0.6" badge that used to sit next to the wordmark was a +// hardcoded literal (./ui-config `version`), independent of the real deployed +// build — removed. The real version is shown once, by `` +// (see app/v2/layout.tsx), not duplicated here. import { tokens } from "./tokens"; -import { version } from "./ui-config"; import type { Report } from "./types"; interface FooterProps { @@ -122,16 +125,6 @@ export function Footer({ data, hasEstimate }: FooterProps) { > МЕРА - - {version} -
); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx index 3aeb5484..49c7c480 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx @@ -23,8 +23,10 @@ // explanation for whichever field blocked it. import { useState } from "react"; import type { CSSProperties, FormEvent } from "react"; +import Link from "next/link"; import { tokens } from "./tokens"; import { useCreateLeadMutation } from "@/lib/trade-in-api"; +import { PRIVACY_PATH } from "@/lib/legal-copy"; const { accent, @@ -306,8 +308,14 @@ export function LeadForm({ estimateId }: LeadFormProps) { style={{ marginTop: 2, cursor: "inherit", flex: "0 0 auto" }} /> - Согласен(-на) на обработку персональных данных в соответствии с - Федеральным законом «О персональных данных» № 152-ФЗ + Согласен(-на) на обработку персональных данных в соответствии с{" "} + {/* target="_blank" — клик по ссылке не должен сбрасывать уже заполненную + форму заявки (телефон + чекбокс) в текущей вкладке. Ссылка вложена + в diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx new file mode 100644 index 00000000..a1275bf1 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/LowConfidenceBanner.tsx @@ -0,0 +1,112 @@ +"use client"; + +// LowConfidenceBanner — fix (never-block estimate). Renders ABOVE the result +// block (ResultPanel) whenever the backend flags the analog sample as thin +// (`reliability !== "ok"`) or had to relax the search just to produce a +// price at all (`relaxations.length > 0`). It never blocks the estimate — +// v2/page.tsx's `insufficient` gate (InsufficientPanel) still fires only +// when there is truly no price (`insufficient_data`, median_price_rub <= 0). +// +// Root incident this fixes: a 23.1 m² studio in Екатеринбург got +// median_price_rub=0 purely because studios were being folded into 1-room +// analogs (see ParamsPanel's initRoomsLabel/rooms=0 fix) and the UI walled +// the whole estimate behind "недостаточно данных". Product call: always show +// the number, with an honest, visible caveat instead of a hard block. + +import { tokens } from "./tokens"; +import { pluralRu } from "./mappers"; +import type { ReliabilityLevel } from "@/types/trade-in"; + +interface LowConfidenceBannerProps { + // Caller (v2/page.tsx) resolves the optional backend fields to concrete + // values (reliability ?? "ok", relaxations ?? []) and decides whether to + // mount this component at all — kept required here so an omitted prop is a + // TS error, not a silent fallback (same contract as ResultPanel/ObjectSummary). + nAnalogs: number; + reliability: ReliabilityLevel; + relaxations: string[]; +} + +// Same one-off "danger" tint pairing already used elsewhere in v2 +// (AnalyticsView's sell-time tier tiles: rgba fill + soft hex border, no +// direct token equivalent) — kept identical here instead of inventing a new +// hex; the actual label colour is the real tokens.danger semantic token. +const bannerBg = "rgba(214,90,90,.08)"; +const bannerBorder = "1px solid #e6c3c3"; + +export function LowConfidenceBanner({ + nAnalogs, + reliability, + relaxations, +}: LowConfidenceBannerProps) { + const title = + reliability === "very_low" + ? "Данные ограничены — оценка ориентировочная" + : "Мало аналогов — точность снижена"; + + // The backend's deals-cession label duplicates the prose we already render + // in the nAnalogs === 0 branch — drop it there so the caveat is stated once. + const visibleRelaxations = + nAnalogs > 0 + ? relaxations + : relaxations.filter((r) => !r.startsWith("оценка по сделкам")); + + return ( +
+
+ {title} +
+ {/* Body text stays on the high-contrast ink token (not the danger + token) — tokens.danger (#cd6868) over this pale tint fails AA for + body copy, ink2 is the codebase's established accessible-contrast + choice (see tokens.ts comment block). */} +
+ {/* n_analogs === 0 with a price on screen is NOT an empty result: it is + the deals-corridor headline (backend cedes the headline to ДКП when + the listings sample is thin). Saying «найдено 0 аналогов» there + would contradict both the shown price and the listing cards below, + which are still rendered from the thin sample. */} + {nAnalogs > 0 ? ( + <> + Найдено {nAnalogs}{" "} + {pluralRu(nAnalogs, ["аналог", "аналога", "аналогов"])} — оценка + может быть неточной. + + ) : ( + <> + Оценка построена по зарегистрированным сделкам — подходящих + объявлений рядом почти нет. + + )} + {visibleRelaxations.length > 0 && ( + <> + {" "} + Для расчёта расширили параметры поиска:{" "} + {visibleRelaxations.join(", ")}. + + )} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 9d6b75a0..3a1fa2d2 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -580,11 +580,15 @@ interface ParamsPanelProps { searchRadiusM?: number | null; } -// rooms number -> dropdown label. The design has no «Студия» option, so studio -// (0) and 1-room both map to "1"; >=5 collapses to "5+". null -> design default. +// rooms number -> dropdown label. fix (never-block estimate) — «Студия» +// (rooms=0) is its own option, no longer collapsed into "1" (that collapse +// sent rooms:1 on submit for real studios — root cause of a prod incident +// where a 23.1 m² studio got a false "недостаточно данных"). >=5 still +// collapses to "5+". null -> design default. function initRoomsLabel(rooms: number | null | undefined): string { if (rooms == null) return "2"; if (rooms >= 5) return "5+"; + if (rooms === 0) return "Студия"; if (rooms <= 1) return "1"; return String(rooms); } @@ -625,7 +629,30 @@ function initCityConfirmed(address: string | undefined): boolean { // РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its // two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides // both ("ищем строго в пределах X м"). Design dropdown was values-only. -const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"]; +// 3000/5000 added (support ticket "выбран мах радиус 2 000") — the dropdown +// used to cap at 2000 m while the backend accepts up to 5000 +// (schemas/trade_in.py Field(ge=100, le=5000)), so a user in a sparse district +// picking "2000 м" thinking it was the widest option actually CUT OFF the +// cascade's own 3/5 km fallback steps (contract #2044 — an explicit radius +// pick must never be silently widened past itself). See radiusHint below for +// the disclosure that "Авто" is the option that widens automatically. +const RADIUS_OPTIONS = [ + "Авто", + "300 м", + "500 м", + "1000 м", + "2000 м", + "3000 м", + "5000 м", +]; + +// #support — "Авто" delegates radius selection to the backend's own two-tier +// (and, since #2044, cascade) widening; any explicit pick is a hard ceiling +// the cascade may not cross. Same zero-layout-cost title= pattern as +// ResultPanel's confidence tooltip / HeroBar's location-index disclaimer — +// a native hover tooltip, no modal, no panel re-layout. +const radiusHint = + "Авто — расширяем радиус сами, если аналогов мало. Явный радиус ограничивает поиск."; // "Авто" sends no radius_m → the backend applies its two-tier default (1000 m // primary / 2000 m fallback, see RADIUS_OPTIONS comment above). The map circle @@ -919,7 +946,7 @@ export default function ParamsPanel({ onSubmit?.({ address: trimmedAddress, area_m2: areaNum, - rooms: rooms === "5+" ? 5 : Number(rooms), + rooms: rooms === "5+" ? 5 : rooms === "Студия" ? 0 : Number(rooms), floor: floor.trim() ? Number(floor) : null, total_floors: totalFloors.trim() ? Number(totalFloors) : null, year_built: year.trim() ? Number(year) : undefined, @@ -1404,6 +1431,7 @@ export default function ParamsPanel({ : undefined } aria-label="Радиус анализа" + title={radiusHint} style={{ display: "flex", alignItems: "center", diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index e2d50e8d..80895489 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -1,9 +1,14 @@ "use client"; +/** + * §1.2-дисклеймер под ценовыми карточками несёт юр-требование владельца от + * 14.08.2026 (блок 4.1): SHORT_ESTIMATE_DISCLAIMER — см. `lib/legal-copy.ts`. + */ import { Fragment } from "react"; import type { Ref } from "react"; import { tokens } from "./tokens"; import type { ResultPanelData } from "./mappers"; +import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy"; const { accent, @@ -557,20 +562,29 @@ export default function ResultPanel({ })} - {/* §1.2 честный разброс: диапазоны цен = рыночный спред, НЕ погрешность - оценки. Ставим сразу под ценовыми карточками, чтобы широкий диапазон - не читался как «неуверенность расчёта». */} + {/* §1.2 честный разброс + юр-дисклеймер (владелец, 14.08.2026, блок 4.1): + SHORT_ESTIMATE_DISCLAIMER идёт первым предложением, "разброс цен" + — вторым, один абзац (три отдельных оговорки подряд читались бы + как шум). Ставим сразу под ценовыми карточками, чтобы широкий + диапазон не читался как «неуверенность расчёта». + Компенсация высоты: этот блок стал на строку выше, чем до + SHORT_ESTIMATE_DISCLAIMER (776px средняя колонка узкая для двух + предложений в одну строку) — lineHeight/marginTop подрезаны, и + padding у "ranges + radar" box ниже уменьшен по вертикали, чтобы + не сжимать плитки "ИСТОЧНИКИ ДАННЫХ" (flex:1 ниже). Высота + артборда (1536×1024, v2/page.tsx) не менялась. */}
- Диапазоны показывают разброс цен на рынке, а не погрешность оценки. + {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, + а не погрешность оценки.
{/* ranges + radar */} @@ -581,7 +595,9 @@ export default function ResultPanel({ backdropFilter: "blur(6px)", border: `1px solid ${line2}`, borderRadius: 8, - padding: "18px 20px", + // было "18px 20px" — вертикаль подрезана на 4px сверху/снизу, см. + // комментарий к дисклеймеру выше (компенсация высоты под #4.1). + padding: "14px 20px", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24, diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx index 3c836620..35cafb7d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx @@ -419,10 +419,22 @@ export function SourcesMap({ estimate }: Props) { {/* Fix #1 — analogPoints is the top-10 display sample (only what the backend returns coords for); estimate.n_analogs is the true total used in the calc. "N из M" mirrors the deals-table "Показано N из - M" pattern so this never contradicts the market KPI band above. */} + M" pattern so this never contradicts the market KPI band above. + fix (v2 stale-tail) — n_analogs=0 with analogPoints non-empty is + the deals-fallback branch (headline built from ДКП сделки, thin + listing sample still plotted) — "N из 0" would read as a lie. + Drop the denominator and disclose the basis instead; kept short + (map caption, not a paragraph) but same tone as elsewhere in + this fix (HeroSummary/ListingsCard/LowConfidenceBanner). */} Объявлений: {analogPoints.length} - {" из "} - {estimate.n_analogs} + {estimate.n_analogs > 0 || analogPoints.length === 0 ? ( + <> + {" из "} + {estimate.n_analogs} + + ) : ( + " · по сделкам ДКП" + )} {dealPoints.length > 0 && ( <> {" · "}сделок: {dealPoints.length} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index 9bf5a19d..70674679 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -2,10 +2,13 @@ // Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port. // Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90): -// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu. +// inline SVG logo + 5 nav tabs (active underline/triangle) + user menu. // Tabs change only local UI state via onNavigate; the user dropdown owns its -// own useState. No data fetching — labels/version come from ./ui-config, the user +// own useState. No data fetching — labels come from ./ui-config, the user // identity is fed in from the page (real useMe), colours from tokens. +// The build-version badge that used to sit next to the logo (hardcoded +// "v2.0.6") was removed — the real deployed version is shown once, by +// `` (app/v2/layout.tsx), not duplicated here. import { useState } from "react"; import type { CSSProperties } from "react"; @@ -13,7 +16,7 @@ import type { CSSProperties } from "react"; import { API_BASE_URL } from "@/lib/api"; import { tokens } from "./tokens"; -import { navLabels, version } from "./ui-config"; +import { navLabels } from "./ui-config"; import { useSupportChat } from "./SupportChatContext"; // Real logged-in user identity, derived by the page from useMe() @@ -70,15 +73,29 @@ const menuItemStyle: CSSProperties = { tokens.muted), что и остальные иконки этого дропдауна. */ function UsersIcon() { return ( -
-
- {version} -
{/* Nav tabs */} @@ -412,7 +417,13 @@ export default function TopNav({ aria-disabled="true" title="Раздел «Профиль» скоро появится" > -
- -