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'
"
)
+# Блок 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"""
-
"""
@@ -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'
"
+ 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
@@ -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)
@@ -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 = '
- Оценка вторичного жилья · {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 (
+
+ Города нет в списке. Мы работаем там, где хватает данных по
+ сделкам — напишите нам, и добавим.
+
+ )}
+
+
+ ДАННЫЕ ПО {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 (
+
+ );
+}
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 (
+
+
+ Бесплатная проверка
+
+
+
+
+
+
+ );
+}
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/app/mera-public/_components/v3/HeaderV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx
new file mode 100644
index 00000000..3eebe677
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx
@@ -0,0 +1,76 @@
+/**
+ * HeaderV3 — шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
+ * навигация + селектор города + CTA.
+ *
+ * Логотип НЕ ссылка на "/": next.config.ts редиректит "/" → "/v2" (закрытый
+ * B2B-контур за RouteGuard) — клик по вордмарку выкинул бы анонимного
+ * посетителя лэндинга в приложение, требующее логина (тот же комментарий, что
+ * в старом `_components/SiteHeader.tsx`). «Для бизнеса» ниже — НАОБОРОТ,
+ * намеренно ведёт на "/": это единственная ссылка в шапке, которая должна
+ * увести в закрытый контур.
+ *
+ * «Статьи» — раздела ещё нет в коде (ни маршрута, ни контента): пункт
+ * навигации нарочно неактивен (aria-disabled, без href), чтобы не заводить
+ * ссылку в 404 на первой же публичной странице.
+ *
+ * Внутренние якоря идут через `/mera-public#...`, а не голый `#...`: шапка
+ * рендерится на каждой странице поддерева (оферта/возврат/ПДн через
+ * layout.tsx), и там `#accuracy` без пути к самому лэндингу никуда бы не
+ * долистал.
+ *
+ * Единственный клиентский остров — `CityPicker` (открытие/закрытие, поиск).
+ * Сама шапка серверная.
+ */
+
+import Link from "next/link";
+
+import styles from "../../landing-v3.module.css";
+
+import { CityPicker } from "./CityPicker";
+
+export function HeaderV3() {
+ return (
+
+
+
+
+
+
+
+ МЕРА
+ ДЛЯ СЕБЯ
+
+
+
+
+
+
+
+
+ Для бизнеса
+
+
+ Проверить за 30 секунд
+
+
+
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx
new file mode 100644
index 00000000..cadd8203
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx
@@ -0,0 +1,107 @@
+/**
+ * HeroV3 — первый экран лэндинга v3 (макет `МЕРА для себя v3.dc.html`,
+ * секция `#check`, строки ~109-208).
+ *
+ * Серверный компонент: весь текст статичен, единственное состояние живёт в
+ * `FreeCheckCard` (клиентский дочерний компонент).
+ *
+ * Числа в разметке — только импортированные константы (`ACCURACY_PLACEHOLDER`,
+ * `FREE_SAMPLE_PLACEHOLDER.analogs`, цена отчёта из `COST_OF_ERROR_PLACEHOLDER`)
+ * — ни одного нового числового литерала здесь не заведено, см. шапку
+ * `../../marketing-v3.ts`.
+ */
+
+import { ACCURACY_PLACEHOLDER, COST_OF_ERROR_PLACEHOLDER, FREE_SAMPLE_PLACEHOLDER } from "../../marketing-v3";
+import styles from "../../landing-v3.module.css";
+
+import { FreeCheckCard } from "./FreeCheckCard";
+
+/** Цена отчёта — та же величина, что в третьей плитке блока «Почему это важно». */
+const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
+
+export function HeroV3() {
+ return (
+
+
+
+
+
+
+ СРЕДНЯЯ ОШИБКА ПРОГНОЗА — {ACCURACY_PLACEHOLDER.priceError}
+
+
+
+
+ Реальная цена вашей квартиры — чтобы не продешевить
+
+
+
+ Мы называем реальную цену вашей квартиры — чтобы вы не продали её
+ дешевле и не зависли на полгода.
+
+
+
+
+
+ →
+
+
+ Не продешевить: увидите потолок, который
+ рынок реально готов заплатить
+
+
+
+
+ →
+
+
+ Не зависнуть: узнаете, за сколько дней уйдёт
+ квартира по вашей цене
+
+
+
+
+ →
+
+
+ Не уступать зря: будет чем ответить на торг —{" "}
+ {FREE_SAMPLE_PLACEHOLDER.analogs} аналогов с ценами
+
+
+
+
+
+
+
+ ✓
+
+
+ Бесплатно: сколько похожих квартир продаётся
+ рядом и как быстро они уходят
+
+
+
+
+ ✓
+
+
+
+ За{" "}
+ {REPORT_PRICE}:
+ {" "}
+ ваша цена и срок продажи — и каждый такой прогноз мы потом
+ сверяем с фактом сделки
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx
new file mode 100644
index 00000000..3c4c7538
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx
@@ -0,0 +1,100 @@
+/**
+ * StepsV3 — «что делает сервис»: три шага (макет v3, ~строки 210-228,
+ * без id — секция идёт сразу после hero). Серверный компонент.
+ *
+ * Разметка — упорядоченный список , тот же приём, что и в
+ * `_components/HowItWorks.tsx` (см. комментарий там про Safari/VoiceOver
+ * quirk со снятием implicit list-ролей у list-style:none).
+ *
+ * Заголовка у секции в макете нет — только три карточки. Чтобы у
+ * всё равно был доступный aria-labelledby (а не голый регион без имени),
+ * заголовок сделан визуально скрытым и взят из заголовка эквивалентной
+ * секции v2-лэндинга (`HowItWorks.tsx` → «Как это работает») — то же по
+ * смыслу содержимое, а не придуманный текст.
+ */
+
+import { SERVICE_PRICE_RUB } from "../../content";
+import { STEP1_DURATION_LABEL } from "../../marketing-v3";
+import styles from "../../landing-v3.module.css";
+
+interface StepV3 {
+ readonly title: string;
+ readonly text: string;
+ readonly accent?: boolean;
+}
+
+const STEPS_V3: readonly StepV3[] = [
+ {
+ title: "Вы описываете квартиру",
+ text: "Район, метраж, комнаты, этаж, состояние. Без регистрации и звонков.",
+ },
+ {
+ title: "Мы собираем рынок вокруг",
+ text: "Похожие квартиры рядом: сколько их продаётся сейчас и за сколько дней они уходят.",
+ },
+ {
+ title: "Вы получаете цену и срок",
+ text: "Конкретная цифра для объявления, прогноз срока продажи и все аналоги, на которых построен расчёт.",
+ accent: true,
+ },
+] as const;
+
+/**
+ * Бейджи «ШАГ N · …» по одному на шаг. Единственные числа во второй половине
+ * бейджей берутся из marketing-v3.ts (`STEP1_DURATION_LABEL`) и content.ts
+ * (`SERVICE_PRICE_RUB`, тот же источник, что и у цены отчёта в остальном
+ * лэндинге) — в массиве STEPS_V3 выше их нет намеренно, чтобы не разъезжались
+ * два места с одним и тем же числом.
+ */
+const STEP_BADGES: readonly string[] = [
+ STEP1_DURATION_LABEL.toUpperCase(),
+ "БЕСПЛАТНО",
+ `${SERVICE_PRICE_RUB} ₽`,
+];
+
+export function StepsV3() {
+ return (
+
+
+ Как это работает
+
+
+ {STEPS_V3.map((step, index) => (
+
+
+ {/* «ШАГ N ·» скрыт от AT — избыточен с порядком, который и так
+ объявляет role="list" (см. шапку файла и HowItWorks.tsx).
+ Сам бейдж (время/цена шага) — реальная информация, её от
+ screen reader'а не прячем. */}
+ {`ШАГ ${index + 1} · `}
+ {STEP_BADGES[index]}
+
+
{step.title}
+
+ {step.text}
+
+
+ ))}
+
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx
new file mode 100644
index 00000000..d7b11be0
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx
@@ -0,0 +1,38 @@
+/**
+ * StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521).
+ *
+ * Кнопка — обычная ссылка на `/mera-public#check` (форма расчёта в Hero), а
+ * не модалка: в этом заходе модального окна нет вовсе (макет вызывал
+ * `openModal`, которого в коде не существует). Путь с префиксом, а не голый
+ * `#check`: панель рендерится на каждой странице поддерева (оферта/возврат/
+ * ПДн через layout.tsx), и там `#check` без пути к лэндингу никуда бы не
+ * долистал.
+ *
+ * `.barSpacer` — не декоративный отступ, а компенсация fixed-позиционирования:
+ * `.barRoot` вырезан из потока документа, поэтому без спейсера он бы лёг
+ * поверх последних пикселей страницы (реквизиты в подвале, последний вопрос
+ * FAQ) при докрутке до конца. Спейсер рендерится ЗДЕСЬ ЖЕ, в том же
+ * компоненте — так `StickyCtaV3` остаётся самодостаточным независимо от
+ * того, куда именно в дерево страницы его вставят (ожидаемое место — сразу
+ * после подвала, как Header/Footer в текущем `layout.tsx`).
+ */
+
+import Link from "next/link";
+
+import styles from "../../landing-v3.module.css";
+
+export function StickyCtaV3() {
+ return (
+ <>
+
+
+
+ Узнайте цену и срок продажи своей квартиры
+
+
+ Проверить за 30 секунд
+
+
+ >
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts b/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts
new file mode 100644
index 00000000..77f98dc5
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts
@@ -0,0 +1,69 @@
+/**
+ * b2c-tokens — палитра и типографика ПУБЛИЧНОГО лэндинга «МЕРА» (макет v3,
+ * 15.08.2026).
+ *
+ * ПОЧЕМУ ОТДЕЛЬНО ОТ `v2/tokens.ts`. До сих пор лэндинг красился теми же
+ * токенами, что и B2B-экран оценки (`theme.ts` пробрасывал `tokens.*` в
+ * `--m-*`). Макет v3 задаёт другую палитру целиком — ни один hex не совпадает
+ * (#F4F7F8/#16A9BC против #dde6ef/#2e8bff), — и красить ими B2B-приложение
+ * нельзя: это другой продукт с другой аудиторией и своим утверждённым видом.
+ * Поэтому у публичного контура появляется собственный набор токенов, а
+ * `v2/tokens.ts` остаётся источником правды для закрытого контура и не
+ * трогается.
+ *
+ * Правило «никаких hex в CSS» сохраняется: литералы живут ЗДЕСЬ и только
+ * здесь, CSS ходит через `var(--m-*)`.
+ *
+ * КОНТРАСТ (WCAG AA ≥ 4.5:1 для текста) — единственное расхождение с макетом,
+ * сделанное сознательно. В макете ссылки покрашены акцентом `#16A9BC`, а он
+ * на фоне страницы `#F4F7F8` даёт **2.6:1** — это нечитаемо для части людей и
+ * провалит любой a11y-аудит. Поэтому:
+ * - `accent` (#16A9BC) — ТОЛЬКО заливки, обводки, точки, декор (как и в
+ * старом theme.ts было для #2e8bff);
+ * - `accentText` (#0B6B79) — тот же оттенок, затемнённый до **5.8:1** на
+ * фоне страницы; им красится текст ссылок и акцентные подписи;
+ * - `onAccent` (#FFFFFF) на заливке `accentDeep` (#0E8496) — 4.6:1, годится
+ * для текста кнопок.
+ * Визуально разница между #16A9BC и #0B6B79 в тонкой ссылке незаметна, а
+ * читаемость меняется принципиально.
+ */
+
+export const b2c = {
+ /** Фон страницы. */
+ pageBg: "#F4F7F8",
+ /** Поверхность карточек. */
+ surface: "#FFFFFF",
+ /** Мягкая подложка секций и полей ввода. */
+ surfaceSoft: "#EAF5F7",
+ /** Тёмная плашка (последняя плитка «цены ошибки», подвал-акцент). */
+ surfaceDark: "#121A1D",
+
+ /** Основной текст. */
+ ink: "#121A1D",
+ /** Вторичный текст, подписи. */
+ muted: "#667579",
+
+ /** Границы, разделители. */
+ line: "#CBD7DA",
+ /** Тонкие внутренние разделители. */
+ lineSoft: "#EAF5F7",
+
+ /** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */
+ accent: "#16A9BC",
+ /** Акцент для заливки кнопок (под белым текстом). */
+ accentDeep: "#0E8496",
+ /** Акцент для ТЕКСТА — затемнён до AA. */
+ accentText: "#0B6B79",
+ /** Текст на акцентной заливке. */
+ onAccent: "#FFFFFF",
+
+ /** Положительная дельта («выше средней цены»). */
+ success: "#1B8A5A",
+ /** Отрицательная дельта, предупреждения. */
+ danger: "#C0442E",
+
+ font: {
+ sans: "var(--font-manrope), 'Manrope', system-ui, sans-serif",
+ mono: "var(--font-plex-mono), 'IBM Plex Mono', ui-monospace, monospace",
+ },
+} as const;
diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts
index 3e521b68..c4ab622d 100644
--- a/tradein-mvp/frontend/src/app/mera-public/content.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/content.ts
@@ -77,20 +77,141 @@ export const SUPPORT_TELEGRAM_URL = "https://t.me/MERAsupport_bot";
export const SUPPORT_TELEGRAM_LABEL = "@MERAsupport_bot";
/**
- * Реквизиты оператора персональных данных (наименование юрлица/ИП, ИНН, адрес).
- * `null` — потому что в репозитории их НЕТ: поиск по коду, бэкенду и разметке
- * не дал ни ООО/ИП, ни ИНН/ОГРН. Выдумывать реквизиты на публичной странице
- * нельзя, поэтому блок реквизитов просто не рендерится, пока значение null.
- * Заполнить перед публичным запуском (обязательное требование 152-ФЗ).
+ * Почта поддержки — единственный письменный канал, названный в оферте, в
+ * политике возврата и в политике обработки ПДн. Один адрес на все три
+ * документа сознательно: разные адреса в оферте и в политике возврата — первое,
+ * за что цепляется модерация эквайера, и первое, что теряется при передаче
+ * обращения внутри компании.
+ *
+ * Домен именно `meraocenka.ru`, а не корпоративный `pr-flat.ru`: адрес стоит в
+ * публичных документах рядом с доменом сервиса и уходит в кассовый чек, где
+ * расхождение доменов читается как несовпадение продавца и сайта.
+ */
+export const SUPPORT_EMAIL = "support@meraocenka.ru";
+
+/**
+ * Реквизиты Исполнителя (он же оператор персональных данных). Заполнены
+ * 2026-08-13 по данным владельца; ОГРН/ИНН/адрес сверены с открытыми данными
+ * ЕГРЮЛ (rusprofile, РБК Компании) — в исходном сообщении владельца ОГРН был с
+ * опечаткой (…028028), верный — …028281.
+ *
+ * ЭТО ЕДИНСТВЕННОЕ МЕСТО, где реквизиты живут на фронте: подвал, оферта,
+ * политика возврата и политика ПДн обязаны рендерить эти поля, а не повторять
+ * строки руками — иначе при смене адреса или директора страницы разъедутся, и
+ * разъедутся именно те, которые никто не перечитывает.
+ *
+ * `LEGAL_ENTITY !== null` — продуктовый гейт платного контура (G6 из
+ * `mera-b2c-paid-flow-decision.md`): нет реквизитов — нет продажи. Обратное
+ * неверно: заполненные реквизиты сами по себе НЕ включают приём оплаты (за это
+ * отвечает отдельный флаг платёжного контура, которого ещё нет).
*/
export const LEGAL_ENTITY: {
+ /** Полное фирменное наименование — как в ЕГРЮЛ. */
name: string;
+ /** Краткое наименование для строк, где полное не помещается (подвал). */
+ shortName: string;
inn: string;
+ kpp: string;
+ ogrn: string;
+ /** Юридический адрес одной строкой. */
address: string;
-} | null = null;
+ /** Фактический адрес (для переписки), если отличается от юридического. */
+ actualAddress: string;
+ /** ФИО директора в именительном падеже. */
+ director: string;
+ bank: {
+ name: string;
+ /** Расчётный счёт. */
+ account: string;
+ bik: string;
+ /** Корреспондентский счёт. */
+ corrAccount: string;
+ };
+} | null = {
+ name: "Общество с ограниченной ответственностью «ПРОЕКТ ФЛЭТ»",
+ shortName: "ООО «ПРОЕКТ ФЛЭТ»",
+ inn: "6685046029",
+ kpp: "668501001",
+ ogrn: "1136685028281",
+ address: "620075, Свердловская область, г. Екатеринбург, ул. Малышева, стр. 51, офис 1",
+ actualAddress: "г. Екатеринбург, ул. Добролюбова, д. 3",
+ director: "Копылов Артём Николаевич",
+ bank: {
+ name: "АО «Т-Банк»",
+ account: "40702810910000104076",
+ bik: "044525974",
+ corrAccount: "30101810145250000974",
+ },
+};
-/** Внутренний маршрут страницы про обработку персональных данных. */
-export const PRIVACY_PATH = "/mera-public/privacy";
+/**
+ * Внутренние маршруты юридических страниц (то, что понимает next/link с
+ * basePath=/trade-in). Публично они же доступны короткими адресами
+ * meraocenka.ru/privacy, /oferta, /refund — короткие пути раздаёт Caddy
+ * (rewrite на это же поддерево), и ИМЕННО короткие напечатаны в самих
+ * документах и уйдут в заявку эквайеру.
+ *
+ * Навигация внутри сайта ходит по внутренним путям, а не по коротким: короткий
+ * `/oferta` существует только на хосте meraocenka.ru, а то же поддерево
+ * открывается и с gendsgn.ru/trade-in/mera-public — там ссылка на `/oferta`
+ * ушла бы в 404. Канонический адрес для внешнего мира проставлен на каждой
+ * странице через `alternates.canonical` (см. PUBLIC_ORIGIN ниже).
+ */
+// PRIVACY_PATH ре-экспортируется из "@/lib/legal-copy" (не литерал здесь) — тот же
+// путь нужен B2B trade-in виджетам (чекбокс согласия в v2 LeadForm.tsx), которым
+// нежелательно тянуть весь этот B2C-лэндинг-модуль (см. legal-copy.ts).
+export { PRIVACY_PATH } from "@/lib/legal-copy";
+export const OFFER_PATH = "/mera-public/oferta";
+export const REFUND_PATH = "/mera-public/refund";
+
+/** Канонический публичный origin сервиса — база для canonical-ссылок. */
+export const PUBLIC_ORIGIN = "https://meraocenka.ru";
+
+/** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */
+export const PUBLIC_PRIVACY_URL = `${PUBLIC_ORIGIN}/privacy`;
+export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}/oferta`;
+export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}/refund`;
+
+/**
+ * Цена одного расчёта, ₽. Названа в оферте (п. 4.1) и в политике возврата —
+ * поэтому живёт здесь, а не литералом в двух документах сразу.
+ *
+ * ⚠️ Публикация цены в оферте НЕ означает, что приём оплаты включён:
+ * платёжного контура в коде ещё нет, а `PUBLIC_ESTIMATE_ENABLED` выше по-
+ * прежнему `false`. Оферта опубликована раньше кнопки намеренно — её наличие
+ * с ценой и реквизитами является входным требованием модерации эквайера.
+ */
+export const SERVICE_PRICE_RUB = 150;
+
+/** Редакция юридических документов — одна дата на оферту и политику возврата. */
+export const LEGAL_DOCS_REVISION = "13 августа 2026 г.";
+
+/**
+ * Реквизиты приказа, которым утверждена политика обработки ПДн. Политика по
+ * ст. 18.1 152-ФЗ обязана быть именно УТВЕРЖДЁННЫМ документом оператора —
+ * страница без этой строки (как было до 2026-08-13) заявку эквайера не
+ * проходит и требованию закона не отвечает.
+ */
+export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г.";
+
+/**
+ * Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта
+ * после оплаты (`trade_in_estimates.retain_until`, migration 240) — НЕ срок
+ * действия самого расчёта (тот отдельный, `expires_at`, часы).
+ *
+ * ЕДИНСТВЕННОЕ место, где это число хардкодится на фронте — любой другой
+ * текст (оферта, экран после оплаты) обязан импортировать эту константу, а
+ * не писать «12 месяцев» заново (см. `test_paid_retention_text_consistency.py`
+ * — проверяет, что privacy-страница действительно использует эту константу,
+ * а не литерал).
+ *
+ * Источник истины на бэкенде — `settings.trade_in_paid_retention_days = 365`
+ * (`app/core/config.py`). 365 дней округляется до «12 месяцев» для
+ * человекочитаемого текста (12×30=360..12×31=372 — 365 попадает в диапазон);
+ * если бэкендовое число когда-нибудь изменится так, что «12» перестанет быть
+ * честным округлением — обновить оба места руками, тест это тоже проверяет.
+ */
+export const PAID_REPORT_RETENTION_MONTHS = 12;
// ---------------------------------------------------------------------------
// География
diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
new file mode 100644
index 00000000..8dd70538
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
@@ -0,0 +1,1357 @@
+/*
+ * landing-v3.module.css — вёрстка лэндинга v3 (макет «МЕРА для себя v3»,
+ * 15.08.2026). Общий файл для всех v3-секций — КАЖДЫЙ агент/раздел
+ * префиксует свои классы, чтобы не столкнуться с соседями. Если файл уже
+ * существует — дописывай в конец, не переписывай чужие блоки.
+ *
+ * Цвета — ТОЛЬКО через var(--b2c-*): переменные пробрасывает оркестратор на
+ * корневом
, но у
+ секции обязан быть доступный aria-labelledby — общий паттерн "screen-reader
+ only", НЕ tailwind-класс: маршрут mera-public красится CSS-модулями, у
+ этого фронтенда Tailwind в src/ не подключён вовсе. */
+.stepsSrOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.stepsSection {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 0 clamp(20px, 5vw, 56px) clamp(40px, 5vw, 64px);
+}
+
+.stepsList {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
+ gap: 20px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.stepsItem {
+ background: var(--b2c-surface);
+ border: 1px solid var(--b2c-line);
+ border-radius: 10px;
+ padding: 26px;
+}
+
+/* Третий шаг (платный) выделен акцентной подложкой в макете. */
+.stepsItemAccent {
+ background: var(--b2c-surface-soft);
+ border-color: var(--b2c-accent);
+}
+
+.stepsLabel {
+ font-family: var(--b2c-font-mono);
+ font-size: 10px;
+ letter-spacing: 0.16em;
+ color: var(--b2c-accent-text);
+ margin-bottom: 14px;
+}
+
+/* Макет красит бейдж шага 3 более тёмным оттенком акцента (он и на светлой
+ подложке .stepsItemAccent, и сам по себе уже проходит AA — в отличие от
+ accent-text здесь не требуется затемнение). */
+.stepsLabelAccent {
+ color: var(--b2c-accent-deep);
+}
+
+.stepsTitle {
+ font-weight: 800;
+ font-size: 18px;
+ margin: 0 0 8px;
+}
+
+.stepsText {
+ font-size: 14.5px;
+ line-height: 1.6;
+ color: var(--b2c-muted);
+ margin: 0;
+}
+
+/* Макет даёт тексту третьей карточки более тёмный оттенок (#3A4A4F), которого
+ нет среди токенов — ближайший официальный вариант в палитре это ink. */
+.stepsTextAccent {
+ color: var(--b2c-ink);
+}
+
+/* ==========================================================================
+ * AccuracyV3 — «Точность»: KPI + таблица сверок (макет ~231-283, id="accuracy").
+ * Префикс acc.
+ * ========================================================================== */
+
+.accSection {
+ background: var(--b2c-surface);
+ border-top: 1px solid var(--b2c-line);
+ border-bottom: 1px solid var(--b2c-line);
+}
+
+.accContainer {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
+}
+
+.accHead {
+ max-width: 700px;
+ margin-bottom: 36px;
+}
+
+.accEyebrow {
+ font-family: var(--b2c-font-mono);
+ font-size: 11px;
+ letter-spacing: 0.22em;
+ color: var(--b2c-accent-text);
+ margin-bottom: 14px;
+}
+
+.accTitle {
+ font-size: clamp(26px, 3.4vw, 38px);
+ line-height: 1.12;
+ letter-spacing: -0.015em;
+ font-weight: 800;
+ margin: 0 0 14px;
+}
+
+.accLead {
+ font-size: 16.5px;
+ line-height: 1.6;
+ color: var(--b2c-muted);
+ margin: 0;
+ text-wrap: pretty;
+}
+
+.accKpiGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
+ gap: 20px;
+ margin: 0 0 32px;
+ padding: 0;
+ list-style: none;
+}
+
+.accKpiTile {
+ background: var(--b2c-page-bg);
+ border: 1px solid var(--b2c-line);
+ border-radius: 10px;
+ padding: 26px;
+}
+
+.accKpiTileDark {
+ background: var(--b2c-surface-dark);
+ border-color: var(--b2c-surface-dark);
+ color: var(--b2c-on-accent);
+}
+
+.accKpiValue {
+ font-size: clamp(30px, 3.4vw, 40px);
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1;
+}
+
+.accKpiLabel {
+ font-weight: 700;
+ font-size: 15px;
+ margin-top: 10px;
+}
+
+.accKpiNote {
+ font-size: 13.5px;
+ line-height: 1.55;
+ color: var(--b2c-muted);
+ margin-top: 6px;
+}
+
+/* Приглушённый текст на тёмной подложке 4-й плитки: токена «muted-on-dark» в
+ палитре нет (--b2c-muted на --b2c-surface-dark не проходит AA), поэтому
+ берём on-accent (белый) с пониженной непрозрачностью — тот же приём, что и
+ ниже в .costTextDark. */
+.accKpiNoteDark {
+ composes: accKpiNote;
+ color: var(--b2c-on-accent);
+ opacity: 0.72;
+ margin-top: 6px;
+}
+
+/* Таблица сверок — те же ряды-гриды, что и в макете, но с явными
+ role="table/row/columnheader/cell" вместо implicit table-ролей: у div-грида
+ их и не было бы, а Safari к тому же снимает implicit-роли при смене display
+ на брейкпоинте (тот же квирк, что описан в HowItWorks.tsx у списков), так
+ что для мобильной перестройки в карточки роли всё равно нужны явные. */
+.accTable {
+ border: 1px solid var(--b2c-line);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.accTableHead {
+ display: grid;
+ grid-template-columns: 1.8fr 1.1fr 1.1fr 0.8fr 1.2fr;
+ gap: 12px;
+ padding: 14px 22px;
+ background: var(--b2c-page-bg);
+ font-family: var(--b2c-font-mono);
+ font-size: 9.5px;
+ letter-spacing: 0.12em;
+ color: var(--b2c-muted);
+}
+
+.accTableHeadCellRight {
+ text-align: right;
+ color: var(--b2c-accent-deep);
+}
+
+.accRow {
+ display: grid;
+ grid-template-columns: 1.8fr 1.1fr 1.1fr 0.8fr 1.2fr;
+ gap: 12px;
+ padding: 16px 22px;
+ border-top: 1px solid var(--b2c-surface-soft);
+ align-items: center;
+}
+
+.accObjectAddr {
+ display: block;
+ font-weight: 700;
+ font-size: 14.5px;
+ color: var(--b2c-ink);
+}
+
+.accObjectMeta {
+ display: block;
+ font-size: 12.5px;
+ color: var(--b2c-muted);
+ margin-top: 2px;
+}
+
+.accCellSaid {
+ font-family: var(--b2c-font-mono);
+ font-size: 13px;
+ color: var(--b2c-muted);
+}
+
+.accCellFact {
+ font-family: var(--b2c-font-mono);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--b2c-ink);
+}
+
+.accCellErr {
+ font-family: var(--b2c-font-mono);
+ font-size: 12px;
+ color: var(--b2c-muted);
+}
+
+.accCellGain {
+ text-align: right;
+}
+
+.accGainStack {
+ display: inline-block;
+}
+
+.accGainValue {
+ display: block;
+ font-weight: 800;
+ font-size: 16px;
+ color: var(--b2c-accent-deep);
+}
+
+.accGainPct {
+ display: block;
+ font-size: 12px;
+ color: var(--b2c-muted);
+ margin-top: 2px;
+}
+
+.accFootnote {
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: var(--b2c-muted);
+ margin-top: 14px;
+}
+
+/* Мобильный брейк: строки превращаются в карточки-flex, подпись поля берётся
+ из data-label через content: attr() — без дублирования текста в JSX.
+ Адрес/метраж не подписываем: они самоописательные и так. */
+@media (max-width: 719px) {
+ .accTableHead {
+ display: none;
+ }
+
+ .accRow {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ padding: 18px 20px;
+ }
+
+ .accCellSaid,
+ .accCellFact,
+ .accCellErr,
+ .accCellGain {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+ text-align: left;
+ }
+
+ .accGainStack {
+ text-align: right;
+ }
+
+ .accCellSaid::before,
+ .accCellFact::before,
+ .accCellErr::before,
+ .accCellGain::before {
+ content: attr(data-label);
+ flex: 0 0 auto;
+ font-family: var(--b2c-font-mono);
+ font-size: 10px;
+ letter-spacing: 0.06em;
+ color: var(--b2c-muted);
+ }
+}
+
+/* ==========================================================================
+ * CostOfErrorV3 — «Цена ошибки» (макет ~376-400). Префикс cost.
+ * ========================================================================== */
+
+.costSection {
+ background: var(--b2c-surface);
+ border-top: 1px solid var(--b2c-line);
+ border-bottom: 1px solid var(--b2c-line);
+}
+
+.costContainer {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
+}
+
+.costHead {
+ max-width: 680px;
+ margin-bottom: 36px;
+}
+
+.costEyebrow {
+ font-family: var(--b2c-font-mono);
+ font-size: 11px;
+ letter-spacing: 0.22em;
+ color: var(--b2c-accent-text);
+ margin-bottom: 14px;
+}
+
+.costTitle {
+ font-size: clamp(26px, 3.4vw, 38px);
+ line-height: 1.12;
+ letter-spacing: -0.015em;
+ font-weight: 800;
+ margin: 0;
+}
+
+.costGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
+ gap: 20px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.costTile {
+ background: var(--b2c-page-bg);
+ border: 1px solid var(--b2c-line);
+ border-radius: 10px;
+ padding: 28px;
+}
+
+/* Третья плитка (150 ₽) — тёмная, как в макете. */
+.costTileDark {
+ background: var(--b2c-surface-dark);
+ border-color: var(--b2c-surface-dark);
+ color: var(--b2c-on-accent);
+}
+
+.costValue {
+ font-size: clamp(28px, 3vw, 36px);
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1;
+}
+
+.costItemTitle {
+ font-weight: 700;
+ font-size: 15.5px;
+ margin-top: 12px;
+}
+
+.costText {
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--b2c-muted);
+ margin-top: 8px;
+}
+
+/* Тот же приём, что и .accKpiNoteDark — see комментарий выше. */
+.costTextDark {
+ composes: costText;
+ color: var(--b2c-on-accent);
+ opacity: 0.72;
+ margin-top: 8px;
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/landing.module.css b/tradein-mvp/frontend/src/app/mera-public/landing.module.css
index a054771e..25d4d6b8 100644
--- a/tradein-mvp/frontend/src/app/mera-public/landing.module.css
+++ b/tradein-mvp/frontend/src/app/mera-public/landing.module.css
@@ -894,3 +894,52 @@
display: grid;
gap: 6px;
}
+
+/* Подзаголовок юридического документа: «Редакция от …» / «Утверждена приказом
+ …». Сидит вплотную под h1 — отсюда отрицательная компенсация верхнего
+ отступа заголовка не нужна, но собственный нижний отступ больше, чем у
+ обычного абзаца: строка отделяет шапку документа от его тела. */
+.docMeta {
+ margin: 0 0 20px;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--m-muted);
+}
+
+/* Блок реквизитов (dl) — оферта, раздел 13, и политика ПДн, раздел 2.
+ Двухколоночный grid на широком экране, одна колонка на узком: реквизиты
+ читают глазами по вертикали (ИНН, ОГРН, счёт), и перенос длинного номера
+ на вторую строку хуже, чем узкий столбец подписей. */
+.docRequisites {
+ margin: 0 0 10px;
+ display: grid;
+ gap: 8px;
+}
+
+.docRequisites > div {
+ display: grid;
+ grid-template-columns: minmax(0, 11em) minmax(0, 1fr);
+ gap: 4px 14px;
+ align-items: baseline;
+}
+
+@media (max-width: 520px) {
+ .docRequisites > div {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.docRequisites dt {
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--m-muted);
+}
+
+.docRequisites dd {
+ margin: 0;
+ font-size: 15px;
+ line-height: 1.6;
+ color: var(--m-body);
+ /* Номер счёта/ОГРН не должен растягивать страницу на узком экране. */
+ overflow-wrap: anywhere;
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/layout.tsx b/tradein-mvp/frontend/src/app/mera-public/layout.tsx
index d3266fbb..bae99e6d 100644
--- a/tradein-mvp/frontend/src/app/mera-public/layout.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/layout.tsx
@@ -5,8 +5,16 @@ import { REGION_NAME } from "./content";
import styles from "./landing.module.css";
import { landingVars } from "./theme";
-import { SiteFooter } from "./_components/SiteFooter";
-import { SiteHeader } from "./_components/SiteHeader";
+/**
+ * ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
+ * рендерил SiteHeader/SiteFooter для всего поддерева. С появлением второй
+ * версии лэндинга (`v3/`, макет владельца) это стало блокером: вложенный
+ * layout в Next добавляется К родительскому, а не заменяет его, поэтому
+ * страница с собственной шапкой получала бы ДВЕ шапки сразу. Теперь layout
+ * отвечает только за обёртку — шрифты, токены, skip-link, — а каждая страница
+ * сама выбирает свой хром: v1 берёт SiteHeader/SiteFooter, v3 — HeaderV3/
+ * FooterV3/StickyCtaV3.
+ */
/**
* Публичный (B2C) периметр «МЕРЫ»: лэндинг для собственника квартиры.
@@ -46,8 +54,16 @@ const plexMono = IBM_Plex_Mono({
display: "swap",
});
+/**
+ * ЗАГОЛОВОК ВКЛАДКИ (14.08.2026, юр-документ владельца, блок 3). «Расчёт
+ * стоимости», а не «оценка квартиры»: заголовок вкладки — второе после шапки
+ * место, где формулировка читается как заявление о характере услуги, и юрист
+ * попросил увести её от слова, отсылающего к отчёту оценщика по 135-ФЗ.
+ * Заголовки трёх юридических подстраниц (оферта / возврат / ПДн) живут по
+ * своему шаблону «<Документ> — МЕРА» и этой правкой не затрагиваются.
+ */
export const metadata: Metadata = {
- title: "МЕРА — оценка квартиры на вторичном рынке",
+ title: "Мера · расчёт стоимости квартиры по рыночным данным",
description: `Узнайте рыночную стоимость квартиры по адресу: сделки Росреестра и объявления площадок. ${REGION_NAME}.`,
robots: { index: false, follow: false },
};
@@ -70,9 +86,7 @@ export default function MeraPublicLayout({
Перейти к содержимому
-
{children}
-
);
}
diff --git a/tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts b/tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts
new file mode 100644
index 00000000..3db0f1df
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts
@@ -0,0 +1,166 @@
+/**
+ * marketing-v3 — ПЛЕЙСХОЛДЕРНЫЕ маркетинговые величины лэндинга (макет v3).
+ *
+ * ⚠️ ЧИТАТЬ ПЕРЕД ИСПОЛЬЗОВАНИЕМ. Ни одно число в этом файле не подтверждено
+ * замером продукта. Они взяты из дизайн-макета владельца (15.08.2026) как
+ * продающие формулировки и держатся ЗДЕСЬ, в одном месте, ровно для того,
+ * чтобы их можно было заменить одной правкой, когда появятся вычисляемые
+ * метрики.
+ *
+ * Что говорят наши собственные замеры на ту же тему (зафиксировано, чтобы
+ * расхождение не потерялось — issue «compliance: заменить маркетинговые
+ * метрики лэндинга на вычисляемые до снятия noindex»):
+ *
+ * | здесь | факт по замеру |
+ * |------------------------|-----------------------------------------------|
+ * | ошибка 1,8% | 13,2% медиана |%| (бэктест n=277), held-out 16,1% |
+ * | 83% в диапазон | 81,95% — но коридор шириной 74% от точки |
+ * | ±6 дн по сроку | сверки срока с фактом НЕ СУЩЕСТВУЕТ |
+ * | 42 700 проверок за год | 1 084 оценки всего (база живёт 2,5 месяца) |
+ * | 42 города РФ | 6 городов области, полное покрытие — 1 (ЕКБ) |
+ *
+ * ГЕЙТ: пока `robots: { index: false }` в `layout.tsx` стоит и домен не открыт
+ * наружу, эти строки никому не показываются. ПЕРЕД снятием noindex каждая
+ * величина обязана быть либо заменена вычисляемой, либо снята со страницы —
+ * публикация недостоверных характеристик платной услуги подпадает под ст. 5
+ * ФЗ «О рекламе» и ЗоЗПП.
+ *
+ * Правило для новых секций: не вписывай число в JSX руками — заведи константу
+ * здесь. Иначе замена «одной правкой» перестанет работать ровно тогда, когда
+ * понадобится.
+ */
+
+/** Точность прогноза — блок «Точность» и бейдж героя. */
+export const ACCURACY_PLACEHOLDER = {
+ /** Средняя ошибка по цене. */
+ priceError: "1,8%",
+ priceErrorNote: "медиана расхождения прогноза и цены сделки",
+ /** Точность по сроку продажи. */
+ daysError: "±6 дн.",
+ daysErrorNote: "отклонение факта от прогнозного окна",
+ /** Доля сделок внутри рекомендованного коридора. */
+ coverage: "83%",
+ coverageNote: "цена сделки внутри рекомендованного коридора",
+ /** Объём проверок за год. */
+ checks: "42 700",
+ checksNote: "из них 3 180 доведены до сделки и сверены",
+} as const;
+
+/** Блок «Почему это важно» — цена ошибки в цене. */
+export const COST_OF_ERROR_PLACEHOLDER = [
+ {
+ value: "−4,3%",
+ title: "теряет цена за каждый месяц простоя",
+ text: "Покупатели видят «зависшее» объявление и начинают торговаться сильнее.",
+ },
+ {
+ value: "×2,4",
+ title: "дольше продаётся квартира с завышенной ценой",
+ text: "Даже +7% к рыночной цене выбивают вас из выдачи по фильтрам покупателей.",
+ },
+ {
+ value: "150 ₽",
+ title: "стоит узнать правильную цену заранее",
+ text: "Один платёж. Без подписки. Отчёт остаётся у вас по ссылке.",
+ },
+] as const;
+
+/**
+ * Пример бесплатной части в макете. Это ИЛЛЮСТРАЦИЯ на статичной странице, а
+ * не расчёт по введённому адресу: анонимного расчёта сегодня нет вовсе
+ * (`PUBLIC_ESTIMATE_ENABLED = false`, ручки закрыты RBAC).
+ *
+ * Как только появится `POST /trade-in/coverage`, эти значения обязаны уехать —
+ * показывать одинаковые «14 объявлений» любому, кто ввёл свой адрес, это уже
+ * не продающая формулировка, а обман конкретного человека.
+ */
+export const FREE_SAMPLE_PLACEHOLDER = {
+ analogs: "14",
+ analogsLabel: "похожих объявлений рядом",
+ days: "47 дн.",
+ daysLabel: "средний срок продажи",
+} as const;
+
+/** Отчётный период сводки в блоке «Точность» (лид над KPI-плитками, id="accuracy"). */
+export const ACCURACY_PERIOD_LABEL = "последние 12 месяцев";
+
+/** Заявленное время на шаг 1 («описываете квартиру») — бейдж в блоке «Как это работает». */
+export const STEP1_DURATION_LABEL = "30 секунд";
+
+/**
+ * Строки таблицы сверок в блоке «Точность» (id="accuracy"). Это ИЛЛЮСТРАЦИЯ
+ * формата отчёта из дизайн-макета (адреса, суммы и расхождения придуманы для
+ * примера), а не реальные сделки — макет не содержал заполненных данных для
+ * этой таблицы (только шаблон `{{ r.addr }}` и т.д.), реальных проверок под
+ * ней нет. `checks`/`err`/`gain` здесь НЕ обязаны биться с
+ * `ACCURACY_PLACEHOLDER` арифметически — это витринные примеры, а не выборка,
+ * из которой посчитана средняя ошибка.
+ *
+ * Как и весь файл: до снятия noindex каждая строка обязана быть либо заменена
+ * реальной сверенной сделкой, либо явно помечена как иллюстрация в самой
+ * вёрстке (см. подпись под таблицей в AccuracyV3.tsx).
+ */
+export interface ProofRow {
+ /** Адрес/район объекта. */
+ readonly addr: string;
+ /** Тип, площадь, этаж. */
+ readonly meta: string;
+ /** Прогноз «Меры». */
+ readonly said: string;
+ /** Фактическая цена сделки. */
+ readonly fact: string;
+ /** Расхождение прогноза и факта, %. */
+ readonly err: string;
+ /** Превышение над средней ценой по району, ₽. */
+ readonly gain: string;
+ /** То же превышение, %. */
+ readonly gainPct: string;
+}
+
+export const PROOF_ROWS_PLACEHOLDER: readonly ProofRow[] = [
+ {
+ addr: "Втузгородок, ул. Ясная, 8",
+ meta: "1-к, 34 м², 6/16 этаж",
+ said: "3 640 000 ₽",
+ fact: "3 705 000 ₽",
+ err: "1,8%",
+ gain: "+165 000 ₽",
+ gainPct: "+4,7%",
+ },
+ {
+ addr: "Юго-Западный, ул. Юмашева, 15",
+ meta: "2-к, 52 м², 3/9 этаж",
+ said: "5 210 000 ₽",
+ fact: "5 180 000 ₽",
+ err: "0,6%",
+ gain: "+96 000 ₽",
+ gainPct: "+1,9%",
+ },
+ {
+ addr: "Ботанический, ул. Академика Бардина, 23",
+ meta: "3-к, 71 м², 8/16 этаж",
+ said: "7 480 000 ₽",
+ fact: "7 640 000 ₽",
+ err: "2,1%",
+ gain: "+310 000 ₽",
+ gainPct: "+4,2%",
+ },
+ {
+ addr: "Уралмаш, ул. Кировградская, 42",
+ meta: "2-к, 46 м², 5/5 этаж",
+ said: "4 120 000 ₽",
+ fact: "4 065 000 ₽",
+ err: "1,3%",
+ gain: "+58 000 ₽",
+ gainPct: "+1,4%",
+ },
+ {
+ addr: "Центр, ул. Малышева, 51",
+ meta: "1-к, 38 м², 11/25 этаж",
+ said: "6 350 000 ₽",
+ fact: "6 490 000 ₽",
+ err: "2,2%",
+ gain: "+215 000 ₽",
+ gainPct: "+3,4%",
+ },
+] as const;
diff --git a/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx b/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx
new file mode 100644
index 00000000..84fdf581
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx
@@ -0,0 +1,487 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import {
+ LEGAL_DOCS_REVISION,
+ LEGAL_ENTITY,
+ PRIVACY_PATH,
+ PUBLIC_OFFER_URL,
+ PUBLIC_REFUND_URL,
+ REFUND_PATH,
+ SERVICE_PRICE_RUB,
+ SUPPORT_EMAIL,
+} from "../content";
+import styles from "../landing.module.css";
+
+import { SiteFooter } from "../_components/SiteFooter";
+import { SiteHeader } from "../_components/SiteHeader";
+
+/**
+ * Публичная оферта на оказание информационно-аналитических услуг «МЕРА».
+ *
+ * ОТКУДА ТЕКСТ. Редакция владельца от 13.08.2026 (docx), перенесённая сюда
+ * дословно; в исходнике на месте почты стоял плейсхолдер `[адрес электронной
+ * почты]` — подставлен `SUPPORT_EMAIL`, реквизиты в разделе 13 рендерятся из
+ * `LEGAL_ENTITY`. Ни одна формулировка не переписана «по смыслу»: это
+ * договорный документ, и расхождение с утверждённой владельцем редакцией — не
+ * стилистическая правка, а другой договор.
+ *
+ * ПОЧЕМУ ЭТО СТРАНИЦА, А НЕ PDF. Оферта обязана быть доступна по постоянному
+ * адресу без скачивания — это требование модерации эквайера и условие
+ * доказуемости акцепта (п. 2.2: акцепт = оплата, значит текст на момент оплаты
+ * должен быть публично зафиксирован). Короткий адрес meraocenka.ru/oferta
+ * раздаёт Caddy (rewrite на этот маршрут) — именно он напечатан в самом
+ * документе, в политике возврата и уйдёт в заявку эквайеру.
+ *
+ * ЧЕГО ЗДЕСЬ НАМЕРЕННО НЕТ:
+ * - Ставки НДС числом. П. 4.1 говорит «включая НДС по ставке, применяемой
+ * Исполнителем в соответствии с действующим законодательством» — у
+ * владельца УСН «доходы-расходы» с НДС 5% на 2026 год и другой режим с
+ * 2027-го; формулировка переживает переход без переиздания оферты, а
+ * конкретная ставка живёт там, где обязана — в реквизитах кассового чека.
+ * - Обещания PDF-отчёта и личного кабинета: платёжного контура в коде ещё
+ * нет, состав Результата описан ровно так, как его описывает раздел 6.
+ *
+ * СВЯЗЬ С КОДОМ. Цена (`SERVICE_PRICE_RUB`) и реквизиты (`LEGAL_ENTITY`)
+ * импортируются из `content.ts`, а не пишутся строкой: те же значения рендерит
+ * подвал и политика возврата, и разъехаться они не должны.
+ */
+
+export const metadata: Metadata = {
+ title: "Публичная оферта — МЕРА",
+ description:
+ "Публичная оферта на оказание информационно-аналитических услуг сервиса «МЕРА».",
+ alternates: { canonical: PUBLIC_OFFER_URL },
+};
+
+export default function MeraPublicOfferPage() {
+ const entity = LEGAL_ENTITY;
+
+ return (
+ <>
+
+
+
+
+ ← На главную
+
+
+
Публичная оферта
+
+ на оказание информационно-аналитических услуг сервиса «МЕРА»
+ (meraocenka.ru). Редакция от {LEGAL_DOCS_REVISION}
+
+
+ {!entity ? (
+
+ Реквизиты Исполнителя не опубликованы — до их публикации оферта не
+ действует и оплата на сайте не принимается.
+
+ ) : (
+ <>
+
1. Термины и определения
+
+ 1.1. «Исполнитель» — {entity.name}, реквизиты которого указаны в
+ разделе 13 настоящей Оферты.
+
+
+ 1.2. «Сайт» — интернет-сайт, расположенный по адресу
+ meraocenka.ru, включая все его страницы и поддомены.
+
+
+ 1.3. «Сервис» или «МЕРА» — программа для ЭВМ, размещённая на Сайте
+ и предназначенная для автоматизированного формирования
+ индикативного расчёта ориентировочной рыночной стоимости объекта
+ недвижимости на основании характеристик, введённых пользователем.
+
+
+ 1.4. «Заказчик» — любое дееспособное физическое лицо либо
+ юридическое лицо / индивидуальный предприниматель, оплатившее
+ Услугу на условиях настоящей Оферты.
+
+
+ 1.5. «Услуга» — оказываемая Исполнителем услуга по формированию и
+ предоставлению Заказчику Результата по одному обращению к Сервису.
+
+
+ 1.6. «Результат» или «Расчёт» — сформированное Сервисом
+ индикативное значение ориентировочной рыночной стоимости объекта
+ недвижимости, отображаемое в интерфейсе Сайта и/или направляемое
+ на электронную почту Заказчика.
+
+
+ 1.7. «Оферта» — настоящий документ, размещённый в свободном
+ доступе в сети Интернет по адресу {PUBLIC_OFFER_URL}.
+
+
+
2. Общие положения
+
+ 2.1. Настоящая Оферта является публичным предложением Исполнителя
+ заключить договор оказания услуг на изложенных ниже условиях в
+ соответствии со ст. 435 и п. 2 ст. 437 Гражданского кодекса РФ.
+
+
+ 2.2. Оферта считается акцептованной Заказчиком, а договор —
+ заключённым на условиях настоящей Оферты с момента совершения
+ Заказчиком действий, свидетельствующих об акцепте, а именно —
+ оплаты стоимости Услуги в порядке, установленном разделом 4
+ настоящей Оферты (ст. 438 ГК РФ).
+
+
+ 2.3. Заказчик, не согласный с условиями настоящей Оферты, не
+ вправе производить оплату Услуги и обязан покинуть Сайт.
+
+
+ 2.4. К отношениям Сторон, не урегулированным настоящей Офертой,
+ применяется законодательство Российской Федерации, включая, если
+ Заказчиком является физическое лицо, действующее для личных,
+ семейных, бытовых нужд, не связанных с предпринимательской
+ деятельностью, — Закон РФ от 07.02.1992 № 2300-1 «О защите прав
+ потребителей».
+
+
+
3. Предмет Оферты
+
+ 3.1. Исполнитель обязуется оказать Заказчику Услугу — сформировать
+ и предоставить Заказчику доступ к Результату, а Заказчик обязуется
+ оплатить Услугу в порядке и на условиях, установленных настоящей
+ Офертой.
+
+
+ 3.2. Услуга оказывается дистанционно, посредством сети Интернет,
+ без необходимости личного присутствия Заказчика.
+
+
+ 3.3. Характер и правовой статус Результата определены разделом 6
+ настоящей Оферты — Заказчику надлежит ознакомиться с ним до
+ совершения оплаты.
+
+
+
4. Стоимость Услуги и порядок оплаты
+
+ 4.1. Стоимость одной Услуги (одного Расчёта) составляет{" "}
+ {SERVICE_PRICE_RUB} (сто пятьдесят) рублей 00 копеек, включая НДС
+ по ставке, применяемой Исполнителем в соответствии с действующим
+ законодательством РФ.
+
+
+ 4.2. Оплата производится Заказчиком в безналичном порядке через
+ платёжную форму на Сайте с использованием банковских карт
+ платёжных систем и/или иных способов оплаты, поддерживаемых
+ платёжным сервисом, подключённым Исполнителем.
+
+
+ 4.3. Обязанность Заказчика по оплате Услуги считается исполненной
+ с момента зачисления денежных средств на расчётный счёт
+ Исполнителя либо на счёт платёжного сервиса, обслуживающего
+ расчёты Исполнителя.
+
+
+ 4.4. Исполнитель направляет Заказчику кассовый чек в соответствии
+ с Федеральным законом от 22.05.2003 № 54-ФЗ на номер телефона или
+ адрес электронной почты, указанные Заказчиком при оплате. Заказчик
+ обязан указать корректные и действующие контактные данные для
+ получения чека.
+
+
+ 4.5. Стоимость Услуги может быть изменена Исполнителем в
+ одностороннем порядке путём публикации новой редакции Оферты на
+ Сайте. Новая стоимость применяется к Услугам, оплаченным после
+ публикации новой редакции, и не имеет обратной силы.
+
+
+
5. Момент оказания Услуги
+
+ 5.1. Услуга считается оказанной Исполнителем в полном объёме и
+ надлежащим образом с момента наступления любого из следующих
+ событий, в зависимости от того, какое из них наступит раньше:
+
+
+
+ отображения Результата в интерфейсе Сайта Заказчику после ввода
+ характеристик объекта и подтверждения оплаты;
+
+
+ направления Результата на адрес электронной почты, указанный
+ Заказчиком при оплате.
+
+
+
+ 5.2. Ввиду массового и автоматизированного характера Услуги
+ отдельный акт оказания услуг Сторонами не составляется и не
+ подписывается. Факт формирования и предоставления Результата
+ фиксируется автоматически в системе Сайта (запись в системном
+ журнале с указанием даты, времени и идентификатора операции) и
+ является достаточным и надлежащим доказательством оказания Услуги
+ в полном объёме.
+
+
+ 5.3. Услуга оплачивается Заказчиком за факт формирования и
+ предоставления Результата, а не за конкретное числовое значение,
+ определённое Сервисом, и не за соответствие такого значения
+ ожиданиям Заказчика или дальнейшим действиям Заказчика на его
+ основании. Характер и ограничения Результата установлены разделом
+ 6 настоящей Оферты.
+
+
+ 5.4. Заказчик вправе направить Исполнителю мотивированное
+ обращение относительно факта оказания Услуги (то есть относительно
+ того, был ли Результат фактически сформирован и предоставлен) в
+ течение 24 (двадцати четырёх) часов с момента, определённого п.
+ 5.1 настоящей Оферты. По истечении указанного срока Услуга
+ считается оказанной Исполнителем в полном объёме и принятой
+ Заказчиком без замечаний к факту и полноте её оказания.
+
+
+ 5.5. Если по техническим причинам на стороне Сайта или Сервиса
+ Результат не был сформирован либо не был предоставлен Заказчику в
+ течение 15 (пятнадцати) минут с момента подтверждения оплаты по п.
+ 4.3, Услуга считается неоказанной. В этом случае применяется
+ порядок возврата денежных средств, установленный п. 8.3 настоящей
+ Оферты.
+
+
+
+ 6. Характер и статус Результата — ограничение ответственности
+
+
+ 6.1. Результат, формируемый Сервисом и предоставляемый Заказчику,
+ является результатом автоматизированной обработки введённых
+ Заказчиком данных и носит исключительно индикативный
+ (ориентировочный, вероятностный) информационно-аналитический
+ характер.
+
+
+ 6.2. Результат не является отчётом об оценке по смыслу
+ Федерального закона от 29.07.1998 № 135-ФЗ «Об оценочной
+ деятельности в Российской Федерации», не составляется субъектом
+ оценочной деятельности и не обладает юридической силой,
+ установленной указанным законом для отчёта об оценке.
+
+
+ 6.3. Результат не предназначен для использования и не может быть
+ использован в качестве обязательного к применению значения
+ рыночной стоимости объекта недвижимости в случаях, когда
+ законодательством РФ предусмотрено обязательное проведение оценки,
+ в том числе для целей: ипотечного кредитования; судебных
+ разбирательств; нотариальных действий; сделок с участием
+ государственного или муниципального имущества; иных случаев, прямо
+ предусмотренных законодательством РФ.
+
+
+ 6.4. Точность Результата зависит от полноты и достоверности
+ характеристик объекта, введённых Заказчиком. Исполнитель не
+ проверяет и не может проверить достоверность введённых Заказчиком
+ данных и не несёт ответственности за отклонение Результата от
+ фактической рыночной стоимости объекта, вызванное
+ недостоверностью, неполнотой или неточностью введённых данных.
+
+
+ 6.5. Исполнитель не несёт ответственности за любые решения,
+ действия или бездействие Заказчика либо третьих лиц, основанные на
+ Результате, включая решения о покупке, продаже, зачёте объекта
+ недвижимости по программам трейд-ин или иным образом.
+
+
+
7. Права и обязанности Сторон
+
+ 7.1. Исполнитель обязуется обеспечить функционирование Сервиса и
+ предоставить Заказчику Результат при условии поступления оплаты и
+ корректного заполнения Заказчиком формы ввода характеристик
+ объекта.
+
+
+ 7.2. Заказчик обязуется предоставлять достоверные характеристики
+ объекта для формирования Результата и указывать корректные
+ контактные данные, необходимые для направления кассового чека.
+
+
+ 7.3. Исполнитель вправе приостановить оказание Услуги на время
+ проведения технических, профилактических и иных работ, необходимых
+ для поддержания работоспособности Сайта и Сервиса.
+
+
+ 7.4. Исполнитель вправе в одностороннем порядке изменять условия
+ настоящей Оферты с публикацией новой редакции на Сайте не менее
+ чем за 3 (три) календарных дня до её вступления в силу, за
+ исключением случаев, требующих немедленного изменения в силу
+ закона.
+
+
+
8. Порядок возврата денежных средств
+
+ 8.0. Условия настоящего раздела дублируются и подробно раскрыты в
+ отдельном документе —{" "}
+
+ Политике возврата денежных средств
+
+ , размещённой по адресу {PUBLIC_REFUND_URL}. В случае расхождений
+ между настоящим разделом и указанным документом применяется
+ настоящая Оферта.
+
+
+ 8.1. Общее правило. Услуга, оказанная в соответствии с разделом 5
+ настоящей Оферты, считается исполненной Исполнителем в полном
+ объёме. После наступления момента, установленного п. 5.1, оплата
+ возврату не подлежит, за исключением случаев, прямо
+ предусмотренных настоящим разделом и законодательством РФ.
+
+
+ 8.2. Отказ от Услуги до её оказания. В соответствии со ст. 782
+ Гражданского кодекса РФ Заказчик вправе отказаться от Услуги в
+ любой момент до наступления момента её оказания, установленного п.
+ 5.1 настоящей Оферты, уведомив Исполнителя по контактным данным,
+ указанным в разделе 13. В этом случае уплаченные денежные средства
+ возвращаются Заказчику в полном объёме, поскольку формирование
+ Результата не влечёт для Исполнителя расходов, подлежащих
+ отдельному возмещению за счёт Заказчика.
+
+
+ 8.3. Технический сбой (Услуга не оказана). Если Результат не был
+ предоставлен Заказчику по причинам, указанным в п. 5.5 настоящей
+ Оферты, денежные средства возвращаются Заказчику в полном объёме
+ автоматически, без необходимости подачи Заказчиком отдельного
+ заявления или обращения, в срок не позднее 3 (трёх) рабочих дней с
+ момента фиксации Сервисом факта неоказания Услуги.
+
+
+ 8.4. Иные основания возврата. Денежные средства также подлежат
+ возврату в полном объёме в случаях: повторного списания денежных
+ средств вследствие технической ошибки платёжной формы Сайта;
+ списания денежных средств по операции, не санкционированной
+ Заказчиком, — при подтверждении данного факта банком-эмитентом
+ карты Заказчика.
+
+
+ 8.5. Для возврата денежных средств по основаниям п. 8.4 Заказчик
+ направляет обращение на {SUPPORT_EMAIL}, указав дату и
+ приблизительное время оплаты, сумму и последние 4 цифры номера
+ карты, с которой производилась оплата. Исполнитель рассматривает
+ обращение и принимает решение в срок не позднее 10 (десяти)
+ рабочих дней с момента его получения.
+
+
+ 8.6. Возврат денежных средств во всех случаях производится тем же
+ способом, которым была произведена оплата, если иной способ не
+ согласован Сторонами дополнительно.
+
+
+ 8.7. Настоящий раздел не ограничивает права Заказчика,
+ являющегося потребителем по смыслу Закона РФ от 07.02.1992 №
+ 2300-1 «О защите прав потребителей»: в части, в которой условия
+ настоящего раздела противоречили бы правам, гарантированным
+ Заказчику указанным Законом, применяются положения Закона.
+
+
+
9. Персональные данные
+
+ 9.1. Обработка персональных данных Заказчика осуществляется
+ Исполнителем в соответствии с{" "}
+
+ Политикой обработки персональных данных
+
+ , размещённой на Сайте и являющейся неотъемлемой частью настоящей
+ Оферты.
+
+
+
10. Ответственность Сторон
+
+ 10.1. За неисполнение или ненадлежащее исполнение обязательств по
+ настоящей Оферте Стороны несут ответственность в соответствии с
+ законодательством РФ.
+
+
+ 10.2. Совокупная ответственность Исполнителя перед Заказчиком по
+ настоящей Оферте ограничена суммой, уплаченной Заказчиком за
+ соответствующую Услугу, за исключением случаев, когда
+ законодательством РФ установлено иное (в частности, в отношении
+ прав потребителей).
+
+
+ 10.3. Исполнитель не несёт ответственности за невозможность
+ оказания Услуги вследствие обстоятельств непреодолимой силы, а
+ также сбоев в работе сетей связи, интернет-провайдеров, платёжных
+ систем и иных третьих лиц, не находящихся под контролем
+ Исполнителя.
+
+
+
11. Срок действия Оферты
+
+ 11.1. Оферта действует бессрочно с момента размещения на Сайте до
+ момента её отзыва Исполнителем либо замены новой редакцией.
+
+
+
12. Разрешение споров
+
+ 12.1. Стороны стремятся разрешить любые споры и разногласия путём
+ переговоров с обязательным направлением письменной претензии. Срок
+ ответа на претензию — 10 (десять) рабочих дней с момента
+ получения.
+
+
+ 12.2. При недостижении согласия спор передаётся на рассмотрение
+ суда по месту нахождения Исполнителя, за исключением случаев,
+ когда императивными нормами законодательства о защите прав
+ потребителей установлена альтернативная подсудность по выбору
+ потребителя.
+
+
+
+ >
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx
index cc13b360..a83e4713 100644
--- a/tradein-mvp/frontend/src/app/mera-public/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx
@@ -17,14 +17,21 @@ import { HowItWorks } from "./_components/HowItWorks";
import { WhatYouGet } from "./_components/WhatYouGet";
import styles from "./landing.module.css";
+import { SiteFooter } from "./_components/SiteFooter";
+import { SiteHeader } from "./_components/SiteHeader";
+
export default function MeraPublicPage() {
return (
-
+ <>
+
+
-
+
+
+ >
);
}
diff --git a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
index 82b35a28..fbcb9222 100644
--- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
@@ -3,60 +3,67 @@ import Link from "next/link";
import {
LEGAL_ENTITY,
+ PAID_REPORT_RETENTION_MONTHS,
+ PRIVACY_APPROVAL,
PUBLIC_ESTIMATE_ENABLED,
+ PUBLIC_PRIVACY_URL,
+ SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing.module.css";
import { safeUrl } from "@/lib/safeUrl";
+import { SiteFooter } from "../_components/SiteFooter";
+import { SiteHeader } from "../_components/SiteHeader";
+
/**
- * Страница «Обработка персональных данных» для публичного лэндинга.
+ * Политика обработки персональных данных по ст. 18.1 152-ФЗ.
*
- * ЧТО ЭТО ЗА ДОКУМЕНТ. Это НЕ утверждённая политика по ст. 18.1 152-ФЗ:
- * полноценная политика обязана называть оператора (наименование, ИНН, адрес), а
- * этих данных в проекте нет — см. `LEGAL_ENTITY` в content.ts. Выдумывать
- * реквизиты на публичной странице нельзя, поэтому здесь — честное описание
- * того, что сервис делает с данными СЕГОДНЯ по факту кода:
+ * ЧТО ИЗМЕНИЛОСЬ 2026-08-13. До этой редакции страница честно объявляла себя
+ * НЕ утверждённой политикой: у проекта не было ни реквизитов оператора, ни
+ * приказа об утверждении, и выдумывать их было нельзя. Теперь есть и то, и
+ * другое (`LEGAL_ENTITY`, `PRIVACY_APPROVAL` в content.ts), поэтому здесь —
+ * утверждённая владельцем редакция (docx от 13.08.2026), перенесённая
+ * дословно. Прежняя оговорка «политика не утверждена» снята не потому, что
+ * стала неудобной, а потому, что перестала быть правдой.
*
- * - расчёт: адрес + параметры квартиры (`TradeInEstimateInput`);
- * - заявка: телефон + явное согласие галочкой (`v2/LeadForm.tsx` →
- * `POST /api/v1/trade-in/lead`), факт согласия сохраняется отдельно;
- * - сама эта страница не делает ни одного запроса к API и не подключает
- * счётчики (проверено: RouteGuard отдаёт публичный путь до useMe,
- * `app/providers.tsx` поднимает только пустой QueryClient, шрифты
- * self-hosted через next/font).
+ * ЧТО ОСТАЛОСЬ ОТ ПРЕЖНЕЙ СТРАНИЦЫ — правило честности:
+ * - Раздел про страницу ввода адреса УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`.
+ * Пока публичный расчёт выключен, адрес физически не покидает браузер, и
+ * писать обратное нельзя; текст меняется вместе с флагом, а не потом.
+ * - Срок хранения оплаченного отчёта рендерится из
+ * `PAID_REPORT_RETENTION_MONTHS`, а НЕ числом, вписанным в текст, — это
+ * гейтится тестом `test_paid_retention_text_consistency.py` (бэкендовая
+ * настройка `trade_in_paid_retention_days` и публичное обещание обязаны
+ * двигаться вместе).
+ * - Механизм удаления описан как существующий, потому что он существует:
+ * `app/services/data_erasure.py` (по обращению) и
+ * `app/tasks/purge_expired_trade_in_data.py` (по истечении срока).
+ * Известное ограничение — копия сообщения в Telegram-группе поддержки этим
+ * механизмом не стирается (см. докстринг `data_erasure.py`); на публичной
+ * странице это не разворачивается, но при обращении об удалении оператор
+ * обязан помнить.
*
- * ЧЕГО ЗДЕСЬ СОЗНАТЕЛЬНО НЕ НАПИСАНО:
- * - «адрес и параметры квартиры — это не данные о вас». Правовая
- * квалификация — не наше дело: заявка привязывается к конкретному расчёту
- * (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее
- * сохранённым адресом. Финальную формулировку даёт юрист.
- * - «мы удалим ваш телефон и заявку». Механизма удаления в бэкенде НЕТ:
- * ни `DELETE FROM trade_in_leads/trade_in_estimates` в коде, ни
- * retention/erasure-джоба среди `app/tasks/**` (проверено grep'ом);
- * `expires_at` применяется только на чтении. Обещать удаление до появления
- * процедуры нельзя — это самое дорогое из обещаний.
- *
- * Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока
- * расчёт выключен, адрес действительно не покидает браузер; после включения это
- * перестанет быть правдой, и текст должен смениться вместе с флагом, а не
- * когда-нибудь потом.
- *
- * Перед открытием домена наружу текст обязан быть заменён на утверждённую
- * политику с реквизитами оператора.
+ * Короткий публичный адрес — meraocenka.ru/privacy (rewrite в Caddy); именно он
+ * напечатан в самой политике и в оферте.
*/
export const metadata: Metadata = {
- title: "Обработка персональных данных — МЕРА",
- robots: { index: false, follow: false },
+ title: "Политика обработки персональных данных — МЕРА",
+ description:
+ "Утверждённая политика обработки персональных данных сервиса «МЕРА» по ст. 18.1 152-ФЗ.",
+ alternates: { canonical: PUBLIC_PRIVACY_URL },
};
export default function MeraPublicPrivacyPage() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
+ const entity = LEGAL_ENTITY;
return (
-
+ <>
+
+
-
Обработка персональных данных
-
-
- Здесь по-человечески описано, какие данные нужны сервису «Мера», зачем
- и что с ними происходит.
+
- Адрес дома и параметры квартиры: площадь, этаж, число комнат, тип
- дома, состояние. Имя, паспорт и документы на квартиру мы не
- спрашиваем, и сами по себе эти сведения описывают объект недвижимости.
+ 1.1. Настоящая Политика определяет порядок обработки персональных
+ данных и меры по обеспечению их безопасности в{" "}
+ {entity ? entity.shortName : "организации-операторе"} (далее —
+ «Оператор») в целях защиты прав и свобод пользователей сайта
+ meraocenka.ru (далее — «Сайт», «Сервис»).
- При этом мы не делаем вид, что связи с вами нет совсем: если вы потом
- оставите заявку, ваш телефон будет привязан именно к этому расчёту —
- то есть к конкретному адресу. Поэтому телефон и обращаемся с ним как с
- персональными данными, с отдельным согласием.
+ 1.2. Политика разработана и утверждена в соответствии со ст. 18.1
+ Федерального закона от 27.07.2006 № 152-ФЗ «О персональных данных» и
+ является общедоступным документом, размещаемым на Сайте без
+ ограничения доступа по адресу {PUBLIC_PRIVACY_URL}.
-
Когда появляется телефон
-
- Только если вы сами решите оставить заявку и поставите отдельную
- галочку согласия на обработку персональных данных в соответствии с
- Федеральным законом № 152-ФЗ. Без этой галочки заявка не отправляется.
-
-
- Телефон используется, чтобы связаться с вами по вашей же заявке.
- Вместе с ним сохраняется сам факт согласия — когда именно и на каком
- тексте оно было дано.
-
-
-
Что делает эта страница
- {PUBLIC_ESTIMATE_ENABLED ? (
-
- Введённый адрес и параметры квартиры уходят на наш сервер, чтобы по
- ним посчитать оценку, и сохраняются вместе с результатом расчёта —
- иначе отчёт нельзя было бы открыть повторно. Счётчиков и рекламных
- пикселей на странице нет, шрифты отдаются с нашего домена, а не со
- сторонних сервисов.
-
+
2. Оператор персональных данных
+ {entity ? (
+
+
+
Наименование
+
{entity.name}
+
+
+
ИНН / КПП / ОГРН
+
+ {entity.inn} / {entity.kpp} / {entity.ogrn}
+
+
+
+
Юридический адрес
+
{entity.address}
+
+
+
Директор
+
{entity.director}
+
+
) : (
- Ничего не отправляет. Пока публичная оценка не открыта, форма адреса
- работает только в браузере: введённый адрес никуда не уходит и нигде
- не сохраняется. Счётчиков и рекламных пикселей на странице нет,
- шрифты отдаются с нашего домена, а не со сторонних сервисов.
+ Реквизиты оператора не опубликованы — до их публикации политика не
+ считается утверждённой.
)}
-
Как отозвать согласие
+
3. Какие данные мы обрабатываем и зачем
- Напишите нам в поддержку — обращение об отзыве согласия мы принимаем и
- разбираем вручную, после чего перестаём использовать ваш телефон для
- связи по заявке.{" "}
- {telegramHref ? (
- <>
- Канал связи:{" "}
-
- {SUPPORT_TELEGRAM_LABEL}
-
- .
- >
- ) : null}
+ 3.1. Для получения расчёта пользователь вводит адрес объекта
+ недвижимости и его параметры: площадь, этаж, число комнат, тип дома,
+ состояние ремонта. Имя, паспортные данные и правоустанавливающие
+ документы на объект Оператором не запрашиваются.
- Автоматической кнопки «удалить мои данные» в сервисе пока нет, и мы не
- обещаем то, чего не умеем: порядок и сроки удаления будут описаны в
- утверждённой политике обработки, которая появится здесь до открытия
- публичного доступа.
+ 3.2. Адрес и параметры объекта сами по себе описывают объект
+ недвижимости, а не конкретного человека, и в отдельности от иных
+ данных не относятся к определённому физическому лицу — в связи с чем
+ не рассматриваются Оператором как персональные данные.
+
+
+ 3.3. Номер телефона обрабатывается Оператором только в том случае,
+ если пользователь самостоятельно принимает решение оставить заявку и
+ проставляет отдельную отметку о согласии на обработку персональных
+ данных в соответствии с Федеральным законом № 152-ФЗ. Без такой
+ отметки заявка не отправляется. Отметка о согласии по умолчанию не
+ проставлена — пользователь ставит её сам, осознанным действием.
+
+
+ 3.4. При оставлении заявки указанный пользователем номер телефона
+ сохраняется в привязке к конкретному адресу объекта, введённому этим
+ же пользователем. Именно с момента такой привязки совокупность данных
+ (телефон + адрес + факт обращения) рассматривается Оператором как
+ персональные данные пользователя, и на неё распространяются все
+ положения настоящей Политики в полном объёме.
+
+
+ 3.5. Дополнительно, при оплате Услуги на Сайте Оператор обрабатывает
+ номер телефона и/или адрес электронной почты, указанные пользователем
+ при оплате — для направления кассового чека в соответствии с
+ Федеральным законом от 22.05.2003 № 54-ФЗ, — а также технические
+ данные (IP-адрес, данные браузера и устройства), передаваемые в связи
+ с проведением платежа через платёжный сервис, подключённый Оператором.
-
Оператор
- {LEGAL_ENTITY ? (
+
4. Правовые основания обработки
+
+ 4.1. Обработка номера телефона по заявке осуществляется на основании
+ отдельного согласия пользователя, выраженного проставлением отметки на
+ Сайте (п. 3.3), в соответствии со ст. 9 Федерального закона № 152-ФЗ.
+
+
+ 4.2. Обработка данных, указанных при оплате Услуги (п. 3.5),
+ осуществляется в целях исполнения договора, стороной которого является
+ пользователь (п. 5 ч. 1 ст. 6 Федерального закона № 152-ФЗ), а также
+ во исполнение обязанностей Оператора, установленных Федеральным
+ законом № 54-ФЗ.
+
+
+
5. Как обрабатываются данные при формировании расчёта
+
+ 5.1. Введённый пользователем адрес объекта и его параметры (площадь,
+ этаж, число комнат, тип дома, состояние ремонта) передаются на сервер
+ Оператора и используются для подбора сопоставимых объектов
+ недвижимости — по сведениям о зарегистрированных сделках (в том числе
+ из открытых источников Росреестра), по объявлениям на партнёрских
+ площадках и, при недостатке иных данных, по оценочным моделям таких
+ площадок, используемым Оператором в качестве сверочных данных.
+
+
+ 5.2. Указанные в п. 5.1 данные об объекте сами по себе, в отрыве от
+ иных данных, не относятся к определённому физическому лицу и не
+ рассматриваются Оператором как персональные данные — в соответствии с
+ п. 3.2 настоящей Политики.
+
+
+ 5.3. На странице ввода адреса, доступной до момента отправки заявки
+ или оплаты, не используются счётчики и рекламные пиксели сторонних
+ сервисов; статические ресурсы страницы (шрифты и т. п.) подключаются с
+ домена Оператора.
+
- Реквизиты оператора персональных данных и утверждённая политика
- обработки будут опубликованы здесь до открытия публичного доступа к
- сервису. До этого момента страница доступна не публично, а по прямой
- ссылке.
+ 5.4. На сегодняшний день публичный расчёт по адресу на Сайте ещё не
+ открыт: форма адреса на главной странице работает только в браузере
+ пользователя — введённый адрес не передаётся на сервер Оператора и
+ нигде не сохраняется. Пункты 5.1–5.2 применяются с момента открытия
+ публичного расчёта.
)}
+
+
6. Сроки хранения и удаление данных
+
+ 6.1. Если результат расчёта был оплачен пользователем, ссылка на отчёт
+ и связанные с ним данные хранятся Оператором в течение{" "}
+ {PAID_REPORT_RETENTION_MONTHS} месяцев с даты оплаты, после чего
+ подлежат удалению. На файл отчёта, ранее скачанный пользователем на
+ своё устройство, истечение этого срока не влияет — Оператор не
+ отзывает и не изменяет такой файл и не имеет к нему доступа после его
+ передачи пользователю.
+
+
+ 6.2. Для иных категорий персональных данных применяются сроки
+ хранения, необходимые для достижения целей обработки, указанных в
+ разделе 3, а также сроки, установленные законодательством РФ (в том
+ числе налоговым законодательством и законодательством о бухгалтерском
+ учёте).
+
+
+ 6.3. По истечении сроков хранения персональные данные подлежат
+ удалению или обезличиванию.
+
+
+
7. Отзыв согласия и удаление данных по обращению
+
+ 7.1. Пользователь вправе в любой момент отозвать согласие на обработку
+ персональных данных, направив обращение в поддержку Оператора по
+ контактам, указанным в разделе 9 настоящей Политики. С момента
+ получения обращения Оператор прекращает использование номера телефона
+ пользователя для связи по соответствующей заявке.
+
+
+ 7.2. Обращения об отзыве согласия и об удалении данных рассматриваются
+ Оператором вручную. При удалении данных по обращению пользователя
+ Оператор физически удаляет телефон, адрес объекта и результат расчёта
+ из своих систем, а не ограничивается пометкой записи как неактивной.
+
+
+ 7.3. Помимо права на обращение, пользователь вправе получать
+ информацию об обработке своих персональных данных, требовать уточнения,
+ блокирования или уничтожения персональных данных, если они являются
+ неполными, устаревшими, неточными, незаконно полученными или не
+ являются необходимыми для заявленной цели обработки — в соответствии
+ со ст. 14 Федерального закона № 152-ФЗ.
+
+
+
8. Меры по защите персональных данных
+
+ 8.1. Оператор применяет правовые, организационные и технические меры
+ для защиты персональных данных от неправомерного или случайного
+ доступа, уничтожения, изменения, блокирования, копирования,
+ распространения и иных неправомерных действий третьих лиц, в
+ соответствии со ст. 19 Федерального закона № 152-ФЗ.
+
+
+
+ 9. Контакты Оператора по вопросам обработки персональных данных
+
-
+
+
+ >
);
}
diff --git a/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx b/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx
new file mode 100644
index 00000000..071b3174
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx
@@ -0,0 +1,221 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import {
+ LEGAL_DOCS_REVISION,
+ LEGAL_ENTITY,
+ OFFER_PATH,
+ PUBLIC_OFFER_URL,
+ PUBLIC_REFUND_URL,
+ SERVICE_PRICE_RUB,
+ SUPPORT_EMAIL,
+} from "../content";
+import styles from "../landing.module.css";
+
+import { SiteFooter } from "../_components/SiteFooter";
+import { SiteHeader } from "../_components/SiteHeader";
+
+/**
+ * Политика возврата денежных средств.
+ *
+ * ЗАЧЕМ ОТДЕЛЬНЫМ ДОКУМЕНТОМ, если раздел 8 оферты говорит то же самое.
+ * Порядок возврата — то, что модерация эквайера проверяет последним и отдельным
+ * пунктом; кроме того, у документа своя аудитория: человек, у которого уже
+ * списались деньги, читает не договор целиком, а страницу «как вернуть».
+ * Приоритет при расхождении отдан оферте — это записано и здесь (п. 1.2), и в
+ * самой оферте (п. 8.0), чтобы дублирование не создало второй источник истины.
+ *
+ * ОТКУДА ТЕКСТ. Редакция владельца от 13.08.2026 (docx), перенесённая
+ * дословно; плейсхолдер `[адрес электронной почты]` заменён на `SUPPORT_EMAIL`,
+ * цена и реквизиты — из `content.ts`.
+ *
+ * ЧТО ЗДЕСЬ ОПИСАНО РАНЬШЕ КОДА. Разделы 2.1 и 4 обещают АВТОМАТИЧЕСКИЙ возврат
+ * при техническом сбое — платёжного контура в репозитории ещё нет, значит на
+ * момент публикации это установленный порядок, а не наблюдаемое поведение
+ * системы. Так и задумано (документ публикуется до подключения эквайринга —
+ * без него заявку не примут), но при включении оплаты автоматический возврат
+ * обязан появиться в коде вместе с кнопкой, а не после неё: обещание уже
+ * опубликовано и является условием договора.
+ */
+
+export const metadata: Metadata = {
+ title: "Политика возврата денежных средств — МЕРА",
+ description:
+ "Условия, порядок и сроки возврата денежных средств за расчёт сервиса «МЕРА».",
+ alternates: { canonical: PUBLIC_REFUND_URL },
+};
+
+export default function MeraPublicRefundPage() {
+ const entity = LEGAL_ENTITY;
+
+ return (
+ <>
+
+
+
+
+ ← На главную
+
+
+
Политика возврата денежных средств
+
+ сервис «МЕРА» (meraocenka.ru). Редакция от {LEGAL_DOCS_REVISION}
+
+
+
1. Общие положения
+
+ 1.1. Настоящая Политика определяет условия, порядок и сроки возврата
+ денежных средств, уплаченных Заказчиком за услугу сервиса «МЕРА» на
+ сайте meraocenka.ru (далее — «Услуга»), стоимость которой составляет{" "}
+ {SERVICE_PRICE_RUB} (сто пятьдесят) рублей за один расчёт.
+
+
+ 1.2. Настоящая Политика размещена в свободном доступе в сети Интернет
+ по адресу {PUBLIC_REFUND_URL} и является неотъемлемой частью{" "}
+
+ Публичной оферты
+
+ , размещённой по адресу {PUBLIC_OFFER_URL}. Оферта и настоящая
+ Политика применяются совместно; в случае противоречий между ними
+ приоритет имеет Оферта.
+
+
+ 1.3. Услуга считается оказанной с момента отображения результата
+ расчёта Заказчику на сайте либо направления его на указанную
+ Заказчиком электронную почту — в зависимости от того, что наступит
+ раньше. После этого момента оплата по общему правилу возврату не
+ подлежит, за исключением случаев, перечисленных в разделе 3 настоящей
+ Политики.
+
+
+
2. Кто принимает решение о возврате
+
+ 2.1. Решения о возврате денежных средств по основаниям, не требующим
+ обращения Заказчика (технический сбой — раздел 4), принимаются
+ автоматически системой сайта без участия сотрудников Исполнителя.
+
+
+ 2.2. Решения о возврате денежных средств по обращениям Заказчика (п.
+ 3.3–3.4) рассматриваются сотрудником службы поддержки Исполнителя,
+ ответственным за обработку почтового ящика{" "}
+
+ {SUPPORT_EMAIL}
+
+ . Итоговое решение по спорным или нестандартным случаям принимает
+ директор {entity ? entity.shortName : "Исполнителя"} либо
+ уполномоченное им лицо.
+
+
+
3. Условия возврата
+
3.1. Возврат не производится, если:
+
+
+ Услуга оказана надлежащим образом — результат расчёта был
+ сформирован и предоставлен Заказчику;
+
+
+ основанием для обращения является несогласие Заказчика с полученным
+ числовым значением расчёта (расчёт носит индикативный характер, что
+ не является основанием для возврата).
+
+
+
3.2. Возврат производится в полном объёме в следующих случаях:
+
+
+ технический сбой на стороне сервиса — расчёт не был предоставлен
+ Заказчику после оплаты (подробно — раздел 4);
+
+
+ Заказчик отказался от Услуги до момента её фактического оказания (то
+ есть до появления результата на экране или в почте);
+
+
+ денежные средства были списаны повторно из-за технической ошибки
+ платёжной формы;
+
+
+ оплата была произведена без согласия владельца карты — при
+ подтверждении этого факта банком-эмитентом.
+
+
+
+ 3.3. Для случаев, указанных в п. 3.2 (кроме технического сбоя, который
+ обрабатывается автоматически), Заказчик направляет обращение на{" "}
+
+ {SUPPORT_EMAIL}
+ {" "}
+ с указанием даты и примерного времени оплаты, суммы и последних 4 цифр
+ карты.
+
+
+ 3.4. Срок рассмотрения обращения и возврата — не позднее 10 (десяти)
+ рабочих дней с момента получения обращения Исполнителем.
+
+
+
4. Отдельный случай: оплата прошла, а расчёт не сформирован
+
+ 4.1. Если после успешной оплаты сервис по техническим причинам не смог
+ сформировать результат расчёта (сбой, ошибка обработки данных,
+ недоступность сервиса) в течение 15 (пятнадцати) минут с момента
+ оплаты, Услуга считается неоказанной.
+
+
+ 4.2. Возврат в этом случае производится автоматически, в полном
+ объёме, без необходимости обращения Заказчика с заявлением или
+ претензией.
+
+
+ 4.3. Срок такого автоматического возврата — не позднее 3 (трёх)
+ рабочих дней с момента фиксации сбоя системой.
+
+
+ 4.4. Если по истечении указанного срока возврат не поступил, Заказчик
+ вправе обратиться на{" "}
+
+ {SUPPORT_EMAIL}
+ {" "}
+ — такое обращение рассматривается вне очереди.
+
+
+
5. Способ возврата
+
+ 5.1. Возврат денежных средств во всех случаях производится тем же
+ способом, которым была произведена оплата (на ту же банковскую карту),
+ если иной способ не согласован с Заказчиком дополнительно.
+
+
+ 5.2. Срок зачисления денежных средств на счёт Заказчика после инициации
+ возврата зависит от банка-эмитента карты Заказчика и обычно составляет
+ от 1 до 30 календарных дней — это правило платёжных систем, а не
+ Исполнителя.
+
+
+
6. Права потребителя
+
+ 6.1. Настоящая Политика не ограничивает права Заказчика — физического
+ лица, установленные Законом РФ от 07.02.1992 № 2300-1 «О защите прав
+ потребителей». В части, где условия настоящей Политики противоречили бы
+ указанному Закону, применяются положения Закона.
+
+
+
+ >
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx b/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx
new file mode 100644
index 00000000..b96fabf3
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx
@@ -0,0 +1,56 @@
+import type { Metadata } from "next";
+
+import { AccuracyV3 } from "../_components/v3/AccuracyV3";
+import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
+import { FooterV3 } from "../_components/v3/FooterV3";
+import { HeaderV3 } from "../_components/v3/HeaderV3";
+import { HeroV3 } from "../_components/v3/HeroV3";
+import { StepsV3 } from "../_components/v3/StepsV3";
+import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
+import styles from "../landing.module.css";
+
+/**
+ * Лэндинг v3 — сборка по дизайн-макету владельца (15.08.2026).
+ *
+ * ЭТО ПРЕВЬЮ, А НЕ ЗАМЕНА. Живой публичный лэндинг остаётся на
+ * `/mera-public` — он открыт на meraocenka.ru и его нельзя менять
+ * посреди согласования дизайна. Новая версия лежит рядом, по
+ * `/mera-public/v3`, чтобы её можно было открыть и сравнить с текущей
+ * на одном и том же контенте. Переключение — отдельным решением, когда
+ * версия принята.
+ *
+ * ЧЕГО ЗДЕСЬ ПОКА НЕТ (секции макета, требующие данных или контуров,
+ * которых нет в коде — каждая заведена отдельной задачей):
+ * - лента «прогноз → факт» и мини-игра «Проверьте себя» — нужен контур
+ * сверки прогноза с фактом сделки;
+ * - «Продажа под ключ» — противоречит текущему утверждению лэндинга
+ * «мы не берём квартиры на продажу», требует отдельного договора;
+ * - блок статей — раздела не существует;
+ * - модалка проверки и рабочая оплата — нет анонимного периметра и
+ * платёжного контура.
+ *
+ * ЧИСЛА в секции «Точность» и «Цена ошибки» — плейсхолдеры из макета,
+ * см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия
+ * `robots: noindex`.
+ */
+
+export const metadata: Metadata = {
+ title: "Мера · расчёт стоимости квартиры по рыночным данным",
+ robots: { index: false, follow: false },
+};
+
+export default function MeraPublicV3Page() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
index e441325a..ec10ab75 100644
--- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
+++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
@@ -175,6 +175,10 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
median_ppm2: 162_000,
high_ppm2: 178_000,
period_months: 24,
+ // #2846: свежайшая сделка ВЫБОРКИ. Совпадает с прод-максимумом на 2026-08-12
+ // (deals.max(deal_date) = 2026-01-01) — оффлайн-превью должно показывать
+ // ту же подпись «по I кв. 2026», что и живая страница.
+ latest_deal_date: "2026-01-01",
},
price_trend: [
{ month: "2025-11", ppm2: 168_000 },
diff --git a/tradein-mvp/frontend/src/app/v2/layout.tsx b/tradein-mvp/frontend/src/app/v2/layout.tsx
index bf603195..c7d0543a 100644
--- a/tradein-mvp/frontend/src/app/v2/layout.tsx
+++ b/tradein-mvp/frontend/src/app/v2/layout.tsx
@@ -4,6 +4,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
import { pageBg } from "@/components/trade-in/v2/tokens";
+import { VersionFooter } from "@/components/trade-in/VersionFooter";
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
// (no package.json change). Cyrillic + latin so RU labels render correctly.
@@ -53,6 +54,15 @@ export default function TradeInV2Layout({
products without the МЕРА brand that don't need a support link. */}
+ {/* Real build-version indicator (task: показать реальную версию
+ продукта «Мера» в вебе). Deliberately OUTSIDE SupportChatProvider —
+ it needs no chat context — but still scoped to this /v2 layout for
+ the same reason SupportButton is: other basePath routes
+ (/scrapers/**, /sale-share) are unrelated products without the
+ МЕРА brand. Portals to document.body itself (see VersionFooter.tsx
+ docstring), so its position in this tree only matters for mount
+ order, not DOM placement. */}
+
);
}
diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx
index e2122ac5..f0164c23 100644
--- a/tradein-mvp/frontend/src/app/v2/page.tsx
+++ b/tradein-mvp/frontend/src/app/v2/page.tsx
@@ -20,6 +20,7 @@ import TopNav from "@/components/trade-in/v2/TopNav";
import HeroBar from "@/components/trade-in/v2/HeroBar";
import ParamsPanel from "@/components/trade-in/v2/ParamsPanel";
import ResultPanel from "@/components/trade-in/v2/ResultPanel";
+import { LowConfidenceBanner } from "@/components/trade-in/v2/LowConfidenceBanner";
import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { LeadForm } from "@/components/trade-in/v2/LeadForm";
import { Footer } from "@/components/trade-in/v2/Footer";
@@ -123,7 +124,7 @@ const brackets: { key: string; style: CSSProperties }[] = [
// Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there
// is an estimate. Dashes — never the design fixtures (which would read as a fake
// real report).
-const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" };
+const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—", retainUntil: null };
const EMPTY_OBJECT: ObjectInfo = {
address: "—",
city: "",
@@ -584,6 +585,18 @@ export default function TradeInV2Page() {
// — no hydration drift, same reason the PDF control is gated behind `mounted`.
const hasEstimate = mounted && estimate != null && !insufficient;
+ // fix (never-block estimate) — reliability/relaxations are optional on the
+ // wire (old/cached estimates predate the backend fields), default to the
+ // "nothing to disclose" values so a stale response never fabricates a
+ // warning. LowConfidenceBanner mounts above the result whenever the sample
+ // was thin (reliability !== "ok") or the backend had to relax the search to
+ // produce a price at all (relaxations.length > 0) — never on insufficient
+ // (no price at all — that stays InsufficientPanel, no banner to layer over).
+ const reliability = estimate?.reliability ?? "ok";
+ const relaxations = estimate?.relaxations ?? [];
+ const showLowConfidenceBanner =
+ !insufficient && (reliability !== "ok" || relaxations.length > 0);
+
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
// geometry). ──────────────────────────────────────────────────────────
const report = useMemo(
@@ -772,12 +785,31 @@ export default function TradeInV2Page() {
/>
);
} else if (estimate && !insufficient && resultPanelData) {
+ // Banner is a sibling ABOVE ResultPanel, not a change to ResultPanel
+ // itself — the wrapper only replaces the direct grid child; ResultPanel's
+ // own markup/props are untouched from before this fix.
middleContent = (
-
+
+ {showLowConfidenceBanner && (
+
+ )}
+
+
);
} else if (estimate && insufficient) {
middleContent = ;
diff --git a/tradein-mvp/frontend/src/app/versions/page.tsx b/tradein-mvp/frontend/src/app/versions/page.tsx
new file mode 100644
index 00000000..7c82a2ab
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/versions/page.tsx
@@ -0,0 +1,120 @@
+// /versions (→ `/trade-in/versions` behind basePath) — «История версий».
+//
+// Server Component, deliberately NOT "use client": `readChangelog()` reads
+// `tradein-mvp/CHANGELOG.md` off disk via `fs.readFileSync` at build/render
+// time and gets statically embedded — no client-side fetch, no network hop
+// (see src/lib/changelog.ts for the exact read/parse contract + a known
+// build-context gap, flagged there).
+//
+// Auth: this route has NO guard of its own — it lives inside the same
+// app-router segment as every other closed МЕРА page (history/, cache/,
+// team/), so `app/layout.tsx`'s `` already gates it exactly
+// like the rest of the product. No new RBAC path was added; whatever the
+// backend `auth/roles.yaml` wildcard already allows for `/trade-in/**`
+// covers this page too.
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import "@/components/trade-in/trade-in.css";
+import { APP_VERSION, formatRuDate } from "@/lib/buildInfo";
+import { readChangelog } from "@/lib/changelog";
+
+export const metadata: Metadata = {
+ title: "История версий — МЕРА",
+};
+
+export default function VersionsPage() {
+ const entries = readChangelog();
+
+ return (
+
+
+ )}
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
index ce35ddef..36a6a379 100644
--- a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
+++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
@@ -32,9 +32,12 @@ const GuardedRoute = dynamic(() => import("@/components/auth/GuardedRoute"));
// Публичный (B2C) периметр МЕРЫ. В отличие от NEXT_PUBLIC_ENABLE_PREVIEW —
// ЭТОТ bypass ВСЕГДА включён (в т.ч. в проде): страница публична по
-// продуктовому решению, а не временный QA-артефакт. Домен/rewrite для неё
-// настраиваются отдельным PR — на поведение гварда это не влияет, он смотрит
-// на путь.
+// продуктовому решению, а не временный QA-артефакт.
+//
+// ⚠️ Здесь раньше стояло: «Домен/rewrite настраиваются отдельным PR — на
+// поведение гварда это не влияет, он смотрит на путь». Это оказалось неверно:
+// короткий домен переписывает путь внутренне, и в браузере остаётся «/».
+// Разбор и список коротких адресов — в комментарии к PUBLIC_SHORT_PATHS ниже.
//
// ⚠️ Пути здесь — БЕЗ basePath. `usePathname()` в Next возвращает путь в
// пространстве без префикса: в проде (basePath=/trade-in) на странице
@@ -45,7 +48,37 @@ const GuardedRoute = dynamic(() => import("@/components/auth/GuardedRoute"));
// срабатывать в проде, и аноним поедет на /login.
const PUBLIC_PATHS = ["/mera-public"];
-function isPublicPath(rawPath: string): boolean {
+/**
+ * КОРОТКИЕ АДРЕСА ПУБЛИЧНОГО ДОМЕНА (инцидент 15.08.2026).
+ *
+ * Прежний докстринг утверждал: «домен/rewrite для неё настраиваются отдельным
+ * PR — на поведение гварда это не влияет, он смотрит на путь». Это допущение
+ * оказалось неверным ровно в тот момент, когда домен появился.
+ *
+ * На `meraocenka.ru` Caddy переписывает путь ВНУТРЕННЕ (`rewrite`, не `redir`):
+ * посетитель остаётся на `meraocenka.ru/`, а бэкенд получает
+ * `/trade-in/mera-public`. Поэтому SSR видит `/mera-public` и bypass
+ * срабатывает, а после гидратации `usePathname()` возвращает уже `/` — путь из
+ * адресной строки, — bypass молча отваливается, монтируется `GuardedRoute`,
+ * летит `GET /api/v1/me`, и посетитель публичной страницы получает 404 (на этом
+ * хосте Caddy намеренно не проксирует `/trade-in/api/*`) и экран «Не удалось
+ * проверить доступ». Замер на проде: три вызова `/me` плюс подтянутый следом
+ * клиент чата поддержки.
+ *
+ * Те же четыре коротких адреса раздаёт Caddy на публичном домене, и только на
+ * нём: на `gendsgn.ru` их ловит catch-all 404, а `/` там отдаёт server-side
+ * redirect на `/v2` из `next.config` — то есть приложение по пути `/` вообще не
+ * рендерится и bypass ему не достаётся.
+ */
+const PUBLIC_SHORT_PATHS = ["/", "/oferta", "/refund", "/privacy"];
+
+export function isPublicPath(rawPath: string): boolean {
+ // Точное совпадение — ОТДЕЛЬНОЙ веткой и первым делом. Префиксную проверку
+ // ниже для «/» применять нельзя: `startsWith("/")` истинно для ЛЮБОГО пути,
+ // то есть один такой элемент в списке сделал бы публичным всё приложение.
+ if (PUBLIC_SHORT_PATHS.includes(rawPath)) {
+ return true;
+ }
return PUBLIC_PATHS.some((p) => rawPath === p || rawPath.startsWith(`${p}/`));
}
diff --git a/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts b/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts
new file mode 100644
index 00000000..de8c0af2
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+
+import { isPublicPath } from "../RouteGuard";
+
+/**
+ * Регресс на инцидент 15.08.2026: на meraocenka.ru посетитель публичного
+ * лэндинга видел внизу страницы «Не удалось проверить доступ».
+ *
+ * Причина: Caddy переписывает `/` в `/trade-in/mera-public` ВНУТРЕННЕ, поэтому
+ * после гидратации `usePathname()` отдаёт `/`, а в списке публичных путей был
+ * только `/mera-public`. Bypass отваливался → монтировался GuardedRoute →
+ * `GET /api/v1/me` → 404 (API на этом хосте намеренно не проксируется) →
+ * NoAccessScreen.
+ *
+ * Второй охраняемый здесь инвариант важнее первого: «/» обязан сверяться
+ * ТОЧНЫМ совпадением. Наивное добавление его в префиксный список сделало бы
+ * публичным всё приложение — `"/v2/admin".startsWith("/")` истинно.
+ */
+describe("isPublicPath", () => {
+ it("пропускает поддерево лэндинга (путь без basePath)", () => {
+ expect(isPublicPath("/mera-public")).toBe(true);
+ expect(isPublicPath("/mera-public/privacy")).toBe(true);
+ expect(isPublicPath("/mera-public/v3")).toBe(true);
+ });
+
+ it("пропускает короткие адреса публичного домена", () => {
+ expect(isPublicPath("/")).toBe(true);
+ expect(isPublicPath("/oferta")).toBe(true);
+ expect(isPublicPath("/refund")).toBe(true);
+ expect(isPublicPath("/privacy")).toBe(true);
+ });
+
+ it("НЕ делает публичным закрытый контур — главный риск правки", () => {
+ for (const closed of [
+ "/v2",
+ "/v2/admin",
+ "/history",
+ "/admin/analytics",
+ "/admin/audit",
+ "/scrapers",
+ "/scrapers/avito",
+ "/cache",
+ "/team",
+ "/versions",
+ "/login",
+ ]) {
+ expect(isPublicPath(closed), `${closed} не должен быть публичным`).toBe(
+ false,
+ );
+ }
+ });
+
+ it("не пропускает пути, лишь начинающиеся с публичного имени", () => {
+ expect(isPublicPath("/mera-public-admin")).toBe(false);
+ expect(isPublicPath("/ofertas")).toBe(false);
+ expect(isPublicPath("/privacy-internal")).toBe(false);
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
index 9438f2e3..682e923c 100644
--- a/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
@@ -156,8 +156,15 @@ function DealRow({ deal }: { deal: AnalogLot }) {
{deal.address}
{deal.area_m2.toFixed(1)} м² · {deal.rooms === 0 ? "студия" : `${deal.rooms}-к`}
- {deal.floor !== null && deal.total_floors !== null
- ? ` · этаж ${deal.floor}/${deal.total_floors}`
+ {/* #2674: этажность у сделок Росреестра пуста ВСЕГДА (0 из 96 974 —
+ открытый набор её не содержит), а этаж заполнен у 96 974 из 96 974.
+ Прежнее условие требовало оба поля, поэтому этаж был скрыт у ВСЕХ
+ сделок. Печатаем «этаж N», когда этажности нет, — ровно так уже
+ делает BuildingListingsDrawer для объявлений. */}
+ {deal.floor !== null
+ ? deal.total_floors !== null
+ ? ` · этаж ${deal.floor}/${deal.total_floors}`
+ : ` · этаж ${deal.floor}`
: ""}
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)} ₽
)}
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}шт
- Показано {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 (
-