Compare commits

...
Sign in to create a new pull request.

100 commits

Author SHA1 Message Date
5f3579b8c2 feat(tradein/auth): восстановить доступ praktika + безлимит через данные вместо хардкода (#2544)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m2s
Deploy Trade-In / build-backend (push) Successful in 5m35s
Deploy Trade-In / deploy (push) Successful in 1m14s
2026-07-26 23:29:53 +00:00
036ff84eaa fix(tradein/tgbot): ограничение частоты на отправителя в мосте поддержки (#2543)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 5m1s
Deploy Trade-In / build-backend (push) Successful in 5m6s
Deploy Trade-In / deploy (push) Successful in 1m2s
2026-07-26 23:16:57 +00:00
ca46411346 fix(tradein/tests): тесты авторизации проверяют настоящий guard + реальный рендер PDF (#2541)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 5m2s
Deploy Trade-In / build-backend (push) Successful in 5m38s
Deploy Trade-In / deploy (push) Successful in 1m4s
2026-07-26 23:04:43 +00:00
bcb903cfa2 fix(tradein/devops): проверка всего стека после деплоя + запас на остановку (#2540)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 35s
Deploy Trade-In / build-frontend (push) Successful in 37s
Deploy Trade-In / test (push) Successful in 4m55s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 1m12s
2026-07-26 22:57:41 +00:00
a450aed71b fix(tradein/sql): цена в подписи дедупликации «доли квартир дома в продаже» (#2539)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m53s
Deploy Trade-In / build-backend (push) Successful in 28s
Deploy Trade-In / deploy (push) Successful in 1m6s
2026-07-26 22:50:39 +00:00
76016fd469 fix(tradein/security): утечка ключа прокси, аудит действий админа, отличимость неудачного входа, IDOR в заявке (#2536)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m52s
Deploy Trade-In / build-backend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 1m8s
2026-07-26 22:42:15 +00:00
35f5c3426b fix(tradein/scrapers): детекция дрейфа разметки вместо тихой пустоты (#2535)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m50s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m34s
2026-07-26 22:33:54 +00:00
dce2cd2040 fix(tradein): откат транзакции в backfill + неотрицательный счётчик квот (#2538)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m52s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 6m14s
2026-07-26 22:21:08 +00:00
6e132f8986 fix(tradein/estimator): граница номера дома в подборе аналогов «тот же дом» (#2534)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m53s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 7m39s
2026-07-26 22:07:00 +00:00
cf3d0eb4f6 fix(tradein/v2): доступность — ориентир главного содержимого и заголовок первого уровня (#2542)
All checks were successful
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m4s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-07-26 22:03:43 +00:00
d6b9891fe2 fix(tradein/v2): заглушка карты в шапке заезжала под адресную карточку (#2537)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m5s
Deploy Trade-In / deploy (push) Successful in 49s
2026-07-26 22:00:23 +00:00
580be61914 fix(tradein/location): заменить сломанный коэффициент локации на калиброванный индекс (#2531)
All checks were successful
Deploy / build-backend (push) Successful in 6m30s
Deploy / build-worker (push) Successful in 6m44s
Deploy / changes (push) Successful in 11s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 2m26s
Deploy Trade-In / deploy (push) Successful in 3m10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m6s
Deploy Trade-In / test (push) Successful in 5m5s
2026-07-26 21:48:15 +00:00
a0647a53a9 feat(tradein/ui): окно чата поддержки на сайте вместо ухода в Telegram (#2533)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m11s
Deploy Trade-In / deploy (push) Successful in 51s
2026-07-26 20:52:30 +00:00
ca1015bd4e feat(tradein/support): веб-чат поддержки — серверная часть поверх Telegram-моста (#2532)
All checks were successful
Deploy Trade-In / test (push) Successful in 4m59s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 1m17s
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-07-26 20:43:47 +00:00
c9ba1b15ba fix(tradein/v2): вернуть адресную карточку поверх карты и координаты при restore-by-id (#2530)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m12s
Deploy Trade-In / deploy (push) Successful in 48s
2026-07-26 20:26:08 +00:00
06bf8dfada fix(tradein/ui): мини-карта адреса вместо статичного фото чужого дома в шапке (#2529)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m18s
Deploy Trade-In / deploy (push) Successful in 51s
2026-07-26 20:15:54 +00:00
e0b63cc637 fix(tradein/v2): настоящая карта Leaflet+OSM вместо декоративного псевдо-SVG (#2528)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m21s
Deploy Trade-In / deploy (push) Successful in 52s
2026-07-26 20:02:28 +00:00
d56103219a feat(tradein/ui): вход в поддержку с сайта — плавающая кнопка + живая «Помощь» (#2527)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / deploy (push) Successful in 56s
2026-07-16 19:11:57 +00:00
bot-backend
7fd40f6ea5 feat(tradein/ui): вход в поддержку с сайта — плавающая кнопка + живая «Помощь»
All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m15s
Support-мост (#2526) уже работает на проде, но попасть в бота с сайта было
неоткуда — клиент о нём просто не узнавал.

Два входа, оба ведут в @MERAsupport_bot:
- SupportButton — плавающая кнопка в правом нижнем углу, на всех экранах /v2
- «Помощь» в меню TopNav — была задизейблена с «скоро появится», теперь живая

Закрывает перекос пошире: LeadForm монтируется только при hasEstimate
(v2/page.tsx:1064), т.е. до расчёта оценки у клиента не было НИ ОДНОГО способа
связаться. Единственная другая ссылка на связь во всём UI — NoAccessScreen.tsx:84,
и та лишь на экране «доступ закончился».

Решения:
- Это внешняя ссылка, а не чат-виджет: бот живёт в Telegram, поэтому ни API,
  ни состояния, ни сокетов не нужно.
- URL — module-константа, не NEXT_PUBLIC_*: env инлайнятся на build-time, так что
  переменная не дала бы гибкости рантайма, только лишний build-arg. Источник
  правды один — SUPPORT_BOT_URL в SupportButton.tsx, TopNav импортирует его.
- Монтаж в v2/layout, не в глобальный app/layout: последний накрывает ещё админку
  /scrapers/** и /sale-share — другой продукт без бренда МЕРА.
- createPortal в document.body: /v2 рисует HUD внутри артборда 1536×1024 с
  transform: scale(), а position:fixed внутри трансформированного предка
  позиционируется относительно него, а не вьюпорта — без портала кнопку унесло бы
  вместе с HUD.
- z-index 25 — осознанно НИЖЕ всех оверлеев v2 (SectionOverlay 29/30,
  LocationDrawer 40/41, TopNav 50, UserMenu 200): при открытой модалке кнопка
  должна уходить под неё, а не воровать клики и таб-порядок.
- Стиль только через tokens.ts (файл прямо запрещает хардкодить hex);
  готового <Button> в проекте нет, следуем инлайн-паттерну LeadForm.

Профиль/Настройки в том же меню остаются задизейбленными — страниц под них нет.

A11y: aria-label, тач-таргет 44px, focus-visible ring, инлайновый SVG с aria-hidden.

Проверка: tsc --noEmit чисто, lint чисто (3 warning'а pre-existing, не в этих
файлах), npm run build успешно (/v2 49.4 kB, все 15 страниц prerender).
Визуальная проверка — на проде после деплоя: локально RouteGuard без бекенда
возвращает null (RouteGuard.tsx:46) и рендер не доходит до кнопки.
2026-07-16 22:09:26 +03:00
fcfc777baa feat(tradein/tgbot): Telegram support-мост @MERAsupport_bot (#2526)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Successful in 44s
Deploy Trade-In / build-browser (push) Successful in 3m18s
Deploy Trade-In / test (push) Successful in 5m2s
Deploy Trade-In / build-backend (push) Successful in 2m16s
Deploy Trade-In / deploy (push) Successful in 1m59s
2026-07-16 18:26:56 +00:00
bot-backend
b579fa4ced feat(tradein/tgbot): Telegram support-мост @MERAsupport_bot
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage
в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот
доставляет ответ клиенту. Полный лог переписки в Postgres.

Отдельный контейнер на long-polling, а не webhook в tradein-backend:
не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и
маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API.
Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage.

Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален
в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате,
поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора
не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор.

Безопасность (найдено ревью, воспроизведено эмпирически):
- токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет;
  утекал в GlitchTip через locals стек-фреймов (include_local_variables
  по умолчанию True) и через span data HttpxIntegration. Закрыто
  include_local_variables=False + regex-редактор в before_send (обе формы:
  /bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub.
- httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в
  docker logs каждые 30с. Приглушён до WARNING.

Надёжность:
- kill-switch при пустом токене — idle-блокировка, не exit(0): при
  restart: unless-stopped выход с любым кодом даёт рестарт-луп.
  unless-stopped выбран сознательно — только он гарантирует автозапуск
  после ребута VPS.
- stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше,
  чем докрутится long-poll (30с) и отработает drain (100с).
- сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset:
  иначе сессия в failed-transaction не давала сохранить offset, апдейт
  переигрывался и зеркалился в топик по кругу.

152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента
одним DELETE. Ретенция — follow-up.

Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS,
без них воркер штатно висит в idle. Порядок — в DEPLOY.md.

Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked,
throttle-окно шапки, redaction токена во всех формах event).
2026-07-16 16:58:53 +03:00
698576e77b fix(tradein/scraper): per-city SERP location for oblast city-sweep (#12) (#2525)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 5m1s
Deploy Trade-In / deploy (push) Successful in 6m47s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-07-13 21:41:07 +00:00
67b50bf713 feat(tradein/ui): admin links to audit + analytics in UserMenu (#2524)
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m54s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 55s
Admin-only «Аудит доступа» → /admin/audit and «Активность» → /admin/analytics links in the UserMenu dropdown. Navigates via API_BASE_URL prefix (Topbar convention, basePath-safe). Pilot menu unchanged.
2026-07-13 21:16:29 +00:00
c61ed5a96e fix(tradein/avito): catch sidecar timeout/transport errors in SERP fetch → graceful banned, not crash (#2523)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m50s
Deploy Trade-In / deploy (push) Successful in 7m48s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-07-13 20:59:39 +00:00
e61c2debe5 fix(tradein/audit): exclude /api/v1/admin/* from api_request events (#2522)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Admin dashboard traffic to /api/v1/admin/* no longer logged as api_request (would pollute behavior analytics); login events kept for IP audit.
2026-07-13 20:55:16 +00:00
c904fbf94e feat(tradein/admin): audit + activity dashboards (Feature 2/3 UI) (#2521)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m56s
Deploy Trade-In / deploy (push) Successful in 46s
Admin-only /admin/audit (accounts × IP/device × searches) and /admin/analytics (KPIs, daily chart, top searches/paths/accounts) over the user_events read API. Gated like /scrapers; 403 → NoAccessScreen.
2026-07-13 20:45:34 +00:00
9fd6396fbc feat(tradein/admin): audit + analytics read API over user_events (#2520)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 57s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 30s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 10s
Admin-only read API: GET /admin/audit/accounts, /admin/audit/accounts/{username}, /admin/analytics over user_events. Read-only, empty-safe, RBAC via central gate.
2026-07-13 20:26:58 +00:00
8b7af92ee3 fix(tradein/estimator): displayed analog cards match n_analogs + cross-source dedup (#2518)
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Failing after 54s
Deploy Trade-In / build-backend (push) Successful in 1m9s
2026-07-13 20:19:20 +00:00
2c5b30f8c9 fix(tradein/auth): RouteGuard session/error screens with retry CTA (#2516)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-07-13 20:19:12 +00:00
d530602fbd fix(tradein/quota): per-user quota override + no-burn on empty estimate (#2517)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-07-13 20:18:42 +00:00
32037af6de feat(tradein/audit): request-audit middleware + estimate_request logging → user_events (#2519)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 51s
RequestAuditMiddleware logs api_request + deduped login per authenticated /api/* request; estimate() logs estimate_request with address. Fire-and-forget, never blocks/breaks the request. Writes to user_events (Feature 2/3 capture).
2026-07-13 20:09:22 +00:00
5647c2b40e feat(tradein/db): user_events table — unified login/IP audit + behavior event log (#2515)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 52s
Append-only user_events table (event_type, username, ip_address inet, user_agent, path, method, estimate_id, payload jsonb, created_at) + 4 indexes. Foundation for login/IP audit + behavior analytics. Resurrects dropped audit_log design.
2026-07-13 19:47:26 +00:00
749c96cd0c feat(tradein/ui): Telegram feedback button in Topbar (env-gated) (#2514)
All checks were successful
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Successful in 2m1s
Deploy Trade-In / deploy (push) Successful in 47s
Env-gated Telegram feedback button next to UserMenu; renders only when NEXT_PUBLIC_FEEDBACK_TG_URL is set. safeUrl() sanitized, outside RBAC nav.
2026-07-13 19:44:00 +00:00
987dce11bc fix(tradein/pdf): МЕРА branding + 4-page report, no empty pages (#2513)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 52s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 54s
2026-07-13 18:04:07 +00:00
b5477362f0 fix(tradein/auth): restore brusnika (user2) pilot access (#2512)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 1m7s
Deploy / changes (push) Successful in 15s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 22s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-worker (push) Successful in 53s
Deploy / build-backend (push) Successful in 54s
Deploy Trade-In / test (push) Successful in 1m20s
Deploy Trade-In / build-backend (push) Successful in 43s
Deploy / deploy (push) Successful in 1m56s
2026-07-13 15:33:36 +00:00
60c21ae2a5 feat(tradein/lead): persist 152-ФЗ proof-of-consent (LOW audit R2 #9) (#2511)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Successful in 58s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / build-frontend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m20s
2026-07-12 22:13:36 +00:00
3bd0873230 fix(tradein/scheduler): re-enable deactivate_stale_domklik now that domclick sweeps run (LOW audit R2 #10) (#2510)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m1s
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
2026-07-12 22:12:12 +00:00
5e73b35206 fix(tradein/location-coef): re-center coefficient band so a strong central location ~= 1.0 (LOW audit R2 #7a) (#2509)
Some checks failed
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 22:12:10 +00:00
69ca746673 fix(tradein/v2): location coefficient informational + price-history clarity (LOW audit R2 #7c/#8) (#2508)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 22:11:08 +00:00
2cd24ecfbc fix(tradein/scrapers): clamp implausible listing/publish dates (LOW audit R2 #6) (#2507)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 22:11:06 +00:00
92371e7d9e fix(tradein/pdf): scale offer-page rent by room count (LOW audit R2 #5) (#2506)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 22:11:04 +00:00
7fa52bb3ad fix(tradein/estimator): honest repair-note + ratio_basis label; doc EKB-calibrated constants (LOW audit R2 #1-4) (#2505)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 22:11:03 +00:00
348be445e3 fix(tradein/geocoder): gate EKB-local tiers for non-EKB oblast addresses (#11)
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Successful in 51s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 5m52s
Non-EKB Свердловская-область addresses no longer snap into Екатеринбург.

Root cause: geocode()/suggest() ran EKB-only local tiers (geoportal/cadastral) before the oblast-aware external providers, with no city gate — a non-EKB street+house colliding with an EKB building resolved to EKB coords. Fix gates the local tiers via _names_non_ekb_city (reuses SVERDLOVSK_OBLAST_CITIES, word-boundary + district-prefix exclusion + \bекатеринбург\b short-circuit). EKB path byte-identical. Deep-review APPROVE, CI green, live-verified (Nominatim returns correct Н.Тагил coords once local tiers skip).

Follow-up (main): geocode_cache purge of ~323 poisoned rows.
2026-07-12 21:08:49 +00:00
bot-backend
8fdcd8df62 fix(tradein/geocoder): word-boundary EKB short-circuit + gate suggest() + gazetteer (#11)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 56s
Deep-review fast-follows on the non-EKB city gate:

1. Word-boundary the "екатеринбург" short-circuit in _names_non_ekb_city
   (new _EKATERINBURG_RE = \bекатеринбург\b, .search() instead of substring
   `in`). A bare-substring check matched "Екатеринбургское шоссе" (a real
   street in satellite towns like Pervouralsk) as if it named EKB, wrongly
   keeping EKB-only local tiers on for a non-EKB address. Still returns False
   for the existing homonym false-positive test case (word boundary is
   satisfied there — "екатеринбург" appears as a standalone token).

2. Gate suggest()'s Tier 1 (same root cause as geocode()): the EKB-only
   cadastral matchers _cadastral_house_match / _cadastral_forward_sync ran
   unconditionally, so a non-EKB oblast autocomplete query could surface an
   EKB building via street+house collision. Gated behind
   `not _names_non_ekb_city(query)`, mirroring geocode()'s use_local_ekb.
   External suggest tiers (DaData/Yandex/Nominatim) untouched — still the
   path for non-EKB autocomplete.

3. Extended SVERDLOVSK_OBLAST_CITIES with unambiguous, deal-heavy oblast
   cities: алапаевск, сухой лог, кушва, красноуральск, карпинск, нижняя
   тура, верхний тагил, нижние серги. Deliberately did NOT add "лесной"
   (reviewer flagged as reviewer-optional, left to judgment): DB check
   (ekb_geoportal_buildings) confirms a real EKB street named exactly
   "Лесной" (3 buildings) — an exact whole-word collision indistinguishable
   from ЗАТО Лесной by word-boundary matching alone (unlike "Серова"/"Серов",
   which are different word forms). Adding it would misclassify a bare
   EKB address ("Лесной, 5", no "Екатеринбург" mention) as non-EKB.

Tests: word-boundary EKB regression test, suggest() gating test, two new
gazetteer entries (Верхняя Пышма multi-word, Сухой Лог).
2026-07-13 00:06:19 +03:00
c98a879a79 Merge pull request 'fix(tradein/matching): Tier-2a city-guard для non-ЕКБ coord-less (п.2, defense-in-depth)' (#2502) from fix/tradein-oblast-tier2a-cityguard into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 47s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Has been cancelled
2026-07-12 21:04:28 +00:00
bot-backend
2ef3e42da1 fix(tradein/geocoder): explicit Екатеринбург wins over oblast-city homonym (#11)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Prod-cache verification (deep-review) found 3 false-positives: EKB addresses
that literally contain "екатеринбург" but also happen to contain a homonym
of another oblast city inside a neighborhood/posyolok name — ЖК "Заречный"
and пос. Сысерть, both administratively inside Ekaterinburg, not the ZATO
Zarechny / town Sysert they share a name with. These were gated off the
EKB-only local tiers; if Nominatim were momentarily down, a real EKB address
would return None — a latent regression not present before this PR.

Add an explicit-city short-circuit at the top of _names_non_ekb_city: if the
normalized address contains "екатеринбург", return False immediately (EKB
tiers stay on). Cannot affect the true-positive (non-EKB) cases, which never
contain "екатеринбург".
2026-07-12 23:50:49 +03:00
bot-backend
720cee2c8e fix(tradein/geocoder): gate EKB-local tiers for non-EKB oblast addresses (#11)
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
geocode() ran EKB-only local tiers (2a _geoportal_house_match on
ekb_geoportal_buildings, 2c _cadastral_house_match, 2d
_cadastral_forward_sync — all strictly EKB or EKB-dominated) BEFORE the
already oblast-aware external providers (Yandex/Nominatim via OBLAST66_BBOX
+ region cross-check, shipped in c0cbdc2f). _parse_street_house drops the
city, so any non-EKB oblast street+house that collides with an EKB building
(e.g. "проспект Ленина 1" exists in both Nizhny Tagil and EKB) short-circuited
to EKB coordinates.

Add _names_non_ekb_city() — reuses the existing SVERDLOVSK_OBLAST_CITIES
gazetteer (minus Ekaterinburg) and word-boundary matching from
_has_oblast_marker/_DISTRICT_PREFIXES — to detect when an address explicitly
names a different oblast city. Gate tiers 2a/2c/2d behind
`use_local_ekb = not _names_non_ekb_city(address)`; EKB/bare addresses keep
the exact same code path (byte-identical ordering/logic, only additive
gating). Non-EKB addresses fall straight through to Yandex/Nominatim, which
already handle oblast-wide geocoding correctly.

Verified gendesign_cad_buildings is 99.85% EKB-scoped (47043/47111 rows);
ekb_geoportal_buildings is 100% EKB by construction — gating loses no
meaningful local coverage for non-EKB cities.

Follow-up (not in this PR): geocode_cache has rows poisoned by the old
behavior (non-EKB addresses cached with EKB coordinates) — needs a purge.
2026-07-12 23:45:40 +03:00
73f932d60a Merge pull request 'feat(tradein): sber-index freshness monitor (п.1 observability)' (#2503) from fix/tradein-sber-freshness-monitor into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 47s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m26s
2026-07-12 20:31:19 +00:00
e7c388dde7 Merge pull request 'refactor(tradein): консолидировать corridor/street-deals city-scope на _resolve_target_city (п.3)' (#2501) from fix/tradein-corridor-dedup-scope into main
Some checks failed
Deploy Trade-In / build-browser (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 20:31:05 +00:00
bot-backend
07275c3c97 feat(tradein): монитор свежести СберИндекса (audit п.1)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
Добавляет sber_freshness_monitor по образцу deals_freshness_monitor:
staleness данных СберИндекса теперь видна на MONITOR-частоте, а не тонет
в per-estimate warning'ах estimator._load_sber_index_series (#audit-5a).

- app/tasks/sber_freshness_monitor.py: чистая evaluate_sber_freshness()
  (frozen-now, без БД) + check_sber_freshness() (один SELECT
  max(period_month) вторичного сегмента по региону, #R2-H1 фильтр как в
  эстиматоре; WARNING при stale, mark_done при алерте — это монитор, не
  сбой; mark_failed только при пустой таблице).
- app/services/product_handlers.py: _job_sber_freshness_monitor + Handler
  в build_product_handlers (run_in_executor, как deals-монитор).
- data/sql/180_seed_sber_freshness_monitor.sql: seed scrape_schedules
  (enabled, daily 09:00-10:00 UTC, lag_allowance_days=25).
- tests/test_sber_freshness_monitor.py: frozen-now (fresh/stale/границы) +
  FakeDB (fresh/stale/empty/кастомный lag) + свойства миграции + registry.

Порог алерта: sber_index_max_age_days (35) + lag_allowance (25) = 60д.
+25 — запас на инхерентный лаг публикации источника (1-2 мес), чтобы не
шуметь на штатном отставании. Прод 2026-07-12: max=2026-05-01, age=72д >
60 → alert=1.
2026-07-12 23:27:35 +03:00
bot-backend
d92ff5c3d3 fix(tradein/oblast): Tier-2a guard — coord-less non-ЕКБ карточка не матчит ЕКБ-alias
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 52s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Follow-up к #2500: закрывает Tier-2a-дыру, которую geo-guard Tier-2b не доставал.
Coord-less карточка, чей адрес РАЗРЕШАЕТ non-ЕКБ город обл.66 (resolve_city_token),
больше не матчит глобально-уникальный alias (ни по fingerprint, ни по
normalized_address) — иначе non-ЕКБ карточка баккетилась бы в одноимённый ЕКБ-дом и
корраптила ЕКБ-данные. При срабатывании guard'а обе alias-выборки пропускаются →
fall-through в New house (Tier-3 coord-gated, тоже пропускается).

normalize.py: + resolve_city_token() (город обл.66 или None), + EKB_CITY_TOKEN;
has_city_token переиспользует resolve_city_token.

EKB happy-path байт-в-байт: guard срабатывает ТОЛЬКО когда адрес называет non-ЕКБ
город И нет координат. ЕКБ-карточки (resolved city = екатеринбург) и доминирующие
bare/city-less coord-less карточки Avito (resolved None) идут Tier-2a/2b как раньше.

ВАЖНО (документировано в коде и отчёте): реальные Avito SERP-адреса — bare (без
city-токена; 2% из 5037 avito-alias'ов несут 'екатеринбург', 0% — oblast). Значит для
bare oblast-карточки resolve_city_token=None и guard дремлет: полное закрытие
bare-Tier-2a-остатка требует sweep-context/city-keyed aliases — отдельный follow-up,
вне scope, актуален лишь при включённом oblast-sweep. Zero prod-impact сегодня.
2026-07-12 23:26:01 +03:00
bot-backend
d31b1e01ee refactor(tradein): консолидировать corridor/street-deals city-scope на _resolve_target_city (п.3)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Successful in 51s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
#2488 (_resolve_target_city + LOWER(d.city)=:target_city) смёржен НА ВЕРХ моего #2489
(substring :address ILIKE '%'||d.city||'%') → в _fetch_dkp_corridor образовался
двойной city-фильтр (benign, но избыточный + латентный ё/е edge в substring).

- estimator._fetch_dkp_corridor: убран мой substring-фильтр, оставлен #2488
  _resolve_target_city (словарь ~30 городов обл.66 вкл. ЕКБ + все sweep-города;
  city=None → фильтр не применяется — прежнее #2488-поведение). Убран unused :address bind.
- api/v1/trade_in.get_street_deals: substring заменён на тот же _resolve_target_city
  паттерн (консистентность; #2488 не трогал street-deals). city_filter — литерал,
  значение bind-параметром (не инъекция).

Live-verified: Ленина 2к ЕКБ → median 118052, 1 город (байт-в-байт как substring).
Regression-гейт байт-зелёный (коридор заморожен в фикстуре), 53 теста, ruff clean.
2026-07-12 23:18:02 +03:00
51473fc841 Merge pull request 'fix(tradein): oblast avito-sweep city-slug + Tier-2b geo-guard (R2, latent)' (#2500) from fix/tradein-scrapers-oblast into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Successful in 2m32s
Deploy Trade-In / deploy (push) Successful in 1m37s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 59s
Deploy Trade-In / build-backend (push) Successful in 1m47s
2026-07-12 19:42:29 +00:00
c3d0f86f37 Merge pull request 'fix(tradein/v2): honesty + a11y — histogram outlier-guard, scatter-релейбл, mobile-notice, keyboard-nav (R2)' (#2499) from fix/tradein-frontend-honesty into main
Some checks failed
Deploy Trade-In / changes (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / build-browser (push) Has been cancelled
2026-07-12 19:42:15 +00:00
bot-backend
219cdfdbcc fix(tradein/v2): честность гистограмм и оси scatter + мобильный guard + a11y графика
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m23s
- Гистограммы флагман-карточки (ОБЪЯВЛЕНИЯ) и ДКП/сделок биннятся по
  outlier-guarded пулу (guardPriceOutliers().clean) — консистентно с числом
  «выброс исключён» и спредом на той же карточке. Раньше bins8 спанил
  [min,max] ВКЛЮЧАЯ выброс 872k, схлопывая реальные аналоги в левые бины.
- Ось детального scatter «СРОК ПРОДАЖИ» → «СРОК НА РЫНКЕ»: для активных лотов
  x = дней на рынке (today−listing_date, нижняя граница), а не измеренный
  срок продажи. Обновлены title/aria-label/подзаголовок карточки.
- <1024px: honest desktop-only notice вместо нечитаемого ~25%-scaled артборда
  (client-side width guard, ранний return после всех хуков; десктоп не тронут).
- WCAG 2.1.1: точки истории цен теперь фокусируемы с клавиатуры
  (role=button / tabIndex / Enter+Space / onFocus-onBlur), мышиный hover сохранён.
2026-07-12 22:38:29 +03:00
bot-backend
bf4ce1ba36 fix(tradein/oblast): city-slug в avito SERP + geo/city guard в Tier-2b матчинга
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m3s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
#2487 avito oblast per-city sweep был dead-on-arrival: _parse_html дропал все
карточки без /ekaterinburg/ в source_url, поэтому oblast-sweep по городу вне ЕКБ
терял 100% выдачи. Kept-slug теперь параметризуется через AvitoScraper.target_city_slug
(дефолт "ekaterinburg" — ЕКБ-поведение неизменно), прокинут scheduler →
run_avito_city_sweep → AvitoScraper. avito_serp_ekb_only НЕ отключается.

Bug #2 (oblast bare-street mis-bucket): голые адреса 'ул. Ленина 100' в Н.Тагиле
мис-баккетились в одноимённые ЕКБ-дома через Tier-2b (match по глобально-уникальному
normalized_address без geo/city guard). Добавлен coarse-guard: coords present →
ST_DWithin ≤3км до geom дома; coords absent → требуется city-token (self-
disambiguating); иначе skip → консервативно New house вместо мис-матча. _insert_alias
при коллизии normalized_address больше не перебивает fingerprint чужого дома (иначе
guard деградировал бы в Tier-2a-дыру).

Оба дефекта латентные (oblast per-city schedules отключены) — правка делает
capability безопасной к включению, без влияния на прод сегодня.
2026-07-12 22:38:11 +03:00
519b482cdd Merge pull request 'fix(tradein/rbac): энфорсить roles.yaml scope на всех non-admin путях (R2 H3 security)' (#2498) from fix/tradein-rbac-scope into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 54s
Deploy Trade-In / deploy (push) Successful in 1m2s
Deploy Trade-In / build-backend (push) Successful in 1m12s
2026-07-12 19:28:38 +00:00
e4bb2fc2e3 Merge pull request 'fix(tradein/lead): phone digit-guard, убрать dead landing-литерал, 152-ФЗ audit (R2)' (#2497) from fix/tradein-lead-hardening into main
Some checks failed
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 19:28:26 +00:00
bot-backend
85321d1d1a fix(tradein/rbac): энфорсить roles.yaml scope на всех non-admin путях (R2 H3)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 52s
rbac_guard гейтил только /api/v1/admin/* → revoked (role=expired, roles.yaml
paths:[] deny:/**) и узко-скоупленные аккаунты сохраняли полный non-admin
API-доступ (напр. POST /api/v1/search — экспорт листингов + estimate-quota).
is_path_allowed (roles.yaml paths/deny) существовал, но НЕ вызывался (0 callers).

Fix: после admin-гейта вызываем is_path_allowed(role, external_path) для всех
non-bootstrap путей. roles.yaml globs — внешние (Caddy срезал /trade-in), поэтому
восстанавливаем внешний путь (_EXTERNAL_PREFIX + path). Bootstrap-пути /me и
/brand/* исключены — expired ДОЛЖЕН получить role=expired через /me (trial-экран)
и брендинг; без исключения trial-UX сломался бы (expired paths:[] → 403 на /me).
На сбой парса — fail-open + громкий лог (не лочим платящего pilot из-за конфиг-бага).

roles.yaml НЕ меняю — только энфорсю уже задекларированную политику. pilot/admin/
analyst доступ сохранён (verified), expired теперь 403 на non-admin API.
Тесты: +5 scope-кейсов (expired denied search/trade-in, allowed me/brand; pilot/
admin/analyst preserved) — 31 passed. Тест-harness middleware — mirror, обновлён.
2026-07-12 22:26:14 +03:00
b1ac551451 Merge pull request 'fix(tradein): ingest/scheduler integrity — year sanity, propagate geo-guard, dkp_import counters + DO UPDATE (R2)' (#2496) from fix/tradein-ingest-scheduler-integrity into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m14s
Deploy Trade-In / deploy (push) Successful in 4m0s
2026-07-12 19:20:10 +00:00
032fd95a7e Merge pull request 'fix(tradein): image_sanitizer — reject decompression-bomb до декода (R2 security)' (#2495) from fix/tradein-image-dos into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 19:19:39 +00:00
bot-backend
0583fb0efd fix(trade-in): усилить валидацию /lead и почистить контракт (#2376)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 58s
- [MED] phone: маска r"^[+]?[\d\s().-]{5,32}$" делала цифры опциональными
  ("(()) -- .." проходило) — добавлен field_validator, требующий 10-15
  реальных цифр (re.sub(r"\D","")), формат-толерантность сохранена.
- [MED] source: убран мёртвый литерал "landing" из Literal — воронка
  недостижима (rbac_guard закрывает /lead 401 без X-Authenticated-User,
  публичного лендинг-роута нет), контракт больше не обещает невозможное.
- [LOW] 152-ФЗ: захват client_ip (X-Forwarded-For -> peer) и
  _CONSENT_POLICY_VERSION в audit-лог; live-схема trade_in_leads не имеет
  колонок под IP/policy/consent-snapshot -> TODO на follow-up миграцию
  (out of scope), durable proof-of-consent gap задокументирован.

Тесты: +digit-free/too-few-digits phone 422, +source=landing 422,
estimate-тест переведён на source=result. 9/9 pass, ruff clean.
2026-07-12 22:19:20 +03:00
a3dfa2a99f Merge pull request 'fix(tradein/pdf): честность отчёта — убрать фейк-сроки, хардкод EKБ, противоречивый дисконт (R2)' (#2494) from fix/tradein-pdf-honesty into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 19:19:08 +00:00
bot-backend
20bdc7dec8 test(tradein/pdf): обновить insufficient-page тест под city-aware № отчёта
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 56s
test_insufficient_data_page_contains_address_and_report_num ассертил старый
захардкоженный «EKБ-» (mixed-script баг). Оценка без target_address → город не
распознан → нейтральный префикс «МЕРА» (#pdf-honesty). Обновлён ассерт: «МЕРА-»
присутствует, «EKБ-» отсутствует.
2026-07-12 22:12:42 +03:00
bot-backend
6487813ec5 fix(tradein/ingest): 4 data-integrity дефекта в бэкфилле годов и импорте ДКП
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 57s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
A. ДОМ.РФ year backfill: авторитетный commission_year больше не экранируется
   невозможным существующим year_built. Валидное существующее значение
   выигрывает (COALESCE-семантика #2013 сохранена), но NULL/impossible
   (< 1850, > текущий+2, 0) заменяется валидным commission_year → zhkh_year.
   parse_int_field получил sanity-гейт [min,max] — мусорный commission_year
   не попадает в staging (7 таких строк в текущем staging).

B. propagate_listings_year: добавлен link-consistency guard. Раньше копировал
   houses.year_built на listings по house_id_fk без проверки — перепривязанный
   FK впрыскивал чужой когортный год. Теперь пропагация только если координаты
   объявления в пределах 500м от геометрии дома (ST_DWithin), либо (без коорд.)
   консервативный address-фоллбек по short_address. Live: блокирует 1 из 10
   текущих кандидатов (>500м mislink).

C. rosreestr_dkp_import: per-row INSERT-ошибки отделены от dedup-skip. Раньше
   except инкрементил тот же batch_skipped, что и легитимный ON CONFLICT — сбой
   маскировался под дедуп и run рапортовал success. Отдельный rows_errored +
   гейт по доле ошибок (> 5% → run FAILED, не silent-green).

D. rosreestr_dkp_import: ON CONFLICT DO NOTHING → DO UPDATE изменяемых сырых
   фактов Росреестра (price/area/rooms/floor/year/deal_date/...), чтобы
   исправленный/переопубликованный квартал обновлялся. Обогащение
   (lat/lon/geom/geocode_tried_at и пр.) не в SET-списке — не затирается.
   IS DISTINCT FROM guard сохраняет идемпотентность resume; RETURNING (xmax=0)
   отличает insert от update (rows_inserted vs rows_updated).
2026-07-12 22:12:31 +03:00
bot-backend
1c9f0e1161 fix(tradein/photos): защита от pixel-flood DoS в image_sanitizer
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
sanitize_image декодировал изображение целиком (img.load) ДО ресайза,
а Pillow MAX_IMAGE_PIXELS оставался дефолтным (~89 MP). Хорошо сжимаемый
≤10 MB аплоад с заявленными ~89-178 MP разворачивался в raw RGB буфер на
сотни MB до thumbnail — OOM-kill backend'а (768 MiB, #2214), усиление ×12
слотов на estimate.

Defense in depth:
- _MAX_PIXELS = 40 MP: проверка img.size (из хедера, без декода) ДО img.load;
  превышение → ImageSanitizationError без загрузки полного буфера.
- Image.MAX_IMAGE_PIXELS понижен до 40 MP — Pillow сам бросает
  DecompressionBombError на любом декод-пути в обход явной проверки.
- Явный except Image.DecompressionBombError + re-raise ImageSanitizationError
  (byte/pixel cap) до широкого except — грациозный reject, не 500.
- _MAX_BYTES backstop (endpoint уже режет 10 MB → 413, но сервис standalone).

Контракт caller'а не меняется: reject → ImageSanitizationError → HTTP 400.
Тест tests/services/test_image_sanitizer.py: happy-path, resize, pixel-flood
без декода (load не вызывается), byte-backstop, граница cap, garbage bytes.
2026-07-12 22:12:12 +03:00
0f4ed2f6e9 Merge pull request 'fix(tradein/estimator): СберИндекс только вторичка + confidence dispersion-ceiling (R2 H1+H2)' (#2493) from fix/tradein-estimator-sber-confidence into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 56s
Deploy Trade-In / build-backend (push) Successful in 1m15s
Deploy Trade-In / deploy (push) Successful in 57s
2026-07-12 19:06:43 +00:00
bot-backend
27d1b09c72 fix(trade-in-pdf): убрать три ложных утверждения из PDF-отчёта
Some checks failed
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m0s
Отчёт печатал измерения, которых не делал:

1. Выдуманный «срок продажи 4–118 дней». _days_on_market_range при
   отсутствии реальных days_on_market (~48% оценок) возвращал хардкод
   (4, 118), а обложка и страница объявлений рисовали его как
   измеренный срок экспозиции. Теперь функция возвращает None, и оба
   call-site'а не рисуют срок (show_days=False) — как уже делает
   страница сделок.

2. № отчёта хардкодил «EKБ» для любой оценки — объект в Серове /
   Нижнем Тагиле получал екатеринбургский код на обложке, в шапках и
   футере. Префикс теперь выводится из target_address (ЕКБ / НТ / СЕР /
   …); неизвестный город → нейтральный «МЕРА», а не ложный «EKБ».

3. Статичный «сделки на 10–18% ниже» противоречил рассчитанному в том
   же PDF «−N%». Совет на обложке и баннер на странице сделок теперь
   ссылаются на реальный _discount_pct (тот же, что chip «−N%»), с
   нейтральной формулировкой когда дисконт неизвестен. Удалён ложный
   хвост «(Екатеринбург, 2026)».

Тесты: +11 кейсов (suppression (4,118), city-aware № отчёта,
computed-discount вместо «10–18%»). Все 23 проходят, ruff чисто.
2026-07-12 22:04:17 +03:00
bot-backend
c1a01fdbf9 fix(tradein/estimator): СберИндекс — только вторичка + confidence dispersion-ceiling (R2 H1+H2)
All checks were successful
CI / changes (pull_request) Successful in 13s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m4s
H1 (деньги, каждая оценка): time-adjust ДКП-коридора брал СберИндекс `residential_real_estate_prices`,
а для обл.66 это 100% «Первичный рынок» (новостройки) → коррекция направленно противоположна
вторичке (первичка Jan→May +0.89% vs вторичка −0.39%). Fix: SBER_COEFF_DASHBOARDS →
(real_estate_deals, dinamika-tsen-obyavlenii) — обе вторичка; + segment-guard в SQL
(`segment ILIKE '%вторичн%'`) как defense-in-depth. Live-verified: остаются только Вторичный-серии.

H2 (честность): `_compute_confidence` ветка «≥4 адреса» не имела потолка разброса → пул с ±45%
IQR + «расширили радиус из-за нехватки данных» уходил как «medium» вопреки объяснению. Fix:
medium требует IQR/median < 0.35; + force-low при fallback-расширении с разбросом > 0.30.

Regression-гейт: перегенерён baseline (dedup OFF, как в гейте) — ровно 1 из 277 фикстур-кейсов
medium→low (высокодисперсный, справедливо): calibration.medium.n 2→1 (mape 14.64→6.99 —
ненадёжный ушёл), low.coverage 81.82→81.88. Δ минимальна и обоснована. 127 тестов зелёные.

Stale-СберИндекс (72д) — НЕ трогаю в коде: time-adjust валидно ре-базит Jan→May; сброс потерял
бы валидную коррекцию. Реальный gap операционный (pull не гонялся с 19.06) — в scheduler/ops.
2026-07-12 22:03:59 +03:00
fb123bd97e Merge pull request 'fix(tradein/estimator): non-EKB headline из deals + geo-bound Tier-S analog leak' (#2492) from fix/tradein-oblast-nonekb-headline into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m14s
Deploy Trade-In / test (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 56s
2026-07-12 18:49:08 +00:00
bot-backend
f9ae6f0c53 fix(tradein/estimator): non-EKB headline из deals + geo-bound Tier-S analog leak
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m3s
CI Trade-In / frontend-checks (pull_request) Has been skipped
Валидация вскрыла: не-ЕКБ headline over-priced — analog Tier-S (same-building) fallback БЕЗ geo-предиката матчил одноимённую улицу+дом в ДРУГОМ городе → тянул ЕКБ-листинги (40191 EKB/~0 non-EKB) с distance_m=0. Серов ask-head 186k vs sold 30k; Н.Тагил 187k vs deal-corridor 89k.

Part 1 (Tier-S geo-bound, ~4624): + ST_DWithin(geom, subject, radius) в address-prefix fallback (зеркалит Tier-W). >99.97% листингов с coords → режет только cross-city false-positives.
Part 2 (deals-headline-fallback): _fetch_dkp_corridor widen на city-wide при street-sample <3 (gated city!=екатеринбург); _price_from_inputs: при median_ppm2<=0 (нет листингов) + anchor_tier None + dkp_raw>=3 → headline из deal-corridor, confidence=low, n_analogs=0, expected_sold=None.

Deep-review  APPROVE (no 🔴/🟠; ~10 downstream stages traced coherent). EKB byte-green regression-гейт (fallback требует median_ppm2<=0, невозможно для frozen fixture; Part 1 SQL не в replay). Тесты: Н.Тагил 0-листингов+corridor85911 → headline=85911 (не 0/186k); EKB-control dense → headline из листингов (fallback не срабатывает). Full suite 2418 passed.

Follow-ups (🟡): fallback без city-gate (sparse EKB n/a→deals, safe); api_analog_tier мислейбл 'city'; Part1/2a integration-test; EKB backtest post-deploy (rule #7).
2026-07-12 21:46:38 +03:00
3293df8e14 Merge pull request 'feat(tradein/backtest): --city per-city валидация точности + corridor city-parity' (#2491) from fix/tradein-oblast-d-backtest into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 46s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 50s
2026-07-12 17:51:12 +00:00
4c56c5189a Merge pull request 'fix(tradein/estimator): резолвить target-город коридора из payload.address (C2 hotfix)' (#2490) from fix/tradein-oblast-c2fix-corridor-city into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 17:50:19 +00:00
bot-backend
25a865f27b feat(tradein/backtest): --city per-city валидация + corridor city-parity
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
CI / openapi-codegen-check (pull_request) Has been skipped
D validation harness для oblast-точности:
- --city '<точное deals.city>' → scope выборки по deals.city; default None = байт-идентично (тот же _SAMPLE_SQL object, literal identity).
- per-city PPM2 band из deal_city_price_bands (fallback global, 0 доп-запросов при None).
- corridor city-parity: backtest _fetch_dkp_corridor теперь передаёт city=_resolve_target_city(deal.address) — зеркалит прод C2 (был cross-city contaminated). NB: меняет и default-run live-числа (не fixture-replay) — intended catch-up к проду.
- _compute_confidence подтверждён comp-count-based (без EKB-хардкода).
- +10 unit-тестов. Регресс-гейт байт-зелёный (replay не гоняет corridor).

code-review  APPROVE. Frozen fixture/baseline не тронуты. Live per-city backtest прогоню после деплоя.
2026-07-12 20:49:04 +03:00
bot-backend
69f8b50934 docs(tradein): исправить GET-rehydrate comment — row.address=geo.full_address (city-stripped), corridor unscoped для не-ЕКБ (known limitation)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
2026-07-12 20:48:25 +03:00
bot-backend
7801ac6cc1 fix(tradein/estimator): резолвить target-город коридора из payload.address (C2 hotfix)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
C2 city-scope коридора был NO-OP для не-ЕКБ: estimate_quality резолвил город из geo.full_address, но геокодер РОНЯЕТ город для не-ЕКБ («Ленина, 1» вместо «Нижний Тагил, Ленина, 1») → target_city=None → коридор либо unscoped, либо None. Verified на проде: Н.Тагил corridor=None.

Фикс: резолвить город из payload.address (город есть) с fallback на canonical/geo; коридор матчить тоже по payload.address. GET: row.address приоритетнее canonical.

Verified прод-диагностикой: corridor(address=payload.address, city=resolve) → Н.Тагил=9, ЕКБ=15 (было: Н.Тагил=None). EKB regression-гейт байт-зелёный. geocoder full_address city-strip (display «Ленина,1») → cosmetic, E.
2026-07-12 20:36:08 +03:00
5d7c3e1c30 Merge pull request 'fix(tradein/estimator): city-scope ДКП-коридора + oblast city-tokens/bbox' (#2488) from fix/tradein-oblast-c2-estimator-scope into main
All checks were successful
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / deploy (push) Successful in 59s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-07-12 17:21:55 +00:00
6936558f6f Merge pull request 'fix(tradein): city-scope ДКП-запросы — убрать oblast-контаминацию (C1+C2)' (#2489) from fix/tradein-oblast-city-scope-corridor-ratio into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-07-12 17:20:31 +00:00
bot-backend
f631af4de3 fix(tradein): city-scope ДКП-запросы — убрать oblast-контаминацию (C1+C2)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
Миграция 177 залила +47183 ДКП-сделки по всей Свердловской обл. (66, 368 городов,
deals.city заполнен). Потребители deals матчат по имени улицы БЕЗ city-фильтра →
одноимённые улицы дешёвых городов области контаминируют ЕКБ-числа.

C1 — street-deals + ДКП-коридор (клиент видит СЕЙЧАС):
- estimator._fetch_dkp_corridor + api/v1/trade_in.get_street_deals: добавлен city-скоуп
  `city IS NOT NULL AND :address ILIKE '%'||city||'%'` (канонический deals.city как
  подстрока целевого адреса — self-normalizing, адрес всегда содержит город).
- Live-repro (Ленина, 2к, 12мес): median 52 908 → 118 052 ₽/м² (было занижение ~−55%).

C2 — дневной пересчёт asking→sold ratio (imminent, «выкупная» −29%):
- tasks/asking_to_sold_ratio: deal_side + deal_global скоупятся на ЕКБ (asking-сторона =
  listings покрыты скрейпом только по ЕКБ; в listings нет колонки city). Константа
  _ASKING_CITY_PATTERN. ask-стороны не трогаем.
- Live-repro (rooms=2): sold_median all-city 82 593 (ratio 0.622) → EKB 116 858
  (ratio 0.880). Предотвращает обвал «выкупной» на след. пересчёте.
- Когда появятся oblast-листинги (B1/B2) → заменить на per-city ratio через `district`.

Тесты: subset-тест 080↔refresh (mirror _drop_segment_guard прецедента для city-guard) +
позитивный тест скоупа; 125 passed вкл. regression-гейт (байт-зелёный — коридор заморожен
в фикстуре), expected_sold, idor, 781. Regression-гейт не затронут (dkp_raw — frozen kwarg).
2026-07-12 20:18:36 +03:00
bot-backend
4a579e74e0 Merge remote-tracking branch 'refs/remotes/forgejo/main' into fix/tradein-oblast-c2-estimator-scope
All checks were successful
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
# Conflicts:
#	tradein-mvp/backend/app/services/estimator.py
2026-07-12 20:14:17 +03:00
bot-backend
b93d26990b fix(tradein/estimator): city-scope ДКП-коридора + oblast city-tokens/bbox
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s
Чинит доказанную кросс-город контаминацию коридора: street-name-матч без city тянул одноимённые улицы всех городов обл. («Ленина» = Н.Тагил+Лесной+Серов+ЕКБ...).

- _resolve_target_city(address) — word-boundary поверх geocoder.SVERDLOVSK_OBLAST_CITIES (F, ~30 городов), district-guard («мкр Заречный» = район, не город-ЗАТО).
- _fetch_dkp_corridor: +param city → AND LOWER(d.city)=CAST(:target_city AS text). Проводка в estimate_quality (dadata.canonical > geo) + trade_in.py GET rehydrate. city=None → прежнее поведение (fallback).
- _CITY_TOKENS + _DEDUP_ADDR_NOISE_WORDS → derive из SVERDLOVSK_OBLAST_CITIES (было 6 EKB-городов) → same-house dedup для non-EKB.
- bbox fast-path → geocoder.is_within_oblast66_bbox.
- docstring: коридор НЕ чисто advisory (клампит headline + radius-floor при n>=clamp_min_n).

EKB regression-гейт байт-зелёный (коридор upstream от frozen _price_from_inputs replay). Deep-review ⚠️APPROVE (no блокеров). Follow-ups (task #10): DB-derived city-set (Лесной 1103 uncovered), asking_to_sold_ratio city-scope, backtest city-parity.
2026-07-12 20:10:59 +03:00
90a6e4bfe9 Merge pull request 'feat(tradein/scraper): per-city sweep capability для 5 городов области (schedules disabled)' (#2487) from fix/tradein-oblast-b1-city-anchors into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 49s
Deploy Trade-In / build-backend (push) Successful in 2m4s
Deploy Trade-In / deploy (push) Has been cancelled
2026-07-12 17:10:22 +00:00
bot-backend
cf91ac6b24 feat(tradein/scraper): per-city sweep capability для 5 городов области (schedules disabled)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 52s
B1: avito/cian/yandex city-sweep по городам Свердловской обл. (Н.Тагил/Каменск/Первоуральск/В.Пышма/Серов). Schedules засеяны DISABLED — rollout вручную по одному городу (proxy = 1 живой прокси).

- pipeline.py: CITY_ANCHORS (5) + get_city_anchors(slug) (None→EKB fallback).
- scheduler.py: sweep-jobs читают params.city → анкоры; wildcard-хендлеры *_city_sweep_* (source UNIQUE → per-city source, как deactivate_stale_*).
- geo.py: OBLAST66_BBOX + is_within_oblast66_bbox; avito/detail.py ingest-guard EKB→oblast66.
- price_brackets.py: 5 city-seed (reuse EKB). migration 179: 15 rows enabled=false, staggered, idempotent.
- +6 тестов. domclick (B2) не тронут.
2026-07-12 20:06:46 +03:00
74017b39c4 Merge pull request 'fix(tradein/dadata): suggest-fallback для house_fias_id когда CLEAN выключен' (#2486) from fix/tradein-dadata-clean-fallback-suggest into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 51s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 52s
2026-07-12 16:59:29 +00:00
bot-backend
e250869c9d fix(tradein/dadata): suggest-fallback для house_fias_id когда CLEAN выключен
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 56s
На проде у DaData-аккаунта выключена услуга CLEAN (Стандартизация) → /clean/address
отдаёт HTTP 403 «Feature CLEAN disabled», из-за чего house_fias_id (ключ join с
houses/ДОМ.РФ) и гео терялись, хотя /suggest их отдаёт бесплатно (token-only).

- enrich_address(): /clean → graceful fallback на /suggest (house-level кандидат);
  canonical_address/house_cadnum честно None (их даёт только CLEAN — не выдумываем).
- clean_address(): 403 «feature disabled» логируется отдельно от auth-rejection
  (токен валиден — refresh не поможет; чинить = включить услугу либо fallback).
- DadataSuggestion + _parse_suggestion: тянут house_fias_id/kladr_id/qc_geo.
- estimator: enrichment-вызов → enrich_address (алиас dadata_clean_address сохранён,
  это точка патча в 11 тестах — без churn).
- Тесты: feature-disabled лог, enrich clean-path/suggest-fallback/both-fail, новые поля.
  Проверено вживую на прод-кредах: clean→403, enrich→house_fias_id восстановлен.
2026-07-12 19:55:37 +03:00
b0b2c94ece Merge pull request 'fix(tradein/estimator): per-city ценовой band ДКП-сделок для всей Свердловской обл. (66)' (#2485) from fix/tradein-oblast-c1-price-band into main
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 47s
Deploy Trade-In / deploy (push) Successful in 48s
2026-07-12 16:09:05 +00:00
bot-backend
e2502a0134 fix(tradein/estimator): per-city ценовой band ДКП-сделок для всей обл. 66
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
Глобальный DEAL_MIN_PPM2=50k резал 46.6% не-ЕКБ сделок (легит дешёвые города: Североуральск p50=21.7k, Новоуральск 36k, Асбест 41k). Band стал per-city.

- migration 178: deal_city_price_bands (city PK, ppm2_min/max, n_deals), populate из deals (source=rosreestr, city<>Екатеринбург, count>=30), min=GREATEST(p1,8000) max=LEAST(p99,800000), idempotent upsert; HAVING-guard p99>=8000 (airtight против inverted band).
- estimator.py оба enforcement-пойнта city-aware с fallback на глобаль:
  - _fetch_dkp_corridor SQL: LEFT JOIN deal_city_price_bands + COALESCE(band, :global).
  - _is_plausible_deal(city, bands) + _fetch_deals: _load_city_price_bands (TTL-cache), SELECT city.
- ЕКБ исключён из таблицы → fallback на 50k/800k → EKB-сделки byte-identical (deals спеллит EKB только 'Екатеринбург', 49791).

EKB deal-level инвариант airtight; interior-EKB оценки идентичны. Регресс-гейт байт-зелёный. Follow-ups: periodic refresh band-таблицы; city-scoping _fetch_dkp_corridor (→ C2).
2026-07-12 19:07:05 +03:00
51952ebd70 Merge pull request 'fix(tradein/geocoder): раз-хардкод геокодера/DaData на всю Свердловскую обл. (region 66)' (#2484) from fix/tradein-oblast-f-geocoder into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 47s
2026-07-12 15:35:27 +00:00
bot-backend
c0cbdc2f99 fix(tradein/geocoder): раз-хардкод геокодера/DaData на всю Свердловскую обл. (region 66)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
CI Trade-In / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
Геокодер был жёстко EKB-bound и ВЫБРАСЫВАЛ корректные non-EKB геокоды (bbox-accept-фильтры) → блокировал оценку любого адреса области. Обобщено до region-66.

- OBLAST66_BBOX + is_within_oblast66_bbox (superset EKB-tight, EKB не затронут); EKB Yandex Tier1/2 fast-path сохранён.
- Все accept-фильтры (Yandex/Nominatim forward+suggest, geocode() accept+typo) → oblast-66.
- City-prefix по word-boundary _has_oblast_marker (не substring): EKB-улицы 'Серова 27'/'Ирбитская 5' и др. больше не роняют 'Екатеринбург,'-префикс (был silent wrong-city баг).
- Two-pass tie-break в accept-циклах: tight-EKB кандидат приоритетнее → EKB-результат идентичен прежнему.
- Region cross-check (Nominatim address.state / Yandex AdministrativeAreaName) отсекает соседние области (Тюмень и др.); bbox-fallback когда region отсутствует.
- DaData suggest: region-параметр (hard-filter), убран no-op restrict_value.
- +26 тестов: OBLAST66 superset TIGHT, far-town accept, Тюмень reject, marker +/- (street-collision), DaData region body.

Foundation для A2 (геокодинг non-EKB сделок) и live-оценки адресов области.
2026-07-12 18:33:05 +03:00
fcc37c46ab fix(tradein/v2): аналитика — выровнять два графика в равную высоту (#2483)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Successful in 49s
Deploy Trade-In / build-frontend (push) Successful in 2m0s
Deploy Trade-In / build-browser (push) Successful in 29s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 1m47s
2026-07-12 15:16:34 +00:00
95f0b0aa64 Merge pull request 'fix(tradein/ingest): раз-хардкод rosreestr DKP импорта на всю Свердловскую обл. (region 66)' (#2482) from fix/tradein-oblast-a1-ingest into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Successful in 31s
Deploy Trade-In / build-browser (push) Successful in 31s
Deploy Trade-In / test (push) Successful in 50s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-07-12 15:14:22 +00:00
bot-backend
752cb1044b fix(tradein/ingest): раз-хардкод rosreestr DKP импорта на всю обл. 66
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
- scheduler.py import_rosreestr_dkp: снят фильтр city ILIKE '%катеринбург%', address из реального city источника, deals.region_code + новая deals.city заполняются (было: хардкод 'Екатеринбург,' + region_code NULL)
- migration 177: deals.city + индекс + бэкфилл существующих EKB-строк region_code=66/city
- guard city IS NOT NULL → address не NULL для ~15 null-city строк источника
- sync deploy/import-rosreestr.sh (ops-fallback) под тот же oblast-scope
- split dedup-теста: 077 (историческая) хранит EKB-фильтр, живой импорт — нет

Открывает +47183 не-ЕКБ сделок region 66, уже сидящих в источнике, ранее резавшихся на импорте.
2026-07-12 18:11:47 +03:00
338ccbb78f fix(tradein/v2): честность+консистентность — реконсиляция счётчика аналогов, убрать фейк-donut/линейку, outlier-guard, created_at, дисклеймеры (#2481)
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m55s
Deploy Trade-In / deploy (push) Successful in 44s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
2026-07-12 15:09:10 +00:00
a88e755403 fix(tradein/v2): аналитика — 2 графика в ряд, чтобы Цена×срок влезала на экран (#2480)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 44s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m55s
2026-07-12 14:56:08 +00:00
4f205bd7da fix(tradein/v2): polish АНАЛИТИКА — растянуть scatter, клэмп оси к 0, честные легенды/бакеты, убрать фейк-тренд (#2479)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 43s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m53s
2026-07-12 14:37:05 +00:00
8dd35c01d8 feat(tradein/v2): карта вторички — сделки Росреестра + объявления по источникам (SourcesView) (#2478)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m3s
Deploy Trade-In / deploy (push) Successful in 45s
2026-07-12 13:26:55 +00:00
ed504cbbd9 fix(tradein): repoint rosreestr_poll to /data-sets/ + verify Content-Type (СДЕЛКИ dataset moved off /opendata) (#2477)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 49s
2026-07-12 13:21:58 +00:00
cc231946c2 feat(tradein): fill houses.year_built/material_walls from ДОМ.РФ КР1.1/1.2 + zhkh_year, propagate to listings (#2013) (#2476)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 2m46s
2026-07-12 13:12:07 +00:00
d22e0e00ae tech-debt(tradein/estimator): collapse won estimate_* flags into defaults (#1970) (#2475)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 47s
2026-07-12 12:46:18 +00:00
346767ba90 Merge pull request 'test(rbac): user2 expected role expired after brusnika trial cutoff' (#2473) from fix/rbac-test-user2-expired into main
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 36s
Deploy / build-worker (push) Successful in 35s
Deploy Trade-In / test (push) Successful in 53s
Deploy Trade-In / build-backend (push) Successful in 31s
Deploy / deploy (push) Successful in 1m23s
Deploy Trade-In / deploy (push) Successful in 1m5s
2026-07-09 16:33:19 +00:00
174 changed files with 21387 additions and 3309 deletions

View file

@ -483,7 +483,11 @@ jobs:
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
# admin-triggered scrape внутри backend, если backend в этом деплое
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
SERVICES="browser backend frontend"
# tgbot: тот же backend-образ (rebuild уже покрыт filters.backend —
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
# с browser/backend/frontend, отдельного graceful-drain не требует.
SERVICES="browser backend frontend tgbot"
SCRAPER_STOP_TS=""
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
@ -588,6 +592,97 @@ jobs:
fi
echo "→ backend healthy на /health."
# Frontend health check — раньше проверялся ТОЛЬКО backend: сломанный
# фронт (500/белый экран после build, или контейнер упавший на старте)
# помечался успешным деплоем, отката не происходило (см. заголовок
# секции выше). Проверяем изнутри backend-контейнера — он в одной
# tradein-net сети с frontend, и curl там уже есть (в отличие от
# node:alpine рантайм-образа frontend, где нет ни curl, ни wget —
# добавлять их туда ради healthcheck не стали, backend достаточно).
# Путь ОБЯЗАН включать /trade-in: basePath запечён в prod-образ на
# build (NEXT_PUBLIC_BASE_PATH=/trade-in, см. build-frontend job) —
# голый "/" внутри Next вернёт 404, а не что-то живое. "/trade-in/"
# редиректит (307) на /trade-in/v2 — curl -f не считает 3xx ошибкой,
# так что это чистая liveness-проверка (процесс жив и роутит),
# без привязки к тому, что именно сейчас показывает витрина.
frontend_healthy=""
for i in $(seq 1 30); do
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://frontend:3000/trade-in/ >/dev/null 2>&1; then
frontend_healthy="yes"; break
fi
sleep 1
done
if [ -z "$frontend_healthy" ]; then
echo "ERROR: frontend не ответил на /trade-in/ за 30s — деплой FAILED"
exit 1
fi
echo "→ frontend healthy на /trade-in/."
# Browser health check — /health в browser/server.py всегда 200, пока
# жив сам aiohttp-процесс (см. health_handler: "compose НЕ имеет
# healthcheck на browser, только depends_on: service_started" — до
# этой правки browser вообще не проверялся никаким деплой-шагом).
# Это liveness процесса, НЕ readiness camoufox-инстансов конкретных
# источников (те поднимаются лениво на первый /fetch) — но упавший
# при старте контейнер (например, битый образ) здесь ловится сразу,
# а не молча остаётся мёртвым до первого реального /fetch scraper'ом.
browser_healthy=""
for i in $(seq 1 30); do
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://browser:3000/health >/dev/null 2>&1; then
browser_healthy="yes"; break
fi
sleep 1
done
if [ -z "$browser_healthy" ]; then
echo "ERROR: browser не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ browser healthy на /health."
# tgbot/scraper — те же backend-образ и Dockerfile, но bare python-
# процессы БЕЗ ASGI/HTTP-сервера (см. комментарии в tgbot_main.py /
# scheduler_main.py: "здесь нет ASGI-приложения"), поэтому HTTP-
# healthcheck для них невозможен в принципе. Liveness проверяем по
# состоянию контейнера через docker inspect: упавший на старте
# процесс (например, ImportError в новом коде) restart-policy
# unless-stopped уводит в бесконечный crash-loop — раньше это НИКАК
# не блокировало деплой (маркер писался, даже если tgbot/scraper
# были мертвы). Двойная проверка (running → пауза → снова running)
# снижает шанс поймать контейнер ровно в момент between-restarts
# промежуточного "running" внутри crash-loop.
# tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES);
# scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому
# проверяем только то, что реально входит в текущий $SERVICES.
for svc in tgbot scraper; do
case " $SERVICES " in
*" $svc "*) ;;
*) continue ;;
esac
container_ok=""
state="unknown"
for i in $(seq 1 15); do
state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown")
if [ "$state" = "running" ]; then
container_ok="yes"; break
fi
sleep 1
done
if [ -n "$container_ok" ]; then
sleep 3
state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown")
if [ "$state" != "running" ]; then
container_ok=""
fi
fi
if [ -z "$container_ok" ]; then
echo "ERROR: tradein-$svc не в стабильном состоянии running (state='$state') — деплой FAILED"
exit 1
fi
echo "→ tradein-$svc running."
done
# Cleanup старых образов
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
ghcr.io/lekss361/gendesign-tradein-frontend; do

View file

@ -70,7 +70,7 @@ users:
admin: admin
kopylov: pilot
user1: pilot
user2: expired # «Брусника» — пробный доступ закрыт 2026-07-09 (аналогично praktika) — NoAccessScreen variant="trial"
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
user3: pilot
user4: pilot
user5: pilot
@ -79,7 +79,13 @@ users:
user8: pilot
user9: pilot
user10: pilot
praktika: expired # пробный доступ закончился 2026-06-27 — см. NoAccessScreen variant="trial"
praktika: pilot # ГК «Практика» — доступ восстановлен 2026-07-27 (решение владельца
# продукта; ранее expired с 2026-06-27). Безлимитная квота оценок
# выдана через account_quota_overrides.unlimited (migration 191),
# не через код — см. app.services.account_quota.is_unlimited.
admintest: admin # temp QA 2026-05-26
pilottest: pilot # temp QA 2026-05-26
analysttest: analyst # temp QA 2026-06-07 (#962)
expiredtest: expired # temp QA 2026-07-27 — role=expired regression coverage для
# test_rbac.py (praktika перестал быть expired-фикстурой
# после восстановления доступа)

View file

@ -19,60 +19,110 @@ logger = logging.getLogger(__name__)
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
# Маппинг OSM-тег → нормализованная category
OSM_CATEGORIES: dict[tuple[str, str], str] = {
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
# уровне отдельных HTTP-запросов: элемент, подходящий под любую из альтернативных схем
# разметки, попадёт в категорию.
OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
# amenity tags — школы расширены (school/college/university)
("amenity", "school"): "school",
("amenity", "college"): "school",
("amenity", "university"): "school",
("amenity", "kindergarten"): "kindergarten",
("amenity", "pharmacy"): "pharmacy",
("amenity", "hospital"): "hospital",
("amenity", "clinic"): "hospital",
(("amenity", "school"),): "school",
(("amenity", "college"),): "school",
(("amenity", "university"),): "school",
(("amenity", "kindergarten"),): "kindergarten",
(("amenity", "pharmacy"),): "pharmacy",
(("amenity", "hospital"),): "hospital",
(("amenity", "clinic"),): "hospital",
# shop tags — supermarket расширен
("shop", "mall"): "shop_mall",
("shop", "supermarket"): "shop_supermarket",
("shop", "hypermarket"): "shop_supermarket",
("shop", "convenience"): "shop_small",
("shop", "bakery"): "shop_small",
(("shop", "mall"),): "shop_mall",
(("shop", "supermarket"),): "shop_supermarket",
(("shop", "hypermarket"),): "shop_supermarket",
(("shop", "convenience"),): "shop_small",
(("shop", "bakery"),): "shop_small",
# leisure
("leisure", "park"): "park",
(("leisure", "park"),): "park",
# transit
("railway", "tram_stop"): "tram_stop",
("highway", "bus_stop"): "bus_stop",
# метро (одна линия в ЕКБ, но добавляем для полноты)
("station", "subway"): "metro_stop",
(("railway", "tram_stop"),): "tram_stop",
(("highway", "bus_stop"),): "bus_stop",
# Метро ЕКБ (9 станций, одна линия). Fix (location-index rework): фильтр раньше ловил
# ТОЛЬКО station=subway и подтягивал лишь 5/9 станций — часть станций в OSM размечена
# без ключа "station" вовсе, комбинацией railway=station + subway=yes (альтернативная,
# но распространённая схема разметки метро). Обе схемы — отдельными записями ниже, чтобы
# не терять станции, размеченные любой из них.
(("station", "subway"),): "metro_stop",
(("railway", "station"), ("subway", "yes")): "metro_stop",
}
def _build_overpass_query_single(key: str, value: str) -> str:
"""Запрос для одной пары tag → нормированной категории.
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
Раньше делали один большой запрос на все 14 категорий Overpass возвращал
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
14 быстрых запросов вместо одного 60+ секундного.
быстрые запросы вместо одного 60+ секундного.
"""
south, west, north, east = EKB_BBOX
bbox = f"({south},{west},{north},{east})"
return (
f"[out:json][timeout:30];"
f'(node["{key}"="{value}"]{bbox};way["{key}"="{value}"]{bbox};);'
f"out center meta;"
)
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
def _classify(tags: dict[str, str]) -> str | None:
"""Определить category из OSM-тегов. None если не соответствует ни одной."""
for (k, v), cat in OSM_CATEGORIES.items():
if tags.get(k) == v:
for tag_filters, cat in OSM_CATEGORIES.items():
if all(tags.get(k) == v for k, v in tag_filters):
return cat
return None
def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
return ",".join(f"{k}={v}" for k, v in tag_filters)
async def _fetch_category(
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
) -> list[dict]:
"""Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
сбоев без риска зациклиться (Overpass rate-limit max 2 concurrent, поэтому не более
2 попыток на категорию).
"""
tag_desc = _tag_filters_desc(tag_filters)
query = _build_overpass_query(tag_filters)
for attempt in (1, 2):
try:
r = await client.post(OVERPASS_URL, data={"data": query})
r.raise_for_status()
elements: list[dict] = r.json().get("elements", [])
logger.info(
"Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
)
# Привязываем category именно к тому per-category запросу, под который
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
for el in elements:
el["_gd_category"] = category
return elements
except Exception as e:
if attempt == 1:
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
await asyncio.sleep(3.0)
continue
logger.warning(
"Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
)
return []
async def fetch_overpass() -> list[dict]:
"""Запросить Overpass API per category, вернуть combined список elements.
Делаем 14 отдельных запросов вместо одного гигантского большой запрос
Делаем отдельные запросы вместо одного гигантского большой запрос
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
policy: max 2 concurrent, лучше 1 req/s).
@ -85,27 +135,14 @@ async def fetch_overpass() -> list[dict]:
}
all_elements: list[dict] = []
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
for (key, value), category in OSM_CATEGORIES.items():
query = _build_overpass_query_single(key, value)
try:
r = await client.post(OVERPASS_URL, data={"data": query})
r.raise_for_status()
elements: list[dict] = r.json().get("elements", [])
logger.info("Overpass: %s=%s (%s) → %d", key, value, category, len(elements))
# Привязываем category именно к тому per-category запросу, под который
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
for el in elements:
el["_gd_category"] = category
for tag_filters, category in OSM_CATEGORIES.items():
elements = await _fetch_category(client, tag_filters, category)
all_elements.extend(elements)
except Exception as e:
# Не падаем на одной категории — логируем и продолжаем
logger.warning("Overpass failed for %s=%s: %s", key, value, e)
await asyncio.sleep(1.0)
logger.info(
"Overpass: total %d elements across %d categories", len(all_elements), len(OSM_CATEGORIES)
"Overpass: total %d elements across %d category-queries",
len(all_elements),
len(OSM_CATEGORIES),
)
return all_elements

View file

@ -114,9 +114,7 @@ def test_get_role_known_users() -> None:
assert auth_mod.get_role("admin") == "admin"
assert auth_mod.get_role("kopylov") == "pilot"
for n in range(1, 11):
# user2 = «Брусника»: пробный доступ закрыт 2026-07-09 (roles.yaml)
expected = "expired" if n == 2 else "pilot"
assert auth_mod.get_role(f"user{n}") == expected
assert auth_mod.get_role(f"user{n}") == "pilot"
def test_get_role_unknown_user_raises() -> None:

View file

@ -0,0 +1,48 @@
-- 188_tradein_osm_poi_view_relax_freshness.sql
-- Fix for 185_tradein_osm_poi_view.sql: v_tradein_osm_poi_ekb enforced a HARD 2-year
-- OSM last-edit-date filter that silently dropped legitimate, stable infrastructure.
--
-- CONTEXT (trade-in location-index rework, replaces the broken location-coef):
-- Audit of the trade-in POI mirror (osm_poi_ekb_local, fed by this view via the FDW
-- bridge) found only 5 of 9 EKB metro stations and just 2787 POI total reaching
-- tradein-mvp, despite osm_poi_ekb (this table, Site Finder's own registry) having more.
--
-- Root cause: this view's WHERE clause dropped any POI whose OSM `last_osm_edit_date` is
-- older than 2 years. A subway station node, once correctly mapped, is essentially never
-- re-edited in OSM — "stale last edit" here means "nobody touched this tag in years",
-- NOT "this station stopped existing". The same logic applies to schools/hospitals/parks:
-- physically permanent infrastructure that simply isn't re-edited often.
--
-- The "2-year freshness" requirement itself (see 82_osm_poi_ekb.sql, "требование
-- Максима") was intended as a SOFT confidence signal, not a hard existence filter — Site
-- Finder itself (backend/app/api/v1/parcels.py, "POI freshness" confidence subscore) only
-- uses last_osm_edit_date to DERATE a confidence score; every POI stays in the result set
-- regardless of staleness. This view diverged into a hard filter when the FDW bridge was
-- built (185) — this migration fixes that divergence, matching Site Finder's own intent.
--
-- WHAT: CREATE OR REPLACE VIEW, same 4-column shape as 185 (category, name, lat, lon) — no
-- WHERE clause. tradein-mvp's FDW foreign table (tradein-mvp/backend/data/sql/
-- 168_fdw_osm_poi_ekb.sql) is untouched — same column list, so no FDW-side change needed.
--
-- Idempotent: CREATE OR REPLACE VIEW. GRANT re-applied (idempotent, matches 185).
BEGIN;
CREATE OR REPLACE VIEW v_tradein_osm_poi_ekb AS
SELECT
category,
name,
lat,
lon
FROM osm_poi_ekb;
GRANT SELECT ON v_tradein_osm_poi_ekb TO tradein_fdw_reader;
COMMENT ON VIEW v_tradein_osm_poi_ekb IS
'FDW source for tradein-mvp (postgres_fdw) location-index/nearby-POI list (replaces the '
'broken location-coef, #2045). No freshness filter — last_osm_edit_date is a soft '
'confidence signal only (see Site Finder parcels.py), not evidence a POI stopped '
'existing. Fixes 185_tradein_osm_poi_view.sql hard 2-year WHERE filter that silently '
'dropped 4/9 EKB metro stations + other stable infrastructure from the trade-in mirror.';
COMMIT;

View file

@ -40,3 +40,14 @@ DADATA_API_SECRET=
POSTGRES_USER=tradein
POSTGRES_PASSWORD=tradein
POSTGRES_DB=tradein
# === Telegram support-bot bridge (tgbot service, docker-compose.prod.yml) ===
# Long-polling worker: пересылает support-обращения в Telegram-топик. Не FastAPI,
# отдельный процесс (app/tgbot_main.py), env читается из backend/.env.runtime на VPS.
#
# BotFather token. Пусто = бот не стартует (выключен).
TELEGRAM_BOT_TOKEN=
# ID супергруппы-форума с включёнными топиками (вида -100XXXXXXXXXX).
TELEGRAM_SUPPORT_CHAT_ID=
# ID топика (thread) внутри супергруппы, куда падают support-сообщения.
TELEGRAM_SUPPORT_TOPIC_ID=

View file

@ -89,10 +89,47 @@ GLITCHTIP_DSN=<dsn или пусто>
# Регистрация ключей: https://dadata.ru/api/clean/
DADATA_API_TOKEN=<token или пусто>
DADATA_API_SECRET=<secret или пусто>
# Telegram support-bot bridge (сервис tgbot, docker-compose.prod.yml).
# Long-polling воркер (app/tgbot_main.py), тот же образ что backend/scraper,
# отдельный контейнер tradein-tgbot. Пусто TELEGRAM_BOT_TOKEN = бот НЕ падает
# и НЕ рестарт-лупится — процесс стартует, уходит в idle-блокировку и просто
# висит (это норма для окружений без токена, не сбой; см. tgbot_main.py).
#
# 1. TELEGRAM_BOT_TOKEN — токен от @BotFather (/newbot). Пусто = бот выключен
# (idle, не polling).
TELEGRAM_BOT_TOKEN=<token или пусто>
# 2. TELEGRAM_SUPPORT_CHAT_ID — id супергруппы-форума (Topics включены в
# настройках группы), вида -100XXXXXXXXXX. Получить: добавить бота в группу,
# отправить любое сообщение в любой топик, дернуть
# https://api.telegram.org/bot<token>/getUpdates — в ответе
# message.chat.id (для супергруппы всегда отрицательный, начинается с -100).
TELEGRAM_SUPPORT_CHAT_ID=<-100... или пусто>
# 3. TELEGRAM_SUPPORT_TOPIC_ID — id конкретного топика (thread) внутри группы,
# куда падают support-обращения. Открыть нужный топик в Telegram Desktop/Web →
# в URL топика (t.me/c/<chat>/<topic_id>) последнее число — это topic_id.
# Либо взять message_thread_id из того же getUpdates-ответа (п.2), отправив
# тестовое сообщение именно в целевой топик.
TELEGRAM_SUPPORT_TOPIC_ID=<topic_id или пусто>
```
Оба файла создаются вручную при первом деплое.
### Деплой / рестарт `tgbot`
```bash
# .env.runtime читается на старте container — `compose restart` НЕ перечитывает.
docker compose -p gendesign-tradein -f docker-compose.prod.yml \
up -d --force-recreate --no-deps tgbot
```
`restart: unless-stopped` + `stop_grace_period: 120s` в compose (см. `docker-compose.prod.yml`)
— автозапуск после ребута VPS гарантирован (`on-failure` сюда не годится: код выхода
контейнера при ребуте — гонка с long-poll таймаутом 30с, `unless-stopped`/`always`
не зависят от exit-кода). 120s grace даёт time докрутить long-poll + отработать
кооперативный drain (`_DRAIN_TIMEOUT_S=100s` в `tgbot_main.py`) до docker SIGKILL —
паттерн скопирован с `scraper` (см. комментарий там же).
### После изменения `backend/.env.runtime`
```bash

View file

@ -2394,9 +2394,14 @@ async def rotate_proxy_ip(
data = resp.json()
except Exception:
data = {}
except Exception as exc:
except Exception:
# НЕ отдавать str(exc) клиенту (аудит-фикс, #security-audit): httpx-исключения
# несут полный request URL, а rotate_url — mobileproxy changeip-ссылка с API-
# ключом провайдера в query-string (?...&proxy_key=...). str(exc) с этим URL в
# HTTP-ответе — прямая утечка секрета вызывающему клиенту. Причина сбоя остаётся
# в логах (exc_info=True) для диагностики; наружу — только нейтральный reason.
logger.warning("rotate-ip: changeip failed source=%s", source, exc_info=True)
return RotateIpResponse(ok=False, reason=f"changeip error: {exc}")
return RotateIpResponse(ok=False, reason="changeip request failed")
# changeip отдаёт новый IP в одном из полей (формат провайдер-зависимый).
new_ip = None

View file

@ -0,0 +1,280 @@
"""Admin read API над `user_events` — Feature 2 (login/IP audit) + Feature 3 (behavior
analytics dashboard data).
Read-only: только SELECT, никаких мутаций. Auth не нужен в этом файле вся ветка
`/api/v1/admin/*` уже гейтится `rbac_guard` middleware в app/main.py
(`_ADMIN_API_RE`, role != admin 403).
`user_events` (migration `184_user_events.sql`) может быть пустой (feature только
что задеплоена) все запросы ниже написаны так, чтобы на пустой таблице отдавать
пустые списки/нулевые счётчики, а не падать.
"""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.audit import (
AccountActivityEntry,
AccountDeviceEntry,
AccountDrilldown,
AccountIpEntry,
AccountSearchEntry,
AccountSummary,
AnalyticsByAccount,
AnalyticsDailyPoint,
AnalyticsDashboard,
AnalyticsSummary,
AnalyticsTopPath,
AnalyticsTopSearch,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/audit/accounts", response_model=list[AccountSummary])
async def list_accounts(
db: Annotated[Session, Depends(get_db)],
) -> list[AccountSummary]:
"""Список аккаунтов, по одной строке на username, с базовой сводкой активности."""
rows = (
db.execute(
text(
"""
SELECT username,
min(created_at) AS first_seen_at,
max(created_at) AS last_seen_at,
count(DISTINCT ip_address) AS distinct_ips,
count(DISTINCT user_agent) AS distinct_devices,
count(*) FILTER (WHERE event_type = 'login') AS login_count,
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
FROM user_events
GROUP BY username
ORDER BY last_seen_at DESC
"""
)
)
.mappings()
.all()
)
return [AccountSummary.model_validate(r) for r in rows]
@router.get("/audit/accounts/{username}", response_model=AccountDrilldown)
async def account_drilldown(
db: Annotated[Session, Depends(get_db)],
username: Annotated[str, Path(min_length=1, max_length=200)],
) -> AccountDrilldown:
"""Drilldown по одному username: IP-адреса, устройства, поиски, недавняя активность.
Неизвестный username НЕ 404, а пустой отчёт (все 4 списка == []): аудит не
подтверждает/опровергает существование аккаунта, просто нет событий.
"""
params = {"username": username}
ips = (
db.execute(
text(
"""
SELECT CAST(ip_address AS text) AS ip_address,
count(*) AS event_count,
min(created_at) AS first_seen,
max(created_at) AS last_seen
FROM user_events
WHERE username = :username AND ip_address IS NOT NULL
GROUP BY ip_address
ORDER BY last_seen DESC
"""
),
params,
)
.mappings()
.all()
)
devices = (
db.execute(
text(
"""
SELECT user_agent,
count(*) AS event_count,
min(created_at) AS first_seen,
max(created_at) AS last_seen
FROM user_events
WHERE username = :username AND user_agent IS NOT NULL
GROUP BY user_agent
ORDER BY last_seen DESC
"""
),
params,
)
.mappings()
.all()
)
searches = (
db.execute(
text(
"""
SELECT payload ->> 'address' AS address,
payload ->> 'area_m2' AS area_m2,
payload ->> 'rooms' AS rooms,
CAST(estimate_id AS text) AS estimate_id,
CAST(ip_address AS text) AS ip_address,
created_at
FROM user_events
WHERE username = :username AND event_type = 'estimate_request'
ORDER BY created_at DESC
LIMIT 200
"""
),
params,
)
.mappings()
.all()
)
recent_activity = (
db.execute(
text(
"""
SELECT event_type,
path,
method,
CAST(ip_address AS text) AS ip_address,
created_at
FROM user_events
WHERE username = :username
ORDER BY created_at DESC
LIMIT 200
"""
),
params,
)
.mappings()
.all()
)
return AccountDrilldown(
ips=[AccountIpEntry.model_validate(r) for r in ips],
devices=[AccountDeviceEntry.model_validate(r) for r in devices],
searches=[AccountSearchEntry.model_validate(r) for r in searches],
recent_activity=[AccountActivityEntry.model_validate(r) for r in recent_activity],
)
@router.get("/analytics", response_model=AnalyticsDashboard)
async def analytics_dashboard(
db: Annotated[Session, Depends(get_db)],
days: Annotated[int, Query(ge=1, le=365)] = 30,
) -> AnalyticsDashboard:
"""Feature 3 dashboard bundle: сводка, дневной time-series, топ-поиски/пути/аккаунты."""
summary_row = (
db.execute(
text(
"""
SELECT count(*) AS total_events,
count(DISTINCT username) AS distinct_users,
count(*) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS events_last_24h,
count(DISTINCT username) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS active_users_last_24h
FROM user_events
"""
)
)
.mappings()
.one()
)
daily_rows = (
db.execute(
text(
"""
SELECT date_trunc('day', created_at)::date AS day,
count(*) AS events,
count(DISTINCT username) AS users
FROM user_events
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
GROUP BY date_trunc('day', created_at)::date
ORDER BY day
"""
),
{"days": days},
)
.mappings()
.all()
)
top_searches_rows = (
db.execute(
text(
"""
SELECT payload ->> 'address' AS address, count(*) AS n
FROM user_events
WHERE event_type = 'estimate_request'
AND payload ->> 'address' IS NOT NULL
GROUP BY payload ->> 'address'
ORDER BY n DESC
LIMIT 20
"""
)
)
.mappings()
.all()
)
top_paths_rows = (
db.execute(
text(
"""
SELECT path, count(*) AS n
FROM user_events
WHERE event_type = 'api_request'
GROUP BY path
ORDER BY n DESC
LIMIT 20
"""
)
)
.mappings()
.all()
)
by_account_rows = (
db.execute(
text(
"""
SELECT username,
count(*) AS events,
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
max(created_at) AS last_seen
FROM user_events
GROUP BY username
ORDER BY events DESC
LIMIT 50
"""
)
)
.mappings()
.all()
)
return AnalyticsDashboard(
summary=AnalyticsSummary.model_validate(summary_row),
daily=[AnalyticsDailyPoint.model_validate(r) for r in daily_rows],
top_searches=[AnalyticsTopSearch.model_validate(r) for r in top_searches_rows],
top_paths=[AnalyticsTopPath.model_validate(r) for r in top_paths_rows],
by_account=[AnalyticsByAccount.model_validate(r) for r in by_account_rows],
)

View file

@ -56,10 +56,12 @@ async def suggest_addresses(
limit: Annotated[int, Query(ge=1, le=15)] = 8,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
) -> SuggestResponse:
"""Автокомплит адресов в пределах ЕКБ.
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path).
Используется в EstimateForm для подсказок пока пользователь печатает.
Bounded viewbox = ЕКБ (lon 60.40-60.85, lat 56.65-56.95).
Bounded viewbox генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
ЕКБ (lon 60.40-60.85, lat 56.65-56.95) внутри него остаётся быстрым fast-path.
Пример:
/api/v1/geocode/suggest?q=Малышева

View file

@ -1,23 +1,35 @@
"""Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971).
POST /api/v1/trade-in/lead контактная заявка с результата оценки (или лендинга):
POST /api/v1/trade-in/lead контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. Notification (Telegram/email) вне scope: нет существующей
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца любой
аутентифицированный пилот мог привязать свою заявку к чужой оценке (утечка через
последующий просмотр лида: чужой адрес/телефон/оценка в заявке, которую видит не
её владелец). Гвард переиспользует `_assert_estimate_access` из
`app.api.v1.trade_in` тот же owner-or-admin подход, что и `GET /estimate/{id}`
(#690, `tests/test_estimate_idor.py`): 401 без `X-Authenticated-User`, 403 —
неизвестная роль, 404 оценка не найдена ИЛИ принадлежит не этому пользователю
(существование чужой оценки не подтверждаем).
"""
from __future__ import annotations
import logging
import re
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.trade_in import _assert_estimate_access
from app.core.db import get_db
logger = logging.getLogger(__name__)
@ -28,13 +40,45 @@ router = APIRouter()
# скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP,
# см. #2376 DoD ("простая regex, не EmailStr-подобное").
_PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$"
# Маска выше делает цифры ОПЦИОНАЛЬНЫМИ: "(()) -- .." её проходит (0 цифр).
# Поэтому дополнительно требуем правдоподобное число реальных цифр. RU-мобильный =
# 11 цифр; берём лениентный диапазон 10-15 (нац. номер без/с кодом страны).
_PHONE_MIN_DIGITS = 10
_PHONE_MAX_DIGITS = 15
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
# только в audit-лог (#2497 TODO, теперь закрыт).
_CONSENT_POLICY_VERSION = "2026-07"
# Снимок точного текста согласия, который видит пользователь при отправке лида.
# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/
# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять
# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки
# будут нести устаревший snapshot под новой version-меткой.
_CONSENT_TEXT_SNAPSHOT = (
"Согласен(-на) на обработку персональных данных в соответствии с "
"Федеральным законом «О персональных данных» № 152-ФЗ"
)
class TradeInLeadInput(BaseModel):
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
estimate_id: UUID | None = None
consent: Literal[True]
source: Literal["result", "landing"] = "result"
# "landing"-воронка недостижима: /api/v1/trade-in/lead закрыт rbac_guard
# (main.py — путь не в _PUBLIC_PATHS => 401 без X-Authenticated-User), а
# публичного лендинг-роута нет. Убрали мёртвый литерал, чтобы контракт не
# обещал невозможную воронку (#2376). Вернуть, если появится public-роут.
source: Literal["result"] = "result"
@field_validator("phone")
@classmethod
def _phone_has_enough_digits(cls, value: str) -> str:
digits = len(re.sub(r"\D", "", value))
if not (_PHONE_MIN_DIGITS <= digits <= _PHONE_MAX_DIGITS):
raise ValueError(f"phone must contain {_PHONE_MIN_DIGITS}-{_PHONE_MAX_DIGITS} digits")
return value
@router.post("/lead")
@ -42,24 +86,47 @@ async def create_trade_in_lead(
payload: TradeInLeadInput,
request: Request,
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict[str, Any]:
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
if payload.estimate_id is not None:
exists = db.execute(
text("SELECT 1 FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
estimate_row = db.execute(
text("SELECT created_by FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
{"id": str(payload.estimate_id)},
).fetchone()
if exists is None:
if estimate_row is None:
raise HTTPException(status_code=404, detail="estimate not found")
# IDOR guard (security-audit, зеркалит #690): нельзя привязать лид к
# чужой оценке. 404 и на "не найдено", и на "чужая" — не подтверждаем
# существование чужого estimate_id.
_assert_estimate_access(estimate_row.created_by, x_authenticated_user)
user_agent = request.headers.get("user-agent")
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
# фронтящий Caddy), fallback — прямой peer. Персистится per-row в
# trade_in_leads.client_ip (migration 182), а не только в лог.
client_ip = request.headers.get("x-forwarded-for")
if client_ip:
client_ip = client_ip.split(",")[0].strip()
elif request.client is not None:
client_ip = request.client.host
# 152-ФЗ proof-of-consent: client_ip / consent_policy_version /
# consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182,
# ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет
# X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден.
row = (
db.execute(
text(
"""
INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent)
VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent)
INSERT INTO trade_in_leads (
estimate_id, phone, consent, source, user_agent,
client_ip, consent_policy_version, consent_text_snapshot
)
VALUES (
CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent,
CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot
)
RETURNING CAST(id AS text), created_at
"""
),
@ -69,6 +136,9 @@ async def create_trade_in_lead(
"consent": payload.consent,
"source": payload.source,
"user_agent": user_agent,
"client_ip": client_ip,
"consent_policy_version": _CONSENT_POLICY_VERSION,
"consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT,
},
)
.mappings()
@ -78,10 +148,12 @@ async def create_trade_in_lead(
db.commit()
logger.info(
"trade_in_lead saved id=%s estimate_id=%s source=%s",
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
row["id"],
payload.estimate_id,
payload.source,
client_ip,
_CONSENT_POLICY_VERSION,
)
return {

View file

@ -0,0 +1,264 @@
"""Веб-чат поддержки (#tgsupport-web) — поверх уже существующего Telegram
support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
Источник обращения сайт (не Telegram-личка клиента): пользователь пишет через
это API, сообщение зеркалится `sendMessage`-ом в тот же support-топик, оператор
отвечает РЕПЛАЕМ ровно так же, как на Telegram-клиента маршрутизация ответа
обратно реализована в `bridge._handle_group_reply` (ветка добавлена там же, без
изменения существующего Telegram-пути).
Изоляция тредов: все 4 ручки резолвят тред ИСКЛЮЧИТЕЛЬНО по `X-Authenticated-User`
(rbac_guard в app/main.py гарантирует его наличие и валидность для non-public
путей). thread_id НИКОГДА не принимается снаружи (ни в query, ни в body) чужой
тред прочитать/отметить нельзя ни при каких параметрах запроса, потому что
параметра, которым можно было бы адресовать чужой тред, попросту не существует.
Копия зеркала в топике всегда помечена "[С САЙТА] <username>: ..." оператор
не должен путать веб-обращение с Telegram-клиентом (#tgsupport-web AC).
КРИТИЧНО (review H1) порядок операций в `send_support_message`:
БД-запись (`get_or_create_thread`) идёт ПОСЛЕ успешного `send_message`, не до.
Прод один uvicorn-процесс БЕЗ `--workers` (docker-compose.prod.yml) с
синхронным SQLAlchemy engine (пул 5+10 overflow) на ОДНОМ event loop. Если бы
`INSERT ... ON CONFLICT DO UPDATE` уходил ДО Telegram-вызова, строка/row-lock
держались бы всё время, пока `send_message` ждёт Telegram (секунды-минуты при
429/5xx на воркерных ретраях) второй параллельный запрос ТОГО ЖЕ юзера
(двойной клик, вторая вкладка) упёрся бы в этот lock ВНУТРИ синхронного
psycopg-вызова внутри `async def`, останавливая event loop целиком (весь API
встаёт, не только этот эндпоинт). `_format_mirror_text` использует только
`username` thread_id для отправки не нужен вообще, поэтому эту БД-операцию
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
отправка больше не создаёт тред.
"""
from __future__ import annotations
import logging
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter
from app.services.tgbot import web_support_storage as storage
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
router = APIRouter()
# Лимит Telegram sendMessage (4096) с запасом — см. #tgsupport-web AC ("~4000").
MAX_MESSAGE_LENGTH = 4000
# Жёстче общего RateLimitMiddleware (300 req/60с на пользователя, app/main.py):
# бот-токен общий на ВСЕХ клиентов веб-чата, флуд одного клиента иначе может
# упереться в Telegram-лимиты (`sendMessage` 429, лимит группы ~20 msg/min) и
# застопорить доставку всем остальным (см. задачу, п.6). 12 сообщений/минуту —
# щедро для живого диалога, но режет скрипт-флуд на порядок раньше общего API-лимита.
_SEND_RATE_LIMIT = 12
_SEND_RATE_WINDOW_S = 60.0
_send_limiter = SlidingWindowLimiter(limit=_SEND_RATE_LIMIT, window_s=_SEND_RATE_WINDOW_S)
# #tgsupport-web review H1: интерактивный HTTP-запрос НЕ МОЖЕТ наследовать
# воркерную политику ретраев `TelegramClient` (по умолчанию — до 5 попыток, на
# 429 спит `retry_after` Telegram'а — для группы штатно 30-60с, на 5xx backoff до
# 30с — легальный суммарный бюджет минуты). Узкий бюджет здесь: 1 повтор, короткий
# timeout — интерактивный клиент должен получить ответ (даже если это ошибка)
# за секунды, а не висеть до исчерпания воркерных ретраев.
_INTERACTIVE_SEND_TIMEOUT_S = 10.0
_INTERACTIVE_SEND_MAX_RETRIES = 1
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
_LIST_MESSAGES_LIMIT = 200
def _require_username(request: Request) -> str:
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
наличие в проде для non-public путей этот guard здесь defence-in-depth и
делает роутер тестируемым без поднятия всего app.main (см. tests/test_support.py,
как test_trade_in_lead.py для /lead). `.strip()` (review L4) лишний пробел
от прокси иначе завёл бы ВТОРОЙ тред на, по сути, того же пользователя
(username UNIQUE ключ треда, "alice" != "alice ")."""
username = (request.headers.get("x-authenticated-user") or "").strip()
if not username:
raise HTTPException(status_code=401, detail="no authenticated user")
return username
def _bot_configured() -> bool:
"""TELEGRAM_BOT_TOKEN и TELEGRAM_SUPPORT_CHAT_ID оба обязательны — без них
зеркалировать в топик некуда (см. app/tgbot_main.py._should_run для токена,
bridge.py для chat_id)."""
return bool(settings.telegram_bot_token) and bool(settings.telegram_support_chat_id)
class SupportMessageInput(BaseModel):
text: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
@field_validator("text")
@classmethod
def _not_blank(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("text must not be blank")
return stripped
class SupportMessageOut(BaseModel):
id: int
direction: Literal["in", "out"]
text_body: str
operator_tg_id: int | None = None
created_at: str
@field_validator("created_at", mode="before")
@classmethod
def _isoformat(cls, value: object) -> str:
if hasattr(value, "isoformat"):
return value.isoformat() # type: ignore[no-any-return]
return str(value)
class UnreadOut(BaseModel):
unread: int
class StatusOut(BaseModel):
status: Literal["ok"] = "ok"
def _format_mirror_text(username: str, message_text: str) -> str:
"""Помечает зеркало как пришедшее С САЙТА, от какого пользователя — оператор
иначе не отличит веб-обращение от Telegram-клиента (#tgsupport-web AC).
Использует ТОЛЬКО username thread_id здесь не нужен (см. H1 в docstring
модуля), это то, что делает возможным отложить БД-запись до после отправки."""
return f"[С САЙТА] {username}:\n{message_text}"
@router.post("/support/messages", response_model=SupportMessageOut)
async def send_support_message(
payload: SupportMessageInput,
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
) -> SupportMessageOut:
"""Отправляет сообщение от лица *username* в support-топик (`sendMessage` —
не `copyMessage`: у веб-сообщения нет исходного Telegram-сообщения для копии).
Порядок операций см. H1 в docstring модуля: rate-limit проверяется (но НЕ
расходуется, review L3) до отправки, thread создаётся ТОЛЬКО после успешного
`send_message` до этого момента с БД не происходит ничего.
"""
if not _bot_configured():
# Предсказуемое поведение вместо 500 (#tgsupport-web AC): бот не настроен
# (пустой TELEGRAM_BOT_TOKEN, dev/staging) или support-топик не задан —
# мирроринг невозможен физически, ничего не пишем в БД.
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
# review L3: peek без расхода бюджета — неудачная отправка НЕ должна стоить
# пользователю попытки (расходуем `.record()` только на успех, ниже).
retry_after = _send_limiter.retry_after(username)
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Слишком много сообщений. Попробуйте через минуту.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=_format_mirror_text(username, payload.text),
message_thread_id=settings.telegram_support_topic_id or None,
# review H1: узкий интерактивный бюджет — НЕ воркерные 5 ретраев/минуты.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
# TelegramApiError и не бывает — см. client.py docstring про redaction).
logger.exception(
"web support: не удалось отправить зеркало в топик (username=%s)", username
)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
_send_limiter.record(username)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
# review L1: без topic_message_id реплай оператора на это сообщение
# НИКОГДА не смаршрутизируется обратно (find_thread_by_topic_message ищет
# именно по этому полю) — тихая, но зафиксированная в логе деградация.
logger.warning(
"web support: Telegram sendMessage не вернул message_id (username=%s) — "
"ответ оператора на это сообщение не будет смаршрутизирован",
username,
)
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
thread_id = storage.get_or_create_thread(db, username)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
)
db.commit()
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
return SupportMessageOut(**row)
@router.get("/support/messages", response_model=list[SupportMessageOut])
def list_support_messages(
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
since: Annotated[int, Query(ge=0)] = 0,
) -> list[SupportMessageOut]:
"""Сообщения СВОЕГО треда с id > since. Тред резолвится по username — чужой
тред недостижим (нет параметра, которым его можно адресовать).
Обычный (sync) `def`, не `async def` (review M3): тело только синхронные
psycopg-вызовы, ни одного `await`; как `async def` это исполнялось бы прямо в
event loop (а фронт поллит эту ручку постоянно). Starlette гонит sync-handlers
в threadpool автоматически тот же паттерн, что `trade_in.py:get_estimate`.
"""
thread_id = storage.find_thread_id(db, username)
if thread_id is None:
return []
rows = storage.list_messages(
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
)
return [SupportMessageOut(**r) for r in rows]
@router.get("/support/unread", response_model=UnreadOut)
def get_support_unread(
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
) -> UnreadOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
thread_id = storage.find_thread_id(db, username)
if thread_id is None:
return UnreadOut(unread=0)
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
@router.post("/support/read", response_model=StatusOut)
def mark_support_read(
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
) -> StatusOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
thread_id = storage.find_thread_id(db, username)
if thread_id is not None:
storage.mark_read(db, thread_id=thread_id)
db.commit()
return StatusOut()

View file

@ -11,12 +11,13 @@ from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, UploadFile
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import _client_ip
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
@ -27,8 +28,8 @@ from app.schemas.trade_in import (
HouseAnalyticsResponse,
HouseInfoForEstimate,
IMVBenchmarkResponse,
LocationCoefFactorOut,
LocationCoefResponse,
LocationIndexResponse,
NearbyPoiOut,
PhotoMeta,
PlacementHistoryEntry,
PriceHistoryYearPoint,
@ -45,6 +46,7 @@ from app.schemas.trade_in import (
from app.services import account_quota
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
@ -147,6 +149,7 @@ def _resolve_target_house_id(
@router.post("/estimate", response_model=AggregatedEstimate)
async def estimate(
payload: TradeInEstimateInput,
request: Request,
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> AggregatedEstimate:
@ -180,9 +183,29 @@ async def estimate(
) from None
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
# тут: при гонке двух /estimate на used=MONTHLY_LIMIT-1 второй получит False.
if not account_quota.increment(db, x_authenticated_user):
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
# Не списываем квоту за пустой результат (нерезолвящийся адрес и т.п.) — иначе
# платный слот сгорает за insufficient_data=True (median=0, n_analogs=0) с HTTP 200.
if not result.insufficient_data and not account_quota.increment(db, x_authenticated_user):
raise HTTPException(status_code=429, detail=account_quota.LIMIT_EXHAUSTED_MESSAGE)
# Feature 2/3 foundation: "что искали" — обогащённая estimate_request-запись
# в user_events (адрес/площадь/комнаты + estimate_id для join с trade_in_estimates).
# schedule_event сам никогда не raises — сбой аудита не должен ронять ответ.
schedule_event(
event_type="estimate_request",
username=x_authenticated_user or "",
ip=_client_ip(request),
user_agent=request.headers.get("user-agent"),
path=str(request.url.path),
method="POST",
estimate_id=str(result.estimate_id),
payload={
"address": payload.address,
"area_m2": payload.area_m2,
"rooms": payload.rooms,
},
)
return result
@ -244,6 +267,7 @@ def get_estimate(
_fetch_house_imv_anchor,
_fetch_price_trend,
_qc_geo_to_precision,
_resolve_target_city,
_source_counts,
)
@ -283,7 +307,16 @@ def get_estimate(
else None
)
dkp_raw = _fetch_dkp_corridor(db, address=row.address, rooms=row.rooms, area=area_f)
# (oblast C2): city-scope корридора на GET-rehydrate. NB: row.address здесь =
# geo.full_address (персистится на estimate-time), который РОНЯЕТ город для
# не-ЕКБ («Ленина, 1») → target_city=None → corridor unscoped/None для не-ЕКБ
# (KNOWN LIMITATION: POST-path чинит через payload.address; GET получит паритет
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
dkp_raw = _fetch_dkp_corridor(
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
)
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
imv_raw = _fetch_house_imv_anchor(
@ -1534,39 +1567,43 @@ def get_estimate_imv_benchmark(
)
# ── Location-coef POI scoring (#2045 BE-3, LocationDrawer) ────────────────────
# ── Location index (issue TBD, замена сломанного location-coef #2045) ────────
@router.get("/location-coef", response_model=LocationCoefResponse)
def get_location_coef(
@router.get("/location-index", response_model=LocationIndexResponse)
def get_location_index(
estimate_id: UUID,
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
radius_m: int | None = None,
) -> LocationCoefResponse:
"""Location-coefficient POI-скоринг для оценки (#2045 BE-3, LocationDrawer).
) -> LocationIndexResponse:
"""Location index для оценки (замена сломанного location-coef, LocationDrawer).
Резолвит lat/lon/median_price оценки, считает coef через
app.services.location_coef.compute_location_coef straight-line POI weighted score
(портировано из Site Finder poi_score.py) поверх локального зеркала osm_poi_ekb_local,
обновляемого scheduler'ом (source=osm_poi_ekb_refresh). result_price_rub = round(
base_price_rub * coef).
Резолвит lat/lon оценки, считает индекс через
app.services.location_index.compute_location_index: % отклонения медианы /м²
сопоставимых активных листингов в радиусе точки от медианы /м² по всему Екатеринбургу
(percentile_cont(0.5) устойчиво к выбросам). НЕ участвует в цене estimator.py про
этот показатель не знает (аналоги уже несут локацию в базовой цене).
404 оценки нет / IDOR (тот же _assert_estimate_access_by_id, что и у соседних
derived-роутов). radius_m опционален (None DEFAULT_RADIUS_M=1200м, подобран для
МКД, НЕ Ptica-дефолт 2000м для участков); явное значение клэмпится в [500, 3000].
derived-роутов). radius_m опционален (None адаптивная лестница радиусов
RADIUS_LADDER_M, расширяется пока выборка не наберёт MIN_SAMPLE_SIZE); явное значение
клэмпится в [500, 3000] и используется РОВНО как задано (без расширения).
Graceful fallback (НЕ 500, НЕ сфабрикованные факторы):
- osm_poi_ekb_local пуста/не отрефрешена на этом окружении coef=1.0, factors=[],
geo_source="unavailable" (см. compute_location_coef).
- у оценки нет lat/lon (легаси/geo-fallback не сработал на POST) тот же fallback.
Честная деградация (НЕ 500, НЕ сфабрикованные значения) см. LocationIndexResponse:
- status="out_of_coverage" у оценки нет lat/lon, ИЛИ точка вне гео-охвата продукта
(Екатеринбург).
- status="insufficient_data" даже на максимальном радиусе сопоставимых активных
листингов меньше порога.
- poi_status="unavailable" osm_poi_ekb_local пуста/не отрефрешена (независимо от
status выше «что рядом» и числовой индекс деградируют раздельно).
"""
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
row = db.execute(
text(
"""
SELECT lat, lon, median_price
SELECT lat, lon
FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
"""
@ -1576,34 +1613,38 @@ def get_location_coef(
if row is None:
raise HTTPException(status_code=404, detail="estimate not found")
base_price_rub = int(row.median_price or 0)
if row.lat is None or row.lon is None:
logger.info("location_coef: estimate=%s has no lat/lon — unavailable fallback", estimate_id)
return LocationCoefResponse(
coef=1.0,
factors=[],
geo_source="unavailable",
base_price_rub=base_price_rub,
result_price_rub=base_price_rub,
logger.info(
"location_index: estimate=%s has no lat/lon — out_of_coverage fallback", estimate_id
)
return LocationIndexResponse(
status="out_of_coverage",
location_index_pct=None,
local_median_price_per_m2=None,
city_median_price_per_m2=None,
sample_size=0,
radius_m=radius_m or 0,
nearby_poi=[],
poi_status="unavailable",
)
from app.services.location_coef import DEFAULT_RADIUS_M, compute_location_coef
from app.services.location_index import compute_location_index
resolved_radius = DEFAULT_RADIUS_M if radius_m is None else max(500, min(radius_m, 3000))
result = compute_location_coef(db, float(row.lat), float(row.lon), radius_m=resolved_radius)
resolved_radius = None if radius_m is None else max(500, min(radius_m, 3000))
result = compute_location_index(db, float(row.lat), float(row.lon), radius_m=resolved_radius)
return LocationCoefResponse(
coef=result.coef,
factors=[
LocationCoefFactorOut(
poi_type=f.poi_type, name=f.name, distance_m=f.distance_m, weight=f.weight
)
for f in result.factors
return LocationIndexResponse(
status=result.status,
location_index_pct=result.location_index_pct,
local_median_price_per_m2=result.local_median_price_per_m2,
city_median_price_per_m2=result.city_median_price_per_m2,
sample_size=result.sample_size,
radius_m=result.radius_m,
nearby_poi=[
NearbyPoiOut(poi_type=p.poi_type, name=p.name, distance_m=p.distance_m)
for p in result.nearby_poi
],
geo_source=result.geo_source,
base_price_rub=base_price_rub,
result_price_rub=round(base_price_rub * result.coef),
poi_status=result.poi_status,
)
@ -1628,7 +1669,12 @@ def get_street_deals(
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
в import-rosreestr.sh).
"""
from app.services.estimator import _deal_to_analog, _percentile, extract_street_name
from app.services.estimator import (
_deal_to_analog,
_percentile,
_resolve_target_city,
extract_street_name,
)
now = datetime.now(tz=UTC)
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
@ -1663,16 +1709,25 @@ def get_street_deals(
area_min = area_m2 * (1.0 - area_tolerance)
area_max = area_m2 * (1.0 + area_tolerance)
# #C1 city-scope (п.3, консистентно с estimator._fetch_dkp_corridor): резолвим
# город целевого адреса через _resolve_target_city (словарь ~30 городов обл.66
# вкл. ЕКБ + sweep-города) и фильтруем сделки по этому городу — одноимённые улицы
# др. городов не контаминируют витрину. None (адрес вне словаря) → фильтр не
# применяется. `city_filter` — литерал (не user-input), значение идёт bind-параметром.
target_city = _resolve_target_city(address)
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
rows = (
db.execute(
text(
"""
f"""
SELECT address, area_m2, rooms, floor, total_floors,
price_rub, price_per_m2, deal_date, source
FROM deals
WHERE source = 'rosreestr'
AND address ILIKE :street_pattern
AND address ~* :street_regex
{city_filter}
AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
@ -1683,6 +1738,7 @@ def get_street_deals(
{
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None,
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,

View file

@ -106,9 +106,7 @@ class Settings(BaseSettings):
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
# no-op (медиана не меняется).
estimate_imv_blend_enabled: bool = True
# При отсутствии IMV/Yandex no-op (медиана не меняется).
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
@ -117,9 +115,8 @@ class Settings(BaseSettings):
# комфорт 15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
# и tighter FSD-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
# и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_same_building_anchor_enabled: bool = True
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
@ -138,8 +135,7 @@ class Settings(BaseSettings):
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
# (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение.
estimate_calibrated_pi_enabled: bool = True
# (проверено: 80.0% coverage на тех же 2366).
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
@ -171,7 +167,6 @@ class Settings(BaseSettings):
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
estimate_corridor_clamp_enabled: bool = True
estimate_corridor_clamp_min_n: int = 10
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
@ -181,9 +176,8 @@ class Settings(BaseSettings):
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
# (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op.
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_enabled: bool = True
# (избегаем ложных подъёмов) и ловит явный undershoot.
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_factor: float = 0.8
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
@ -215,7 +209,6 @@ class Settings(BaseSettings):
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
# ratio — не затрагиваются.
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
@ -225,8 +218,6 @@ class Settings(BaseSettings):
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
# Отдельный флаг: False → точно старое поведение (гейта нет).
estimate_sb_low_conf_gate_enabled: bool = True
estimate_sb_gate_min_n: int = 3
estimate_sb_gate_max_fsd: float = 0.20
@ -235,25 +226,12 @@ class Settings(BaseSettings):
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
estimate_price_trend_max_age_months: int = 6
# ── Mera-audit fix-3: cross-source dedup в price_trend ────────────────────
# Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт
# double-count в house_placement_history → шум в помесячной медиане тренда.
# True (дефолт) = дедуплицировать строки перед агрегацией по ключу
# (round(area_m2,0), floor, COALESCE(last_price,start_price),
# COALESCE(last_price_date,start_price_date)), приоритет avito_imv.
# False = старое поведение без дедупа (backward-compat).
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
estimate_price_trend_dedup_enabled: bool = True
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
# аналога → headline выглядит достоверным при нулевой реальной базе.
# False = старое поведение без принудительного понижения (для отката).
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
estimate_confidence_floor_no_analogs: bool = True
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
@ -261,8 +239,6 @@ class Settings(BaseSettings):
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
# False = флаг всегда выключен (manual_review_recommended=False, reasons=[]).
estimate_manual_review_enabled: bool = True
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
@ -274,12 +250,8 @@ class Settings(BaseSettings):
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
# (source, source_id) — radius нет. True (дефолт) добавляет rn_dup=1 фильтр в
# каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)).
# False = старое поведение без radius-дедупа (для отката). Окно rn_dup всегда
# в SQL (безвредно без фильтра); флаг управляет только применением фильтра.
# ENV: ESTIMATE_RADIUS_DEDUP_ENABLED.
estimate_radius_dedup_enabled: bool = True
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
# (freshest scraped_at на (source, source_id|source_url|ctid)).
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
@ -302,7 +274,7 @@ class Settings(BaseSettings):
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор
# estimate_sb_clip_after_weight, уже ON). Default ON (#2173): бэктест #1966 OFF vs
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%, median width/cv
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
@ -367,11 +339,9 @@ class Settings(BaseSettings):
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
# растягивается через несколько ценовых режимов → коридор range_low/high
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True +
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
# disclosure в explanation. НЕ трогает point/median/range.
# ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED.
estimate_wide_corridor_disclosure_enabled: bool = True
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
@ -388,11 +358,6 @@ class Settings(BaseSettings):
cian_valuation_min_rub: float = 500_000
cian_valuation_max_rub: float = 500_000_000
# ── #audit-4: MAD-clip after similarity-weighting ─────────────────────────
# True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²).
# False = clip ДО weighting (старое поведение). Дефолт True.
estimate_sb_clip_after_weight: bool = True
# ── #audit-5: data-age guards ─────────────────────────────────────────────
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
@ -410,8 +375,6 @@ class Settings(BaseSettings):
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
# За флагом: False → точно текущее поведение (backward-compatible).
estimate_quarter_index_enabled: bool = True
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
estimate_quarter_index_min_n_deals: int = 10
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
@ -701,5 +664,19 @@ class Settings(BaseSettings):
# честная маркировка.
sell_time_sensitivity_min_n_lots: int = 10
# ── Telegram support bridge (@MERAsupport_bot) ───────────────────────────
# Клиент пишет боту в личку → зеркалится в топик support-группы → оператор
# отвечает реплаем в топике → бот доставляет ответ клиенту. Standalone
# long-polling воркер (app.tgbot_main), НЕ webhook — см. app/services/tgbot/.
# Пусто/0 = бот выключен: tgbot_main логирует «disabled» и выходит с кодом 0
# (чтобы контейнер без секрета не крутил рестарт-луп). ENV: TELEGRAM_BOT_TOKEN,
# TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_SUPPORT_TOPIC_ID.
telegram_bot_token: str = Field(default="", validation_alias="TELEGRAM_BOT_TOKEN")
# Telegram id форум-группы (супергруппы с включёнными топиками), куда
# зеркалятся обращения клиентов. Отрицательный для supergroup id (напр. -100...).
telegram_support_chat_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_CHAT_ID")
# message_thread_id топика внутри support-группы, в который идут зеркала.
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
settings = Settings()

View file

@ -80,6 +80,63 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return await call_next(request)
class SlidingWindowLimiter:
"""Reusable in-process sliding-window limiter — тот же алгоритм, что
`RateLimitMiddleware.dispatch` (deque per key, отбрасываем протухшие метки),
вынесенный для feature-специфичных лимитов, которые нужны ЖЁСТЧЕ общего
per-user порога `/api/*` (напр. отправка сообщений в веб-чат поддержки,
#tgsupport-web — общий лимит 300/60с не спасёт support-топик от заливки
одним флудящим клиентом, т.к. Telegram Bot API токен общий на всех).
Не заменяет `RateLimitMiddleware` (тот остаётся общим гейтом на `/api/*`),
а даёт отдельный, более узкий бюджет для конкретного эндпоинта/действия.
"""
def __init__(self, limit: int, window_s: float) -> None:
self._limit = limit
self._window_s = window_s
self._hits: dict[str, deque[float]] = defaultdict(deque)
def _prune(self, bucket: deque[float], now: float) -> None:
cutoff = now - self._window_s
while bucket and bucket[0] < cutoff:
bucket.popleft()
def retry_after(self, key: str) -> float | None:
"""Non-destructive проверка: сколько секунд ждать, если *key* СЕЙЧАС за
лимитом, иначе None. НЕ регистрирует попытку вызывающая сторона решает
сама, когда звать `record()` (обычно только на успех действия, #tgsupport-web
review L3: неудачная попытка не должна съедать бюджет)."""
now = time.monotonic()
bucket = self._hits[key]
self._prune(bucket, now)
if len(bucket) >= self._limit:
return self._window_s - (now - bucket[0])
return None
def record(self, key: str) -> None:
"""Регистрирует одну успешную попытку под *key*."""
now = time.monotonic()
bucket = self._hits[key]
self._prune(bucket, now)
bucket.append(now)
# Лёгкая защита от утечки памяти — чистим пустые корзины изредка (тот же
# паттерн, что RateLimitMiddleware.dispatch).
if len(self._hits) > 10000:
for k in [k for k, v in self._hits.items() if not v]:
del self._hits[k]
def check(self, key: str) -> float | None:
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
`retry_after`/`record` для раздельного варианта)."""
retry_after = self.retry_after(key)
if retry_after is not None:
return retry_after
self.record(key)
return None
def _client_ip(request: Request) -> str:
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.

View file

@ -0,0 +1,136 @@
"""RBAC guard middleware — extracted from ``app/main.py``.
Historically ``rbac_guard`` lived inline in ``app/main.py`` and the test suite
(``tests/test_rbac.py``, ``tests/test_internal_auth_secret.py``) kept a
hand-maintained *copy* of it, labelled "MIRROR of app.main — keep in sync
manually". The copy drifted: it was missing the #2213
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
so a regression in that check would NOT have failed CI.
This module holds the real guard with no DB/lifespan/scheduler side effects
(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at
import time beyond requiring ``DATABASE_URL`` in the environment for
``Settings()``). ``app/main.py`` and the test apps both import THIS module,
so tests exercise the exact production code path instead of a copy that can
silently fall out of sync.
"""
from __future__ import annotations
import logging
import re
import secrets
from collections.abc import Awaitable, Callable
from fastapi import Request
from fastapi.responses import JSONResponse, Response
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
logger = logging.getLogger(__name__)
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
# (см. app/core/auth.py + auth/roles.yaml). Правила:
# 1) Любой non-public path требует X-Authenticated-User — иначе 401.
# 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
# не видит» — decided 2026-05-25).
# 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
# `uri strip_prefix /trade-in`) — только role=admin, иначе 403.
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
# X-Authenticated-User там не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь.
_EXTERNAL_PREFIX = "/trade-in"
# Bootstrap-пути, доступные ЛЮБОМУ известному юзеру независимо от роли: /me отдаёт
# роль (expired → trial-экран), /brand/* — брендинг login/trial-экрана. Без них
# expired (roles.yaml paths:[] deny:/**) не получил бы роль и не увидел trial-экран.
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
async def rbac_guard(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS:
return await call_next(request)
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
)
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
secret = settings.tradein_internal_auth_secret
if secret:
provided = request.headers.get("X-Internal-Auth-Secret", "")
if not secrets.compare_digest(provided, secret):
logger.warning(
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
"на %s — возможная подделка заголовка мимо Caddy",
username,
path,
)
return JSONResponse(
status_code=401,
content={"detail": "invalid or missing internal auth secret"},
)
try:
role = get_role(username)
except KeyError:
logger.warning("RBAC: unknown user %r tried %s", username, path)
return JSONResponse(
status_code=403,
content={"detail": "user not in roles config"},
)
if _ADMIN_API_RE.match(path) and role != "admin":
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
return JSONResponse(
status_code=403,
content={"detail": "admin only"},
)
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
try:
allowed = is_path_allowed(role, external_path)
except Exception:
logger.exception(
"RBAC scope-check raised for %s %s (ext=%s) — fail-open",
username,
path,
external_path,
)
allowed = True
if not allowed:
logger.info(
"RBAC: scope-blocked %s (role=%s) from %s (ext=%s)",
username,
role,
path,
external_path,
)
return JSONResponse(
status_code=403,
content={"detail": "forbidden for role"},
)
return await call_next(request)

View file

@ -0,0 +1,130 @@
"""RequestAuditMiddleware — пишет `api_request` / `admin_action` (+ дедуплицированный
`login`/`login_failed`) события в `user_events` для каждого аутентифицированного
`/api/*` запроса.
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
ответ клиенту) и через fire-and-forget `schedule_event` сбой аудита никогда
не влияет на HTTP-ответ.
Порядок middleware-стека (см. `app/main.py`: `rbac_guard` `@app.middleware("http")`,
объявлен ДО `app.add_middleware(RequestAuditMiddleware)`) делает `RequestAudit`
ВНЕШНИМ по отношению к `rbac_guard` (Starlette строит стек в обратном порядке
регистрации последний `add_middleware` оборачивает предыдущие). Поэтому к моменту,
когда код ниже читает `response.status_code`, в нём уже отражён исход rbac_guard
(401/403 short-circuit) ИЛИ реального хендлера статус несёт реальный смысл
"успех/отказ", а не только "запрос дошёл до хендлера".
Заведомо неаутентифицированный трафик (сканеры, долбящиеся в /wp-login.php и т.п.
без валидного basic_auth) сюда вообще не попадает: Caddy гейтит basic_auth ПЕРЕД
проксированием, так что `X-Authenticated-User` в таких запросах нет условие
`if username and ...` ниже их уже отсекает. Поэтому шум сканеров не нужно
дополнительно фильтровать в этом файле тот класс проблемы («сигнал тонет в шуме
сканера») здесь структурно невозможен: событие может появиться только для
запроса, прошедшего Caddy basic_auth.
"""
from __future__ import annotations
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from app.core.ratelimit import _client_ip
from app.services.user_events import schedule_event, should_log_login
logger = logging.getLogger(__name__)
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
# циклическую зависимость.
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
# Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в
# аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил
# прокси / изменил настройки скрапера / выполнил bulk-операцию). GET/HEAD/OPTIONS
# на /admin/* остаются вне аудита (см. комментарий ниже — это просмотр дашбордов,
# не действие).
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
class RequestAuditMiddleware(BaseHTTPMiddleware):
"""Логирует активность аутентифицированных пользователей в `user_events`."""
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
response: Response = await call_next(request)
try:
username = request.headers.get("x-authenticated-user")
path = request.url.path
if username and path.startswith("/api/") and path not in _PUBLIC_PATHS:
ip = _client_ip(request)
ua = request.headers.get("user-agent")
method = request.method
success = response.status_code < 400
is_admin_path = path.startswith("/api/v1/admin/")
# Общий behavior/activity-поток — каждый authenticated API-запрос.
# /api/v1/admin/* исключаем из `api_request`: это ops-действия
# (просмотр дашбордов аудита/аналитики), а не поведение пилота —
# иначе запросы дашборда зашумляют top_paths и счётчики активности.
if not is_admin_path:
schedule_event(
event_type="api_request",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=method,
payload={"status_code": response.status_code},
)
elif method in _MUTATING_METHODS:
# Admin-аудит (security-audit fix): раньше ЛЮБОЙ запрос под
# /api/v1/admin/* (включая меняющие состояние — загрузка кук,
# авто-логин, правка прокси, настройки скраперов, bulk-операции)
# полностью исключался из `user_events` тем же условием, что и
# шумные GET-дашборды — установить, КТО совершил действие, было
# невозможно. Пишем факт действия + атрибуцию (username/ip/path/
# method/статус) — БЕЗ тела запроса (там куки/пароли/секреты
# правки прокси), это НЕ payload-лог, а событие "что произошло".
schedule_event(
event_type="admin_action",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=method,
payload={"status_code": response.status_code, "success": success},
)
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
# на (юзер, IP, устройство). Security-audit fix: раньше событие
# всегда писалось как `login` независимо от исхода запроса —
# отражённая RBAC-попытка (валидный Caddy basic_auth, но
# 401/403 от rbac_guard: протухший X-Internal-Auth-Secret,
# неизвестная роль, scope-блок) была неотличима от настоящего
# входа. Теперь тип события расходится по `response.status_code`:
# `login` — успех, `login_failed` — otказ. Дедуп-бакет (once per
# user+ip+ua+day) НЕ разбит отдельно на success/fail (это
# потребовало бы менять `should_log_login` в user_events.py —
# вне scope этого фикса): если в рамках одного дня с этого же
# устройства сначала случился отказ, а затем реальный успешный
# вход, второе событие в тот же день не запишется — тот же
# компромисс дедупа, что был и раньше, разница только в том, что
# теперь ЕДИНСТВЕННОЕ событие дня корректно отражает, чем оно было.
if should_log_login(username, ip, ua):
schedule_event(
event_type="login" if success else "login_failed",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=method,
payload={"status_code": response.status_code},
)
except Exception:
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
return response

View file

@ -8,27 +8,36 @@ from __future__ import annotations
import logging
import os
import re
import secrets
from collections.abc import AsyncGenerator, Awaitable, Callable
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import sentry_sdk
from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in
from app.core.auth import get_role
from app.api.v1 import (
admin,
audit,
brand,
buildings,
geocode,
lead,
me,
search,
support,
trade_in,
)
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
from app.core.ratelimit import RateLimitMiddleware
from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware
from app.observability.sentry_scrub import scrub_pii_event
logger = logging.getLogger(__name__)
@ -38,6 +47,13 @@ logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
# #tgsupport-web: этот процесс теперь тоже зовёт Telegram Bot API напрямую
# (app/api/v1/support.py — sendMessage при отправке веб-сообщения в топик), не
# только изолированный tgbot_main.py. httpx-INFO логирует ПОЛНЫЙ request URL,
# включая токен в пути (https://api.telegram.org/bot<TOKEN>/...) — то же самое
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
logging.getLogger("httpx").setLevel(logging.WARNING)
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
@ -45,13 +61,28 @@ logging.basicConfig(
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
from app.observability.sentry_scrub import redact_telegram_bot_token
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
sendMessage, а include_local_variables=False ниже первый рубеж защиты)."""
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0, # только ошибки, без performance-трейсов
send_default_pii=False, # не шлём client_name / client_phone в отчёты
before_send=scrub_pii_event, # дочищаем consumer-PII из тела error events
include_local_variables=False, # #tgsupport-web: TelegramClient._request
# держит base URL с токеном в локальных переменных стек-фрейма — default
# sentry_sdk (True) приложил бы их к traceback открытым текстом.
before_send=_before_send,
integrations=[
StarletteIntegration(),
FastApiIntegration(),
@ -104,63 +135,9 @@ app = FastAPI(
# не видит» — decided 2026-05-25).
# 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
# `uri strip_prefix /trade-in`) — только role=admin, иначе 403.
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
# X-Authenticated-User там не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
@app.middleware("http")
async def rbac_guard(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS:
return await call_next(request)
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
)
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
# Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
# Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
secret = settings.tradein_internal_auth_secret
if secret:
provided = request.headers.get("X-Internal-Auth-Secret", "")
if not secrets.compare_digest(provided, secret):
logger.warning(
"RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
"на %s — возможная подделка заголовка мимо Caddy",
username,
path,
)
return JSONResponse(
status_code=401,
content={"detail": "invalid or missing internal auth secret"},
)
try:
role = get_role(username)
except KeyError:
logger.warning("RBAC: unknown user %r tried %s", username, path)
return JSONResponse(
status_code=403,
content={"detail": "user not in roles config"},
)
if _ADMIN_API_RE.match(path) and role != "admin":
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
return JSONResponse(
status_code=403,
content={"detail": "admin only"},
)
return await call_next(request)
# Guard body живёт в app/core/rbac.py (без DB/lifespan side effects), чтобы
# тесты могли импортировать РЕАЛЬНЫЙ guard вместо hand-maintained копии.
app.middleware("http")(rbac_guard)
app.add_middleware(
@ -172,6 +149,8 @@ app.add_middleware(
)
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
app.add_middleware(RateLimitMiddleware)
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
app.add_middleware(RequestAuditMiddleware)
@app.get("/health")
@ -181,9 +160,11 @@ def health() -> dict[str, str]:
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
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(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"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])

View file

@ -1,22 +1,76 @@
"""Хук before_send для GlitchTip/Sentry SDK (tradein-local, #396).
"""Хуки before_send для GlitchTip/Sentry SDK (tradein-local, #396, #tgsupport).
Redact-ит consumer-PII (client_name / client_phone / client_email и пр.)
из error events до отправки в GlitchTip estimator/trade-in flow таскает
эти поля, а send_default_pii=False их не покрывает (это user-data в
request.data / extra / contexts, не PII-заголовки).
`redact_telegram_bot_token` отдельный хук (#tgsupport review): Telegram Bot
API токен живёт в URL-пути (`https://api.telegram.org/bot<id>:<secret>/...`),
а не в query/userinfo, поэтому НЕ покрывается sentry_sdk `sanitize_url` (тот
режет только `user:pass@` и query-параметры). Токен утекает ДВУМЯ путями,
которые `_scrub`/`scrub_pii_event` (ключ-based, PII-словарь) не ловят:
1. `include_local_variables=True` (sentry_sdk default) кладёт locals
stack-фрейма (`self._base`, `url` в `TelegramClient._request`) в
traceback полный токен открытым текстом.
2. `HttpxIntegration` кладёт полный request URL в span `data` (виден при
любом ненулевом `traces_sample_rate`), а не только в traceback.
Поэтому редактор НЕ ключ-based, а regex full-text по КАЖДОЙ строке во всём
event (глубокий обход dict/list/tuple) токен может всплыть в любом поле.
"""
from __future__ import annotations
import re
from typing import Any
from sentry_sdk.types import Event
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
_PII_KEYS = frozenset(
{"client_name", "client_phone", "client_email", "phone", "email", "name"}
_PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"})
# Telegram Bot API токен в пути URL: /bot<numeric_id>:<secret-part>/<method>.
# Матчим ровно этот сегмент (не весь URL) — сохраняет остальной путь/query
# читаемым для диагностики (метод API, error code и т.п.).
_TG_BOT_TOKEN_RE = re.compile(r"/bot\d+:[A-Za-z0-9_-]+")
_TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]"
# Тот же токен БЕЗ префикса `/bot` — форма `<numeric_id>:<secret>` сама по себе
# (напр. локаль `token` в конструкторе TelegramClient, или если его кто-то
# засунет в log-сообщение). Сейчас единственный путь такой формы в event —
# locals стек-фрейма, а они выключены через include_local_variables=False в
# tgbot_main. Но именно на отказ того флага этот редактор и страхует: без этой
# ветки рубеж был бы один, а не два. Формат токена BotFather: 8-12 цифр `:` 35
# символов base64url — нижние границы взяты с запасом, чтобы не промахнуться
# на нестандартных id, но остаться уже, чем `\d+:\S+` (тот бил бы по любым
# `id:value` в логах, напр. `chat_id:12345`).
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`).
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
# сам вырезал бы query-параметры) и кладёт полный URL в span `data["url"]` —
# сейчас неактивно (`traces_sample_rate=0.0` в app/main.py/scheduler_main.py →
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
# когда-нибудь включат.
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`,
# `exc` в rotate_proxy_ip) в traceback открытым текстом.
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
# завязано на конкретного провайдера — покрывает любой query-параметр из
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
# access_token/auth), т.к. cian/yandex у нас имеют СВОИ rotate-URL (потенциально
# другой провайдер, другое имя параметра).
_URL_SECRET_QUERY_RE = re.compile(
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
r"access[_-]?token|auth)=)[^&\s\"'<>]+"
)
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
def _scrub(obj: Any) -> None:
@ -32,8 +86,50 @@ 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`).
"""
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)
if redacted != value:
obj[key] = redacted
else:
_redact_url_secrets_inplace(value)
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)
if redacted != value:
obj[i] = redacted
else:
_redact_url_secrets_inplace(value)
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
# выбор для dict/list).
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact consumer-PII из error event перед отправкой. Возвращает event (не None)."""
"""Redact consumer-PII + URL query-string секретов из error event перед отправкой.
Композиция (обе 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).
"""
if not isinstance(event, dict):
return event
request = event.get("request")
@ -41,4 +137,36 @@ 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)
return event
def _redact_strings(obj: Any) -> Any:
"""Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ
строке (не только по конкретным ключам) токен может оказаться в locals
stack-фрейма, span data, breadcrumb message, request.url и т.д. Возвращает
НОВУЮ структуру (не мутирует `obj` в отличие от `_scrub`, чтобы не зависеть
от того, какие контейнеры sentry_sdk считает mutable в своём event dict)."""
if isinstance(obj, str):
redacted = _TG_BOT_TOKEN_RE.sub(_TG_BOT_TOKEN_REPLACEMENT, obj)
return _TG_BOT_TOKEN_BARE_RE.sub(_REDACTED, redacted)
if isinstance(obj, dict):
return {k: _redact_strings(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_redact_strings(v) for v in obj]
if isinstance(obj, tuple):
return tuple(_redact_strings(v) for v in obj)
return obj
def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Full-text regex redaction Telegram Bot API токена по ВСЕМУ event (#tgsupport).
Ловит оба вектора утечки токена в GlitchTip, которые ключ-based `scrub_pii_event`
не покрывает: locals stack-фреймов (`include_local_variables=True`) и httpx-span
`data` (полный request URL). Композировать с `scrub_pii_event`, не вместо него
разные классы секретов (PII полей формы vs bot-токен в URL).
"""
if not isinstance(event, dict):
return event
return _redact_strings(event) # type: ignore[return-value]

View file

@ -0,0 +1,146 @@
"""Pydantic-схемы admin read API над `user_events` (Feature 2 audit + Feature 3 analytics).
`user_events` (migration `184_user_events.sql`) unified append-only событийный лог
(login/IP audit + behavior analytics), admin-read-only. Эти схемы описывают ответы
`GET /api/v1/admin/audit/*` и `GET /api/v1/admin/analytics` (см. app/api/v1/audit.py).
"""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class AccountSummary(BaseModel):
"""Одна строка списка `GET /audit/accounts` — сводка по одному username."""
model_config = ConfigDict(from_attributes=True)
username: str
first_seen_at: datetime
last_seen_at: datetime
distinct_ips: int
distinct_devices: int
login_count: int
request_count: int
search_count: int
class AccountIpEntry(BaseModel):
"""Одна строка `ips` в drilldown `GET /audit/accounts/{username}`."""
model_config = ConfigDict(from_attributes=True)
ip_address: str | None = None
event_count: int
first_seen: datetime
last_seen: datetime
class AccountDeviceEntry(BaseModel):
"""Одна строка `devices` в drilldown `GET /audit/accounts/{username}`."""
model_config = ConfigDict(from_attributes=True)
user_agent: str | None = None
event_count: int
first_seen: datetime
last_seen: datetime
class AccountSearchEntry(BaseModel):
"""Одна строка `searches` в drilldown — недавний estimate_request."""
model_config = ConfigDict(from_attributes=True)
address: str | None = None
area_m2: str | None = None
rooms: str | None = None
estimate_id: str | None = None
ip_address: str | None = None
created_at: datetime
class AccountActivityEntry(BaseModel):
"""Одна строка `recent_activity` в drilldown — сырое событие."""
model_config = ConfigDict(from_attributes=True)
event_type: str
path: str | None = None
method: str | None = None
ip_address: str | None = None
created_at: datetime
class AccountDrilldown(BaseModel):
"""Полный ответ `GET /audit/accounts/{username}` — 4 списка.
Неизвестный username НЕ 404 это просто пустой отчёт (все списки == []).
"""
ips: list[AccountIpEntry]
devices: list[AccountDeviceEntry]
searches: list[AccountSearchEntry]
recent_activity: list[AccountActivityEntry]
class AnalyticsSummary(BaseModel):
"""Верхнеуровневые счётчики дашборда `GET /analytics`."""
model_config = ConfigDict(from_attributes=True)
total_events: int
distinct_users: int
events_last_24h: int
active_users_last_24h: int
class AnalyticsDailyPoint(BaseModel):
"""Одна точка time-series `daily` — события/пользователи за день."""
model_config = ConfigDict(from_attributes=True)
day: date
events: int
users: int
class AnalyticsTopSearch(BaseModel):
"""Одна строка `top_searches` — самый частый искомый адрес."""
model_config = ConfigDict(from_attributes=True)
address: str | None = None
n: int
class AnalyticsTopPath(BaseModel):
"""Одна строка `top_paths` — самый частый API-путь."""
model_config = ConfigDict(from_attributes=True)
path: str | None = None
n: int
class AnalyticsByAccount(BaseModel):
"""Одна строка `by_account` — сводка активности по username."""
model_config = ConfigDict(from_attributes=True)
username: str
events: int
searches: int
last_seen: datetime
class AnalyticsDashboard(BaseModel):
"""Полный ответ `GET /analytics` — бандл для дашборда Feature 3."""
summary: AnalyticsSummary
daily: list[AnalyticsDailyPoint]
top_searches: list[AnalyticsTopSearch]
top_paths: list[AnalyticsTopPath]
by_account: list[AnalyticsByAccount]

View file

@ -222,7 +222,9 @@ class AggregatedEstimate(BaseModel):
# конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг
# HouseAnalyticsKpi.
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.720.93
ratio_basis: str | None = None # 'per_rooms' | 'global_fallback'
# 'per_rooms' | 'global_fallback' | 'expected_over_median' (LOW audit #2: set when
# #2141's honest_ratio recompute overwrites the raw ratio_resolver value below).
ratio_basis: str | None = None
# ── DaData enrichment (PR Q1) — on-demand для target адреса ──
# canonical_address — DaData-нормализованная форма (с улицей в short form).
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
@ -266,7 +268,7 @@ class AggregatedEstimate(BaseModel):
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
# рекомендация не сработала (или флаг estimate_manual_review_enabled выключен).
# рекомендация не сработала.
manual_review_recommended: bool = False
manual_review_reasons: list[str] = Field(default_factory=list)
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──
@ -596,29 +598,48 @@ class QuotaStatus(BaseModel):
unlimited: bool # True для admin / kopylov / без заголовка
class LocationCoefFactorOut(BaseModel):
"""Один POI-фактор в ответе GET /api/v1/trade-in/location-coef (#2045 BE-3)."""
class NearbyPoiOut(BaseModel):
"""Один пункт «что рядом» в ответе GET /api/v1/trade-in/location-index.
Качественная справка (школа 185 м, остановка 93 м) НЕ участвует в location_index_pct.
"""
poi_type: str # категория POI (school/kindergarten/metro_stop/... — те же значения,
# что в osm_poi_ekb на стороне gendesign)
name: str | None
distance_m: float
weight: float
class LocationCoefResponse(BaseModel):
"""Ответ GET /api/v1/trade-in/location-coef (#2045 BE-3, LocationDrawer).
class LocationIndexResponse(BaseModel):
"""Ответ GET /api/v1/trade-in/location-index — замена сломанного location-coef.
coef MVP-эвристика (НЕ откалибрована на реальных ценовых дельтах, см.
app/services/location_coef.py::_score_to_coef), диапазон [0.95, 1.05].
result_price_rub = round(base_price_rub * coef).
ИСТОРИЯ: старый `location-coef` (`coef = 0.95 + score/100*0.10`, range [0.95,1.05],
`result_price_rub = round(base_price_rub * coef)`) не был откалиброван на ценах 67% из
1500 адресов ЕКБ попадали в ±1%, а бакеты coef НЕ монотонны относительно медианы /м² по
4000 активным лотам (дороже не значит выше coef). Полностью заменён.
geo_source="unavailable" osm_poi_ekb_local пуста/не отрефрешена на этом окружении
(graceful fallback: coef=1.0, factors=[], НЕ 500 и НЕ сфабрикованные факторы).
location_index_pct % отклонения медианы /м² сопоставимых активных листингов в радиусе
точки от медианы /м² по всему Екатеринбургу (см. app/services/location_index.py). НЕ
зажат искусственно диапазон реальный. НЕ участвует в цене (estimator.py про него не
знает: аналоги уже несут локацию в базовой цене, повторное умножение double-count).
status:
- "ok" location_index_pct/local_median_price_per_m2 надёжны.
- "out_of_coverage" точка вне гео-охвата продукта (Екатеринбург). Все числовые
поля None честный прочерк на фронте, НЕ 0%.
- "insufficient_data" даже на максимальном радиусе сопоставимых активных листингов
меньше порога (см. MIN_SAMPLE_SIZE). Числовые поля None, но sample_size/radius_m
показывают, что реально нашлось.
poi_status независимый статус для nearby_poi: "ok" | "unavailable" (osm_poi_ekb_local
ещё не отрефрешена на этом окружении пустой список, НЕ сфабрикованные точки).
"""
coef: float
factors: list[LocationCoefFactorOut]
geo_source: str
base_price_rub: int
result_price_rub: int
status: str
location_index_pct: float | None
local_median_price_per_m2: int | None
city_median_price_per_m2: int | None
sample_size: int
radius_m: int
nearby_poi: list[NearbyPoiOut]
poi_status: str

View file

@ -1,9 +1,22 @@
"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт.
Правила:
- Лимит = settings.estimate_quota_limit успешных оценок за календарный месяц
(UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, default 15.
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
- Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный
месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT,
default 15.
- Персональный override: таблица account_quota_overrides (username monthly_limit),
см. миграцию 185_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток
через negative `used` (ломал /quota «Осталось 50 из 15»).
- `used` в account_estimate_usage защищён CHECK (used >= 0) на уровне схемы, см.
миграцию 189_account_estimate_usage_nonnegative.sql 185 сбросила негативный
used только для user2, 189 закрывает остальные аккаунты + запрещает регресс.
В коде декремента `used` НЕТ increment() только `used + 1` под TOCTOU-guard
(#747); любой negative used приходит исключительно извне (ручной UPDATE).
- Без лимита (unlimited): роль admin (без похода в БД) ИЛИ персональный грант
account_quota_overrides.unlimited = true (миграция 191_account_quota_unlimited_flag.sql).
До миграции 191 unlimited для non-admin аккаунтов был захардкожен как
`username == 'kopylov'` прямо в коде данные (kopylov + praktika) заменяют этот
хардкод целиком, единый источник правды для всех безлимитных non-admin грантов.
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) unlimited,
лимит не применяется (fail-open).
@ -38,26 +51,67 @@ def current_period() -> str:
return datetime.now(UTC).strftime("%Y-%m")
def is_unlimited(username: str) -> bool:
def is_unlimited(db: Session, username: str) -> bool:
"""True если пользователь не ограничен квотой.
Unlimited: роль admin ИЛИ username == 'kopylov'.
KeyError (неизвестный пользователь) трактуется как limited (False).
Unlimited если:
- роль admin (RBAC roles.yaml, in-memory, БЕЗ похода в БД admin гарантированно
безлимитен по дизайну RBAC, отдельная per-user запись не нужна);
- ЛИБО персональный грант account_quota_overrides.unlimited = true (миграция
191) единственный источник правды для non-admin безлимитных аккаунтов,
включая kopylov (перенесён сюда этой же миграцией, до 191 был захардкожен
как `username == 'kopylov'`) и praktika (пилот восстановлен 2026-07-27).
KeyError (неизвестный пользователь, не в roles.yaml) трактуется как limited
(False), БЕЗ похода в БД override-таблица не источник правды для юзеров,
которых вообще нет в RBAC-конфиге.
"""
if username == "kopylov":
return True
try:
role = get_role(username)
return role == "admin"
except KeyError:
return False
if role == "admin":
return True
row = db.execute(
text(
"""
SELECT unlimited FROM account_quota_overrides
WHERE username = :u
"""
),
{"u": username},
).fetchone()
return bool(row is not None and row.unlimited)
def user_limit(db: Session, username: str) -> int:
"""Персональный месячный лимит для username, иначе глобальный MONTHLY_LIMIT.
Источник override таблица account_quota_overrides (см. миграцию
185_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через
negative `used`, который ломал /quota (limit=15, used=-35 remaining=50
«Осталось 50 из 15»).
"""
row = db.execute(
text(
"""
SELECT monthly_limit FROM account_quota_overrides
WHERE username = :u
"""
),
{"u": username},
).fetchone()
if row is not None and row.monthly_limit is not None:
return int(row.monthly_limit)
return MONTHLY_LIMIT
def get_status(db: Session, username: str | None) -> dict:
"""Возвращает статус квоты для пользователя.
Если username is None unlimited True, used 0, remaining 15.
Если unlimited used = фактический или 0, remaining = limit.
Если username is None unlimited True, used 0, remaining = MONTHLY_LIMIT.
Если unlimited used = фактический или 0, remaining = limit (per-user override
или глобальный MONTHLY_LIMIT).
"""
if username is None:
return {
@ -67,8 +121,9 @@ def get_status(db: Session, username: str | None) -> dict:
"unlimited": True,
}
unlimited = is_unlimited(username)
unlimited = is_unlimited(db, username)
period = current_period()
limit = user_limit(db, username)
row = db.execute(
text(
@ -84,15 +139,18 @@ def get_status(db: Session, username: str | None) -> dict:
if unlimited:
return {
"limit": MONTHLY_LIMIT,
"limit": limit,
"used": used,
"remaining": MONTHLY_LIMIT,
"remaining": limit,
"unlimited": True,
}
remaining = max(0, MONTHLY_LIMIT - used)
# Защитный кламп: remaining никогда не превышает limit, даже если used всё же
# снова просочится отрицательным (прежний бонус-хак) — max(0, used) обнуляет
# отрицательный used перед вычитанием.
remaining = max(0, limit - max(0, used))
return {
"limit": MONTHLY_LIMIT,
"limit": limit,
"used": used,
"remaining": remaining,
"unlimited": False,
@ -107,10 +165,11 @@ def check_and_raise(db: Session, username: str | None) -> None:
if username is None:
return
if is_unlimited(username):
if is_unlimited(db, username):
return
period = current_period()
limit = user_limit(db, username)
row = db.execute(
text(
"""
@ -122,13 +181,13 @@ def check_and_raise(db: Session, username: str | None) -> None:
).fetchone()
used = row.used if row is not None else 0
if used >= MONTHLY_LIMIT:
if used >= limit:
logger.warning(
"quota exhausted: username=%r period=%s used=%d limit=%d",
username,
period,
used,
MONTHLY_LIMIT,
limit,
)
raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE)
@ -140,14 +199,16 @@ def increment(db: Session, username: str | None) -> bool:
None / unlimited True (no-op success).
Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE
два параллельных запроса при used=MONTHLY_LIMIT-1 не могут оба инкрементировать
(второй упрётся в WHERE RETURNING пуст False). Свежая вставка (used=1) НЕ
задевается WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит.
два параллельных запроса при used=lim-1 не могут оба инкрементировать (второй
упрётся в WHERE RETURNING пуст False). Свежая вставка (used=1) НЕ задевается
WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит. `lim`
персональный лимит (user_limit), НЕ жёстко зашитый глобальный MONTHLY_LIMIT.
"""
if username is None or is_unlimited(username):
if username is None or is_unlimited(db, username):
return True
period = current_period()
lim = user_limit(db, username)
row = db.execute(
text(
"""
@ -161,7 +222,7 @@ def increment(db: Session, username: str | None) -> bool:
RETURNING used
"""
),
{"u": username, "p": period, "lim": MONTHLY_LIMIT},
{"u": username, "p": period, "lim": lim},
).fetchone()
db.commit()
ok = row is not None
@ -172,6 +233,6 @@ def increment(db: Session, username: str | None) -> bool:
"quota increment refused (atomic, #747): username=%r period=%s limit=%d",
username,
period,
MONTHLY_LIMIT,
lim,
)
return ok

View file

@ -28,7 +28,7 @@ class Brand:
_DEFAULT = Brand(
slug="generic",
name="Trade-In Estimator",
name="МЕРА",
logo_url=None,
primary_color="#1d4ed8",
accent_color="#f59e0b",

View file

@ -71,6 +71,24 @@ _MFE_AUTH = "header-frontend"
# Callers that need to distinguish ban from valid auth should check state.get("_ban").
VERIFY_BAN_SENTINEL: dict[str, Any] = {"_ban": True}
# audit-scrapers finding 4: verify_session раньше сводило 5xx / сетевой сбой /
# смену вёрстки к тому же None, что и реальный логаут (401 / isAuthenticated=false) —
# вызывающие (_cian_pre_claim, admin upload/auto-login) реагировали "куки протухли,
# перезалей" там, где куки ни при чём (Cian недоступен ИЛИ scraper_kit.cian_state_parser
# больше не находит header-frontend initialState). Два отдельных сигнала ниже НЕ
# триггерят "cookies expired" алерт у вызывающих.
# Cian источник недоступен прямо сейчас (5xx-ответ ИЛИ сетевой/транспортный сбой —
# timeout, DNS, connection reset). Cookies могут быть абсолютно валидны — просто
# нечем было их проверить. Retry позже, БЕЗ пометки session invalid.
VERIFY_SOURCE_UNAVAILABLE_SENTINEL: dict[str, Any] = {"_source_unavailable": True}
# HTTP 200 получен, но ожидаемый auth-state (header-frontend/initialState с
# user.isAuthenticated) не найден/не распарсился — Cian изменил вёрстку/MFE-схему.
# Это engineering-проблема (extract_state/_MFE_AUTH нужно обновить), НЕ протухшие
# cookies — переставлять куки здесь бесполезно.
VERIFY_MARKUP_CHANGED_SENTINEL: dict[str, Any] = {"_markup_changed": True}
def _classify_verify_response(
status_code: int,
@ -79,19 +97,25 @@ def _classify_verify_response(
"""Pure classifier — maps (status_code, html) to verify_session outcome.
Returns:
VERIFY_BAN_SENTINEL 403/TLS ban (cookies may be fine, server is blocking)
None 401 or isAuthenticated=false (cookies genuinely expired)
VERIFY_BAN_SENTINEL 403/TLS ban (cookies могут быть в порядке,
блокирует сервер)
VERIFY_SOURCE_UNAVAILABLE_SENTINEL 5xx/иной non-200 без содержимого
источник недоступен, НЕ cookies
VERIFY_MARKUP_CHANGED_SENTINEL HTTP 200, но auth-state не найден/не
распарсился вёрстка/схема изменилась
None 401 ИЛИ isAuthenticated=false cookies
ДЕЙСТВИТЕЛЬНО протухли/разлогинены
state dict authenticated successfully
"""
if status_code == 403:
return VERIFY_BAN_SENTINEL
if status_code == 401:
return None
if html is None:
return None
if status_code != 200 or html is None:
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
state = extract_state(html, mfe=_MFE_AUTH, key="initialState")
if state is None:
return None
return VERIFY_MARKUP_CHANGED_SENTINEL
user = state.get("user", {}) or {}
if not user.get("isAuthenticated"):
return None
@ -104,11 +128,18 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
TLS-fingerprint bans that httpx would trigger.
Returns:
state dict authenticated (contains user.isAuthenticated + userId)
VERIFY_BAN_SENTINEL HTTP 403 TLS/bot ban; cookies may still be valid
callers should NOT trigger a cookie-refresh alert
None HTTP 401 or isAuthenticated=false; cookies expired
Returns (проверяй через `is`, НЕ `==` это sentinel-объекты):
state dict authenticated (user.isAuthenticated + userId)
VERIFY_BAN_SENTINEL HTTP 403 TLS/bot ban; cookies могут быть
валидны НЕ триггерить cookie-refresh alert
VERIFY_SOURCE_UNAVAILABLE_SENTINEL 5xx/network/timeout; источник недоступен,
НЕ триггерить cookie-refresh alert, retry позже
VERIFY_MARKUP_CHANGED_SENTINEL HTTP 200 но auth-state не распарсился;
Cian изменил вёрстку НЕ cookie-проблема,
нужен engineering-фикс extract_state/_MFE_AUTH
None HTTP 401 или isAuthenticated=false; cookies
ДЕЙСТВИТЕЛЬНО протухли здесь и только здесь
имеет смысл просить re-upload
Никогда не логирует сырые значения cookies.
"""
@ -134,6 +165,18 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
logger.warning(
"Cian cookies verify: HTTP 403 TLS/bot ban — cookies NOT marked expired"
)
elif result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
logger.warning(
"Cian cookies verify: source unavailable (status=%d) — "
"cookies NOT marked expired, retry later",
status,
)
elif result is VERIFY_MARKUP_CHANGED_SENTINEL:
logger.error(
"Cian cookies verify: HTTP 200 but auth-state not found/parseable "
"(mfe=%s) — markup/schema changed, cookies NOT marked expired",
_MFE_AUTH,
)
elif result is None:
logger.warning("Cian cookies verify: expired/unauthenticated (status=%d)", status)
else:
@ -142,8 +185,11 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
return result
except Exception as exc:
logger.warning("Cian cookies verify failed: %s", exc)
return None
# Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
# недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
# возвращался None, конфлируя с реальным логаутом.
logger.warning("Cian cookies verify: transport/network error — %s", exc)
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
def save_session(

View file

@ -167,9 +167,26 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
return None
if status in (401, 403):
body_preview = (response.text or "")[:200]
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
if status == 403 and (
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
):
logger.error(
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
body_preview,
)
else:
logger.error(
"dadata: HTTP %d — auth/secret rejected. "
"Проверь DADATA_API_TOKEN/SECRET. Ответ: %r",
status,
body_preview,
)
return None
if status >= 500:
@ -246,6 +263,12 @@ class DadataSuggestion:
street: str | None
city: str | None
kind: str
# #dadata-403 — доп. поля для suggest-fallback в enrich_address (когда CLEAN
# выключен на аккаунте). house_fias_id — ФИАС ДОМА (для house-level == fias_id);
# kladr_id / qc_geo дублируют то, что отдаёт /clean, чтобы деградация была равноценной.
house_fias_id: str | None = None
kladr_id: str | None = None
qc_geo: int | None = None
def _classify_kind(fias_level: Any) -> str:
@ -295,11 +318,17 @@ def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
street=data.get("street"),
city=data.get("city"),
kind=_classify_kind(data.get("fias_level")),
house_fias_id=data.get("house_fias_id"),
kladr_id=data.get("kladr_id"),
qc_geo=_coerce_int(data.get("qc_geo")),
)
async def suggest_addresses(
query: str, limit: int = 8, city: str = "Екатеринбург"
query: str,
limit: int = 8,
city: str | None = "Екатеринбург",
region: str | None = None,
) -> list[DadataSuggestion]:
"""Автокомплит адресов через DaData /suggest/address.
@ -307,6 +336,13 @@ async def suggest_addresses(
query: текст для подсказки (минимум 2 символа).
limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
Игнорируется, если задан `region`.
region: область для hard-constraint в `locations` используется вместо
`city`, когда нужен охват шире одного города (например, вся
Свердловская область / region 66). DaData `locations` сам по себе уже
жёсткий фильтр (не boost) на уровне указанного admin-поля доп.
параметров не требуется. По умолчанию не задан поведение (и body
запроса) для существующих вызовов не меняется.
Returns:
list[DadataSuggestion] пустой список если:
@ -334,7 +370,13 @@ async def suggest_addresses(
"query": query.strip(),
"count": max(1, min(int(limit), 20)),
}
if city:
if region:
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,
# поэтому не добавляем.
body["locations"] = [{"region": region}]
elif city:
body["locations"] = [{"city": city}]
try:
@ -352,9 +394,7 @@ async def suggest_addresses(
logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
return []
if status in (401, 403):
logger.error(
"dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status
)
logger.error("dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status)
return []
if status >= 500:
logger.warning("dadata suggest: HTTP %d — transient server error", status)
@ -387,3 +427,55 @@ async def suggest_addresses(
logger.info("dadata suggest: %r%d вариантов", query[:60], len(out))
return out
# ── Enrichment orchestrator (clean → suggest fallback) ───────────────────────
async def enrich_address(address: str) -> DadataAddressResult | None:
"""Обогащение целевого адреса с graceful-деградацией CLEAN → SUGGEST.
Порядок:
1. /clean/address (token+secret) canonical + house_cadnum + house_fias_id +
qc_geo + metro. Если вернул результат используем его.
2. Fallback: /suggest/address (token-only, доступен на free-tier 10k/день)
берём top house-level кандидата. Даёт house_fias_id + гео + kladr_id + qc_geo,
но НЕ canonical_address / house_cadnum (их умеет только CLEAN) они остаются
None. Честная деградация: недостающие поля не выдумываем.
Мотивация (#dadata-403, vault Mera_Hard_Audit_0712): на проде у аккаунта услуга
CLEAN выключена (HTTP 403 «Feature CLEAN disabled»), из-за чего house_fias_id
ключ join с houses/ДОМ.РФ (year_built и пр.) терялся, хотя /suggest его отдаёт
бесплатно. Город НЕ ограничиваем (city="") enrichment работает по всей обл. (66).
"""
cleaned = await clean_address(address)
if cleaned is not None:
return cleaned
suggestions = await suggest_addresses(address, limit=5, city="")
for s in suggestions:
fias = s.house_fias_id or s.fias_id
if s.kind == "house" and fias:
# house-level ФИАС → гео дома точное; qc_geo из ответа либо 0 (exact) по умолчанию.
qc_geo = s.qc_geo if s.qc_geo is not None else 0
logger.info(
"dadata: CLEAN недоступен → suggest-fallback %r → house_fias_id=%s "
"(canonical/cadnum недоступны без CLEAN)",
address[:60],
fias,
)
return DadataAddressResult(
canonical_address=None,
house_cadnum=None,
house_fias_id=fias,
lat=s.lat,
lon=s.lon,
qc_geo=qc_geo,
qc_house=None,
kladr_id=s.kladr_id,
okato=None,
oktmo=None,
metro=[],
raw={"_source": "suggest_fallback", "value": s.value},
)
return None

View file

@ -0,0 +1,578 @@
"""ДОМ.РФ капремонт open data loader: houses.year_built/material_walls (issue #2013).
CONTEXT: houses.year_built заполнен только на 38%, houses.material_walls на 0%.
Существующий ГИС-ЖКХ loader (zhkh_flats_loader.py, мигр. 146/149) заполняет
houses.zhkh_year (70%) и houses.zhkh_floors (70%), НО никогда не копирует их в
houses.year_built/total_floors это основной пробел. material_walls вообще ни разу
не заполнялся никаким источником. Estimator (app/services/estimator.py) фильтрует
когорту по `year_built BETWEEN ...` реальные годы напрямую двигают точность оценки.
ИСТОЧНИК: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
КР1.1 house registry (export/190) zip CSV, delimiter ';', UTF-8 BOM. Колонки:
mkd_code, houseguid (ФИАС GUID дома), address, commission_year (год ввода, int),
total_sq (decimal, запятая «909,80»), number_floors_max (int), + служебные.
КР1.2 constructive elements (export/275) zip CSV, тот же delimiter/encoding.
LONG FORMAT: одна строка на конструктивный элемент на mkd_code; wall_material
заполнен ТОЛЬКО на строке construction_element_type='фасад'. Чтобы получить
материал стен на дом группируем по mkd_code, берём первую строку с непустым
wall_material.
TLS: домен домрф.рф отдаёт RU-сертификат, который httpx с default trust store не
верифицирует ("unable to get local issuer certificate") та же ситуация, что и у
sber_index.py. Открытые неавторизованные данные, поэтому используем verify=False,
как остальные RU-gov loader'ы в этом репо.
Матч staginghouses: domrf_kapremont.houseguid = COALESCE(houses.gar_house_guid,
houses.house_fias_id, houses.zhkh_house_guid) простой приоритетный COALESCE-джойн
(без multi-attempt fallback-если-нет-матча see PR discussion, "keep it simple/safe").
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
"""
from __future__ import annotations
import csv
import io
import logging
import tempfile
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Константы
# ─────────────────────────────────────────────────────────────────────────────
# export/190 (КР1.1) и export/275 (КР1.2) без доп. параметров отдают Свердловскую
# обл. (region 66) — подтверждено инспекцией скачанных файлов. Для других регионов
# понадобится region-gid параметр (не реализовано — вне скоупа #2013).
KR11_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/190"
KR12_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/275"
DOWNLOAD_TIMEOUT_SEC = 180
# UPSERT в staging чанками (в одном SAVEPOINT), как zhkh_flats_loader.SAVEPOINT_CHUNK —
# сбойный чанк откатывается изолированно, остальные доезжают.
UPSERT_CHUNK_SIZE = 500
# Правдоподобные границы года постройки. Значение вне диапазона — почти наверняка
# опечатка/мусор: для МКД-реестра капремонта дом < 1850 нереален, а > текущий+2 ещё
# физически не введён в эксплуатацию. Такой год НЕ должен (а) попадать из ДОМ.РФ CSV
# в staging (parse-гейт в parse_int_field), (б) экранировать авторитетный ДОМ.РФ
# commission_year при бэкфилле houses.year_built (SQL-гейт в _BACKFILL_*_SQL).
YEAR_BUILT_MIN = 1850
YEAR_BUILT_FUTURE_SLACK = 2 # дом «в стройке» — текущий год + 2 ещё допустим
def plausible_year_max() -> int:
"""Верхняя граница правдоподобного года постройки (текущий год + slack)."""
return date.today().year + YEAR_BUILT_FUTURE_SLACK
# ─────────────────────────────────────────────────────────────────────────────
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
# ─────────────────────────────────────────────────────────────────────────────
def parse_decimal_comma(raw: str | None) -> float | None:
"""«909,80» / «909.80» / «» / None → float|None. Запятая — decimal separator ДОМ.РФ CSV."""
if raw is None:
return None
s = raw.strip()
if not s:
return None
try:
return float(s.replace(",", "."))
except ValueError:
return None
def parse_int_field(
raw: str | None,
*,
min_value: int | None = None,
max_value: int | None = None,
) -> int | None:
"""Устойчивый str → int|None (commission_year / number_floors_max).
Пусто/None/нечисло None. Терпит decimal-строки («5,0») через float-фоллбек
ДОМ.РФ CSV в принципе может так отдать целочисленные поля.
min_value/max_value опциональный sanity-гейт: распарсенное значение вне
[min_value, max_value] трактуется как None (для commission_year передаём
YEAR_BUILT_MIN..plausible_year_max(), чтобы мусорный год не попал в staging;
для number_floors_max границы не задаём там гейт не требуется).
"""
if raw is None:
return None
s = raw.strip()
if not s:
return None
value: int | None = None
try:
value = int(s)
except ValueError:
try:
value = int(float(s.replace(",", ".")))
except ValueError:
return None
if min_value is not None and value < min_value:
return None
if max_value is not None and value > max_value:
return None
return value
@dataclass(slots=True)
class Kr11Row:
"""Одна строка КР1.1 house registry (house-per-row)."""
mkd_code: str
houseguid: str | None
address: str | None
commission_year: int | None
number_floors_max: int | None
total_sq: float | None
@dataclass(slots=True)
class DomrfHouse:
"""Объединённая КР1.1 + КР1.2(wall_material) строка — готова к UPSERT в staging."""
mkd_code: str
houseguid: str | None
address: str | None
commission_year: int | None
number_floors_max: int | None
total_sq: float | None
wall_material: str | None
def _open_csv(path: str | Path) -> Iterator[dict[str, str]]:
"""csv.DictReader по ДОМ.РФ CSV: delimiter ';', UTF-8 BOM (utf-8-sig)."""
with open(path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f, delimiter=";")
yield from reader
def parse_kr11_csv(path: str | Path) -> dict[str, Kr11Row]:
"""КР1.1 → dict по mkd_code. Строки без mkd_code пропускаются.
Дубликат mkd_code (не ожидается mkd_code PK в реестре ДОМ.РФ) последняя
строка побеждает (совпадает с семантикой ON CONFLICT DO UPDATE ниже).
"""
rows: dict[str, Kr11Row] = {}
for raw in _open_csv(path):
mkd_code = (raw.get("mkd_code") or "").strip()
if not mkd_code:
continue
rows[mkd_code] = Kr11Row(
mkd_code=mkd_code,
houseguid=(raw.get("houseguid") or "").strip() or None,
address=(raw.get("address") or "").strip() or None,
# sanity-гейт: год вне [1850, текущий+2] — мусор, не пускаем в staging
# (иначе он позже мог бы записаться в houses.year_built как «авторитетный»).
commission_year=parse_int_field(
raw.get("commission_year"),
min_value=YEAR_BUILT_MIN,
max_value=plausible_year_max(),
),
number_floors_max=parse_int_field(raw.get("number_floors_max")),
total_sq=parse_decimal_comma(raw.get("total_sq")),
)
return rows
def parse_kr12_wall_materials(path: str | Path) -> dict[str, str]:
"""КР1.2 (long-format) → dict mkd_code → wall_material.
wall_material непустой ТОЛЬКО на строке construction_element_type='фасад'
группировка не по этому полю, а просто "первая непустая wall_material на
mkd_code" (устойчиво даже если разметка типа элемента когда-то изменится).
"""
materials: dict[str, str] = {}
for raw in _open_csv(path):
mkd_code = (raw.get("mkd_code") or "").strip()
wall = (raw.get("wall_material") or "").strip()
if mkd_code and wall and mkd_code not in materials:
materials[mkd_code] = wall
return materials
def build_domrf_houses(
kr11: dict[str, Kr11Row], wall_materials: dict[str, str]
) -> list[DomrfHouse]:
"""КР1.1 rows + КР1.2 wall_material lookup → список DomrfHouse (для UPSERT)."""
return [
DomrfHouse(
mkd_code=row.mkd_code,
houseguid=row.houseguid,
address=row.address,
commission_year=row.commission_year,
number_floors_max=row.number_floors_max,
total_sq=row.total_sq,
wall_material=wall_materials.get(row.mkd_code),
)
for row in kr11.values()
]
# ─────────────────────────────────────────────────────────────────────────────
# HTTP: скачивание zip + извлечение CSV
# ─────────────────────────────────────────────────────────────────────────────
def _download_zip(url: str, *, client: httpx.Client) -> bytes:
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
def _extract_csv_from_zip(data: bytes, dest_dir: Path) -> Path:
"""Распаковывает первый *.csv из zip-байтов в dest_dir, возвращает путь."""
with zipfile.ZipFile(io.BytesIO(data)) as zf:
csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
if not csv_names:
raise ValueError(f"zip не содержит .csv записей: {zf.namelist()!r}")
extracted = zf.extract(csv_names[0], dest_dir)
return Path(extracted)
def fetch_domrf_csvs(dest_dir: Path, *, client: httpx.Client) -> tuple[Path, Path]:
"""Скачивает КР1.1 + КР1.2 zip'ы, распаковывает в dest_dir. Возвращает (kr11, kr12)."""
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest_dir)
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest_dir)
return kr11_path, kr12_path
# ─────────────────────────────────────────────────────────────────────────────
# UPSERT staging (domrf_kapremont, мигр. 176)
# ─────────────────────────────────────────────────────────────────────────────
_UPSERT_SQL = text(
"""
INSERT INTO domrf_kapremont (
mkd_code, houseguid, address, commission_year,
number_floors_max, total_sq, wall_material, loaded_at
)
VALUES (
CAST(:mkd_code AS text), CAST(:houseguid AS text), CAST(:address AS text),
CAST(:commission_year AS smallint), CAST(:number_floors_max AS int),
CAST(:total_sq AS numeric), CAST(:wall_material AS text), now()
)
ON CONFLICT (mkd_code) DO UPDATE SET
houseguid = EXCLUDED.houseguid,
address = EXCLUDED.address,
commission_year = EXCLUDED.commission_year,
number_floors_max = EXCLUDED.number_floors_max,
total_sq = EXCLUDED.total_sq,
wall_material = EXCLUDED.wall_material,
loaded_at = now()
WHERE domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid
OR domrf_kapremont.address IS DISTINCT FROM EXCLUDED.address
OR domrf_kapremont.commission_year IS DISTINCT FROM EXCLUDED.commission_year
OR domrf_kapremont.number_floors_max IS DISTINCT FROM EXCLUDED.number_floors_max
OR domrf_kapremont.total_sq IS DISTINCT FROM EXCLUDED.total_sq
OR domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material
"""
)
def _chunk_houses(items: list[DomrfHouse], size: int) -> Iterator[list[DomrfHouse]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def upsert_domrf_kapremont(
db: Session, houses: list[DomrfHouse], *, chunk_size: int = UPSERT_CHUNK_SIZE
) -> int:
"""UPSERT списка DomrfHouse в domrf_kapremont, чанками по SAVEPOINT. Не коммитит (caller).
Идемпотентно (IS DISTINCT FROM gate в _UPSERT_SQL повторный прогон с теми же
данными не трогает уже актуальные строки). Сбойный чанк откатывается изолированно
(та же схема, что zhkh_flats_loader.match_houses_to_zhkh).
"""
upserted = 0
for chunk in _chunk_houses(houses, chunk_size):
try:
with db.begin_nested():
for h in chunk:
res = db.execute(
_UPSERT_SQL,
{
"mkd_code": h.mkd_code,
"houseguid": h.houseguid,
"address": h.address,
"commission_year": h.commission_year,
"number_floors_max": h.number_floors_max,
"total_sq": h.total_sq,
"wall_material": h.wall_material,
},
)
upserted += res.rowcount
except Exception:
logger.warning(
"domrf_kapremont upsert: чанк из %d строк сбойнул (откат savepoint)",
len(chunk),
exc_info=True,
)
return upserted
def load_domrf_kapremont(
db: Session,
*,
kr11_path: str | Path | None = None,
kr12_path: str | Path | None = None,
work_dir: str | Path | None = None,
chunk_size: int = UPSERT_CHUNK_SIZE,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачивает (если пути не заданы) КР1.1+КР1.2, парсит, UPSERT в domrf_kapremont.
kr11_path/kr12_path локальные CSV (пропустить скачивание; используется тестами
и ops-дебагом). work_dir куда распаковывать скачанные zip (по умолчанию
временный каталог, удаляется после загрузки). dry_run парс происходит, но НИ
ОДНОЙ записи в БД не делается. Не коммитит (caller).
"""
tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
if kr11_path is None or kr12_path is None:
if work_dir is not None:
dest = Path(work_dir)
dest.mkdir(parents=True, exist_ok=True)
else:
tmp_ctx = tempfile.TemporaryDirectory()
dest = Path(tmp_ctx.name)
# verify=False: домрф.рф отдаёт RU-сертификат, не верифицируемый дефолтным
# trust store'ом (та же ситуация, что sber_index.py). Публичные open data,
# без auth/PII — приемлемо skip'нуть TLS-верификацию.
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client:
if kr11_path is None:
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest)
if kr12_path is None:
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest)
try:
kr11 = parse_kr11_csv(kr11_path)
wall_materials = parse_kr12_wall_materials(kr12_path)
houses = build_domrf_houses(kr11, wall_materials)
upserted = 0 if dry_run else upsert_domrf_kapremont(db, houses, chunk_size=chunk_size)
finally:
if tmp_ctx is not None:
tmp_ctx.cleanup()
result = {
"kr11_rows": len(kr11),
"kr12_wall_rows": len(wall_materials),
"houses_built": len(houses),
"upserted": upserted,
}
logger.info("domrf_kapremont load DONE (dry_run=%s): %s", dry_run, result)
return result
# ─────────────────────────────────────────────────────────────────────────────
# Backfill houses.year_built/material_walls/total_floors
# ─────────────────────────────────────────────────────────────────────────────
# Шаг 1: дома с domrf-матчем — year_built/material_walls/total_floors из domrf,
# С ЗАФОЛЖЕННЫМ zhkh_year/zhkh_floors фоллбеком (COALESCE, как в issue #2013).
#
# ПРИОРИТЕТ year_built (Fix A): ВАЛИДНОЕ существующее значение выигрывает (COALESCE
# семантика сохранена для valid). НО невозможное существующее значение (< :ymin,
# > :ymax или NULL) НЕ должно экранировать авторитетный ДОМ.РФ commission_year —
# для домов реестра капремонта commission_year это источник истины по году ввода.
# Поэтому impossible/NULL заменяется валидным commission_year → затем валидным
# zhkh_year; если валидной замены нет — оставляем существующее как есть (не обнуляем
# даже мусор, чтобы не терять данные без выигрыша). Кандидаты замены тоже проходят
# плаузибилити-гейт [:ymin, :ymax], чтобы в houses.year_built не попал мусорный год.
# material_walls/total_floors — прежняя COALESCE-семантика (только заполнить NULL).
_BACKFILL_HOUSES_FROM_DOMRF_SQL = text(
"""
UPDATE houses h
SET year_built = CASE
WHEN h.year_built BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.year_built
ELSE COALESCE(
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN d.commission_year END,
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.zhkh_year END,
h.year_built
)
END,
material_walls = COALESCE(h.material_walls, d.wall_material),
total_floors = COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)
FROM domrf_kapremont d
WHERE d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
AND (
(
(h.year_built IS NULL
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
AND COALESCE(
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN d.commission_year END,
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.zhkh_year END
) IS NOT NULL
)
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
)
"""
)
_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL = text(
"""
SELECT count(*)
FROM houses h
JOIN domrf_kapremont d
ON d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
WHERE (
(h.year_built IS NULL
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
AND COALESCE(
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN d.commission_year END,
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.zhkh_year END
) IS NOT NULL
)
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
"""
)
# Шаг 2: ЛЮБОЙ дом (включая без domrf-матча вообще — шаг 1 INNER JOIN их не трогает)
# добивается zhkh_year/zhkh_floors, если ещё NULL. Домам, уже тронутым шагом 1, шаг 2
# ничего не меняет: их COALESCE в шаге 1 уже включал zhkh_* как fallback, поэтому
# гейт `IS NULL AND zhkh_* IS NOT NULL` здесь для них ложен — шаги взаимоисключающи
# (не двойной счёт при суммировании houses_updated).
_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL = text(
"""
UPDATE houses
SET year_built = COALESCE(year_built, zhkh_year),
total_floors = COALESCE(total_floors, zhkh_floors)
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
"""
)
_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL = text(
"""
SELECT count(*)
FROM houses
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
"""
)
def backfill_houses_from_domrf(db: Session, *, dry_run: bool = False) -> dict[str, int]:
"""COALESCE(year_built, domrf.commission_year, zhkh_year) + material_walls + total_floors.
Двухшаговый UPDATE (мирроринг zhkh guid-match cadastre-fallback паттерна из
zhkh_flats_loader.py):
1) дома с domrf-матчем (COALESCE(gar_house_guid, house_fias_id, zhkh_house_guid) =
domrf_kapremont.houseguid) заполняются из domrf, с zhkh_year/zhkh_floors
фоллбеком внутри той же COALESCE.
2) ЛЮБОЙ дом (в т.ч. без domrf-матча) добивает year_built/total_floors из
zhkh_year/zhkh_floors, если шаг 1 их не тронул.
Только COALESCE (заполняет NULL, никогда не перезаписывает existing non-null).
Не коммитит (caller). dry_run ноль записей, только SELECT count(*) по тем же
предикатам.
"""
year_bounds = {"ymin": YEAR_BUILT_MIN, "ymax": plausible_year_max()}
if dry_run:
domrf_matched = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL, year_bounds).scalar_one()
zhkh_fallback = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL).scalar_one()
result = {
"domrf_matched": domrf_matched,
"zhkh_fallback": zhkh_fallback,
"houses_updated": 0,
}
logger.info("backfill_houses_from_domrf DRY-RUN: %s", result)
return result
domrf_updated = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_SQL, year_bounds).rowcount
zhkh_updated = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL).rowcount
result = {
"domrf_matched": domrf_updated,
"zhkh_fallback": zhkh_updated,
"houses_updated": domrf_updated + zhkh_updated,
}
logger.info("backfill_houses_from_domrf DONE: %s", result)
return result
# ─────────────────────────────────────────────────────────────────────────────
# Propagate houses.year_built → listings.year_built
# ─────────────────────────────────────────────────────────────────────────────
# Только NULL listings.year_built, только когда houses.year_built уже известен —
# никогда не перезаписывает существующий listings.year_built (source-provided данные
# приоритетнее houses-агрегата).
#
# LINK-CONSISTENCY GUARD (Fix B): раньше пропагация шла по house_id_fk БЕЗ проверки,
# что listing реально относится к этому дому. Перепривязанный FK (house dedup/merge
# «съезжает» на дом за сотни метров) впрыскивал бы чужой когортный год в живой comp.
# Пропагируем только если координаты самого объявления в пределах
# PROPAGATE_MAX_DISTANCE_M от геометрии дома. Если у объявления нет координат —
# консервативный address-фоллбек: адрес объявления должен содержать short_address
# дома (тот же дом по адресной строке); нет short_address — не пропагируем.
PROPAGATE_MAX_DISTANCE_M = 500
# Общий link-consistency предикат для UPDATE и COUNT (единый источник — одна правка
# порога PROPAGATE_MAX_DISTANCE_M меняет обе SQL). Порог — int-константа, не bind-param
# (в ST_DWithin проще литерал; значение доверенное, не из пользовательского ввода).
_PROPAGATE_LINK_GUARD = f"""
AND (
(l.geom IS NOT NULL AND h.geom IS NOT NULL
AND ST_DWithin(
CAST(l.geom AS geography), CAST(h.geom AS geography),
{PROPAGATE_MAX_DISTANCE_M}))
OR (l.geom IS NULL
AND h.short_address IS NOT NULL AND l.address IS NOT NULL
AND l.address ILIKE '%' || h.short_address || '%')
)
"""
_PROPAGATE_LISTINGS_YEAR_SQL = text(
f"""
UPDATE listings l
SET year_built = h.year_built
FROM houses h
WHERE l.house_id_fk = h.id
AND l.year_built IS NULL
AND h.year_built IS NOT NULL
{_PROPAGATE_LINK_GUARD}
"""
)
_PROPAGATE_LISTINGS_YEAR_COUNT_SQL = text(
f"""
SELECT count(*)
FROM listings l
JOIN houses h ON l.house_id_fk = h.id
WHERE l.year_built IS NULL
AND h.year_built IS NOT NULL
{_PROPAGATE_LINK_GUARD}
"""
)
def propagate_listings_year_from_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
"""UPDATE listings.year_built = houses.year_built где listing.year_built ещё NULL.
Не коммитит (caller). dry_run ноль записей, только SELECT count(*) по тому же
предикату (ключ результата would_update вместо listings_updated).
"""
if dry_run:
would_update = db.execute(_PROPAGATE_LISTINGS_YEAR_COUNT_SQL).scalar_one()
logger.info("propagate_listings_year_from_houses DRY-RUN: would_update=%d", would_update)
return {"listings_updated": 0, "would_update": would_update}
updated = db.execute(_PROPAGATE_LISTINGS_YEAR_SQL).rowcount
logger.info("propagate_listings_year_from_houses DONE: listings_updated=%d", updated)
return {"listings_updated": updated}

File diff suppressed because it is too large Load diff

View file

@ -40,7 +40,6 @@ import logging
import pathlib
import re
from urllib.parse import urlparse
from uuid import UUID
import matplotlib
@ -376,11 +375,60 @@ def _mono(text: str) -> str:
return f"<span class='mono'>{text}</span>"
def _report_number(estimate_id: UUID) -> str:
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
short = int(estimate_id.int) % 10_000
long = int(estimate_id.int) % 10_000_000_000
return f"EKБ-{short:04d}-{long:010d}"
# Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
# захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
# екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
# теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
# _resolve_target_city в estimator.py). Город вне карты → _DEFAULT_REPORT_PREFIX.
_CITY_REPORT_PREFIX: dict[str, str] = {
"екатеринбург": "ЕКБ",
"нижний тагил": "НТ",
"каменск-уральский": "КУ",
"первоуральск": "ПРВ",
"серов": "СЕР",
"новоуральск": "НВУ",
"ревда": "РЕВ",
"полевской": "ПЛВ",
"асбест": "АСБ",
"верхняя пышма": "ВП",
"березовский": "БРЗ",
"краснотурьинск": "КРТ",
"камышлов": "КМШ",
}
# Нейтральный префикс, когда город адреса не распознан (пустой / вне региона) —
# НЕ выдумываем локацию, не подставляем ложный «EKБ» (#pdf-honesty).
_DEFAULT_REPORT_PREFIX = "МЕРА"
# Длинные имена раньше коротких — чтобы «нижний тагил» матчился целиком.
_REPORT_CITY_RE = re.compile(
r"\b(?:"
+ "|".join(re.escape(c) for c in sorted(_CITY_REPORT_PREFIX, key=len, reverse=True))
+ r")\b"
)
def _report_city_prefix(address: str | None) -> str:
"""Буквенный префикс № отчёта по городу из адреса (region 66).
«МЕРА» если город не распознан не выдумываем локацию (раньше был хардкод
«EKБ» для любого адреса, включая не-екатеринбургские). ёе нормализация до
поиска (симметрично _resolve_target_city в estimator.py)."""
if not address:
return _DEFAULT_REPORT_PREFIX
norm = address.replace("ё", "е").replace("Ё", "е").lower()
m = _REPORT_CITY_RE.search(norm)
return _CITY_REPORT_PREFIX[m.group(0)] if m else _DEFAULT_REPORT_PREFIX
def _report_number(estimate: AggregatedEstimate) -> str:
"""№ отчёта: 'PREFIX-NNNN-XXXXXXXXXX'.
PREFIX буквенный код города оценки (ЕКБ / НТ / по target_address, «МЕРА»
если город не распознан); NNNN/XXXXXXXXXX детерминированные коды от
estimate_id. Раньше PREFIX был захардкожен «EKБ» для всех оценок (#pdf-honesty)."""
prefix = _report_city_prefix(estimate.target_address or estimate.canonical_address)
short = int(estimate.estimate_id.int) % 10_000
long = int(estimate.estimate_id.int) % 10_000_000_000
return f"{prefix}-{short:04d}-{long:010d}"
def _expires_date(estimate: AggregatedEstimate) -> dt.date:
@ -518,7 +566,7 @@ def _page_footer(
# тела страницы) и был источником сложности; заменён на простую тонкую
# градиентную линию-разделитель между строками meta/wordmark.
return f"""
<div style="margin-top:20pt;">
<div style="margin-top:6pt;">
{note_html}
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
@ -811,8 +859,8 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
)
explainer = (
f'<p style="margin:6pt 0 2pt 0;padding:6pt 10pt;background:{_ACCENT_SOFT};'
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.3;">'
f'<p style="margin:4pt 0 2pt 0;padding:4pt 10pt;background:{_ACCENT_SOFT};'
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.25;">'
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
"Росреестра, обычно ниже запроса."
@ -821,7 +869,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
return (
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
f'margin:6pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
f'margin:3pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
f"{explainer}"
)
@ -970,7 +1018,7 @@ def _quality_card_html(estimate: AggregatedEstimate) -> str:
cv_str = f"{cv}%" if cv is not None else ""
gauge = _data_quality_gauge_svg(estimate.confidence)
return f"""
<div class="card-glass bracket-tr" style="margin:6pt 0 6pt 0;padding:8pt 12pt 7pt 12pt;
<div class="card-glass bracket-tr" style="margin:3pt 0 3pt 0;padding:6pt 12pt 5pt 12pt;
display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
{gauge}
<div style="flex:1;min-width:170pt;overflow-wrap:anywhere;">
@ -1000,7 +1048,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
if estimate.expires_at is not None
else today + dt.timedelta(days=30)
)
report_num = _report_number(estimate.estimate_id)
report_num = _report_number(estimate)
# Короткий адрес (для cover): берём первую часть до запятой
full_address = input_snapshot.get("address", "")
@ -1038,29 +1086,37 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
house_label = house_labels.get(house_type, "") if house_type else ""
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else ""
# #pdf-none: year_built может быть None (адрес без house_metadata) — раньше в
# таблицу утекало литеральное «None»; показываем «—» как в остальных пустых полях.
year_label = _mono(str(year_built)) if year_built else ""
# Active market subband — 4-118 days range (fallback без данных).
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
days_min, days_max = _days_on_market_range(estimate.analogs)
# Deals range — если deals есть, считаем; иначе fallback к listings range
deals_low, deals_high = _deals_range(
estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
)
# Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs;
# если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
days_range = _days_on_market_range(estimate.analogs)
listings_bar = _price_range_chart_svg(
estimate.range_low_rub,
estimate.range_high_rub,
sub_label="Активный рынок с аналогичным состоянием ремонта",
days_min=days_min,
days_max=days_max,
show_days=True,
days_min=days_range[0] if days_range else None,
days_max=days_range[1] if days_range else None,
show_days=days_range is not None,
)
deals_bar = _price_range_chart_svg(
deals_low,
deals_high,
sub_label="Диапазон цен по фактическим сделкам",
# Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ
# рассчитанный дисконт запрос→продажа (тот же, что chip «N%» в dual-price),
# а не хардкод «1018%», который противоречил бы вычисленному «N%» (#pdf-honesty).
discount_pct = _discount_pct(estimate)
if discount_pct is not None:
advice_discount_text = (
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
f"ДомКлик и продажи агентств недвижимости"
)
else:
advice_discount_text = (
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
"Росреестр, ДомКлик и продажи агентств недвижимости"
)
disclaimer_html = ""
@ -1070,8 +1126,6 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
f"{_html.escape(brand.pdf_disclaimer)}</p>"
)
footer_html = _page_footer(brand, report_num, today, valid_until=expires)
heading_html = _section_heading(
"01",
"АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
@ -1082,12 +1136,10 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
return f"""
<div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html}
<!-- Параметры квартиры (dotted-row ObjectSummary.tsx summary-row идиома) -->
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:10pt;">
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:6pt;">
<tr><td class="dotted-row"> отчета</td>
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
<tr><td class="dotted-row">Дата отчета</td>
@ -1096,7 +1148,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
<tr><td class="dotted-row">Год постройки</td>
<td class="bold dotted-row">{_mono(str(year_built))}</td></tr>
<td class="bold dotted-row">{year_label}</td></tr>
<tr><td class="dotted-row">Тип дома</td><td class="bold dotted-row">{house_label}</td></tr>
<tr><td class="dotted-row">Этаж / этажность</td>
<td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr>
@ -1116,58 +1168,56 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
{_dual_price_block(estimate, brand)}
<div style="break-inside:avoid;page-break-inside:avoid;">
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
<p style="margin:3pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
Диапазон цен в объявлениях
<span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
</p>
{listings_bar}
</div>
<div style="break-inside:avoid;page-break-inside:avoid;">
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
Диапазон цен по фактическим сделкам
</p>
{deals_bar}
</div>
<!-- Что важно при оценке -->
<h3 style="margin-top:8pt;font-size:{_FS_MD};font-weight:700;">
<h3 style="margin-top:4pt;font-size:{_FS_MD};font-weight:700;">
Что важно при оценке стоимости квартиры:
</h3>
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:2pt;">
<tr>
<td class="advice-title">Цены в объявлениях ожидания собственников</td>
<td class="advice-text">Фактические сделки проходят на 1018% ниже, что подтверждают
Росреестр, ДомКлик и продажи агентств недвижимости</td>
<td class="advice-text">{advice_discount_text}</td>
</tr>
<tr>
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
<td class="advice-text">Инвестиции в 400 600 тыс. руб. повышают цену объекта всего
на 150 250 тыс. руб покупатель оценивает общее состояние квартиры</td>
</tr>
<tr>
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
</tr>
</table>
<!-- #4-pages-fix: 3-я строка advice-table («Неочевидные расходы при
самостоятельной продаже до 15%») убрана дублирует детальную
постатейную разбивку на Offer-странице (см. _build_offer_page: торг
5-15% + риелтор 2-5% + аренда + юрсопровождение + реклама), которая
даёт ту же цифру НАМНОГО подробнее. Единственный content-cut в этом
фиксе (см. PR/задача про 2 пустые страницы); остальной контент cover
сохранён полностью. -->
<p style="margin-top:6pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
padding-top:4pt;overflow-wrap:anywhere;">
<p style="margin-top:3pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
padding-top:3pt;overflow-wrap:anywhere;">
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
</p>
{disclaimer_html}
{footer_html}
</div>
"""
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
"""Min/max days_on_market по аналогам. Fallback 4-118 при отсутствии данных."""
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
"""Min/max days_on_market по аналогам, либо None если реальных данных нет.
Раньше возвращал хардкод (4, 118) при отсутствии days_on_market обложка и
страница объявлений печатали этот выдуманный коридор как измеренный срок
экспозиции (~48% оценок без days_on_market). Теперь None caller не рисует
срок (show_days=False), как уже делает страница сделок (#pdf-honesty)."""
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
if not days:
return 4, 118
return None
return min(days), max(days)
@ -1225,38 +1275,27 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
)
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
# Полоска диапазона
days_min, days_max = _days_on_market_range(estimate.analogs)
# Полоска диапазона — срок экспозиции только при реальных days_on_market
# (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
days_range = _days_on_market_range(estimate.analogs)
range_bar = _price_range_chart_svg(
estimate.range_low_rub,
estimate.range_high_rub,
sub_label="Рынок",
days_min=days_min,
days_max=days_max,
show_days=True,
days_min=days_range[0] if days_range else None,
days_max=days_range[1] if days_range else None,
show_days=days_range is not None,
)
# Топ-5 примеров (отсортированных по distance)
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
examples_rows = _examples_rows(top5)
report_num = _report_number(estimate.estimate_id)
today = dt.date.today()
footer_html = _page_footer(
brand,
report_num,
today,
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2",
valid_until=_expires_date(estimate),
)
heading_html = _section_heading("02", "РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
return f"""
<div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html}
<!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
@ -1306,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
<tbody>{examples_rows}</tbody>
</table>
{footer_html}
</div>
"""
@ -1391,6 +1428,21 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
today = dt.date.today()
period_start = today - dt.timedelta(days=estimate.period_months * 30)
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
# (тот же _discount_pct, что chip «N%» на обложке), а не хардкод «1018%»,
# который противоречил бы вычисленному «N%» в том же PDF. Убран и ложный
# хвост «(Екатеринбург, 2026)» — локация не привязана к объекту (#pdf-honesty).
discount_pct = _discount_pct(estimate)
if discount_pct is not None:
deals_discount_text = (
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в "
f"объявлениях — по этому объекту на {discount_pct}%"
)
else:
deals_discount_text = (
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в объявлениях"
)
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
@ -1438,22 +1490,11 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
top5 = estimate.actual_deals[:5]
examples_rows = _examples_rows(top5)
report_num = _report_number(estimate.estimate_id)
footer_html = _page_footer(
brand,
report_num,
today,
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3",
valid_until=_expires_date(estimate),
)
heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
return f"""
<div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html}
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
@ -1495,8 +1536,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
По данным реальных сделок, квартиры продаются в среднем на 1018% дешевле,
чем заявлено в объявлениях (Екатеринбург, 2026)
{deals_discount_text}
</div>
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
@ -1508,8 +1548,6 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<tbody>{examples_rows}</tbody>
</table>
{footer_html}
</div>
"""
@ -1517,7 +1555,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: ignore[no-untyped-def]
def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
median = estimate.median_price_rub
# Расчёт расходов: часть — на основе медианы (торг «от цены в объявлении»),
@ -1544,8 +1582,24 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
rieltor_low = int(sold_price * rieltor_pct_low / 100)
rieltor_high = int(sold_price * rieltor_pct_high / 100)
# 3 месяца аренды 1-комнатной квартиры, ЕКБ 2026: ~28-45 тыс. руб./мес.
rent_low, rent_high = 84_000, 135_000
# 3 месяца аренды квартиры, ЕКБ 2026 (базовая ставка — студия/1-комн., ~28-45
# тыс. руб./мес.), масштабируется по числу комнат объекта (#pdf-honesty: раньше
# был плоский 1-комнатный тариф для ЛЮБОГО объекта, включая 3-4-комнатные).
# Ориентировочные месячные ставки аренды по комнатности, ЕКБ 2026:
# студия/1к ~28-45k, 2к ~40-60k, 3к ~55-80k, 4к+ ~75-110k.
# Множители — грубое отношение верхних границ этих диапазонов к базовой (1к).
rooms = int(input_snapshot.get("rooms", 0) or 0)
if rooms <= 1:
rent_multiplier = 1.0
elif rooms == 2:
rent_multiplier = 1.35
elif rooms == 3:
rent_multiplier = 1.8
else:
rent_multiplier = 2.4
rent_base_low, rent_base_high = 84_000, 135_000
rent_low = round(rent_base_low * rent_multiplier / 1000) * 1000
rent_high = round(rent_base_high * rent_multiplier / 1000) * 1000
# Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
juridical_low, juridical_high = 15_000, 50_000
# Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
@ -1558,22 +1612,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
brand_short = _html.escape(brand.name)
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
report_num = _report_number(estimate.estimate_id)
today = dt.date.today()
footer_html = _page_footer(
brand,
report_num,
today,
f"{brand_short} · Анализ рынка вторичной недвижимости · стр. 4 · "
"Расчёт носит ориентировочный характер и не является офертой.",
valid_until=_expires_date(estimate),
)
return f"""
<div><!-- last page: NO page-break-after -->
{_page_header(brand, report_num, today)}
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;">
@ -1626,7 +1667,8 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
<tr style="border-bottom:1px solid {_BORDER};">
<td style="padding:6pt 4pt;">
<div class="bold">Аренда после сделки</div>
<div style="font-size:{_FS_XS};color:{_MUTED};">однокомнатной квартиры на 3 месяца</div>
<div style="font-size:{_FS_XS};color:{_MUTED};">
{f"{rooms}-комн. квартиры на 3 месяца" if rooms > 0 else "квартиры на 3 месяца"}</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
бесплатно</td>
@ -1738,7 +1780,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
</div>
</div>
{footer_html}
<p style="margin-top:14pt;font-size:{_FS_XS};color:{_MUTED};">
Расчёт носит ориентировочный характер и не является офертой.
</p>
</div>
"""
@ -1794,8 +1838,12 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
}}
@page {{
size: A4;
margin: 20mm 18mm 20mm 18mm;
margin: 25mm 18mm 19mm 18mm;
@top-center {{ content: element(runningHeader); vertical-align: bottom; }}
@bottom-center {{ content: element(runningFooter); vertical-align: top; }}
}}
.running-header {{ position: running(runningHeader); }}
.running-footer {{ position: running(runningFooter); }}
* {{ box-sizing: border-box; }}
body {{
font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
@ -2015,7 +2063,7 @@ def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot:
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
"""
address = _html.escape(input_snapshot.get("address", ""))
report_num = _report_number(estimate.estimate_id)
report_num = _report_number(estimate)
today = dt.date.today()
return f"""
<div>
@ -2103,11 +2151,28 @@ def generate_trade_in_pdf(
if estimate.insufficient_data:
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
else:
# Running header/footer — единый HUD-хром печатается WeasyPrint'ом в page
# margin-boxes (@top-center/@bottom-center, см. _build_css) на КАЖДОЙ странице
# документа, а не только там, где явно вставлен в поток секции-билдера.
# Рендерится один раз здесь (не внутри каждого _build_*_page) — иначе WeasyPrint
# печатает независимый running-элемент с последнего "видимого" перед разрывом
# страницы, что дало бы 4 разных чанка вместо одного персистентного хрома.
_chrome_report_num = _report_number(estimate)
_chrome_today = dt.date.today()
_chrome_expires = _expires_date(estimate)
chrome_html = (
f'<div class="running-header">'
f"{_page_header(brand, _chrome_report_num, _chrome_today)}</div>"
f'<div class="running-footer">'
f"{_page_footer(brand, _chrome_report_num, _chrome_today, valid_until=_chrome_expires)}"
f"</div>"
)
body_html = (
_build_cover(estimate, input_snapshot, brand)
chrome_html
+ _build_cover(estimate, input_snapshot, brand)
+ _build_listings_page(estimate, input_snapshot, brand)
+ _build_deals_page(estimate, input_snapshot, brand)
+ _build_offer_page(estimate, brand)
+ _build_offer_page(estimate, input_snapshot, brand)
)
html_str = (

View file

@ -72,6 +72,165 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)
# ── Sverdlovsk oblast (region 66) ────────────────────────────────────────────
# Генеральный bbox всей области — от южной границы (ЕКБ/Полевской) до
# северных городов (Ивдель/Североуральск/Краснотурьинск). Специально щедрый:
# используется как fallback-accept для провайдеров БЕЗ структурного region-поля
# (сырые Point.pos от Yandex/Nominatim) — когда нет ФИАС/КЛАДР для точной
# проверки region==66. Дороже false-positive у самой границы соседних регионов,
# дешевле false-negative (выбросить корректный областной адрес — исходный баг).
# bbox = (lat_min, lat_max, lon_min, lon_max).
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat).
OBLAST66_VIEWBOX = {
"viewbox": "56.7,62.2,66.6,55.8",
"ll": "61.65,59.0",
"spn": "9.9,6.4",
}
def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
"""True если (lat, lon) в генеральном bbox Свердловской области (регион 66)."""
return is_within_ekb_bbox(lat, lon, OBLAST66_BBOX)
# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
# городов), покрывает основные региональные центры. Используется чтобы:
# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал
# другой город региона (геокодер forward/suggest prefix-логика);
# (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
SVERDLOVSK_OBLAST_CITIES = frozenset(
{
"екатеринбург",
"нижний тагил",
"каменск-уральский",
"первоуральск",
"серов",
"новоуральск",
"ревда",
"полевской",
"асбест",
"богданович",
"ирбит",
"красноуфимск",
"верхняя пышма",
"берёзовский",
"березовский",
"заречный",
"качканар",
"краснотурьинск",
"североуральск",
"ивдель",
"тавда",
"туринск",
"сысерть",
"среднеуральск",
"дегтярск",
"верхняя салда",
"нижняя салда",
"невьянск",
"артёмовский",
"артемовский",
"камышлов",
"алапаевск",
"сухой лог",
"кушва",
"красноуральск",
"карпинск",
"нижняя тура",
"верхний тагил",
"нижние серги",
# НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
# «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
# города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
# где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
# без district-префикса ложно ушёл бы в non-EKB gate.
}
)
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
# пер.» — «Невьянск», «Богдановича» — «Богданович» (все реальные EKB-адреса,
# substring-матч ложно ронял "Екатеринбург, "-префикс на них → неверный город
# → неверная цена). Python `\b` для str-паттернов юникод-осведомлён (Cyrillic
# входит в `\w`), поэтому «серова» корректно НЕ матчит `\bсеров\b` (после
# «серов» идёт «а» — тоже \w, границы нет).
_OBLAST_CITY_RE = re.compile(
r"\b(?:" + "|".join(re.escape(city) for city in SVERDLOVSK_OBLAST_CITIES) + r")\b"
)
_OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
# Приставки, после которых токен обозначает РАЙОН/микрорайон города, а не сам
# город региона: «мкр Заречный» — микрорайон ВНУТРИ другого города (не ЗАТО
# Заречный). Без этого исключения точное совпадение слова "заречный" всё равно
# ложно сработало бы (это не substring-баг, а реальная омонимия имени).
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р", "р-он", "район", "жк"})
def _has_oblast_marker(text_lower: str) -> bool:
"""True если текст уже содержит упоминание области/города региона 66.
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
уже привязан к другому городу/области иначе получим двойной город
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
Матчинг по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
"""
normalized = " ".join(text_lower.split())
if _OBLAST_MARKER_RE.search(normalized):
return True
for m in _OBLAST_CITY_RE.finditer(normalized):
prefix_words = normalized[: m.start()].split()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
return True
return False
# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
_NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"}
_NON_EKB_CITY_RE = re.compile(
r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b"
)
# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в
# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург».
_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b")
def _names_non_ekb_city(address: str) -> bool:
"""True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н
<город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
(geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
(проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).
Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
города региона иначе ЖК/посёлки с названием, совпадающим с другим
городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
"""
normalized = " ".join(address.lower().split())
# Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе
# → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК
# «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. —
# реальные false-positive из прод-кэша). Word-boundary же не даёт слову
# «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город.
if _EKATERINBURG_RE.search(normalized):
return False
for m in _NON_EKB_CITY_RE.finditer(normalized):
prefix_words = normalized[: m.start()].split()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный
return True
return False
# ── Address normalisation ───────────────────────────────────────────────────
def normalize_address(address: str) -> str:
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
@ -166,11 +325,35 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
"""Single Nominatim search. Возвращает первый item или None.
def _nominatim_region_ok(item: dict) -> bool | None:
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
ВАЖНО: фильтруем результаты по ЕКБ bbox прямо тут, чтобы при опечатках
не возвращать Пермский край / Челябинск.
True/False если state однозначно про/не про Свердловскую область. None если
поле отсутствует/не строка тогда accept-логика падает обратно на bbox.
Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри генерального OBLAST66_BBOX
(специально щедрого), но их state явно другой регион.
"""
addr = item.get("address")
if not isinstance(addr, dict):
return None
state = addr.get("state")
if not isinstance(state, str) or not state:
return None
return "свердловск" in state.lower()
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
"""Single Nominatim search. Возвращает лучший item или None.
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
опечатках не возвращать Пермский край / Челябинск но не резать легитимные
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
не первым) иначе первый кандидат внутри OBLAST66. Плюс region cross-check
(`address.state`) отсекает кандидатов ЯВНО из другого региона (Тюмень и
т.п.), даже если координаты попали в генеральный bbox.
"""
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -180,21 +363,26 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
"limit": "3",
"countrycodes": "ru",
"addressdetails": "1",
"viewbox": EKB_BBOX["viewbox"],
"bounded": "1", # строго в ЕКБ bbox
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1", # строго в пределах области (region 66)
},
)
response.raise_for_status()
data = response.json()
oblast_fallback: dict | None = None
for item in data:
try:
lat_f = float(item["lat"])
lon_f = float(item["lon"])
if is_within_ekb_bbox(lat_f, lon_f):
return item
except Exception:
continue
return None
if _nominatim_region_ok(item) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
oblast_fallback = item
return oblast_fallback
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
@ -202,8 +390,8 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
Бан-policy: User-Agent с email обязателен.
Tier 1: bounded ЕКБ на оригинальный адрес.
Tier 2: bounded ЕКБ на typo-варианты (Цвилинга Цвиллинга).
Tier 1: bounded область (region 66) на оригинальный адрес.
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга Цвиллинга).
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
@ -237,6 +425,30 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
)
def _yandex_region_ok(geo_object: dict) -> bool | None:
"""Кросс-чек региона по Yandex `AdministrativeAreaName`.
True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую
область. None если поля нет / оно не строка тогда accept-логика падает
обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри
генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой.
"""
try:
admin_area = (
geo_object.get("metaDataProperty", {})
.get("GeocoderMetaData", {})
.get("AddressDetails", {})
.get("Country", {})
.get("AdministrativeArea", {})
.get("AdministrativeAreaName")
)
except AttributeError:
return None
if not isinstance(admin_area, str) or not admin_area:
return None
return "свердловск" in admin_area.lower()
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
@ -247,8 +459,9 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
но БЕЗ rspn чтобы fuzzy matching работал при опечатках.
"""
# Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest)
geocode_query = address if "екатеринбург" in address.lower() else f"Екатеринбург, {address}"
# Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66
# (типичный кейс из suggest, либо явный запрос по другому городу области).
geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
"https://geocode-maps.yandex.ru/1.x/",
@ -269,22 +482,44 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
if not members:
return None
# Фильтруем top-5 по ЕКБ bbox — игнорируем Челябинск/Уфу/Москву при опечатке
# Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву
# при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass
# tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже
# если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат.
# Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из
# другого региона (Тюмень и т.п. попадают в генеральный bbox координатно).
best = None
oblast_fallback = None
for m in members:
obj = m.get("GeoObject", {})
try:
lon_str, lat_str = obj["Point"]["pos"].split()
lat_f, lon_f = float(lat_str), float(lon_str)
except Exception:
continue
if _yandex_region_ok(obj) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
best = obj
break
except Exception:
continue
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
oblast_fallback = obj
if best is None:
# Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то)
best = members[0]["GeoObject"]
best = oblast_fallback
if best is None:
# Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно
# отсеяны по региону) → берём первый «как есть», НО только если его
# регион не опровергнут явно — иначе весь смысл region cross-check
# теряется на garbage-query, где top-1 — гарантированно не тот регион
# (например, Тюмень).
first_obj = members[0]["GeoObject"]
if _yandex_region_ok(first_obj) is not False:
best = first_obj
if best is None:
return None
lon_str, lat_str = best["Point"]["pos"].split()
precision_raw = (
@ -310,6 +545,8 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного
# охвата (region 66) — см. OBLAST66_VIEWBOX выше.
EKB_BBOX = {
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
"ll": "60.605,56.838", # Yandex center (lon,lat)
@ -375,9 +612,13 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
типа город/район, для autocomplete с привязкой к карте они бесполезны).
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
Без city suffix query уже ограничен `locations=[{city: 'Екатеринбург'}]`.
Constraint вся область (region='Свердловская область', hard-filter внутри
`suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/Серов/etc
никогда не появились бы в подсказках.
"""
raw = await dadata.suggest_addresses(query, limit=limit)
raw = await dadata.suggest_addresses(
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
)
out: list[GeocodeSuggestion] = []
for s in raw:
if s.lat is None or s.lon is None:
@ -424,16 +665,19 @@ async def _yandex_geocode_request(
async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
"""Yandex Geocoder с авто-fallback на typo-tolerant режим.
Tier 1: bounded ЕКБ (rspn=1) на оригинальный query.
Tier 1: bounded ЕКБ (rspn=1) быстрый путь для основного (ЕКБ) трафика.
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
Tier 3: без rspn fuzzy по всей стране, фильтр результатов по ЕКБ bbox.
Tier 3: без rspn fuzzy по всей стране, фильтр результатов по bbox области
(region 66) ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
(bounded строго ЕКБ) структурно вернуть не могут.
"""
prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}"
async with httpx.AsyncClient(timeout=8.0) as client:
# Tier 1: strict bounded на оригинал
# Tier 1: strict bounded на оригинал (ЕКБ fast path)
members = await _yandex_geocode_request(
client,
api_key,
f"Екатеринбург, {query}",
prefixed_query,
limit,
bounded=True,
)
@ -441,12 +685,15 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
if results:
return results
# Tier 2: bounded на typo-варианты
# Tier 2: bounded на typo-варианты (тот же ЕКБ fast path)
for variant in _typo_variants(query, limit=4):
variant_query = (
variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}"
)
members = await _yandex_geocode_request(
client,
api_key,
f"Екатеринбург, {variant}",
variant_query,
limit,
bounded=True,
)
@ -454,21 +701,21 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
if results:
return results
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
members = await _yandex_geocode_request(
client,
api_key,
f"Екатеринбург, {query}",
prefixed_query,
limit,
bounded=False,
)
results = _parse_yandex_members(members)
in_ekb = [r for r in results if 60.40 <= r.lon <= 60.85 and 56.65 <= r.lat <= 56.95]
return in_ekb
in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
return in_oblast
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
"""Один Nominatim search с фильтром по ЕКБ bbox. Возвращает up to N items."""
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
response = await client.get(
"https://nominatim.openstreetmap.org/search",
params={
@ -476,7 +723,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
"format": "json",
"limit": str(limit),
"countrycodes": "ru",
"viewbox": EKB_BBOX["viewbox"],
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1",
"addressdetails": "1",
},
@ -488,21 +735,29 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен)."""
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).
Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области
региона 66 иначе не режем явные запросы по другим городам области.
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
"Accept": "application/json",
"Accept-Language": "ru,en;q=0.8",
}
suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург"
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
# Tier 1: оригинальный query
data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
data = await _nominatim_query_multi(client, suffixed_query, limit)
# Tier 2: typo-варианты если оригинал пустой
if not data:
for variant in _typo_variants(query, limit=3):
await asyncio.sleep(1.0) # Nominatim 1 req/sec
data = await _nominatim_query_multi(client, f"{variant}, Екатеринбург", limit)
variant_query = (
variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург"
)
data = await _nominatim_query_multi(client, variant_query, limit)
if data:
logger.info("nominatim suggest typo-fixed: %s%s", query, variant)
break
@ -832,7 +1087,8 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]:
"""Автокомплит адресов в ЕКБ. Cadastral FDW → DaData → Yandex → Nominatim → [].
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW DaData Yandex Nominatim [].
db: если передан cadastral lookup через gendesign_cad_buildings (первый tier).
DaData /suggest (PR Q2) token-only, 10k/день, заменяет Yandex который
@ -842,8 +1098,12 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list
if not query or len(query.strip()) < 2:
return []
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов
if db is not None:
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
# EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет
# другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом
# с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже
# oblast-aware.
if db is not None and not _names_non_ekb_city(query):
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
parsed = _parse_street_house(query.strip())
@ -910,8 +1170,14 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
# 2. Локальные источники по street+house (без внешнего API).
parsed = _parse_street_house(address.strip())
# #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на
# адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом
# (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в
# ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4).
use_local_ekb = not _names_non_ekb_city(address)
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
if parsed is not None:
if use_local_ekb and parsed is not None:
street, house = parsed
try:
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
@ -939,7 +1205,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
# Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм
# (литеральная подстрока не совпадает).
if parsed is not None:
if use_local_ekb and parsed is not None:
street, house = parsed
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
if hit is not None:
@ -960,7 +1226,10 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
return result
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
cad_suggestions = await asyncio.to_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
if use_local_ekb:
cad_suggestions = await asyncio.to_thread(
_cadastral_forward_sync, db, address.strip(), limit=1
)
if cad_suggestions:
s = cad_suggestions[0]
result = GeocodeResult(
@ -971,18 +1240,18 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
confidence="exact",
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
logger.info(
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
)
return result
# 3. Yandex (если есть key) с typo-fallback
if settings.yandex_geocoder_api_key:
try:
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
# Если результат вне ЕКБ — пробуем typo-варианты
in_ekb = (
result is not None and 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95
)
if result is not None and in_ekb:
# Если результат вне области (region 66) — пробуем typo-варианты
in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
if result is not None and in_oblast:
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result
@ -994,7 +1263,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
continue
if result is None:
continue
if 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95:
if is_within_oblast66_bbox(result.lat, result.lon):
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode yandex typo-fixed: %s%s → (%.5f, %.5f)",
@ -1128,11 +1397,12 @@ async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocod
for p in parts
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
]
# Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец
# Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
# в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
locality = None
rest: list[str] = []
for p in filtered:
if locality is None and p in {"Екатеринбург"}:
if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
locality = p
else:
rest.append(p)

View file

@ -24,6 +24,24 @@ logger = logging.getLogger(__name__)
# small enough to bound storage & decode cost.
_MAX_DIMENSION = 2400
# Hard cap on declared pixel count (width*height) enforced BEFORE any decode.
# A highly-compressible ≤10 MB upload can declare ~89-178 MP; once img.load()
# inflates it to raw RGB (3 B/px) that is a 260-530 MB buffer (+ transpose/convert
# copies) → OOM-kills the 768 MiB backend (#2214), amplified ×12 photo slots.
# 40 MP (~8000×5000) sits far above any real listing photo yet well below the
# ~89 MP default Pillow bomb threshold. img.size is read from the header on
# open() without decoding pixels, so this check is cheap and pre-decode.
_MAX_PIXELS = 40_000_000
# Backstop byte cap (the upload endpoint already enforces 10 MB → 413, but this
# service is standalone; keep it self-defending against oversized inputs).
_MAX_BYTES = 10 * 1024 * 1024
# Belt-and-braces: lower Pillow's own DecompressionBombError threshold from its
# ~89 MP default to our cap, so oversized declared dimensions raise even on any
# decode path that bypasses the explicit size guard below.
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS
# JPEG quality. 85 is industry standard for photos — visually lossless,
# ~70% size reduction vs original phone JPEGs.
_JPEG_QUALITY = 85
@ -46,10 +64,28 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
(sanitized_bytes, "image/jpeg") both ready for storage/serving.
Raises:
ImageSanitizationError: bytes are not a recognized image format.
ImageSanitizationError: bytes are not a recognized image format, or the
declared dimensions / byte size exceed the pixel-flood safety caps.
"""
if len(content) > _MAX_BYTES:
raise ImageSanitizationError(f"image too large: {len(content)} bytes exceeds cap")
try:
with Image.open(io.BytesIO(content)) as img:
# #2214: reject pixel-flood bombs BEFORE img.load(). img.size comes
# from the header (no pixel decode); loading an oversized declared
# image would inflate a small file into a hundreds-of-MB RGB buffer.
width, height = img.size
if width * height > _MAX_PIXELS:
logger.warning(
"image rejected: %dx%d (%d px) exceeds %d px cap",
width,
height,
width * height,
_MAX_PIXELS,
)
raise ImageSanitizationError(
f"image too large: {width}x{height} exceeds {_MAX_PIXELS} px cap"
)
img.load() # force full decode; surfaces UnidentifiedImageError early
# Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone
# portrait photos (Orientation=6/8) end up rotated 90/270° once the tag
@ -65,10 +101,16 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
# save() does NOT carry over .info / EXIF unless we pass exif=...; we don't.
img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True)
return buf.getvalue(), SANITIZED_CONTENT_TYPE
except ImageSanitizationError:
raise # already a handled, user-facing rejection (byte/pixel cap)
except UnidentifiedImageError as e:
raise ImageSanitizationError("not a recognized image format") from e
except Image.DecompressionBombError as e:
# Any decode path that outran the size guard above (e.g. >2× cap) lands here.
logger.warning("decompression bomb rejected: %s", e)
raise ImageSanitizationError("image dimensions exceed safe limits") from e
except Exception as e:
# PIL can raise broad errors (DecompressionBombError, OSError on truncated, ...);
# normalize to one user-facing error type.
# PIL can raise broad errors (OSError on truncated, DecompressionBombWarning
# promoted to error, ...); normalize to one user-facing error type.
logger.warning("image sanitization failed: %s", e)
raise ImageSanitizationError(f"image decode failed: {e}") from e

View file

@ -1,200 +0,0 @@
"""Location-coefficient POI scoring for trade-in estimates (#2045 BE-3, LocationDrawer).
Ported straight-line formula from Site Finder (ПТИЦА)
`backend/app/services/site_finder/poi_score.py::compute_poi_weighted_top7`:
weight = (1 / (distance_m + 100)) * CATEGORY_WEIGHTS[category]
Reads from the LOCAL mirror table `osm_poi_ekb_local` (populated by
`app/tasks/osm_poi_ekb_refresh.py` from the `gendesign_osm_poi_ekb` FDW see migrations
168-170). We deliberately do NOT query the FDW directly per-request: the same per-row cost
measured for the analogous cadastral-buildings FDW (~1.16s/row without a geom index on the
remote) would make a synchronous endpoint unusable.
Scope (MVP, #2045 BE-3):
- Straight-line distance only. The ORS routing-decay mode from Site Finder
(`compute_poi_routing_decay`) is NOT ported no ORS infrastructure in trade-in, out of
MVP scope.
- Radius tuned for apartments (1000-1500m), NOT Site Finder's 2000m default for land parcels.
- The scorecoef mapping (`_score_to_coef`) is a NEW MVP heuristic, not present in Site
Finder (there POI score is a ranking metric, not a price multiplier) see its docstring.
"""
from __future__ import annotations
import logging
from typing import Any
from pydantic import BaseModel
from sqlalchemy import text
logger = logging.getLogger(__name__)
# Веса по категории — скопированы as-is из Site Finder CATEGORY_WEIGHTS
# (backend/app/services/site_finder/poi_score.py), чтобы ranking POI был согласован
# между продуктами.
CATEGORY_WEIGHTS: dict[str, float] = {
"metro_stop": 6.0,
"school": 5.0,
"kindergarten": 4.5,
"hospital": 4.0,
"shop_mall": 4.0,
"shop_supermarket": 3.5,
"bus_stop": 4.5,
"park": 3.5,
"pharmacy": 2.5,
"tram_stop": 2.0,
"shop_small": 2.0,
"default": 1.0,
}
# Радиус подобран для КВАРТИР (МКД), а не для участков (Ptica default 2000м) —
# пешая доступность в пределах квартала/микрорайона.
DEFAULT_RADIUS_M = 1200
DEFAULT_TOP_N = 7
# Теоретический максимум суммы весов top-7 POI при идеальном расположении (d=0):
# w_i = cat_weight_i / (0 + 100) → max_sum = Σ(top7 cat_weights) / 100.
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).
_COEF_BASE = 0.95
_COEF_SPREAD = 0.10
def _category_weight(category: str | None) -> float:
"""Вернуть вес категории. Если не знаем — default."""
return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"])
class LocationCoefFactor(BaseModel):
"""Один POI-фактор в ответе location-coef."""
poi_type: str
name: str | None
distance_m: float
weight: float
class LocationCoefResult(BaseModel):
"""Результат compute_location_coef — потребляется эндпоинтом location-coef."""
coef: float
factors: list[LocationCoefFactor]
geo_source: str # "osm_poi_ekb" (норма) | "unavailable" (mirror пуста/не отрефрешена)
def _score_to_coef(poi_weighted_score: float) -> float:
"""MVP-эвристика score(0..100) → ценовой коэффициент.
coef = 0.95 + (score/100) * 0.10 диапазон [0.95, 1.05].
ВНИМАНИЕ: это НЕ откалиброванная на реальных ценовых дельтах формула первая рабочая
эвристика для MVP location-coef. Site Finder использует ту же POI-модель как ранжирующую
метрику (poi_weighted_score), а не как прямой ценовой множитель; здесь смысл другой,
поэтому маппинг введён отдельно и явно помечен как heuristic v1.
"""
return round(_COEF_BASE + (poi_weighted_score / 100.0) * _COEF_SPREAD, 4)
_NEAREST_POI_SQL = text(
"""
SELECT
p.name,
p.category,
CAST(
ST_Distance(
p.geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
) AS double precision
) AS distance_m
FROM osm_poi_ekb_local p
WHERE p.geom IS NOT NULL
AND ST_DWithin(
p.geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
CAST(:radius_m AS double precision)
)
ORDER BY distance_m ASC
LIMIT :limit
"""
)
def compute_location_coef(
db: Any,
lat: float,
lon: float,
radius_m: int = DEFAULT_RADIUS_M,
top_n: int = DEFAULT_TOP_N,
) -> LocationCoefResult:
"""Посчитать location-coef для координат (lat, lon) по POI из osm_poi_ekb_local.
Graceful fallback: если osm_poi_ekb_local пуста (рефреш ещё не запускался на этом
окружении) возвращает coef=1.0, factors=[], geo_source="unavailable" вместо 500 или
сфабрикованных факторов. Отсутствие POI В РАДИУСЕ у непустой таблицы это легитимный
результат (coef=0.95, factors=[], geo_source="osm_poi_ekb"), не fallback.
Args:
db: SQLAlchemy Session.
lat: широта целевой квартиры.
lon: долгота целевой квартиры.
radius_m: радиус поиска в метрах (default 1200 подобран для МКД, не для участков).
top_n: количество POI, учитываемых в score (default 7).
"""
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
if total == 0:
logger.warning(
"location_coef: osm_poi_ekb_local is empty (refresh job not yet run on this "
"environment) — returning unavailable fallback, no fabricated factors"
)
return LocationCoefResult(coef=1.0, factors=[], geo_source="unavailable")
rows = (
db.execute(
_NEAREST_POI_SQL,
{"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10},
)
.mappings()
.all()
)
scored: list[tuple[float, LocationCoefFactor]] = []
for row in rows:
distance_m = float(row["distance_m"])
category = row["category"] or "default"
weight = (1.0 / (distance_m + 100.0)) * _category_weight(category)
scored.append(
(
weight,
LocationCoefFactor(
poi_type=category,
name=row["name"],
distance_m=round(distance_m, 1),
weight=round(weight, 6),
),
)
)
scored.sort(key=lambda pair: pair[0], reverse=True)
top_factors = [factor for _weight, factor in scored[:top_n]]
raw_sum = sum(factor.weight for factor in top_factors)
poi_weighted_score = min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0)
coef = _score_to_coef(poi_weighted_score)
logger.debug(
"location_coef: lat=%.5f lon=%.5f radius=%dm poi_found=%d top=%d " "score=%.1f coef=%.4f",
lat,
lon,
radius_m,
len(rows),
len(top_factors),
poi_weighted_score,
coef,
)
return LocationCoefResult(coef=coef, factors=top_factors, geo_source="osm_poi_ekb")

View file

@ -0,0 +1,436 @@
"""Location index for trade-in estimates — replaces the broken `location_coef` (LocationDrawer).
ИСТОРИЯ / ПОЧЕМУ ПЕРЕПИСАНО:
Старый `location_coef.py` считал `coef = 0.95 + (poi_weighted_score/100) * 0.10` диапазон
жёстко зажат в [0.95, 1.05], без какой-либо калибровки на реальных ценах. Аудит на боевой БД
(1500 адресов ЕКБ + 4000 активных лотов) показал:
- 67% адресов попадали в 1%+1%, у ~25% coef был РОВНО 1.0 (score=50) почти
неинформативно, весь город умещался в 4%+5%;
- связи с ценой не было вообще: медиана /м² по бакетам coef плоская и НЕ монотонна
(бакет 4% дороже бакета +3%).
Для сравнения, расстояние до центра ЕКБ на 31 тыс. лотов даёт чистый монотонный градиент
(0-2км 249 686 /м² 12-13км 93 677 /м², разброс 2.7×) сигнал в данных есть, просто
POI-score его не улавливал (POI ranking цена).
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
location_index_pct = (медиана /м² сопоставимых активных листингов в радиусе точки
медиана /м² по всему ЕКБ) / медиана по ЕКБ * 100
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5)
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
искусственно если район реально на 40% дороже, так и покажет.
ЧЕСТНАЯ ДЕГРАДАЦИЯ (см. LocationIndexResult.status):
- "out_of_coverage" точка вне гео-охвата продукта (bbox Екатеринбурга). НЕ 0%, НЕ
fallback-число прочерк на фронте.
- "insufficient_data" даже на максимальном радиусе выборки < MIN_SAMPLE_SIZE сопоставимых
активных листингов. Тоже прочерк, а не шум по 3 объявлениям.
- "ok" index надёжен.
В ЦЕНУ НЕ ИДЁТ: estimator.py про этот модуль не знает и не должен знать аналоги уже берутся
из того же района (локация учтена в базовой цене через сам подбор сопоставимых объектов),
повторное умножение на локационный индекс было бы двойным учётом одного и того же эффекта.
POI («что рядом» школа/метро/остановка) сохранены как ОТДЕЛЬНАЯ качественная справка
(`nearby_poi`, ранжирование как раньше в location_coef.py), но больше не участвуют в числовом
показателе.
"""
from __future__ import annotations
import logging
from typing import Any
from pydantic import BaseModel
from sqlalchemy import text
logger = logging.getLogger(__name__)
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
# Тот же bbox, что EKB_BBOX в backend/app/services/site_finder/poi_loader.py (main
# gendesign backend, Overpass-загрузчик osm_poi_ekb) и что использовался при аудите
# (1500 адресов / 4000 активных лотов / 2787 POI, все — "по Екатеринбургу"). tradein-mvp —
# отдельный деплой/venv от backend/, поэтому константа продублирована, не импортирована;
# при изменении bbox в одном месте — проверить и второе (комментарий в обе стороны).
_EKB_BBOX_SOUTH = 56.70
_EKB_BBOX_WEST = 60.50
_EKB_BBOX_NORTH = 56.95
_EKB_BBOX_EAST = 60.75
def _in_ekb_bbox(lat: float, lon: float) -> bool:
"""True если точка внутри гео-охвата продукта (Екатеринбург)."""
return _EKB_BBOX_SOUTH <= lat <= _EKB_BBOX_NORTH and _EKB_BBOX_WEST <= lon <= _EKB_BBOX_EAST
# ── Калибровочные константы (радиус / минимальная выборка) ──────────────────
#
# Плотность-прикидка для обоснования порядка величины (НЕ подтверждено живым запросом к
# прод-БД в этом изменении — см. PR description "непроверенное"): ЕКБ-аудит насчитал ~4000
# активных лотов в bbox площадью ~ 27.8км (0.25° широты) × 15.3км (0.25° долготы на широте
# 56.8°) ≈ 425 км² → плотность ~9.4 лота/км². Круг радиусом 800м имеет площадь ~2.01 км² →
# ожидаемо ~19 лотов при равномерной плотности — близко к MIN_SAMPLE_SIZE=20, т.е. стартовый
# радиус разумен для "средней" точки. Плотность в городе крайне неравномерна (центр много
# гуще окраин) — поэтому лестница радиусов расширяется, а не фиксированный радиус.
RADIUS_LADDER_M: tuple[int, ...] = (800, 1500, 2500)
# Ниже этого числа сопоставимых активных листингов медиана — шум, не показатель.
# Порог не откалиброван статистически (например через доверительный интервал медианы) —
# первая рабочая оценка для MVP. TODO: перепроверить на реальном распределении выборок по
# районам ЕКБ (см. "непроверенное" в отчёте задачи).
MIN_SAMPLE_SIZE = 20
# Санитарные (НЕ бизнес-калибровочные) границы ₽/м² — отсекают заведомо битые скрейп-строки
# (парсинг ошибся на порядок и т.п.), не сужают реальный рынок ЕКБ (там диапазон примерно
# 40-400 тыс₽/м², с большим запасом по краям).
_PRICE_PER_M2_SANITY_MIN = 20_000
_PRICE_PER_M2_SANITY_MAX = 1_000_000
DEFAULT_POI_RADIUS_M = 1200 # как в старом location_coef.py — подобран для МКД
DEFAULT_POI_TOP_N = 7
# Веса по категории POI — те же, что были в location_coef.py (ranking "что рядом",
# больше НЕ конвертируются в число, влияющее на индекс).
CATEGORY_WEIGHTS: dict[str, float] = {
"metro_stop": 6.0,
"school": 5.0,
"kindergarten": 4.5,
"hospital": 4.0,
"shop_mall": 4.0,
"shop_supermarket": 3.5,
"bus_stop": 4.5,
"park": 3.5,
"pharmacy": 2.5,
"tram_stop": 2.0,
"shop_small": 2.0,
"default": 1.0,
}
def _category_weight(category: str | None) -> float:
"""Вернуть вес категории. Если не знаем — default."""
return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"])
class NearbyPoi(BaseModel):
"""Один пункт «что рядом» — качественная справка, НЕ участвует в location_index_pct."""
poi_type: str
name: str | None
distance_m: float
class LocationIndexResult(BaseModel):
"""Результат compute_location_index — потребляется эндпоинтом location-index."""
status: str # "ok" | "out_of_coverage" | "insufficient_data"
location_index_pct: float | None
local_median_price_per_m2: int | None
city_median_price_per_m2: int | None
sample_size: int
radius_m: int
nearby_poi: list[NearbyPoi]
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
"""% отклонения локальной медианы от городской.
Округление до 1 знака не создаёт ложной точности (исходные данные шумные скрейп-цены).
"""
if city_median_ppm2 <= 0:
# Защита от деления на ноль при вырожденной городской выборке — не должно
# случаться в проде (там ~4000 активных лотов), только в пустой dev-БД.
return 0.0
return round((local_median_ppm2 - city_median_ppm2) / city_median_ppm2 * 100.0, 1)
# ── SQL: медиана ₽/м² сопоставимых активных листингов ────────────────────────
#
# percentile_cont(0.5) — тот же идиом, что уже используется в estimator.py для медианных
# ₽/м² трендов (_fetch_price_trend) — устойчив к выбросам В ОТЛИЧИЕ от AVG/min/max: единичный
# аномально дорогой/дешёвый лот не сдвигает медиану заметно.
#
# geo_precision IS DISTINCT FROM 'city' — тот же фильтр, что в estimator.py (#769 Part E):
# исключает листинги с геокодом до центра города (city-centroid fallback без номера дома),
# которые иначе "подмешивались" бы в любой радиус вокруг центра.
#
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
#
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
# явно ограничил владелец продукта.
_MEDIAN_PPM2_LOCAL_SQL = text(
"""
SELECT
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision)
AS median_ppm2,
count(*) AS n
FROM listings
WHERE is_active = true
AND price_per_m2 IS NOT NULL
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
AND (geo_precision IS DISTINCT FROM 'city')
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
AND CAST(:bbox_east AS double precision)
AND ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
CAST(:radius_m AS double precision)
)
"""
)
_MEDIAN_PPM2_CITYWIDE_SQL = text(
"""
SELECT
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision)
AS median_ppm2,
count(*) AS n
FROM listings
WHERE is_active = true
AND price_per_m2 IS NOT NULL
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
AND (geo_precision IS DISTINCT FROM 'city')
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
AND CAST(:bbox_east AS double precision)
"""
)
_NEAREST_POI_SQL = text(
"""
SELECT
p.name,
p.category,
CAST(
ST_Distance(
p.geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
) AS double precision
) AS distance_m
FROM osm_poi_ekb_local p
WHERE p.geom IS NOT NULL
AND ST_DWithin(
p.geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
CAST(:radius_m AS double precision)
)
ORDER BY distance_m ASC
LIMIT :limit
"""
)
def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[float | None, int]:
row = (
db.execute(
_MEDIAN_PPM2_LOCAL_SQL,
{
"lat": lat,
"lon": lon,
"radius_m": radius_m,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"bbox_south": _EKB_BBOX_SOUTH,
"bbox_north": _EKB_BBOX_NORTH,
"bbox_west": _EKB_BBOX_WEST,
"bbox_east": _EKB_BBOX_EAST,
},
)
.mappings()
.first()
)
if row is None:
return None, 0
median = row["median_ppm2"]
return (float(median) if median is not None else None), int(row["n"] or 0)
def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
row = (
db.execute(
_MEDIAN_PPM2_CITYWIDE_SQL,
{
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"bbox_south": _EKB_BBOX_SOUTH,
"bbox_north": _EKB_BBOX_NORTH,
"bbox_west": _EKB_BBOX_WEST,
"bbox_east": _EKB_BBOX_EAST,
},
)
.mappings()
.first()
)
if row is None:
return None, 0
median = row["median_ppm2"]
return (float(median) if median is not None else None), int(row["n"] or 0)
def _fetch_nearby_poi(
db: Any, lat: float, lon: float, radius_m: int, top_n: int
) -> tuple[list[NearbyPoi], str]:
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
([], "unavailable") вместо 500 или сфабрикованного списка.
"""
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
if total == 0:
logger.warning(
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
"environment) — nearby_poi unavailable, no fabricated factors"
)
return [], "unavailable"
rows = (
db.execute(
_NEAREST_POI_SQL,
{"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10},
)
.mappings()
.all()
)
ranked: list[tuple[float, NearbyPoi]] = []
for row in rows:
distance_m = float(row["distance_m"])
category = row["category"] or "default"
weight = (1.0 / (distance_m + 100.0)) * _category_weight(category)
ranked.append(
(
weight,
NearbyPoi(poi_type=category, name=row["name"], distance_m=round(distance_m, 1)),
)
)
ranked.sort(key=lambda pair: pair[0], reverse=True)
return [poi for _weight, poi in ranked[:top_n]], "ok"
def compute_location_index(
db: Any,
lat: float,
lon: float,
*,
radius_m: int | None = None,
poi_radius_m: int = DEFAULT_POI_RADIUS_M,
poi_top_n: int = DEFAULT_POI_TOP_N,
) -> LocationIndexResult:
"""Посчитать location index для координат (lat, lon).
location_index_pct = (медиана /м² сопоставимых активных листингов в радиусе точки
медиана /м² по всему ЕКБ) / медиана по ЕКБ * 100. Радиус лестница RADIUS_LADDER_M
(расширяется, пока выборка не наберёт MIN_SAMPLE_SIZE), если явный radius_m не передан
(тогда используется РОВНО он, без расширения для отладки/тестов).
Args:
db: SQLAlchemy Session.
lat: широта целевой точки.
lon: долгота целевой точки.
radius_m: явный радиус в метрах если задан, лестница не используется.
poi_radius_m: радиус для качественного списка «что рядом» (независим от индекса).
poi_top_n: сколько POI показать в «что рядом».
Returns:
LocationIndexResult со status:
- "out_of_coverage" точка вне bbox Екатеринбурга, ничего не считаем.
- "insufficient_data" даже на максимальном радиусе сопоставимых листингов
меньше MIN_SAMPLE_SIZE (или городская выборка-эталон сама вырождена).
- "ok" location_index_pct надёжен.
"""
if not _in_ekb_bbox(lat, lon):
logger.info(
"location_index: lat=%.5f lon=%.5f outside EKB coverage bbox — out_of_coverage",
lat,
lon,
)
return LocationIndexResult(
status="out_of_coverage",
location_index_pct=None,
local_median_price_per_m2=None,
city_median_price_per_m2=None,
sample_size=0,
radius_m=radius_m or RADIUS_LADDER_M[0],
nearby_poi=[],
poi_status="unavailable",
)
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
city_median, city_n = _citywide_median_ppm2(db)
if city_median is None or city_n < MIN_SAMPLE_SIZE:
logger.warning(
"location_index: citywide reference sample too small (n=%d) — insufficient_data",
city_n,
)
return LocationIndexResult(
status="insufficient_data",
location_index_pct=None,
local_median_price_per_m2=None,
city_median_price_per_m2=(round(city_median) if city_median is not None else None),
sample_size=city_n,
radius_m=radius_m or RADIUS_LADDER_M[-1],
nearby_poi=nearby_poi,
poi_status=poi_status,
)
radii = [radius_m] if radius_m is not None else list(RADIUS_LADDER_M)
local_median: float | None = None
sample_size = 0
used_radius = radii[-1]
for r in radii:
local_median, sample_size = _local_median_ppm2(db, lat, lon, r)
used_radius = r
if sample_size >= MIN_SAMPLE_SIZE:
break
if local_median is None or sample_size < MIN_SAMPLE_SIZE:
logger.info(
"location_index: lat=%.5f lon=%.5f sample=%d < MIN_SAMPLE_SIZE=%d up to "
"radius=%dm — insufficient_data",
lat,
lon,
sample_size,
MIN_SAMPLE_SIZE,
used_radius,
)
return LocationIndexResult(
status="insufficient_data",
location_index_pct=None,
local_median_price_per_m2=None,
city_median_price_per_m2=round(city_median),
sample_size=sample_size,
radius_m=used_radius,
nearby_poi=nearby_poi,
poi_status=poi_status,
)
pct = _pct_deviation(local_median, city_median)
logger.debug(
"location_index: lat=%.5f lon=%.5f radius=%dm n=%d local=%d city=%d pct=%.1f",
lat,
lon,
used_radius,
sample_size,
round(local_median),
round(city_median),
pct,
)
return LocationIndexResult(
status="ok",
location_index_pct=pct,
local_median_price_per_m2=round(local_median),
city_median_price_per_m2=round(city_median),
sample_size=sample_size,
radius_m=used_radius,
nearby_poi=nearby_poi,
poi_status=poi_status,
)

View file

@ -5,6 +5,25 @@ Source-of-truth dicts read by merge logic in match_or_create_house/listing.
Sources covered: avito (serp/detail/houses_catalog/domoteka/imv),
cian (serp/bti/detail/stats/valuation), yandex (serp/detail/realty_nb/valuation).
STATUS (audit finding, confirmed against vault Decision_774_Matching_Architecture,
2026-05-31, code-archaeology + live-DB verified): `resolve_house_field` /
`resolve_listing_field` / `HOUSE_FIELD_PRIORITY` / `LISTING_FIELD_PRIORITY` are
NOT called anywhere in the production merge path real house/listing upserts
in `matching/houses.py` / `matching/listings.py` use a simpler ad hoc
`COALESCE(EXCLUDED.x, table.x)` (newest-non-null-wins) pattern instead.
`update_canonical_fields` below is a deliberate Stage-8-v1 no-op stub ("Full
arbitration deferred to Stage 8.x" — see its own docstring); this is NOT an
accidentally-orphaned integration, it is an intentionally-staged one that never
got a Stage-8.x follow-up. Decision_774 already scoped removing this
(`resolve_*`/stub/`match_or_create_listing`) as an independent "Path 2 / Sub-3"
cleanup PR, deliberately kept separate from the accuracy-affecting Path 1a work
(house_id_fk anchor) do NOT wire this into the live price-calc path without a
dedicated backtest+A/B (would change client-facing estimates). Left in place
(not deleted) here because ~30 existing tests in
`tests/matching/test_conflict_resolution.py` + `tests/test_matching.py` cover
it in detail; removing both belongs in that separate Sub-3 PR, not bundled with
an unrelated Tier-S bugfix.
"""
from __future__ import annotations

View file

@ -16,14 +16,25 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.matching.normalize import (
EKB_CITY_TOKEN,
address_fingerprint,
has_city_token,
has_house_number,
house_number_token,
normalize_address,
resolve_city_token,
)
logger = logging.getLogger(__name__)
# Tier-2b oblast guard radius (metres). A bare street+number normalized_address carries
# NO city token yet is globally unique in house_address_aliases, so 'улица ленина 100'
# physically exists in ЕКБ, Н.Тагил, Каменск… When the incoming card HAS coords we accept
# the alias only if its house geom is within this radius. Cities in обл.66 are ≥ ~9-15 km
# apart, so 3 km cleanly separates them while absorbing the intra-city geocoder drift that
# Tier-2b exists to bridge (same building, different provider coords).
_TIER2B_GUARD_M = 3000
def match_or_create_house(
db: Session,
@ -180,19 +191,78 @@ def match_or_create_house(
# Without 2b, two scrapers for the same house with slightly different lat/lon (beyond
# the 4-decimal rounding tolerance) would produce distinct fingerprints, miss Tier 2a,
# and each potentially create a duplicate house row.
#
# Tier-2a/2b oblast city guard (#2500 follow-up, closes the Tier-2a mis-bucket that the
# #2500 Tier-2b geo-guard could not reach): a coord-less card whose address explicitly
# resolves a NON-ЕКБ обл.66 city must NOT match a globally-unique alias (fingerprint OR
# normalized_address) that almost certainly belongs to ЕКБ/another city — that would
# corrupt ЕКБ house data. When it fires we skip both alias lookups and fall through to a
# New house (Tier 3 is coord-gated, so coord-less cards skip it too).
#
# This PREVENTS ЕКБ CORRUPTION. Full oblast-internal dedup (deduping two listings of the
# 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
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
if _skip_oblast_alias:
logger.info(
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
_resolved_city,
norm_addr,
ext_source,
)
row = None
if not _skip_oblast_alias:
row = (
db.execute(
text("SELECT house_id FROM house_address_aliases " "WHERE fingerprint = :fp LIMIT 1"),
text("SELECT house_id FROM house_address_aliases WHERE fingerprint = :fp LIMIT 1"),
{"fp": fp},
)
.mappings()
.first()
)
if row is None and norm_addr and has_num:
if row is None and norm_addr and has_num and not _skip_oblast_alias:
# Tier 2b: same normalized address, possibly different coords fingerprint.
# Gated on has_num (P1): never match a bare-street normalized_address — any
# numberless listing would otherwise collapse into whichever house first
# registered that street.
#
# Oblast guard (bug #2, обл.66 per-city rollout): normalized_address has NO city
# token, so a bare street+number is globally unique in the alias table yet exists
# physically in several cities. Accept the match only when the incoming listing is
# confirmably the same place:
# • coords present → aliased house geom within _TIER2B_GUARD_M metres;
# • coords absent → norm_addr carries a city token (self-disambiguating —
# 'екатеринбург улица ленина 5' can't collide with a Н.Тагил address);
# • neither → skip. A bare common-street with no geo signal is too
# ambiguous — matching would mis-bucket an oblast card into a same-named ЕКБ
# house. Conservative: fall through to a New house rather than mis-match.
if lat is not None and lon is not None:
row = (
db.execute(
text(
"SELECT a.house_id FROM house_address_aliases a "
"JOIN houses h ON h.id = a.house_id "
"WHERE a.normalized_address = :na "
" AND h.geom IS NOT NULL "
" AND ST_DWithin(h.geom::geography, "
" ST_MakePoint(:lon, :lat)::geography, :thr) "
"LIMIT 1"
),
{"na": norm_addr, "lon": lon, "lat": lat, "thr": _TIER2B_GUARD_M},
)
.mappings()
.first()
)
elif has_city_token(norm_addr):
row = (
db.execute(
text(
@ -204,6 +274,12 @@ def match_or_create_house(
.mappings()
.first()
)
else:
logger.info(
"house tier2b skip: bare common-street, no coords/city na=%r src=%s",
norm_addr,
ext_source,
)
if row:
house_id = int(row["house_id"])
_upsert_house_source(
@ -520,6 +596,12 @@ def _insert_alias(
row with the latest fingerprint, which is then found by Tier 2a on the next scrape.
house_id is not updated on conflict: the first writer wins canonical ownership.
Oblast guard (bug #2): the fingerprint/source are refreshed ONLY when the conflicting
row belongs to the SAME house. A DIFFERENT house colliding on a bare common-street
normalized_address (an oblast card that fell through Tier-2b's guard to a New INSERT)
must NOT rewrite the owner's fingerprint — doing so would downgrade a safe coord-bearing
alias to a coord-less one and let the next no-coord card mis-bucket via Tier 2a.
P1: a bare-street normalized_address (no house number) is NOT registered as an
alias it is too ambiguous to serve as a building key. Any later numberless
listing would otherwise hit that alias via Tier 2b and be mass-dumped into the
@ -533,8 +615,12 @@ def _insert_alias(
INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source)
VALUES (CAST(:hid AS bigint), :na, :fp, :src)
ON CONFLICT (normalized_address) DO UPDATE SET
fingerprint = EXCLUDED.fingerprint,
source = EXCLUDED.source
fingerprint = CASE
WHEN house_address_aliases.house_id = EXCLUDED.house_id
THEN EXCLUDED.fingerprint ELSE house_address_aliases.fingerprint END,
source = CASE
WHEN house_address_aliases.house_id = EXCLUDED.house_id
THEN EXCLUDED.source ELSE house_address_aliases.source END
"""),
{
"hid": house_id,

View file

@ -112,6 +112,62 @@ def house_number_token(normalized: str | None) -> str | None:
return m.group(1) if m else None
# Екатеринбург — the home city; every other _CITY_TOKENS entry is a non-ЕКБ обл.66 city.
# The Tier-2a oblast guard treats a resolved non-ЕКБ city specially (see resolve_city_token).
EKB_CITY_TOKEN = "екатеринбург"
# Cities covered by the обл.66 sweep rollout (ЕКБ + oblast per-city schedules).
# Normalized form: lowercase, hyphens collapsed to spaces (mirrors normalize_address,
# e.g. 'каменск-уральский' -> 'каменск уральский'). Mirrors CITY_ANCHORS in
# scraper_kit.orchestration.pipeline — extend together as the sweep adds cities.
# EKB_CITY_TOKEN must be first (leftmost-alternation determinism / documentation).
_CITY_TOKENS: tuple[str, ...] = (
EKB_CITY_TOKEN,
"нижний тагил",
"каменск уральский",
"первоуральск",
"верхняя пышма",
"серов",
)
# Token-bounded (not substring) so a street named after a city does NOT false-positive:
# 'улица серова 5' has token 'серова' (trailing 'а'), never bare 'серов'.
_CITY_TOKEN_RE = re.compile(
r"(?:^|\s)(?:" + "|".join(re.escape(c) for c in _CITY_TOKENS) + r")(?:\s|$)"
)
def resolve_city_token(normalized: str | None) -> str | None:
"""Return the обл.66 city token present in the normalized address, else None.
Token-bounded (see _CITY_TOKEN_RE) so a street named after a city never matches
('улица серова 5' -> None, not 'серов'). When the address names ЕКБ, returns
EKB_CITY_TOKEN; a non-ЕКБ oblast city returns its own token. Used by the Tier-2a
oblast guard in match_or_create_house: a coord-less card that resolves a NON-ЕКБ city
must not fingerprint/normalized_address-match a globally-unique alias that (almost
certainly) belongs to ЕКБ/another city that would corrupt ЕКБ house data.
"""
if not normalized:
return None
m = _CITY_TOKEN_RE.search(normalized)
if m is None:
return None
# group(0) carries the (?:^|\s)...(?:\s|$) boundary whitespace — strip it to the token.
return m.group(0).strip()
def has_city_token(normalized: str | None) -> bool:
"""True if the normalized address carries a known обл.66 city token.
A city token makes the normalized_address city-scoped two addresses in different
cities can then never collapse to the same key, so the alias is a safe cross-source
building key. Used by the Tier-2b oblast guard in match_or_create_house: a bare
common-street address with NO city token AND no coords is too ambiguous to match a
globally-unique normalized_address alias (it would mis-bucket an oblast card into a
same-named ЕКБ house bug #2 oblast rollout).
"""
return resolve_city_token(normalized) is not None
def address_fingerprint(address: str | None, lat: float | None, lon: float | None) -> str:
"""SHA-256 fingerprint of normalized address + rounded coordinates (4 dp ≈ 11 m).

View file

@ -193,6 +193,16 @@ async def _job_deals_freshness_monitor(
await loop.run_in_executor(None, check_deals_freshness, db, run_id, params)
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.sber_freshness_monitor import check_sber_freshness
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, check_sber_freshness, db, run_id, params)
# ── newbuilding_enrich — async, owns lifecycle ───────────────────────────────
async def _job_newbuilding_enrich(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -394,6 +404,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"

View file

@ -5,31 +5,62 @@
открытых данных Росреестра.
- ALERT: залогировать actionable-сообщение, если квартал доступен.
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders это ручной
ops-шаг. После алерта оператор запускает:
ops-шаг. После алерта оператор запускает (оба скрипта живут вне этого
репозитория при смене базового пути их тоже нужно поправить вручную):
data/sql/02_load_all_quarters.sh
tradein-mvp/deploy/import-rosreestr.sh
Архив открытых данных Росреестра (сделки ДКП/ДДУ) публикуется на портале:
https://rosreestr.gov.ru/opendata/
ИСТОРИЯ (важно для будущих правок): раньше архив открытых данных Росреестра
(сделки ДКП/ДДУ) публиковался на https://rosreestr.gov.ru/opendata/ с индексом
/opendata/f.json. Этот портал был переделан на Bitrix прямые ссылки на ZIP
теперь отвечают HTTP 200 с Content-Type: text/html (soft-404 "заглушка"), а
/opendata/f.json больше не отдаёт JSON. Старая версия поллера трактовала любой
HTTP 200 как "квартал доступен" и из-за soft-404 стала ложно репортить КАЖДЫЙ
квартал как доступный это и есть баг #issue, который чинит этот модуль.
Файлы именуются по шаблону:
dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
АКТУАЛЬНОЕ (2026-07) расположение датасетов открытый Apache autoindex без
авторизации:
https://rosreestr.gov.ru/data-sets/
Структура:
- Папки кварталов названы в кодировке Windows-1251 (percent-encoded href
именно cp1251, НЕ utf-8), например "1 квартал 2026г."
href="1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./". Плюс папка
"Архив до 2023г. включительно/" для старых периодов.
- Внутри папки квартала лежит dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
(тоже cp1251-кодированный href, например
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"), рядом
dataset_КАДАСТРСТОИМОСТЬ_* файлы (не наши).
Индексный JSON живёт по адресу:
https://rosreestr.gov.ru/opendata/f.json (список всех датасетов)
Вместо того чтобы вручную высчитывать percent-encoding (хрупко: имена папок
расставляют руками, возможны отличия в пробелах/пунктуации между кварталами)
поллер запрашивает autoindex HTML и парсит href'ы, декодируя каждый
percent-encoded href как cp1251 (urllib.parse.unquote(href, encoding="cp1251")),
затем ищет папку/файл по совпадению с ожидаемым (год, квартал) в декодированном
имени. См. check_new_quarter_available().
Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал.
Формат ссылки:
https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
КЛЮЧЕВАЯ проверка доступности (защита от soft-404, см. "ИСТОРИЯ" выше): найденный
файл датасета считается ДОСТУПНЫМ только если HEAD-ответ имеет
Content-Type: application/zip И Content-Length больше ~100 KB. Голый HTTP 200
недостаточен.
При сетевой ошибке / HTTP 5xx логируем warning, возвращаем available=False.
Статус 404 квартал не опубликован available=False (штатный случай до начала августа).
rosreestr.gov.ru отдаёт сертификат от российского TLS root CA, которому нет
доверия в стандартном trust store соединение делаем с verify=False, как
sber_index.py для sberindex.ru (см. #922, тот же паттерн: публичные
неавторизованные открытые данные, TLS verify отключаем осознанно). Сервер также
отвечает HTTP 403 без браузерного User-Agent шлём Chrome UA (тот же паттерн,
что DEFAULT_UA в zhkh_flats_loader.py).
При сетевой ошибке / HTTP 5xx / таймауте логируем warning, возвращаем
available=False. Отсутствие папки/файла квартала available=False (штатный
случай до публикации квартала, до начала следующего месяца после конца квартала).
"""
from __future__ import annotations
import logging
import re
from typing import Any
from urllib.parse import quote, unquote, urljoin
import httpx
from sqlalchemy import text
@ -37,16 +68,24 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Базовый URL открытых данных Росреестра (прямая ссылка на ZIP-файл квартала).
# Шаблон подтверждён по документации портала opendata.rosreestr.gov.ru.
_ROSREESTR_DATASET_URL_TEMPLATE = (
"https://rosreestr.gov.ru/opendata/"
"dataset_%D0%A1%D0%94%D0%95%D0%9B%D0%9A%D0%98_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
# Открытый Apache autoindex открытых данных Росреестра (см. докстринг модуля).
_DATA_SETS_BASE_URL = "https://rosreestr.gov.ru/data-sets/"
# Браузерный UA — без него rosreestr.gov.ru отвечает HTTP 403.
# Тот же паттерн, что DEFAULT_UA в app/services/zhkh_flats_loader.py.
_BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
# Таймаут HEAD-запроса. Росреестр может быть медленным — 15s достаточно.
# Порог отличия реального ZIP-архива от soft-404 заглушки/пустого файла.
_MIN_DATASET_SIZE_BYTES = 100_000 # 100 KB
# Таймаут запросов. Росреестр может быть медленным — 15s достаточно.
_HTTP_TIMEOUT = 15.0
_HREF_RE = re.compile(r'href="([^"]+)"', re.IGNORECASE)
def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
"""Вернуть (year, quarter) для следующего квартала.
@ -117,11 +156,68 @@ def latest_loaded_quarter(db: Session) -> tuple[int, int] | None:
def rosreestr_dataset_url(year: int, quarter: int) -> str:
"""Сформировать URL датасета Росреестра для заданного квартала.
"""Сформировать best-effort прямую ссылку на ZIP датасета (для alert-сообщения оператору).
Возвращает прямую ссылку на ZIP-архив (percent-encoded шаблон).
ВНИМАНИЕ: это ДЕТЕРМИНИРОВАННАЯ догадка по известному шаблону именования
папка "{quarter} квартал {year}г.", файл
"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip" закодированная как
cp1251 percent-encoding (подтверждено вручную против реального URL портала).
Она НЕ используется для самой проверки доступности: check_new_quarter_available()
парсит реальный autoindex и не полагается на угаданный URL, потому что
Росреестр расставляет имена папок вручную и они могут отличаться в мелочах
(лишний пробел, пунктуация) от квартала к кварталу.
Если угаданная ссылка не откроется ориентир для оператора: _DATA_SETS_BASE_URL
(https://rosreestr.gov.ru/data-sets/), там нужно найти папку глазами.
"""
return _ROSREESTR_DATASET_URL_TEMPLATE.format(year=year, quarter=quarter)
folder_name = f"{quarter} квартал {year}г."
file_name = f"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
folder_enc = quote(folder_name.encode("cp1251"), safe="")
file_enc = quote(file_name.encode("cp1251"), safe="")
return f"{_DATA_SETS_BASE_URL}{folder_enc}/{file_enc}"
def _decode_cp1251_href(href: str) -> str:
"""Декодировать percent-encoded href как cp1251 (портал кодирует кириллицу так, НЕ utf-8)."""
try:
return unquote(href, encoding="cp1251", errors="strict")
except (UnicodeDecodeError, LookupError):
# href без кириллицы (например "../") или неожиданная кодировка — fallback.
return unquote(href)
def _normalize_ws(s: str) -> str:
"""Схлопнуть повторяющиеся пробелы — Росреестр расставляет имена папок вручную."""
return " ".join(s.split())
def _extract_hrefs(html: str) -> list[str]:
"""Достать все href="..." из HTML autoindex-страницы (простой regex, не наш HTML)."""
return _HREF_RE.findall(html)
def _find_quarter_folder_href(html: str, year: int, quarter: int) -> str | None:
"""Найти (не декодированный) href папки квартала (year, quarter) в HTML /data-sets/.
Сравнение по декодированному (cp1251) и нормализованному по пробелам имени
папки, ожидаемый паттерн "{quarter} квартал {year}г.".
"""
expected_name = f"{quarter} квартал {year}г."
for href in _extract_hrefs(html):
decoded = _normalize_ws(_decode_cp1251_href(href).rstrip("/"))
if decoded == expected_name:
return href
return None
def _find_dataset_file_href(html: str, year: int, quarter: int) -> str | None:
"""Найти href файла dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip в HTML папки квартала."""
expected_suffix = f"_y_{year}_q_{quarter}.csv.zip"
for href in _extract_hrefs(html):
decoded = _decode_cp1251_href(href)
if decoded.startswith("dataset_СДЕЛКИ") and decoded.endswith(expected_suffix):
return href
return None
async def check_new_quarter_available(
@ -129,48 +225,109 @@ async def check_new_quarter_available(
year: int,
quarter: int,
) -> bool:
"""Проверить, опубликован ли датасет Росреестра для (year, quarter).
"""Проверить, опубликован ли датасет СДЕЛКИ Росреестра для (year, quarter).
HEAD-запрос к прямой ссылке на ZIP без скачивания файла.
Шаги (см. докстринг модуля за подробностями):
1. GET autoindex _DATA_SETS_BASE_URL, найти папку квартала по декодированному
(cp1251) имени "{quarter} квартал {year}г.".
2. GET найденную папку, найти файл dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip.
3. HEAD найденный файл считаем ДОСТУПНЫМ, только если Content-Type
начинается с application/zip И Content-Length > _MIN_DATASET_SIZE_BYTES.
Голый HTTP 200 НЕ считается доступностью это была причина бага
(Bitrix-заглушка /opendata/ тоже отвечала 200).
Возвращает True если сервер ответил 200 (или 2xx/3xx с redirect).
Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке.
Отсутствие папки/файла квартала available=False (нормально до публикации).
Сетевая ошибка / таймаут / неожиданное исключение warning, available=False.
Никогда не поднимает исключения в вызывающий код (scheduler-safe).
"""
url = rosreestr_dataset_url(year, quarter)
try:
resp = await client.head(url, follow_redirects=True)
if resp.status_code == 200:
logger.info(
"rosreestr_poll: Q%d %d available at %s (HTTP %d)",
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
if index_resp.status_code != 200:
logger.warning(
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
index_resp.status_code,
_DATA_SETS_BASE_URL,
quarter,
year,
url,
resp.status_code,
)
return False
folder_href = _find_quarter_folder_href(index_resp.text, year, quarter)
if folder_href is None:
logger.info(
"rosreestr_poll: quarter folder for Q%d %d not found under %s — not yet published",
quarter,
year,
_DATA_SETS_BASE_URL,
)
return False
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
folder_resp = await client.get(folder_url, follow_redirects=True)
if folder_resp.status_code != 200:
logger.warning(
"rosreestr_poll: unexpected HTTP %d listing folder %s"
"treating Q%d %d as unavailable",
folder_resp.status_code,
folder_url,
quarter,
year,
)
return False
file_href = _find_dataset_file_href(folder_resp.text, year, quarter)
if file_href is None:
logger.info(
"rosreestr_poll: dataset_СДЕЛКИ file for Q%d %d not found in folder %s",
quarter,
year,
folder_url,
)
return False
file_url = urljoin(folder_url, file_href)
file_resp = await client.head(file_url, follow_redirects=True)
content_type = file_resp.headers.get("content-type", "")
try:
content_length = int(file_resp.headers.get("content-length") or "0")
except ValueError:
content_length = 0
if (
file_resp.status_code == 200
and content_type.startswith("application/zip")
and content_length > _MIN_DATASET_SIZE_BYTES
):
logger.info(
"rosreestr_poll: Q%d %d available at %s (HTTP 200, %s, %d bytes)",
quarter,
year,
file_url,
content_type,
content_length,
)
return True
if resp.status_code == 404:
logger.info(
"rosreestr_poll: Q%d %d not yet published (HTTP 404)",
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
"treating as unavailable",
quarter,
year,
file_url,
file_resp.status_code,
content_type,
content_length,
)
return False
# Другие коды (403, 5xx, etc.) — логируем как warning, не кидаем
logger.warning(
"rosreestr_poll: unexpected HTTP %d for Q%d %d url=%s — treating as unavailable",
resp.status_code,
quarter,
year,
url,
)
return False
except httpx.TimeoutException:
logger.warning(
"rosreestr_poll: timeout checking Q%d %d url=%s — treating as unavailable",
"rosreestr_poll: timeout checking Q%d %d under %s — treating as unavailable",
quarter,
year,
url,
_DATA_SETS_BASE_URL,
)
return False
except httpx.RequestError as exc:
@ -197,7 +354,7 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
Шаги:
1. Найти MAX(deal_date) WHERE source='rosreestr' (loaded_year, loaded_quarter).
2. Вычислить next = _next_quarter(loaded_year, loaded_quarter).
3. HEAD-запросом проверить наличие датасета на rosreestr.gov.ru.
3. Проверить наличие датасета на rosreestr.gov.ru/data-sets/ (autoindex + Content-Type).
4. Если доступен логировать actionable INFO с инструкцией по запуску ingest.
5. Вернуть dict с результатом.
@ -230,18 +387,27 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
loaded_year,
)
# 3. Проверка наличия
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client:
# 3. Проверка наличия (verify=False: см. докстринг модуля — RU root CA не в trust store;
# браузерный UA: без него rosreestr.gov.ru отвечает 403)
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT,
verify=False,
headers={"User-Agent": _BROWSER_USER_AGENT},
) as client:
available = await check_new_quarter_available(client, next_year, next_quarter)
# 4. Алерт если доступен
if available:
logger.info(
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d. "
"Run ingest to load: "
"data/sql/02_load_all_quarters.sh + tradein-mvp/deploy/import-rosreestr.sh",
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d (%s). "
"Run ingest to load: data/sql/02_load_all_quarters.sh + "
"tradein-mvp/deploy/import-rosreestr.sh "
"(both live outside this repo — if they still point at the old "
"rosreestr.gov.ru/opendata/ base, update them to %s first)",
next_quarter,
next_year,
rosreestr_dataset_url(next_year, next_quarter),
_DATA_SETS_BASE_URL,
)
return {

View file

@ -37,6 +37,11 @@ from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
# которой прогон помечается FAILED, а не silent-green (Fix C). Единичные битые строки
# (редкий bad row) не валят импорт; систематический сбой (≈100% ошибок) — валит.
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
def compute_next_run_at(
window_start_hour: int,
@ -180,9 +185,16 @@ def import_rosreestr_dkp(
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
сидящих в source foreign table). address и deals.city строятся из реального city
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
источника (= 66 при текущем фильтре).
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
- region_code = 66 (Свердловская область)
- city ILIKE '%катеринбург%'
- region_code = 66 (вся Свердловская область, все города)
- city IS NOT NULL AND trim(city) != '' (непустой город корректный address)
- realestate_type_code = '002001003000' (квартира)
- area BETWEEN 18 AND 200
- deal_price BETWEEN 1000000 AND 100000000
@ -210,7 +222,16 @@ def import_rosreestr_dkp(
counters: dict[str, int] = {
"rows_fetched": 0,
"rows_inserted": 0,
# rows_updated: ON CONFLICT DO UPDATE обновил существующую строку
# (исправленный/переопубликованный квартал — Fix D).
"rows_updated": 0,
# rows_skipped: ТОЛЬКО легитимный dedup-пропуск (строка уже есть, факты
# идентичны — DO UPDATE ... WHERE distinct не сработал).
"rows_skipped": 0,
# rows_errored: реальные per-row INSERT-ошибки, отделены от dedup-skip (Fix C),
# раньше обе категории клались в rows_skipped → систематический сбой выглядел
# как обычный дедуп и прогон рапортовал success.
"rows_errored": 0,
"batches_done": 0,
}
@ -248,8 +269,9 @@ def import_rosreestr_dkp(
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run
# пере-сканирует с id=0 (INSERT ... ON CONFLICT DO NOTHING идемпотентен)
# → уже вставленные строки пропускаются, остаток до-импортируется.
# пере-сканирует с id=0 — INSERT ... ON CONFLICT DO UPDATE идемпотентен
# для неизменных строк (WHERE ... IS DISTINCT FROM пропускает совпадающие
# факты), поэтому повторный проход не плодит дубликаты и не трогает данные.
# mark_done выводит run из 'running' → reap_zombies его не тронет.
logger.info(
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
@ -273,7 +295,9 @@ def import_rosreestr_dkp(
id,
id AS source_id_src,
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
'Екатеринбург, ' || trim(street) AS address,
trim(city) || ', ' || trim(street) AS address,
region_code,
trim(city) AS city,
CASE
WHEN area < 30 THEN 0
WHEN area < 44 THEN 1
@ -296,7 +320,7 @@ def import_rosreestr_dkp(
period_start_date AS deal_date
FROM gendesign_rosreestr_deals
WHERE region_code = 66
AND city ILIKE '%катеринбург%'
AND city IS NOT NULL AND trim(city) <> ''
AND realestate_type_code = '002001003000'
AND area BETWEEN 18 AND 200
AND deal_price BETWEEN 1000000 AND 100000000
@ -318,7 +342,9 @@ def import_rosreestr_dkp(
total_batches += 1
batch_inserted = 0
batch_updated = 0
batch_skipped = 0
batch_errored = 0
batch_max_id = last_id
for row in batch_rows:
@ -328,17 +354,30 @@ def import_rosreestr_dkp(
try:
with db.begin_nested(): # SAVEPOINT per row
inserted = db.execute(
# Fix D: ON CONFLICT DO UPDATE вместо прежнего no-op-дедупа — при
# повторном импорте ИСПРАВЛЕННОГО/переопубликованного квартала обновляем
# сырые факты Росреестра. WHERE ... IS DISTINCT FROM оставляет
# неизменные строки нетронутыми (идемпотентность resume: строка
# без изменений → 0 returned → legit dedup-skip). source/source_id
# (identity) и dedup_hash (ключ конфликта) стабильны, не трогаем.
# Обогащение (lat/lon/geom/geocode_tried_at, cadastral_number,
# total_floors, house_type ...) НЕ в EXCLUDED-списке → сохраняется.
# RETURNING (xmax = 0): freshly-inserted → xmax=0 (was_inserted),
# обновлённая по ON CONFLICT → xmax<>0; отличаем insert от update.
result = db.execute(
text("""
INSERT INTO deals (
source, dedup_hash, source_id, address, rooms, area_m2,
floor, year_built, price_rub, price_per_m2, deal_date
source, dedup_hash, source_id, address, region_code, city,
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
deal_date
)
VALUES (
'rosreestr',
CAST(:dedup_hash AS text),
CAST(:source_id AS text),
CAST(:address AS text),
CAST(:region_code AS int),
CAST(:city AS text),
CAST(:rooms AS int),
CAST(:area_m2 AS numeric),
CAST(:floor_num AS int),
@ -347,13 +386,35 @@ def import_rosreestr_dkp(
CAST(:price_per_m2 AS int),
CAST(:deal_date AS date)
)
ON CONFLICT (dedup_hash) DO NOTHING
RETURNING id
ON CONFLICT (dedup_hash) DO UPDATE SET
address = EXCLUDED.address,
region_code = EXCLUDED.region_code,
city = EXCLUDED.city,
rooms = EXCLUDED.rooms,
area_m2 = EXCLUDED.area_m2,
floor = EXCLUDED.floor,
year_built = EXCLUDED.year_built,
price_rub = EXCLUDED.price_rub,
price_per_m2 = EXCLUDED.price_per_m2,
deal_date = EXCLUDED.deal_date
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
OR deals.city IS DISTINCT FROM EXCLUDED.city
OR deals.rooms IS DISTINCT FROM EXCLUDED.rooms
OR deals.area_m2 IS DISTINCT FROM EXCLUDED.area_m2
OR deals.floor IS DISTINCT FROM EXCLUDED.floor
OR deals.year_built IS DISTINCT FROM EXCLUDED.year_built
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
RETURNING (xmax = 0) AS was_inserted
"""),
{
"dedup_hash": row["dedup_hash"],
"source_id": str(row["source_id_src"]),
"address": row["address"],
"region_code": row["region_code"],
"city": row["city"],
"rooms": row["rooms"],
"area_m2": row["area_m2"],
"floor_num": row["floor_num"],
@ -363,25 +424,33 @@ def import_rosreestr_dkp(
"deal_date": row["deal_date"],
},
).fetchone()
if inserted is not None:
if result is None:
# DO UPDATE ... WHERE distinct не сработал → строка есть и
# факты идентичны = легитимный dedup-skip (Fix C: только это
# теперь считается skip, INSERT-ошибки — отдельно ниже).
batch_skipped += 1
elif result[0]:
batch_inserted += 1
else:
batch_skipped += 1
batch_updated += 1
except Exception as exc:
# Fix C: реальная per-row INSERT-ошибка — ОТДЕЛЬНЫЙ счётчик, не skip.
logger.warning(
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
run_id,
row_id,
exc,
)
batch_skipped += 1
batch_errored += 1
last_id = batch_max_id
db.commit()
counters["rows_fetched"] += len(batch_rows)
counters["rows_inserted"] += batch_inserted
counters["rows_updated"] += batch_updated
counters["rows_skipped"] += batch_skipped
counters["rows_errored"] += batch_errored
counters["batches_done"] = total_batches
counters["last_id"] = last_id # type: ignore[assignment]
@ -389,26 +458,42 @@ def import_rosreestr_dkp(
runs_mod.update_heartbeat(db, run_id, counters)
logger.info(
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
"inserted=%d skipped=%d last_id=%d",
"inserted=%d updated=%d skipped=%d errored=%d last_id=%d",
run_id,
total_batches,
len(batch_rows),
batch_inserted,
batch_updated,
batch_skipped,
batch_errored,
last_id,
)
if len(batch_rows) < batch_size:
break # Last partial batch — no more rows
# Fix C: систематический per-row INSERT-сбой больше не рапортует success.
# Отделив rows_errored от rows_skipped, проверяем долю ошибок: выше порога —
# прогон FAILED (raise → внешний except → mark_failed), а не silent-green.
fetched = counters["rows_fetched"]
errored = counters["rows_errored"]
if fetched > 0 and errored / fetched > DKP_IMPORT_ERROR_RATE_THRESHOLD:
raise RuntimeError(
f"rosreestr_dkp_import: per-row INSERT error rate "
f"{errored}/{fetched} ({errored / fetched:.1%}) exceeds "
f"{DKP_IMPORT_ERROR_RATE_THRESHOLD:.0%} threshold — marking run failed"
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
"rosreestr_dkp_import run_id=%d done: "
"total_fetched=%d inserted=%d skipped=%d batches=%d",
"total_fetched=%d inserted=%d updated=%d skipped=%d errored=%d batches=%d",
run_id,
counters["rows_fetched"],
counters["rows_inserted"],
counters["rows_updated"],
counters["rows_skipped"],
counters["rows_errored"],
total_batches,
)

View file

@ -0,0 +1,9 @@
"""Telegram support bridge (@MERAsupport_bot) — long-polling мост клиент↔оператор.
Клиент пишет боту в личку зеркалится в топик support-группы (`client.py`
тонкая HTTP-обёртка над Bot API; `bridge.py` маршрутизация апдейтов и
персистентность через tg_support_* таблицы). Standalone entrypoint
`app.tgbot_main` (long-polling воркер, НЕ webhook, отдельный контейнер/процесс).
"""
from __future__ import annotations

View file

@ -0,0 +1,728 @@
"""Маршрутизация Telegram-апдейтов для support-моста (#tgsupport, #tgsupport-web).
Поток:
A) Клиент пишет боту в личку (chat.type == 'private')
upsert tg_support_users (если первое сообщение за последний час шапка
с идентификацией клиента в топик) copyMessage контента в support-топик
запись в tg_support_messages (direction='in', topic_message_id ключ
маршрутизации ответа).
A') Пользователь сайта пишет через `app.api.v1.support` (веб-чат поддержки,
#tgsupport-web) → тот эндпоинт САМ зеркалит sendMessage'ом в топик и пишет
web_support_messages (direction='in') этот модуль в этой ветке не участвует,
только в разборе ответа (B ниже).
B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента
резолвим topic_message_id ОБЕ стороны (tg_support_messages И
web_support_messages), скоупя к ТЕКУЩЕМУ TELEGRAM_SUPPORT_CHAT_ID
(#tgsupport-web review M1 — если группу когда-нибудь сменят/пересоздадут,
Telegram message_id стартует заново и может совпасть со старым числом из
другой таблицы; без скоупинга это была бы ТИХАЯ доставка постороннему
клиенту). Совпадение НА ОБЕИХ сторонах одновременно громкий отказ
(`logger.error`, ничего не доставляем) вместо произвольного выбора одной из
них. Иначе: chat_id найден copyMessage ответа в личку клиента запись
(direction='out'); thread_id найден (веб-зеркало) доставка идёт НЕ в
Telegram (у веб-клиента нет личного чата с ботом), а записью direction='out'
в web_support_messages (веб-фронт вычитывает её обычным polling'ом); реплай
медиа-типом на веб-зеркало веб-чат текстовый MVP, доставка целиком
отклоняется (не частично фото с подписью НЕ превращается в "ответ = только
подпись"), оператор получает уведомление в топике (review M2). Реплай не на
зеркало (или не реплай вообще) обычная болтовня в топике, тихий игнор.
Telegram 403 (клиент заблокировал бота) is_blocked=true + уведомление в
топике (только для Telegram-ветки у веб-клиента нет "заблокировал бота").
C) Дедуп: update_id <= сохранённого offset skip. Offset сохраняется И
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
`finally`), после КАЖДОГО апдейта рестарт воркера не переигрывает уже
обработанные апдейты и не подвисает вечно на «ядовитом» апдейте.
D) TELEGRAM_BOT_TOKEN пуст бот выключен проверяется в `app.tgbot_main`
(entrypoint), не здесь.
E) /start клиенту короткое приветствие МЕРЫ, без зеркалирования в топик
(команда не содержательное обращение, не должна засорять топик).
F) Флуд-лимит на отправителя (низкий приоритет, per-chat_id): воркер
long-polling однопоточный и обрабатывает апдейты СТРОГО последовательно, а
Telegram ограничивает саму support-группу ~20 сообщениями/минуту ОДНИМ
бюджетом на ВСЕХ клиентов разом (зеркала + шапки + ответы оператора).
Превышение 429 с ожиданием 30-60с, на которые воркер не может обработать
НИ ОДНОГО следующего апдейта один флудящий клиент подвешивает доставку
всем остальным. `_flood_limiter` (тот же `SlidingWindowLimiter`, что и
веб-чат поддержки, ключ TELEGRAM chat_id) режет per-sender поток заметно
ниже группового лимита; сообщения сверх бюджета НЕ зеркалируются (иначе
сам факт мирроринга уже съедает групповой бюджет, который мы и защищаем) и
НЕ пишутся в tg_support_messages (нечего маршрутизировать без
topic_message_id). Клиент получает уведомление, что сообщение НЕ
доставлено (молчать нельзя иначе клиент решит, что оператор его получил),
но не чаще ОДНОГО РАЗА за то же окно (`_flood_notify_limiter`, limit=1)
иначе само уведомление стало бы вторым источником флуда.
Персистентность вынесена за `BridgeStorage`-протокол маршрутизирующая логика
(`process_update` и приватные `_handle_*`) не завязана на реальную БД, тестируется
на in-memory fake storage + mock httpx (см. tests/services/tgbot/). Веб-чат
таблицы (web_support_threads/web_support_messages) сознательно ОТДЕЛЬНЫ от
tg_support_* обоснование в data/sql/187_web_support_chat.sql; здесь `BridgeStorage`
несёт два дополнительных метода (`find_web_thread_by_topic_message`,
`record_web_out_message`), делегирующих в `web_support_storage`.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from typing import Any, Protocol
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.ratelimit import SlidingWindowLimiter
from app.core.shutdown import shutdown_requested
from app.services.tgbot import web_support_storage
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
# Ключ в tg_support_state под который сохраняется last processed update_id
# (см. data/sql/186_tg_support.sql — комментарий на колонке .key).
_OFFSET_KEY = "last_update_id"
# Окно, за которое повторное сообщение клиента НЕ дублирует шапку-идентификацию
# в топике (одна шапка на "сессию" обращения).
_HEADER_THROTTLE_WINDOW_S = 3600
GREETING_TEXT = (
"Здравствуйте! Это служба поддержки МЕРА (сервис trade-in квартир). "
"Опишите ваш вопрос — оператор ответит вам в этом чате в ближайшее время."
)
# Отправляется клиенту вместо тихой потери сообщения, если TELEGRAM_SUPPORT_CHAT_ID
# не сконфигурирован (иначе клиент ждёт ответа, которого никогда не будет — #5 review).
SERVICE_UNAVAILABLE_TEXT = (
"Служба поддержки временно недоступна. Пожалуйста, попробуйте написать позже."
)
# Kinds, задокументированные в data/sql/186_tg_support.sql (COMMENT ON COLUMN
# tg_support_messages.kind): "text | photo | document | video | voice | other".
_KNOWN_KINDS = ("text", "photo", "document", "video", "voice")
# (низкий приоритет, флуд-защита) — см. пункт F) в докстринге модуля. Порог
# НАМЕРЕННО заметно ниже группового лимита Telegram (~20 msg/min): бюджет
# делится с шапками-идентификациями и ответами оператора, и с другими
# одновременными клиентами — щедрый лимит одного отправителя всё равно упёрся
# бы в общий групповой 429. Тот же примитив, что и веб-чат поддержки
# (app/api/v1/support.py `_send_limiter`), ключ здесь — TELEGRAM chat_id
# отправителя (не username — у Telegram-клиента username может отсутствовать).
_FLOOD_LIMIT = 5
_FLOOD_WINDOW_S = 60.0
_flood_limiter = SlidingWindowLimiter(limit=_FLOOD_LIMIT, window_s=_FLOOD_WINDOW_S)
# Уведомление о флуде — не чаще ОДНОГО раза за то же окно, иначе само
# уведомление стало бы вторым источником флуда. Отдельный лимитер с limit=1 на
# то же окно: `check()` возвращает None (и фиксирует попытку) ровно один раз за
# окно, дальше молчит до его истечения — без отдельной структуры "когда в
# последний раз уведомляли".
_flood_notify_limiter = SlidingWindowLimiter(limit=1, window_s=_FLOOD_WINDOW_S)
FLOOD_LIMITED_TEXT = (
"Сообщение не доставлено — вы отправляете сообщения слишком часто. "
"Пожалуйста, подождите немного и напишите ещё раз."
)
# #tgsupport-web review M2: реплай оператора медиа-типом (в т.ч. фото С ПОДПИСЬЮ)
# на веб-зеркало НЕ доставляется частично — веб-чат текстовый MVP, оператор
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
class BridgeStorage(Protocol):
"""Persistence-контракт моста. `SqlBridgeStorage` — прод-реализация поверх
tg_support_* (см. data/sql/186_tg_support.sql). Тесты используют in-memory fake."""
def get_offset(self) -> int: ...
def save_offset(self, update_id: int) -> None: ...
def commit(self) -> None: ...
def rollback(self) -> None: ...
def upsert_user(
self,
*,
chat_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
) -> None: ...
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool: ...
def record_message(
self,
*,
chat_id: int,
direction: str,
tg_message_id: int | None,
topic_message_id: int | None,
kind: str,
text_body: str | None,
operator_tg_id: int | None,
support_chat_id: int | None = None,
) -> int | None: ...
def find_chat_by_topic_message(
self, topic_message_id: int, support_chat_id: int
) -> int | None: ...
def mark_blocked(self, chat_id: int) -> None: ...
def find_web_thread_by_topic_message(
self, topic_message_id: int, support_chat_id: int
) -> int | None: ...
def record_web_out_message(
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> None: ...
class SqlBridgeStorage:
"""`BridgeStorage` поверх SQLAlchemy Session (psycopg v3), tg_support_* таблицы.
Методы исполняют SQL немедленно, но НЕ коммитят по отдельности коммит
один раз в конце `process_update` (после записи сообщения И offset'а), чтобы
оба изменения фиксировались атомарно в одной транзакции (требование C).
"""
def __init__(self, db: Session) -> None:
self._db = db
def get_offset(self) -> int:
row = self._db.execute(
text("SELECT value FROM tg_support_state WHERE key = CAST(:key AS text)"),
{"key": _OFFSET_KEY},
).fetchone()
if row is None or row[0] is None:
return 0
try:
return int(row[0])
except (TypeError, ValueError):
logger.warning("tgbot storage: невалидный offset в БД (%r) — считаем 0", row[0])
return 0
def save_offset(self, update_id: int) -> None:
self._db.execute(
text(
"""
INSERT INTO tg_support_state (key, value, updated_at)
VALUES (CAST(:key AS text), CAST(:value AS text), NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, updated_at = NOW()
"""
),
{"key": _OFFSET_KEY, "value": str(update_id)},
)
def commit(self) -> None:
self._db.commit()
def rollback(self) -> None:
"""Откатывает текущую (возможно failed-transaction) сессию перед save_offset.
Нужно, когда исключение пришло от самой БД (напр. обрыв коннекта к
postgres при деплое) SQLAlchemy Session после такого исключения
переходит в failed-transaction state, и ЛЮБОЙ следующий `execute()`
(включая `save_offset`) кидает `PendingRollbackError` без явного
rollback() (#3 review — иначе update_id не сдвигается, апдейт
переигрывается на следующей итерации, copyMessage дублирует зеркало
клиента в топик на каждый повтор).
"""
self._db.rollback()
def upsert_user(
self,
*,
chat_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
) -> None:
self._db.execute(
text(
"""
INSERT INTO tg_support_users
(chat_id, username, first_name, last_name, language_code,
created_at, last_seen_at, is_blocked)
VALUES
(CAST(:chat_id AS bigint), :username, :first_name, :last_name,
:language_code, NOW(), NOW(), FALSE)
ON CONFLICT (chat_id) DO UPDATE
SET username = EXCLUDED.username,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
language_code = EXCLUDED.language_code,
last_seen_at = NOW(),
is_blocked = FALSE
"""
),
{
"chat_id": chat_id,
"username": username,
"first_name": first_name,
"last_name": last_name,
"language_code": language_code,
},
)
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool:
row = self._db.execute(
text(
"""
SELECT 1
FROM tg_support_messages
WHERE chat_id = CAST(:chat_id AS bigint)
AND direction = 'in'
AND created_at > NOW() - make_interval(secs => CAST(:window_seconds AS integer))
LIMIT 1
"""
),
{"chat_id": chat_id, "window_seconds": window_seconds},
).fetchone()
return row is not None
def record_message(
self,
*,
chat_id: int,
direction: str,
tg_message_id: int | None,
topic_message_id: int | None,
kind: str,
text_body: str | None,
operator_tg_id: int | None,
support_chat_id: int | None = None,
) -> int | None:
row = self._db.execute(
text(
"""
INSERT INTO tg_support_messages
(chat_id, direction, tg_message_id, topic_message_id, kind,
text_body, operator_tg_id, support_chat_id, created_at)
VALUES
(CAST(:chat_id AS bigint), CAST(:direction AS text),
CAST(:tg_message_id AS bigint), CAST(:topic_message_id AS bigint),
CAST(:kind AS text), :text_body, CAST(:operator_tg_id AS bigint),
CAST(:support_chat_id AS bigint), NOW())
RETURNING id
"""
),
{
"chat_id": chat_id,
"direction": direction,
"tg_message_id": tg_message_id,
"topic_message_id": topic_message_id,
"kind": kind,
"text_body": text_body,
"operator_tg_id": operator_tg_id,
"support_chat_id": support_chat_id,
},
).fetchone()
return int(row[0]) if row is not None else None
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
"""Скоупим к ТЕКУЩЕМУ support_chat_id (#tgsupport-web review M1) — строка
со ЧУЖИМ (не NULL, не текущим) support_chat_id исторический артефакт
ротации support-группы, не валидный маршрут сегодня. NULL (строки до
миграции 188, если есть) лениентный wildcard-матч (единственный
действовавший чат на тот момент)."""
row = self._db.execute(
text(
"""
SELECT chat_id
FROM tg_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in'
AND (support_chat_id = CAST(:support_chat_id AS bigint)
OR support_chat_id IS NULL)
ORDER BY created_at DESC
LIMIT 1
"""
),
{"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
).fetchone()
return int(row[0]) if row is not None else None
def mark_blocked(self, chat_id: int) -> None:
self._db.execute(
text(
"UPDATE tg_support_users SET is_blocked = TRUE "
"WHERE chat_id = CAST(:chat_id AS bigint)"
),
{"chat_id": chat_id},
)
def find_web_thread_by_topic_message(
self, topic_message_id: int, support_chat_id: int
) -> int | None:
"""Делегирует в `web_support_storage` (#tgsupport-web) — то же соединение/
транзакцию, что и tg-путь, коммитится вместе offset'ом в `process_update`."""
return web_support_storage.find_thread_by_topic_message(
self._db, topic_message_id, support_chat_id
)
def record_web_out_message(
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> None:
web_support_storage.record_outbound(
self._db,
thread_id=thread_id,
text_body=text_body,
operator_tg_id=operator_tg_id,
)
# ── Pure helpers ──────────────────────────────────────────────────────────────
def _infer_kind(message: dict[str, Any]) -> str:
"""Content-type сообщения → kind-строка. Неизвестные типы (voice/sticker/location/
etc.) сворачиваются в 'other' см. документированный набор в COMMENT ON COLUMN."""
for field in _KNOWN_KINDS:
if field in message:
return field
return "other"
def _format_topic_header(
chat_id: int, username: str | None, first_name: str | None, last_name: str | None
) -> str:
"""Короткая шапка-идентификация клиента для support-топика."""
display_name = " ".join(p for p in (first_name, last_name) if p) or "Без имени"
username_part = f", @{username}" if username else ""
return f"Новое обращение от {display_name}{username_part} (chat_id={chat_id})"
# ── Update routing ────────────────────────────────────────────────────────────
async def _handle_private_message(
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
) -> None:
"""A) Личка клиента → бот. upsert user → (опц. шапка) → зеркало в топик."""
chat = message.get("chat") or {}
chat_id = chat.get("id")
if not isinstance(chat_id, int):
logger.warning("tgbot bridge: приватное сообщение без валидного chat.id — игнор")
return
from_user = message.get("from") or {}
username = from_user.get("username")
first_name = from_user.get("first_name")
last_name = from_user.get("last_name")
language_code = from_user.get("language_code")
storage.upsert_user(
chat_id=chat_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
)
text_body = message.get("text")
if text_body == "/start":
# E) команда — не содержательное обращение, топик не засоряем.
await client.send_message(chat_id=chat_id, text=GREETING_TEXT)
return
if not settings.telegram_support_chat_id:
logger.warning(
"tgbot bridge: TELEGRAM_SUPPORT_CHAT_ID не задан — сообщение от chat_id=%d "
"не может быть зеркалировано; отвечаем клиенту вместо тихой потери (#5 review)",
chat_id,
)
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
await client.send_message(chat_id=chat_id, text=SERVICE_UNAVAILABLE_TEXT)
return
message_id = message.get("message_id")
if not isinstance(message_id, int):
logger.warning("tgbot bridge: приватное сообщение без message_id — игнор")
return
# F) Флуд-лимит на отправителя — peek БЕЗ расхода бюджета (тот же паттерн,
# что `_send_limiter` в app/api/v1/support.py: под лимитом ниже сразу
# `.record()`-им попытку). Над лимитом — НЕ зеркалируем (иначе сам мирроринг
# уже съедает групповой Telegram-бюджет, который лимит и защищает) и НЕ
# пишем в tg_support_messages (без topic_message_id маршрутизировать ответ
# всё равно нечего).
flood_key = str(chat_id) # SlidingWindowLimiter — ключ str (см. app/core/ratelimit.py)
if _flood_limiter.retry_after(flood_key) is not None:
logger.warning(
"tgbot bridge: chat_id=%d превысил флуд-лимит (%d msg/%.0fs) — "
"сообщение НЕ зеркалируется в топик (защита группового Telegram-лимита)",
chat_id,
_FLOOD_LIMIT,
_FLOOD_WINDOW_S,
)
# Уведомляем клиента, что сообщение НЕ доставлено (молчать нельзя —
# иначе клиент решит, что оператор его получил), но не чаще одного раза
# за окно — `_flood_notify_limiter.check()` возвращает None (и сам
# фиксирует попытку) ровно один раз за окно.
if _flood_notify_limiter.check(flood_key) is None:
await client.send_message(chat_id=chat_id, text=FLOOD_LIMITED_TEXT)
return
_flood_limiter.record(flood_key)
# Шапка — только на первое сообщение клиента за окно, иначе топик засоряется.
if not storage.had_recent_inbound(chat_id, window_seconds=_HEADER_THROTTLE_WINDOW_S):
header = _format_topic_header(chat_id, username, first_name, last_name)
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=header,
message_thread_id=settings.telegram_support_topic_id or None,
)
mirrored = await client.copy_message(
chat_id=settings.telegram_support_chat_id,
from_chat_id=chat_id,
message_id=message_id,
message_thread_id=settings.telegram_support_topic_id or None,
)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
storage.record_message(
chat_id=chat_id,
direction="in",
tg_message_id=message_id,
topic_message_id=topic_message_id,
kind=_infer_kind(message),
text_body=text_body or message.get("caption"),
operator_tg_id=None,
support_chat_id=settings.telegram_support_chat_id,
)
async def _handle_group_reply(
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
) -> None:
"""B) Реплай оператора в support-группе → доставка ответа клиенту (Telegram
ЛИБО веб-чат, #tgsupport-web — см. модульный docstring)."""
reply_to = message.get("reply_to_message")
if not isinstance(reply_to, dict):
return # не реплай вообще — обычная болтовня в топике, тихий игнор
mirror_message_id = reply_to.get("message_id")
if not isinstance(mirror_message_id, int):
return
# #tgsupport-web review M1: резолвим ОБЕ стороны с текущим support_chat_id
# (НЕ short-circuit на первом найденном) — если topic_message_id совпал в
# ОБЕИХ таблицах одновременно, это значит инвариант "уникален в пределах
# текущей support-группы" нарушен (баг/ручная правка данных) — отказываем в
# доставке ГРОМКО, вместо того чтобы молча выбрать tg-путь и отправить ответ
# постороннему Telegram-клиенту (152-ФЗ misroute risk).
current_chat_id = settings.telegram_support_chat_id
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id, current_chat_id)
web_thread_id = storage.find_web_thread_by_topic_message(mirror_message_id, current_chat_id)
if target_chat_id is not None and web_thread_id is not None:
logger.error(
"tgbot bridge: topic_message_id=%d резолвится ОДНОВРЕМЕННО в Telegram "
"(chat_id=%d) и веб-чат (thread_id=%d) под support_chat_id=%d — отказ в "
"доставке, требуется ручной разбор tg_support_messages/web_support_messages",
mirror_message_id,
target_chat_id,
web_thread_id,
current_chat_id,
)
return
if target_chat_id is not None:
# Существующий Telegram-путь — НЕ ТРОНУТ.
message_id = message.get("message_id")
if not isinstance(message_id, int):
return
operator = message.get("from") or {}
operator_id = operator.get("id")
try:
delivered = await client.copy_message(
chat_id=target_chat_id,
from_chat_id=settings.telegram_support_chat_id,
message_id=message_id,
)
except TelegramApiError as exc:
if exc.error_code == 403:
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике.
storage.mark_blocked(target_chat_id)
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=(
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — "
"бот заблокирован."
),
message_thread_id=settings.telegram_support_topic_id or None,
reply_to_message_id=message_id,
)
return
raise
tg_message_id = delivered.get("message_id") if isinstance(delivered, dict) else None
storage.record_message(
chat_id=target_chat_id,
direction="out",
tg_message_id=tg_message_id,
topic_message_id=None,
kind=_infer_kind(message),
text_body=message.get("text") or message.get("caption"),
operator_tg_id=operator_id,
)
return
if web_thread_id is not None:
message_id = message.get("message_id")
kind = _infer_kind(message)
if kind != "text":
# #tgsupport-web review M2: НЕ доставляем частично (фото С ПОДПИСЬЮ
# молча превратилось бы в "ответ = только текст подписи", клиент решил
# бы что это весь ответ) — отказ целиком + явное уведомление оператору
# в топике (тот же паттерн, что 403-уведомление выше), иначе оператор
# уверен, что ответ доставлен, хотя веб-чат не поддерживает медиа.
logger.warning(
"tgbot bridge: реплай на веб-зеркало (thread_id=%d) содержит %s, "
"не текст — веб-чат поддерживает только текст, доставка отклонена",
web_thread_id,
kind,
)
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
message_thread_id=settings.telegram_support_topic_id or None,
reply_to_message_id=message_id if isinstance(message_id, int) else None,
)
return
text_body = message.get("text")
if not text_body:
# Текстовый kind, но пустой text (защитный edge case) — нечего доставлять.
return
operator = message.get("from") or {}
operator_id = operator.get("id")
storage.record_web_out_message(
thread_id=web_thread_id,
text_body=text_body,
operator_tg_id=operator_id,
)
return
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ
# (is_bot=True) и при этом отсутствующее ни в tg_support_messages, ни в
# web_support_messages — подозрительно: вероятная причина — осиротевшее
# зеркало (воркер/API упал МЕЖДУ отправкой зеркала и commit'ом записи в БД).
# Дискриминатор неидеальный (шапка-идентификация тоже от бота, но не
# routing-ключ — тоже даст этот WARNING), но лучше редкий ложный WARNING, чем
# оператор молча решает, что ответ доставлен, хотя реплай тихо утонул
# (#4 review — двухфазный протокол НЕ делаем, overkill).
reply_from = reply_to.get("from") or {}
if reply_from.get("is_bot"):
logger.warning(
"tgbot bridge: реплай на сообщение бота (message_id=%d) не найден ни в "
"tg_support_messages, ни в web_support_messages как зеркало — возможно, "
"осиротевшее зеркало (крах между отправкой и commit'ом) или "
"шапка-идентификация; ответ оператора НЕ доставлен",
mirror_message_id,
)
async def process_update(
update: dict[str, Any], client: TelegramClient, storage: BridgeStorage
) -> None:
"""Маршрутизирует один Telegram update. Дедуп (C) + атомарный offset-commit.
Дедуп: update_id <= сохранённого offset skip без side-effects. Offset
сохраняется и коммитится ПОСЛЕ обработки (в т.ч. если обработка упала
иначе «ядовитый» апдейт блокировал бы весь поток навсегда).
Различаем сбой БД (`SQLAlchemyError`) от прочих (Telegram API и т.п.):
сбой БД оставляет сессию в failed-transaction state `rollback()` ОБЯЗАН
отработать ПЕРЕД `save_offset`, иначе тот сам кинет `PendingRollbackError`,
`process_update` вылетит без сохранения offset'а, следующая итерация
`run_poll_loop` получит СТАРЫЙ offset от `get_offset()` и переиграет тот же
апдейт заново copyMessage задублирует зеркало клиента в топике на
каждый повтор поллинга (#3 review, воспроизведено).
"""
update_id = update.get("update_id")
if not isinstance(update_id, int):
logger.warning("tgbot bridge: update без валидного update_id — игнор")
return
current_offset = storage.get_offset()
if update_id <= current_offset:
logger.debug(
"tgbot bridge: update_id=%d уже обработан (offset=%d) — skip",
update_id,
current_offset,
)
return
message = update.get("message")
try:
if isinstance(message, dict):
chat = message.get("chat") or {}
chat_type = chat.get("type")
chat_id = chat.get("id")
if chat_type == "private":
await _handle_private_message(message, client, storage)
elif settings.telegram_support_chat_id and chat_id == settings.telegram_support_chat_id:
await _handle_group_reply(message, client, storage)
# иначе — необрабатываемый тип чата/апдейта (edited_message, канал и
# т.п.) — тихий игнор, но offset всё равно сдвигаем ниже.
except SQLAlchemyError:
logger.exception(
"tgbot bridge: DB-ошибка на update_id=%d — rollback перед сохранением "
"offset (иначе save_offset сам упадёт на failed-transaction state)",
update_id,
)
storage.rollback()
except Exception:
logger.exception(
"tgbot bridge: обработка update_id=%d упала — offset всё равно сдвигаем "
"(не блокируем поток на 'ядовитом' апдейте)",
update_id,
)
finally:
storage.save_offset(update_id)
storage.commit()
# ── Long-polling loop ─────────────────────────────────────────────────────────
async def run_poll_loop(
client: TelegramClient,
session_factory: Callable[[], Session],
poll_timeout_s: int = 30,
) -> None:
"""Бесконечный long-polling цикл до `shutdown_requested()`.
Свежая DB-сессия на каждую итерацию (одна итерация = один getUpdates-вызов +
обработка полученной пачки апдейтов) не держим соединение открытым на
неопределённый срок между итерациями.
"""
logger.info("tgbot bridge: старт poll loop (timeout=%ds)", poll_timeout_s)
consecutive_errors = 0
while not shutdown_requested():
try:
with session_factory() as db:
storage = SqlBridgeStorage(db)
offset = storage.get_offset()
updates = await client.get_updates(
offset=offset + 1, timeout=poll_timeout_s, allowed_updates=["message"]
)
for update in updates:
await process_update(update, client, storage)
consecutive_errors = 0
except Exception:
consecutive_errors += 1
backoff = min(5 * consecutive_errors, 60)
logger.exception("tgbot bridge: итерация poll loop упала — retry через %ds", backoff)
await asyncio.sleep(backoff)
logger.info("tgbot bridge: poll loop остановлен (shutdown)")

View file

@ -0,0 +1,265 @@
"""Тонкая httpx-обёртка над Telegram Bot API (#tgsupport).
Зачем свой клиент, а не aiogram: единственные нужные методы `getUpdates`
(long-polling), `copyMessage` (зеркалирование ЛЮБОГО типа контента без ре-аплоада)
и `sendMessage` (заголовки/приветствия/уведомления). aiogram избыточная
зависимость (webhook-framework, dispatcher, FSM) ради трёх HTTP-вызовов; в стеке
уже есть httpx (см. `app.services.dadata`, `app.services.geocoder` тот же паттерн
retry/timeout).
Docs: https://core.telegram.org/bots/api
Ретраи:
- HTTP 429 (Too Many Requests) уважаем `parameters.retry_after` из тела ответа
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
- HTTP 5xx / сетевые ошибки (timeout/connect) экспоненциальный backoff,
`capped` на `_MAX_BACKOFF_S`.
- Любая другая 4xx (400/401/403/404) НЕ ретраится, сразу `TelegramApiError`
(запрос некорректен или прав нет повтор не поможет).
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
HTTP-статус и `description` из ответа Telegram токен туда не пишем.
Это НЕ гарантирует, что токен не утечёт по другим стокам: он живёт в
`self._base`/`url` (локальные переменные stack-фрейма `_request`), а GlitchTip
(sentry_sdk) по умолчанию прикладывает locals к traceback и Httpx-интеграция
кладёт полный URL в span data. Эти стоки закрываются НЕ здесь, а в
`app.tgbot_main` (`include_local_variables=False`, `before_send`-редактор,
`traces_sample_rate=0.0`) и подавлением INFO-логов самого `httpx`-логгера
(который печатает полный request URL, включая токен, на уровне INFO).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT_S = 15.0
_DEFAULT_RETRY_AFTER_S = 5.0
_MAX_BACKOFF_S = 30.0
_DEFAULT_MAX_RETRIES = 5
class TelegramApiError(Exception):
"""Telegram Bot API ответил `ok: false` (после исчерпания ретраев, если применимо)."""
def __init__(self, method: str, error_code: int, description: str) -> None:
self.method = method
self.error_code = error_code
self.description = description
super().__init__(f"Telegram API {method} failed: {error_code} {description}")
def _extract_retry_after(
response: httpx.Response, default: float = _DEFAULT_RETRY_AFTER_S
) -> float:
"""Достаёт `parameters.retry_after` из тела 429-ответа. Fallback — `default`."""
try:
data = response.json()
except ValueError:
return default
if not isinstance(data, dict):
return default
params = data.get("parameters")
if isinstance(params, dict):
retry_after = params.get("retry_after")
if isinstance(retry_after, int | float):
return float(retry_after)
return default
def _error_from_body(response: httpx.Response) -> tuple[int, str]:
"""Парсит (error_code, description) из тела ответа Telegram; fallback на HTTP-статус."""
try:
data = response.json()
except ValueError:
return response.status_code, (response.text or "")[:200]
if not isinstance(data, dict):
return response.status_code, str(data)[:200]
error_code = data.get("error_code", response.status_code)
description = data.get("description", "")
code = int(error_code) if isinstance(error_code, int | float) else response.status_code
return code, str(description)
class TelegramClient:
"""Bot API клиент на httpx.AsyncClient. Каждый вызов — отдельное короткоживущее соединение."""
def __init__(
self,
token: str,
base_url: str = "https://api.telegram.org",
timeout: float = _DEFAULT_TIMEOUT_S,
) -> None:
self._base = f"{base_url}/bot{token}"
self._timeout = timeout
async def _request(
self,
method: str,
payload: dict[str, Any],
*,
timeout: float | None = None,
max_retries: int = _DEFAULT_MAX_RETRIES,
) -> Any:
"""POST `method` с JSON-телом `payload`. Ретраит 429/5xx/network, иначе raise сразу."""
url = f"{self._base}/{method}"
effective_timeout = timeout if timeout is not None else self._timeout
attempt = 0
while True:
attempt += 1
try:
async with httpx.AsyncClient(timeout=effective_timeout) as client:
response = await client.post(url, json=payload)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
if attempt > max_retries:
logger.error(
"tg client: %s — network error после %d попыток: %s", method, attempt, exc
)
raise
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
logger.warning(
"tg client: %s — network error (попытка %d/%d): %s — retry через %.0fs",
method,
attempt,
max_retries,
exc,
backoff,
)
await asyncio.sleep(backoff)
continue
if response.status_code == 429:
retry_after = _extract_retry_after(response)
if attempt > max_retries:
error_code, description = _error_from_body(response)
logger.error("tg client: %s — 429 после %d попыток, сдаёмся", method, attempt)
raise TelegramApiError(method, error_code, description)
logger.warning(
"tg client: %s — HTTP 429 (попытка %d/%d), retry_after=%.0fs",
method,
attempt,
max_retries,
retry_after,
)
await asyncio.sleep(retry_after)
continue
if response.status_code >= 500:
if attempt > max_retries:
error_code, description = _error_from_body(response)
logger.error(
"tg client: %s — HTTP %d после %d попыток, сдаёмся",
method,
response.status_code,
attempt,
)
raise TelegramApiError(method, error_code, description)
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
logger.warning(
"tg client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs",
method,
response.status_code,
attempt,
max_retries,
backoff,
)
await asyncio.sleep(backoff)
continue
if response.status_code >= 400:
# 4xx кроме 429 — запрос некорректен/прав нет, повтор не поможет.
error_code, description = _error_from_body(response)
raise TelegramApiError(method, error_code, description)
try:
data = response.json()
except ValueError as exc:
raise TelegramApiError(
method, response.status_code, f"invalid json: {exc}"
) from exc
if not isinstance(data, dict) or not data.get("ok"):
error_code, description = _error_from_body(response)
raise TelegramApiError(method, error_code, description)
return data.get("result")
async def get_updates(
self,
offset: int,
timeout: int = 30,
allowed_updates: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Long-polling getUpdates. `timeout` — сколько Telegram держит запрос открытым (сек).
HTTP-таймаут запроса берётся с запасом (`timeout + 10s`), чтобы не обрывать
соединение раньше, чем ответит сам Telegram long-poll.
"""
payload: dict[str, Any] = {"offset": offset, "timeout": timeout}
if allowed_updates is not None:
payload["allowed_updates"] = allowed_updates
result = await self._request(
"getUpdates", payload, timeout=float(timeout) + 10.0, max_retries=3
)
return result if isinstance(result, list) else []
async def copy_message(
self,
*,
chat_id: int,
from_chat_id: int,
message_id: int,
message_thread_id: int | None = None,
reply_to_message_id: int | None = None,
) -> dict[str, Any]:
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла."""
payload: dict[str, Any] = {
"chat_id": chat_id,
"from_chat_id": from_chat_id,
"message_id": message_id,
}
if message_thread_id:
payload["message_thread_id"] = message_thread_id
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
result = await self._request("copyMessage", payload)
return result if isinstance(result, dict) else {}
async def send_message(
self,
*,
chat_id: int,
text: str,
message_thread_id: int | None = None,
reply_to_message_id: int | None = None,
timeout: float | None = None,
max_retries: int | None = None,
) -> dict[str, Any]:
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
`timeout`/`max_retries` по умолчанию наследуют воркерную политику
(`_DEFAULT_TIMEOUT_S`/`_DEFAULT_MAX_RETRIES`: на 429 спим Telegram-овский
`retry_after` для группы это штатные 30-60с, на 5xx backoff до 30с).
Это ПРИЕМЛЕМО для `tgbot_main.py` (изолированный long-polling воркер), но
ФАТАЛЬНО для интерактивного HTTP-запроса (#tgsupport-web review H1) —
синхронный request/response путь не может легально висеть минуты. Вызывающая
сторона на interactive-пути ОБЯЗАНА передать узкий бюджет явно (см.
`app.api.v1.support.send_support_message`)."""
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
if message_thread_id:
payload["message_thread_id"] = message_thread_id
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
kwargs: dict[str, Any] = {}
if timeout is not None:
kwargs["timeout"] = timeout
if max_retries is not None:
kwargs["max_retries"] = max_retries
result = await self._request("sendMessage", payload, **kwargs)
return result if isinstance(result, dict) else {}

View file

@ -0,0 +1,216 @@
"""Persistence для веб-чата поддержки (#tgsupport-web) — web_support_threads /
web_support_messages (см. data/sql/187_web_support_chat.sql, комментарий там же
объясняет, почему отдельные таблицы, а не `channel`-колонка в tg_support_*).
Чистые SQL-функции поверх SQLAlchemy `Session` (psycopg v3) никакой
Telegram-логики здесь. Используется ДВУМЯ вызывающими сторонами:
- `app.api.v1.support` (FastAPI-роутер) создание/поиск треда, запись
inbound-сообщения, чтение истории/unread/read.
- `app.services.tgbot.bridge._handle_group_reply` резолвит
topic_message_id реплая оператора в web-тред и пишет outbound-ответ.
Тред резолвится ТОЛЬКО по username (X-Authenticated-User) ни один метод
здесь не принимает thread_id снаружи, поэтому IDOR (чтение чужого треда)
структурно невозможен на уровне API (см. app/api/v1/support.py).
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def find_thread_id(db: Session, username: str) -> int | None:
"""Возвращает id треда для *username*, если он уже существует, иначе None.
Используется read-путями (GET .../messages, .../unread) они НЕ должны
создавать тред просто фактом обращения (иначе каждое открытие виджета
поддержки создавало бы пустой тред для любого пользователя сайта).
"""
row = db.execute(
text("SELECT id FROM web_support_threads WHERE username = CAST(:username AS text)"),
{"username": username},
).fetchone()
return int(row[0]) if row is not None else None
def get_or_create_thread(db: Session, username: str) -> int:
"""Гарантирует существование треда для *username*, обновляя last_seen_at.
Вызывается ТОЛЬКО из send-пути (POST .../messages) отправка сообщения
это единственное действие, которое должно "создавать" тред.
"""
row = db.execute(
text(
"""
INSERT INTO web_support_threads (username, created_at, last_seen_at, last_read_at)
VALUES (CAST(:username AS text), NOW(), NOW(), NOW())
ON CONFLICT (username) DO UPDATE
SET last_seen_at = NOW()
RETURNING id
"""
),
{"username": username},
).fetchone()
assert row is not None # INSERT ... RETURNING всегда отдаёт строку
return int(row[0])
def record_inbound(
db: Session,
*,
thread_id: int,
text_body: str,
topic_message_id: int | None,
support_chat_id: int | None,
) -> dict[str, Any]:
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
`support_chat_id` TELEGRAM_SUPPORT_CHAT_ID В МОМЕНТ отправки (#tgsupport-web
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
support-группе если группу когда-нибудь сменят/пересоздадут, Telegram
message_id стартует заново с 1 в новом чате и может совпасть с числом из
старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188)."""
row = (
db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, created_at)
VALUES
(CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, NOW())
RETURNING id, direction, text_body, operator_tg_id, created_at
"""
),
{
"thread_id": thread_id,
"text_body": text_body,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
},
)
.mappings()
.one()
)
return dict(row)
def find_thread_by_topic_message(
db: Session, topic_message_id: int, support_chat_id: int
) -> int | None:
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186).
Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со
ЧУЖИМ (не NULL и не текущим) support_chat_id это исторический артефакт
ротации support-группы, НЕ валидный маршрут для сегодняшнего реплая. NULL
(легаси-строки до этой колонки, если такие есть) лениентно матчатся как
"любой чат", т.к. до введения этого поля был ровно один действующий чат."""
row = db.execute(
text(
"""
SELECT thread_id
FROM web_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in'
AND (support_chat_id = CAST(:support_chat_id AS bigint) OR support_chat_id IS NULL)
ORDER BY created_at DESC
LIMIT 1
"""
),
{"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
).fetchone()
return int(row[0]) if row is not None else None
def record_outbound(
db: Session, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> int | None:
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
`topic_message_id` всегда NULL маршрутизирующий ключ живёт только на
inbound-записи (см. tg_support_messages-конвенцию, 186)."""
row = db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id, operator_tg_id, created_at)
VALUES
(CAST(:thread_id AS bigint), 'out', :text_body, NULL,
CAST(:operator_tg_id AS bigint), NOW())
RETURNING id
"""
),
{
"thread_id": thread_id,
"text_body": text_body,
"operator_tg_id": operator_tg_id,
},
).fetchone()
return int(row[0]) if row is not None else None
def list_messages(
db: Session, *, thread_id: int, since_id: int, limit: int = 200
) -> list[dict[str, Any]]:
"""Сообщения треда с id > since_id, по возрастанию (обычный polling с фронта).
`limit` (#tgsupport-web review M5): без него КАЖДОЕ монтирование виджета на
старом треде отдавало бы ВЕСЬ лог переписки. Берём последние `limit` (ORDER
BY id DESC + LIMIT), потом разворачиваем в хронологический порядок так
incremental-polling (`since_id` = последний известный id, обычно единицы
новых строк) не страдает, а первый холодный load длинного треда получает
последние `limit`, а не самые старые."""
rows = (
db.execute(
text(
"""
SELECT id, direction, text_body, operator_tg_id, created_at
FROM web_support_messages
WHERE thread_id = CAST(:thread_id AS bigint)
AND id > CAST(:since_id AS bigint)
ORDER BY id DESC
LIMIT CAST(:limit AS integer)
"""
),
{"thread_id": thread_id, "since_id": since_id, "limit": limit},
)
.mappings()
.all()
)
return [dict(r) for r in reversed(rows)]
def count_unread(db: Session, *, thread_id: int) -> int:
"""Кол-во ответов оператора (direction='out'), пришедших после last_read_at."""
row = db.execute(
text(
"""
SELECT count(*)
FROM web_support_messages m
JOIN web_support_threads t ON t.id = m.thread_id
WHERE m.thread_id = CAST(:thread_id AS bigint)
AND m.direction = 'out'
AND m.created_at > t.last_read_at
"""
),
{"thread_id": thread_id},
).scalar()
return int(row or 0)
def mark_read(db: Session, *, thread_id: int) -> None:
db.execute(
text(
"UPDATE web_support_threads SET last_read_at = NOW() "
"WHERE id = CAST(:thread_id AS bigint)"
),
{"thread_id": thread_id},
)

View file

@ -0,0 +1,146 @@
"""Сервис записи в `user_events` — Feature 2 (login/IP audit) + база для Feature 3
(behavior analytics).
`user_events` (migration `184_user_events.sql`) unified append-only event log,
admin-read-only. Запись события НИКОГДА не должна ронять реальный HTTP-запрос:
любая ошибка (БД недоступна, сетевой сбой, схема разъехалась) ловится и логируется
через `logger.warning`, без re-raise вызывающий код (middleware / handler)
продолжает работать так, как если бы аудит был выключен.
"""
from __future__ import annotations
import asyncio
import json
import logging
import threading
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import text
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
def record_event(
*,
event_type: str,
username: str,
ip: str | None = None,
user_agent: str | None = None,
path: str | None = None,
method: str | None = None,
estimate_id: str | None = None,
payload: dict[str, Any] | None = None,
) -> None:
"""Синхронно вставляет одну строку в `user_events`.
Открывает СОБСТВЕННУЮ сессию (`SessionLocal()`), декаплённую от транзакции
вызывающего запроса событие коммитится независимо и переживает rollback
основного хендлера (и наоборот: сбой записи события не трогает основную tx,
т.к. она уже закоммичена/не связана с этой сессией).
Никогда не поднимает исключение вызывающий код (middleware / endpoint)
не должен падать из-за проблем с аудит-логом.
"""
try:
with SessionLocal() as db:
db.execute(
text(
"""
INSERT INTO user_events
(event_type, username, ip_address, user_agent, path, method,
estimate_id, payload)
VALUES
(:event_type, :username, CAST(:ip AS inet), :user_agent, :path,
:method, CAST(:estimate_id AS uuid), CAST(:payload AS jsonb))
"""
),
{
"event_type": event_type,
"username": username,
"ip": ip,
"user_agent": user_agent,
"path": path,
"method": method,
"estimate_id": estimate_id,
"payload": json.dumps(payload or {}, ensure_ascii=False),
},
)
db.commit()
except Exception:
logger.warning(
"user_events: failed to record event_type=%r username=%r path=%r",
event_type,
username,
path,
exc_info=True,
)
def schedule_event(**kwargs: Any) -> None:
"""Fire-and-forget обёртка над `record_event` — никогда не блокирует запрос и
никогда не поднимает исключение наружу.
- Есть running event loop (обычный async FastAPI handler / middleware)
INSERT уводится в отдельный поток через `asyncio.to_thread`, вызывающая
корутина не ждёт результата. Ошибки ловятся done-callback'ом и просто
логируются (иначе asyncio ругался бы "Task exception was never retrieved").
- Нет running loop (sync-контекст: тесты, Celery task, скрипт) вызывает
`record_event(**kwargs)` синхронно inline (она сама никогда не raises).
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
record_event(**kwargs)
return
try:
task = loop.create_task(asyncio.to_thread(record_event, **kwargs))
except Exception:
logger.warning("user_events: failed to schedule background event", exc_info=True)
return
def _log_task_exception(t: asyncio.Task[None]) -> None:
if t.cancelled():
return
exc = t.exception()
if exc is not None:
logger.warning("user_events: background record_event failed: %s", exc)
task.add_done_callback(_log_task_exception)
# ---------------------------------------------------------------------------
# In-process best-effort dedup для login-события.
# ---------------------------------------------------------------------------
_LOGIN_DEDUP_LOCK = threading.Lock()
_LOGIN_DEDUP_SEEN: set[str] = set()
_LOGIN_DEDUP_MAX = 50_000
def should_log_login(username: str, ip: str | None, user_agent: str | None) -> bool:
"""True максимум один раз в сутки на комбинацию (username, ip, user_agent) в
рамках жизни процесса держит объём `event_type='login'` строк на уровне
~одной записи на аккаунт+устройство в день, а не одной на каждый API-запрос.
Best-effort/не персистентно: рестарт процесса (деплой, worker respawn)
сбрасывает in-memory set, так что после рестарта первая комбинация дня
залогируется заново недо-дедуп, не потеря данных (для отчётов по
user_events всё равно используется `DISTINCT (username, ip_address,
user_agent, date_trunc('day', created_at))`, так что дубликаты не искажают
метрики, лишь чуть увеличивают объём записи).
"""
day = datetime.now(UTC).strftime("%Y-%m-%d")
key = f"{username}|{ip or ''}|{user_agent or ''}|{day}"
with _LOGIN_DEDUP_LOCK:
if key in _LOGIN_DEDUP_SEEN:
return False
if len(_LOGIN_DEDUP_SEEN) >= _LOGIN_DEDUP_MAX:
_LOGIN_DEDUP_SEEN.clear()
_LOGIN_DEDUP_SEEN.add(key)
return True

View file

@ -40,6 +40,14 @@ from app.services import scrape_runs as runs_mod
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
_PPM2_MIN: int = 30_000
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
# ratio (0.877→0.62, «выкупная» 29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
@ -80,6 +88,7 @@ _REDERIVE_SQL = text(
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY LEAST(GREATEST(rooms, 0), 4)
@ -128,6 +137,7 @@ _REDERIVE_SQL = text(
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
),
@ -213,7 +223,11 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
db.execute(_DELETE_SQL)
db.execute(
_REDERIVE_SQL,
{"ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max},
{
"ppm2_min": _PPM2_MIN,
"ppm2_max": settings.asking_ratio_ppm2_max,
"asking_city": _ASKING_CITY_PATTERN,
},
)
row = db.execute(_COUNTERS_SQL).mappings().first()

View file

@ -158,6 +158,18 @@ async def backfill_cian_history(
"cian_detail save failed for listing_id=%s: %s", listing_id, exc
)
result.listings_failed_save += 1
# Roll back to clean session state so next listing can proceed.
# save_detail_enrichment commits on success; on failure the
# transaction is left open/dirty — rollback to avoid session poison
# (same class of defect as the houses block below).
try:
db.rollback()
except Exception as rb_exc:
logger.warning(
"cian_detail rollback failed for listing_id=%s: %s",
listing_id,
rb_exc,
)
await asyncio.sleep(delay)

View file

@ -0,0 +1,124 @@
"""CLI: ДОМ.РФ капремонт open data → houses.year_built/material_walls (issue #2013).
Тянет КР1.1 (house registry) + КР1.2 (constructive elements) с открытых данных
ДОМ.РФ (region 66), UPSERT'ит в staging `domrf_kapremont` (мигр. 176), затем:
1. backfill_houses_from_domrf COALESCE(houses.year_built, domrf.commission_year,
houses.zhkh_year) + material_walls + total_floors (fold-in уже загруженного
ГИС-ЖКХ года houses без domrf-матча тоже выигрывают).
2. propagate_listings_year_from_houses UPDATE listings.year_built = houses.year_built
там, где listing ещё NULL. Именно это открывает estimator'у (year_built BETWEEN
когорта-фильтр) реальные годы на источниках вроде avito (0% year_built, 52.5%
linked к houses).
Запуск из контейнера tradein-backend (у прода есть интернет к домрф.рф):
python -m app.tasks.domrf_kapremont_load # полный прогон
python -m app.tasks.domrf_kapremont_load --dry-run # без записи, только подсчёт
python -m app.tasks.domrf_kapremont_load --load-only # только staging, без backfill
python -m app.tasks.domrf_kapremont_load --backfill-only # staging уже загружена — только
# backfill+propagate
python -m app.tasks.domrf_kapremont_load \\
--kr11-path /tmp/kr11.csv --kr12-path /tmp/kr12.csv # локальные CSV, без скачивания
В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи
в БД не делается.
"""
from __future__ import annotations
import argparse
import logging
from app.core.db import SessionLocal
from app.services.domrf_kapremont_loader import (
backfill_houses_from_domrf,
load_domrf_kapremont,
propagate_listings_year_from_houses,
)
logger = logging.getLogger(__name__)
def build_parser() -> argparse.ArgumentParser:
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
parser = argparse.ArgumentParser(
description=(
"ДОМ.РФ капремонт loader: КР1.1+КР1.2 → domrf_kapremont staging → "
"houses.year_built/material_walls/total_floors → listings.year_built"
)
)
parser.add_argument(
"--kr11-path", default=None, help="локальный CSV КР1.1 (пропустить скачивание)"
)
parser.add_argument(
"--kr12-path", default=None, help="локальный CSV КР1.2 (пропустить скачивание)"
)
parser.add_argument(
"--work-dir",
default=None,
help="каталог для скачанных/распакованных CSV (по умолчанию — временный, удаляется после)",
)
parser.add_argument(
"--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт"
)
parser.add_argument(
"--load-only",
action="store_true",
help="только скачать+распарсить+UPSERT staging, без backfill houses/propagate listings",
)
parser.add_argument(
"--backfill-only",
action="store_true",
help="пропустить скачивание/load staging (считаем что уже загружена этим же "
"или предыдущим прогоном) — только backfill houses + propagate listings",
)
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = build_parser()
args = parser.parse_args()
if args.load_only and args.backfill_only:
parser.error("--load-only и --backfill-only взаимоисключающи")
db = SessionLocal()
try:
load_counts: dict[str, int] = {}
if not args.backfill_only:
load_counts = load_domrf_kapremont(
db,
kr11_path=args.kr11_path,
kr12_path=args.kr12_path,
work_dir=args.work_dir,
dry_run=args.dry_run,
)
if not args.dry_run:
db.commit()
logger.info("domrf_kapremont_load: load stage done: %s", load_counts)
houses_counts: dict[str, int] = {}
listings_counts: dict[str, int] = {}
if not args.load_only:
houses_counts = backfill_houses_from_domrf(db, dry_run=args.dry_run)
listings_counts = propagate_listings_year_from_houses(db, dry_run=args.dry_run)
if not args.dry_run:
db.commit()
logger.info(
"domrf_kapremont_load DONE: dry_run=%s load=%s houses=%s listings=%s",
args.dry_run,
load_counts,
houses_counts,
listings_counts,
)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -1,4 +1,4 @@
"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-coef).
"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-index).
Populates `osm_poi_ekb_local` (empty at deploy, migration 169) via a single bulk scan of the
`gendesign_osm_poi_ekb` FDW foreign table (migration 168 live view of gendesign's
@ -8,9 +8,11 @@ of #2045, already merged + deployed on gendesign).
WHY a local mirror (perf fact measured for the analogous gendesign_cad_buildings FDW see
`app/tasks/cadastral_geo_match.py`): a per-request FDW nearest-POI query pays a per-row FDW
round-trip (~1.16s/row without a geom index on the remote table) UNUSABLE for a synchronous
endpoint (`GET /api/v1/trade-in/location-coef`). We materialize the FDW once (single bulk
endpoint (`GET /api/v1/trade-in/location-index`). We materialize the FDW once (single bulk
scan) into `osm_poi_ekb_local` with a real Point geom + GIST index, then
`app/services/location_coef.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate.
`app/services/location_index.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate
(nearby-POI qualitative list only the numeric index itself comes from `listings`, not POI;
see that module's docstring for the location-coef → location-index rewrite history).
Scheduler source='osm_poi_ekb_refresh' (daily OSM POI data changes rarely). Pure internal
DB op one FDW read + local TRUNCATE+INSERT, no HTTP/anti-bot.

View file

@ -0,0 +1,196 @@
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — 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'е, тонущем в логах оценок.
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага
публикации так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
Порог алерта (документирование выбора):
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 лейбл
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог:
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» алерта нет
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев
алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 alert=1.
Задача синхронная (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 тестируется без БД).
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) WARNING
достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего
оценивать).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
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
logger = logging.getLogger(__name__)
__all__ = [
"DEFAULT_LAG_ALLOWANCE_DAYS",
"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
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
# sber_price_index для областного уровня.
SBER_MONITOR_CITY = "Свердловская область"
_LATEST_SBER_PERIOD_SQL = text("""
SELECT max(period_month) AS latest
FROM sber_price_index
WHERE city = CAST(:city AS text)
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
-- estimator._load_sber_index_series.
AND (segment IS NULL OR segment ILIKE '%вторичн%')
""")
@dataclass(frozen=True)
class SberFreshnessVerdict:
"""Вердикт свежести СберИндекса по max(period_month)."""
latest_period: date
age_days: int
stale: bool
def evaluate_sber_freshness(
latest_period: date,
now: datetime,
max_age_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` без БД.
"""
age_days = (now.date() - latest_period).days
stale = age_days > max_age_days
return SberFreshnessVerdict(
latest_period=latest_period,
age_days=age_days,
stale=stale,
)
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.
Params (default_params jsonb):
lag_allowance_days: int запас сверх sber_index_max_age_days (default 25).
`now` инъектируется в тестах (frozen); в проде None datetime.now(UTC).
Returns counters {latest_year, latest_month, age_days, alert}.
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
при алерте прогон помечается done (это монитор, не сбой джобы).
"""
params = params or {}
now = now or datetime.now(UTC)
counters: dict[str, int] = {
"latest_year": 0,
"latest_month": 0,
"age_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:
logger.warning(
"sber freshness: sber_price_index пуст/недоступен для region=%s "
"(вторичка) — оценить свежесть нельзя",
SBER_MONITOR_CITY,
)
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)
counters = {
"latest_year": latest.year,
"latest_month": latest.month,
"age_days": verdict.age_days,
"alert": int(verdict.stale),
}
if verdict.stale:
logger.warning(
"sber freshness: max(period_month)=%s устарел на %d дней "
"(> порога %d = sber_index_max_age_days %d + lag %d); "
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
"проверь sber_index_pull и доступность новых периодов источника",
latest,
verdict.age_days,
max_age_days,
settings.sber_index_max_age_days,
lag_days,
)
else:
logger.info(
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
"region=%s — алерта нет",
latest,
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",
run_id,
latest,
counters["alert"],
counters["age_days"],
)
return counters
except Exception as exc:
logger.exception("check_sber_freshness run_id=%d failed", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise

View file

@ -0,0 +1,180 @@
"""Standalone entrypoint для Telegram support-bridge воркера (#tgsupport).
Зачем отдельный процесс/контейнер: `getUpdates` long-polling держит открытый
HTTP-запрос к Telegram до 30с за раз в бесконечном цикле деплой основного API
(docker restart tradein-backend) не должен обрывать эту петлю на середине, как и
API не должен блокироваться долгим poll'ом. Тот же паттерн, что и
`scheduler_main.py` (#1182) для scraper'ов — отдельный контейнер с тем же образом,
другая команда.
Запуск: python -m app.tgbot_main
Kill-switch: TELEGRAM_BOT_TOKEN пуст (дефолт) воркер логирует «disabled» и
блокируется на `wait_for_shutdown()` (idle, ~0 CPU) НЕ `sys.exit(0)`. Сервис в
compose поднят с `restart: unless-stopped`, который рестартует контейнер
независимо от кода выхода чистый exit(0) без токена дал бы бесконечный
рестарт-луп. Idle-блокировка держит процесс живым (автозапуск после ребута VPS
работает штатно через restart-policy) без CPU-луп и без спама рестартов;
SIGTERM просто убивает процесс восстанавливать здесь нечего (bridge-задача
не запущена).
"""
from __future__ import annotations
import asyncio
import logging
import os
import signal
from contextlib import suppress
from typing import Any
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
from app.services.tgbot.bridge import run_poll_loop
from app.services.tgbot.client import TelegramClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
# httpx INFO-логи печатают ПОЛНЫЙ request URL, включая Telegram Bot API токен
# в пути (https://api.telegram.org/bot<id>:<secret>/...) — `httpx: HTTP Request:
# POST https://api.telegram.org/bot<TOKEN>/getMe "HTTP/1.1 401 Unauthorized"`.
# При бесконечном long-polling'e это боевой токен в `docker logs` каждые ~30с.
# WARNING+ у httpx не логирует URL запроса (#tgsupport review, воспроизведено).
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# Тот же safety-net паттерн, что scheduler_main.py — ниже docker stop_grace_period.
_DRAIN_TIMEOUT_S = 100.0
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396). Только integrations
# без Starlette/FastAPI — здесь нет ASGI-приложения (тот же выбор что scheduler_main).
if settings.glitchtip_dsn:
import sentry_sdk
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from app.observability.sentry_scrub import redact_telegram_bot_token, scrub_pii_event
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
"""Композиция PII-scrub (form-данные) + Telegram bot-токен redaction
(#tgsupport review). Токен утекает ДВУМЯ независимыми векторами, которые
`include_local_variables=False` ниже и этот хук закрывают вместе:
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
locals (`self._base`/`url` в `TelegramClient._request`) в traceback
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
2. `HttpxIntegration` кладёт полный request URL в span `data` (не только
traceback) `traces_sample_rate=0.0` спасает СЕЙЧАС, но молча
перестанет спасать, если трейсинг когда-нибудь включат. Regex-редактор
belt-and-suspenders на случай #1 (если include_local_variables
случайно вернут) И на span data.
"""
scrubbed = scrub_pii_event(event, hint)
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint)
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0,
send_default_pii=False,
include_local_variables=False,
before_send=_before_send,
integrations=[
HttpxIntegration(),
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
logger.info("GlitchTip monitoring enabled (tgbot_main)")
def _should_run() -> bool:
"""Kill-switch: TELEGRAM_BOT_TOKEN не задан → бот выключен (dev/staging без секрета)."""
return bool(settings.telegram_bot_token)
async def _run_bridge() -> None:
client = TelegramClient(settings.telegram_bot_token)
await run_poll_loop(client, SessionLocal)
async def _await_bridge(task: asyncio.Task[None]) -> None:
"""Кооперативный SIGTERM-drain — идентичная семантика scheduler_main._await_scheduler.
`run_poll_loop` сам проверяет `shutdown_requested()` между итерациями (между
getUpdates-вызовами) long-polling запрос к Telegram (до 30с) докручивается,
затем цикл выходит сам. Safety-net здесь на случай зависшего HTTP-вызова.
"""
shutdown_waiter = asyncio.create_task(wait_for_shutdown())
try:
await asyncio.wait({task, shutdown_waiter}, return_when=asyncio.FIRST_COMPLETED)
finally:
shutdown_waiter.cancel()
with suppress(asyncio.CancelledError):
await shutdown_waiter
if task.done():
task.result()
logger.info("tgbot_main: bridge task exited cleanly")
return
logger.info(
"tgbot_main: SIGTERM-drain — waiting up to %.0fs for current poll iteration to finish",
_DRAIN_TIMEOUT_S,
)
try:
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
logger.info("tgbot_main: bridge drained and exited cleanly")
except TimeoutError:
logger.warning(
"tgbot_main: drain exceeded %.0fs grace — hard-cancelling bridge task",
_DRAIN_TIMEOUT_S,
)
task.cancel()
with suppress(asyncio.CancelledError):
await task
async def _run() -> None:
task = asyncio.create_task(_run_bridge())
loop = asyncio.get_running_loop()
def _on_signal(signum: int) -> None:
logger.info("tgbot_main: signal %d received — requesting cooperative drain", signum)
request_shutdown()
try:
loop.add_signal_handler(signal.SIGTERM, lambda: _on_signal(signal.SIGTERM))
loop.add_signal_handler(signal.SIGINT, lambda: _on_signal(signal.SIGINT))
except NotImplementedError:
# Windows dev: signal handlers через loop не поддерживаются
logger.warning("tgbot_main: loop.add_signal_handler not supported (Windows dev)")
await _await_bridge(task)
if shutdown_requested():
logger.info("tgbot_main: bridge drained cleanly (SIGTERM)")
else:
logger.info("tgbot_main: bridge task exited")
if __name__ == "__main__":
if not _should_run():
# NOT sys.exit(0): compose service has `restart: unless-stopped`, который
# рестартует контейнер независимо от кода выхода — чистый exit(0) без
# токена дал бы бесконечный рестарт-луп. Idle-блокировка вместо этого:
# ~0 CPU, SIGTERM просто убивает процесс (нечего дренировать).
logger.warning(
"tgbot_main: TELEGRAM_BOT_TOKEN не задан — бот выключен, "
"блокируемся на idle (не exit, чтобы не было рестарт-лупа с restart:unless-stopped)"
)
asyncio.run(wait_for_shutdown())
else:
asyncio.run(_run())

View file

@ -22,6 +22,19 @@
--
-- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling.
-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert.
--
-- LOW audit #4 — EKB scope (doc-only, no behavior change): this seed and its
-- `premium_houses` MV fallback (data/sql/139) are both curated/calibrated against
-- Ekaterinburg secondary-market listings ONLY. Non-EKB descriptions encountered
-- during curation were treated as noise and actively DEMOTED (is_premium=false) —
-- e.g. house_id=372944 below ("г. Нижний Новгород, Александровская слобода... это
-- НЕ ЕКБ" — mislabeled, not premium). Consequence: an oblast (non-EKB) elite
-- building will NOT receive the `premium_building` flag from this table or its MV
-- fallback — the estimator's `_is_premium_building` (app/services/estimator.py)
-- has no per-city variant of this overlay. This is not a silent gap: the estimator's
-- `_manual_review` still routes genuinely high-value objects to manual review via
-- absolute-value thresholds (estimate_manual_review_high_value_rub,
-- estimate_manual_review_elite_ppm2) independent of the premium_building flag.
BEGIN;

View file

@ -0,0 +1,67 @@
-- 176_domrf_kapremont.sql
--
-- CONTEXT: issue #2013 — houses.year_built заполнен только на 38%, houses.material_walls
-- на 0%. Существующий ГИС-ЖКХ loader (app/tasks/zhkh_flats_load.py, мигр. 146/149)
-- заполняет houses.zhkh_year (70% = 5002/7128) и houses.zhkh_floors (70%), НО НИКОГДА
-- не копирует их в houses.year_built/total_floors — это основной пробел. material_walls
-- вообще ни разу не заполнялся никаким источником (0%). Estimator
-- (app/services/estimator.py) фильтрует когорту по `year_built BETWEEN ...` — реальные
-- годы напрямую двигают точность оценки. Это REAL fix для #2013 (взамен запаркованного
-- когорт-исключения PR #2474).
--
-- Источник: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
-- КР1.1 house registry (export/190) — zip → CSV. mkd_code, houseguid (ФИАС GUID дома),
-- address, commission_year (год ввода, int), total_sq, number_floors_max.
-- КР1.2 constructive elements (export/275) — zip → CSV, LONG FORMAT (строка на
-- конструктивный элемент на mkd_code); wall_material заполнен только на строке
-- элемента «фасад».
-- listings→houses линковка через listings.house_id_fk (avito 52.5% linked, year_built
-- 0% на avito → backfill поднимет до ~36-52% через houses.year_built propagation).
--
-- WHAT:
-- Таблица domrf_kapremont — staging-приёмник КР1.1+КР1.2 (region 66), ключ mkd_code.
-- Заполняется отдельным loader'ом (app/services/domrf_kapremont_loader.py,
-- app/tasks/domrf_kapremont_load.py). Backfill houses.year_built/material_walls/
-- total_floors и propagation listings.year_built — отдельный код-путь той же таски,
-- НЕ в этой миграции (только схема здесь, никакого DML над houses/listings).
--
-- DEPENDENCIES (existing prod objects): houses (gar_house_guid, house_fias_id,
-- zhkh_house_guid, zhkh_year, zhkh_floors, year_built, material_walls, total_floors —
-- все уже существуют: мигр. 009/010/070/146/149/152), listings (house_id_fk, year_built —
-- мигр. 002/130).
--
-- SAFETY / IDEMPOTENCY: только CREATE TABLE/INDEX IF NOT EXISTS + COMMENT. Повторный
-- прогон — no-op. Деплой-раннер гонит файл через psql -v ON_ERROR_STOP=on БЕЗ
-- --single-transaction → транзакцию открывает САМ файл (BEGIN/COMMIT ниже), как 143/149.
--
-- NB по нумерации: последний занятый = 175 (ls data/sql | grep '^17[0-9]'); следующий
-- свободный sequential = 176.
--
-- Deploy order: после 175_scrape_schedules_seed_domclick_detail_backfill.sql.
BEGIN;
CREATE TABLE IF NOT EXISTS domrf_kapremont (
mkd_code text PRIMARY KEY, -- ДОМ.РФ код МКД (КР1.1/КР1.2 join-ключ)
houseguid text, -- ФИАС GUID дома (КР1.1 houseguid)
address text,
commission_year smallint, -- год ввода в эксплуатацию (КР1.1 commission_year)
number_floors_max int, -- этажность (КР1.1 number_floors_max)
total_sq numeric(10, 2), -- общая площадь дома, м² (КР1.1 total_sq)
wall_material text, -- материал стен (КР1.2, строка «фасад»; wall_material)
loaded_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS domrf_kapremont_houseguid_idx
ON domrf_kapremont (houseguid)
WHERE houseguid IS NOT NULL;
COMMENT ON TABLE domrf_kapremont IS
'ДОМ.РФ капремонт open data (КР1.1 house registry export/190 + КР1.2 constructive '
'elements export/275), region 66 (Свердловская обл.). Ключ mkd_code. houseguid — ФИАС '
'GUID дома, матчится к houses через COALESCE(gar_house_guid, house_fias_id, '
'zhkh_house_guid) (app/services/domrf_kapremont_loader.py). wall_material берётся из '
'КР1.2 со строки конструктивного элемента «фасад» (единственная строка на mkd_code, '
'где это поле непустое). Заполняется app/tasks/domrf_kapremont_load.py (issue #2013).';
COMMIT;

View file

@ -0,0 +1,51 @@
-- 177_deals_city_region.sql
-- deals.city + region_code backfill — расширение Mera trade-in estimator с
-- Екатеринбурга на ВСЮ Свердловскую область (region_code=66, все города).
--
-- WHY:
-- Ночной import_rosreestr_dkp (scheduler.py, source='rosreestr_dkp_import')
-- до сих пор фильтровал `city ILIKE '%катеринбург%'` и хардкодил
-- 'Екатеринбург, ' в address, а deals.region_code никогда не заполнялся
-- (NULL на всех 49791 текущих строках). Foreign table
-- gendesign_rosreestr_deals уже отдаёт чистые city + region_code из
-- source-таблицы (migration 072) — снятие фильтра открывает +47183
-- не-ЕКБ сделок, уже сидящих в источнике.
--
-- WHAT:
-- - deals.region_code уже существует (002_core_tables.sql), но deals.city
-- отсутствовал (было только текстовое поле address с хардкод-префиксом
-- 'Екатеринбург, '). Добавляем city text — импортёр (scheduler.py,
-- тот же PR) теперь заполняет его из реального source.city.
-- - Индекс (city, deal_date) — под будущие per-city аналитические запросы
-- (аналог deals_source_idx для (source, deal_date)).
-- - Backfill существующих 49791 строк: все текущие deals с source='rosreestr'
-- импортированы ДО этой миграции старым EKB-only кодом, т.е. фактически
-- все они — Екатеринбург (то же допущение, что зашито в address
-- 'Екатеринбург, ' + street). Простановка region_code=66, city='Екатеринбург'
-- задним числом делает старые строки self-describing наравне с новыми.
--
-- SCOPE: только schema (city column + index) + backfill старых rosreestr-строк.
-- Новые импорты (после деплоя scheduler.py) заполняют оба поля сами.
--
-- IDEMPOTENCY / SAFETY:
-- - ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS — безопасный re-run.
-- - Backfill UPDATE ограничен `region_code IS NULL OR city IS NULL` —
-- повторный прогон no-op (после первого раза условие уже false).
--
-- Dependencies: 002_core_tables.sql (deals, deals.region_code)
BEGIN;
ALTER TABLE deals ADD COLUMN IF NOT EXISTS city text;
CREATE INDEX IF NOT EXISTS deals_city_deal_date_idx ON deals (city, deal_date);
-- Backfill: все существующие rosreestr-сделки импортированы старым EKB-only
-- кодом (до этой миграции) => гарантированно Екатеринбург, region_code=66.
UPDATE deals
SET region_code = 66,
city = 'Екатеринбург'
WHERE source = 'rosreestr'
AND (region_code IS NULL OR city IS NULL);
COMMIT;

View file

@ -0,0 +1,87 @@
-- 178_deal_city_price_bands.sql
-- Per-city ppm² plausible-deal guard-bands — снятие Екатеринбург-центричного
-- глобального DEAL_MIN_PPM2=50_000 (estimator.py) для остальной Свердловской обл.
--
-- WHY:
-- Миграция 177 открыла +47183 не-ЕКБ сделок (deals.city заполнен для всей
-- области). Но estimator.py всё ещё режет ДКП-сделки абсолютным guard-band
-- [DEAL_MIN_PPM2=50_000, DEAL_MAX_PPM2=800_000] ₽/м² — калиброванным
-- ИСКЛЮЧИТЕЛЬНО под ЕКБ-вторичку (см. комментарий у DEAL_MIN_PPM2 в
-- estimator.py). Для дешёвых городов области этот пол — не anti-outlier
-- guard, а cut-off легитимного рынка:
-- Североуральск median ≈ 21.7k ₽/м², Новоуральск ≈ 36k, Асбест ≈ 41k —
-- ВСЕ ниже 50k → 46.6% не-ЕКБ сделок молча дропаются как «доля/ошибка»,
-- хотя это нормальная цена для города.
--
-- WHAT:
-- Таблица deal_city_price_bands — per-city [ppm2_min, ppm2_max] band,
-- вычисленный из РЕАЛЬНЫХ rosreestr-сделок города (p1/p99 персентиль,
-- зажатый абсолютными hard-floor/ceiling 8000/800000 — доли и опечатки
-- режутся даже в самых дешёвых городах). Екатеринбург НАМЕРЕННО исключён
-- из популяции — estimator.py при отсутствии строки в этой таблице
-- fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 константы, т.е.
-- ЕКБ-поведение byte-identical сегодняшнему (см. invariant в PR).
--
-- Города с < 30 сделок исключены (HAVING count(*) >= 30) — слишком тонкая
-- выборка для устойчивого перцентиля, estimator fallback'ит на global band.
--
-- USAGE (estimator.py, тот же PR):
-- - SQL enforcement (_fetch_dkp_corridor, ~line 1263): LEFT JOIN на
-- deals.city = deal_city_price_bands.city, COALESCE(b.ppm2_min/max, global).
-- - Python enforcement (_is_plausible_deal via _fetch_deals): bands dict,
-- загружен один раз через _load_city_price_bands(db), .get(city, global).
--
-- IDEMPOTENCY / SAFETY:
-- CREATE TABLE IF NOT EXISTS — безопасный re-run. Populate — INSERT ...
-- ON CONFLICT (city) DO UPDATE, т.е. повторный прогон РЕФРЕШИТ бэнды под
-- свежие сделки (не no-op по дизайну — таблица предполагает периодический
-- refresh по мере роста deals; scheduler для авто-рефреша НЕ входит в этот
-- PR, см. follow-up в PR description).
--
-- Dependencies: 177_deals_city_region.sql (deals.city, backfill).
BEGIN;
CREATE TABLE IF NOT EXISTS deal_city_price_bands (
city text PRIMARY KEY,
ppm2_min int NOT NULL,
ppm2_max int NOT NULL,
n_deals int NOT NULL,
refreshed_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE deal_city_price_bands IS
'Per-city ppm² plausible-deal guard-bands (estimator.py _is_plausible_deal / '
'_fetch_dkp_corridor). Екатеринбург НЕ включён — estimator fallback на глобальные '
'DEAL_MIN_PPM2=50000/DEAL_MAX_PPM2=800000 сохраняет ЕКБ-поведение byte-identical. '
'Refresh: повторный прогон этого файла (ON CONFLICT DO UPDATE); авто-scheduler '
'пока не реализован (см. issue follow-up).';
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, refreshed_at)
SELECT
city,
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
AS ppm2_min,
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
AS ppm2_max,
count(*) AS n_deals,
now() AS refreshed_at
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
GROUP BY city
HAVING count(*) >= 30
-- Инвариант ppm2_max >= ppm2_min: если весь распределение города < 8000 ₽/м²
-- (p99 < 8000), LEAST(p99,800000) < GREATEST(p1,8000) → band [8000, <8000] не
-- матчит ничего → все сделки города молча дропались бы. Гейтим такие города
-- (для жилой ДКП недостижимо, но airtight против мусорного города).
AND round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int >= 8000
ON CONFLICT (city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
refreshed_at = EXCLUDED.refreshed_at;
COMMIT;

View file

@ -0,0 +1,199 @@
-- 179_scrape_schedules_seed_oblast_city_sweeps.sql
-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — B1 rollout:
-- avito/cian/yandex city-sweep за пределами Екатеринбурга для 5 городов области.
-- Domclick (BFF, city_id-based) — отдельный B2 rollout, сюда НЕ входит.
--
-- !!! DORMANT BY DESIGN !!! Все 15 строк ship enabled = false.
-- Причина: в проксях-пуле сейчас только ОДИН здоровый прокси (см. scrape_proxies,
-- 157/173) — параллельный/резкий rollout на 5 новых городов рискует забанить его.
-- Capability полностью wired (pipeline.CITY_ANCHORS/get_city_anchors,
-- scheduler._job_{avito,cian,yandex}_city_sweep читают default_params->>'city' и
-- резолвят anchors города, wildcard-registry "*_city_sweep_*" в
-- scraper_kit.orchestration.scheduler._default_kit_handlers), но schedule'ы
-- намеренно ВЫКЛЮЧЕНЫ. Оператор включает ВРУЧНУЮ по одному городу за раз:
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'avito_city_sweep_nizhniy_tagil';
--
-- WHY отдельный source на город (а не один source + params.city со списком городов):
-- scrape_schedules.source — UNIQUE (052_scrape_schedules.sql). Несколько строк с
-- одним source='avito_city_sweep' невозможны (второй INSERT/UPDATE тем же source
-- либо конфликтует, либо перезаписывает первую строку — по факту только один город
-- был бы шедулирован). Поэтому каждый (source_type, город) — своя строка с source =
-- '<source_type>_<city_slug>' (например 'cian_city_sweep_kamensk_uralskiy'), а
-- `resolve_handler` в scraper_kit.orchestration.scheduler резолвит её на тот же
-- handler, что и EKB source, через wildcard-запись "avito_city_sweep_*" (и т.п.,
-- тот же механизм, что уже используется для "deactivate_stale_*"). Job читает
-- default_params->>'city' и подставляет anchors города вместо EKB_ANCHORS.
--
-- default_params: source's текущие прод-дефолты (см. SELECT ниже) + {"city": "<slug>"}.
-- Никакие другие поля (radius_m/pages_per_anchor/...) НЕ меняются в этой миграции —
-- один anchor на город (pipeline.CITY_ANCHORS) может не покрывать город целиком на
-- текущем radius_m (1500м у avito/cian) — ОПЕРАТОР должен пересмотреть radius_m перед
-- enable (город крупнее одного 1500м-круга — напр. Н.Тагил/Каменск-Уральский).
--
-- window_start_hour/window_end_hour (UTC, 1-часовые окна) — расставлены НЕ пересекаясь
-- ни друг с другом, ни с уже enabled EKB-окнами (avito 6-7, cian 2-5, yandex 16-17) —
-- на случай если оператор включит несколько строк сразу (proxy-pool = 1, конкурентные
-- sweep'ы разных источников делили бы тот же прокси).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален
-- по построению (SELECT ниже подтверждает текущие прод-дефолты трёх EKB source'ов,
-- НЕ вставляется повторно и не конфликтует с ними — city_sweep_<slug> != city_sweep).
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
-- ── avito_city_sweep_<city> (база: radius_m 1500, detail_top_n 20, enrich_houses true,
-- pages_per_anchor 3, request_delay_sec 7 — прод-дефолты avito_city_sweep) ──────────
(
'avito_city_sweep_nizhniy_tagil',
false,
0,
1,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "nizhniy_tagil"}'::jsonb
),
(
'avito_city_sweep_kamensk_uralskiy',
false,
1,
2,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "kamensk_uralskiy"}'::jsonb
),
(
'avito_city_sweep_pervouralsk',
false,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "pervouralsk"}'::jsonb
),
(
'avito_city_sweep_verkhnyaya_pyshma',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "verkhnyaya_pyshma"}'::jsonb
),
(
'avito_city_sweep_serov',
false,
8,
9,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "serov"}'::jsonb
),
-- ── cian_city_sweep_<city> (база: radius_m 1500, detail_top_n 10, enrich_houses true,
-- pages_per_anchor 3, request_delay_sec 5 — прод-дефолты cian_city_sweep) ─────────
(
'cian_city_sweep_nizhniy_tagil',
false,
9,
10,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "nizhniy_tagil"}'::jsonb
),
(
'cian_city_sweep_kamensk_uralskiy',
false,
10,
11,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "kamensk_uralskiy"}'::jsonb
),
(
'cian_city_sweep_pervouralsk',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "pervouralsk"}'::jsonb
),
(
'cian_city_sweep_verkhnyaya_pyshma',
false,
12,
13,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "verkhnyaya_pyshma"}'::jsonb
),
(
'cian_city_sweep_serov',
false,
13,
14,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "serov"}'::jsonb
),
-- ── yandex_city_sweep_<city> (база: radius_m 25000, pages_per_anchor 3,
-- request_delay_sec 9 — прод-дефолты yandex_city_sweep; radius_m большой ok —
-- per 119_yandex_city_sweep_center_combos.sql это лишь fallback-фильтр, не
-- query-ограничение, лишний охват не увеличивает load) ────────────────────────
(
'yandex_city_sweep_nizhniy_tagil',
false,
14,
15,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "nizhniy_tagil"}'::jsonb
),
(
'yandex_city_sweep_kamensk_uralskiy',
false,
15,
16,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "kamensk_uralskiy"}'::jsonb
),
(
'yandex_city_sweep_pervouralsk',
false,
17,
18,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "pervouralsk"}'::jsonb
),
(
'yandex_city_sweep_verkhnyaya_pyshma',
false,
18,
19,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "verkhnyaya_pyshma"}'::jsonb
),
(
'yandex_city_sweep_serov',
false,
19,
20,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "serov"}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (заменяет cron-script setup). '
'Sources: avito_city_sweep, yandex_city_sweep, cian_city_sweep (EKB, enabled), '
'avito_full_load, avito_full_load_exhaustive, cian_full_load, domclick_city_sweep, '
'avito_city_sweep_<city>/cian_city_sweep_<city>/yandex_city_sweep_<city> '
'(oblast B1 rollout — nizhniy_tagil/kamensk_uralskiy/pervouralsk/'
'verkhnyaya_pyshma/serov, dormant, #179 — resolve_handler wildcard "*_city_sweep_*"), '
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot, '
'asking_to_sold_ratio_refresh, refresh_search_matview, yandex_address_backfill, '
'sber_index_pull, rosreestr_quarter_poll, deals_freshness_monitor, newbuilding_enrich, '
'yandex_newbuilding_sweep, geoportal_coords_backfill, geocode_missing_listings, '
'avito_detail_backfill, yandex_detail_backfill, cadastral_geo_match, '
'osm_poi_ekb_refresh, house_imv_backfill, house_dedup_merge, proxy_healthcheck, '
'deactivate_stale_avito/yandex/cian.';
COMMIT;

View file

@ -0,0 +1,75 @@
-- 180_seed_sber_freshness_monitor.sql
-- Audit п.1 — seed scrape_schedules row for the daily СберИндекс data-freshness monitor.
--
-- Context (verified on prod 2026-07-12):
-- estimator._load_sber_index_series (#794/#audit-5a) applies a СберИндекс time-adjustment
-- to ДКП deals and only LOGS a per-estimate warning when the latest series month is older
-- than settings.sber_index_max_age_days (35d). The `sber_index_pull` job runs monthly
-- (enabled), but СберИндекс publishes with an inherent ~1-2 month lag, so
-- sber_price_index.period_month drifts. Current max(period_month) for the oblast secondary
-- segment = 2026-05-01 (~72d old). NOT a silent failure, but the staleness is buried in
-- per-estimate warnings — invisible at monitor cadence.
--
-- Audit gap: ops has no monitor-frequency signal for СберИндекс staleness. This monitor
-- watches max(period_month) directly and raises a per-day WARNING when the data is stale
-- beyond the allowed publication lag.
--
-- Staleness threshold (app/tasks/sber_freshness_monitor.py::evaluate_sber_freshness):
-- age_days = now() - max(period_month)
-- max_age_days = settings.sber_index_max_age_days (35) + lag_allowance_days (25) = 60
-- stale = age_days > max_age_days
-- The +25 lag_allowance covers the INHERENT СберИндекс publication lag (source is 1-2mo
-- behind; period_month is the first-of-month label; the monthly pull has not yet caught the
-- newest period). Below 60d the latest period is treated as normally-lagging -> NO alert
-- (avoids daily noise on штатный lag). Above 60d the data is stuck beyond ~2 months -> alert.
-- At 2026-07-12: max=2026-05-01, age=72d > 60 -> alert=1 (surfaces the current drift).
--
-- Components deployed together:
-- 1. app/tasks/sber_freshness_monitor.py — check_sber_freshness() + evaluate_sber_freshness()
-- 2. app/services/product_handlers.py — kit Handler 'sber_freshness_monitor'
-- 3. This migration — seeds the scrape_schedules row (enabled=true, daily window)
--
-- Schedule window 09:00-10:00 UTC:
-- Daily cadence, placed AFTER deals_freshness_monitor (08:00-09:00 UTC) so the two
-- data-freshness monitors do not overlap. Cheap: one SELECT max(period_month), no external
-- HTTP — safe to run daily even though sber_index_pull itself is monthly.
--
-- default_params.lag_allowance_days = 25:
-- Allowance for СберИндекс publication lag on top of the 35d per-estimate guard (-> 60d).
--
-- next_run_at bootstrapped to tomorrow 09:00 UTC — scheduler will not fire immediately
-- on deploy (same pattern as 162/093/096/160).
--
-- Idempotent: ON CONFLICT (source) DO NOTHING — safe to re-apply.
--
-- Dependencies:
-- 052_scrape_schedules.sql (table + UNIQUE(source))
-- sber_price_index table (populated by app.tasks.sber_index_pull)
-- app/services/product_handlers.py + tasks/sber_freshness_monitor.py deployed
--
-- Deploy order:
-- Apply after deploying the product_handlers.py + task changes so the dispatch can resolve
-- 'sber_freshness_monitor' correctly on first fire.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'sber_freshness_monitor',
true, -- SAFE: single SELECT max(period_month), no ext calls
9, -- window 09:00-10:00 UTC (after deals_freshness_monitor)
10,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
'{"lag_allowance_days": 25}'::jsonb -- publication-lag allowance over the 35d per-estimate guard
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,16 @@
-- 181_clamp_bad_listing_dates.sql
-- LOW audit R2 (#6): null out implausible listing_date/publish_date left by scrapers
-- before the clamp guard landed (Avito 1970-epoch: ~698 rows; near-future: ~79 rows).
-- Idempotent: re-running is a no-op once values are already NULL / in-window.
BEGIN;
UPDATE listings SET listing_date = NULL
WHERE listing_date IS NOT NULL
AND (listing_date < DATE '2010-01-01' OR listing_date > CURRENT_DATE + 2);
UPDATE listings SET publish_date = NULL
WHERE publish_date IS NOT NULL
AND (publish_date < DATE '2010-01-01' OR publish_date > CURRENT_DATE + 2);
COMMIT;

View file

@ -0,0 +1,43 @@
-- 182_trade_in_leads_consent_proof.sql
-- LOW audit R2 (#9): durable 152-ФЗ proof-of-consent for trade_in_leads.
--
-- WHY:
-- #2497 already computes client_ip (X-Forwarded-For, fallback direct peer)
-- and pins _CONSENT_POLICY_VERSION = "2026-07" in app/api/v1/lead.py, but
-- both were written ONLY to the audit log — trade_in_leads had no columns
-- to hold them (explicit TODO at the INSERT in lead.py). A bare boolean
-- `consent` column plus a log line is not durable proof: logs rotate/expire,
-- the row itself carries no evidence of what was agreed to, under what
-- policy version, or from what IP.
--
-- WHAT:
-- Three nullable columns on trade_in_leads:
-- - client_ip (inet) — same value already computed in
-- create_trade_in_lead, now persisted
-- per-row instead of log-only.
-- - consent_policy_version (text) — snapshot of _CONSENT_POLICY_VERSION
-- at the time this lead was captured.
-- - consent_text_snapshot (text) — snapshot of the exact consent sentence
-- shown to the user (_CONSENT_TEXT_SNAPSHOT
-- in lead.py, same PR).
--
-- IDEMPOTENCY / SAFETY:
-- ADD COLUMN IF NOT EXISTS x3 — safe re-run. All nullable, no DEFAULT, no
-- backfill: existing rows keep NULL (no consent proof was captured for them
-- pre-migration — that's an honest reflection of what actually happened,
-- not a schema gap). Purely additive: no existing reads/writes break.
--
-- Dependencies: 172_trade_in_leads.sql (trade_in_leads table).
BEGIN;
ALTER TABLE trade_in_leads
ADD COLUMN IF NOT EXISTS client_ip inet,
ADD COLUMN IF NOT EXISTS consent_policy_version text,
ADD COLUMN IF NOT EXISTS consent_text_snapshot text;
COMMENT ON COLUMN trade_in_leads.client_ip IS '152-ФЗ proof-of-consent: клиентский IP на момент согласия (ранее только в audit-логе, #2497 TODO).';
COMMENT ON COLUMN trade_in_leads.consent_policy_version IS '152-ФЗ proof-of-consent: снимок _CONSENT_POLICY_VERSION на момент согласия.';
COMMENT ON COLUMN trade_in_leads.consent_text_snapshot IS '152-ФЗ proof-of-consent: снимок текста согласия, показанного пользователю (_CONSENT_TEXT_SNAPSHOT).';
COMMIT;

View file

@ -0,0 +1,28 @@
-- 183_reenable_deactivate_stale_domklik.sql
--
-- ПРОДУКТОВОЕ РЕШЕНИЕ (user, 2026-07-13): ВКЛЮЧАЕМ обратно TTL-деактивацию
-- deactivate_stale_domklik (была отключена миграцией 163 «до возобновления
-- регулярных domclick-свипов»).
--
-- Условие из 163 снято: свипы domclick_city_sweep + domclick_detail_backfill
-- возобновлены и стабильно бегают ежедневно (scraped_at свежий, диапазон
-- последней недели), т.е. причина отключения больше не действует.
--
-- Безопасность re-enable (LOW audit R2 #10):
-- • Джоб ключуется на scraped_at (whitelist last_seen_at/scraped_at в
-- deactivate_stale_avito.py::_ALLOWED_STALENESS_COLUMNS), НЕ на listing_date —
-- исходная гипотеза «выпадет весь domklik по NULL listing_date» неприменима.
-- • При живых свипах scraped_at < NOW()-14d только у реально протухших строк,
-- а не у всего пула (в отличие от ситуации 163, где свипы стояли) —
-- деактивация корректна, а не тотальна. Чинит завышенный freshness domklik.
--
-- Идемпотентно: повторный прогон — no-op (enabled уже true). Row создаётся
-- миграцией 160; если отсутствует — UPDATE затронет 0 строк (безопасно).
BEGIN;
UPDATE scrape_schedules
SET enabled = true
WHERE source = 'deactivate_stale_domklik';
COMMIT;

View file

@ -0,0 +1,60 @@
-- 184_user_events.sql
-- Foundation schema for Features 2 & 3: unified user-event tracking.
--
-- WHY:
-- trade-in had no durable event log for login/IP audit or behavior
-- analytics. A near-identical design already existed once — `audit_log`
-- (event_type, ip_address inet, user_agent, estimate_id uuid, payload
-- jsonb, created_at) was defined in 002_core_tables.sql and DROPPED in
-- 095_dead_schema.sql as unused dead schema at the time. Product now
-- needs exactly that shape again, so this migration resurrects the
-- design under a new name, `user_events`, as the ONE unified
-- append-only table serving:
-- - login-audit (who logged in, from what IP/UA, when)
-- - search-audit (estimate requests, listing lookups)
-- - behavior-analytics (pdf_download, listing_click, page_view,
-- drawer_open, and future event_type values)
--
-- WHAT:
-- `user_events` — append-only, admin-read-only. No 152-ФЗ consent
-- gating and no mandatory retention policy for MVP (product-owner
-- decision): we log IP/UA/path/method/payload unconditionally for every
-- tracked event. `estimate_id` is a plain uuid column with NO FK
-- constraint on purpose — this keeps the log decoupled/append-only so
-- estimate deletion (or any future estimate lifecycle change) never
-- blocks or cascades into event rows.
--
-- IDEMPOTENCY / SAFETY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
-- safe re-run. Purely additive: no existing table/view/column is
-- touched.
--
-- Dependencies: none (new standalone table). Auto-applied on deploy via
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
BEGIN;
CREATE TABLE IF NOT EXISTS user_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_type text NOT NULL,
username text NOT NULL,
ip_address inet,
user_agent text,
path text,
method text,
estimate_id uuid,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE user_events IS 'Unified append-only event log (login/IP audit + behavior analytics), admin-read-only. Resurrects the design of audit_log (002_core_tables.sql, dropped in 095_dead_schema.sql).';
COMMENT ON COLUMN user_events.event_type IS 'e.g. login / estimate_request / pdf_download / listing_click / page_view / drawer_open.';
COMMENT ON COLUMN user_events.username IS 'X-Authenticated-User value at the time of the event.';
COMMENT ON COLUMN user_events.estimate_id IS 'No FK constraint by design — keeps the log decoupled/append-only from trade_in_estimates lifecycle.';
CREATE INDEX IF NOT EXISTS user_events_username_created_at_idx ON user_events (username, created_at DESC);
CREATE INDEX IF NOT EXISTS user_events_event_type_created_at_idx ON user_events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS user_events_ip_address_idx ON user_events (ip_address);
CREATE INDEX IF NOT EXISTS user_events_created_at_idx ON user_events (created_at DESC);
COMMIT;

View file

@ -0,0 +1,59 @@
-- Migration 185: account_quota_overrides — персональные месячные лимиты оценок
--
-- WHY:
-- Бонусные попытки для платящих пилотов раздавались хаком через SQL-runbook:
-- `UPDATE account_estimate_usage SET used = used - N` (negative used). Для user2
-- (Брусника) это дало used=-35 при глобальном limit=15 → GET /quota отдавал
-- {limit:15, used:-35, remaining:50} → фронт (EstimateForm.tsx) рендерил абсурд
-- «Осталось 50 из 15». Правильное решение — персональный лимит, а не отрицательный
-- счётчик использования.
--
-- WHAT:
-- 1. Таблица account_quota_overrides(username PK, monthly_limit, note, updated_at) —
-- per-user override глобального settings.estimate_quota_limit. Читается через
-- app.services.account_quota.user_limit().
-- 2. Seed: user2 (Брусника) → monthly_limit=50, пилотный грант 2026-07-13.
-- 3. Сброс хака: account_estimate_usage.used < 0 → 0 (для user2, единственного
-- затронутого аккаунта). После этой миграции user2: limit=50, used=0,
-- remaining=50 — корректно вместо «50 из 15».
--
-- IDEMPOTENCY:
-- CREATE TABLE IF NOT EXISTS + INSERT ... ON CONFLICT DO UPDATE (upsert, безопасен
-- при повторном применении) + UPDATE ... WHERE used < 0 (после первого прогона
-- used=0, условие больше не матчит — безопасный no-op при ре-apply).
--
-- Dependencies: 076_account_estimate_quota.sql (account_estimate_usage).
BEGIN;
CREATE TABLE IF NOT EXISTS account_quota_overrides (
username text PRIMARY KEY,
monthly_limit integer NOT NULL,
note text,
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE account_quota_overrides IS
'Персональный override месячного лимита оценок trade-in (вместо глобального '
'settings.estimate_quota_limit). Читается app.services.account_quota.user_limit().';
COMMENT ON COLUMN account_quota_overrides.monthly_limit IS
'Персональный лимит успешных оценок в календарный месяц для username.';
COMMENT ON COLUMN account_quota_overrides.note IS
'Причина override (grant / pilot / manual bump) — для аудита ручных изменений.';
INSERT INTO account_quota_overrides (username, monthly_limit, note)
VALUES ('user2', 50, 'Брусника — пилот, грант 2026-07-13')
ON CONFLICT (username) DO UPDATE SET
monthly_limit = EXCLUDED.monthly_limit,
note = EXCLUDED.note,
updated_at = now();
-- Сбрасываем прежний хак бонусных попыток (negative used), который ломал /quota:
-- limit=15, used=-35 → remaining=50 → фронт рендерил «Осталось 50 из 15».
UPDATE account_estimate_usage
SET used = 0, updated_at = now()
WHERE username = 'user2' AND used < 0;
COMMIT;

View file

@ -0,0 +1,93 @@
-- 186_tg_support.sql
-- Telegram support bridge: @MERAsupport_bot mirrors client DMs into a
-- support-group topic; an operator replies in-thread; the bot relays the
-- reply back to the client's private chat.
--
-- WHY:
-- No durable state existed for this flow. Two things are required to make
-- it work reliably:
-- 1. A mapping from "message mirrored into the support topic" back to
-- "which client chat_id it came from" — this is how an operator's
-- reply (a Telegram reply-to a topic message) gets routed to the
-- right client. `topic_message_id` on tg_support_messages is that
-- routing key.
-- 2. A durable long-polling offset (`tg_support_state`) so a worker
-- restart does not replay already-processed Telegram updates.
--
-- WHAT:
-- - tg_support_users — one row per client Telegram private chat
-- (chat_id is the Telegram chat id, stable per
-- client, used directly as PK — no surrogate key
-- needed).
-- - tg_support_messages — full conversation log, both directions.
-- - tg_support_state — singleton key/value store for worker offsets
-- (e.g. key='last_update_id').
--
-- 152-FZ:
-- tg_support_users / tg_support_messages hold personal data (Telegram
-- username/name + free-text conversation content). ON DELETE CASCADE from
-- tg_support_users -> tg_support_messages makes client erasure a single
-- `DELETE FROM tg_support_users WHERE chat_id = :chat_id` statement, no
-- separate cleanup pass needed.
--
-- IDEMPOTENCY / SAFETY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
-- safe re-run. Purely additive: no existing table/view/column touched.
--
-- Dependencies: none (new standalone tables). Auto-applied on deploy via
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
BEGIN;
CREATE TABLE IF NOT EXISTS tg_support_users (
chat_id bigint PRIMARY KEY,
username text,
first_name text,
last_name text,
language_code text,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
is_blocked boolean NOT NULL DEFAULT false
);
COMMENT ON TABLE tg_support_users IS '152-ФЗ: ПДн клиентов Telegram-поддержки (@MERAsupport_bot). Удаление клиента — DELETE FROM tg_support_users WHERE chat_id=...; ON DELETE CASCADE в tg_support_messages подчищает переписку одной операцией.';
COMMENT ON COLUMN tg_support_users.chat_id IS 'Telegram private chat id клиента (стабильный, используется как PK напрямую).';
COMMENT ON COLUMN tg_support_users.is_blocked IS 'true, если клиент заблокировал бота (Telegram 403 на отправку) — бот перестаёт пытаться слать сообщения.';
CREATE TABLE IF NOT EXISTS tg_support_messages (
id bigserial PRIMARY KEY,
chat_id bigint NOT NULL REFERENCES tg_support_users (chat_id) ON DELETE CASCADE,
direction text NOT NULL CHECK (direction IN ('in', 'out')),
tg_message_id bigint,
topic_message_id bigint,
kind text NOT NULL,
text_body text,
operator_tg_id bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE tg_support_messages IS '152-ФЗ: полный лог переписки Telegram-поддержки (ПДн, содержимое сообщений). Каскадно удаляется вместе с tg_support_users по chat_id.';
COMMENT ON COLUMN tg_support_messages.direction IS '''in'' — сообщение от клиента боту; ''out'' — ответ бота/оператора клиенту.';
COMMENT ON COLUMN tg_support_messages.tg_message_id IS 'id сообщения в личном чате с клиентом (Telegram message_id в chat_id).';
COMMENT ON COLUMN tg_support_messages.topic_message_id IS 'id зеркала сообщения в support-топике группы — ключ маршрутизации: реплай оператора на это сообщение адресуется данному chat_id.';
COMMENT ON COLUMN tg_support_messages.kind IS 'text | photo | document | video | voice | other.';
COMMENT ON COLUMN tg_support_messages.text_body IS 'Текст сообщения или caption медиа; NULL для медиа без подписи.';
COMMENT ON COLUMN tg_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
CREATE UNIQUE INDEX IF NOT EXISTS tg_support_messages_topic_message_id_uq
ON tg_support_messages (topic_message_id)
WHERE topic_message_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS tg_support_messages_chat_id_created_at_idx
ON tg_support_messages (chat_id, created_at DESC);
CREATE TABLE IF NOT EXISTS tg_support_state (
key text PRIMARY KEY,
value text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE tg_support_state IS 'Singleton key/value store для состояния Telegram-поддержки (например last_update_id для long-polling), переживает рестарт воркера.';
COMMENT ON COLUMN tg_support_state.key IS 'e.g. ''last_update_id''.';
COMMIT;

View file

@ -0,0 +1,122 @@
-- 187_web_support_chat.sql
-- Web-чат поддержки (сайт МЕРА) поверх УЖЕ существующего Telegram support-моста
-- (data/sql/186_tg_support.sql, app/services/tgbot/bridge.py). Источник обращения
-- меняется (сайт вместо Telegram-лички клиента), маршрутизация ответа оператора
-- (реплай на зеркало в топике супергруппы) остаётся ТОЙ ЖЕ — оператор ничего
-- нового не учит.
--
-- ПОЧЕМУ ОТДЕЛЬНЫЕ ТАБЛИЦЫ, А НЕ "tg_support_* + channel"
-- (взвешено явно, per code review requirement):
--
-- Вариант А (отклонён) — добавить channel text ('telegram'|'web') в
-- tg_support_users/tg_support_messages:
-- - tg_support_users.chat_id bigint PRIMARY KEY — это Telegram private
-- chat id клиента. У веб-пользователя сайта ЕГО НЕТ (клиент никогда не
-- писал боту в личку) — пришлось бы либо (а) городить синтетический
-- chat_id для веб-юзера (напр. отрицательный hash от username) — это
-- вводит ВТОРУЮ систему идентификации внутри одной PK-колонки,
-- семантика которой документирована как "Telegram chat id" (186:54),
-- либо (б) делать chat_id NULLABLE и городить ещё одну колонку
-- username NULLABLE рядом — таблица с двумя взаимоисключающими
-- identity-схемами и кучей CHECK-ограничений вида
-- "chat_id XOR username NOT NULL".
-- - Доставка обратно ТОЖЕ разная: Telegram-путь шлёт copyMessage в личку
-- клиента, веб-путь просто пишет строку в БД (personal chat не
-- существует) — код в bridge.py и так ветвится по каналу, общая
-- таблица не убирает эту ветку, только добавляет NULL-поля.
-- - Риск регрессии: tg_support_* уже покрыты test_bridge.py (14+
-- кейсов) и работают в проде (PR #2526) — трогать рабочую, протестированную
-- схему ради ещё не запущенной фичи повышает blast radius без выгоды.
--
-- Вариант Б (выбран) — новые web_support_threads/web_support_messages:
-- - Идентификатор клиента — username (X-Authenticated-User, сайт закрыт
-- Caddy basic_auth, публичного доступа нет — см. app/main.py rbac_guard)
-- — чистый, не smoke-и-зеркала не переиспользующий Telegram identity.
-- - topic_message_id-маршрутизация (ключевой механизм моста) СОХРАНЕНА
-- 1-в-1 по конвенции 186: partial UNIQUE на topic_message_id,
-- заполняется только для direction='in', NULL для direction='out'.
-- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну
-- дополнительную ветку (резолвит tg-путь И web-путь, потом
-- existing orphan-warning) — существующий Telegram-путь не трогается.
--
-- ⚠️ CROSS-TABLE КОЛЛИЗИЯ topic_message_id (review M1, зафиксировано ДО
-- первого прод-использования, пока обе таблицы пусты):
-- Инвариант "topic_message_id уникален между tg_support_messages и
-- web_support_messages" на самом деле звучит так: "уникален, ПОКА
-- TELEGRAM_SUPPORT_CHAT_ID не менялся". Это OPS-инвариант, а НЕ DB-инвариант —
-- ничем не гарантирован. Смена/пересоздание support-группы обнуляет счётчик
-- Telegram message_id в новом чате; когда он дорастёт до диапазона,
-- использованного старым чатом, — number, ранее занятый ОДНОЙ таблицей,
-- может совпасть с числом, занятым ДРУГОЙ. Внутри одной таблицы partial
-- UNIQUE превращает такую коллизию в громкий отказ INSERT — это ок. МЕЖДУ
-- таблицами constraint'а нет: без доп. скоупинга бот молча доставил бы ответ
-- оператора НЕ ТОМУ клиенту (152-ФЗ-инцидент, происходящий тихо).
-- Фикс: колонка `support_chat_id` на web_support_messages (симметричная
-- колонка для УЖЕ применённой tg_support_messages — отдельная миграция
-- 188_tg_support_chat_id_scope.sql, эту таблицу нельзя трогать здесь, она
-- уже применена/задеплоена как часть 186). Резолв (bridge.py) матчит ПАРУ
-- (support_chat_id, topic_message_id), а не topic_message_id в одиночку;
-- NULL (легаси-строки без этой колонки) — лениентный wildcard, т.к. на тот
-- момент действовал ровно один чат.
--
-- ЧТО:
-- - web_support_threads — один тред на username (сайт = 1 логин = 1 линия
-- переписки с поддержкой, без под-тредов).
-- - web_support_messages — лог переписки, direction='in' (от юзера) |
-- 'out' (ответ оператора, реплай из bridge.py).
--
-- 152-ФЗ:
-- Переписка (text_body) — ПДн (может содержать любые данные, которые юзер
-- решит написать). ON DELETE CASCADE от web_support_threads делает erasure
-- ОДНОЙ операцией (DELETE FROM web_support_threads WHERE username = :u) ДЛЯ
-- КОПИИ В ЭТОЙ БД. Копия того же текста уже ушла в Telegram-топик (sendMessage
-- зеркало) и живёт ТАМ вне зоны действия этого DELETE — реальное "право на
-- забвение" по всей цепочке требует ОТДЕЛЬНОЙ процедуры (удаление сообщений в
-- Telegram-супергруппе через Bot API deleteMessage, вне scope этой миграции).
-- Не ссылаться на этот комментарий как на доказательство полного erasure.
--
-- IDEMPOTENCY: CREATE TABLE/INDEX IF NOT EXISTS — безопасный re-run.
-- Зависимости: нет (новые standalone таблицы, никакие существующие
-- tg_support_*/иные таблицы не трогаются — см. 188 для ALTER на tg_support_messages).
BEGIN;
CREATE TABLE IF NOT EXISTS web_support_threads (
id bigserial PRIMARY KEY,
username text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
last_read_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE web_support_threads IS '152-ФЗ: одна строка на username (сайт МЕРА, X-Authenticated-User) — единый тред переписки с поддержкой через веб-чат. Удаление клиента — DELETE FROM web_support_threads WHERE username=...; ON DELETE CASCADE в web_support_messages подчищает переписку одной операцией.';
COMMENT ON COLUMN web_support_threads.username IS 'X-Authenticated-User (Caddy basic_auth) — сайт закрыт, анонимов нет, см. app/main.py rbac_guard.';
COMMENT ON COLUMN web_support_threads.last_seen_at IS 'Обновляется при отправке юзером нового сообщения (send-активность, НЕ на чтение истории).';
COMMENT ON COLUMN web_support_threads.last_read_at IS 'Отметка "прочитано до" (POST /api/v1/trade-in/support/read) — используется для счётчика непрочитанного (GET /support/unread).';
CREATE TABLE IF NOT EXISTS web_support_messages (
id bigserial PRIMARY KEY,
thread_id bigint NOT NULL REFERENCES web_support_threads (id) ON DELETE CASCADE,
direction text NOT NULL CHECK (direction IN ('in', 'out')),
text_body text NOT NULL CHECK (char_length(btrim(text_body)) > 0),
topic_message_id bigint,
support_chat_id bigint,
operator_tg_id bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE web_support_messages IS '152-ФЗ: полный лог веб-чата поддержки (ПДн — содержимое сообщений; удаление подчищает КОПИЮ В ЭТОЙ БД, не Telegram-топик — см. блок 152-ФЗ выше). Каскадно удаляется вместе с web_support_threads по username.';
COMMENT ON COLUMN web_support_messages.direction IS '''in'' — сообщение от пользователя сайта; ''out'' — ответ оператора (доставлен через реплай в Telegram-топике, см. bridge.py _handle_group_reply).';
COMMENT ON COLUMN web_support_messages.text_body IS 'Текст сообщения. Веб-чат — текстовый MVP, медиа не поддерживается (в отличие от tg_support_messages.kind).';
COMMENT ON COLUMN web_support_messages.topic_message_id IS 'id зеркала (sendMessage) в support-топике — ключ маршрутизации ответа, только для direction=''in''. NULL для ''out'' (конвенция 186: маршрутизирующий ключ живёт исключительно на inbound-записи).';
COMMENT ON COLUMN web_support_messages.support_chat_id IS 'TELEGRAM_SUPPORT_CHAT_ID в момент отправки — скоупит резолв topic_message_id к ТЕКУЩЕЙ support-группе (review M1: без этого поля ротация группы даёт тихую cross-table коллизию, см. блок выше). NULL — лениентный wildcard для строк без этого поля.';
COMMENT ON COLUMN web_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_topic_message_id_uq
ON web_support_messages (topic_message_id)
WHERE topic_message_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS web_support_messages_thread_id_created_at_idx
ON web_support_messages (thread_id, created_at DESC);
COMMIT;

View file

@ -0,0 +1,40 @@
-- 188_tg_support_chat_id_scope.sql
-- Симметричная колонка для web_support_messages.support_chat_id (см.
-- data/sql/187_web_support_chat.sql — полный разбор проблемы в блоке "CROSS-TABLE
-- КОЛЛИЗИЯ topic_message_id" там же).
--
-- ПОЧЕМУ ОТДЕЛЬНАЯ МИГРАЦИЯ, А НЕ ПРАВКА 186:
-- tg_support_messages создана в data/sql/186_tg_support.sql — миграция, которая
-- к моменту написания этого файла уже смержена в main отдельным PR (#2526) и,
-- по конвенции проекта (deploy-tradein.yml применяет каждый data/sql/*.sql РОВНО
-- ОДИН РАЗ по bare filename через _schema_migrations), скорее всего уже
-- применена на проде. Редактирование СОДЕРЖИМОГО уже применённого файла НЕ
-- долетает до прода повторным прогоном — прод просто пропустит файл с тем же
-- именем. Единственный корректный способ добавить колонку в уже существующую
-- таблицу — новый ALTER-файл.
--
-- ЧТО: tg_support_messages.support_chat_id bigint (nullable) — TELEGRAM_SUPPORT_
-- CHAT_ID в момент записи 'in'-сообщения. bridge.py.find_chat_by_topic_message
-- матчит (support_chat_id, topic_message_id) вместо topic_message_id в одиночку;
-- NULL (все строки ДО этой миграции) — лениентный wildcard-матч, т.к. до
-- появления этой колонки действовал ровно один support-чат за раз.
--
-- Бэкфилл существующих строк текущим TELEGRAM_SUPPORT_CHAT_ID НЕ делаем: значение
-- живёт в Python `settings`/env, разное на каждом окружении (dev/staging/prod), а
-- plain-SQL миграция не имеет доступа к процессным env vars — хардкодить
-- конкретный chat_id в SQL-файл было бы хрупко и окружение-специфично. NULL
-- (wildcard) для существующих строк — безопасный дефолт: они писались, когда
-- support-чат был ровно один, коллизии из-за смены чата у НИХ по определению
-- невозможны (см. 187 — только смена чата ПОСЛЕ появления этой колонки создаёт
-- сценарий, который она защищает).
--
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS — безопасный re-run. Не трогает
-- существующие данные/constraints tg_support_messages.
BEGIN;
ALTER TABLE tg_support_messages ADD COLUMN IF NOT EXISTS support_chat_id bigint;
COMMENT ON COLUMN tg_support_messages.support_chat_id IS 'TELEGRAM_SUPPORT_CHAT_ID в момент записи ''in''-сообщения — скоупит резолв topic_message_id к ТЕКУЩЕЙ support-группе (review M1, см. data/sql/187_web_support_chat.sql). NULL — строки до этой колонки (лениентный wildcard-матч).';
COMMIT;

View file

@ -0,0 +1,51 @@
-- Migration 189: account_estimate_usage.used >= 0 — защита от бонус-хака (negative used)
--
-- WHY:
-- Прежний SQL-runbook хак раздачи бонусных попыток (`UPDATE account_estimate_usage
-- SET used = used - N`) уводил used в отрицательные значения. Migration 185 сбросила
-- это ТОЛЬКО для user2 (used=-35 -> 0, WHERE username = 'user2'). На проде остаётся
-- минимум ещё один затронутый аккаунт тем же классом порчи (praktika, период 2026-06:
-- used=-3 при 42 фактических успешных оценках — расхождение объясняется именно этим
-- хаком, не кодовым багом).
--
-- Аудит app.services.account_quota подтверждает: декремента `used` в текущем коде
-- НЕТ. increment() делает только `used + 1` под предикатом `WHERE used < :lim`
-- (#747, atomic conditional increment) — этот путь не может уйти в минус. Значит
-- источник отрицательных значений исключительно внешний (ручной UPDATE через
-- runbook), а не баг в приложении.
--
-- WHAT:
-- 1. Сброс ВСЕХ оставшихся negative used -> 0 (не только user2, как в 185) —
-- закрывает praktika и любой другой пропущенный аккаунт.
-- 2. CHECK (used >= 0) — защита на уровне схемы: любой будущий ручной UPDATE/хак,
-- уводящий used < 0, теперь падает на уровне БД вместо тихой порчи /quota
-- (GET /quota мог отдать remaining > limit — «Осталось 50 из 15», см. 185).
--
-- IDEMPOTENCY:
-- - UPDATE ... WHERE used < 0 — no-op при повторном прогоне (после первого раза
-- условие больше не матчит).
-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint — Postgres не
-- поддерживает `ADD CONSTRAINT IF NOT EXISTS` напрямую для CHECK, поэтому
-- оборачиваем в идемпотентную проверку по имени constraint.
--
-- Dependencies: 076_account_estimate_quota.sql (account_estimate_usage),
-- 185_account_quota_overrides.sql (первый частичный сброс, только user2).
BEGIN;
UPDATE account_estimate_usage
SET used = 0, updated_at = now()
WHERE used < 0;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'account_estimate_usage_used_nonnegative'
) THEN
ALTER TABLE account_estimate_usage
ADD CONSTRAINT account_estimate_usage_used_nonnegative CHECK (used >= 0);
END IF;
END $$;
COMMIT;

View file

@ -0,0 +1,231 @@
-- 190_sale_share_price_bucket_signature.sql
--
-- CONTEXT: аудит МЕРЫ. Числитель v_building_sale_share (мигр. 148) дедупит
-- листинги по сигнатуре (rooms, round(area_m2), floor) — убирает кросс-
-- площадочные дубли одной физической квартиры (avito+cian+domclick). Но в
-- типовом секционном доме 4 РАЗНЫЕ квартиры на одном этаже в разных
-- подъездах имеют ТУ ЖЕ тройку признаков (подъезда в данных нет) → ложно
-- схлопываются в одну.
--
-- Прод-замер (снят вручную, до этой миграции; не переснят в рамках неё —
-- нет доступа к БД из этой сессии, см. ниже):
-- · без дедупа (активные вторичные, house_id_fk/rooms/area_m2/floor/
-- price_rub все NOT NULL): 15 424 записи;
-- · текущая сигнатура (rooms, round(area_m2), floor): 11 324 «квартиры»
-- (4 100 против raw — почти весь эффект дедупа, но и false-merge тоже);
-- · та же сигнатура + price_bucket round(price_rub/100000): 12 497
-- (+1 173 против текущей, +10.4%) — возвращает часть false-merge'ов.
-- Внутри 3 220 групп, схлопнутых текущей сигнатурой:
-- · 1 252 группы (1 756 записей) имеют РАЗНЫЕ цены — почти наверняка
-- разные квартиры, не кросс-пост;
-- · 233 группы (249 записей) пришли с ОДНОЙ площадки — одна площадка
-- редко публикует одну и ту же квартиру дважды, тоже почти наверняка
-- разные квартиры (см. "residual risk" ниже — этот класс НЕ решается
-- одним лишь price_bucket, если у них к тому же совпала цена).
--
-- РЕШЕНИЕ ВЛАДЕЛЬЦА ПРОДУКТА: схлопывать записи, только если они совпадают
-- ЕЩЁ И по цене (round(price_rub/100000) — тот же бакет, что уже
-- используется в backend/app/services/estimator.py::_DEDUP_PRICE_BUCKET_RUB
-- для кросс-source физ-дедупа аналогов; ~±0.5% допуска при 21М, ~±2% при
-- 2.5М — см. app/core/config.py:265). Разные квартиры в одном доме
-- почти никогда не стоят ровно одинаково, кросс-пост одного лота — стоит.
--
-- ЧТО НЕ ВОШЛО (source-distinctness) и почему:
-- Продуктовое решение также просило требовать "с разных площадок". Честно
-- выразить это внутри count(DISTINCT ...) НЕЛЬЗЯ без перестройки CTE
-- listing_agg в двухуровневую агрегацию (сначала GROUP BY house_id +
-- расширенная сигнатура + count(DISTINCT source) per группа, потом per-house
-- SUM(CASE WHEN distinct_sources>=2 THEN 1 ELSE listing_count END)) — это
-- затронуло бы ВСЕ 6 агрегатов CTE (active_secondary, listings_45d,
-- median_price_rub, median_price_per_m2, avg_days_on_market,
-- listings_med_floors), которые сейчас делят один плоский FILTER-паттерн,
-- накопленный за 6 миграций (148-153). Риск регрессии от такой перестройки
-- в одной миграции выше, чем ценность второго guard'а поверх уже сильно
-- сузившего false-merge price_bucket. Берём только price-часть.
--
-- RESIDUAL RISK (направление ошибки после этой миграции):
-- 1) НЕ решено — 233 группы/249 записей с ОДНОЙ площадкой: если у них
-- внутри группы цена ТОЖЕ совпадает (не проверено, нет прод-доступа
-- в этой сессии), они останутся ложно схлопнуты (недосчёт числителя,
-- sale_share_pct ЗАНИЖЕН для этих домов) — тот же вид ошибки, что и
-- раньше, но у существенно меньшего подмножества.
-- 2) НОВЫЙ вид ошибки, которого не было: настоящий кросс-пост одного
-- физлота, где цена УСПЕЛА измениться между скрейпами разных площадок
-- (снизили цену на avito, domclick ещё не досканирован) — теперь НЕ
-- схлопнется (разные price_bucket) → числитель ЗАВЫШЕН для этих домов.
-- Раньше такая пара схлопывалась верно (без price в ключе). Прямого
-- прод-замера размера этого класса нет.
-- Итого: миграция МЕНЯЕТ баланс ошибки с «сильный недосчёт от false-merge
-- по этажу/подъезду» на «слабый недосчёт по одноплощадочным совпадениям +
-- небольшой new-пересчёт по кросс-постам с ценовым дрейфом» — чище, но не
-- идеально в обе стороны.
--
-- price_rub NULL/0 handling: listings.price_rub объявлена `bigint NOT NULL`
-- (002_core_tables.sql), но код уже трактует её defensively как потенциально
-- отсутствующую (146/148: `l.price_rub IS NOT NULL` в median FILTER) — то же
-- делаем здесь. price_bucket-компонент = NULL, когда price_rub IS NULL ИЛИ
-- <= 0 (0/отрицательное — sentinel нераспарсенной цены, не реальная цена).
-- Партиально-NULL кортеж (rooms/area/floor есть, price_bucket NULL)
-- count(DISTINCT ROW(...)) трактует как СВОЙ отдельный кортеж (см. NULL-
-- handling мигр. 148) — т.е. листинг без подтверждённой цены НЕ схлопывается
-- ни с чем, считается один. Консервативно (не создаёт ложных совпадений по
-- цене) и совпадает с философией estimator.py::_lot_dedup_components
-- (`if not price: composite = None` → лот не участвует в физ-дедупе).
--
-- WHAT: в CTE listing_agg расширяем сигнатуру DISTINCT В ОБОИХ числителях
-- (active_secondary, listings_45d) с (rooms, round(area_m2), floor) до
-- (rooms, round(area_m2), floor, price_bucket), где price_bucket = CASE
-- WHEN l.price_rub IS NULL OR l.price_rub <= 0 THEN NULL
-- ELSE round(l.price_rub / 100000.0) END.
-- Остальные 4 агрегата CTE (median_price_rub, median_price_per_m2,
-- avg_days_on_market, listings_med_floors) — НЕ дедуп-based (считают по
-- сырым листингам, прошедшим FILTER), не трогаем. Весь top-level SELECT /
-- WHERE / плаузибилити-гейт (мигр. 145/153) / appended-колонки
-- (zhkh_flat_count, flat_count_source) / гео(≤300м, мигр.150) / floors-guard
-- (±3, мигр.152) — БАЙТ-В-БАЙТ как в мигр. 153.
--
-- DEPENDENCIES: 143 (view + houses.gar_*), 144 (canon match → gar_flat_count),
-- 145 (плаузибилити-гейт знаменателя), 146 (listings_45d + sale_share_pct_45d
-- + zhkh в COALESCE), 147 (canon strip geo-prefixes), 148 (дедуп
-- кросс-площадочных дублей — база сигнатуры, которую здесь расширяем), 149
-- (ЖКХ-приоритет знаменателя), 150 (гео-фильтр ≤300м в CTE), 151 (bare-street
-- aliases — view не трогала), 152 (floors-guard ±3), 153 (плаузибилити по
-- листинговой медианной этажности). Базируется на текущем (153) определении
-- view — меняем ТОЛЬКО DISTINCT-выражение в active_secondary/listings_45d.
--
-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE VIEW ONLY (структура top-level
-- колонок не меняется — те же позиции/типы/имена, что в 153) + COMMENT.
-- Никакого DDL над таблицами. Повторный прогон — no-op (REPLACE на
-- идентичное определение). Деплой-раннер гонит файл через
-- psql -v ON_ERROR_STOP=on БЕЗ --single-transaction → транзакцию открывает
-- САМ файл (BEGIN/COMMIT ниже), как 146/148/149/150/152/153.
--
-- CONSUMERS (грепнуто по backend+frontend, не тронуты этой миграцией):
-- backend/app/services/buildings_query.py — SELECT * колонок view (список,
-- summary, гистограмма) — тот же набор колонок, не ломается;
-- backend/app/schemas/buildings.py, backend/app/api/v1/buildings.py —
-- Pydantic-схема поверх тех же колонок, не ломается;
-- backend/tests/test_buildings_api.py — тестирует ТОЛЬКО текст SQL-билдеров
-- (строку "FROM v_building_sale_share" и т.п.), не внутренний DISTINCT view
-- → не ломается этой миграцией;
-- ⚠ backend/app/services/buildings_query.py::build_listings_query — ОТДЕЛЬНЫЙ
-- SQL (не читает view), реализует ТУ ЖЕ (rooms, round(area_m2), floor)
-- сигнатуру САМОСТОЯТЕЛЬНО (DISTINCT ON) для панели листингов одного дома.
-- После этой миграции сигнатуры /buildings/sale-share (список, через view,
-- теперь +price_bucket) и /buildings/{id}/listings (панель, старая 3-тройка)
-- РАСХОДЯТСЯ — на детальной панели дома возможен чуть меньший count уникальных
-- квартир, чем active_secondary в списке. НЕ трогаем buildings_query.py в
-- этой миграции (вне границ задачи) — фиксируем расхождение как known
-- follow-up для отдельной задачи.
--
-- NB по нумерации: последний занятый = 188 (187/188 заняты веб-чатом);
-- следующий свободный sequential = 189 (проверено `ls tradein-mvp/backend/
-- data/sql | grep '^18'` — 187, 188 заняты, 189 свободен; дубля basename нет).
--
-- Deploy order: после 188_tg_support_chat_id_scope.sql.
BEGIN;
CREATE OR REPLACE VIEW v_building_sale_share AS
WITH listing_agg AS (
SELECT l.house_id_fk AS house_id,
count(DISTINCT (l.rooms, round(l.area_m2), l.floor,
CASE WHEN l.price_rub IS NULL OR l.price_rub <= 0 THEN NULL
ELSE round(l.price_rub / 100000.0) END))
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS active_secondary,
count(DISTINCT (l.rooms, round(l.area_m2), l.floor,
CASE WHEN l.price_rub IS NULL OR l.price_rub <= 0 THEN NULL
ELSE round(l.price_rub / 100000.0) END)) FILTER (
WHERE l.listing_segment = 'vtorichka'::text
AND l.last_seen_at >= (now() - interval '45 days')
AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300)
AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)
) AS listings_45d,
percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_rub::double precision))
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_rub IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS median_price_rub,
percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_per_m2::double precision))
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_per_m2 IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS median_price_per_m2,
avg(l.days_on_market)
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.days_on_market IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS avg_days_on_market,
percentile_cont(0.5) WITHIN GROUP (ORDER BY l.total_floors)
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS listings_med_floors
FROM listings l
JOIN houses hg ON hg.id = l.house_id_fk
WHERE l.house_id_fk IS NOT NULL
GROUP BY l.house_id_fk
)
SELECT h.id AS house_id,
h.short_address,
h.full_address,
h.address,
h.lat,
h.lon,
h.year_built,
h.house_type,
h.total_floors,
h.series_name,
h.is_emergency,
COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) AS flat_count_effective,
h.gar_flat_count,
h.gar_match_method,
la.active_secondary,
la.median_price_rub,
la.median_price_per_m2,
la.avg_days_on_market,
CASE
WHEN COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
>= GREATEST(COALESCE(h.total_floors, 0), COALESCE(la.listings_med_floors, 0)::int, 8)
AND la.active_secondary <= COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
THEN round(100.0 * la.active_secondary::numeric
/ COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1)
ELSE NULL::numeric
END AS sale_share_pct,
la.listings_45d,
CASE
WHEN COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
>= GREATEST(COALESCE(h.total_floors, 0), COALESCE(la.listings_med_floors, 0)::int, 8)
AND la.listings_45d <= COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
THEN round(100.0 * la.listings_45d::numeric
/ COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1)
ELSE NULL::numeric
END AS sale_share_pct_45d,
h.zhkh_flat_count,
CASE
WHEN h.zhkh_flat_count IS NOT NULL THEN 'zhkh'
WHEN h.gar_flat_count IS NOT NULL THEN 'gar'
WHEN NULLIF(h.total_units, 0) IS NOT NULL THEN 'total_units'
WHEN NULLIF(h.flat_count, 0) IS NOT NULL THEN 'flat_count'
ELSE NULL::text
END AS flat_count_source
FROM houses h
JOIN listing_agg la ON la.house_id = h.id
WHERE h.geom IS NOT NULL AND (la.active_secondary > 0 OR la.listings_45d > 0);
COMMENT ON VIEW v_building_sale_share IS
'Per-building rollup вторички для «доли квартир дома в продаже» (мигр. 143; знаменатель — '
'ГАР canon-match мигр. 144; 2-й источник ЖКХ + окно 45д мигр. 146; дедуп кросс-площадочных '
'дублей мигр. 148 + price_bucket мигр. 189; ЖКХ-приоритет знаменателя мигр. 149; гео-фильтр '
'числителя ≤300м мигр. 150). flat_count_effective = '
'COALESCE(zhkh_flat_count, gar_flat_count, NULLIF(total_units,0), NULLIF(flat_count,0)) — '
'ЖКХ ПРИОРИТЕТ (ГИС ЖКХ точнее ГАР, который дико недосчитывает квартиры в МКД; мигр. 149). '
'Колонки zhkh_flat_count (сырой ЖКХ-счёт) + flat_count_source (zhkh|gar|total_units|flat_count|'
'NULL — какой источник реально дал знаменатель) добавлены для прозрачности. Оба числителя '
'считают УНИКАЛЬНЫЕ КВАРТИРЫ по сигнатуре count(DISTINCT (rooms, round(area_m2), floor, '
'price_bucket)), где price_bucket = round(price_rub/100000) ИЛИ NULL при price_rub NULL/<=0 '
'(мигр. 189: одна тройка rooms/area/floor не отличает соседние квартиры на одном этаже в разных '
'подъездах — совпадение ЕЩЁ И по цене резко снижает false-merge; NULL-цена не схлопывается ни с '
'чем, считается отдельно — та же партиально-NULL философия, что и в мигр. 148 для rooms/area/'
'floor, и что в estimator.py::_lot_dedup_components для физ-дедупа аналогов). Требование '
'«разных площадок» из продуктового решения НЕ выражено в SQL (потребовало бы двухуровневой '
'агрегации across всех 6 FILTER-агрегатов CTE) — residual risk: однисточниковые группы с '
'совпавшей ценой остаются ложно схлопнуты; кросс-посты с ценовым дрейфом между скрейпами '
'перестают схлопываться (см. комментарий мигр. 189 в файле). active_secondary = FILTER '
'(is_active AND vtorichka); listings_45d = FILTER (vtorichka AND last_seen_at>=now()-45d). '
'sale_share_pct = active_secondary/denom; sale_share_pct_45d = listings_45d/denom. Оба под '
'плаузибилити-гейтом (denom>=GREATEST(total_floors, листинговая-медианная-этажность, 8) AND '
'числитель<=denom; мигр. 145 + 153), иначе NULL. Фильтр: geom NOT NULL AND (active_secondary>0 '
'OR listings_45d>0) — churn-only дома тоже видны. active_secondary/listings_45d/медианы цены и '
'срока считают ТОЛЬКО листинги ≤300м от geom своего дома (мигр. 150) с floors-guard ±3 (мигр. '
'152). Листинги/дома без geom — кепим. Знаменатель НЕ изменён мигр. 189.';
COMMIT;

View file

@ -0,0 +1,68 @@
-- Migration 191: account_quota_overrides.unlimited — безлимит как данные, не хардкод
--
-- WHY:
-- app.services.account_quota.is_unlimited() до этой миграции проверял ровно два
-- условия: роль admin ИЛИ literal `username == 'kopylov'` — захардкоженное сравнение
-- строки прямо в коде. Восстановление пилота praktika (ГК «Практика», доступ вернул
-- владелец продукта 2026-07-27 — см. auth/roles.yaml) с безлимитным грантом сделало
-- бы это хардкодом ВТОРОГО имени: не масштабируется (каждый следующий безлимитный
-- клиент требовал бы code-change + review + deploy вместо data-change) и плохо само
-- по себе как паттерн (магическая строка вместо конфигурируемых данных).
--
-- unlimited — отдельная boolean-колонка, а не sentinel-значение monthly_limit
-- (-1 / 0): `0` неоднозначен («ноль оценок в месяц» vs «без лимита»), explicit
-- boolean честнее и не требует специального парсинга в user_limit()/is_unlimited().
--
-- kopylov ПЕРЕНЕСЁН в данные этой же миграцией (хардкод в коде убран, не оставлен
-- параллельно) — единый источник правды для non-admin unlimited-аккаунтов вместо
-- двух параллельных механизмов (код-константа + таблица). Порядок деплоя
-- (SQL-миграция применяется РАНЬШЕ, чем стартует новый код — см.
-- .claude/rules/sql.md "Migration order") гарантирует, что строка kopylov уже в
-- таблице к моменту, когда новый is_unlimited() (без хардкода) начинает работать —
-- поведение kopylov не меняется ни на секунду простоя.
--
-- WHAT:
-- 1. account_quota_overrides.unlimited boolean NOT NULL DEFAULT false.
-- 2. Seed: kopylov (перенос хардкода) + praktika (новый грант, пилот восстановлен
-- 2026-07-27) — оба unlimited=true. monthly_limit=999999 — placeholder: get_status()
-- безусловно читает monthly_limit через user_limit() даже для unlimited-аккаунтов
-- (чтобы вернуть какое-то "limit" поле в /quota), а само enforcement для unlimited
-- обходит этот лимит (is_unlimited() гейтит раньше в check_and_raise()/increment()).
-- Значение просто не должно выглядеть абсурдным, если когда-либо surfaced напрямую.
--
-- IDEMPOTENCY:
-- ALTER TABLE ... ADD COLUMN IF NOT EXISTS (новая колонка) + INSERT ... ON CONFLICT
-- DO UPDATE (повторный прогон сходится к тому же состоянию, не дублирует строки).
--
-- Dependencies: 185_account_quota_overrides.sql (создаёт account_quota_overrides).
BEGIN;
ALTER TABLE account_quota_overrides
ADD COLUMN IF NOT EXISTS unlimited boolean NOT NULL DEFAULT false;
COMMENT ON COLUMN account_quota_overrides.unlimited IS
'Безлимитный грант (нет месячного лимита оценок) — читается '
'app.services.account_quota.is_unlimited(). Заменяет прежний хардкод username в коде.';
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
VALUES ('kopylov', 999999, true,
'Личный аккаунт — безлимит перенесён из хардкода is_unlimited() в данные '
'(migration 191, 2026-07-27), поведение не изменилось')
ON CONFLICT (username) DO UPDATE SET
monthly_limit = EXCLUDED.monthly_limit,
unlimited = EXCLUDED.unlimited,
note = EXCLUDED.note,
updated_at = now();
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
VALUES ('praktika', 999999, true,
'ГК «Практика» — пилот восстановлен 2026-07-27 (решение владельца продукта), '
'безлимитный грант')
ON CONFLICT (username) DO UPDATE SET
monthly_limit = EXCLUDED.monthly_limit,
unlimited = EXCLUDED.unlimited,
note = EXCLUDED.note,
updated_at = now();
COMMIT;

View file

@ -176,3 +176,31 @@
169_osm_poi_ekb_local.sql
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
172_trade_in_leads.sql
173_scrape_proxies_add_domclick_affinity.sql
174_domclick_session_cookies.sql
175_scrape_schedules_seed_domclick_detail_backfill.sql
176_domrf_kapremont.sql
177_deals_city_region.sql
178_deal_city_price_bands.sql
179_scrape_schedules_seed_oblast_city_sweeps.sql
180_seed_sber_freshness_monitor.sql
181_clamp_bad_listing_dates.sql
182_trade_in_leads_consent_proof.sql
183_reenable_deactivate_stale_domklik.sql
184_user_events.sql
185_account_quota_overrides.sql
186_tg_support.sql
#
# 187_web_support_chat.sql / 188_tg_support_chat_id_scope.sql — НАМЕРЕННО НЕ
# добавлены (2026-07-27, devops-аудит). Прецедент из ЭТОГО же репо:
# commit 5eadae1e (fix(tradein/support): address deep-review ... L5) добавил
# и тут же убрал "187_web_support_chat.sql" из этого файла с формулировкой
# "keeping an unmerged migration name out of it preserves the option to
# rename before merge without tripping the "can't rename applied
# migrations" test". Обе миграции — часть веб-чата поддержки (#2532/#2533),
# который на момент этой правки ещё активно дорабатывается в параллельной
# сессии/окне (тот же фиче-набор, соседняя задача). Дописывать их сюда сейчас
# повторило бы именно ту ошибку, которую L5 исправил: заморозить имя файла
# ДО того как он гарантированно осел на проде в финальном виде. Когда фича
# стабилизируется и подтверждено, что 187/188 применены (_schema_migrations
# на проде) — дописать одной строкой в отдельном PR.

View file

@ -86,6 +86,9 @@ USAGE
# machine-readable:
python -m scripts.backtest_estimator --json
# oblast D: per-city validation (exact deals.city name, not a slug):
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
"""
from __future__ import annotations
@ -181,6 +184,14 @@ logger = logging.getLogger("backtest_estimator")
# Price-per-m² sanity band — shared by the deal sample and the listings
# subquery. Mirrors the estimator's working range for EKB вторичка and drops
# obvious data-entry garbage / commercial outliers.
# (oblast D) When --city is set, _load_sample sources a PER-CITY band from
# deal_city_price_bands instead (see _resolve_city_ppm2_band) — these globals
# remain the fallback (default/unscoped sample, and any city with no band row,
# e.g. Екатеринбург — intentionally excluded from that table).
# NB: the listings subquery (_CANDIDATES_SQL, asking-core engine only) still
# always uses these globals — left global deliberately (not city-scoped) to
# keep the change minimal; the asking-core engine is the legacy comparison
# path, not the primary oblast-D target (the full engine, default).
PPM2_MIN = 30_000
PPM2_MAX = 600_000
@ -1043,22 +1054,115 @@ _CANDIDATES_SQL = text(
"""
)
# (oblast D) Per-city PPM2 sanity band — same table the estimator's
# _fetch_dkp_corridor COALESCEs against (migration 178). Looked up only when
# --city is set (see _resolve_city_ppm2_band); the default (city=None) path
# never issues this query.
_CITY_PPM2_BAND_SQL = text(
"""
SELECT ppm2_min, ppm2_max
FROM deal_city_price_bands
WHERE city = CAST(:city AS text)
"""
)
def _load_sample(db: Session, *, sample: int, since: str) -> list[DealSample]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample]."""
rows = (
db.execute(
_SAMPLE_SQL,
{
"ppm2_min": PPM2_MIN,
"ppm2_max": PPM2_MAX,
def _sample_sql(city: str | None) -> Any:
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
before this change literal identity, not just equal text so the
default CLI invocation (and the frozen EKB regression gate, which never
calls this path at all) see byte-identical SQL.
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
scopes the sample to that ``deals.city`` value populated oblast-wide by
the #C1 migration (368 cities, 96,974 deals). Pass the EXACT canonical
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
slug see the ``--city`` CLI help for the naming decision.
"""
if city is None:
return _SAMPLE_SQL
return text(
"""
SELECT
id,
ST_X(geom::geometry) AS lon,
ST_Y(geom::geometry) AS lat,
rooms,
price_per_m2 AS sold_ppm2,
deal_date,
area_m2,
address,
floor,
total_floors,
year_built,
house_type
FROM deals
WHERE source = 'rosreestr'
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND area_m2 > 0
AND deal_date >= CAST(:since AS date)
AND city = CAST(:city AS text)
ORDER BY id DESC
LIMIT CAST(:sample AS integer)
"""
)
def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float]:
"""Per-city PPM2 sanity band (oblast D) — falls back to the module globals.
``city is None`` ``(PPM2_MIN, PPM2_MAX)``, unchanged default behaviour,
NO extra query. Otherwise looks up ``deal_city_price_bands`` for that
city's own ``[ppm2_min, ppm2_max]`` — a region-66 town can have a
materially different sane / range than the EKB-tuned 30k..600k globals
(e.g. Нижний Тагил: 16 955..108 175). No row for the city (e.g.
Екатеринбург intentionally excluded from the table, mirrors
estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB error →
the globals; read-only best-effort, never raises.
"""
if city is None:
return float(PPM2_MIN), float(PPM2_MAX)
try:
row = db.execute(_CITY_PPM2_BAND_SQL, {"city": city}).mappings().first()
except Exception as exc: # pragma: no cover — defensive, read-only best-effort
logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc)
return float(PPM2_MIN), float(PPM2_MAX)
if row is None or row["ppm2_min"] is None or row["ppm2_max"] is None:
return float(PPM2_MIN), float(PPM2_MAX)
return float(row["ppm2_min"]), float(row["ppm2_max"])
def _load_sample(
db: Session, *, sample: int, since: str, city: str | None = None
) -> list[DealSample]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
value via ``_sample_sql`` and sources the PPM2 sanity band from
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
falls back to the module globals). Default None is byte-identical to the
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
no extra query.
"""
if city is None:
ppm2_min: float = PPM2_MIN
ppm2_max: float = PPM2_MAX
else:
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
params: dict[str, Any] = {
"ppm2_min": ppm2_min,
"ppm2_max": ppm2_max,
"since": since,
"sample": sample,
},
)
.mappings()
.all()
)
}
if city is not None:
params["city"] = city
rows = db.execute(_sample_sql(city), params).mappings().all()
out: list[DealSample] = []
for r in rows:
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
@ -1516,11 +1620,22 @@ def _predict_full_spine(
)
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
dkp_raw = m._fetch_dkp_corridor(db, address=deal.address, rooms=deal.rooms, area=deal.area_m2)
# (oblast C2 parity): estimate_quality now resolves a target city and passes
# it to _fetch_dkp_corridor (estimator.py:3210-3220) — deals.city is
# populated oblast-wide (368 cities), and an unscoped corridor lets
# same-named streets in OTHER region-66 towns (e.g. "Ленина" exists in many)
# contaminate the corridor. Mirror that resolve+pass here UNCONDITIONALLY
# (independent of --city, which only scopes WHICH deals get sampled) so this
# harness measures the SAME corridor prod actually computes today, not the
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
target_city = m._resolve_target_city(deal.address)
dkp_raw = m._fetch_dkp_corridor(
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
)
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address
# → ([], None) instead of an unconditional fetch.
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address:
# estimate_quality — no-area / no-address → ([], None) instead of an
# unconditional fetch.
if deal.area_m2 and deal.address:
# NB(#2002): kept at None deliberately — the deliverable scopes the
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor.
# Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
@ -1733,6 +1848,7 @@ def run_backtest(
radius: int,
rooms_tolerance: int,
holdout_split: bool = False,
city: str | None = None,
) -> dict[str, Any]:
"""Drive the full read-only backtest and return a metrics dict.
@ -1747,9 +1863,13 @@ def run_backtest(
so its bias is near-zero by construction it proves the MECHANISM, not
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
to fit on even-id deals and evaluate on the odd-id half for an honest number.
``city`` (oblast D, default None) scopes the deal sample to one
``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour).
"""
deals = _load_sample(db, sample=sample, since=since)
logger.info("loaded sample: %d ДКП deals (since=%s)", len(deals), since)
deals = _load_sample(db, sample=sample, since=since, city=city)
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
matched_rows: list[tuple[float, float, int]] = []
matched_ids: list[int] = []
@ -1809,6 +1929,7 @@ def run_backtest(
"n_matched": len(matched_rows),
"n_no_analogs": n_no_analogs,
"holdout_split": holdout_split,
"city": city,
}
return metrics
@ -1820,6 +1941,7 @@ def run_backtest_full(
since: str,
dump_fixture: str | None = None,
resolve_house_id: bool = False,
city: str | None = None,
) -> dict[str, Any]:
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
@ -1844,10 +1966,18 @@ def run_backtest_full(
the Tier-S analog ladder + the house Avito-IMV anchor, mirroring prod, and a
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
attached to the returned metrics. Default False byte-identical prior output.
``city`` (oblast D, default None) scopes the deal sample to one
``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
``_predict_full_spine`` ALWAYS resolves + passes a per-deal target city to
``_fetch_dkp_corridor`` (oblast C2 parity fix) see its docstring.
"""
est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since)
logger.info("loaded sample: %d ДКП deals (since=%s) [full spine]", len(deals), since)
deals = _load_sample(db, sample=sample, since=since, city=city)
logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
)
predictions: list[Prediction] = []
n_no_prediction = 0
@ -1922,6 +2052,7 @@ def run_backtest_full(
"n_matched": len(predictions),
"n_no_prediction": n_no_prediction,
"price_segments_ppm2": [list(seg) for seg in _price_segments()],
"city": city,
}
# #2002: house_id resolution coverage — the key Tier-S + IMV reach number.
@ -2011,6 +2142,20 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
default="2025-06-01",
help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
)
p.add_argument(
"--city",
default=None,
metavar="NAME",
help="Oblast D: scope the held-out ДКП deal sample to ONE deals.city value "
"(region 66 is oblast-wide — deals.city covers 368 cities, 96,974 deals). "
"Pass the EXACT canonical Russian name as stored in deals.city, e.g. "
"'Нижний Тагил' or 'Каменск-Уральский' — NOT a slug/transliteration (run "
"`SELECT DISTINCT city FROM deals` to check the exact spelling). Also "
"sources the deal-sample PPM2 sanity band from deal_city_price_bands for "
"that city (falls back to the global PPM2_MIN/PPM2_MAX when the city has "
"no band row, e.g. Екатеринбург — intentionally excluded from that table). "
"Default None → unscoped sample, byte-identical to the pre-oblast-D SQL.",
)
p.add_argument(
"--radius",
type=int,
@ -2115,7 +2260,7 @@ def main(argv: list[str] | None = None) -> int:
logger.info(
"backtest start: engine=%s sample=%d since=%s radius=%dm "
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s",
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
args.engine,
args.sample,
args.since,
@ -2124,6 +2269,7 @@ def main(argv: list[str] | None = None) -> int:
args.holdout_split,
args.dump_fixture,
args.resolve_house_id,
args.city,
)
db = _session()
@ -2135,6 +2281,7 @@ def main(argv: list[str] | None = None) -> int:
since=args.since,
dump_fixture=args.dump_fixture,
resolve_house_id=args.resolve_house_id,
city=args.city,
)
else:
metrics = run_backtest(
@ -2144,6 +2291,7 @@ def main(argv: list[str] | None = None) -> int:
radius=args.radius,
rooms_tolerance=args.rooms_tolerance,
holdout_split=args.holdout_split,
city=args.city,
)
finally:
db.close()

View file

@ -0,0 +1,18 @@
"""Repo-wide test config for tradein-mvp/backend.
Currently only registers custom pytest markers so they don't emit
PytestUnknownMarkWarning when used (`--strict-markers` is not enabled in
pyproject.toml, so an unregistered marker would only warn, not fail this
just keeps output clean and documents intent in one place).
"""
from __future__ import annotations
def pytest_configure(config) -> None:
config.addinivalue_line(
"markers",
"pdf_render: real (non-mocked) WeasyPrint render — needs native "
"Pango/cairo/GObject libs, self-skips where unavailable (see "
"tests/test_pdf_real_render.py docstring for how to run it for real).",
)

View file

@ -7,16 +7,16 @@
"n_covered": 0
},
"low": {
"coverage_pct": 81.82,
"mape_pct": 13.23,
"n": 275,
"n_covered": 225
"coverage_pct": 81.88,
"mape_pct": 13.25,
"n": 276,
"n_covered": 226
},
"medium": {
"coverage_pct": 100.0,
"mape_pct": 14.64,
"n": 2,
"n_covered": 2
"mape_pct": 6.99,
"n": 1,
"n_covered": 1
}
},
"confidence_order": [
@ -136,14 +136,14 @@
"n_covered": 0
},
"low": {
"coverage_pct": 81.82,
"n": 275,
"n_covered": 225
"coverage_pct": 81.88,
"n": 276,
"n_covered": 226
},
"medium": {
"coverage_pct": 100.0,
"n": 2,
"n_covered": 2
"n": 1,
"n_covered": 1
}
}
},

View file

@ -0,0 +1,60 @@
"""LOW audit R2 (#6) — `_clamp_plausible_date` в `scraper_kit.base`.
Choke point для `listing_date`/`publish_date` в `save_listings()` (единый для
avito/cian/yandex/domclick). Проверено в БД: avito пишет 1970-01-01
(epoch-sentinel, ~698 строк) и даты до +11 дней в будущем (~79 строк); у
yandex единичные аномально старые годы. Правдоподобный диапазон
[2010-01-01, today+2d]; всё остальное None (колонка nullable).
Офлайн: без сети/curl_cffi/БД, только импорт + чистая функция.
"""
from __future__ import annotations
import os
from datetime import date, timedelta
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.base import _clamp_plausible_date
def test_avito_epoch_sentinel_rejected() -> None:
"""1970-01-01 — классический Avito 0-epoch sentinel."""
assert _clamp_plausible_date(date(1970, 1, 1)) is None
def test_just_before_min_plausible_rejected() -> None:
assert _clamp_plausible_date(date(2009, 12, 31)) is None
def test_far_future_rejected() -> None:
assert _clamp_plausible_date(date(2099, 1, 1)) is None
def test_none_passes_through_as_none() -> None:
assert _clamp_plausible_date(None) is None
def test_normal_recent_date_passes_through() -> None:
d = date(2024, 6, 1)
assert _clamp_plausible_date(d) == d
def test_today_passes() -> None:
today = date.today()
assert _clamp_plausible_date(today) == today
def test_today_plus_one_day_passes() -> None:
d = date.today() + timedelta(days=1)
assert _clamp_plausible_date(d) == d
def test_today_plus_three_days_rejected() -> None:
assert _clamp_plausible_date(date.today() + timedelta(days=3)) is None
def test_min_plausible_boundary_passes() -> None:
"""2010-01-01 сам по себе — граница включительно (в окне)."""
assert _clamp_plausible_date(date(2010, 1, 1)) == date(2010, 1, 1)

View file

@ -652,6 +652,55 @@ async def test_suggest_addresses_sends_proper_request() -> None:
assert captured["x_secret"] is None
async def test_suggest_addresses_region_body_shape_no_restrict_value() -> None:
"""region= constrains `locations` by region, WITHOUT a restrict_value key.
`restrict_value` is a top-level DaData body param (not a per-location dict
key) putting it inside the locations object would be a silent no-op.
The region constraint in `locations` is already a hard filter on its own.
"""
from app.services import dadata
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
captured["body"] = _json.loads(request.content.decode("utf-8"))
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
transport = httpx.MockTransport(handler)
with _patch_settings(token="my-token"), _patch_async_client(transport):
await dadata.suggest_addresses("Ленина", limit=5, city=None, region="Свердловская область")
body = captured["body"]
assert isinstance(body, dict)
assert body["locations"] == [{"region": "Свердловская область"}]
assert "restrict_value" not in body["locations"][0]
assert "restrict_value" not in body
async def test_suggest_addresses_region_overrides_city() -> None:
"""Когда задан и `city`, и `region` — region побеждает, city игнорируется."""
from app.services import dadata
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
captured["body"] = _json.loads(request.content.decode("utf-8"))
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
transport = httpx.MockTransport(handler)
with _patch_settings(), _patch_async_client(transport):
await dadata.suggest_addresses("Ленина", city="Екатеринбург", region="Свердловская область")
body = captured["body"]
assert isinstance(body, dict)
assert body["locations"] == [{"region": "Свердловская область"}]
async def test_suggest_addresses_clamps_limit() -> None:
"""count должен быть clamped в [1, 20] — DaData ограничение."""
from app.services import dadata
@ -671,3 +720,168 @@ async def test_suggest_addresses_clamps_limit() -> None:
body = captured["body"]
assert isinstance(body, dict)
assert body["count"] == 20 # clamped to max
# ─────────────────────────────────────────────────────────────────────────────
# #dadata-403 — clean feature-disabled log + enrich_address (clean → suggest fallback)
# ─────────────────────────────────────────────────────────────────────────────
import logging as _logging # noqa: E402
# Real prod 403 body when услуга CLEAN не подключена (не отклонённый токен).
CLEAN_FEATURE_DISABLED_BODY = {
"timestamp": "2026-07-12T16:37:54.585+00:00",
"status": 403,
"error": "Forbidden",
"message": (
"Feature 'CLEAN' disabled for token 'xxx'. "
"See https://dadata.userecho.com/topics/7784 for help."
),
"path": "/api/v1/clean/address",
}
# House-level suggest candidate carrying house_fias_id/kladr_id/qc_geo (что нужно fallback'у).
SUGGEST_HOUSE_PAYLOAD = {
"suggestions": [
{
"value": "г Екатеринбург, ул Малышева, д 4",
"unrestricted_value": "620014, Свердловская обл, г Екатеринбург, ул Малышева, д 4",
"data": {
"geo_lat": "56.831463",
"geo_lon": "60.580943",
"fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
"house_fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
"kladr_id": "6600000100006480017",
"qc_geo": "0",
"fias_level": "8",
"house": "4",
"street": "Малышева",
"city": "Екатеринбург",
},
}
]
}
def _mock_enrich_transport(
clean_status: int, clean_body: object, suggest_body: object
) -> httpx.MockTransport:
"""Routes /clean vs /suggest by URL — enrich_address зовёт оба через один AsyncClient."""
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if "cleaner.dadata.ru" in url:
return httpx.Response(clean_status, json=clean_body)
if "suggestions.dadata.ru" in url:
return httpx.Response(200, json=suggest_body)
return httpx.Response(404, json={"error": "unexpected url"})
return httpx.MockTransport(handler)
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
from app.services import dadata
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
result = await dadata.clean_address("Екатеринбург, Малышева 4")
assert result is None
text = caplog.text
assert "Стандартизация" in text or "выключена" in text
# Не должны обвинять токен при feature-disabled.
assert "auth/secret rejected" not in text
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
"""401 (или 403 без 'disabled') → сообщение про креды."""
from app.services import dadata
transport = _mock_transport_returning(401, {"message": "Unauthorized"})
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
result = await dadata.clean_address("Екатеринбург, Малышева 4")
assert result is None
assert "auth/secret rejected" in caplog.text
async def test_suggest_addresses_captures_house_fias_kladr_qc() -> None:
"""_parse_suggestion теперь тянет house_fias_id / kladr_id / qc_geo из data."""
from app.services import dadata
transport = _mock_suggest_transport(200, SUGGEST_HOUSE_PAYLOAD)
with _patch_settings(), _patch_async_client(transport):
results = await dadata.suggest_addresses("Малышева 4")
assert len(results) == 1
assert results[0].house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
assert results[0].kladr_id == "6600000100006480017"
assert results[0].qc_geo == 0
async def test_enrich_address_uses_clean_when_available() -> None:
"""CLEAN 200 → берём его результат (canonical/cadnum), suggest не подменяет."""
from app.services import dadata
transport = _mock_enrich_transport(200, SAMPLE_OK_PAYLOAD, {"suggestions": []})
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 125")
assert result is not None
assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125"
assert result.house_cadnum == "66:41:0704045:350"
assert result.raw.get("_source") != "suggest_fallback"
async def test_enrich_address_falls_back_to_suggest_when_clean_unavailable() -> None:
"""CLEAN 403 disabled → suggest-fallback даёт house_fias_id/гео; canonical/cadnum=None."""
from app.services import dadata
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, SUGGEST_HOUSE_PAYLOAD)
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 4")
assert result is not None
assert result.house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
assert result.kladr_id == "6600000100006480017"
assert result.qc_geo == 0
assert result.lat is not None and abs(result.lat - 56.831463) < 1e-6
# Честная деградация: без CLEAN нет canonical/cadnum — не выдумываем.
assert result.canonical_address is None
assert result.house_cadnum is None
assert result.raw.get("_source") == "suggest_fallback"
async def test_enrich_address_ignores_non_house_suggestions() -> None:
"""Если suggest вернул только street/city (не house) → fallback не даёт house_fias_id → None."""
from app.services import dadata
street_only = {
"suggestions": [
{
"value": "г Екатеринбург, ул Малышева",
"unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева",
"data": {"geo_lat": "56.83", "geo_lon": "60.58", "fias_id": "s", "fias_level": "7"},
}
]
}
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, street_only)
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Екатеринбург, ул. Малышева")
assert result is None
async def test_enrich_address_returns_none_when_clean_and_suggest_both_fail() -> None:
"""CLEAN недоступен + suggest пустой → None (graceful)."""
from app.services import dadata
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, {"suggestions": []})
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Малышева 4")
assert result is None

View file

@ -0,0 +1,112 @@
"""Unit tests for `app.services.image_sanitizer.sanitize_image`.
Coverage:
- happy path: a small RGBA PNG re-encodes to a clean JPEG (RGB, EXIF stripped)
- resize path: an image above _MAX_DIMENSION is thumbnailed down, still decodes
- pixel-flood DoS: an image declaring > _MAX_PIXELS is rejected via img.size
BEFORE img.load() is ever called (no full decode no OOM) #2214
- byte-size backstop: content over _MAX_BYTES rejected before Image.open
- Pillow's own bomb threshold (MAX_IMAGE_PIXELS) is lowered to our cap
- garbage bytes still raise ImageSanitizationError (regression guard)
The oversized-dimension case is asserted via monkeypatch rather than a real
100 MP buffer: allocating the bomb is exactly the OOM we are defending against,
so the test proves img.load() is NOT reached.
"""
from __future__ import annotations
import io
import os
from unittest.mock import MagicMock
import pytest
# DATABASE_URL required by config before any app import (см. sibling service tests).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from PIL import Image
from app.services import image_sanitizer
from app.services.image_sanitizer import (
SANITIZED_CONTENT_TYPE,
ImageSanitizationError,
sanitize_image,
)
def _png_bytes(width: int, height: int, mode: str = "RGBA") -> bytes:
buf = io.BytesIO()
Image.new(mode, (width, height), color=(120, 60, 30, 255)[: len(mode)]).save(buf, format="PNG")
return buf.getvalue()
def test_small_image_passes_and_becomes_jpeg() -> None:
out, ctype = sanitize_image(_png_bytes(100, 80))
assert ctype == SANITIZED_CONTENT_TYPE
# Output is a valid JPEG, decoded back in RGB (transparency dropped).
reloaded = Image.open(io.BytesIO(out))
assert reloaded.format == "JPEG"
assert reloaded.mode == "RGB"
assert reloaded.size == (100, 80)
def test_oversized_but_under_pixel_cap_is_resized() -> None:
# 3000x2000 = 6 MP (< 40 MP cap) but longest edge > _MAX_DIMENSION → thumbnailed.
out, _ = sanitize_image(_png_bytes(3000, 2000, mode="RGB"))
reloaded = Image.open(io.BytesIO(out))
assert max(reloaded.size) == image_sanitizer._MAX_DIMENSION
assert reloaded.size == (2400, 1600)
def test_pixel_flood_rejected_without_decode(monkeypatch) -> None:
"""Image declaring > cap pixels is rejected via img.size, never img.load()."""
fake_img = MagicMock()
fake_img.size = (10_000, 10_000) # 100 MP >> 40 MP cap
fake_img.__enter__.return_value = fake_img
fake_img.__exit__.return_value = False
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
with pytest.raises(ImageSanitizationError) as excinfo:
sanitize_image(b"\x89PNG\r\n\x1a\n-tiny-header")
assert "exceeds" in str(excinfo.value)
# The whole point of the fix: no full decode happened → no hundreds-of-MB buffer.
fake_img.load.assert_not_called()
def test_pixel_cap_boundary_allows_at_cap(monkeypatch) -> None:
"""Exactly _MAX_PIXELS is allowed through to load() (strict '>' comparison)."""
fake_img = MagicMock()
fake_img.size = (image_sanitizer._MAX_PIXELS, 1) # width*height == cap
fake_img.mode = "RGB"
fake_img.__enter__.return_value = fake_img
fake_img.__exit__.return_value = False
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
monkeypatch.setattr(image_sanitizer.ImageOps, "exif_transpose", lambda im: im)
sanitize_image(b"whatever")
fake_img.load.assert_called_once()
def test_byte_size_backstop_rejects_before_open(monkeypatch) -> None:
called = {"open": False}
def _guard(*_a, **_k):
called["open"] = True
raise AssertionError("Image.open must not be called for oversized bytes")
monkeypatch.setattr(image_sanitizer.Image, "open", _guard)
with pytest.raises(ImageSanitizationError):
sanitize_image(b"\x00" * (image_sanitizer._MAX_BYTES + 1))
assert called["open"] is False
def test_pillow_bomb_threshold_lowered_to_cap() -> None:
assert image_sanitizer._MAX_PIXELS == 40_000_000
assert Image.MAX_IMAGE_PIXELS == image_sanitizer._MAX_PIXELS
def test_garbage_bytes_rejected() -> None:
with pytest.raises(ImageSanitizationError):
sanitize_image(b"definitely not an image")

View file

@ -1,178 +0,0 @@
"""Unit tests for app.services.location_coef (#2045 BE-3, LocationDrawer).
No live Postgres needed DB is a minimal fake returning queued results (mirrors the
convention in tests/tasks/test_cadastral_geo_match.py). Covers:
- pure functions: _category_weight, _score_to_coef, normalization constants
- compute_location_coef: weighted top-N scoring, empty-mirror graceful fallback,
no-POI-in-radius (legit zero-score, NOT "unavailable")
"""
from __future__ import annotations
import os
from typing import Any
# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import location_coef as lc
# ── Pure functions ──────────────────────────────────────────────────────────
def test_category_weight_known_categories() -> None:
assert lc._category_weight("metro_stop") == 6.0
assert lc._category_weight("school") == 5.0
assert lc._category_weight("kindergarten") == 4.5
assert lc._category_weight("hospital") == 4.0
assert lc._category_weight("shop_mall") == 4.0
assert lc._category_weight("shop_supermarket") == 3.5
assert lc._category_weight("bus_stop") == 4.5
assert lc._category_weight("park") == 3.5
assert lc._category_weight("pharmacy") == 2.5
assert lc._category_weight("tram_stop") == 2.0
assert lc._category_weight("shop_small") == 2.0
def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
assert lc._category_weight("unknown_category") == 1.0
assert lc._category_weight(None) == 1.0
def test_top7_weight_sum_matches_ptica() -> None:
"""Same category set as Site Finder → identical top-7 normalization constant (31.5)."""
assert lc._TOP7_WEIGHT_SUM == 31.5
assert abs(lc._MAX_STRAIGHT_SCORE - 0.315) < 1e-9
def test_score_to_coef_bounds() -> None:
assert lc._score_to_coef(0.0) == 0.95
assert lc._score_to_coef(100.0) == 1.05
def test_score_to_coef_midpoint() -> None:
assert lc._score_to_coef(50.0) == 1.0
def test_score_to_coef_is_monotonic() -> None:
scores = [0.0, 10.0, 25.0, 50.0, 75.0, 90.0, 100.0]
coefs = [lc._score_to_coef(s) for s in scores]
assert coefs == sorted(coefs)
# ── compute_location_coef with a fake DB ─────────────────────────────────────
class _FakeResult:
def __init__(self, *, scalar_value: Any = None, mapping_rows: list[dict] | None = None):
self._scalar_value = scalar_value
self._mapping_rows = mapping_rows or []
def scalar(self) -> Any:
return self._scalar_value
def mappings(self) -> Any:
class _Mappings:
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
def all(self) -> list[dict]:
return self._rows
return _Mappings(self._mapping_rows)
class _FakeDB:
"""Minimal Session stand-in: execute() returns queued results in order."""
def __init__(self, results: list[_FakeResult]) -> None:
self._results = list(results)
self.executed: list[Any] = []
def execute(self, clause: Any, params: dict | None = None) -> _FakeResult:
self.executed.append((clause, params))
return self._results.pop(0)
def test_compute_location_coef_empty_mirror_returns_unavailable() -> None:
"""osm_poi_ekb_local not yet refreshed (count=0) → unavailable, no fabricated factors."""
db = _FakeDB([_FakeResult(scalar_value=0)])
result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
assert result.coef == 1.0
assert result.factors == []
assert result.geo_source == "unavailable"
# Only the count probe ran — no nearest-POI query issued against an empty mirror.
assert len(db.executed) == 1
def test_compute_location_coef_no_poi_in_radius_is_legit_zero_score() -> None:
"""Mirror populated (count>0) but nothing within radius → coef floor, NOT unavailable."""
db = _FakeDB(
[
_FakeResult(scalar_value=500), # mirror has rows elsewhere
_FakeResult(mapping_rows=[]), # nothing near this point
]
)
result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
assert result.factors == []
assert result.geo_source == "osm_poi_ekb"
assert result.coef == lc._score_to_coef(0.0) == 0.95
def test_compute_location_coef_weights_and_ranks_top_n() -> None:
"""Nearer + higher-weight-category POI ranks above farther/lower-weight ones."""
rows = [
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
{"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0},
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
]
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7)
assert result.geo_source == "osm_poi_ekb"
assert len(result.factors) == 4
# metro_stop (weight 6.0) at 150m beats school (5.0) at 300m despite being closer only
# marginally — sanity check the ranking is weight-driven, not distance-only.
assert result.factors[0].poi_type == "metro_stop"
# Weights strictly descending (sorted DESC by weight before slicing to top_n).
weights = [f.weight for f in result.factors]
assert weights == sorted(weights, reverse=True)
# coef must land inside the documented [0.95, 1.05] MVP range.
assert 0.95 <= result.coef <= 1.05
def test_compute_location_coef_limits_to_top_n() -> None:
"""More than top_n candidates → only top_n factors surface in the response."""
rows = [
{"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)}
for i in range(20)
]
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7)
assert len(result.factors) == 7
def test_compute_location_coef_unknown_category_uses_default_weight() -> None:
rows = [{"name": "Неизвестный POI", "category": "some_new_osm_tag", "distance_m": 200.0}]
db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=rows)])
result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
assert len(result.factors) == 1
expected_weight = (1.0 / (200.0 + 100.0)) * lc.CATEGORY_WEIGHTS["default"]
assert abs(result.factors[0].weight - round(expected_weight, 6)) < 1e-9
def test_compute_location_coef_passes_radius_param() -> None:
"""radius_m is forwarded as a bound param (psycopg v3 CAST discipline, no :p::type)."""
db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=[])])
lc.compute_location_coef(db, lat=56.84, lon=60.6, radius_m=1500)
_clause, params = db.executed[1]
assert params is not None
assert params["radius_m"] == 1500
def test_no_psycopg_v3_colon_colon_cast() -> None:
"""psycopg v3: never :param::type — must use CAST(:param AS type)."""
import re
assert not re.search(r":\w+::", str(lc._NEAREST_POI_SQL.text))

View file

@ -0,0 +1,346 @@
"""Unit tests for app.services.location_index (replaces test_location_coef.py).
No live Postgres needed DB is a minimal fake returning queued results (mirrors the
convention in tests/tasks/test_cadastral_geo_match.py / the deleted test_location_coef.py).
Covers:
- pure functions: _category_weight, _in_ekb_bbox, _pct_deviation (incl. monotonicity)
- _fetch_nearby_poi: qualitative POI ranking (unchanged behaviour from the old module)
- compute_location_index: out-of-coverage degradation, insufficient-sample degradation
(citywide AND local), radius-ladder expansion, explicit radius_m override, happy path
- SQL discipline: psycopg v3 CAST, percentile_cont (not naive AVG/MIN/MAX) for outlier
robustness
"""
from __future__ import annotations
import os
from typing import Any
# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import location_index as lc
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
_LAT_IN_EKB = 56.838
_LON_IN_EKB = 60.605
# ── Pure functions ──────────────────────────────────────────────────────────
def test_category_weight_known_categories() -> None:
assert lc._category_weight("metro_stop") == 6.0
assert lc._category_weight("school") == 5.0
assert lc._category_weight("kindergarten") == 4.5
assert lc._category_weight("hospital") == 4.0
assert lc._category_weight("shop_mall") == 4.0
assert lc._category_weight("shop_supermarket") == 3.5
assert lc._category_weight("bus_stop") == 4.5
assert lc._category_weight("park") == 3.5
assert lc._category_weight("pharmacy") == 2.5
assert lc._category_weight("tram_stop") == 2.0
assert lc._category_weight("shop_small") == 2.0
def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
assert lc._category_weight("unknown_category") == 1.0
assert lc._category_weight(None) == 1.0
def test_in_ekb_bbox_center_is_inside() -> None:
assert lc._in_ekb_bbox(_LAT_IN_EKB, _LON_IN_EKB) is True
def test_in_ekb_bbox_bounds_are_inclusive() -> None:
assert lc._in_ekb_bbox(56.70, 60.50) is True
assert lc._in_ekb_bbox(56.95, 60.75) is True
def test_in_ekb_bbox_outside_is_rejected() -> None:
# Nizhny Tagil — same oblast (region_code=66), well outside the EKB product bbox.
assert lc._in_ekb_bbox(57.910, 59.970) is False
# Just past each edge of the bbox.
assert lc._in_ekb_bbox(56.69, 60.60) is False
assert lc._in_ekb_bbox(56.96, 60.60) is False
assert lc._in_ekb_bbox(56.80, 60.49) is False
assert lc._in_ekb_bbox(56.80, 60.76) is False
def test_pct_deviation_above_and_below_city_median() -> None:
assert lc._pct_deviation(165_000.0, 150_000.0) == 10.0
assert lc._pct_deviation(135_000.0, 150_000.0) == -10.0
assert lc._pct_deviation(150_000.0, 150_000.0) == 0.0
def test_pct_deviation_not_artificially_clamped() -> None:
"""Owner requirement: a genuinely +40% district must read as +40%, not clamped."""
assert lc._pct_deviation(210_000.0, 150_000.0) == 40.0
def test_pct_deviation_guards_zero_division() -> None:
assert lc._pct_deviation(100_000.0, 0.0) == 0.0
def test_pct_deviation_is_monotonic_in_local_median() -> None:
"""Индекс строго монотонен по локальной медиане при фиксированной городской — в отличие
от старого coef (немонотонные бакеты на реальных данных, см. модуль docstring).
Значения ниже медианы /м² по дистанционным бакетам от центра ЕКБ, измеренные на 31
тыс. лотов (аудит владельца продукта), отсортированные по возрастанию. Индекс,
построенный на этих же локальных медианах, обязан сохранить порядок.
"""
city_median = 155_000.0
local_medians_ascending = [
93_677.0,
136_729.0,
150_063.0,
159_382.0,
159_486.0,
191_682.0,
249_686.0,
]
pct_values = [lc._pct_deviation(m, city_median) for m in local_medians_ascending]
assert pct_values == sorted(pct_values)
# ── SQL discipline ────────────────────────────────────────────────────────────
def test_no_psycopg_v3_colon_colon_cast() -> None:
"""psycopg v3: never :param::type — must use CAST(:param AS type)."""
import re
for sql in (
lc._MEDIAN_PPM2_LOCAL_SQL,
lc._MEDIAN_PPM2_CITYWIDE_SQL,
lc._NEAREST_POI_SQL,
):
assert not re.search(r":\w+::", str(sql.text))
def test_median_queries_use_percentile_not_naive_minmax() -> None:
"""Outlier robustness requirement: percentile_cont(0.5) (median), not AVG/MIN/MAX."""
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text).lower()
assert "percentile_cont(0.5)" in sql_text
assert "avg(" not in sql_text
assert "min(" not in sql_text
assert "max(" not in sql_text
def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
"""Comparable-selection quality control (owner requirement #1): city-centroid geocodes
excluded (mirrors estimator.py #769 Part E), sample bounded to the EKB bbox."""
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text)
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
assert "bbox_south" in sql_text and "bbox_north" in sql_text
assert "bbox_west" in sql_text and "bbox_east" in sql_text
# ── _fetch_nearby_poi (qualitative "что рядом" list) ─────────────────────────
class _FakeResult:
def __init__(
self,
*,
scalar_value: Any = None,
mapping_rows: list[dict] | None = None,
mapping_one: dict | None = None,
):
self._scalar_value = scalar_value
self._mapping_rows = mapping_rows or []
self._mapping_one = mapping_one
def scalar(self) -> Any:
return self._scalar_value
def mappings(self) -> Any:
outer = self
class _Mappings:
def all(self) -> list[dict]:
return outer._mapping_rows
def first(self) -> dict | None:
return outer._mapping_one
return _Mappings()
class _FakeDB:
"""Minimal Session stand-in: execute() returns queued results in order."""
def __init__(self, results: list[_FakeResult]) -> None:
self._results = list(results)
self.executed: list[Any] = []
def execute(self, clause: Any, params: dict | None = None) -> _FakeResult:
self.executed.append((clause, params))
return self._results.pop(0)
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
db = _FakeDB([_FakeResult(scalar_value=0)])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
assert poi == []
assert status == "unavailable"
assert len(db.executed) == 1 # only the count probe ran
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
assert poi == []
assert status == "ok"
def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
rows = [
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
{"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0},
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
]
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
assert status == "ok"
assert len(poi) == 4
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
# distance-only ranking.
assert poi[0].poi_type == "metro_stop"
def test_fetch_nearby_poi_limits_to_top_n() -> None:
rows = [
{"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)}
for i in range(20)
]
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
assert len(poi) == 7
# ── compute_location_index: degradation + ladder logic ───────────────────────
def test_compute_location_index_out_of_coverage_skips_all_db_calls() -> None:
"""Owner requirement #2: point outside EKB → honest 'no data', never a fallback number.
Also a perf/honesty check: no DB round-trip at all for an out-of-scope point.
"""
db = _FakeDB([])
result = lc.compute_location_index(db, lat=57.910, lon=59.970) # Nizhny Tagil
assert result.status == "out_of_coverage"
assert result.location_index_pct is None
assert result.local_median_price_per_m2 is None
assert result.city_median_price_per_m2 is None
assert result.sample_size == 0
assert result.nearby_poi == []
assert result.poi_status == "unavailable"
assert db.executed == []
def test_compute_location_index_citywide_sample_too_small_short_circuits() -> None:
"""Degenerate citywide reference (e.g. empty dev DB) → insufficient_data without ever
issuing a local-radius query (nothing to compare against anyway)."""
db = _FakeDB(
[
_FakeResult(scalar_value=0), # POI mirror empty
_FakeResult(mapping_one={"median_ppm2": None, "n": 3}), # citywide: n < MIN
]
)
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
assert result.status == "insufficient_data"
assert result.location_index_pct is None
assert result.sample_size == 3
assert len(db.executed) == 2 # poi-count + citywide only — no radius-ladder queries
def test_compute_location_index_first_radius_rung_sufficient() -> None:
db = _FakeDB(
[
_FakeResult(scalar_value=0), # poi mirror empty
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), # citywide
_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25}), # radius[0]=800
]
)
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
assert result.status == "ok"
assert result.radius_m == lc.RADIUS_LADDER_M[0]
assert result.sample_size == 25
assert result.local_median_price_per_m2 == 165_000
assert result.city_median_price_per_m2 == 150_000
assert result.location_index_pct == 10.0
assert len(db.executed) == 3 # ladder stopped at rung 1 — no further radius queries
def test_compute_location_index_expands_ladder_when_first_rung_insufficient() -> None:
db = _FakeDB(
[
_FakeResult(scalar_value=0),
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
_FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 10}), # 800m: too few
_FakeResult(mapping_one={"median_ppm2": 180_000.0, "n": 30}), # 1500m: enough
]
)
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
assert result.status == "ok"
assert result.radius_m == lc.RADIUS_LADDER_M[1]
assert result.sample_size == 30
assert len(db.executed) == 4
def test_compute_location_index_insufficient_even_at_max_radius() -> None:
db = _FakeDB(
[
_FakeResult(scalar_value=0),
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
_FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 5}), # 800m
_FakeResult(mapping_one={"median_ppm2": 195_000.0, "n": 12}), # 1500m
_FakeResult(mapping_one={"median_ppm2": 190_000.0, "n": 15}), # 2500m — still < 20
]
)
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
assert result.status == "insufficient_data"
assert result.location_index_pct is None
assert result.local_median_price_per_m2 is None
assert result.city_median_price_per_m2 == 150_000
assert result.radius_m == lc.RADIUS_LADDER_M[-1]
assert result.sample_size == 15 # honest: shows how close it got, not just "no data"
assert len(db.executed) == 5 # exhausted the full ladder
def test_compute_location_index_explicit_radius_skips_ladder() -> None:
"""An explicit radius_m must be used AS-IS — no adaptive expansion (caller-controlled)."""
db = _FakeDB(
[
_FakeResult(scalar_value=0),
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
_FakeResult(mapping_one={"median_ppm2": 160_000.0, "n": 50}), # single query only
]
)
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB, radius_m=1000)
assert result.status == "ok"
assert result.radius_m == 1000
assert len(db.executed) == 3 # exactly one radius query, no ladder rungs tried
nearest_call_params = db.executed[2][1]
assert nearest_call_params["radius_m"] == 1000
def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
"""poi_status and status degrade INDEPENDENTLY — an empty POI mirror must not prevent a
perfectly computable price-based index."""
db = _FakeDB(
[
_FakeResult(scalar_value=0), # poi mirror empty
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
_FakeResult(mapping_one={"median_ppm2": 172_500.0, "n": 40}),
]
)
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
assert result.status == "ok"
assert result.poi_status == "unavailable"
assert result.nearby_poi == []
assert result.location_index_pct == 15.0

View file

@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402
from app.services.brand import Brand # noqa: E402
from app.services.exporters import trade_in_pdf as mod # noqa: E402
@ -79,7 +79,16 @@ class _WellFormed(HTMLParser):
"""Minimal balanced-tag check: every non-void tag that opens must close."""
_VOID: ClassVar[set[str]] = {
"br", "img", "hr", "meta", "input", "rect", "path", "circle", "line", "polyline",
"br",
"img",
"hr",
"meta",
"input",
"rect",
"path",
"circle",
"line",
"polyline",
}
def __init__(self) -> None:
@ -224,7 +233,9 @@ def test_build_cover_with_sold_contains_dual_block() -> None:
assert "Ожидаемая цена продажи" in html
# existing sections untouched
assert "Диапазон цен в объявлениях" in html
assert "Диапазон цен по фактическим сделкам" in html
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover
# (остался на стр. 03) — cover ужат до одной A4-страницы.
assert "Диапазон цен по фактическим сделкам" not in html
def test_build_cover_without_sold_renders_only_asking() -> None:
@ -234,4 +245,151 @@ def test_build_cover_without_sold_renders_only_asking() -> None:
assert "Ожидаемая цена продажи" not in html
# existing layout intact
assert "Диапазон цен в объявлениях" in html
assert "Диапазон цен по фактическим сделкам" in html
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover (см. выше).
assert "Диапазон цен по фактическим сделкам" not in html
# ── _days_on_market_range — no fabricated (4, 118) corridor (#pdf-honesty) ─────
def _lot(days_on_market: int | None, price_rub: int = 10_000_000) -> AnalogLot:
return AnalogLot(
address="Екатеринбург, ул. Ленина, 1",
area_m2=50.0,
rooms=2,
floor=3,
total_floors=9,
price_rub=price_rub,
price_per_m2=int(price_rub / 50),
listing_date=None,
days_on_market=days_on_market,
)
def test_days_range_none_when_no_lots() -> None:
# Раньше возвращал хардкод (4, 118) — выдуманный срок экспозиции.
assert mod._days_on_market_range([]) is None
def test_days_range_none_when_all_days_missing() -> None:
assert mod._days_on_market_range([_lot(None), _lot(None)]) is None
def test_days_range_min_max_when_data_present() -> None:
assert mod._days_on_market_range([_lot(12), _lot(None), _lot(88)]) == (12, 88)
def test_cover_without_days_data_still_renders_range_chart() -> None:
# Нет days_on_market → show_days=False, но ценовой бар всё равно строится.
est = _estimate(analogs=[_lot(None)])
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
_assert_well_formed(html)
assert "Диапазон цен в объявлениях" in html
# ── _report_number — city-aware prefix, never hardcoded «EKБ» (#pdf-honesty) ───
def test_report_number_ekb_prefix_for_ekb_address() -> None:
est = _estimate(target_address="Екатеринбург, ул. Ленина, 1")
assert mod._report_number(est).startswith("ЕКБ-")
def test_report_number_city_prefix_for_non_ekb_address() -> None:
assert mod._report_number(_estimate(target_address="Нижний Тагил, ул. Мира, 5")).startswith(
"НТ-"
)
assert mod._report_number(_estimate(target_address="Серов, ул. Ленина, 3")).startswith("СЕР-")
def test_report_number_neutral_prefix_when_city_unknown() -> None:
# None адрес и адрес вне региона → нейтральный «МЕРА», НЕ ложный «EKБ».
assert mod._report_number(_estimate(target_address=None)).startswith("МЕРА-")
assert mod._report_number(_estimate(target_address="Москва, ул. Тверская, 1")).startswith(
"МЕРА-"
)
def test_report_number_never_hardcodes_old_ekb_literal() -> None:
# Старый баг: «EKБ-» (латинские EK + кириллическая Б) для ЛЮБОГО адреса.
for addr in (None, "Серов, ул. Ленина, 3", "Москва, ул. Тверская, 1"):
assert not mod._report_number(_estimate(target_address=addr)).startswith("EK")
def test_report_number_falls_back_to_canonical_address() -> None:
est = _estimate(target_address=None, canonical_address="Первоуральск, ул. Вайнера, 2")
assert mod._report_number(est).startswith("ПРВ-")
# ── Static «1018%» haircut replaced by computed «N%» (#pdf-honesty) ──────────
def test_cover_advice_uses_computed_discount_not_static_range() -> None:
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # 20%
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
assert "1018%" not in html
assert "на 20%" in html
def test_cover_advice_neutral_when_no_discount() -> None:
html = mod._build_cover(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
assert "1018%" not in html
assert "проходят ниже цен в объявлениях" in html
def test_deals_banner_uses_computed_discount_not_static_range() -> None:
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # 20%
html = mod._build_deals_page(est, _SNAPSHOT, _BRAND)
_assert_well_formed(html)
assert "1018%" not in html
# Ложный локейшн-хвост «(Екатеринбург, 2026)» удалён.
assert "Екатеринбург, 2026" not in html
assert "на 20%" in html
def test_deals_banner_neutral_when_no_discount() -> None:
html = mod._build_deals_page(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
_assert_well_formed(html)
assert "1018%" not in html
assert "продаются дешевле, чем заявлено" in html
# ── _build_offer_page — rent scales with room count, not a flat 1-room figure ──
# (#pdf-honesty LOW audit R2 #5: «Общие финансовые потери» hardcoded a 1-room
# 3-month rent for EVERY object, including 3-4-room ones.)
def test_offer_page_rent_scales_up_for_larger_objects() -> None:
est = _estimate()
snapshot_1room = {**_SNAPSHOT, "rooms": 1}
snapshot_3room = {**_SNAPSHOT, "rooms": 3}
html_1room = mod._build_offer_page(est, snapshot_1room, _BRAND)
html_3room = mod._build_offer_page(est, snapshot_3room, _BRAND)
_assert_well_formed(html_1room)
_assert_well_formed(html_3room)
# Base (1-room) rent range unchanged: 84 000 135 000.
assert mod._fmt_rub(84_000) in html_1room
assert mod._fmt_rub(135_000) in html_1room
# 3-room rent scaled up (×1.8 heuristic): 151 000 243 000.
assert mod._fmt_rub(151_000) in html_3room
assert mod._fmt_rub(243_000) in html_3room
# The flat 1-room figure must NOT leak into the 3-room object's report.
assert mod._fmt_rub(135_000) not in html_3room
def test_offer_page_rent_subtitle_reflects_room_count() -> None:
est = _estimate()
html_1room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 1}, _BRAND)
assert "1-комн. квартиры на 3 месяца" in html_1room
assert "однокомнатной квартиры на 3 месяца" not in html_1room
html_3room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 3}, _BRAND)
assert "3-комн. квартиры на 3 месяца" in html_3room
# Unknown room count (0/missing) → neutral subtitle, no fabricated room number.
html_unknown = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 0}, _BRAND)
assert "квартиры на 3 месяца" in html_unknown
assert "-комн. квартиры на 3 месяца" not in html_unknown

View file

@ -0,0 +1,975 @@
"""Unit tests for `app.services.tgbot.bridge` — чистая логика роутинга.
Coverage (per task spec + review follow-up):
- user topic (личка клиента зеркалится в support-топик, с шапкой на первое
сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
после истечения окна #6 review)
- реплай оператора user (доставка ответа клиенту + запись direction='out')
- реплай оператора веб-чат (#tgsupport-web): зеркало веб-сообщения резолвится
в web-тред (скоуп по (topic_message_id, support_chat_id) review M1), ответ
пишется direction='out' БЕЗ Telegram-доставки; медиа-реплай (в т.ч. фото С
ПОДПИСЬЮ) на веб-зеркало отказ ЦЕЛИКОМ + уведомление оператору в топике
(review M2, никакой частичной доставки одной подписи); зеркало от ЧУЖОГО/
устаревшего support_chat_id не матчится (ротация группы); NULL
support_chat_id (легаси) wildcard-матч; совпадение ОБЕИХ сторон
одновременно (tg И web) громкий отказ (logger.error), а не молчаливый
выбор tg-пути (152-ФЗ misroute risk)
- реплай не на зеркало (или не реплай вообще) тихий игнор, не мусорим в чат;
реплай на СООБЩЕНИЕ БОТА без записи в БД WARNING про осиротевшее зеркало
(#4 review)
- дедуп update_id (<=offset skip без side-effects; poison-pill апдейт всё
равно сдвигает offset, чтобы не подвесить весь поток)
- сбой БД (SQLAlchemyError) во время обработки rollback() ПЕРЕД save_offset,
offset всё равно сдвигается без этого следующий поллинг переиграл бы тот
же апдейт и задублировал зеркало клиента в топике (#3 review)
- /start приветствие без зеркалирования
- Telegram 403 на доставку оператору is_blocked + уведомление в топике
- TELEGRAM_SUPPORT_CHAT_ID не задан клиенту уходит "сервис недоступен"
вместо тихой потери сообщения (#5 review)
NEVER calls real Telegram API все HTTP-запросы mock'аются через
httpx.MockTransport (consistent с tests/services/test_dadata.py).
"""
from __future__ import annotations
import logging
import os
from typing import Any
import httpx
import pytest
from sqlalchemy.exc import SQLAlchemyError
# DATABASE_URL required by app.core.config before any app import (см. test_dadata.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.tgbot import bridge
from app.services.tgbot.client import TelegramClient
SUPPORT_CHAT_ID = -100123456789
SUPPORT_TOPIC_ID = 42
# ── Fake in-memory storage (БД не нужна) ─────────────────────────────────────
class FakeBridgeStorage:
"""In-memory `BridgeStorage` — никакой реальной БД, чистая логика роутинга.
`clock_s` управляемые тестом "фейковые часы" (просто float, продвигается
вручную через `storage.clock_s += ...`), чтобы честно проверить throttle-окно
в `had_recent_inbound` (#6 review) без реального `time.sleep`/datetime-моков.
`fail_next_record_message` если True, следующий вызов `record_message`
кидает `SQLAlchemyError` (симулирует обрыв коннекта к БД) и сбрасывается в
False для теста rollback-пути в `process_update` (#3 review).
"""
def __init__(self, offset: int = 0) -> None:
self._offset = offset
self.users: dict[int, dict[str, Any]] = {}
self.messages: list[dict[str, Any]] = []
self.blocked: set[int] = set()
self.commits = 0
self.rollbacks = 0
self._next_id = 1
self.clock_s: float = 0.0
self.fail_next_record_message = False
# #tgsupport-web: web_support_messages-эквивалент, topic_message_id ->
# (thread_id, support_chat_id) — второй элемент моделирует колонку
# web_support_messages.support_chat_id (review M1); None = легаси wildcard.
# + журнал outbound-записей, записанных через реплай оператора.
self.web_topic_to_thread: dict[int, tuple[int, int | None]] = {}
self.web_out_messages: list[dict[str, Any]] = []
def get_offset(self) -> int:
return self._offset
def save_offset(self, update_id: int) -> None:
self._offset = update_id
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
self.rollbacks += 1
def upsert_user(
self,
*,
chat_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language_code: str | None,
) -> None:
self.users[chat_id] = {
"username": username,
"first_name": first_name,
"last_name": last_name,
"language_code": language_code,
}
def had_recent_inbound(self, chat_id: int, window_seconds: int) -> bool:
return any(
m["chat_id"] == chat_id
and m["direction"] == "in"
and (self.clock_s - m["recorded_at_s"]) < window_seconds
for m in self.messages
)
def record_message(
self,
*,
chat_id: int,
direction: str,
tg_message_id: int | None,
topic_message_id: int | None,
kind: str,
text_body: str | None,
operator_tg_id: int | None,
support_chat_id: int | None = None,
) -> int:
if self.fail_next_record_message:
self.fail_next_record_message = False
raise SQLAlchemyError("simulated DB failure (deploy connection reset)")
row_id = self._next_id
self._next_id += 1
self.messages.append(
{
"id": row_id,
"chat_id": chat_id,
"direction": direction,
"tg_message_id": tg_message_id,
"topic_message_id": topic_message_id,
"kind": kind,
"text_body": text_body,
"operator_tg_id": operator_tg_id,
"support_chat_id": support_chat_id,
"recorded_at_s": self.clock_s,
}
)
return row_id
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
"""support_chat_id-скоуп (review M1): запись со ЧУЖИМ (не None, не текущим)
support_chat_id не матчится None (легаси/дефолт) матчится всегда."""
for m in reversed(self.messages):
if m["direction"] != "in" or m["topic_message_id"] != topic_message_id:
continue
entry_chat_id = m.get("support_chat_id")
if entry_chat_id is not None and entry_chat_id != support_chat_id:
continue
return m["chat_id"]
return None
def mark_blocked(self, chat_id: int) -> None:
self.blocked.add(chat_id)
# ── #tgsupport-web ────────────────────────────────────────────────────
def find_web_thread_by_topic_message(
self, topic_message_id: int, support_chat_id: int
) -> int | None:
"""Тот же support_chat_id-скоуп, что и `find_chat_by_topic_message` (review M1)."""
entry = self.web_topic_to_thread.get(topic_message_id)
if entry is None:
return None
thread_id, entry_chat_id = entry
if entry_chat_id is not None and entry_chat_id != support_chat_id:
return None
return thread_id
def record_web_out_message(
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> None:
self.web_out_messages.append(
{
"thread_id": thread_id,
"text_body": text_body,
"operator_tg_id": operator_tg_id,
}
)
# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _method_from_url(url: httpx.URL) -> str:
return str(url).rsplit("/", 1)[-1]
def _make_client(
responses: dict[str, Any], calls: list[tuple[str, dict[str, Any]]]
) -> TelegramClient:
"""TelegramClient wired to a MockTransport. `responses[method]` may be a dict
(returned as Bot API `result`), an int (HTTP error status), or a callable
`(payload) -> dict`. Every request is recorded into `calls`."""
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
method = _method_from_url(request.url)
payload = _json.loads(request.content.decode("utf-8")) if request.content else {}
calls.append((method, payload))
canned = responses.get(method)
if isinstance(canned, int):
return httpx.Response(
canned, json={"ok": False, "error_code": canned, "description": "mocked error"}
)
if callable(canned):
canned = canned(payload)
result = canned if canned is not None else {"message_id": 999}
return httpx.Response(200, json={"ok": True, "result": result})
transport = httpx.MockTransport(handler)
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
client = TelegramClient(token="fake-token")
import unittest.mock as mock
# Патчим httpx.AsyncClient ТОЛЬКО внутри client-модуля — не трогаем глобальный httpx.
patcher = mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory)
patcher.start()
return client
@pytest.fixture(autouse=True)
def _support_chat_settings(monkeypatch: pytest.MonkeyPatch) -> None:
"""Все тесты по умолчанию считают support-группу/топик настроенными."""
monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", SUPPORT_CHAT_ID)
monkeypatch.setattr(bridge.settings, "telegram_support_topic_id", SUPPORT_TOPIC_ID)
@pytest.fixture(autouse=True)
def _reset_flood_limiters(monkeypatch: pytest.MonkeyPatch) -> None:
"""F) `_flood_limiter`/`_flood_notify_limiter` — module-level singletons (тот же
паттерн, что `_send_limiter` в app/api/v1/support.py); большинство тестов в
этом файле шлют сообщения от одного и того же chat_id=555, поэтому без сброса
накопленные хиты одного теста бы протекали в следующий и ломали его
предположения (тест флуда должен видеть ЧИСТЫЙ бюджет)."""
monkeypatch.setattr(
bridge,
"_flood_limiter",
bridge.SlidingWindowLimiter(limit=bridge._FLOOD_LIMIT, window_s=bridge._FLOOD_WINDOW_S),
)
monkeypatch.setattr(
bridge,
"_flood_notify_limiter",
bridge.SlidingWindowLimiter(limit=1, window_s=bridge._FLOOD_WINDOW_S),
)
@pytest.fixture(autouse=True)
def _stop_patches():
"""Останавливает httpx.AsyncClient monkeypatch после каждого теста (unittest.mock.patch.start()
без контекст-менеджера требует явного stop, чтобы не утекать в соседние тесты)."""
import unittest.mock as mock
yield
mock.patch.stopall()
def _private_message(
*,
message_id: int = 1,
chat_id: int = 555,
text: str | None = "Здравствуйте, вопрос по trade-in",
username: str | None = "client_ivan",
first_name: str | None = "Иван",
last_name: str | None = "Петров",
) -> dict[str, Any]:
msg: dict[str, Any] = {
"message_id": message_id,
"chat": {"id": chat_id, "type": "private"},
"from": {
"id": chat_id,
"username": username,
"first_name": first_name,
"last_name": last_name,
"language_code": "ru",
},
}
if text is not None:
msg["text"] = text
return msg
def _group_reply_message(
*,
message_id: int = 200,
reply_to_message_id: int | None = 100,
text: str = "Ответ оператора",
operator_id: int = 777,
) -> dict[str, Any]:
msg: dict[str, Any] = {
"message_id": message_id,
"chat": {"id": SUPPORT_CHAT_ID, "type": "supergroup"},
"from": {"id": operator_id, "username": "operator1"},
"text": text,
}
if reply_to_message_id is not None:
msg["reply_to_message"] = {"message_id": reply_to_message_id}
return msg
# ── A) user → topic ──────────────────────────────────────────────────────────
async def test_private_message_mirrors_to_topic_with_header_on_first_contact() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 555}}, calls)
storage = FakeBridgeStorage()
update = {"update_id": 10, "message": _private_message()}
await bridge.process_update(update, client, storage)
methods = [m for m, _ in calls]
# Первое сообщение за окно → шапка ПЕРЕД зеркалом контента.
assert methods == ["sendMessage", "copyMessage"]
header_call = calls[0][1]
assert header_call["chat_id"] == SUPPORT_CHAT_ID
assert header_call["message_thread_id"] == SUPPORT_TOPIC_ID
assert "Иван Петров" in header_call["text"]
assert "@client_ivan" in header_call["text"]
mirror_call = calls[1][1]
assert mirror_call["from_chat_id"] == 555
assert mirror_call["chat_id"] == SUPPORT_CHAT_ID
assert mirror_call["message_id"] == 1
assert mirror_call["message_thread_id"] == SUPPORT_TOPIC_ID
assert len(storage.messages) == 1
rec = storage.messages[0]
assert rec["direction"] == "in"
assert rec["chat_id"] == 555
assert rec["topic_message_id"] == 555 # copyMessage result.message_id
assert rec["kind"] == "text"
assert rec["text_body"] == "Здравствуйте, вопрос по trade-in"
assert storage.get_offset() == 10
assert storage.commits == 1
assert 555 in storage.users
async def test_private_message_second_message_within_window_skips_header() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 556}}, calls)
storage = FakeBridgeStorage()
# Симулируем уже существующее inbound-сообщение за последний час.
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=0,
topic_message_id=100,
kind="text",
text_body="первое сообщение",
operator_tg_id=None,
)
update = {"update_id": 11, "message": _private_message(message_id=2)}
await bridge.process_update(update, client, storage)
methods = [m for m, _ in calls]
# Шапка НЕ отправляется повторно — только зеркало.
assert methods == ["copyMessage"]
assert len(storage.messages) == 2
async def test_private_message_header_resent_after_window_expires() -> None:
"""#6 review: throttle-окно (3600с) реально проверяется по времени — после
истечения окна шапка отправляется заново (не одна на весь чат навсегда)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 557}}, calls)
storage = FakeBridgeStorage()
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=0,
topic_message_id=100,
kind="text",
text_body="первое сообщение (час назад)",
operator_tg_id=None,
)
# Продвигаем фейковые часы за throttle-окно (3600с).
storage.clock_s += bridge._HEADER_THROTTLE_WINDOW_S + 1
update = {"update_id": 13, "message": _private_message(message_id=3)}
await bridge.process_update(update, client, storage)
methods = [m for m, _ in calls]
assert methods == ["sendMessage", "copyMessage"] # шапка снова отправлена
assert len(storage.messages) == 2
async def test_private_message_start_sends_greeting_without_mirroring() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
update = {"update_id": 12, "message": _private_message(text="/start")}
await bridge.process_update(update, client, storage)
methods = [m for m, _ in calls]
assert methods == ["sendMessage"]
greeting_call = calls[0][1]
assert greeting_call["chat_id"] == 555
assert "МЕРА" in greeting_call["text"]
# /start не зеркалируется и не попадает в лог переписки.
assert storage.messages == []
assert storage.get_offset() == 12
async def test_private_message_notifies_client_when_support_chat_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""#5 review: TELEGRAM_SUPPORT_CHAT_ID не задан → клиент получает "сервис
недоступен" вместо того, чтобы молча ждать ответа, который никогда не придёт."""
monkeypatch.setattr(bridge.settings, "telegram_support_chat_id", 0)
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
update = {"update_id": 14, "message": _private_message()}
await bridge.process_update(update, client, storage)
methods = [m for m, _ in calls]
assert methods == ["sendMessage"]
notify_call = calls[0][1]
assert notify_call["chat_id"] == 555
assert notify_call["text"] == bridge.SERVICE_UNAVAILABLE_TEXT
# Ничего не зеркалируется и не пишется в лог переписки — support-группа не настроена.
assert storage.messages == []
assert storage.get_offset() == 14
# ── F) флуд-лимит на отправителя (низкий приоритет) ─────────────────────────
async def test_private_message_flood_limit_blocks_excess_and_notifies_once() -> None:
"""Больше `_FLOOD_LIMIT` сообщений от ОДНОГО chat_id за окно — зеркалирование
сверх лимита отключается (никакого copyMessage, никакой записи в
tg_support_messages маршрутизировать ответ всё равно нечего без
topic_message_id). Клиент получает уведомление о недоставке РОВНО один раз
за окно, а не на каждое следующее превышение иначе само уведомление стало
бы вторым источником флуда."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 900}}, calls)
storage = FakeBridgeStorage()
update_id = 100
for i in range(bridge._FLOOD_LIMIT):
update = {"update_id": update_id, "message": _private_message(message_id=i + 1)}
await bridge.process_update(update, client, storage)
update_id += 1
# Ровно _FLOOD_LIMIT сообщений прошли мирроринг: первое — шапка + зеркало,
# остальные — только зеркало.
mirrored_calls = [m for m, _ in calls if m == "copyMessage"]
assert len(mirrored_calls) == bridge._FLOOD_LIMIT
assert len(storage.messages) == bridge._FLOOD_LIMIT
calls.clear()
over_limit_update = {
"update_id": update_id,
"message": _private_message(message_id=bridge._FLOOD_LIMIT + 1),
}
await bridge.process_update(over_limit_update, client, storage)
update_id += 1
# Сверх лимита — НЕ зеркалируется, НЕ пишется в лог переписки, клиент
# получает уведомление о недоставке (не тихий игнор — клиент не должен
# решить, что оператор получил сообщение).
assert len(calls) == 1
method, payload = calls[0]
assert method == "sendMessage"
assert payload["chat_id"] == 555
assert payload["text"] == bridge.FLOOD_LIMITED_TEXT
assert len(storage.messages) == bridge._FLOOD_LIMIT
calls.clear()
second_over_limit_update = {
"update_id": update_id,
"message": _private_message(message_id=bridge._FLOOD_LIMIT + 2),
}
await bridge.process_update(second_over_limit_update, client, storage)
# Повторное превышение в ТОМ ЖЕ окне — уведомление подавлено (не второй
# источник флуда), никаких Telegram-вызовов вообще.
assert calls == []
assert len(storage.messages) == bridge._FLOOD_LIMIT
async def test_private_message_flood_limit_does_not_block_other_client() -> None:
"""Флуд-лимит — per-chat_id: клиент А исчерпал свой бюджет, но клиент Б
(другой chat_id) продолжает получать зеркалирование как обычно один
флудящий клиент не блокирует доставку сообщений остальным (сама суть
задачи воркер однопоточный, но лимит не даёт флудеру монополизировать
его через Telegram 429)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 901}}, calls)
storage = FakeBridgeStorage()
flooding_chat_id = 555
update_id = 300
for i in range(bridge._FLOOD_LIMIT + 2):
update = {
"update_id": update_id,
"message": _private_message(chat_id=flooding_chat_id, message_id=i + 1),
}
await bridge.process_update(update, client, storage)
update_id += 1
calls.clear()
other_chat_id = 777001
other_update = {
"update_id": update_id,
"message": _private_message(chat_id=other_chat_id, message_id=1, username="another_client"),
}
await bridge.process_update(other_update, client, storage)
methods = [m for m, _ in calls]
# Другой клиент получает шапку (первое обращение) + зеркало как обычно —
# флуд первого клиента на него не влияет.
assert methods == ["sendMessage", "copyMessage"]
mirror_call = calls[1][1]
assert mirror_call["from_chat_id"] == other_chat_id
# ── B) реплай оператора → user ──────────────────────────────────────────────
async def test_group_reply_delivers_to_client_and_records_outbound() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 42}}, calls)
storage = FakeBridgeStorage()
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=1,
topic_message_id=100,
kind="text",
text_body="вопрос клиента",
operator_tg_id=None,
)
update = {
"update_id": 20,
"message": _group_reply_message(reply_to_message_id=100),
}
await bridge.process_update(update, client, storage)
methods = [m for m, _ in calls]
assert methods == ["copyMessage"]
delivery = calls[0][1]
assert delivery["chat_id"] == 555
assert delivery["from_chat_id"] == SUPPORT_CHAT_ID
assert len(storage.messages) == 2
out_rec = storage.messages[-1]
assert out_rec["direction"] == "out"
assert out_rec["chat_id"] == 555
assert out_rec["operator_tg_id"] == 777
assert out_rec["text_body"] == "Ответ оператора"
assert storage.get_offset() == 20
async def test_group_reply_not_a_reply_is_ignored() -> None:
"""Обычное сообщение в топике (не реплай) — тихий игнор, никаких Telegram-вызовов."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
update = {
"update_id": 21,
"message": _group_reply_message(reply_to_message_id=None),
}
await bridge.process_update(update, client, storage)
assert calls == []
assert storage.messages == []
# offset всё равно сдвигается — апдейт "обработан" (даже если ничего не сделано).
assert storage.get_offset() == 21
async def test_group_reply_to_unknown_message_is_ignored() -> None:
"""Реплай на сообщение, которого нет в tg_support_messages как зеркало клиента —
тихий игнор (обычная болтовня в топике на постороннее сообщение, не от бота)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
update = {
"update_id": 22,
"message": _group_reply_message(reply_to_message_id=999999),
}
await bridge.process_update(update, client, storage)
assert calls == []
assert storage.messages == []
assert storage.get_offset() == 22
async def test_group_reply_to_bot_message_without_record_logs_orphaned_mirror_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#4 review: реплай на сообщение БОТА, которого нет в tg_support_messages, —
вероятное осиротевшее зеркало (крах между copyMessage и commit). WARNING, не
тихий игнор оператор иначе решит, что ответ клиенту доставлен."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
message = _group_reply_message(reply_to_message_id=100)
message["reply_to_message"]["from"] = {"id": 999, "is_bot": True, "username": "MERAsupport_bot"}
update = {"update_id": 24, "message": message}
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
await bridge.process_update(update, client, storage)
assert calls == [] # ответ НЕ доставлен — routing-ключ потерян
assert "осиротевшее" in caplog.text
assert storage.get_offset() == 24
async def test_group_reply_to_non_bot_message_without_record_stays_silent(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Обычный реплай на сообщение ДРУГОГО ЧЕЛОВЕКА (не бота) в топике — реальная
болтовня, никакого WARNING (дискриминатор `is_bot` работает в обе стороны)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
message = _group_reply_message(reply_to_message_id=101)
message["reply_to_message"]["from"] = {"id": 42, "is_bot": False, "username": "colleague"}
update = {"update_id": 25, "message": message}
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
await bridge.process_update(update, client, storage)
assert calls == []
assert caplog.text == ""
async def test_group_reply_403_marks_blocked_and_notifies_topic() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": 403}, calls)
storage = FakeBridgeStorage()
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=1,
topic_message_id=100,
kind="text",
text_body="вопрос клиента",
operator_tg_id=None,
)
update = {
"update_id": 23,
"message": _group_reply_message(reply_to_message_id=100),
}
await bridge.process_update(update, client, storage)
methods = [m for m, _ in calls]
assert methods == ["copyMessage", "sendMessage"]
notify_call = calls[1][1]
assert notify_call["chat_id"] == SUPPORT_CHAT_ID
assert "заблокирован" in notify_call["text"]
assert 555 in storage.blocked
# Неудачная доставка НЕ должна создавать фейковую запись 'out'.
assert len(storage.messages) == 1
assert storage.get_offset() == 23
# ── B') реплай оператора → веб-чат (#tgsupport-web) ──────────────────────────
async def test_group_reply_to_web_mirror_records_outbound_web_message() -> None:
"""Реплай на зеркало веб-сообщения (не найдено в tg_support_messages, найдено
среди web_support_messages) записывается в веб-тред, БЕЗ Telegram-доставки
(у веб-клиента нет личного чата с ботом)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
# topic_message_id=300 -> thread_id=42, под ТЕКУЩИМ support_chat_id.
storage.web_topic_to_thread[300] = (42, SUPPORT_CHAT_ID)
update = {
"update_id": 60,
"message": _group_reply_message(reply_to_message_id=300, text="Ответ по веб-чату"),
}
await bridge.process_update(update, client, storage)
# Никакого Telegram API вызова — веб-клиент не имеет личного чата с ботом.
assert calls == []
assert len(storage.web_out_messages) == 1
rec = storage.web_out_messages[0]
assert rec["thread_id"] == 42
assert rec["text_body"] == "Ответ по веб-чату"
assert rec["operator_tg_id"] == 777
# tg-путь тоже не тронут — ни одной записи в tg_support_messages.
assert storage.messages == []
assert storage.get_offset() == 60
async def test_group_reply_to_web_mirror_with_null_support_chat_id_matches_current_chat() -> None:
"""Легаси-строка (до 187/188, support_chat_id=None) — лениентный wildcard,
матчится под ЛЮБЫМ текущим support_chat_id (review M1)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
storage.web_topic_to_thread[305] = (46, None)
update = {
"update_id": 63,
"message": _group_reply_message(reply_to_message_id=305, text="Ответ по легаси-зеркалу"),
}
await bridge.process_update(update, client, storage)
assert len(storage.web_out_messages) == 1
assert storage.web_out_messages[0]["thread_id"] == 46
async def test_group_reply_to_web_mirror_from_stale_support_chat_is_not_matched() -> None:
"""#tgsupport-web review M1: зеркало, записанное под ДРУГИМ (не текущим,
не None) support_chat_id исторический артефакт ротации группы, НЕ валидный
маршрут сегодня. Не матчится падает в orphan-check (не-bot реплай тихий
игнор, никакой доставки в чужой/устаревший тред)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
stale_chat_id = -999999999999
storage.web_topic_to_thread[306] = (47, stale_chat_id)
update = {
"update_id": 64,
"message": _group_reply_message(reply_to_message_id=306),
}
await bridge.process_update(update, client, storage)
assert calls == []
assert storage.web_out_messages == [] # НЕ доставлено в устаревший тред
async def test_group_reply_to_web_mirror_without_text_is_refused_with_operator_notice(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Веб-чат — текстовый MVP: реплай медиа-типом (нет text/caption) на веб-зеркало
не может быть доставлен WARNING в лог И явное уведомление оператору в топике
(review M2: раньше был тихий игнор, оператор был уверен что ответил)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
storage.web_topic_to_thread[301] = (43, SUPPORT_CHAT_ID)
message = _group_reply_message(reply_to_message_id=301, message_id=201)
del message["text"] # медиа-реплай без текста/caption
message["voice"] = {"file_id": "x"}
update = {"update_id": 61, "message": message}
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
await bridge.process_update(update, client, storage)
assert storage.web_out_messages == []
assert "не текст" in caplog.text
assert storage.get_offset() == 61
methods = [m for m, _ in calls]
assert methods == ["sendMessage"]
notice_call = calls[0][1]
assert notice_call["chat_id"] == SUPPORT_CHAT_ID
assert notice_call["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
assert notice_call["reply_to_message_id"] == 201
async def test_group_reply_to_web_mirror_with_photo_and_caption_is_refused_not_partial() -> None:
"""Фото С ПОДПИСЬЮ на веб-зеркало — НЕ доставляем только подпись молча
(клиент решил бы, что подпись весь ответ): отказ целиком, как и без caption
(review M2)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
storage.web_topic_to_thread[302] = (44, SUPPORT_CHAT_ID)
message = _group_reply_message(reply_to_message_id=302, message_id=202)
del message["text"]
message["photo"] = [{"file_id": "x"}]
message["caption"] = "Смотрите скриншот"
update = {"update_id": 65, "message": message}
await bridge.process_update(update, client, storage)
assert storage.web_out_messages == [] # подпись НЕ доставлена как "весь ответ"
methods = [m for m, _ in calls]
assert methods == ["sendMessage"]
assert calls[0][1]["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
async def test_group_reply_refuses_delivery_when_both_tg_and_web_match(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#tgsupport-web review M1: если topic_message_id одновременно резолвится и в
tg_support_messages, И в web_support_messages (под ОДНИМ и тем же
support_chat_id целостность нарушена) ГРОМКИЙ отказ (logger.error), НИКАКОЙ
доставки ни в Telegram-личку, ни в веб-тред. Раньше tg-путь выбирался молча
misroute постороннему Telegram-клиенту (152-ФЗ risk)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 999}}, calls)
storage = FakeBridgeStorage()
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=1,
topic_message_id=400,
kind="text",
text_body="вопрос клиента",
operator_tg_id=None,
support_chat_id=SUPPORT_CHAT_ID,
)
storage.web_topic_to_thread[400] = (99, SUPPORT_CHAT_ID)
update = {
"update_id": 62,
"message": _group_reply_message(reply_to_message_id=400),
}
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
await bridge.process_update(update, client, storage)
assert calls == [] # ничего не доставлено НИ В ОДНУ сторону
assert storage.web_out_messages == []
assert len(storage.messages) == 1 # только исходное 'in', никакого 'out'
assert "ОДНОВРЕМЕННО" in caplog.text
assert storage.get_offset() == 62
# ── C) дедуп ──────────────────────────────────────────────────────────────────
async def test_dedup_update_id_leq_offset_is_skipped_without_side_effects() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage(offset=50)
update = {"update_id": 50, "message": _private_message()}
await bridge.process_update(update, client, storage)
assert calls == []
assert storage.messages == []
assert storage.commits == 0 # ранний return — offset уже актуален, коммитить нечего
assert storage.get_offset() == 50
update_older = {"update_id": 10, "message": _private_message()}
await bridge.process_update(update_older, client, storage)
assert calls == []
assert storage.get_offset() == 50
async def test_poison_pill_update_still_advances_offset() -> None:
"""Апдейт, на котором обработчик упал (например, message без chat), не должен
подвесить весь поток offset сдвигается даже при исключении внутри handler'а."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
malformed_message = {"message_id": 1, "chat": {"type": "private"}} # нет chat.id
update = {"update_id": 30, "message": malformed_message}
await bridge.process_update(update, client, storage)
assert storage.get_offset() == 30
assert storage.commits == 1
assert storage.messages == []
async def test_db_error_during_processing_rolls_back_and_still_advances_offset() -> None:
"""#3 review: SQLAlchemyError (напр. обрыв коннекта к БД при деплое) во время
`record_message` storage.rollback() ПЕРЕД save_offset, offset всё равно
сдвигается. Без rollback() save_offset сам кинул бы PendingRollbackError
process_update вылетел бы без сохранения offset'а → следующая итерация
переиграла бы тот же апдейт copyMessage задублировал бы зеркало в топике
на каждый повтор поллинга (воспроизведено ревьюером)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 558}}, calls)
storage = FakeBridgeStorage()
storage.fail_next_record_message = True
update = {"update_id": 15, "message": _private_message()}
await bridge.process_update(update, client, storage)
# copyMessage успел уйти в Telegram (реальная утечка мирроринга при DB-сбое
# известное ограничение атомарности между внешним API и БД, вне scope этого фикса),
# но rollback() отработал, offset сдвинут, commit вызван РОВНО один раз (в finally).
assert storage.rollbacks == 1
assert storage.commits == 1
assert storage.get_offset() == 15
# Запись сообщения НЕ попала в storage (record_message упал до append).
assert storage.messages == []
# Повторный вызов с тем же update_id теперь корректно дедупится — НЕ переигрывается.
calls.clear()
await bridge.process_update(update, client, storage)
assert calls == []
assert storage.get_offset() == 15
async def test_update_without_update_id_is_ignored() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
await bridge.process_update({"message": _private_message()}, client, storage)
assert calls == []
assert storage.commits == 0
assert storage.get_offset() == 0
# ── kind inference ────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
("message_extra", "expected_kind"),
[
({"text": "hi"}, "text"),
({"photo": [{"file_id": "x"}]}, "photo"),
({"document": {"file_id": "x"}}, "document"),
({"video": {"file_id": "x"}}, "video"),
({"voice": {"file_id": "x"}}, "voice"),
({"sticker": {"file_id": "x"}}, "other"),
({"location": {"latitude": 1, "longitude": 2}}, "other"),
({}, "other"),
],
)
def test_infer_kind(message_extra: dict[str, Any], expected_kind: str) -> None:
message = {"message_id": 1, "chat": {"id": 1, "type": "private"}, **message_extra}
assert bridge._infer_kind(message) == expected_kind
# ── unrelated chat types ─────────────────────────────────────────────────────
async def test_update_from_unrelated_chat_is_ignored_but_offset_advances() -> None:
"""Апдейт не из личного чата и не из support-группы (например, другой чат/канал)
молча игнорируется, offset всё равно сдвигается."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
message = {
"message_id": 1,
"chat": {"id": -999, "type": "group"},
"from": {"id": 1},
"text": "болтовня в постороннем чате",
}
update = {"update_id": 40, "message": message}
await bridge.process_update(update, client, storage)
assert calls == []
assert storage.messages == []
assert storage.get_offset() == 40

View file

@ -0,0 +1,164 @@
"""Unit tests for `app.services.tgbot.client.TelegramClient` retry/backoff logic.
NEVER calls real Telegram API httpx.MockTransport only (consistent с
tests/services/test_dadata.py). `asyncio.sleep` is patched to a no-op so retry
tests run instantly regardless of configured backoff/retry_after durations.
"""
from __future__ import annotations
import os
from typing import Any
from unittest import mock
import httpx
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.tgbot.client import TelegramApiError, TelegramClient
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _install_transport(handler) -> None:
transport = httpx.MockTransport(handler)
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory).start()
@pytest.fixture(autouse=True)
def _stop_patches_and_noop_sleep():
sleep_patcher = mock.patch("app.services.tgbot.client.asyncio.sleep", return_value=None)
sleep_patcher.start()
yield
mock.patch.stopall()
async def test_get_updates_happy_path_returns_list() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/getUpdates")
return httpx.Response(200, json={"ok": True, "result": [{"update_id": 1}]})
_install_transport(handler)
client = TelegramClient(token="fake-token")
updates = await client.get_updates(offset=1)
assert updates == [{"update_id": 1}]
async def test_never_logs_or_leaks_token_in_request_url_host() -> None:
"""Sanity: token lives only in the path, base host stays api.telegram.org."""
captured: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={"ok": True, "result": {}})
_install_transport(handler)
client = TelegramClient(token="super-secret-token")
await client.send_message(chat_id=1, text="hi")
assert "bot" + "super-secret-token" in captured["url"] # goes over the wire, not logged
async def test_copy_message_retries_on_429_then_succeeds() -> None:
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
if calls["n"] == 1:
return httpx.Response(
429,
json={
"ok": False,
"error_code": 429,
"description": "Too Many Requests",
"parameters": {"retry_after": 3},
},
)
return httpx.Response(200, json={"ok": True, "result": {"message_id": 5}})
_install_transport(handler)
client = TelegramClient(token="fake-token")
result = await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
assert result == {"message_id": 5}
assert calls["n"] == 2
async def test_send_message_retries_on_5xx_then_succeeds() -> None:
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
if calls["n"] < 3:
return httpx.Response(
502, json={"ok": False, "error_code": 502, "description": "bad gw"}
)
return httpx.Response(200, json={"ok": True, "result": {"message_id": 9}})
_install_transport(handler)
client = TelegramClient(token="fake-token")
result = await client.send_message(chat_id=1, text="retrying")
assert result == {"message_id": 9}
assert calls["n"] == 3
async def test_send_message_raises_immediately_on_non_retryable_4xx() -> None:
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(
403, json={"ok": False, "error_code": 403, "description": "Forbidden: bot blocked"}
)
_install_transport(handler)
client = TelegramClient(token="fake-token")
with pytest.raises(TelegramApiError) as exc_info:
await client.send_message(chat_id=1, text="hi")
assert exc_info.value.error_code == 403
assert calls["n"] == 1 # НЕ ретраится
async def test_copy_message_gives_up_after_max_retries_on_persistent_5xx() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={"ok": False, "error_code": 500, "description": "boom"})
_install_transport(handler)
client = TelegramClient(token="fake-token")
with pytest.raises(TelegramApiError) as exc_info:
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
assert exc_info.value.error_code == 500
async def test_get_updates_returns_empty_list_on_malformed_result() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "result": "not-a-list"})
_install_transport(handler)
client = TelegramClient(token="fake-token")
assert await client.get_updates(offset=1) == []
async def test_optional_thread_and_reply_params_omitted_when_falsy() -> None:
captured: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
captured["body"] = _json.loads(request.content.decode("utf-8"))
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
_install_transport(handler)
client = TelegramClient(token="fake-token")
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3, message_thread_id=0)
assert "message_thread_id" not in captured["body"]

View file

@ -82,7 +82,6 @@ def _run_qa_estimate(
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
radius_analogs: list[dict[str, Any]] | None = None,
) -> Any:
from app.core.config import settings
from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import estimate_quality
from app.services.geocoder import GeocodeResult
@ -105,7 +104,6 @@ def _run_qa_estimate(
async def _run() -> Any:
with (
patch.object(settings, "estimate_same_building_anchor_enabled", True),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),

View file

@ -1,20 +1,30 @@
"""Tests for app.services.account_quota — monthly estimate quota enforcement.
Coverage:
(a) admin и kopylov unlimited не блокируются, increment является no-op
(a) admin, kopylov, praktika unlimited не блокируются, increment является no-op
(b) обычный pilot-юзер блокируется на 16-м запросе (429 + нужный detail)
(c) increment растит used счётчик
(d) get_status корректен для different сценариев
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
(f) #747 — атомарно-условный increment (TOCTOU fix)
(g) account_quota_overrides.monthly_limit персональный лимит вместо negative-used
хака
(h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
(i) account_quota_overrides.unlimited data-driven безлимит (migration 191):
kopylov (перенесён из хардкода) и praktika (восстановленный пилот) безлимитны
через таблицу, не через код
DB мокируется через MagicMock реальная БД не требуется.
DB мокируется через _FakeDB (роутинг по SQL-тексту, см. ниже) реальная БД не
требуется.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
# psycopg v3 driver required; stub DATABASE_URL before any app import
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -29,6 +39,7 @@ import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
from app.services.account_quota import ( # noqa: E402
LIMIT_EXHAUSTED_MESSAGE,
MONTHLY_LIMIT,
@ -36,81 +47,173 @@ from app.services.account_quota import ( # noqa: E402
get_status,
increment,
is_unlimited,
user_limit,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
#
# is_unlimited() теперь (migration 191) может как шорткатиться БЕЗ похода в БД
# (роль admin, ИЛИ username вообще не в roles.yaml → KeyError), так и делать
# реальный SELECT unlimited FROM account_quota_overrides (обычный pilot / kopylov /
# praktika). Это значит, что порядок/количество db.execute() вызовов зависит от
# username, а не только от вызываемой функции — позиционные side_effect-списки
# были бы хрупкими. Вместо этого _FakeDB роутит execute() по ТЕКСТУ SQL, что
# устойчиво к тому, сколько раз и в каком порядке реально стучимся в БД.
def _db_with_used(used: int) -> MagicMock:
"""DB session mock whose execute().fetchone() returns row with .used = N."""
row = MagicMock()
row.used = used
execute_result = MagicMock()
execute_result.fetchone.return_value = row
db = MagicMock()
db.execute.return_value = execute_result
return db
class _Row:
"""Row stand-in: произвольные named-поля + позиционный доступ row[0]
(нужен increment() для RETURNING used в debug-логе)."""
def __init__(self, **fields: int | bool) -> None:
for name, value in fields.items():
setattr(self, name, value)
self._t = tuple(fields.values())
def __getitem__(self, i: int) -> int:
return self._t[i]
def _db_no_row() -> MagicMock:
"""DB session mock where no row exists yet (first estimate of the month)."""
execute_result = MagicMock()
execute_result.fetchone.return_value = None
db = MagicMock()
db.execute.return_value = execute_result
return db
class _FakeDB:
"""DB session mock, роутит execute() по подстроке в SQL-тексте, а не по
порядку вызова устойчив к тому, что is_unlimited() иногда обращается к БД
(обычный pilot / kopylov / praktika), а иногда шорткатится без неё (admin /
неизвестный username)."""
def __init__(
self,
*,
unlimited: bool | None = None,
override_limit: int | None = None,
used: int | None = None,
upsert_used: int | None = None,
) -> None:
self.unlimited = unlimited
self.override_limit = override_limit
self.used = used
self.upsert_used = upsert_used
self.commits = 0
self.execute_calls: list[tuple[str, dict]] = []
def execute(self, stmt: object, params: dict | None = None) -> MagicMock:
sql = str(stmt)
self.execute_calls.append((sql, dict(params or {})))
result = MagicMock()
if "SELECT unlimited FROM account_quota_overrides" in sql:
result.fetchone.return_value = (
None if self.unlimited is None else _Row(unlimited=self.unlimited)
)
elif "SELECT monthly_limit FROM account_quota_overrides" in sql:
result.fetchone.return_value = (
None if self.override_limit is None else _Row(monthly_limit=self.override_limit)
)
elif "INSERT INTO account_estimate_usage" in sql:
result.fetchone.return_value = (
None if self.upsert_used is None else _Row(used=self.upsert_used)
)
elif "SELECT used FROM account_estimate_usage" in sql:
result.fetchone.return_value = None if self.used is None else _Row(used=self.used)
else:
raise AssertionError(f"_FakeDB: unrecognized SQL: {sql!r}")
return result
def commit(self) -> None:
self.commits += 1
def _override_result(limit: int | None) -> MagicMock:
"""Mock результата запроса account_quota_overrides.monthly_limit — для тестов,
вызывающих user_limit() напрямую (без is_unlimited в цепочке)."""
result = MagicMock()
result.fetchone.return_value = None if limit is None else _Row(monthly_limit=limit)
return result
# ---------------------------------------------------------------------------
# (a) admin and kopylov are unlimited
# (a) admin / kopylov / praktika unlimited
# ---------------------------------------------------------------------------
def test_is_unlimited_admin() -> None:
assert is_unlimited("admin") is True
"""admin шорткатится по роли — БЕЗ похода в БД."""
db = MagicMock()
assert is_unlimited(db, "admin") is True
db.execute.assert_not_called()
def test_is_unlimited_kopylov() -> None:
assert is_unlimited("kopylov") is True
"""kopylov — unlimited через account_quota_overrides.unlimited=true (migration
191), не через хардкод в коде."""
db = _FakeDB(unlimited=True)
assert is_unlimited(db, "kopylov") is True
def test_is_unlimited_praktika() -> None:
"""praktika — восстановленный пилот с безлимитным грантом (migration 191)."""
db = _FakeDB(unlimited=True)
assert is_unlimited(db, "praktika") is True
def test_is_unlimited_pilot_user1() -> None:
assert is_unlimited("user1") is False
"""user1 — обычный pilot, нет override-строки → limited."""
db = _FakeDB()
assert is_unlimited(db, "user1") is False
def test_is_unlimited_unknown_user() -> None:
"""Неизвестный пользователь → False (KeyError трактуется как limited)."""
assert is_unlimited("ghost_unknown_xyz") is False
"""Неизвестный пользователь → False (KeyError трактуется как limited), БЕЗ
похода в БД."""
db = MagicMock()
assert is_unlimited(db, "ghost_unknown_xyz") is False
db.execute.assert_not_called()
def test_check_and_raise_admin_not_blocked() -> None:
"""admin с used=15 не получает 429."""
db = _db_with_used(MONTHLY_LIMIT)
# Should not raise
check_and_raise(db, "admin")
db = MagicMock()
check_and_raise(db, "admin") # не должно поднять исключение
db.execute.assert_not_called()
def test_check_and_raise_kopylov_not_blocked() -> None:
"""kopylov с used=100 не получает 429."""
db = _db_with_used(100)
check_and_raise(db, "kopylov")
"""kopylov с used=100 (гипотетически) не получает 429 — is_unlimited гейтит
раньше usage-lookup."""
db = _FakeDB(unlimited=True)
check_and_raise(db, "kopylov") # не должно поднять исключение
def test_check_and_raise_praktika_not_blocked() -> None:
"""praktika (unlimited=true) не получает 429 независимо от used."""
db = _FakeDB(unlimited=True)
check_and_raise(db, "praktika") # не должно поднять исключение
def test_increment_admin_is_noop() -> None:
"""increment для admin → никаких db.execute вызовов."""
db = MagicMock()
increment(db, "admin")
assert increment(db, "admin") is True
db.execute.assert_not_called()
db.commit.assert_not_called()
def test_increment_kopylov_is_noop() -> None:
"""increment для kopylov → no-op."""
db = MagicMock()
increment(db, "kopylov")
db.execute.assert_not_called()
"""increment для kopylov (unlimited=true) → True, БЕЗ UPSERT/commit —
is_unlimited() гейтит раньше инкремента (единственный execute проверка
unlimited-флага, не usage-UPSERT)."""
db = _FakeDB(unlimited=True)
assert increment(db, "kopylov") is True
assert db.commits == 0
assert not any("INSERT INTO account_estimate_usage" in sql for sql, _ in db.execute_calls)
def test_increment_praktika_is_noop() -> None:
"""increment для praktika (unlimited=true) → True, без UPSERT/commit."""
db = _FakeDB(unlimited=True)
assert increment(db, "praktika") is True
assert db.commits == 0
assert not any("INSERT INTO account_estimate_usage" in sql for sql, _ in db.execute_calls)
# ---------------------------------------------------------------------------
@ -120,17 +223,17 @@ def test_increment_kopylov_is_noop() -> None:
def test_check_and_raise_pilot_not_blocked_at_14() -> None:
"""used=14 < 15 → не блокируется."""
db = _db_with_used(14)
db = _FakeDB(used=14)
check_and_raise(db, "user1") # должно пройти без исключения
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
Логика: used >= MONTHLY_LIMIT block. После 15-й успешной оценки
Логика: used >= limit block. После 15-й успешной оценки
increment делает used=15, поэтому следующий запрос (16-й) блокируется.
"""
db = _db_with_used(MONTHLY_LIMIT)
db = _FakeDB(used=MONTHLY_LIMIT)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
@ -141,7 +244,7 @@ def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
def test_check_and_raise_pilot_blocked_exact_detail() -> None:
"""Проверяем точный текст сообщения 429."""
db = _db_with_used(MONTHLY_LIMIT)
db = _FakeDB(used=MONTHLY_LIMIT)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
@ -153,8 +256,8 @@ def test_check_and_raise_pilot_blocked_exact_detail() -> None:
def test_check_and_raise_pilot_blocked_over_limit() -> None:
"""used=20 тоже блокируется."""
db = _db_with_used(20)
"""used=20 тоже блокируется (нет override → глобальный лимит)."""
db = _FakeDB(used=20)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
@ -168,17 +271,17 @@ def test_check_and_raise_pilot_blocked_over_limit() -> None:
def test_increment_pilot_calls_upsert() -> None:
"""increment для pilot → выполняет db.execute (UPSERT) и db.commit."""
db = MagicMock()
increment(db, "user1")
db.execute.assert_called_once()
db.commit.assert_called_once()
"""increment для pilot → is_unlimited-lookup + user_limit-lookup + UPSERT
(3 execute), 1 commit."""
db = _FakeDB(upsert_used=1)
result = increment(db, "user1")
assert result is True
assert len(db.execute_calls) == 3
assert db.commits == 1
# Проверяем что SQL содержит ON CONFLICT ... DO UPDATE
call_args = db.execute.call_args
sql_text = str(call_args[0][0]) # first positional arg — text() object
assert "ON CONFLICT" in sql_text
assert "used" in sql_text
upsert_calls = [sql for sql, _ in db.execute_calls if "ON CONFLICT" in sql]
assert len(upsert_calls) == 1
assert "used" in upsert_calls[0]
def test_increment_none_username_is_noop() -> None:
@ -190,9 +293,9 @@ def test_increment_none_username_is_noop() -> None:
def test_increment_pilot_first_estimate_of_month() -> None:
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
db = MagicMock()
db = _FakeDB(upsert_used=1)
increment(db, "user5")
db.execute.assert_called_once()
assert len(db.execute_calls) == 3
# ---------------------------------------------------------------------------
@ -212,17 +315,29 @@ def test_get_status_none_username() -> None:
def test_get_status_admin() -> None:
"""admin → unlimited, remaining=15 вне зависимости от used."""
db = _db_with_used(7)
"""admin → unlimited, remaining=limit вне зависимости от used."""
db = _FakeDB(used=7)
status = get_status(db, "admin")
assert status["unlimited"] is True
assert status["remaining"] == MONTHLY_LIMIT
assert status["used"] == 7 # фактический used из БД
def test_get_status_praktika_unlimited() -> None:
"""praktika (unlimited=true) → unlimited=True, remaining=limit, без деления
на ноль и без «Осталось N из 0» (limit берётся из user_limit(), не 0)."""
db = _FakeDB(unlimited=True, override_limit=999_999, used=42)
status = get_status(db, "praktika")
assert status["unlimited"] is True
assert status["limit"] == 999_999
assert status["remaining"] == 999_999
assert status["used"] == 42
assert status["limit"] > 0 # защита от «N из 0»
def test_get_status_pilot_with_used() -> None:
"""pilot с used=10 → remaining=5."""
db = _db_with_used(10)
db = _FakeDB(used=10)
status = get_status(db, "user2")
assert status["unlimited"] is False
assert status["used"] == 10
@ -232,7 +347,7 @@ def test_get_status_pilot_with_used() -> None:
def test_get_status_pilot_no_row_yet() -> None:
"""Новый месяц — строки нет → used=0, remaining=15."""
db = _db_no_row()
db = _FakeDB()
status = get_status(db, "user3")
assert status["used"] == 0
assert status["remaining"] == MONTHLY_LIMIT
@ -241,7 +356,7 @@ def test_get_status_pilot_no_row_yet() -> None:
def test_get_status_pilot_exhausted() -> None:
"""used=15 → remaining=0."""
db = _db_with_used(MONTHLY_LIMIT)
db = _FakeDB(used=MONTHLY_LIMIT)
status = get_status(db, "user4")
assert status["remaining"] == 0
assert status["unlimited"] is False
@ -249,7 +364,7 @@ def test_get_status_pilot_exhausted() -> None:
def test_get_status_pilot_over_limit_remaining_zero() -> None:
"""used=20 → remaining=0 (не отрицательное)."""
db = _db_with_used(20)
db = _FakeDB(used=20)
status = get_status(db, "user5")
assert status["remaining"] == 0
@ -281,7 +396,7 @@ def quota_app() -> FastAPI:
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _db_no_row()
yield _FakeDB()
application.dependency_overrides[get_db] = _override_db
return application
@ -324,6 +439,39 @@ def test_quota_endpoint_admin_unlimited(quota_app: FastAPI) -> None:
assert data["unlimited"] is True
@pytest.fixture()
def quota_app_praktika_unlimited() -> FastAPI:
"""FastAPI app где БД отдаёт unlimited=true для praktika."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _FakeDB(unlimited=True, override_limit=999_999, used=100)
application.dependency_overrides[get_db] = _override_db
return application
def test_quota_endpoint_praktika_unlimited(quota_app_praktika_unlimited: FastAPI) -> None:
"""GET /quota с praktika (unlimited grant) → unlimited=True, осмысленный
(не нулевой) limit/remaining фронт (page.tsx) всё равно скрывает эти числа
при unlimited=True, но backend не должен отдавать «0 из 0»."""
client = TestClient(quota_app_praktika_unlimited)
resp = client.get(
"/api/v1/trade-in/quota",
headers={"X-Authenticated-User": "praktika"},
)
assert resp.status_code == 200
data = resp.json()
assert data["unlimited"] is True
assert data["limit"] > 0
assert data["remaining"] > 0
assert data["used"] == 100
# ---------------------------------------------------------------------------
# Integration: POST /estimate quota enforcement через TestClient
# ---------------------------------------------------------------------------
@ -339,7 +487,7 @@ def estimate_app_exhausted() -> FastAPI:
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _db_with_used(MONTHLY_LIMIT)
yield _FakeDB(used=MONTHLY_LIMIT)
application.dependency_overrides[get_db] = _override_db
return application
@ -391,23 +539,45 @@ def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None
assert resp.status_code != 429
@pytest.fixture()
def estimate_app_praktika_unlimited() -> FastAPI:
"""FastAPI app где БД отдаёт unlimited=true для praktika (used заведомо
«за пределами» обычного лимита проверяем что это НЕ блокирует)."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _FakeDB(unlimited=True, override_limit=999_999, used=MONTHLY_LIMIT + 500)
application.dependency_overrides[get_db] = _override_db
return application
def test_estimate_praktika_not_blocked_429(estimate_app_praktika_unlimited: FastAPI) -> None:
"""POST /estimate с praktika (unlimited=true, used far above обычного лимита)
НЕ 429 восстановленный пилот с безлимитным грантом не упирается в квоту."""
client = TestClient(estimate_app_praktika_unlimited, raise_server_exceptions=False)
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "praktika"},
)
assert resp.status_code != 429
# ---------------------------------------------------------------------------
# (f) #747 — атомарно-условный increment (TOCTOU fix)
# ---------------------------------------------------------------------------
class _Row:
"""Row stand-in поддерживающий и .used, и индексный доступ row[0]."""
def __init__(self, used: int) -> None:
self.used = used
self._t = (used,)
def __getitem__(self, i: int) -> int:
return self._t[i]
class _Result:
class _AtomicResult:
def __init__(self, row: _Row | None) -> None:
self._row = row
@ -416,28 +586,54 @@ class _Result:
class _AtomicQuotaFakeDB:
"""In-memory fake, моделирующий атомарный conditional UPSERT (#747).
"""In-memory fake, воспроизводящий:
- is_unlimited(): SELECT unlimited FROM account_quota_overrides ->
unlimited_override (None строки нет not unlimited);
- user_limit(): SELECT monthly_limit FROM account_quota_overrides ->
override_limit (None глобальный MONTHLY_LIMIT);
- increment(): атомарный conditional UPSERT (#747) — WHERE used < :lim в SQL,
эмулируется через params["lim"].
Воспроизводит семантику `INSERT ... ON CONFLICT DO UPDATE SET used=used+1
WHERE used < :lim RETURNING used`:
- строки ещё нет (used=None) INSERT used=1, RETURNING row (WHERE не для INSERT);
- used < lim used+=1, RETURNING row;
- used >= lim конфликтная строка НЕ обновлена, RETURNING пуст (None).
Роутинг по ТЕКСТУ SQL (не по наличию "lim" в params) обе override-lookup
query (is_unlimited и user_limit) не содержат "lim" в params, поэтому их нужно
различать по содержимому запроса, а не по форме params.
"""
def __init__(self, *, used: int | None) -> None:
def __init__(
self,
*,
used: int | None,
override_limit: int | None = None,
unlimited_override: bool | None = None,
) -> None:
self.used = used
self.override_limit = override_limit
self.unlimited_override = unlimited_override
self.commits = 0
def execute(self, _stmt: object, params: dict) -> _Result:
def execute(self, stmt: object, params: dict) -> _AtomicResult:
sql = str(stmt)
if "SELECT unlimited FROM account_quota_overrides" in sql:
row = (
_Row(unlimited=self.unlimited_override)
if self.unlimited_override is not None
else None
)
return _AtomicResult(row)
if "SELECT monthly_limit FROM account_quota_overrides" in sql:
row = (
_Row(monthly_limit=self.override_limit) if self.override_limit is not None else None
)
return _AtomicResult(row)
# UPSERT — WHERE used < :lim
lim = params["lim"]
if self.used is None:
self.used = 1
elif self.used < lim:
self.used += 1
else:
return _Result(None)
return _Result(_Row(self.used))
return _AtomicResult(None)
return _AtomicResult(_Row(used=self.used))
def commit(self) -> None:
self.commits += 1
@ -472,3 +668,219 @@ def test_increment_atomic_unlimited_noop_returns_true() -> None:
assert increment(db, "admin") is True
assert db.commits == 0
assert db.used == MONTHLY_LIMIT # не тронут
def test_increment_atomic_respects_override_limit() -> None:
"""override=50 (user2): used=49 → increment True (used→50); used=50 → False.
Глобальный MONTHLY_LIMIT (15) НЕ применяется используется персональный override."""
db = _AtomicQuotaFakeDB(used=49, override_limit=50)
assert increment(db, "user2") is True
assert db.used == 50
assert increment(db, "user2") is False
assert db.used == 50 # не 51
def test_increment_atomic_override_below_global_blocks_early() -> None:
"""override=5 (ниже глобального 15): used=5 уже блокирует, хотя < MONTHLY_LIMIT.
Демонстрирует что per-user override заменяет глобальный лимит полностью
не является дополнительным потолком поверх него. username вымышленный (не в
roles.yaml) is_unlimited() шорткатится на KeyError без похода в БД.
"""
db = _AtomicQuotaFakeDB(used=5, override_limit=5)
assert increment(db, "user_low_override") is False
assert db.used == 5 # не выросло
def test_increment_atomic_kopylov_unlimited_bypasses_upsert() -> None:
"""kopylov (unlimited=true через account_quota_overrides) — increment() True
без похода в UPSERT-ветку, used в фейке не растёт."""
db = _AtomicQuotaFakeDB(used=MONTHLY_LIMIT, unlimited_override=True)
assert increment(db, "kopylov") is True
assert db.commits == 0
assert db.used == MONTHLY_LIMIT # не тронут — is_unlimited гейтит раньше UPSERT
# ---------------------------------------------------------------------------
# (g) account_quota_overrides.monthly_limit — персональный лимит (замена
# negative-used хака)
# ---------------------------------------------------------------------------
def test_user_limit_no_override_returns_global() -> None:
"""Нет строки в account_quota_overrides → user_limit() возвращает MONTHLY_LIMIT."""
db = MagicMock()
db.execute.return_value = _override_result(None)
assert user_limit(db, "user1") == MONTHLY_LIMIT
def test_user_limit_with_override_returns_override() -> None:
"""Есть строка override → user_limit() возвращает monthly_limit из неё."""
db = MagicMock()
db.execute.return_value = _override_result(50)
assert user_limit(db, "user2") == 50
def test_get_status_override_limit() -> None:
"""user2 с override=50, used=0 (после сброса хака) → limit=50, remaining=50."""
db = _FakeDB(used=0, override_limit=50)
status = get_status(db, "user2")
assert status["limit"] == 50
assert status["remaining"] == 50
assert status["used"] == 0
def test_get_status_override_remaining_clamped_even_if_used_negative() -> None:
"""Кламп: даже если used снова просочится отрицательным (regression прежнего
negative-used хака), remaining НЕ превышает limit не «Осталось 50 из 15»."""
db = _FakeDB(used=-35, override_limit=50)
status = get_status(db, "user2")
assert status["limit"] == 50
assert status["used"] == -35 # raw used не скрываем — диагностическая честность
assert status["remaining"] == 50 # clamp: max(0, 50 - max(0, -35)) == 50
assert status["remaining"] <= status["limit"]
def test_check_and_raise_override_blocks_below_global_limit() -> None:
"""override=5 (ниже глобального 15) — used=5 блокируется, хотя < MONTHLY_LIMIT.
username вымышленный (не в roles.yaml) is_unlimited() KeyError-шорткат."""
db = _FakeDB(used=5, override_limit=5)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user_low_override")
assert exc_info.value.status_code == 429
def test_check_and_raise_override_allows_above_global_limit() -> None:
"""override=50 — used=20 (> глобального 15) НЕ блокируется."""
db = _FakeDB(used=20, override_limit=50)
check_and_raise(db, "user2") # не должно поднять исключение
def test_increment_override_blocks_at_override_not_global() -> None:
"""increment уважает per-user override: used>=override → False, даже если
used < MONTHLY_LIMIT (15)."""
db = _FakeDB(override_limit=5, upsert_used=None) # WHERE used<5 не матчит
assert increment(db, "user_low_override") is False
def test_increment_override_allows_above_global_limit() -> None:
"""increment с override=50: used=20 (>15 глобального) успешно инкрементит."""
db = _FakeDB(override_limit=50, upsert_used=21)
assert increment(db, "user2") is True
# ---------------------------------------------------------------------------
# (h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
# ---------------------------------------------------------------------------
def _insufficient_estimate() -> AggregatedEstimate:
"""Пустой результат — как _empty_estimate() в estimator.py: median=0 → #697
computed_field insufficient_data=True."""
return AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=0,
range_low_rub=0,
range_high_rub=0,
median_price_per_m2=0,
confidence="low",
confidence_explanation="address_not_geocoded",
n_analogs=0,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
)
def _real_estimate() -> AggregatedEstimate:
"""Непустой результат — insufficient_data=False (median_price_rub > 0)."""
return AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=5_000_000,
range_low_rub=4_500_000,
range_high_rub=5_500_000,
median_price_per_m2=100_000,
confidence="medium",
n_analogs=8,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
)
@pytest.fixture()
def estimate_app_ok() -> FastAPI:
"""FastAPI app где check_and_raise проходит (used=0, без override) —
estimate_quality мокается отдельно в каждом тесте."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _FakeDB()
application.dependency_overrides[get_db] = _override_db
return application
def test_estimate_insufficient_data_does_not_increment_quota(
estimate_app_ok: FastAPI,
) -> None:
"""POST /estimate с insufficient_data=True результатом (нерезолвящийся адрес) →
account_quota.increment НЕ вызывается (пустой результат не списывает платный слот)."""
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
with (
patch(
"app.services.estimator.estimate_quality",
new=AsyncMock(return_value=_insufficient_estimate()),
),
patch("app.services.account_quota.increment") as mock_increment,
):
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, несуществующий адрес xyz",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 200
assert resp.json()["median_price_rub"] == 0
assert resp.json()["insufficient_data"] is True
mock_increment.assert_not_called()
def test_estimate_real_result_still_increments_quota(estimate_app_ok: FastAPI) -> None:
"""Контрольный тест: непустой результат (insufficient_data=False) — increment
вызывается как обычно (регресс-guard, не ломаем #747 TOCTOU-семантику)."""
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
with (
patch(
"app.services.estimator.estimate_quality",
new=AsyncMock(return_value=_real_estimate()),
),
patch("app.services.account_quota.increment", return_value=True) as mock_increment,
):
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 200
assert resp.json()["insufficient_data"] is False
mock_increment.assert_called_once()

View file

@ -113,6 +113,22 @@ def test_rederivation_cte_blocks_match_080() -> None:
assert f"{cte} AS" in _REDERIVE_SQL, f"missing CTE {cte!r}"
def test_rederivation_scopes_sold_side_to_asking_city() -> None:
"""#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).
Миграция 177 залила ДКП по всей обл.66, а asking (listings) только ЕКБ. Без скоупа
sold-медиана смешивала дешёвую область ratio 0.8770.62, «выкупная» 29%. Оба
deal-CTE (per-rooms + global) должны нести предикат; ask-стороны НЕ трогаем.
"""
assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
# Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения.
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
# ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city).
_a = _REDERIVE_SQL.index("ask_side AS")
_b = _REDERIVE_SQL.index("per_bucket AS")
assert "asking_city" not in _REDERIVE_SQL[_a:_b]
def _strip_sql(s: str) -> str:
"""Drop -- line comments and collapse whitespace — leaves only the executable SQL.
@ -159,9 +175,14 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
s,
)
assert _strip_sql(_normalise_ppm2(_drop_segment_guard(seed_derivation))) in _strip_sql(
_normalise_ppm2(_drop_segment_guard(_REDERIVE_SQL))
)
def _drop_city_guard(s: str) -> str:
"""Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed)."""
return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
def _norm(s: str) -> str:
return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))
assert _norm(seed_derivation) in _norm(_REDERIVE_SQL)
# ── Counters query ────────────────────────────────────────────────────────────

View file

@ -0,0 +1,344 @@
"""Tests for app.api.v1.audit — admin read API over `user_events` (Feature 2/3).
Coverage:
(a) static SQL guard CAST(:x AS type), never `:x::type` (psycopg v3 trap).
(b) GET /audit/accounts empty table [] (never errors); populated shape-valid
AccountSummary rows pass through response_model unchanged.
(c) GET /audit/accounts/{username} empty table (incl. unknown username, NOT 404)
all 4 lists == []; populated shape-valid drilldown rows.
(d) GET /analytics empty table zeroed summary + empty lists (never errors);
populated shape-valid dashboard bundle, `days` query param clamped [1, 365].
(e) optional real-Postgres round trip inserts a couple of user_events rows and
asserts aggregation through the real DB; self-SKIPS without a reachable,
non-placeholder Postgres (mirrors tests/test_house_dedup_merge.py's live-DB
pattern).
Router is tested in isolation on a minimal FastAPI app (mirrors tests/test_ratelimit.py
and the quota_app fixture in tests/test_user_events.py) no need to pull in the full
app.main (sentry/scheduler/CORS/rate-limit wiring).
"""
from __future__ import annotations
import inspect
import os
import re
import uuid
from datetime import UTC, date, datetime
from typing import Any
# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import audit as audit_module
from app.core.db import get_db
# ---------------------------------------------------------------------------
# (a) Static SQL guard
# ---------------------------------------------------------------------------
_AUDIT_SRC = inspect.getsource(audit_module)
def test_no_psycopg_v3_doublecolon_cast() -> None:
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
assert not re.search(r":\w+::\w", _AUDIT_SRC)
def test_days_param_uses_cast_as_int() -> None:
assert "CAST(:days AS int)" in _AUDIT_SRC
# ---------------------------------------------------------------------------
# Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc.
# ---------------------------------------------------------------------------
class _FakeMappingResult:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def all(self) -> list[dict[str, Any]]:
return list(self._rows)
def one(self) -> dict[str, Any]:
return self._rows[0]
class _FakeExecResult:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def mappings(self) -> _FakeMappingResult:
return _FakeMappingResult(self._rows)
class _FakeSession:
"""Returns queued row-lists in the exact order audit.py issues db.execute() calls."""
def __init__(self, responses: list[list[dict[str, Any]]]) -> None:
self._responses = list(responses)
self._i = 0
def execute(self, _stmt: object, _params: dict[str, Any] | None = None) -> _FakeExecResult:
rows = self._responses[self._i]
self._i += 1
return _FakeExecResult(rows)
def _make_app(responses: list[list[dict[str, Any]]]) -> FastAPI:
application = FastAPI()
application.include_router(audit_module.router, prefix="/api/v1/admin")
def _override_db() -> Any:
yield _FakeSession(responses)
application.dependency_overrides[get_db] = _override_db
return application
_NOW = datetime(2026, 7, 13, 12, 0, 0, tzinfo=UTC)
# ---------------------------------------------------------------------------
# (b) GET /audit/accounts
# ---------------------------------------------------------------------------
def test_list_accounts_empty_table() -> None:
app = _make_app(responses=[[]])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts")
assert resp.status_code == 200
assert resp.json() == []
def test_list_accounts_populated_shape() -> None:
row = {
"username": "user1",
"first_seen_at": _NOW,
"last_seen_at": _NOW,
"distinct_ips": 3,
"distinct_devices": 2,
"login_count": 5,
"request_count": 40,
"search_count": 7,
}
app = _make_app(responses=[[row]])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["username"] == "user1"
assert data[0]["distinct_ips"] == 3
assert data[0]["login_count"] == 5
assert data[0]["request_count"] == 40
assert data[0]["search_count"] == 7
# ---------------------------------------------------------------------------
# (c) GET /audit/accounts/{username}
# ---------------------------------------------------------------------------
def test_account_drilldown_empty_table_unknown_user_not_404() -> None:
"""Unknown username → 200 with all 4 lists empty, NOT a 404."""
app = _make_app(responses=[[], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts/ghost_user_xyz")
assert resp.status_code == 200
data = resp.json()
assert data == {"ips": [], "devices": [], "searches": [], "recent_activity": []}
def test_account_drilldown_populated_shape() -> None:
ips = [{"ip_address": "1.2.3.4", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}]
devices = [
{"user_agent": "pytest-agent", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}
]
searches = [
{
"address": "ул. Малышева, 1",
"area_m2": "50.0",
"rooms": "2",
"estimate_id": str(uuid.uuid4()),
"ip_address": "1.2.3.4",
"created_at": _NOW,
}
]
recent_activity = [
{
"event_type": "api_request",
"path": "/api/v1/trade-in/estimate",
"method": "POST",
"ip_address": "1.2.3.4",
"created_at": _NOW,
}
]
app = _make_app(responses=[ips, devices, searches, recent_activity])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts/user1")
assert resp.status_code == 200
data = resp.json()
assert data["ips"][0]["ip_address"] == "1.2.3.4"
assert data["ips"][0]["event_count"] == 10
assert data["devices"][0]["user_agent"] == "pytest-agent"
assert data["searches"][0]["address"] == "ул. Малышева, 1"
assert data["searches"][0]["area_m2"] == "50.0"
assert data["recent_activity"][0]["event_type"] == "api_request"
assert data["recent_activity"][0]["path"] == "/api/v1/trade-in/estimate"
# ---------------------------------------------------------------------------
# (d) GET /analytics
# ---------------------------------------------------------------------------
_ZERO_SUMMARY = {
"total_events": 0,
"distinct_users": 0,
"events_last_24h": 0,
"active_users_last_24h": 0,
}
def test_analytics_empty_table_never_errors() -> None:
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics")
assert resp.status_code == 200
data = resp.json()
assert data["summary"] == _ZERO_SUMMARY
assert data["daily"] == []
assert data["top_searches"] == []
assert data["top_paths"] == []
assert data["by_account"] == []
def test_analytics_populated_shape() -> None:
summary = {
"total_events": 120,
"distinct_users": 4,
"events_last_24h": 15,
"active_users_last_24h": 2,
}
daily = [{"day": date(2026, 7, 13), "events": 15, "users": 2}]
top_searches = [{"address": "ул. Малышева, 1", "n": 3}]
top_paths = [{"path": "/api/v1/trade-in/estimate", "n": 40}]
by_account = [{"username": "user1", "events": 60, "searches": 7, "last_seen": _NOW}]
app = _make_app(responses=[[summary], daily, top_searches, top_paths, by_account])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics")
assert resp.status_code == 200
data = resp.json()
assert data["summary"]["total_events"] == 120
assert data["daily"][0]["events"] == 15
assert data["daily"][0]["day"] == "2026-07-13"
assert data["top_searches"][0]["address"] == "ул. Малышева, 1"
assert data["top_paths"][0]["n"] == 40
assert data["by_account"][0]["username"] == "user1"
def test_analytics_days_query_param_clamped() -> None:
"""days=0 (below ge=1) → 422; days=9999 (above le=365) → 422."""
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
assert client.get("/api/v1/admin/analytics?days=0").status_code == 422
assert client.get("/api/v1/admin/analytics?days=9999").status_code == 422
def test_analytics_days_query_param_default_30() -> None:
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics")
assert resp.status_code == 200
def test_analytics_days_query_param_accepted_in_range() -> None:
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics?days=7")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# (e) Optional real-Postgres round trip (self-skips without a reachable DB)
# ---------------------------------------------------------------------------
def _live_session() -> Any | None:
"""Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None."""
try:
from sqlalchemy import create_engine
from sqlalchemy import text as _t
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(_t("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_accounts_and_analytics_aggregate_inserted_rows() -> None:
"""End-to-end on a real DB: insert 2 user_events rows for a throwaway test username,
assert /audit/accounts, /audit/accounts/{username} and /analytics reflect them."""
from sqlalchemy import text as _t
from app.core.db import SessionLocal
db = _live_session()
assert db is not None
username = f"audit_test_{uuid.uuid4().hex[:8]}"
try:
db.execute(
_t(
"""
INSERT INTO user_events
(event_type, username, ip_address, user_agent, path, method, payload)
VALUES
('login', :u, CAST('9.9.9.9' AS inet), 'pytest-ua', NULL, NULL,
CAST('{}' AS jsonb)),
('estimate_request', :u, CAST('9.9.9.9' AS inet), 'pytest-ua',
'/api/v1/trade-in/estimate', 'POST',
CAST(:payload AS jsonb))
"""
),
{"u": username, "payload": '{"address": "ул. Тестовая, 1", "area_m2": "42.0"}'},
)
db.commit()
application = FastAPI()
application.include_router(audit_module.router, prefix="/api/v1/admin")
def _override_db() -> Any:
yield SessionLocal()
application.dependency_overrides[get_db] = _override_db
client = TestClient(application)
accounts = client.get("/api/v1/admin/audit/accounts").json()
row = next(r for r in accounts if r["username"] == username)
assert row["login_count"] == 1
assert row["search_count"] == 1
drilldown = client.get(f"/api/v1/admin/audit/accounts/{username}").json()
assert len(drilldown["searches"]) == 1
assert drilldown["searches"][0]["address"] == "ул. Тестовая, 1"
assert len(drilldown["recent_activity"]) == 2
dashboard = client.get("/api/v1/admin/analytics?days=1").json()
assert dashboard["summary"]["total_events"] >= 2
finally:
db.rollback()
db.execute(_t("DELETE FROM user_events WHERE username = :u"), {"u": username})
db.commit()

View file

@ -0,0 +1,94 @@
"""Audit-scrapers finding 3: Avito detail publish_date year-boundary rollover.
Avito не показывает год для дат текущего года («20 декабря в 15:30»). Раньше
`_extract_meta` всегда брал ТЕКУЩИЙ год момента парсинга объявлению, опубликованному
в декабре и прочитанному в январе следующего года, ставился год парсинга (будущая
дата), завышая свежесть лота. Фикс: если получившаяся дата оказалась в будущем
относительно момента парсинга откатываем на год назад.
Refs: audit-scrapers 2026-07-26, finding 3 (low).
"""
from __future__ import annotations
import os
from datetime import date as real_date
import pytest
from selectolax.parser import HTMLParser
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.avito import detail as kit_detail
def _freeze_today(monkeypatch: pytest.MonkeyPatch, frozen: real_date) -> None:
"""Подменяет `date` в scraper_kit.providers.avito.detail так, что date.today()
детерминированно возвращает `frozen` (date immutable C-тип, .today нельзя
monkeypatch'нуть напрямую — подменяем ссылку на класс в модуле)."""
class _FrozenDate(real_date):
@classmethod
def today(cls) -> real_date: # type: ignore[override]
return frozen
monkeypatch.setattr(kit_detail, "date", _FrozenDate)
def _tree_with_publish_text(text: str) -> HTMLParser:
html = f'<html><body><div data-marker="item-view/item-id">{text}</div></body></html>'
return HTMLParser(html)
def test_december_publish_date_read_in_january_rolls_back_a_year(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Объявление '20 декабря' парсится 5 января СЛЕДУЮЩЕГО года: без фикса
дата была бы 2027-12-20 (в будущем относительно today=2027-01-05) теперь
откатывается на 2026-12-20."""
_freeze_today(monkeypatch, real_date(2027, 1, 5))
tree = _tree_with_publish_text("№ 4291500000 · 20 декабря в 15:30")
publish_date, _, _ = kit_detail._extract_meta(tree)
assert publish_date == real_date(2026, 12, 20)
def test_same_year_past_publish_date_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
"""Control: дата в прошлом (не будущем) в том же году — год НЕ откатывается."""
_freeze_today(monkeypatch, real_date(2027, 1, 5))
tree = _tree_with_publish_text("№ 4291500001 · 3 января в 09:00")
publish_date, _, _ = kit_detail._extract_meta(tree)
assert publish_date == real_date(2027, 1, 3)
def test_publish_date_equal_to_today_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
"""Control: дата ровно = today (не строго будущее) — год НЕ откатывается."""
_freeze_today(monkeypatch, real_date(2027, 1, 5))
tree = _tree_with_publish_text("№ 4291500002 · 5 января в 12:00")
publish_date, _, _ = kit_detail._extract_meta(tree)
assert publish_date == real_date(2027, 1, 5)
def test_mid_year_publish_date_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
"""Обычный случай вдали от границы года — поведение не меняется."""
_freeze_today(monkeypatch, real_date(2027, 6, 15))
tree = _tree_with_publish_text("№ 4291500003 · 20 марта в 10:00")
publish_date, _, _ = kit_detail._extract_meta(tree)
assert publish_date == real_date(2027, 3, 20)
def test_no_publish_date_in_text_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""Regression guard: отсутствие даты в тексте по-прежнему даёт None (не падает)."""
_freeze_today(monkeypatch, real_date(2027, 1, 5))
tree = _tree_with_publish_text("№ 4291500004")
publish_date, _, _ = kit_detail._extract_meta(tree)
assert publish_date is None

View file

@ -0,0 +1,84 @@
"""Avito SERP city-slug filter (#2487 oblast rollout).
`_parse_html` drops padding cards ("по всей России") when `avito_serp_ekb_only`
is on. The kept city-slug used to be hardcoded `/ekaterinburg/`, so an oblast
per-city sweep (`_job_avito_city_sweep` with a `city` param) discarded 100% of the
target city's cards. The slug is now parameterized via `AvitoScraper.target_city_slug`
(default "ekaterinburg", i.e. ЕКБ behavior unchanged when no target is set).
No network / no DB `_parse_html` runs on inline HTML with a stub config.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.avito.serp import AvitoScraper
_BASE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?p=1"
def _card(slug: str, item_id: str, price: int = 5_000_000) -> str:
"""Minimal Avito SERP card whose per-card href carries `slug` as the city segment."""
href = f"/{slug}/kvartiry/2k_kvartira_50_m_5_5et_{item_id}"
return (
f'<div data-marker="item" data-item-id="{item_id}">'
f'<a data-marker="item-title" href="{href}">2-к. квартира, 50 м², 5/9 эт.</a>'
f'<meta itemprop="price" content="{price}">'
f"</div>"
)
def _html(*cards: str) -> str:
return "<html><body>" + "".join(cards) + "</body></html>"
def _ekb_only_cfg() -> SimpleNamespace:
# _parse_html only reads config.avito_serp_ekb_only; a stub keeps the test
# deterministic regardless of AVITO_SERP_EKB_ONLY env in the runner.
return SimpleNamespace(avito_serp_ekb_only=True)
def test_parse_keeps_target_oblast_city_drops_off_target_padding() -> None:
"""Sweep targeting Н.Тагил keeps its cards; ЕКБ/Москва padding is dropped."""
s = AvitoScraper(_ekb_only_cfg(), target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
html = _html(
_card("nizhniy_tagil", "nt1"), # target city → KEPT
_card("ekaterinburg", "ekb1"), # off-target padding → DROPPED
_card("moskva", "msk1"), # off-target padding → DROPPED
)
lots = s._parse_html(html, source_url_base=_BASE_URL)
urls = [lot.source_url or "" for lot in lots]
assert len(lots) == 1, urls
assert "/nizhniy_tagil/" in urls[0]
assert all("/ekaterinburg/" not in u for u in urls)
assert all("/moskva/" not in u for u in urls)
def test_parse_default_target_is_ekb_and_drops_oblast_padding() -> None:
"""No target (ЕКБ sweep/full-load): kept slug defaults to 'ekaterinburg' — behavior
identical to the previous hardcode. ЕКБ card kept, oblast padding dropped."""
s = AvitoScraper(_ekb_only_cfg(), target_city_slug=None) # type: ignore[arg-type]
html = _html(
_card("ekaterinburg", "e1"), # ЕКБ → KEPT
_card("nizhniy_tagil", "nt1"), # off-target padding → DROPPED
)
lots = s._parse_html(html, source_url_base=_BASE_URL)
urls = [lot.source_url or "" for lot in lots]
assert len(lots) == 1, urls
assert "/ekaterinburg/" in urls[0]
assert all("/nizhniy_tagil/" not in u for u in urls)
def test_parse_ekb_only_disabled_keeps_all_cities() -> None:
"""avito_serp_ekb_only=False → no city filter at all, every card is kept."""
cfg = SimpleNamespace(avito_serp_ekb_only=False)
s = AvitoScraper(cfg, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
html = _html(_card("nizhniy_tagil", "nt1"), _card("ekaterinburg", "e1"))
lots = s._parse_html(html, source_url_base=_BASE_URL)
assert len(lots) == 2

View file

@ -0,0 +1,206 @@
"""Audit-scrapers finding 1: Avito citywide/byrooms/exhaustive sweep DOM-drift detection.
Раньше 0 карточек на page=1 (обход всего города / категории комнатности / ценового
бакета exhaustive-сбора) молча трактовалось как «объявлений действительно нет»
неотличимо от content-block/captcha или дрейфа DOM-маркера карточки (`data-marker=
"item-*"`). Фикс переиспользует существующий механизм `AvitoContentBlockedError`
(см. `fetch_around`, #754/#779) + новый `_is_unexpected_empty_page()` — независимый
сигнал `_extract_total_count` (счётчик `page-title/count` либо no-results маркер):
- page=1, 0 карточек, НЕТ no-results маркера/счётчика аномалия raise.
- page=1, 0 карточек, ЕСТЬ no-results маркер (total=0) валидная пустая выборка.
- page>1, 0 карточек всегда graceful end-of-pagination (не regressed).
- exhaustive leaf-бакет: probe независимо утверждал total>0, но после пагинации
всех страниц собрано 0 карточек аномалия raise (даже без per-page проверки
внутри _paginate_leaf_bucket, т.к. там нет break-on-empty цикла).
Refs: audit-scrapers 2026-07-26, finding 1 (medium).
"""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.avito_exceptions import AvitoContentBlockedError
from scraper_kit.base import ScrapedLot
from scraper_kit.providers.avito.serp import ROOM_SLUGS, AvitoScraper
from app.services.scraper_adapters import RealScraperConfig
# HTML "успешно получен, разумного размера", но БЕЗ data-marker="item-*" карточек
# И без no-results маркера/счётчика — неотличимо от content-block/DOM-drift.
_NO_MARKER_HTML = "<html><body>" + ("x" * 500) + "</body></html>"
# Валидная пустая выборка: no-results маркер присутствует (_AVITO_NO_RESULTS_MARKERS).
_NO_RESULTS_HTML = (
"<html><body>По вашему запросу ничего не найдено. Попробуйте изменить фильтры."
+ ("y" * 200)
+ "</body></html>"
)
# Firewall/captcha-страница (переиспользуем существующий fixture-паттерн из #754) —
# используется только для проверки, что page>1 остаётся graceful независимо от
# содержимого (проверка применяется ТОЛЬКО к page==1).
_BLOCKPAGE_HTML = "<html><body><h1>Доступ ограничен</h1></body></html>"
def _make_lot(source_id: str) -> ScrapedLot:
return ScrapedLot(
source="avito",
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}",
source_id=source_id,
price_rub=6_000_000,
)
# ── fetch_city_wide (_paginate_sweep) ────────────────────────────────────────
@pytest.mark.asyncio
async def test_citywide_page1_zero_cards_no_marker_raises() -> None:
s = AvitoScraper(RealScraperConfig())
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
with pytest.raises(AvitoContentBlockedError):
await s.fetch_city_wide(pages=5, delay_override_sec=0)
@pytest.mark.asyncio
async def test_citywide_page1_zero_cards_with_no_results_marker_is_valid_empty() -> None:
s = AvitoScraper(RealScraperConfig())
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
assert result == []
@pytest.mark.asyncio
async def test_citywide_page_gt1_zero_cards_stays_graceful() -> None:
"""page=1 реально возвращает карточки (mock _parse_html) — page=2 пустой
firewall-текст без карточек НЕ должен поднимать исключение (только page==1)."""
s = AvitoScraper(RealScraperConfig())
call_n = 0
async def _fetch(url: str, page: int) -> str:
return "<html>page1</html>" if page == 1 else _BLOCKPAGE_HTML
def _parse(html: str, source_url_base: str) -> list[ScrapedLot]:
nonlocal call_n
call_n += 1
return [_make_lot("A"), _make_lot("B")] if call_n == 1 else []
with patch.object(s, "_fetch_serp_html", AsyncMock(side_effect=_fetch)):
with patch.object(s, "_parse_html", side_effect=_parse):
with patch.object(s, "sleep_between_requests", AsyncMock(return_value=None)):
result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
assert len(result) == 2
assert call_n == 2 # page1(2 lots) + page2(0 lots) → stop, no raise
# ── fetch_by_rooms ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_byrooms_category_page1_zero_cards_no_marker_raises() -> None:
s = AvitoScraper(RealScraperConfig())
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
with pytest.raises(AvitoContentBlockedError):
await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
@pytest.mark.asyncio
async def test_byrooms_category_page1_zero_cards_with_marker_is_valid_empty() -> None:
s = AvitoScraper(RealScraperConfig())
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
result = await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
assert result == []
# ── _paginate_leaf_bucket (exhaustive/fetch_all_secondary) ───────────────────
@pytest.mark.asyncio
async def test_leaf_bucket_expected_total_positive_but_zero_parsed_raises() -> None:
"""Probe независимо утверждал total=5 (bucket не может быть легитимно пустым),
но парсинг всех страниц дал 0 карточек DOM-drift, не пустой бакет."""
s = AvitoScraper(RealScraperConfig())
seen: dict[str, ScrapedLot] = {}
with patch.object(s, "_parse_html", return_value=[]):
with pytest.raises(AvitoContentBlockedError):
await s._paginate_leaf_bucket(
room_slug="studii-ASgBAgICAUSSA8YQ",
room_label="studio",
lo=0,
hi=3_000_000,
html="<html>probe-page-1</html>",
max_pages=1,
seen=seen,
price_cap_per_bucket=1400,
max_pages_per_bucket=100,
concurrency=5,
secondary_only=True,
on_bucket=None,
skip_buckets=None,
expected_total=5,
)
assert seen == {}
@pytest.mark.asyncio
async def test_leaf_bucket_expected_total_none_zero_parsed_no_raise() -> None:
"""Probe провалился (expected_total=None, best-effort пагинация) — 0 карточек
здесь НЕ аномалия (мы не знаем, есть ли реально данные в бакете)."""
s = AvitoScraper(RealScraperConfig())
seen: dict[str, ScrapedLot] = {}
with patch.object(s, "_parse_html", return_value=[]):
# Не должно поднимать исключение.
await s._paginate_leaf_bucket(
room_slug="studii-ASgBAgICAUSSA8YQ",
room_label="studio",
lo=0,
hi=3_000_000,
html=None,
max_pages=1,
seen=seen,
price_cap_per_bucket=1400,
max_pages_per_bucket=100,
concurrency=5,
secondary_only=True,
on_bucket=None,
skip_buckets=None,
expected_total=None,
)
assert seen == {}
@pytest.mark.asyncio
async def test_leaf_bucket_expected_total_matches_collected_no_raise() -> None:
"""Нормальный путь: probe total=2, парсинг реально даёт 2 карточки — не аномалия."""
s = AvitoScraper(RealScraperConfig())
seen: dict[str, ScrapedLot] = {}
lots = [_make_lot("L1"), _make_lot("L2")]
with patch.object(s, "_parse_html", return_value=lots):
await s._paginate_leaf_bucket(
room_slug="studii-ASgBAgICAUSSA8YQ",
room_label="studio",
lo=0,
hi=3_000_000,
html="<html>probe-page-1</html>",
max_pages=1,
seen=seen,
price_cap_per_bucket=1400,
max_pages_per_bucket=100,
concurrency=5,
secondary_only=True,
on_bucket=None,
skip_buckets=None,
expected_total=2,
)
assert set(seen.keys()) == {"L1", "L2"}

View file

@ -832,3 +832,177 @@ def test_render_full_table_handles_empty_sample() -> None:
out = bt._render_full_table(m)
assert "n/a" in out # None metrics render as n/a, no crash
assert "BACKTEST" in out
# --------------------------------------------------------------------------- #
# Oblast D — `--city` deal-sample scoping (parse + SQL predicate + PPM2 band).
# --------------------------------------------------------------------------- #
def test_argparse_city_defaults_none() -> None:
assert bt._parse_args([]).city is None
def test_argparse_city_override() -> None:
ns = bt._parse_args(["--city", "Нижний Тагил"])
assert ns.city == "Нижний Тагил"
def test_sample_sql_default_is_unscoped_and_same_object() -> None:
# city=None must return the SAME _SAMPLE_SQL object used before oblast D —
# literal identity, not just equal text — so the default CLI invocation (and
# the frozen EKB regression gate, which never calls this path at all) see a
# byte-identical query.
assert bt._sample_sql(None) is bt._SAMPLE_SQL
def test_sample_sql_default_has_no_city_predicate() -> None:
assert ":city" not in bt._SAMPLE_SQL.text
def test_sample_sql_city_scoped_adds_city_predicate() -> None:
sql = bt._sample_sql("Нижний Тагил")
assert sql is not bt._SAMPLE_SQL
built = sql.text
assert "AND city = CAST(:city AS text)" in built
# City-scoping is additive — the base predicates are still present.
assert "source = 'rosreestr'" in built
assert "deal_date >= CAST(:since AS date)" in built
assert "ORDER BY id DESC" in built
# --------------------------------------------------------------------------- #
# Oblast D — _resolve_city_ppm2_band (per-city PPM2 sanity band, no live DB).
# --------------------------------------------------------------------------- #
class _FakeBandResult:
"""Minimal stand-in for a SQLAlchemy Result exposing .mappings().first()."""
def __init__(self, row: dict[str, object] | None) -> None:
self._row = row
def mappings(self) -> "_FakeBandResult":
return self
def first(self) -> dict[str, object] | None:
return self._row
class _FakeBandSession:
"""Minimal stand-in for a Session — only .execute() is exercised here."""
def __init__(self, row: dict[str, object] | None = None, *, raise_exc: bool = False) -> None:
self._row = row
self._raise = raise_exc
def execute(self, *_args: object, **_kwargs: object) -> _FakeBandResult:
if self._raise:
raise RuntimeError("boom")
return _FakeBandResult(self._row)
def test_resolve_city_ppm2_band_none_city_returns_globals_no_query() -> None:
# city=None must not even touch the DB (no _FakeBandSession.execute call).
class _NoExecuteSession:
def execute(self, *_a: object, **_kw: object) -> None:
raise AssertionError("must not query DB when city is None")
assert bt._resolve_city_ppm2_band(_NoExecuteSession(), None) == (
float(bt.PPM2_MIN),
float(bt.PPM2_MAX),
)
def test_resolve_city_ppm2_band_found_row() -> None:
db = _FakeBandSession({"ppm2_min": 16955, "ppm2_max": 108175})
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (16955.0, 108175.0)
def test_resolve_city_ppm2_band_no_row_falls_back_to_globals() -> None:
db = _FakeBandSession(None)
assert bt._resolve_city_ppm2_band(db, "Екатеринбург") == (
float(bt.PPM2_MIN),
float(bt.PPM2_MAX),
)
def test_resolve_city_ppm2_band_db_error_falls_back_to_globals() -> None:
db = _FakeBandSession(raise_exc=True)
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (
float(bt.PPM2_MIN),
float(bt.PPM2_MAX),
)
# --------------------------------------------------------------------------- #
# Oblast C2 parity — _predict_full_spine must resolve + pass a target city to
# _fetch_dkp_corridor, mirroring estimate_quality (estimator.py:3210-3220).
# --------------------------------------------------------------------------- #
def test_predict_full_spine_passes_resolved_city_to_corridor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from types import SimpleNamespace
from app.services import estimator as est_mod
captured: dict[str, object] = {}
def _fake_resolve_target_city(address: str | None) -> str | None:
return "нижний тагил"
def _fake_fetch_dkp_corridor(
_db: object,
*,
address: object,
rooms: object,
area: object,
city: object = None,
**_kw: object,
) -> None:
captured["called"] = True
captured["city"] = city
return None
def _fake_price_from_inputs(**_kwargs: object) -> SimpleNamespace:
return SimpleNamespace(
median_price=100_000.0,
median_ppm2=100_000.0,
confidence="low",
anchor_tier=None,
expected_sold_per_m2=95_000.0,
expected_sold_price=4_750_000.0,
expected_sold_range_low=4_000_000.0,
expected_sold_range_high=5_500_000.0,
)
monkeypatch.setattr(est_mod, "_resolve_target_city", _fake_resolve_target_city)
monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", _fake_fetch_dkp_corridor)
monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
monkeypatch.setattr(bt, "_select_analogs_full", lambda *a, **kw: ([], "W", False, False))
est = bt._import_estimator_full()
deal = bt.DealSample(
id=1,
lon=60.6,
lat=58.05,
rooms=2,
sold_ppm2=90_000.0,
deal_date=None,
area_m2=50.0,
address="Нижний Тагил, ул. Ленина, 5",
floor=3,
total_floors=9,
year_built=2000,
house_type="панель",
)
pred = bt._predict_full_spine(None, deal, est)
assert captured.get("called") is True
assert captured.get("city") == "нижний тагил"
assert pred is not None

View file

@ -0,0 +1,145 @@
"""Regression test: listings block of cian_history_backfill must rollback on
save_detail_enrichment failure, same as the houses block already does.
Bug (audit finding #1, fix/tradein-audit-backfill-quota): save_detail_enrichment()
runs several unprotected db.execute() calls and only commits at the very end. If
any of them fails (e.g. a malformed `change_time` from Cian hits
`CAST(:ct AS timestamptz)` with a DataError), the session is left in a failed
in-transaction state (PendingRollbackError). Without `db.rollback()` in the except
branch, EVERY subsequent listing in the batch (up to 49 more) raises the same
PendingRollbackError one real failure looks like N independent failures in the
logs, and the batch effectively stops processing after the first bad row.
This test proves: (a) db.rollback() is called after a save failure, (b) the batch
continues past the failing row and still succeeds on the next one the exact
"cascading failure" scenario this fix prevents.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.tasks import cian_history_backfill
class _FakeBrowserFetcher:
def __init__(self, **kwargs: Any) -> None:
pass
async def __aenter__(self) -> _FakeBrowserFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
def _enrichment() -> SimpleNamespace:
return SimpleNamespace(price_changes=[])
async def test_listings_save_failure_rolls_back_session() -> None:
"""save_detail_enrichment raising -> db.rollback() called (session un-poisoned)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = [
{"id": 1, "source_url": "https://cian.ru/1"},
]
with (
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
patch.object(cian_history_backfill, "fetch_detail", AsyncMock(return_value=_enrichment())),
patch.object(
cian_history_backfill,
"save_detail_enrichment",
side_effect=Exception("DataError: invalid timestamptz"),
),
patch("asyncio.sleep", new_callable=AsyncMock),
):
result = await cian_history_backfill.backfill_cian_history(
db, do_listings=True, do_houses=False, do_valuations=False
)
db.rollback.assert_called_once()
assert result.listings_failed_save == 1
assert result.listings_succeeded == 0
async def test_listings_batch_continues_after_one_bad_row() -> None:
"""The exact cascading-failure repro: row 1 fails save, row 2 must still
succeed proves rollback actually un-poisons the session for later rows,
not just that rollback was called."""
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = [
{"id": 1, "source_url": "https://cian.ru/1"},
{"id": 2, "source_url": "https://cian.ru/2"},
]
save_mock = MagicMock(side_effect=[Exception("DataError: invalid timestamptz"), None])
with (
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
patch.object(cian_history_backfill, "fetch_detail", AsyncMock(return_value=_enrichment())),
patch.object(cian_history_backfill, "save_detail_enrichment", save_mock),
patch("asyncio.sleep", new_callable=AsyncMock),
):
result = await cian_history_backfill.backfill_cian_history(
db, do_listings=True, do_houses=False, do_valuations=False
)
assert save_mock.call_count == 2
db.rollback.assert_called_once()
assert result.listings_failed_save == 1
assert result.listings_succeeded == 1
assert result.listings_processed == 2
async def test_listings_rollback_failure_itself_does_not_crash_the_batch() -> None:
"""If db.rollback() ALSO raises (e.g. connection already dropped), the batch
logs a warning and keeps going mirrors the houses block's same guard."""
db = MagicMock()
db.rollback.side_effect = Exception("connection already closed")
db.execute.return_value.mappings.return_value.all.return_value = [
{"id": 1, "source_url": "https://cian.ru/1"},
]
with (
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
patch.object(cian_history_backfill, "fetch_detail", AsyncMock(return_value=_enrichment())),
patch.object(
cian_history_backfill,
"save_detail_enrichment",
side_effect=Exception("DataError: invalid timestamptz"),
),
patch("asyncio.sleep", new_callable=AsyncMock),
):
result = await cian_history_backfill.backfill_cian_history(
db, do_listings=True, do_houses=False, do_valuations=False
)
assert result.listings_failed_save == 1
async def test_listings_no_rollback_on_success() -> None:
"""Successful save must NOT trigger a rollback (would discard the commit
that save_detail_enrichment already made)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = [
{"id": 1, "source_url": "https://cian.ru/1"},
]
with (
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
patch.object(cian_history_backfill, "fetch_detail", AsyncMock(return_value=_enrichment())),
patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()),
patch("asyncio.sleep", new_callable=AsyncMock),
):
result = await cian_history_backfill.backfill_cian_history(
db, do_listings=True, do_houses=False, do_valuations=False
)
db.rollback.assert_not_called()
assert result.listings_succeeded == 1

View file

@ -0,0 +1,106 @@
"""Audit-scrapers finding 2: Cian totalOffers vs results.offers length mismatch.
`_parse_serp_html` извлекает `totalOffers` и `results.offers` из ОДНОГО Redux
state-блоба (одна SSR-выдача). Раньше `results.offers` пустой при `totalOffers>0`
логировался WARNING'ом и тихо возвращался `[]` — не считался schema-regression, не
попадал в мониторинг (`_report_schema_regression`/Glitchtip).
Порог: 0 vs >0 единственный позиционно-независимый сигнал, который можно
проверить без номера страницы внутри `_parse_serp_html` (эта функция не знает,
какая это страница пагинации дробный порог типа "< 50% от totalOffers" ложно
сработал бы на легитимной последней частичной странице exhaustive-пагинации,
которую эта функция не различает). totalOffers=0 (реально пустой поиск) НЕ
считается регрессией.
Refs: audit-scrapers 2026-07-26, finding 2 (low).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.cian.serp import CianScraper
from app.services.scraper_adapters import RealScraperConfig
def _scraper() -> CianScraper:
return CianScraper(RealScraperConfig())
def test_total_offers_positive_but_offers_empty_reports_regression() -> None:
"""totalOffers=5, results.offers=[] — internal contradiction, must report."""
s = _scraper()
state = {"results": {"totalOffers": 5, "offers": []}}
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
assert lots == []
mock_report.assert_called_once()
(msg,), _ = mock_report.call_args
assert "totalOffers=5" in msg
def test_total_offers_zero_and_offers_empty_is_valid_empty_search() -> None:
"""totalOffers=0, offers=[] — легитимная пустая выборка, НЕ регрессия."""
s = _scraper()
state = {"results": {"totalOffers": 0, "offers": []}}
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
assert lots == []
mock_report.assert_not_called()
def test_total_offers_none_and_offers_empty_is_not_reported_as_regression() -> None:
"""totalOffers отсутствует/None в state — недостаточно сигнала для regression-репорта
(могла быть частично битая state-структура без явного totalOffers>0 контр-сигнала)."""
s = _scraper()
state = {"results": {"offers": []}}
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
assert lots == []
mock_report.assert_not_called()
def test_offers_present_normal_path_unaffected() -> None:
"""totalOffers=1, offers содержит 1 запись без cianId/id — не проходит
_offer_to_lot, но это уже существующая (0/N offer-level) охрана, не finding 2."""
s = _scraper()
state = {"results": {"totalOffers": 1, "offers": [{"noId": True}]}}
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
# offers_data непустой → finding 2 guard не участвует; существующая offer-level
# охрана (raw_count>0 and saved_count==0) должна отработать вместо неё.
assert lots == []
mock_report.assert_called_once()
(msg,), _ = mock_report.call_args
assert "_offer_to_lot" in msg
def test_state_none_extraction_failed_no_regression_report() -> None:
"""extract_state вернул None (captcha/структура целиком не найдена) — уже
существующая ветка, НЕ должна триггерить finding-2 regression report."""
s = _scraper()
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=None):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
assert lots == []
mock_report.assert_not_called()
def test_report_schema_regression_swallows_missing_glitchtip_dsn() -> None:
"""_report_schema_regression не должен падать, если glitchtip_dsn не настроен."""
s = _scraper()
s._config = MagicMock(glitchtip_dsn=None)
s._report_schema_regression("test message") # не должно бросить исключение

View file

@ -10,6 +10,8 @@ import pytest
from app.services.cian_session import (
CIAN_REQUIRED_COOKIES,
VERIFY_BAN_SENTINEL,
VERIFY_MARKUP_CHANGED_SENTINEL,
VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
_classify_verify_response,
load_session,
mark_session_invalid,
@ -211,14 +213,40 @@ def test_classify_200_authenticated_returns_state(monkeypatch: pytest.MonkeyPatc
assert result == expected
def test_classify_200_state_missing_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""200 but extract_state returns None → None."""
def test_classify_200_state_missing_returns_markup_changed_sentinel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""audit-scrapers finding 4: HTTP 200 но extract_state не нашёл auth-state
(Cian сменил вёрстку/MFE-схему header-frontend) VERIFY_MARKUP_CHANGED_SENTINEL,
НЕ None. Раньше это конфлировалось с "cookies expired" (реальный логаут)."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: None,
)
result = _classify_verify_response(200, "<html></html>")
assert result is None
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
assert result is not None # НЕ должно триггерить cookie-refresh alert
def test_classify_5xx_returns_source_unavailable_sentinel() -> None:
"""audit-scrapers finding 4: HTTP 500 (источник недоступен) →
VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None (cookies тут ни при чём)."""
result = _classify_verify_response(500, None)
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
assert result is not None
def test_classify_502_returns_source_unavailable_sentinel() -> None:
"""Любой non-200/403/401 статус (напр. 502 bad gateway) — источник недоступен."""
result = _classify_verify_response(502, None)
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
def test_classify_status_200_html_none_returns_source_unavailable_sentinel() -> None:
"""Defensive: status=200 но html=None (не должно случаться в проде, но
classifier не должен молча вернуть None='expired') source-unavailable."""
result = _classify_verify_response(200, None)
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
def test_classify_403_is_distinct_from_401() -> None:
@ -230,6 +258,28 @@ def test_classify_403_is_distinct_from_401() -> None:
assert expired is None
def test_classify_all_four_outcomes_are_mutually_distinct() -> None:
"""audit-scrapers finding 4: expired (401) / ban (403) / source-unavailable (5xx)
/ markup-changed (200+extract_state=None) четыре РАЗНЫХ сигнала, ни один не
коллапсирует в другой. Только expired (None) должен триггерить re-login alert."""
expired = _classify_verify_response(401, None)
ban = _classify_verify_response(403, None)
source_down = _classify_verify_response(500, None)
with pytest.MonkeyPatch.context() as mp:
mp.setattr("app.services.cian_session.extract_state", lambda html, mfe, key: None)
markup_changed = _classify_verify_response(200, "<html></html>")
outcomes = [expired, ban, source_down, markup_changed]
# None встречается ровно один раз (только expired) — остальные три truthy sentinel'а
# и все различны между собой (identity, не equality — это разные dict-объекты).
assert outcomes.count(None) == 1
assert expired is None
non_none = [o for o in outcomes if o is not None]
assert len(non_none) == 3
assert len({id(o) for o in non_none}) == 3
# ---------------------------------------------------------------------------
# verify_session (async) — integration with curl_cffi mock
# ---------------------------------------------------------------------------
@ -317,10 +367,12 @@ async def test_verify_session_not_authenticated_returns_none(
@pytest.mark.asyncio
async def test_verify_session_state_missing_returns_none(
async def test_verify_session_state_missing_returns_markup_changed_sentinel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""200 + extract_state returns None → None."""
"""audit-scrapers finding 4: 200 + extract_state returns None (markup changed)
VERIFY_MARKUP_CHANGED_SENTINEL, НЕ None. Раньше ложно триггерило "cookies
expired, please re-upload" для реальной причины "Cian сменил вёрстку"."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: None,
@ -334,7 +386,42 @@ async def test_verify_session_state_missing_returns_none(
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is None
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
assert result is not None
@pytest.mark.asyncio
async def test_verify_session_5xx_returns_source_unavailable_sentinel() -> None:
"""audit-scrapers finding 4: HTTP 500 → VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
НЕ None. Источник временно недоступен cookies тут ни при чём, вызывающий
не должен помечать сессию invalid / просить re-upload."""
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(return_value=_make_cffi_resp(500))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
assert result is not None
@pytest.mark.asyncio
async def test_verify_session_network_error_returns_source_unavailable_sentinel() -> None:
"""audit-scrapers finding 4: сетевой/транспортный сбой (timeout, connection
reset и т.п.) VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None. Раньше generic
except возвращал None конфлировал сетевой сбой с протухшими cookies."""
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(side_effect=ConnectionError("connection reset by peer"))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
assert result is not None
@pytest.mark.asyncio

View file

@ -0,0 +1,443 @@
"""Тесты ДОМ.РФ капремонт loader'а (app/services/domrf_kapremont_loader.py, мигр. 176, #2013).
Coverage:
- Чистые парс-хелперы: parse_decimal_comma / parse_int_field.
- parse_kr11_csv / parse_kr12_wall_materials фикстуры собраны из РЕАЛЬНЫХ строк
региона 66 (export-kr1_1-66-20260701.csv / export-kr1_2-66-20260701.csv, дом
2170017185 «Ломоносова 3» и 2170025270 «Ломоносова 4»): КР1.2 long-format,
wall_material непустой только на строке construction_element_type='фасад'.
- build_domrf_houses join КР1.1+КР1.2 по mkd_code.
- fetch_domrf_csvs скачивание+распаковка zip, HTTP замокан через httpx.MockTransport
(никакой живой сети).
- Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST),
backfill houses (COALESCE-порядок year_built/domrf/zhkh_year, only-fill-NULL),
propagate listings (только NULL listings.year_built).
- backfill_houses_from_domrf / propagate_listings_year_from_houses dry_run вызывает
COUNT-SQL и не пишет, real-run суммирует rowcount обоих шагов (MagicMock db).
"""
from __future__ import annotations
import io
import os
import re
import zipfile
from pathlib import Path
from unittest.mock import MagicMock
# settings/SessionLocal не импортируем, но пакет app.* на импорте может тронуть DSN — как в
# sibling-тестах (test_zhkh_flats_loader) задаём заглушку (ядро статично/без реальной БД).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import httpx
import pytest
from app.services import domrf_kapremont_loader as dkl
# ─────────────────────────────────────────────────────────────────────────────
# Фикстуры: РЕАЛЬНЫЕ строки региона 66 (export-kr1_1/kr1_2-66-20260701.csv)
# ─────────────────────────────────────────────────────────────────────────────
# КР1.1 house registry — 2 дома (Ломоносова 3 / Ломоносова 4, п. Арамиль).
_KR11_HEADER = (
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;houseguid;address;commission_year;"
"architectural_monument_category;total_sq;total_rooms_amount;living_rooms_amount;"
"total_rooms_sq;living_rooms_sq;total_ppl;number_floors_max;money_collecting_way;"
"money_ppl_collected;money_ppl_collected_debts;overhaul_funds_spent_all;"
"overhaul_funds_spent_subsidy;overhaul_fund_spent_other;overhaul_funds_balance;"
"update_date_of_information;money_ppl_collected_date;owners_payment;energy_efficiency;"
"alarm_document_date;exclude_date_from_program;inclusion_date_to_program;comment;"
"last_update;house_id"
)
_KR11_ROW_3 = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170017185;04b98bcb-3232-4b26-b0fa-3c4a47adf394;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
'1964;;399,90;;;366,60;333,30;20;2;"Счет регионального оператора";486,84407;72,59638;'
"1580,29712;0,00000;0,00000;-1093,45305;2026-05-31;01.10.2014;18,81;"
'"не присвоен";;;22.04.2014;;15.05.2026;8857551'
)
_KR11_ROW_4 = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170025270;15064d04-3aec-4266-985b-da26e9a5f392;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
'1984;;906,40;;;;;;2;"Счет регионального оператора";1046,79431;204,75296;0,00000;'
'0,00000;0,00000;1046,79431;2026-05-31;01.10.2014;18,81;"не присвоен";;;22.04.2014;;'
"15.05.2026;8857552"
)
KR11_CSV = "\n".join([_KR11_HEADER, _KR11_ROW_3, _KR11_ROW_4]) + "\n"
# КР1.2 constructive elements — long-format: обычная строка (wall_material пуст) +
# строка "фасад" (wall_material='кирпич') на каждый из тех же 2 домов.
_KR12_HEADER = (
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;address;construction_element_code;"
"construction_element_type;system_type;lift_type;stops_count;commissioning_date;"
"decommissioning_date;roof_type;roofing_area;basement_area;facade_type;facade_area;"
"foundation_type;wall_material;comment;last_update"
)
_KR12_ROW_3_OTHER = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170017185;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
'580000;"система дымоудаления и (или) противопожарной автоматики";;;;;;;;;;;;;;15.05.2026'
)
_KR12_ROW_3_FACADE = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170017185;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
'60000;фасад;;;;;;;;;"облицованный камнем";480,00;;кирпич;;15.05.2026'
)
_KR12_ROW_4_OTHER = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170025270;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
'100000;"инженерная система водоснабжения (ХВС)";центральная;;;;;;;;;;;;;15.05.2026'
)
_KR12_ROW_4_FACADE = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170025270;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
"60000;фасад;;;;;;;;;;;;кирпич;;15.05.2026"
)
KR12_CSV = (
"\n".join(
[_KR12_HEADER, _KR12_ROW_3_OTHER, _KR12_ROW_3_FACADE, _KR12_ROW_4_OTHER, _KR12_ROW_4_FACADE]
)
+ "\n"
)
@pytest.fixture
def kr11_path(tmp_path: Path) -> Path:
p = tmp_path / "kr11.csv"
p.write_text(KR11_CSV, encoding="utf-8-sig")
return p
@pytest.fixture
def kr12_path(tmp_path: Path) -> Path:
p = tmp_path / "kr12.csv"
p.write_text(KR12_CSV, encoding="utf-8-sig")
return p
# ─────────────────────────────────────────────────────────────────────────────
# parse_decimal_comma / parse_int_field
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_decimal_comma() -> None:
assert dkl.parse_decimal_comma("909,80") == pytest.approx(909.80)
assert dkl.parse_decimal_comma("399,90") == pytest.approx(399.90)
assert dkl.parse_decimal_comma("100.5") == pytest.approx(100.5)
assert dkl.parse_decimal_comma("") is None
assert dkl.parse_decimal_comma(None) is None
assert dkl.parse_decimal_comma("не число") is None
def test_parse_int_field() -> None:
assert dkl.parse_int_field("1964") == 1964
assert dkl.parse_int_field("2") == 2
assert dkl.parse_int_field("") is None
assert dkl.parse_int_field(None) is None
assert dkl.parse_int_field("x") is None
assert dkl.parse_int_field("5,0") == 5 # decimal-фоллбек
def test_parse_int_field_year_sanity_gate() -> None:
# Fix A: значение вне [min_value, max_value] → None (мусорный год не в staging).
ymax = dkl.plausible_year_max()
assert dkl.parse_int_field("1841", min_value=1850, max_value=ymax) is None # слишком старый
assert dkl.parse_int_field("0", min_value=1850, max_value=ymax) is None # ноль
assert dkl.parse_int_field(str(ymax + 5), min_value=1850, max_value=ymax) is None # будущее
assert dkl.parse_int_field("1964", min_value=1850, max_value=ymax) == 1964 # валидный
assert dkl.parse_int_field("1850", min_value=1850, max_value=ymax) == 1850 # граница вкл.
# Без границ (number_floors_max) — гейт не применяется.
assert dkl.parse_int_field("1841") == 1841
def test_parse_kr11_gates_impossible_commission_year(tmp_path: Path) -> None:
# Fix A: строка КР1.1 с невозможным годом (1200) → commission_year=None.
bad_row = _KR11_ROW_3.replace("1964;", "1200;", 1)
csv_text = "\n".join([_KR11_HEADER, bad_row]) + "\n"
p = tmp_path / "bad_year.csv"
p.write_text(csv_text, encoding="utf-8-sig")
rows = dkl.parse_kr11_csv(p)
assert rows["2170017185"].commission_year is None
# ─────────────────────────────────────────────────────────────────────────────
# parse_kr11_csv — house registry
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_kr11_csv_real_rows(kr11_path: Path) -> None:
rows = dkl.parse_kr11_csv(kr11_path)
assert set(rows.keys()) == {"2170017185", "2170025270"}
house3 = rows["2170017185"]
assert house3.houseguid == "04b98bcb-3232-4b26-b0fa-3c4a47adf394"
assert house3.commission_year == 1964
assert house3.number_floors_max == 2
assert house3.total_sq == pytest.approx(399.90)
assert "Ломоносова, д. 3" in (house3.address or "")
house4 = rows["2170025270"]
assert house4.houseguid == "15064d04-3aec-4266-985b-da26e9a5f392"
assert house4.commission_year == 1984
assert house4.total_sq == pytest.approx(906.40)
def test_parse_kr11_csv_skips_blank_mkd_code(tmp_path: Path) -> None:
csv_text = _KR11_HEADER + "\n" + (";" * (_KR11_HEADER.count(";"))) + "\n"
p = tmp_path / "blank.csv"
p.write_text(csv_text, encoding="utf-8-sig")
assert dkl.parse_kr11_csv(p) == {}
# ─────────────────────────────────────────────────────────────────────────────
# parse_kr12_wall_materials — long-format, wall_material только на строке 'фасад'
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_kr12_wall_materials_extracts_facade_row_only(kr12_path: Path) -> None:
materials = dkl.parse_kr12_wall_materials(kr12_path)
# Обе non-facade строки (дымоудаление / ХВС) имели wall_material='' — исключены.
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
def test_parse_kr12_wall_materials_empty_when_no_facade_row(tmp_path: Path) -> None:
csv_text = "\n".join([_KR12_HEADER, _KR12_ROW_3_OTHER]) + "\n"
p = tmp_path / "no_facade.csv"
p.write_text(csv_text, encoding="utf-8-sig")
assert dkl.parse_kr12_wall_materials(p) == {}
# ─────────────────────────────────────────────────────────────────────────────
# build_domrf_houses — join КР1.1 + КР1.2(wall_material)
# ─────────────────────────────────────────────────────────────────────────────
def test_build_domrf_houses_joins_wall_material(kr11_path: Path, kr12_path: Path) -> None:
kr11 = dkl.parse_kr11_csv(kr11_path)
walls = dkl.parse_kr12_wall_materials(kr12_path)
houses = dkl.build_domrf_houses(kr11, walls)
by_code = {h.mkd_code: h for h in houses}
assert len(houses) == 2
assert by_code["2170017185"].wall_material == "кирпич"
assert by_code["2170017185"].commission_year == 1964
assert by_code["2170025270"].wall_material == "кирпич"
# Дом без строки 'фасад' в КР1.2 → wall_material остаётся None (не KeyError/пустая строка).
def test_wall_material_none_when_house_missing_from_kr12(tmp_path: Path, kr11_path: Path) -> None:
kr11 = dkl.parse_kr11_csv(kr11_path)
houses = dkl.build_domrf_houses(kr11, {}) # пустой КР1.2 lookup
assert all(h.wall_material is None for h in houses)
# ─────────────────────────────────────────────────────────────────────────────
# fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан (MockTransport)
# ─────────────────────────────────────────────────────────────────────────────
def _zip_bytes(inner_name: str, content: str) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(inner_name, content.encode("utf-8-sig"))
return buf.getvalue()
def test_fetch_domrf_csvs_downloads_and_extracts(tmp_path: Path) -> None:
kr11_zip = _zip_bytes("export-kr1_1-66-20260701.csv", KR11_CSV)
kr12_zip = _zip_bytes("export-kr1_2-66-20260701.csv", KR12_CSV)
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url == dkl.KR11_URL:
return httpx.Response(200, content=kr11_zip)
if url == dkl.KR12_URL:
return httpx.Response(200, content=kr12_zip)
raise AssertionError(f"unexpected URL {url}")
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport) as client:
kr11_out, kr12_out = dkl.fetch_domrf_csvs(tmp_path, client=client)
assert kr11_out.exists() and kr11_out.name.endswith(".csv")
assert kr12_out.exists() and kr12_out.name.endswith(".csv")
# Круглый путь: распакованные файлы парсятся так же, как локальные фикстуры.
rows = dkl.parse_kr11_csv(kr11_out)
assert set(rows.keys()) == {"2170017185", "2170025270"}
materials = dkl.parse_kr12_wall_materials(kr12_out)
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
def test_extract_csv_from_zip_raises_on_no_csv_entry(tmp_path: Path) -> None:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("readme.txt", "not a csv")
with pytest.raises(ValueError, match="csv"):
dkl._extract_csv_from_zip(buf.getvalue(), tmp_path)
# ─────────────────────────────────────────────────────────────────────────────
# Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность)
# ─────────────────────────────────────────────────────────────────────────────
_UPSERT_SQL = str(dkl._UPSERT_SQL.text)
def test_upsert_sql_uses_psycopg_v3_cast_not_double_colon() -> None:
assert not re.search(r":\w+::", _UPSERT_SQL)
assert "CAST(:mkd_code AS text)" in _UPSERT_SQL
assert "CAST(:commission_year AS smallint)" in _UPSERT_SQL
assert "CAST(:total_sq AS numeric)" in _UPSERT_SQL
def test_upsert_sql_idempotent_on_conflict() -> None:
flat = re.sub(r"\s+", " ", _UPSERT_SQL)
assert "INSERT INTO domrf_kapremont" in flat
assert "ON CONFLICT (mkd_code) DO UPDATE SET" in flat
assert "domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid" in flat
assert "domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat
def test_upsert_uses_savepoint_per_chunk() -> None:
import inspect
src = inspect.getsource(dkl.upsert_domrf_kapremont)
assert "with db.begin_nested():" in src
assert "db.commit()" not in src # не коммитит сам (коммитит caller)
# ─────────────────────────────────────────────────────────────────────────────
# Статические asserts: backfill houses — COALESCE(year_built, domrf, zhkh_year)
# ─────────────────────────────────────────────────────────────────────────────
_BACKFILL_DOMRF_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_FROM_DOMRF_SQL.text))
_BACKFILL_ZHKH_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_ZHKH_FALLBACK_SQL.text))
def test_backfill_domrf_sql_year_priority_and_plausibility_guard() -> None:
bound = "BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)"
# Fix A: year_built — валидное существующее (в границах :ymin..:ymax) выигрывает,
# иначе (NULL/impossible) заменяется авторитетным commission_year → zhkh_year.
assert f"WHEN h.year_built {bound}" in _BACKFILL_DOMRF_SQL
assert "THEN h.year_built" in _BACKFILL_DOMRF_SQL
# commission_year и zhkh_year тоже проходят плаузибилити-гейт перед записью.
assert f"CASE WHEN d.commission_year {bound}" in _BACKFILL_DOMRF_SQL
# Невозможный существующий год НЕ экранирует замену (WHERE ловит NOT BETWEEN).
assert f"h.year_built NOT {bound}" in _BACKFILL_DOMRF_SQL
# material_walls / total_floors — прежняя COALESCE-семантика (только заполнить NULL).
assert "COALESCE(h.material_walls, d.wall_material)" in _BACKFILL_DOMRF_SQL
assert "COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)" in _BACKFILL_DOMRF_SQL
def test_backfill_domrf_sql_matches_by_guid_coalesce_priority() -> None:
# gar_house_guid первый (78% coverage), затем house_fias_id, затем zhkh_house_guid.
assert (
"d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)"
in _BACKFILL_DOMRF_SQL
)
def test_backfill_domrf_sql_only_fills_null_never_overwrites() -> None:
# Гейт: каждый SET-таргет требует h.<col> IS NULL — COALESCE никогда не перезапишет
# существующее non-null значение (SQL COALESCE тоже это гарантирует, но гейт защищает
# от no-op UPDATE на уже заполненных строках).
assert "h.year_built IS NULL" in _BACKFILL_DOMRF_SQL
assert "h.material_walls IS NULL" in _BACKFILL_DOMRF_SQL
assert "h.total_floors IS NULL" in _BACKFILL_DOMRF_SQL
def test_backfill_zhkh_fallback_sql_folds_zhkh_for_unmatched_houses() -> None:
# Шаг 2 — без JOIN на domrf_kapremont вообще: применяется к ЛЮБОМУ дому, включая
# те, что не заматчились в шаге 1 (issue: "houses without a ДОМ.РФ match still benefit").
assert "domrf_kapremont" not in _BACKFILL_ZHKH_SQL
assert "COALESCE(year_built, zhkh_year)" in _BACKFILL_ZHKH_SQL
assert "COALESCE(total_floors, zhkh_floors)" in _BACKFILL_ZHKH_SQL
assert "year_built IS NULL AND zhkh_year IS NOT NULL" in _BACKFILL_ZHKH_SQL
def test_backfill_sql_no_psycopg_v3_colon_colon_cast() -> None:
assert not re.search(r":\w+::", _BACKFILL_DOMRF_SQL)
assert not re.search(r":\w+::", _BACKFILL_ZHKH_SQL)
# ─────────────────────────────────────────────────────────────────────────────
# backfill_houses_from_domrf — dry_run vs real-run (MagicMock db)
# ─────────────────────────────────────────────────────────────────────────────
def test_backfill_houses_real_run_sums_both_steps_rowcount() -> None:
db = MagicMock()
results = [MagicMock(rowcount=3), MagicMock(rowcount=2)]
db.execute.side_effect = results
out = dkl.backfill_houses_from_domrf(db, dry_run=False)
assert out == {"domrf_matched": 3, "zhkh_fallback": 2, "houses_updated": 5}
assert db.execute.call_count == 2
executed_sql = [str(call.args[0]) for call in db.execute.call_args_list]
assert "UPDATE houses" in executed_sql[0]
assert "UPDATE houses" in executed_sql[1]
# Fix A: domrf-шаг прокидывает плаузибилити-границы года как bind-параметры.
domrf_params = db.execute.call_args_list[0].args[1]
assert domrf_params["ymin"] == dkl.YEAR_BUILT_MIN
assert domrf_params["ymax"] == dkl.plausible_year_max()
def test_backfill_houses_dry_run_only_counts_no_update() -> None:
db = MagicMock()
db.execute.return_value.scalar_one.side_effect = [7, 4]
out = dkl.backfill_houses_from_domrf(db, dry_run=True)
assert out == {"domrf_matched": 7, "zhkh_fallback": 4, "houses_updated": 0}
for call in db.execute.call_args_list:
sql_text = str(call.args[0]).upper()
assert "UPDATE" not in sql_text
assert "SELECT COUNT(*)" in sql_text
# ─────────────────────────────────────────────────────────────────────────────
# Статические asserts + behavior: propagate_listings_year_from_houses
# ─────────────────────────────────────────────────────────────────────────────
_PROPAGATE_SQL = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_SQL.text))
def test_propagate_sql_only_fills_null_listings_year() -> None:
assert "SET year_built = h.year_built" in _PROPAGATE_SQL
assert "l.year_built IS NULL" in _PROPAGATE_SQL
assert "h.year_built IS NOT NULL" in _PROPAGATE_SQL
assert "l.house_id_fk = h.id" in _PROPAGATE_SQL
def test_propagate_sql_has_link_consistency_distance_guard() -> None:
# Fix B: пропагация только если координаты объявления в пределах порога от дома
# (ST_DWithin, geography → метры) — перепривязанный FK не впрыснет чужой год.
assert "ST_DWithin(" in _PROPAGATE_SQL
assert "CAST(l.geom AS geography)" in _PROPAGATE_SQL
assert "CAST(h.geom AS geography)" in _PROPAGATE_SQL
assert str(dkl.PROPAGATE_MAX_DISTANCE_M) in _PROPAGATE_SQL
# Оба SQL (UPDATE и COUNT) несут один и тот же guard.
count_sql = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_COUNT_SQL.text))
assert "ST_DWithin(" in count_sql
def test_propagate_sql_no_coords_falls_back_to_address_check() -> None:
# Fix B: объявление без координат → консервативный address-фоллбек
# (адрес объявления содержит short_address дома), не пропагируем «вслепую».
assert "l.geom IS NULL" in _PROPAGATE_SQL
assert "h.short_address IS NOT NULL" in _PROPAGATE_SQL
assert "l.address ILIKE" in _PROPAGATE_SQL
def test_propagate_listings_year_real_run_returns_rowcount() -> None:
db = MagicMock()
db.execute.return_value.rowcount = 42
out = dkl.propagate_listings_year_from_houses(db, dry_run=False)
assert out == {"listings_updated": 42}
sql_text = str(db.execute.call_args[0][0])
assert "UPDATE listings" in sql_text
def test_propagate_listings_year_dry_run_uses_count_sql() -> None:
db = MagicMock()
db.execute.return_value.scalar_one.return_value = 99
out = dkl.propagate_listings_year_from_houses(db, dry_run=True)
assert out == {"listings_updated": 0, "would_update": 99}
sql_text = str(db.execute.call_args[0][0]).upper()
assert "UPDATE" not in sql_text

View file

@ -139,6 +139,9 @@ def _stub_precision_and_pdf():
_fetch_price_trend=lambda *a, **k: None,
_fetch_dkp_corridor=lambda *a, **k: None,
_fetch_house_imv_anchor=lambda *a, **k: None,
# (oblast C2): GET-rehydrate also resolves the target city (for
# _fetch_dkp_corridor city-scoping) before calling the corridor helper.
_resolve_target_city=lambda *a, **k: None,
# #2043 (BE-1): GET-rehydrate also recomputes cv / source_counts from the
# persisted analogs. Empty analogs in the fixture → None / {} (real behaviour).
_cv_from_ppm2=lambda *a, **k: None,

View file

@ -0,0 +1,261 @@
"""Display-consistency fix — показанные карточки аналогов согласованы с n_analogs
и не содержат ни MAD-clip-отсечённых ценовых выбросов, ни кросс-source дублей
одного физлота (live QA прод-смоук: «Екатеринбург, ул. Малышева, 51»).
Два независимых root cause (см. PR):
1. `n_analogs` считался ПОСЛЕ MAD-clip (_compute_same_building_anchor: n = len
выживших ppm2), но отображаемые карточки строились из ИСХОДНОГО (пред-clip)
anchor_comps клип-отсечённый ценовой выброс оставался видимым в UI, даже
когда он не участвовал в headline/n_analogs. Фикс: anchor_comps_used теперь =
anchor["comps"] (тот же пост-clip пул, что дал n).
2. Статистический кросс-source дедуп (_dedup_cross_source) требует совпадения
ЦЕНОВОГО бакета (price_bucket) реальный кросс-пост одного физлота на разных
площадках почти всегда имеет небольшой ценовой дрейф (перевыставили дешевле/
дороже), который может увести пару в разные бакеты дубль выживает в
популяции. Фикс: отдельный display-only дедуп (_dedup_display_lots) тот же
физический ключ (building/floor/area), но БЕЗ price_bucket ловит именно эти
остаточные дубли ТОЛЬКО на уровне отображаемых карточек, не трогая
n_analogs/median/cv (frozen backtest regression gate не задет см.
tests/test_backtest_regression_gate.py, который эту ветку кода не вызывает).
Test A воспроизводит буквальный прод-паттерн (кросс-source дубль-пара, которая
ЦЕЛИКОМ является ценовым выбросом, «Гоголя 18» из QA-смоука): MAD-clip убирает
ОБЕ копии из anchor["comps"] они пропадают из карточек естественным образом
(fix #1 самодостаточен) → len(analogs) == n_analogs И нет дубль-адресов.
Test B покрывает менее тривиальный случай дубль-пара НЕ является ценовым
выбросом (обычная цена, просто с небольшим дрейфом между площадками) и
переживает MAD-clip как 2 отдельные записи (её и ловит fix #2, display-only
дедуп). Здесь len(analogs) < n_analogs ЧЕСТНО (статистика не трогается) тест
документирует именно это, а не искусственное равенство.
Полный estimate-путь со всеми I/O застаблен через harness _run_estimate из
test_same_building_anchor.py (anchor_tier="A" same-building anchor ветка, где
живёт MAD-clip).
"""
from __future__ import annotations
import importlib.util
import os
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services import estimator
_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py"
_spec = importlib.util.spec_from_file_location("_anchor_harness_display_consistency", _ANCHOR_TEST)
assert _spec is not None and _spec.loader is not None
_h = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_h)
def _comp(
*,
source: str,
address: str,
ppm2: float,
area_m2: float = 146.2,
rooms: int = 4,
scraped_at: datetime | None = None,
) -> dict[str, Any]:
"""Минимальный same-building comp-dict (без floor/total_floors → нейтральный
floor-вес; area_m2 по умолчанию = target площадь _make_payload(), area-вес ~1.0).
"""
return {
"source": source,
"source_url": f"https://{source}.example/offer/{address}/{ppm2}",
"address": address,
"area_m2": area_m2,
"rooms": rooms,
"price_per_m2": ppm2,
"price_rub": ppm2 * area_m2,
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
"photo_urls": [],
}
# --------------------------------------------------------------------------- #
# Test A — outlier дубль-пара («Гоголя 18»-паттерн): MAD-clip убирает ОБЕ копии
# --------------------------------------------------------------------------- #
def test_outlier_duplicate_pair_excluded_from_both_count_and_cards() -> None:
"""5 обычных комплов (разные дома, MAD-clip не трогает) + кросс-source
дубль-пара одного физлота, которая ЦЕЛИКОМ ценовой выброс (~4.4× медианы,
как «Гоголя 18» в QA-смоуке) MAD-clip убирает ОБЕ копии из
anchor["comps"]. Т.к. карточки теперь строятся из anchor["comps"] (не из
исходного anchor_comps), выброс не просачивается в UI количество карточек
равно n_analogs, и обеих копий дубля нет ни в счётчике, ни в карточках.
"""
clean = [
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 58", ppm2=215_000.0),
]
# Кросс-source дубль одного физлота — ~4.4-4.5× дороже остальных (целиком
# ценовой выброс, как реальный прод-кейс «Гоголя 18»).
outlier_dup = [
_comp(source="avito", address="Екатеринбург, ул. Гоголя, 18", ppm2=900_000.0),
_comp(source="domklik", address="Екатеринбург, ул. Гоголя, 18", ppm2=920_000.0),
]
est = _h._run_estimate(anchor_comps=clean + outlier_dup, anchor_tier="A")
# Выброс не участвовал в headline/n_analogs (MAD-clip) — фикс #1 не менял эту
# часть: сверяем, что n_analogs действительно = 5 (7 - 2 отсечённых).
assert est.n_analogs == 5
# Главный инвариант display-consistency: показанные карточки == n_analogs.
assert len(est.analogs) == est.n_analogs == 5
# Ни одна из копий выброса не попала в карточки.
shown_addresses = {a.address for a in est.analogs}
assert "Екатеринбург, ул. Гоголя, 18" not in shown_addresses
shown_ppm2 = {a.price_per_m2 for a in est.analogs}
assert 900_000 not in shown_ppm2
assert 920_000 not in shown_ppm2
# Нет дублей адресов среди карточек.
assert len(shown_addresses) == len(est.analogs)
# --------------------------------------------------------------------------- #
# Test B — non-outlier дубль-пара («Пушкина 9»-паттерн): переживает MAD-clip,
# ловится ТОЛЬКО display-only дедупом (price_bucket-строгий стат-дедуп её
# пропускает из-за ценового дрейфа, бакет-straddle).
# --------------------------------------------------------------------------- #
def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
"""4 обычных компла + кросс-source дубль-пара ОБЫЧНОЙ цены (небольшой дрейф
2-25к, «Пушкина 9»-паттерн из QA-смоука) НЕ выброс, переживает MAD-clip как
2 отдельные записи (n_analogs их считает по отдельности, статистика не
тронута). display-only дедуп (без price_bucket) схлопывает пару в ОДНУ
карточку карточек становится МЕНЬШЕ n_analogs (честно: это НЕ баг, а
следствие того, что стат-пайплайн и display-дедуп используют разные ключи
по дизайну см. docstring _dedup_display_lots). Инвариант, который держит
этот тест: адрес дубля встречается в карточках РОВНО один раз.
"""
clean = [
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
]
# Один физлот, кросс-source (yandex/domklik), лёгкий ценовой дрейф ~1.6% —
# достаточно, чтобы price_bucket (_DEDUP_PRICE_BUCKET_RUB=100_000 руб.)
# развёл пару по РАЗНЫМ бакетам (311 vs 316), но НЕ выброс (в пределах
# MAD-clip диапазона остальных 4).
dup_a = _comp(
source="yandex",
address="Екатеринбург, ул. Пушкина, 9",
ppm2=219_000.0,
area_m2=142.0,
scraped_at=datetime(2026, 6, 1, tzinfo=UTC),
)
dup_b = _comp(
source="domklik",
address="Екатеринбург, ул. Пушкина, 9",
ppm2=222_500.0,
area_m2=142.0,
scraped_at=datetime(2026, 6, 3, tzinfo=UTC), # свежее → представитель
)
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
# иначе тест проверял бы не то, что заявлено в docstring.
assert estimator.settings.estimate_dedup_analogs_enabled is True
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"
est = _h._run_estimate(anchor_comps=[*clean, dup_a, dup_b], anchor_tier="A")
# Статистика ЧЕСТНО считает обе копии (стат-дедуп их не поймал, MAD-clip не
# выброс) — n_analogs = 6.
assert est.n_analogs == 6
# display-only дедуп схлопнул пару → карточек МЕНЬШЕ n_analogs.
assert len(est.analogs) == 5
assert len(est.analogs) < est.n_analogs
# Инвариант: адрес дубля встречается РОВНО один раз среди карточек.
shown_addresses = [a.address for a in est.analogs]
assert shown_addresses.count("Екатеринбург, ул. Пушкина, 9") == 1
# Представитель — свежайший scraped_at (domklik, 222_500).
pushkina_card = next(a for a in est.analogs if a.address == "Екатеринбург, ул. Пушкина, 9")
assert pushkina_card.price_per_m2 == 222_500
# --------------------------------------------------------------------------- #
# Unit-level: _dedup_display_lots — прямые проверки поведения helper'а
# --------------------------------------------------------------------------- #
def _lot(
*,
source: str,
address: str = "ул. Ленина, 5",
area: float = 60.0,
price: float = 12_000_000.0,
floor: int | None = 5,
scraped_at: datetime | None = None,
) -> dict[str, Any]:
return {
"source": source,
"address": address,
"area_m2": area,
"price_rub": price,
"price_per_m2": price / area if area else 0,
"floor": floor,
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
"photo_urls": [],
}
def test_dedup_display_lots_collapses_price_drifted_cross_source_pair() -> None:
# Тот же building/floor/area, РАЗНЫЙ source, цена отличается на 20% — стат-
# дедуп (price_bucket) не поймал бы такой дрейф, display-дедуп игнорирует
# цену вовсе.
lots = [
_lot(source="avito", price=12_000_000.0, scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
_lot(source="cian", price=14_400_000.0, scraped_at=datetime(2026, 6, 5, tzinfo=UTC)),
]
out = estimator._dedup_display_lots(lots)
assert len(out) == 1
assert out[0]["source"] == "cian" # свежайший scraped_at
def test_dedup_display_lots_same_source_pair_not_merged() -> None:
# Тот же building/floor/area, ОДИНАКОВЫЙ source, разная цена — НЕ кросс-пост
# (либо два разных реальных юнита, случайно совпавших по округлённой
# площади/этажу), display-дедуп НЕ должен их схлопывать (require_diff_source
# guard).
lots = [
_lot(source="cian", price=12_000_000.0),
_lot(source="cian", price=13_000_000.0),
]
out = estimator._dedup_display_lots(lots)
assert len(out) == 2
def test_dedup_display_lots_different_addresses_stay_distinct() -> None:
lots = [
_lot(source="avito", address="ул. Ленина, 5", price=12_000_000.0),
_lot(source="cian", address="ул. Мира, 3", price=12_000_000.0),
]
out = estimator._dedup_display_lots(lots)
assert len(out) == 2
def test_dedup_display_lots_noop_under_two_lots() -> None:
lots = [_lot(source="avito")]
out = estimator._dedup_display_lots(lots)
assert out is lots

View file

@ -56,8 +56,8 @@ def test_fix1_low_conf_anchor_suppressed_by_gate() -> None:
assert anchor_raw is not None, "Должен построить якорь без гейта"
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
# Гейт: settings.estimate_sb_low_conf_gate_enabled=True
# Это применяется в estimate_quality (выше уровня _compute_same_building_anchor),
# Гейт low-confidence якоря применяется в estimate_quality (выше уровня
# _compute_same_building_anchor),
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
assert anchor_raw["confidence"] == "low"
# Проверяем что гейт-условие срабатывает:
@ -299,17 +299,15 @@ def test_fix4_floor_sigma_zero_does_not_crash() -> None:
def test_fix4_premium_comp_survives_post_weight_clip() -> None:
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
Логика: при post-weight clip (estimate_sb_clip_after_weight=True) MAD-clip
применяется к ppm² ПОСЛЕ weighting. Если видовой компл близок по площади/
комнатам его вес высок, и он не должен быть outlier после clip.
Логика: post-weight MAD-clip применяется к ppm² ПОСЛЕ weighting. Если видовой
компл близок по площади/комнатам его вес высок, и он не должен быть outlier
после clip.
"""
# 4 стандартных компла + 1 видовой (выше на 30%)
base = 200_000
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
with patch("app.services.estimator.settings") as mock_settings:
# Активируем post-weight clip
mock_settings.estimate_sb_clip_after_weight = True
mock_settings.estimate_sb_mad_k_small_n = 2.5
mock_settings.estimate_sb_small_n_threshold = 10
mock_settings.avito_imv_thin_market_threshold = 10

View file

@ -0,0 +1,238 @@
"""Tests for the oblast-D deals-headline-fallback (non-EKB accuracy gap).
Context: `listings` coverage is ~40k EKB / ~0 non-EKB (Нижний Тагил / Серов /
Каменск-Уральский). Before this fix, a non-EKB subject either:
(a) leaked a DISTANT EKB listing median via the Tier S address-prefix
fallback (no geo bound see `_fetch_analogs` Tier S fix), or
(b) surfaced an empty ("n/a") headline once (a) was fixed and the
geo-bound radius tiers legitimately found 0 local listings.
This fallback builds the headline from the ДКП deal corridor (`dkp_raw`,
already city+street-scoped via `_resolve_target_city`/`_fetch_dkp_corridor`)
instead honestly flagged 'low' confidence, deals-only.
These tests exercise `estimate_quality` end-to-end (real spine code, mocked
DB-facing helpers only) the same pattern as test_estimator_radius_floor.py.
"""
from __future__ import annotations
import os
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_listing(*, price_per_m2: float, area_m2: float = 45.0) -> dict[str, Any]:
"""An EKB analog — used only in the "EKB unaffected" control test."""
return {
"source": "cian",
"source_url": "https://cian.ru/offer/1",
"address": "ЕКБ, ул. Малышева, 30",
"lat": 56.838,
"lon": 60.595,
"rooms": 2,
"area_m2": area_m2,
"floor": 5,
"total_floors": 16,
"price_rub": price_per_m2 * area_m2,
"price_per_m2": price_per_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 10,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 150.0,
"relevance_score": 0.1,
}
def _make_geo_tagil():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=57.9094,
lon=59.9789,
full_address="Свердловская обл., Нижний Тагил, ул. Ленина, 5",
provider="nominatim",
)
def _make_payload_tagil():
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="Нижний Тагил, ул. Ленина, 5",
area_m2=45.0,
rooms=2,
floor=5,
total_floors=9,
)
def _run_estimate(
*,
analogs: list[dict[str, Any]],
dkp_raw: dict[str, Any] | None,
geo: Any,
payload: Any,
) -> Any:
from app.services.estimator import estimate_quality
db = MagicMock()
async def _run() -> Any:
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
# Post Part-1 (geo-bound Tier S) reality for a non-EKB town: the
# radius/tier ladder legitimately returns NOTHING — tier='W' is
# what the always-executed final fallback tier returns.
return_value=(list(analogs), False, "W"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
return await estimate_quality(payload, db)
return anyio.run(_run)
# ── Нижний Тагил: no local listings, deal corridor present ───────────────────
def test_non_ekb_empty_listings_uses_deals_headline() -> None:
"""0 local listings + a usable ДКП corridor → headline comes from deals.
Mirrors the reported Нижний Тагил gap: deal_median 85 911 /м² (accurate)
vs the old EKB-leaked asking headline 186 461 (~6x over). After the fix,
the headline must equal the deal corridor's median — nowhere near the
EKB-range figure and confidence must be honestly 'low' (deals-only, zero
scraped analogs).
"""
dkp_raw = {
"count": 12,
"low_ppm2": 70_000,
"median_ppm2": 85_911,
"high_ppm2": 100_000,
"period_months": 12,
}
est = _run_estimate(
analogs=[], # 0 listings — the honest post-geo-bound-fix reality
dkp_raw=dkp_raw,
geo=_make_geo_tagil(),
payload=_make_payload_tagil(),
)
assert est.median_price_per_m2 == 85_911, (
f"headline={est.median_price_per_m2} must equal the deal corridor "
f"median, not 0/n-a and nowhere near an EKB-range figure (~186k)"
)
assert est.median_price_per_m2 < 120_000, "must NOT be EKB-leaked (~186k)"
assert est.n_analogs == 0, "honest: zero scraped-listing analogs were used"
assert est.confidence == "low", "deals-only headline must be honestly low-confidence"
assert est.median_price_rub == round(85_911 * 45.0)
# Range should bracket the corridor's P10/P90, not collapse to a point.
assert est.range_low_rub <= est.median_price_rub <= est.range_high_rub
def test_non_ekb_empty_listings_no_deals_stays_insufficient() -> None:
"""0 listings + NO deal corridor either → stays honest n/a (median=0).
Guards against the fallback inventing a number when there is truly no
signal at all (e.g. Каменск-Уральский with an unresolvable street).
"""
est = _run_estimate(
analogs=[],
dkp_raw=None,
geo=_make_geo_tagil(),
payload=_make_payload_tagil(),
)
assert est.median_price_per_m2 == 0
assert est.n_analogs == 0
assert est.confidence == "low"
def test_non_ekb_thin_deal_corridor_below_min_n_stays_insufficient() -> None:
"""Deal corridor exists but below DEALS_HEADLINE_FALLBACK_MIN_N → no fallback.
A single stale sold price should not become the town's headline.
"""
dkp_raw = {
"count": 1,
"low_ppm2": 70_000,
"median_ppm2": 85_911,
"high_ppm2": 100_000,
"period_months": 12,
}
est = _run_estimate(
analogs=[],
dkp_raw=dkp_raw,
geo=_make_geo_tagil(),
payload=_make_payload_tagil(),
)
assert est.median_price_per_m2 == 0
assert est.n_analogs == 0
# ── EKB control: dense local listings → deals-fallback must NOT engage ───────
def test_ekb_with_dense_listings_ignores_deals_fallback() -> None:
"""EKB has plenty of local listings — the radius-path headline must win,
NOT the deal corridor, even though dkp_raw is present (byte-green guard:
EKB must stay on the existing listings-median path unconditionally).
"""
from app.schemas.trade_in import TradeInEstimateInput
from app.services.geocoder import GeocodeResult
analogs = [
_make_listing(price_per_m2=140_000.0),
_make_listing(price_per_m2=145_000.0),
_make_listing(price_per_m2=150_000.0),
]
dkp_raw = {
"count": 20,
"low_ppm2": 120_000,
"median_ppm2": 144_000,
"high_ppm2": 160_000,
"period_months": 12,
}
geo = GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Малышева, 30",
provider="nominatim",
)
payload = TradeInEstimateInput(
address="ЕКБ, ул. Малышева, 30", area_m2=45.0, rooms=2, floor=5, total_floors=16
)
est = _run_estimate(analogs=analogs, dkp_raw=dkp_raw, geo=geo, payload=payload)
# Headline built from the LISTINGS median (~145k), not silently replaced —
# n_analogs must reflect the real listing count (deals-fallback never ran).
assert est.n_analogs == len(analogs)
assert 140_000 <= est.median_price_per_m2 <= 150_000

View file

@ -313,7 +313,6 @@ def _run_estimate_anchor_only(
anchor_tier: str | None = "A",
):
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
from app.core.config import settings
from app.services.estimator import estimate_quality
db = MagicMock()
@ -322,7 +321,6 @@ def _run_estimate_anchor_only(
async def _run():
with (
patch.object(settings, "estimate_same_building_anchor_enabled", True),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),

View file

@ -5,22 +5,19 @@
blend, rehydrate, миграция), который наполнит median/sources_used без единого
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
38.45M, yandex_valuation). Mainline уже честен это belt-and-suspenders.
Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True).
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
# pydantic Settings требует DATABASE_URL при инициализации.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# WeasyPrint stubbed in CI.
sys.modules.setdefault("weasyprint", MagicMock())
import app.services.estimator as est # noqa: E402
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
@ -108,23 +105,8 @@ def test_none_explanation_returns_empty_string_not_none() -> None:
assert isinstance(explanation, str)
def test_flag_default_true() -> None:
"""estimate_confidence_floor_no_analogs по умолчанию True (гард активен)."""
from app.core.config import settings
assert settings.estimate_confidence_floor_no_analogs is True
def test_flag_off_does_not_downgrade() -> None:
"""Флаг OFF: n_analogs=0 + confidence='high' → guard пропускается, high сохраняется.
Проверяет escape-hatch: при estimate_confidence_floor_no_analogs=False
_enforce_zero_analog_low не вызывается (вызов обёрнут в if-гейт в estimator.py).
Тест патчит settings через patch.object, вызывает функцию напрямую убеждается
что сам гард всё ещё понижает (без флага); интеграционный эффект флага проверяется
через patch в estimate_quality (ниже).
"""
# Напрямую: _enforce_zero_analog_low всегда понижает (флаг только в if-гейте caller'а)
def test_enforce_zero_analog_low_always_downgrades() -> None:
"""_enforce_zero_analog_low всегда понижает high→low при n_analogs=0 (гард всегда ON)."""
confidence, _ = _enforce_zero_analog_low(
"high",
0,
@ -133,19 +115,3 @@ def test_flag_off_does_not_downgrade() -> None:
sources_used=["yandex_valuation"],
)
assert confidence == "low" # сама функция не смотрит на settings
# Через патч: гейт в estimate_quality при флаге OFF пропускает вызов.
with patch.object(est.settings, "estimate_confidence_floor_no_analogs", False):
# _enforce_zero_analog_low не должна быть вызвана —
# мокаем её и убеждаемся что мок не тронут.
mock_guard = MagicMock(return_value=("high", "unchanged"))
with patch.object(est, "_enforce_zero_analog_low", mock_guard):
# Вызываем блок кода напрямую: повторяем if-гейт как в estimate_quality.
confidence_in = "high"
n = 0
expl = "test"
if est.settings.estimate_confidence_floor_no_analogs:
confidence_in, expl = est._enforce_zero_analog_low(
confidence_in, n, expl, median_price=0, sources_used=[]
)
mock_guard.assert_not_called()

View file

@ -6,8 +6,7 @@
- комбинированный случай (несколько причин сразу);
- all-clear (нет причин recommended False);
- граничные значения порогов (>= high_value, >= wide_ratio);
- div-guard на диапазон (range_low None/0 без деления на ноль);
- выключенный флаг estimate_manual_review_enabled (False, []).
- div-guard на диапазон (range_low None/0 без деления на ноль).
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг метаданные: сериализация
без изменений, когда ручная оценка не рекомендована).
@ -36,14 +35,12 @@ ELITE_REASON = (
def _settings(
*,
enabled: bool = True,
high_value: int = 20_000_000,
wide_ratio: float = 1.9,
elite_ppm2: int = 250_000,
) -> SimpleNamespace:
"""Duck-typed заглушка Settings — helper читает только эти поля."""
return SimpleNamespace(
estimate_manual_review_enabled=enabled,
estimate_manual_review_high_value_rub=high_value,
estimate_manual_review_wide_range_ratio=wide_ratio,
estimate_manual_review_elite_ppm2=elite_ppm2,
@ -320,23 +317,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
assert WIDE_RANGE_REASON not in reasons
# ── Выключенный флаг ─────────────────────────────────────────────────────────
def test_disabled_flag_suppresses_all() -> None:
"""estimate_manual_review_enabled=False → (False, []) даже когда всё триггерит."""
rec, reasons = _manual_review(
premium_building=True,
expected_sold_price=99_000_000,
confidence="low",
range_low=10_000_000,
range_high=99_000_000,
settings=_settings(enabled=False),
)
assert rec is False
assert reasons == []
# ── invariant recommended == bool(reasons) ───────────────────────────────────

View file

@ -1,7 +1,7 @@
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation
с разными ext_item_id не double-count'ится в месячной медиане.
Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id
не double-count'ится в месячной медиане (dedup всегда включён).
"""
from __future__ import annotations
@ -40,19 +40,11 @@ def _make_mock_db(source1_rows: list[dict], source2_rows: list[dict]) -> MagicMo
# ---------------------------------------------------------------------------
def test_dedup_enabled_flag_is_true_by_default() -> None:
"""estimate_price_trend_dedup_enabled дефолт True."""
from app.core.config import settings
assert settings.estimate_price_trend_dedup_enabled is True
def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
"""Когда dedup включён, SQL-запрос к house_placement_history содержит DISTINCT ON."""
def test_price_trend_dedup_sql_uses_distinct_on() -> None:
"""SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup)."""
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = True
mock_settings.estimate_price_trend_max_age_months = 6
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
@ -64,24 +56,8 @@ def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
def test_price_trend_dedup_disabled_no_distinct_on() -> None:
"""Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path)."""
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = False
mock_settings.estimate_price_trend_max_age_months = 6
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
calls = mock_db.execute.call_args_list
assert len(calls) >= 2
second_sql = str(calls[1].args[0].text) if calls[1].args else ""
assert "distinct on" not in second_sql.lower()
def test_price_trend_returns_result_with_dedup_enabled() -> None:
"""Функция корректно возвращает точки тренда при dedup_enabled=True."""
def test_price_trend_returns_result_with_dedup() -> None:
"""Функция корректно возвращает точки тренда (dedup включён)."""
rows = [
{"month": "2025-11", "ppm2": 148_000},
{"month": "2025-12", "ppm2": 152_000},
@ -90,7 +66,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
mock_db = _make_mock_db([], rows)
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = True
mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
@ -101,25 +76,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
assert result[0]["ppm2"] == 148_000
def test_price_trend_dedup_disabled_returns_same_points() -> None:
"""Флаг OFF не ломает возврат данных (backward-compat)."""
rows = [
{"month": "2025-11", "ppm2": 148_000},
{"month": "2025-12", "ppm2": 152_000},
{"month": "2026-01", "ppm2": 155_000},
]
mock_db = _make_mock_db([], rows)
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = False
mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
assert result is not None
assert len(result) == 3
def test_price_trend_source1_preferred_over_dedup_path() -> None:
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
source1_rows = [
@ -130,7 +86,6 @@ def test_price_trend_source1_preferred_over_dedup_path() -> None:
mock_db = _make_mock_db(source1_rows, [])
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = True
mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)

View file

@ -181,18 +181,49 @@ def test_confidence_high_with_7_unique_addresses_and_tight_iqr() -> None:
def test_confidence_medium_via_4_unique_addresses() -> None:
# 4 unique addresses → medium branch (independent of IQR).
# Wide IQR ensures it is NOT "high". avg = 4/4 = 1.0 (no downgrade).
# #R2-H2: 4 unique addresses AND IQR/median < 0.35 → medium. avg = 4/4 = 1.0 (no downgrade).
listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=115, # IQR/median = 0.25 < 0.35 → medium
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
def test_confidence_4_addresses_wide_iqr_now_low() -> None:
# #R2-H2: 4 unique addresses but IQR/median = 0.60 (huge dispersion) → low, NOT medium.
# Dispersion ceiling: the badge must not contradict a ±30% spread in its own explanation.
listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=70,
q3=130, # IQR/median = 0.60 → not high
q3=130, # IQR/median = 0.60
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
assert level == "low"
def test_confidence_force_low_on_radius_widen_with_dispersion() -> None:
# #R2-H2: a pool that would be medium (4 addr, IQR 0.32 < 0.35) is FORCED low when it
# was radius-widened due to sparse data AND spread > 0.30 — badge can't say "medium"
# while the explanation admits "расширили радиус … из-за нехватки данных".
listings = _addr_lots(["a", "b", "c", "d"])
level, expl = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=84,
q3=116, # IQR/median = 0.32 → medium base, then forced low
fallback_radius_used=True,
listings=listings,
)
assert level == "low"
assert "расширили радиус" in expl
def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None:
@ -313,7 +344,6 @@ def _clamp(
tier: str | None,
slack: float = 0.25,
min_n: int = 10,
enabled: bool = True,
median_price: int = 0,
range_low: int = 0,
range_high: int = 0,
@ -328,7 +358,6 @@ def _clamp(
anchor_tier=tier,
slack=slack,
min_n=min_n,
enabled=enabled,
)
@ -374,15 +403,6 @@ def test_corridor_clamp_low_n_is_noop() -> None:
assert new_ppm2 == 296_000
def test_corridor_clamp_disabled_flag_is_noop() -> None:
# enabled=False → точно старое поведение.
new_ppm2, _, _, _, clamped = _clamp(
median_ppm2=296_000, corridor_high=138_000, count=20, tier="C", enabled=False
)
assert clamped is False
assert new_ppm2 == 296_000
def test_corridor_clamp_tier_none_clamps() -> None:
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
new_ppm2, _, _, _, clamped = _clamp(

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