Compare commits

..

83 commits

Author SHA1 Message Date
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
134 changed files with 13928 additions and 1726 deletions

View file

@ -483,7 +483,11 @@ jobs:
# рискует ложно отменить НЕ относящийся к этому recreate run (напр. # рискует ложно отменить НЕ относящийся к этому recreate run (напр.
# admin-triggered scrape внутри backend, если backend в этом деплое # admin-triggered scrape внутри backend, если backend в этом деплое
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а). # не пересоздавался — его 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="" SCRAPER_STOP_TS=""
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)" echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"

View file

@ -70,7 +70,7 @@ users:
admin: admin admin: admin
kopylov: pilot kopylov: pilot
user1: pilot user1: pilot
user2: expired # «Брусника» — пробный доступ закрыт 2026-07-09 (аналогично praktika) — NoAccessScreen variant="trial" user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
user3: pilot user3: pilot
user4: pilot user4: pilot
user5: pilot user5: pilot

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

View file

@ -40,3 +40,14 @@ DADATA_API_SECRET=
POSTGRES_USER=tradein POSTGRES_USER=tradein
POSTGRES_PASSWORD=tradein POSTGRES_PASSWORD=tradein
POSTGRES_DB=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/ # Регистрация ключей: https://dadata.ru/api/clean/
DADATA_API_TOKEN=<token или пусто> DADATA_API_TOKEN=<token или пусто>
DADATA_API_SECRET=<secret или пусто> 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` ### После изменения `backend/.env.runtime`
```bash ```bash

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, limit: Annotated[int, Query(ge=1, le=15)] = 8,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment] db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
) -> SuggestResponse: ) -> SuggestResponse:
"""Автокомплит адресов в пределах ЕКБ. """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path).
Используется в EstimateForm для подсказок пока пользователь печатает. Используется в 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=Малышева /api/v1/geocode/suggest?q=Малышева

View file

@ -1,6 +1,6 @@
"""Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971). """Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971).
POST /api/v1/trade-in/lead контактная заявка с результата оценки (или лендинга): POST /api/v1/trade-in/lead контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. Notification (Telegram/email) вне scope: нет существующей trade_in_leads. Notification (Telegram/email) вне scope: нет существующей
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
@ -10,11 +10,12 @@ persist + log; `notified_at` в таблице зарезервирован по
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -28,13 +29,45 @@ router = APIRouter()
# скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP, # скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP,
# см. #2376 DoD ("простая regex, не EmailStr-подобное"). # см. #2376 DoD ("простая regex, не EmailStr-подобное").
_PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$" _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): class TradeInLeadInput(BaseModel):
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN) phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
estimate_id: UUID | None = None estimate_id: UUID | None = None
consent: Literal[True] 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") @router.post("/lead")
@ -53,13 +86,31 @@ async def create_trade_in_lead(
raise HTTPException(status_code=404, detail="estimate not found") raise HTTPException(status_code=404, detail="estimate not found")
user_agent = request.headers.get("user-agent") 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 = ( row = (
db.execute( db.execute(
text( text(
""" """
INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent) INSERT INTO trade_in_leads (
VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent) 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 RETURNING CAST(id AS text), created_at
""" """
), ),
@ -69,6 +120,9 @@ async def create_trade_in_lead(
"consent": payload.consent, "consent": payload.consent,
"source": payload.source, "source": payload.source,
"user_agent": user_agent, "user_agent": user_agent,
"client_ip": client_ip,
"consent_policy_version": _CONSENT_POLICY_VERSION,
"consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT,
}, },
) )
.mappings() .mappings()
@ -78,10 +132,12 @@ async def create_trade_in_lead(
db.commit() db.commit()
logger.info( 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"], row["id"],
payload.estimate_id, payload.estimate_id,
payload.source, payload.source,
client_ip,
_CONSENT_POLICY_VERSION,
) )
return { return {

View file

@ -11,12 +11,13 @@ from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any from typing import Annotated, Any
from uuid import UUID 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 import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.db import get_db from app.core.db import get_db
from app.core.ratelimit import _client_ip
from app.schemas.trade_in import ( from app.schemas.trade_in import (
AggregatedEstimate, AggregatedEstimate,
AnalogLot, AnalogLot,
@ -45,6 +46,7 @@ from app.schemas.trade_in import (
from app.services import account_quota from app.services import account_quota
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -147,6 +149,7 @@ def _resolve_target_house_id(
@router.post("/estimate", response_model=AggregatedEstimate) @router.post("/estimate", response_model=AggregatedEstimate)
async def estimate( async def estimate(
payload: TradeInEstimateInput, payload: TradeInEstimateInput,
request: Request,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> AggregatedEstimate: ) -> AggregatedEstimate:
@ -180,9 +183,29 @@ async def estimate(
) from None ) from None
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise # #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение # выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
# тут: при гонке двух /estimate на used=MONTHLY_LIMIT-1 второй получит False. # тут: при гонке двух /estimate на used=lim-1 второй получит False.
if not account_quota.increment(db, x_authenticated_user): # Не списываем квоту за пустой результат (нерезолвящийся адрес и т.п.) — иначе
# платный слот сгорает за 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) 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 return result
@ -244,6 +267,7 @@ def get_estimate(
_fetch_house_imv_anchor, _fetch_house_imv_anchor,
_fetch_price_trend, _fetch_price_trend,
_qc_geo_to_precision, _qc_geo_to_precision,
_resolve_target_city,
_source_counts, _source_counts,
) )
@ -283,7 +307,16 @@ def get_estimate(
else None 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 dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
imv_raw = _fetch_house_imv_anchor( imv_raw = _fetch_house_imv_anchor(
@ -1628,7 +1661,12 @@ def get_street_deals(
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
в import-rosreestr.sh). в 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) now = datetime.now(tz=UTC)
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который # #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
@ -1663,16 +1701,25 @@ def get_street_deals(
area_min = area_m2 * (1.0 - area_tolerance) area_min = area_m2 * (1.0 - area_tolerance)
area_max = 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 = ( rows = (
db.execute( db.execute(
text( text(
""" f"""
SELECT address, area_m2, rooms, floor, total_floors, SELECT address, area_m2, rooms, floor, total_floors,
price_rub, price_per_m2, deal_date, source price_rub, price_per_m2, deal_date, source
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND address ILIKE :street_pattern AND address ILIKE :street_pattern
AND address ~* :street_regex AND address ~* :street_regex
{city_filter}
AND rooms = CAST(:rooms AS integer) AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
@ -1683,6 +1730,7 @@ def get_street_deals(
{ {
"street_pattern": "%" + street_name + "%", "street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M", "street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None,
"rooms": rooms, "rooms": rooms,
"area_min": area_min, "area_min": area_min,
"area_max": area_max, "area_max": area_max,

View file

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

View file

@ -0,0 +1,71 @@
"""RequestAuditMiddleware — пишет `api_request` (+ дедуплицированный `login`)
события в `user_events` для каждого аутентифицированного `/api/*` запроса.
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
ответ клиенту) и через fire-and-forget `schedule_event` сбой аудита никогда
не влияет на HTTP-ответ.
"""
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"})
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")
# Общий behavior/activity-поток — каждый authenticated API-запрос.
# /api/v1/admin/* исключаем: это ops-действия (просмотр самих
# дашбордов аудита/аналитики), а не поведение пилота — иначе
# запросы дашборда зашумляют top_paths и счётчики активности.
# login ниже логируем всегда (вход админа с IP — валидный аудит).
if not path.startswith("/api/v1/admin/"):
schedule_event(
event_type="api_request",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=request.method,
)
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
# на (юзер, IP, устройство).
if should_log_login(username, ip, ua):
schedule_event(
event_type="login",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=request.method,
)
except Exception:
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
return response

View file

@ -23,12 +23,13 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in from app.api.v1 import admin, audit, brand, buildings, geocode, lead, me, search, trade_in
from app.core.auth import get_role from app.core.auth import get_role, is_path_allowed
from app.core.config import settings from app.core.config import settings
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping from app.core.fdw import ensure_fdw_user_mapping
from app.core.ratelimit import RateLimitMiddleware from app.core.ratelimit import RateLimitMiddleware
from app.core.request_audit import RequestAuditMiddleware
from app.observability.sentry_scrub import scrub_pii_event from app.observability.sentry_scrub import scrub_pii_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -108,6 +109,14 @@ app = FastAPI(
# X-Authenticated-User там не приходит из Caddy. # X-Authenticated-User там не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) _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")
@app.middleware("http") @app.middleware("http")
@ -160,6 +169,39 @@ async def rbac_guard(
status_code=403, status_code=403,
content={"detail": "admin only"}, 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) return await call_next(request)
@ -172,6 +214,8 @@ app.add_middleware(
) )
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза. # Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
app.add_middleware(RateLimitMiddleware) app.add_middleware(RateLimitMiddleware)
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
app.add_middleware(RequestAuditMiddleware)
@app.get("/health") @app.get("/health")
@ -181,6 +225,7 @@ def health() -> dict[str, str]:
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) 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(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(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(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(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])

View file

@ -1,22 +1,51 @@
"""Хук 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 и пр.) Redact-ит consumer-PII (client_name / client_phone / client_email и пр.)
из error events до отправки в GlitchTip estimator/trade-in flow таскает из error events до отправки в GlitchTip estimator/trade-in flow таскает
эти поля, а send_default_pii=False их не покрывает (это user-data в эти поля, а send_default_pii=False их не покрывает (это user-data в
request.data / extra / contexts, не PII-заголовки). 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 from __future__ import annotations
import re
from typing import Any from typing import Any
from sentry_sdk.types import Event from sentry_sdk.types import Event
_REDACTED = "[REDACTED]" _REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive). # Ключи consumer-PII (нижний регистр; сверка case-insensitive).
_PII_KEYS = frozenset( _PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"})
{"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")
def _scrub(obj: Any) -> None: def _scrub(obj: Any) -> None:
@ -42,3 +71,34 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
_scrub(event.get("extra")) _scrub(event.get("extra"))
_scrub(event.get("contexts")) _scrub(event.get("contexts"))
return 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 мес) — другая база и другой смысл «торга», см. докстринг # конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг
# HouseAnalyticsKpi. # HouseAnalyticsKpi.
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.720.93 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 адреса ── # ── DaData enrichment (PR Q1) — on-demand для target адреса ──
# canonical_address — DaData-нормализованная форма (с улицей в short form). # canonical_address — DaData-нормализованная форма (с улицей в short form).
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра). # house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
@ -266,7 +268,7 @@ class AggregatedEstimate(BaseModel):
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность / # оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges. # широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда # manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
# рекомендация не сработала (или флаг estimate_manual_review_enabled выключен). # рекомендация не сработала.
manual_review_recommended: bool = False manual_review_recommended: bool = False
manual_review_reasons: list[str] = Field(default_factory=list) manual_review_reasons: list[str] = Field(default_factory=list)
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ── # ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──

View file

@ -1,8 +1,12 @@
"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт. """Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт.
Правила: Правила:
- Лимит = settings.estimate_quota_limit успешных оценок за календарный месяц - Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный
(UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, default 15. месяц (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»).
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'. - Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality). - Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) unlimited, - Если заголовок X-Authenticated-User отсутствует (dev без Caddy) unlimited,
@ -53,11 +57,34 @@ def is_unlimited(username: str) -> bool:
return False return False
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: def get_status(db: Session, username: str | None) -> dict:
"""Возвращает статус квоты для пользователя. """Возвращает статус квоты для пользователя.
Если username is None unlimited True, used 0, remaining 15. Если username is None unlimited True, used 0, remaining = MONTHLY_LIMIT.
Если unlimited used = фактический или 0, remaining = limit. Если unlimited used = фактический или 0, remaining = limit (per-user override
или глобальный MONTHLY_LIMIT).
""" """
if username is None: if username is None:
return { return {
@ -69,6 +96,7 @@ def get_status(db: Session, username: str | None) -> dict:
unlimited = is_unlimited(username) unlimited = is_unlimited(username)
period = current_period() period = current_period()
limit = user_limit(db, username)
row = db.execute( row = db.execute(
text( text(
@ -84,15 +112,18 @@ def get_status(db: Session, username: str | None) -> dict:
if unlimited: if unlimited:
return { return {
"limit": MONTHLY_LIMIT, "limit": limit,
"used": used, "used": used,
"remaining": MONTHLY_LIMIT, "remaining": limit,
"unlimited": True, "unlimited": True,
} }
remaining = max(0, MONTHLY_LIMIT - used) # Защитный кламп: remaining никогда не превышает limit, даже если used всё же
# снова просочится отрицательным (прежний бонус-хак) — max(0, used) обнуляет
# отрицательный used перед вычитанием.
remaining = max(0, limit - max(0, used))
return { return {
"limit": MONTHLY_LIMIT, "limit": limit,
"used": used, "used": used,
"remaining": remaining, "remaining": remaining,
"unlimited": False, "unlimited": False,
@ -111,6 +142,7 @@ def check_and_raise(db: Session, username: str | None) -> None:
return return
period = current_period() period = current_period()
limit = user_limit(db, username)
row = db.execute( row = db.execute(
text( text(
""" """
@ -122,13 +154,13 @@ def check_and_raise(db: Session, username: str | None) -> None:
).fetchone() ).fetchone()
used = row.used if row is not None else 0 used = row.used if row is not None else 0
if used >= MONTHLY_LIMIT: if used >= limit:
logger.warning( logger.warning(
"quota exhausted: username=%r period=%s used=%d limit=%d", "quota exhausted: username=%r period=%s used=%d limit=%d",
username, username,
period, period,
used, used,
MONTHLY_LIMIT, limit,
) )
raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE) raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE)
@ -140,14 +172,16 @@ def increment(db: Session, username: str | None) -> bool:
None / unlimited True (no-op success). None / unlimited True (no-op success).
Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE
два параллельных запроса при used=MONTHLY_LIMIT-1 не могут оба инкрементировать два параллельных запроса при used=lim-1 не могут оба инкрементировать (второй
(второй упрётся в WHERE RETURNING пуст False). Свежая вставка (used=1) НЕ упрётся в WHERE RETURNING пуст False). Свежая вставка (used=1) НЕ задевается
задевается WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит. WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит. `lim`
персональный лимит (user_limit), НЕ жёстко зашитый глобальный MONTHLY_LIMIT.
""" """
if username is None or is_unlimited(username): if username is None or is_unlimited(username):
return True return True
period = current_period() period = current_period()
lim = user_limit(db, username)
row = db.execute( row = db.execute(
text( text(
""" """
@ -161,7 +195,7 @@ def increment(db: Session, username: str | None) -> bool:
RETURNING used RETURNING used
""" """
), ),
{"u": username, "p": period, "lim": MONTHLY_LIMIT}, {"u": username, "p": period, "lim": lim},
).fetchone() ).fetchone()
db.commit() db.commit()
ok = row is not None ok = row is not None
@ -172,6 +206,6 @@ def increment(db: Session, username: str | None) -> bool:
"quota increment refused (atomic, #747): username=%r period=%s limit=%d", "quota increment refused (atomic, #747): username=%r period=%s limit=%d",
username, username,
period, period,
MONTHLY_LIMIT, lim,
) )
return ok return ok

View file

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

View file

@ -167,10 +167,27 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)") logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
return None return None
if status in (401, 403): if status in (401, 403):
logger.error( body_preview = (response.text or "")[:200]
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.", # 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
status, # но услуга «Стандартизация» (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 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 return None
if status >= 500: if status >= 500:
logger.warning("dadata: HTTP %d — transient server error", status) logger.warning("dadata: HTTP %d — transient server error", status)
@ -246,6 +263,12 @@ class DadataSuggestion:
street: str | None street: str | None
city: str | None city: str | None
kind: str 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: def _classify_kind(fias_level: Any) -> str:
@ -295,11 +318,17 @@ def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
street=data.get("street"), street=data.get("street"),
city=data.get("city"), city=data.get("city"),
kind=_classify_kind(data.get("fias_level")), 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( 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]: ) -> list[DadataSuggestion]:
"""Автокомплит адресов через DaData /suggest/address. """Автокомплит адресов через DaData /suggest/address.
@ -307,6 +336,13 @@ async def suggest_addresses(
query: текст для подсказки (минимум 2 символа). query: текст для подсказки (минимум 2 символа).
limit: сколько вариантов вернуть (DaData параметр `count`, max 20). limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ. city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
Игнорируется, если задан `region`.
region: область для hard-constraint в `locations` используется вместо
`city`, когда нужен охват шире одного города (например, вся
Свердловская область / region 66). DaData `locations` сам по себе уже
жёсткий фильтр (не boost) на уровне указанного admin-поля доп.
параметров не требуется. По умолчанию не задан поведение (и body
запроса) для существующих вызовов не меняется.
Returns: Returns:
list[DadataSuggestion] пустой список если: list[DadataSuggestion] пустой список если:
@ -334,7 +370,13 @@ async def suggest_addresses(
"query": query.strip(), "query": query.strip(),
"count": max(1, min(int(limit), 20)), "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}] body["locations"] = [{"city": city}]
try: try:
@ -352,9 +394,7 @@ async def suggest_addresses(
logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)") logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
return [] return []
if status in (401, 403): if status in (401, 403):
logger.error( logger.error("dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status)
"dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status
)
return [] return []
if status >= 500: if status >= 500:
logger.warning("dadata suggest: HTTP %d — transient server error", status) 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)) logger.info("dadata suggest: %r%d вариантов", query[:60], len(out))
return 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 pathlib
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID
import matplotlib import matplotlib
@ -376,11 +375,60 @@ def _mono(text: str) -> str:
return f"<span class='mono'>{text}</span>" return f"<span class='mono'>{text}</span>"
def _report_number(estimate_id: UUID) -> str: # Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код.""" # захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
short = int(estimate_id.int) % 10_000 # екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
long = int(estimate_id.int) % 10_000_000_000 # теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
return f"EKБ-{short:04d}-{long:010d}" # _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: def _expires_date(estimate: AggregatedEstimate) -> dt.date:
@ -518,7 +566,7 @@ def _page_footer(
# тела страницы) и был источником сложности; заменён на простую тонкую # тела страницы) и был источником сложности; заменён на простую тонкую
# градиентную линию-разделитель между строками meta/wordmark. # градиентную линию-разделитель между строками meta/wordmark.
return f""" return f"""
<div style="margin-top:20pt;"> <div style="margin-top:6pt;">
{note_html} {note_html}
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt; <div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em; 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 = ( explainer = (
f'<p style="margin:6pt 0 2pt 0;padding:6pt 10pt;background:{_ACCENT_SOFT};' 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.3;">' f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.25;">'
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. " "<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП " "<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
"Росреестра, обычно ниже запроса." "Росреестра, обычно ниже запроса."
@ -821,7 +869,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
return ( return (
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;' 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}" f"{explainer}"
) )
@ -970,7 +1018,7 @@ def _quality_card_html(estimate: AggregatedEstimate) -> str:
cv_str = f"{cv}%" if cv is not None else "" cv_str = f"{cv}%" if cv is not None else ""
gauge = _data_quality_gauge_svg(estimate.confidence) gauge = _data_quality_gauge_svg(estimate.confidence)
return f""" 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;"> display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
{gauge} {gauge}
<div style="flex:1;min-width:170pt;overflow-wrap:anywhere;"> <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 if estimate.expires_at is not None
else today + dt.timedelta(days=30) else today + dt.timedelta(days=30)
) )
report_num = _report_number(estimate.estimate_id) report_num = _report_number(estimate)
# Короткий адрес (для cover): берём первую часть до запятой # Короткий адрес (для cover): берём первую часть до запятой
full_address = input_snapshot.get("address", "") full_address = input_snapshot.get("address", "")
@ -1038,30 +1086,38 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
house_label = house_labels.get(house_type, "") if house_type else "" house_label = house_labels.get(house_type, "") if house_type else ""
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано" repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False 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;
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно. # если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
days_min, days_max = _days_on_market_range(estimate.analogs) days_range = _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)
)
listings_bar = _price_range_chart_svg( listings_bar = _price_range_chart_svg(
estimate.range_low_rub, estimate.range_low_rub,
estimate.range_high_rub, estimate.range_high_rub,
sub_label="Активный рынок с аналогичным состоянием ремонта", sub_label="Активный рынок с аналогичным состоянием ремонта",
days_min=days_min, days_min=days_range[0] if days_range else None,
days_max=days_max, days_max=days_range[1] if days_range else None,
show_days=True, show_days=days_range is not None,
) )
deals_bar = _price_range_chart_svg( # Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ
deals_low, # рассчитанный дисконт запрос→продажа (тот же, что chip «N%» в dual-price),
deals_high, # а не хардкод «1018%», который противоречил бы вычисленному «N%» (#pdf-honesty).
sub_label="Диапазон цен по фактическим сделкам", 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 = "" disclaimer_html = ""
if brand.pdf_disclaimer: if brand.pdf_disclaimer:
@ -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"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
f"{_html.escape(brand.pdf_disclaimer)}</p>" f"{_html.escape(brand.pdf_disclaimer)}</p>"
) )
footer_html = _page_footer(brand, report_num, today, valid_until=expires)
heading_html = _section_heading( heading_html = _section_heading(
"01", "01",
"АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ", "АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
@ -1082,12 +1136,10 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
return f""" return f"""
<div style="page-break-after:always;"> <div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html} {heading_html}
<!-- Параметры квартиры (dotted-row ObjectSummary.tsx summary-row идиома) --> <!-- Параметры квартиры (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> <tr><td class="dotted-row"> отчета</td>
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr> <td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
<tr><td class="dotted-row">Дата отчета</td> <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> <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">{address}</td></tr>
<tr><td class="dotted-row">Год постройки</td> <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">{house_label}</td></tr>
<tr><td class="dotted-row">Этаж / этажность</td> <tr><td class="dotted-row">Этаж / этажность</td>
<td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr> <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)} {_dual_price_block(estimate, brand)}
<div style="break-inside:avoid;page-break-inside:avoid;"> <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> <span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
</p> </p>
{listings_bar} {listings_bar}
</div> </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> </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> <tr>
<td class="advice-title">Цены в объявлениях ожидания собственников</td> <td class="advice-title">Цены в объявлениях ожидания собственников</td>
<td class="advice-text">Фактические сделки проходят на 1018% ниже, что подтверждают <td class="advice-text">{advice_discount_text}</td>
Росреестр, ДомКлик и продажи агентств недвижимости</td>
</tr> </tr>
<tr> <tr>
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td> <td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
<td class="advice-text">Инвестиции в 400 600 тыс. руб. повышают цену объекта всего <td class="advice-text">Инвестиции в 400 600 тыс. руб. повышают цену объекта всего
на 150 250 тыс. руб покупатель оценивает общее состояние квартиры</td> на 150 250 тыс. руб покупатель оценивает общее состояние квартиры</td>
</tr> </tr>
<tr>
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
</tr>
</table> </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}; <p style="margin-top:3pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
padding-top:4pt;overflow-wrap:anywhere;"> padding-top:3pt;overflow-wrap:anywhere;">
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id} <strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
</p> </p>
{disclaimer_html} {disclaimer_html}
{footer_html}
</div> </div>
""" """
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]: def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
"""Min/max days_on_market по аналогам. Fallback 4-118 при отсутствии данных.""" """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] days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
if not days: if not days:
return 4, 118 return None
return min(days), max(days) 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} комнаты" rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
# Полоска диапазона # Полоска диапазона — срок экспозиции только при реальных days_on_market
days_min, days_max = _days_on_market_range(estimate.analogs) # (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
days_range = _days_on_market_range(estimate.analogs)
range_bar = _price_range_chart_svg( range_bar = _price_range_chart_svg(
estimate.range_low_rub, estimate.range_low_rub,
estimate.range_high_rub, estimate.range_high_rub,
sub_label="Рынок", sub_label="Рынок",
days_min=days_min, days_min=days_range[0] if days_range else None,
days_max=days_max, days_max=days_range[1] if days_range else None,
show_days=True, show_days=days_range is not None,
) )
# Топ-5 примеров (отсортированных по distance) # Топ-5 примеров (отсортированных по distance)
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5] top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
examples_rows = _examples_rows(top5) 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", "РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ") heading_html = _section_heading("02", "РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
return f""" return f"""
<div style="page-break-after:always;"> <div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html} {heading_html}
<!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) --> <!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
@ -1306,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
<tbody>{examples_rows}</tbody> <tbody>{examples_rows}</tbody>
</table> </table>
{footer_html}
</div> </div>
""" """
@ -1391,6 +1428,21 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
today = dt.date.today() today = dt.date.today()
period_start = today - dt.timedelta(days=estimate.period_months * 30) 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 (не захардкоженный список). # Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate). # Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
_deal_source_keys = {"etazhi", "domklik", "rosreestr"} _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] top5 = estimate.actual_deals[:5]
examples_rows = _examples_rows(top5) 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", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ") heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
return f""" return f"""
<div style="page-break-after:always;"> <div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html} {heading_html}
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;"> <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}; <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;"> background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
По данным реальных сделок, квартиры продаются в среднем на 1018% дешевле, {deals_discount_text}
чем заявлено в объявлениях (Екатеринбург, 2026)
</div> </div>
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;"> <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> <tbody>{examples_rows}</tbody>
</table> </table>
{footer_html}
</div> </div>
""" """
@ -1517,7 +1555,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
# ── Page 4: Offer (Trade-In vs Самопродажа) ────────────────────────────────── # ── 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 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_low = int(sold_price * rieltor_pct_low / 100)
rieltor_high = int(sold_price * rieltor_pct_high / 100) rieltor_high = int(sold_price * rieltor_pct_high / 100)
# 3 месяца аренды 1-комнатной квартиры, ЕКБ 2026: ~28-45 тыс. руб./мес. # 3 месяца аренды квартиры, ЕКБ 2026 (базовая ставка — студия/1-комн., ~28-45
rent_low, rent_high = 84_000, 135_000 # тыс. руб./мес.), масштабируется по числу комнат объекта (#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 juridical_low, juridical_high = 15_000, 50_000
# Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого # Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
@ -1558,22 +1612,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
brand_short = _html.escape(brand.name) brand_short = _html.escape(brand.name)
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In" 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""" return f"""
<div><!-- last page: NO page-break-after --> <div><!-- last page: NO page-break-after -->
{_page_header(brand, report_num, today)}
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")} {_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;"> <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};"> <tr style="border-bottom:1px solid {_BORDER};">
<td style="padding:6pt 4pt;"> <td style="padding:6pt 4pt;">
<div class="bold">Аренда после сделки</div> <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>
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;"> <td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
бесплатно</td> бесплатно</td>
@ -1738,7 +1780,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
</div> </div>
</div> </div>
{footer_html} <p style="margin-top:14pt;font-size:{_FS_XS};color:{_MUTED};">
Расчёт носит ориентировочный характер и не является офертой.
</p>
</div> </div>
""" """
@ -1794,8 +1838,12 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
}} }}
@page {{ @page {{
size: A4; 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; }} * {{ box-sizing: border-box; }}
body {{ body {{
font-family: 'PT Sans', 'DejaVu Sans', sans-serif; font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
@ -2015,7 +2063,7 @@ def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot:
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь. Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
""" """
address = _html.escape(input_snapshot.get("address", "")) address = _html.escape(input_snapshot.get("address", ""))
report_num = _report_number(estimate.estimate_id) report_num = _report_number(estimate)
today = dt.date.today() today = dt.date.today()
return f""" return f"""
<div> <div>
@ -2103,11 +2151,28 @@ def generate_trade_in_pdf(
if estimate.insufficient_data: if estimate.insufficient_data:
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand) body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
else: 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 = ( body_html = (
_build_cover(estimate, input_snapshot, brand) chrome_html
+ _build_cover(estimate, input_snapshot, brand)
+ _build_listings_page(estimate, input_snapshot, brand) + _build_listings_page(estimate, input_snapshot, brand)
+ _build_deals_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 = ( 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) 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 ─────────────────────────────────────────────────── # ── Address normalisation ───────────────────────────────────────────────────
def normalize_address(address: str) -> str: def normalize_address(address: str) -> str:
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace. """Нормализация для cache lookup: lowercase + trim + collapse whitespace.
@ -166,11 +325,35 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
# ── Provider: Nominatim (OSM, без ключа) ──────────────────────────────────── # ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None: def _nominatim_region_ok(item: dict) -> bool | None:
"""Single Nominatim search. Возвращает первый item или 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( response = await client.get(
"https://nominatim.openstreetmap.org/search", "https://nominatim.openstreetmap.org/search",
@ -180,21 +363,26 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
"limit": "3", "limit": "3",
"countrycodes": "ru", "countrycodes": "ru",
"addressdetails": "1", "addressdetails": "1",
"viewbox": EKB_BBOX["viewbox"], "viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1", # строго в ЕКБ bbox "bounded": "1", # строго в пределах области (region 66)
}, },
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
oblast_fallback: dict | None = None
for item in data: for item in data:
try: try:
lat_f = float(item["lat"]) lat_f = float(item["lat"])
lon_f = float(item["lon"]) lon_f = float(item["lon"])
if is_within_ekb_bbox(lat_f, lon_f):
return item
except Exception: except Exception:
continue 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)) @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. """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
Бан-policy: User-Agent с email обязателен. Бан-policy: User-Agent с email обязателен.
Tier 1: bounded ЕКБ на оригинальный адрес. Tier 1: bounded область (region 66) на оригинальный адрес.
Tier 2: bounded ЕКБ на typo-варианты (Цвилинга Цвиллинга). Tier 2: bounded область (region 66) на typo-варианты (Цвилинга Цвиллинга).
""" """
headers = { headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "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, лучшее покрытие РФ) ───────────── # ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) @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: 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 (центр ЕКБ) для приоритизации местных результатов, Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
но БЕЗ rspn чтобы fuzzy matching работал при опечатках. но БЕЗ rspn чтобы fuzzy matching работал при опечатках.
""" """
# Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest) # Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66
geocode_query = address if "екатеринбург" in address.lower() else f"Екатеринбург, {address}" # (типичный кейс из suggest, либо явный запрос по другому городу области).
geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}"
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get( response = await client.get(
"https://geocode-maps.yandex.ru/1.x/", "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: if not members:
return None 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 best = None
oblast_fallback = None
for m in members: for m in members:
obj = m.get("GeoObject", {}) obj = m.get("GeoObject", {})
try: try:
lon_str, lat_str = obj["Point"]["pos"].split() lon_str, lat_str = obj["Point"]["pos"].split()
lat_f, lon_f = float(lat_str), float(lon_str) lat_f, lon_f = float(lat_str), float(lon_str)
if is_within_ekb_bbox(lat_f, lon_f):
best = obj
break
except Exception: except Exception:
continue continue
if _yandex_region_ok(obj) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
best = obj
break
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
oblast_fallback = obj
if best is None: if best is None:
# Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то) best = oblast_fallback
best = members[0]["GeoObject"]
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() lon_str, lat_str = best["Point"]["pos"].split()
precision_raw = ( precision_raw = (
@ -310,6 +545,8 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
# ── Suggest (автокомплит) ─────────────────────────────────────────────────── # ── Suggest (автокомплит) ───────────────────────────────────────────────────
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85 # ЕКБ 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 = { EKB_BBOX = {
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE) "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) "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 с привязкой к карте они бесполезны). типа город/район, для autocomplete с привязкой к карте они бесполезны).
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»). 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] = [] out: list[GeocodeSuggestion] = []
for s in raw: for s in raw:
if s.lat is None or s.lon is None: 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]: async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
"""Yandex Geocoder с авто-fallback на typo-tolerant режим. """Yandex Geocoder с авто-fallback на typo-tolerant режим.
Tier 1: bounded ЕКБ (rspn=1) на оригинальный query. Tier 1: bounded ЕКБ (rspn=1) быстрый путь для основного (ЕКБ) трафика.
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных). 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: async with httpx.AsyncClient(timeout=8.0) as client:
# Tier 1: strict bounded на оригинал # Tier 1: strict bounded на оригинал (ЕКБ fast path)
members = await _yandex_geocode_request( members = await _yandex_geocode_request(
client, client,
api_key, api_key,
f"Екатеринбург, {query}", prefixed_query,
limit, limit,
bounded=True, bounded=True,
) )
@ -441,12 +685,15 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
if results: if results:
return results return results
# Tier 2: bounded на typo-варианты # Tier 2: bounded на typo-варианты (тот же ЕКБ fast path)
for variant in _typo_variants(query, limit=4): 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( members = await _yandex_geocode_request(
client, client,
api_key, api_key,
f"Екатеринбург, {variant}", variant_query,
limit, limit,
bounded=True, bounded=True,
) )
@ -454,21 +701,21 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
if results: if results:
return results return results
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox # Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
members = await _yandex_geocode_request( members = await _yandex_geocode_request(
client, client,
api_key, api_key,
f"Екатеринбург, {query}", prefixed_query,
limit, limit,
bounded=False, bounded=False,
) )
results = _parse_yandex_members(members) 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] in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
return in_ekb return in_oblast
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]: 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( response = await client.get(
"https://nominatim.openstreetmap.org/search", "https://nominatim.openstreetmap.org/search",
params={ params={
@ -476,7 +723,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
"format": "json", "format": "json",
"limit": str(limit), "limit": str(limit),
"countrycodes": "ru", "countrycodes": "ru",
"viewbox": EKB_BBOX["viewbox"], "viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1", "bounded": "1",
"addressdetails": "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)) @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]: async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).""" """Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).
Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области
региона 66 иначе не режем явные запросы по другим городам области.
"""
headers = { headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
"Accept": "application/json", "Accept": "application/json",
"Accept-Language": "ru,en;q=0.8", "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: async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
# Tier 1: оригинальный query # Tier 1: оригинальный query
data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit) data = await _nominatim_query_multi(client, suffixed_query, limit)
# Tier 2: typo-варианты если оригинал пустой # Tier 2: typo-варианты если оригинал пустой
if not data: if not data:
for variant in _typo_variants(query, limit=3): for variant in _typo_variants(query, limit=3):
await asyncio.sleep(1.0) # Nominatim 1 req/sec 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: if data:
logger.info("nominatim suggest typo-fixed: %s%s", query, variant) logger.info("nominatim suggest typo-fixed: %s%s", query, variant)
break 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]: 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). db: если передан cadastral lookup через gendesign_cad_buildings (первый tier).
DaData /suggest (PR Q2) token-only, 10k/день, заменяет Yandex который 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: if not query or len(query.strip()) < 2:
return [] return []
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов # Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
if db is not None: # 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 → точный матч по дом-маркеру. # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits. # Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
parsed = _parse_street_house(query.strip()) parsed = _parse_street_house(query.strip())
@ -910,8 +1170,14 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
# 2. Локальные источники по street+house (без внешнего API). # 2. Локальные источники по street+house (без внешнего API).
parsed = _parse_street_house(address.strip()) 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%). # 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
if parsed is not None: if use_local_ekb and parsed is not None:
street, house = parsed street, house = parsed
try: try:
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house) 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 → точный матч по дом-маркеру. # Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм # 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 street, house = parsed
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house) hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
if hit is not None: if hit is not None:
@ -960,29 +1226,32 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
return result return result
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм) # 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:
if cad_suggestions: cad_suggestions = await asyncio.to_thread(
s = cad_suggestions[0] _cadastral_forward_sync, db, address.strip(), limit=1
result = GeocodeResult(
lat=s.lat,
lon=s.lon,
full_address=s.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact",
) )
await asyncio.to_thread(_cache_put, db, addr_norm, result) if cad_suggestions:
logger.info("geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) s = cad_suggestions[0]
return result result = GeocodeResult(
lat=s.lat,
lon=s.lon,
full_address=s.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim
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
)
return result
# 3. Yandex (если есть key) с typo-fallback # 3. Yandex (если есть key) с typo-fallback
if settings.yandex_geocoder_api_key: if settings.yandex_geocoder_api_key:
try: try:
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key) result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
# Если результат вне ЕКБ — пробуем typo-варианты # Если результат вне области (region 66) — пробуем typo-варианты
in_ekb = ( in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
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_oblast:
)
if result is not None and in_ekb:
await asyncio.to_thread(_cache_put, db, addr_norm, result) await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result return result
@ -994,7 +1263,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
continue continue
if result is None: if result is None:
continue 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) await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info( logger.info(
"geocode yandex typo-fixed: %s%s → (%.5f, %.5f)", "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 for p in parts
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ") if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
] ]
# Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец # Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
# в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
locality = None locality = None
rest: list[str] = [] rest: list[str] = []
for p in filtered: for p in filtered:
if locality is None and p in {"Екатеринбург"}: if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
locality = p locality = p
else: else:
rest.append(p) rest.append(p)

View file

@ -24,6 +24,24 @@ logger = logging.getLogger(__name__)
# small enough to bound storage & decode cost. # small enough to bound storage & decode cost.
_MAX_DIMENSION = 2400 _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, # JPEG quality. 85 is industry standard for photos — visually lossless,
# ~70% size reduction vs original phone JPEGs. # ~70% size reduction vs original phone JPEGs.
_JPEG_QUALITY = 85 _JPEG_QUALITY = 85
@ -46,10 +64,28 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
(sanitized_bytes, "image/jpeg") both ready for storage/serving. (sanitized_bytes, "image/jpeg") both ready for storage/serving.
Raises: 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: try:
with Image.open(io.BytesIO(content)) as img: 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 img.load() # force full decode; surfaces UnidentifiedImageError early
# Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone # Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone
# portrait photos (Orientation=6/8) end up rotated 90/270° once the tag # 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. # save() does NOT carry over .info / EXIF unless we pass exif=...; we don't.
img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True) img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True)
return buf.getvalue(), SANITIZED_CONTENT_TYPE return buf.getvalue(), SANITIZED_CONTENT_TYPE
except ImageSanitizationError:
raise # already a handled, user-facing rejection (byte/pixel cap)
except UnidentifiedImageError as e: except UnidentifiedImageError as e:
raise ImageSanitizationError("not a recognized image format") from 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: except Exception as e:
# PIL can raise broad errors (DecompressionBombError, OSError on truncated, ...); # PIL can raise broad errors (OSError on truncated, DecompressionBombWarning
# normalize to one user-facing error type. # promoted to error, ...); normalize to one user-facing error type.
logger.warning("image sanitization failed: %s", e) logger.warning("image sanitization failed: %s", e)
raise ImageSanitizationError(f"image decode failed: {e}") from e raise ImageSanitizationError(f"image decode failed: {e}") from e

View file

@ -53,11 +53,35 @@ CATEGORY_WEIGHTS: dict[str, float] = {
DEFAULT_RADIUS_M = 1200 DEFAULT_RADIUS_M = 1200
DEFAULT_TOP_N = 7 DEFAULT_TOP_N = 7
# Теоретический максимум суммы весов top-7 POI при идеальном расположении (d=0): # Теоретический максимум суммы весов top-7 POI.
# w_i = cat_weight_i / (0 + 100) → max_sum = Σ(top7 cat_weights) / 100. #
# ИСТОРИЯ (audit R2 #7a): раньше максимум считался при d=0 (все top-7 категорий прямо у
# порога) — недостижимо на практике. Аудит показал, что даже сильная центральная локация в
# Екб (транспорт/садик/супермаркет рядом, школа/метро/ТЦ уже заметно дальше — типичный
# профиль квартала, реалистичные дистанции по категориям ~80-700м) даёт raw_sum лишь
# ~0.08-0.095, т.е. score ~25-30/100 при старой нормировке (/100 при d=0) → coef ~0.975-0.98.
# Сильная локация систематически читалась как "немного снижает стоимость" — знаменатель
# сжимал реалистичные scores в нижнюю треть шкалы 0-100.
#
# ФИКС: нормируем не на d=0, а на РЕФЕРЕНСНОЕ расстояние _REF_DISTANCE_M — top-7 весов,
# как если бы каждая категория была на этом расстоянии. REF=100м ровно удваивает
# знаменатель (distance_m + 100): 100+100=200 вместо 0+100=100 → теоретический максимум
# вдвое ниже прежнего → реалистичные scores вдвое выше: сильный центр ~25-30 → ~50-60
# (coef ~1.00-1.01), с запасом выше 1.0 для исключительно плотных локаций (все top-7
# категорий <150м). Слабые/разреженные локации остаются далеко ниже 1.0; пустая
# osm_poi_ekb_local (рефреш не запускался) по-прежнему даёт coef=1.0 через отдельный
# graceful-fallback путь в compute_location_coef, эту нормировку не трогающий.
#
# ВНИМАНИЕ: REF_DIST — калибровочная константа для MVP-эвристики (см. _score_to_coef),
# НЕ откалибрована на реальных ценовых дельтах — только на распределении реалистичных POI-
# профилей, чтобы диапазон [0.95, 1.05] соответствовал интуиции "сильный центр ≈ 1.0".
#
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica). # 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]) _TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315 _REF_DISTANCE_M: float = 100.0
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / (
_REF_DISTANCE_M + 100.0
) # ≈ 0.1575 (было 0.315 при d=0)
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах # coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель). # (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).

View file

@ -16,14 +16,25 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.matching.normalize import ( from app.services.matching.normalize import (
EKB_CITY_TOKEN,
address_fingerprint, address_fingerprint,
has_city_token,
has_house_number, has_house_number,
house_number_token, house_number_token,
normalize_address, normalize_address,
resolve_city_token,
) )
logger = logging.getLogger(__name__) 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( def match_or_create_house(
db: Session, db: Session,
@ -180,30 +191,95 @@ def match_or_create_house(
# Without 2b, two scrapers for the same house with slightly different lat/lon (beyond # 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, # the 4-decimal rounding tolerance) would produce distinct fingerprints, miss Tier 2a,
# and each potentially create a duplicate house row. # and each potentially create a duplicate house row.
row = ( #
db.execute( # Tier-2a/2b oblast city guard (#2500 follow-up, closes the Tier-2a mis-bucket that the
text("SELECT house_id FROM house_address_aliases " "WHERE fingerprint = :fp LIMIT 1"), # #2500 Tier-2b geo-guard could not reach): a coord-less card whose address explicitly
{"fp": fp}, # 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,
) )
.mappings()
.first() row = None
) if not _skip_oblast_alias:
if row is None and norm_addr and has_num:
# 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.
row = ( row = (
db.execute( db.execute(
text( text("SELECT house_id FROM house_address_aliases WHERE fingerprint = :fp LIMIT 1"),
"SELECT house_id FROM house_address_aliases " {"fp": fp},
"WHERE normalized_address = :na LIMIT 1"
),
{"na": norm_addr},
) )
.mappings() .mappings()
.first() .first()
) )
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(
"SELECT house_id FROM house_address_aliases "
"WHERE normalized_address = :na LIMIT 1"
),
{"na": norm_addr},
)
.mappings()
.first()
)
else:
logger.info(
"house tier2b skip: bare common-street, no coords/city na=%r src=%s",
norm_addr,
ext_source,
)
if row: if row:
house_id = int(row["house_id"]) house_id = int(row["house_id"])
_upsert_house_source( _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. 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. 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 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 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 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) INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source)
VALUES (CAST(:hid AS bigint), :na, :fp, :src) VALUES (CAST(:hid AS bigint), :na, :fp, :src)
ON CONFLICT (normalized_address) DO UPDATE SET ON CONFLICT (normalized_address) DO UPDATE SET
fingerprint = EXCLUDED.fingerprint, fingerprint = CASE
source = EXCLUDED.source 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, "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 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: 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). """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) 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 ─────────────────────────────── # ── newbuilding_enrich — async, owns lifecycle ───────────────────────────────
async def _job_newbuilding_enrich( async def _job_newbuilding_enrich(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext 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"), "sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"), "rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"), "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"), "newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler( "yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep" _job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"

View file

@ -5,31 +5,62 @@
открытых данных Росреестра. открытых данных Росреестра.
- ALERT: залогировать actionable-сообщение, если квартал доступен. - ALERT: залогировать actionable-сообщение, если квартал доступен.
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders это ручной - НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders это ручной
ops-шаг. После алерта оператор запускает: ops-шаг. После алерта оператор запускает (оба скрипта живут вне этого
репозитория при смене базового пути их тоже нужно поправить вручную):
data/sql/02_load_all_quarters.sh data/sql/02_load_all_quarters.sh
tradein-mvp/deploy/import-rosreestr.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, который чинит этот модуль.
Файлы именуются по шаблону: АКТУАЛЬНОЕ (2026-07) расположение датасетов открытый Apache autoindex без
dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip авторизации:
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 живёт по адресу: Вместо того чтобы вручную высчитывать percent-encoding (хрупко: имена папок
https://rosreestr.gov.ru/opendata/f.json (список всех датасетов) расставляют руками, возможны отличия в пробелах/пунктуации между кварталами)
поллер запрашивает autoindex HTML и парсит href'ы, декодируя каждый
percent-encoded href как cp1251 (urllib.parse.unquote(href, encoding="cp1251")),
затем ищет папку/файл по совпадению с ожидаемым (год, квартал) в декодированном
имени. См. check_new_quarter_available().
Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал. КЛЮЧЕВАЯ проверка доступности (защита от soft-404, см. "ИСТОРИЯ" выше): найденный
Формат ссылки: файл датасета считается ДОСТУПНЫМ только если HEAD-ответ имеет
https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip Content-Type: application/zip И Content-Length больше ~100 KB. Голый HTTP 200
недостаточен.
При сетевой ошибке / HTTP 5xx логируем warning, возвращаем available=False. rosreestr.gov.ru отдаёт сертификат от российского TLS root CA, которому нет
Статус 404 квартал не опубликован available=False (штатный случай до начала августа). доверия в стандартном 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 from __future__ import annotations
import logging import logging
import re
from typing import Any from typing import Any
from urllib.parse import quote, unquote, urljoin
import httpx import httpx
from sqlalchemy import text from sqlalchemy import text
@ -37,16 +68,24 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Базовый URL открытых данных Росреестра (прямая ссылка на ZIP-файл квартала). # Открытый Apache autoindex открытых данных Росреестра (см. докстринг модуля).
# Шаблон подтверждён по документации портала opendata.rosreestr.gov.ru. _DATA_SETS_BASE_URL = "https://rosreestr.gov.ru/data-sets/"
_ROSREESTR_DATASET_URL_TEMPLATE = (
"https://rosreestr.gov.ru/opendata/" # Браузерный UA — без него rosreestr.gov.ru отвечает HTTP 403.
"dataset_%D0%A1%D0%94%D0%95%D0%9B%D0%9A%D0%98_r-r_01-92_y_{year}_q_{quarter}.csv.zip" # Тот же паттерн, что 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 _HTTP_TIMEOUT = 15.0
_HREF_RE = re.compile(r'href="([^"]+)"', re.IGNORECASE)
def _next_quarter(year: int, quarter: int) -> tuple[int, int]: def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
"""Вернуть (year, quarter) для следующего квартала. """Вернуть (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: 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( async def check_new_quarter_available(
@ -129,48 +225,109 @@ async def check_new_quarter_available(
year: int, year: int,
quarter: int, quarter: int,
) -> bool: ) -> 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). Отсутствие папки/файла квартала available=False (нормально до публикации).
Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке. Сетевая ошибка / таймаут / неожиданное исключение warning, available=False.
Никогда не поднимает исключения в вызывающий код (scheduler-safe). Никогда не поднимает исключения в вызывающий код (scheduler-safe).
""" """
url = rosreestr_dataset_url(year, quarter)
try: try:
resp = await client.head(url, follow_redirects=True) index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
if resp.status_code == 200: if index_resp.status_code != 200:
logger.info( logger.warning(
"rosreestr_poll: Q%d %d available at %s (HTTP %d)", "rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
quarter, index_resp.status_code,
year, _DATA_SETS_BASE_URL,
url,
resp.status_code,
)
return True
if resp.status_code == 404:
logger.info(
"rosreestr_poll: Q%d %d not yet published (HTTP 404)",
quarter, quarter,
year, year,
) )
return False return False
# Другие коды (403, 5xx, etc.) — логируем как warning, не кидаем
logger.warning( folder_href = _find_quarter_folder_href(index_resp.text, year, quarter)
"rosreestr_poll: unexpected HTTP %d for Q%d %d url=%s — treating as unavailable", if folder_href is None:
resp.status_code, 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
logger.info(
"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, quarter,
year, year,
url, file_url,
file_resp.status_code,
content_type,
content_length,
) )
return False return False
except httpx.TimeoutException: except httpx.TimeoutException:
logger.warning( 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, quarter,
year, year,
url, _DATA_SETS_BASE_URL,
) )
return False return False
except httpx.RequestError as exc: 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). 1. Найти MAX(deal_date) WHERE source='rosreestr' (loaded_year, loaded_quarter).
2. Вычислить next = _next_quarter(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. 4. Если доступен логировать actionable INFO с инструкцией по запуску ingest.
5. Вернуть dict с результатом. 5. Вернуть dict с результатом.
@ -230,18 +387,27 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
loaded_year, loaded_year,
) )
# 3. Проверка наличия # 3. Проверка наличия (verify=False: см. докстринг модуля — RU root CA не в trust store;
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: # браузерный 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) available = await check_new_quarter_available(client, next_year, next_quarter)
# 4. Алерт если доступен # 4. Алерт если доступен
if available: if available:
logger.info( logger.info(
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d. " "rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d (%s). "
"Run ingest to load: " "Run ingest to load: data/sql/02_load_all_quarters.sh + "
"data/sql/02_load_all_quarters.sh + tradein-mvp/deploy/import-rosreestr.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_quarter,
next_year, next_year,
rosreestr_dataset_url(next_year, next_quarter),
_DATA_SETS_BASE_URL,
) )
return { return {

View file

@ -37,6 +37,11 @@ from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__) 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( def compute_next_run_at(
window_start_hour: int, window_start_hour: int,
@ -180,9 +185,16 @@ def import_rosreestr_dkp(
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql. SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader). 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): Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
- region_code = 66 (Свердловская область) - region_code = 66 (вся Свердловская область, все города)
- city ILIKE '%катеринбург%' - city IS NOT NULL AND trim(city) != '' (непустой город корректный address)
- realestate_type_code = '002001003000' (квартира) - realestate_type_code = '002001003000' (квартира)
- area BETWEEN 18 AND 200 - area BETWEEN 18 AND 200
- deal_price BETWEEN 1000000 AND 100000000 - deal_price BETWEEN 1000000 AND 100000000
@ -210,7 +222,16 @@ def import_rosreestr_dkp(
counters: dict[str, int] = { counters: dict[str, int] = {
"rows_fetched": 0, "rows_fetched": 0,
"rows_inserted": 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_skipped": 0,
# rows_errored: реальные per-row INSERT-ошибки, отделены от dedup-skip (Fix C),
# раньше обе категории клались в rows_skipped → систематический сбой выглядел
# как обычный дедуп и прогон рапортовал success.
"rows_errored": 0,
"batches_done": 0, "batches_done": 0,
} }
@ -248,8 +269,9 @@ def import_rosreestr_dkp(
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper). # #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор # Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run # уже зафиксирован 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 его не тронет. # mark_done выводит run из 'running' → reap_zombies его не тронет.
logger.info( logger.info(
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial " "rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
@ -273,7 +295,9 @@ def import_rosreestr_dkp(
id, id,
id AS source_id_src, id AS source_id_src,
'ros:dkp:' || CAST(id AS text) AS dedup_hash, '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 CASE
WHEN area < 30 THEN 0 WHEN area < 30 THEN 0
WHEN area < 44 THEN 1 WHEN area < 44 THEN 1
@ -296,7 +320,7 @@ def import_rosreestr_dkp(
period_start_date AS deal_date period_start_date AS deal_date
FROM gendesign_rosreestr_deals FROM gendesign_rosreestr_deals
WHERE region_code = 66 WHERE region_code = 66
AND city ILIKE '%катеринбург%' AND city IS NOT NULL AND trim(city) <> ''
AND realestate_type_code = '002001003000' AND realestate_type_code = '002001003000'
AND area BETWEEN 18 AND 200 AND area BETWEEN 18 AND 200
AND deal_price BETWEEN 1000000 AND 100000000 AND deal_price BETWEEN 1000000 AND 100000000
@ -318,7 +342,9 @@ def import_rosreestr_dkp(
total_batches += 1 total_batches += 1
batch_inserted = 0 batch_inserted = 0
batch_updated = 0
batch_skipped = 0 batch_skipped = 0
batch_errored = 0
batch_max_id = last_id batch_max_id = last_id
for row in batch_rows: for row in batch_rows:
@ -328,17 +354,30 @@ def import_rosreestr_dkp(
try: try:
with db.begin_nested(): # SAVEPOINT per row 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(""" text("""
INSERT INTO deals ( INSERT INTO deals (
source, dedup_hash, source_id, address, rooms, area_m2, source, dedup_hash, source_id, address, region_code, city,
floor, year_built, price_rub, price_per_m2, deal_date rooms, area_m2, floor, year_built, price_rub, price_per_m2,
deal_date
) )
VALUES ( VALUES (
'rosreestr', 'rosreestr',
CAST(:dedup_hash AS text), CAST(:dedup_hash AS text),
CAST(:source_id AS text), CAST(:source_id AS text),
CAST(:address AS text), CAST(:address AS text),
CAST(:region_code AS int),
CAST(:city AS text),
CAST(:rooms AS int), CAST(:rooms AS int),
CAST(:area_m2 AS numeric), CAST(:area_m2 AS numeric),
CAST(:floor_num AS int), CAST(:floor_num AS int),
@ -347,13 +386,35 @@ def import_rosreestr_dkp(
CAST(:price_per_m2 AS int), CAST(:price_per_m2 AS int),
CAST(:deal_date AS date) CAST(:deal_date AS date)
) )
ON CONFLICT (dedup_hash) DO NOTHING ON CONFLICT (dedup_hash) DO UPDATE SET
RETURNING id 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"], "dedup_hash": row["dedup_hash"],
"source_id": str(row["source_id_src"]), "source_id": str(row["source_id_src"]),
"address": row["address"], "address": row["address"],
"region_code": row["region_code"],
"city": row["city"],
"rooms": row["rooms"], "rooms": row["rooms"],
"area_m2": row["area_m2"], "area_m2": row["area_m2"],
"floor_num": row["floor_num"], "floor_num": row["floor_num"],
@ -363,25 +424,33 @@ def import_rosreestr_dkp(
"deal_date": row["deal_date"], "deal_date": row["deal_date"],
}, },
).fetchone() ).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 batch_inserted += 1
else: else:
batch_skipped += 1 batch_updated += 1
except Exception as exc: except Exception as exc:
# Fix C: реальная per-row INSERT-ошибка — ОТДЕЛЬНЫЙ счётчик, не skip.
logger.warning( logger.warning(
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s", "rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
run_id, run_id,
row_id, row_id,
exc, exc,
) )
batch_skipped += 1 batch_errored += 1
last_id = batch_max_id last_id = batch_max_id
db.commit() db.commit()
counters["rows_fetched"] += len(batch_rows) counters["rows_fetched"] += len(batch_rows)
counters["rows_inserted"] += batch_inserted counters["rows_inserted"] += batch_inserted
counters["rows_updated"] += batch_updated
counters["rows_skipped"] += batch_skipped counters["rows_skipped"] += batch_skipped
counters["rows_errored"] += batch_errored
counters["batches_done"] = total_batches counters["batches_done"] = total_batches
counters["last_id"] = last_id # type: ignore[assignment] counters["last_id"] = last_id # type: ignore[assignment]
@ -389,26 +458,42 @@ def import_rosreestr_dkp(
runs_mod.update_heartbeat(db, run_id, counters) runs_mod.update_heartbeat(db, run_id, counters)
logger.info( logger.info(
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d " "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, run_id,
total_batches, total_batches,
len(batch_rows), len(batch_rows),
batch_inserted, batch_inserted,
batch_updated,
batch_skipped, batch_skipped,
batch_errored,
last_id, last_id,
) )
if len(batch_rows) < batch_size: if len(batch_rows) < batch_size:
break # Last partial batch — no more rows 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) runs_mod.mark_done(db, run_id, counters)
logger.info( logger.info(
"rosreestr_dkp_import run_id=%d done: " "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, run_id,
counters["rows_fetched"], counters["rows_fetched"],
counters["rows_inserted"], counters["rows_inserted"],
counters["rows_updated"],
counters["rows_skipped"], counters["rows_skipped"],
counters["rows_errored"],
total_batches, 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,537 @@
"""Маршрутизация Telegram-апдейтов для support-моста (#tgsupport).
Поток:
A) Клиент пишет боту в личку (chat.type == 'private')
upsert tg_support_users (если первое сообщение за последний час шапка
с идентификацией клиента в топик) copyMessage контента в support-топик
запись в tg_support_messages (direction='in', topic_message_id ключ
маршрутизации ответа).
B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента
находим chat_id по topic_message_id copyMessage ответа в личку клиента
запись (direction='out'). Реплай не на зеркало (или не реплай вообще)
обычная болтовня в топике, тихий игнор. Telegram 403 (клиент заблокировал
бота) is_blocked=true + уведомление в топике.
C) Дедуп: update_id <= сохранённого offset skip. Offset сохраняется И
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
`finally`), после КАЖДОГО апдейта рестарт воркера не переигрывает уже
обработанные апдейты и не подвисает вечно на «ядовитом» апдейте.
D) TELEGRAM_BOT_TOKEN пуст бот выключен проверяется в `app.tgbot_main`
(entrypoint), не здесь.
E) /start клиенту короткое приветствие МЕРЫ, без зеркалирования в топик
(команда не содержательное обращение, не должна засорять топик).
Персистентность вынесена за `BridgeStorage`-протокол маршрутизирующая логика
(`process_update` и приватные `_handle_*`) не завязана на реальную БД, тестируется
на in-memory fake storage + mock httpx (см. tests/services/tgbot/).
"""
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.shutdown import shutdown_requested
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")
# ── 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,
) -> int | None: ...
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None: ...
def mark_blocked(self, chat_id: int) -> 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,
) -> 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, 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), 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,
},
).fetchone()
return int(row[0]) if row is not None else None
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None:
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'
ORDER BY created_at DESC
LIMIT 1
"""
),
{"topic_message_id": topic_message_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},
)
# ── 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
# Шапка — только на первое сообщение клиента за окно, иначе топик засоряется.
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,
)
async def _handle_group_reply(
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
) -> None:
"""B) Реплай оператора в support-группе → доставка ответа клиенту."""
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
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id)
if target_chat_id is None:
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ
# (is_bot=True) и при этом отсутствующее в tg_support_messages — подозрительно:
# вероятная причина — осиротевшее зеркало (воркер упал МЕЖДУ copyMessage и
# storage.commit() в `_handle_private_message` — зеркало ушло в Telegram, а
# запись в БД потерялась). Дискриминатор неидеальный (шапка-идентификация
# тоже от бота, но не 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 как зеркало клиента — возможно, осиротевшее "
"зеркало (крах между copyMessage и commit) или шапка-идентификация; "
"ответ оператора НЕ доставлен клиенту",
mirror_message_id,
)
return
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,
)
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,249 @@
"""Тонкая 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,
) -> dict[str, Any]:
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке)."""
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
result = await self._request("sendMessage", payload)
return result if isinstance(result, dict) else {}

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² — отсекает нежилые/технические сделки; не меняется. # Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
_PPM2_MIN: int = 30_000 _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). # Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals # QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок. # WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
@ -80,6 +88,7 @@ _REDERIVE_SQL = text(
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND rooms IS NOT NULL 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 price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months' AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY LEAST(GREATEST(rooms, 0), 4) GROUP BY LEAST(GREATEST(rooms, 0), 4)
@ -128,6 +137,7 @@ _REDERIVE_SQL = text(
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND rooms IS NOT NULL 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 price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months' 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(_DELETE_SQL)
db.execute( db.execute(
_REDERIVE_SQL, _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() row = db.execute(_COUNTERS_SQL).mappings().first()

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

@ -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. -- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling.
-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert. -- 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; 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

@ -86,6 +86,9 @@ USAGE
# machine-readable: # machine-readable:
python -m scripts.backtest_estimator --json 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 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 # Price-per-m² sanity band — shared by the deal sample and the listings
# subquery. Mirrors the estimator's working range for EKB вторичка and drops # subquery. Mirrors the estimator's working range for EKB вторичка and drops
# obvious data-entry garbage / commercial outliers. # 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_MIN = 30_000
PPM2_MAX = 600_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].""" def _sample_sql(city: str | None) -> Any:
rows = ( """ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
db.execute(
_SAMPLE_SQL, ``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
{ before this change literal identity, not just equal text so the
"ppm2_min": PPM2_MIN, default CLI invocation (and the frozen EKB regression gate, which never
"ppm2_max": PPM2_MAX, calls this path at all) see byte-identical SQL.
"since": since,
"sample": sample, 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
.mappings() Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
.all() 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,
}
if city is not None:
params["city"] = city
rows = db.execute(_sample_sql(city), params).mappings().all()
out: list[DealSample] = [] out: list[DealSample] = []
for r in rows: for r in rows:
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None: 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) ─────── # ── 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 # #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address # estimate_quality — no-area / no-address → ([], None) instead of an
# → ([], None) instead of an unconditional fetch. # unconditional fetch.
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address: if deal.area_m2 and deal.address:
# NB(#2002): kept at None deliberately — the deliverable scopes the # NB(#2002): kept at None deliberately — the deliverable scopes the
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor. # 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 # Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
@ -1733,6 +1848,7 @@ def run_backtest(
radius: int, radius: int,
rooms_tolerance: int, rooms_tolerance: int,
holdout_split: bool = False, holdout_split: bool = False,
city: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Drive the full read-only backtest and return a metrics dict. """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 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`` 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. 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) deals = _load_sample(db, sample=sample, since=since, city=city)
logger.info("loaded sample: %d ДКП deals (since=%s)", len(deals), since) logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
matched_rows: list[tuple[float, float, int]] = [] matched_rows: list[tuple[float, float, int]] = []
matched_ids: list[int] = [] matched_ids: list[int] = []
@ -1809,6 +1929,7 @@ def run_backtest(
"n_matched": len(matched_rows), "n_matched": len(matched_rows),
"n_no_analogs": n_no_analogs, "n_no_analogs": n_no_analogs,
"holdout_split": holdout_split, "holdout_split": holdout_split,
"city": city,
} }
return metrics return metrics
@ -1820,6 +1941,7 @@ def run_backtest_full(
since: str, since: str,
dump_fixture: str | None = None, dump_fixture: str | None = None,
resolve_house_id: bool = False, resolve_house_id: bool = False,
city: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966). """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 the Tier-S analog ladder + the house Avito-IMV anchor, mirroring prod, and a
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is ``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
attached to the returned metrics. Default False byte-identical prior output. 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() est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since) deals = _load_sample(db, sample=sample, since=since, city=city)
logger.info("loaded sample: %d ДКП deals (since=%s) [full spine]", len(deals), since) logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
)
predictions: list[Prediction] = [] predictions: list[Prediction] = []
n_no_prediction = 0 n_no_prediction = 0
@ -1922,6 +2052,7 @@ def run_backtest_full(
"n_matched": len(predictions), "n_matched": len(predictions),
"n_no_prediction": n_no_prediction, "n_no_prediction": n_no_prediction,
"price_segments_ppm2": [list(seg) for seg in _price_segments()], "price_segments_ppm2": [list(seg) for seg in _price_segments()],
"city": city,
} }
# #2002: house_id resolution coverage — the key Tier-S + IMV reach number. # #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", default="2025-06-01",
help="Only deals with deal_date >= this ISO date (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( p.add_argument(
"--radius", "--radius",
type=int, type=int,
@ -2115,7 +2260,7 @@ def main(argv: list[str] | None = None) -> int:
logger.info( logger.info(
"backtest start: engine=%s sample=%d since=%s radius=%dm " "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.engine,
args.sample, args.sample,
args.since, args.since,
@ -2124,6 +2269,7 @@ def main(argv: list[str] | None = None) -> int:
args.holdout_split, args.holdout_split,
args.dump_fixture, args.dump_fixture,
args.resolve_house_id, args.resolve_house_id,
args.city,
) )
db = _session() db = _session()
@ -2135,6 +2281,7 @@ def main(argv: list[str] | None = None) -> int:
since=args.since, since=args.since,
dump_fixture=args.dump_fixture, dump_fixture=args.dump_fixture,
resolve_house_id=args.resolve_house_id, resolve_house_id=args.resolve_house_id,
city=args.city,
) )
else: else:
metrics = run_backtest( metrics = run_backtest(
@ -2144,6 +2291,7 @@ def main(argv: list[str] | None = None) -> int:
radius=args.radius, radius=args.radius,
rooms_tolerance=args.rooms_tolerance, rooms_tolerance=args.rooms_tolerance,
holdout_split=args.holdout_split, holdout_split=args.holdout_split,
city=args.city,
) )
finally: finally:
db.close() db.close()

View file

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

@ -614,12 +614,12 @@ async def test_suggest_addresses_classifies_kind() -> None:
results = await dadata.suggest_addresses("test") results = await dadata.suggest_addresses("test")
assert len(results) == 6 assert len(results) == 6
assert results[0].kind == "house" # 8 assert results[0].kind == "house" # 8
assert results[1].kind == "street" # 7 assert results[1].kind == "street" # 7
assert results[2].kind == "city" # 4 assert results[2].kind == "city" # 4
assert results[3].kind == "city" # 1 assert results[3].kind == "city" # 1
assert results[4].kind == "house" # 9 (>=8) assert results[4].kind == "house" # 9 (>=8)
assert results[5].kind == "city" # missing → default assert results[5].kind == "city" # missing → default
async def test_suggest_addresses_sends_proper_request() -> None: async def test_suggest_addresses_sends_proper_request() -> None:
@ -652,6 +652,55 @@ async def test_suggest_addresses_sends_proper_request() -> None:
assert captured["x_secret"] is 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: async def test_suggest_addresses_clamps_limit() -> None:
"""count должен быть clamped в [1, 20] — DaData ограничение.""" """count должен быть clamped в [1, 20] — DaData ограничение."""
from app.services import dadata from app.services import dadata
@ -671,3 +720,168 @@ async def test_suggest_addresses_clamps_limit() -> None:
body = captured["body"] body = captured["body"]
assert isinstance(body, dict) assert isinstance(body, dict)
assert body["count"] == 20 # clamped to max 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

@ -40,9 +40,21 @@ def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
def test_top7_weight_sum_matches_ptica() -> None: def test_top7_weight_sum_matches_ptica() -> None:
"""Same category set as Site Finder → identical top-7 normalization constant (31.5).""" """Same category set as Site Finder → identical top-7 weight-sum constant (31.5)."""
assert lc._TOP7_WEIGHT_SUM == 31.5 assert lc._TOP7_WEIGHT_SUM == 31.5
assert abs(lc._MAX_STRAIGHT_SCORE - 0.315) < 1e-9
def test_max_straight_score_normalized_at_reference_distance_not_zero() -> None:
"""Audit R2 #7a: denominator uses _REF_DISTANCE_M (100m), not an unreachable d=0 max.
Old d=0 normalization was _TOP7_WEIGHT_SUM / 100 == 0.315 the theoretical max with all
top-7 categories AT the doorstep. That compressed realistic scores (~25-30/100 for even a
strong central address) into the bottom third of the range. The new normalization halves
the max (denominator distance+100 doubles from 100 to 200 at REF=100m), doubling realistic
scores instead.
"""
assert lc._REF_DISTANCE_M == 100.0
assert abs(lc._MAX_STRAIGHT_SCORE - 0.1575) < 1e-9
def test_score_to_coef_bounds() -> None: def test_score_to_coef_bounds() -> None:
@ -142,6 +154,45 @@ def test_compute_location_coef_weights_and_ranks_top_n() -> None:
assert 0.95 <= result.coef <= 1.05 assert 0.95 <= result.coef <= 1.05
def test_compute_location_coef_strong_central_fixture_lands_near_one() -> None:
"""Audit R2 #7a: a realistic strong central-EKB POI profile now reads as ~1.0, not ~0.98.
Fixture mirrors a real strong central address audit: near transit/kindergarten/pharmacy,
but school/metro/mall noticeably farther (a typical quarter profile, NOT everything at
the doorstep). Under the OLD d=0 normalization this fixture scores ~28/100 coef ~0.978
(verified separately against the pre-fix formula). After re-centering on _REF_DISTANCE_M
it should land close to 1.0 (within the documented ±0.01 calibration target).
"""
rows = [
{"name": "Остановка", "category": "bus_stop", "distance_m": 80.0},
{"name": "Аптека", "category": "pharmacy", "distance_m": 120.0},
{"name": "Супермаркет", "category": "shop_supermarket", "distance_m": 180.0},
{"name": "Детсад №5", "category": "kindergarten", "distance_m": 220.0},
{"name": "Школа №10", "category": "school", "distance_m": 350.0},
{"name": "Метро Геологическая", "category": "metro_stop", "distance_m": 500.0},
{"name": "ТЦ Гринвич", "category": "shop_mall", "distance_m": 700.0},
]
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
assert result.geo_source == "osm_poi_ekb"
assert len(result.factors) == 7
assert abs(result.coef - 1.0) <= 0.01
def test_compute_location_coef_weak_fixture_stays_well_below_one() -> None:
"""A sparse/poor location (only far, low-weight POI) must still stay well below 1.0."""
rows = [
{"name": "Магазинчик", "category": "shop_small", "distance_m": 950.0},
{"name": "Прочее", "category": "some_unknown_tag", "distance_m": 1100.0},
]
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
assert result.geo_source == "osm_poi_ekb"
assert result.coef < 0.98
def test_compute_location_coef_limits_to_top_n() -> None: def test_compute_location_coef_limits_to_top_n() -> None:
"""More than top_n candidates → only top_n factors surface in the response.""" """More than top_n candidates → only top_n factors surface in the response."""
rows = [ rows = [

View file

@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
_wp_mock = MagicMock() _wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock) 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.brand import Brand # noqa: E402
from app.services.exporters import trade_in_pdf as mod # 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.""" """Minimal balanced-tag check: every non-void tag that opens must close."""
_VOID: ClassVar[set[str]] = { _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: def __init__(self) -> None:
@ -224,7 +233,9 @@ def test_build_cover_with_sold_contains_dual_block() -> None:
assert "Ожидаемая цена продажи" in html assert "Ожидаемая цена продажи" in html
# existing sections untouched # existing sections untouched
assert "Диапазон цен в объявлениях" in html 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: 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 assert "Ожидаемая цена продажи" not in html
# existing layout intact # existing layout intact
assert "Диапазон цен в объявлениях" in html 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,654 @@
"""Unit tests for `app.services.tgbot.bridge` — чистая логика роутинга.
Coverage (per task spec + review follow-up):
- user topic (личка клиента зеркалится в support-топик, с шапкой на первое
сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
после истечения окна #6 review)
- реплай оператора user (доставка ответа клиенту + запись direction='out')
- реплай не на зеркало (или не реплай вообще) тихий игнор, не мусорим в чат;
реплай на СООБЩЕНИЕ БОТА без записи в БД 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
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,
) -> 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,
"recorded_at_s": self.clock_s,
}
)
return row_id
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None:
for m in reversed(self.messages):
if m["direction"] == "in" and m["topic_message_id"] == topic_message_id:
return m["chat_id"]
return None
def mark_blocked(self, chat_id: int) -> None:
self.blocked.add(chat_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 _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
# ── 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
# ── 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"), ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
radius_analogs: list[dict[str, Any]] | None = None, radius_analogs: list[dict[str, Any]] | None = None,
) -> Any: ) -> Any:
from app.core.config import settings
from app.schemas.trade_in import TradeInEstimateInput from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import estimate_quality from app.services.estimator import estimate_quality
from app.services.geocoder import GeocodeResult from app.services.geocoder import GeocodeResult
@ -105,7 +104,6 @@ def _run_qa_estimate(
async def _run() -> Any: async def _run() -> Any:
with ( with (
patch.object(settings, "estimate_same_building_anchor_enabled", True),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None), patch("app.services.estimator.match_house_readonly", return_value=None),

View file

@ -6,6 +6,9 @@ Coverage:
(c) increment растит used счётчик (c) increment растит used счётчик
(d) get_status корректен для different сценариев (d) get_status корректен для different сценариев
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open) (e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
(f) #747 — атомарно-условный increment (TOCTOU fix)
(g) account_quota_overrides персональный лимит вместо negative-used хака
(h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
DB мокируется через MagicMock реальная БД не требуется. DB мокируется через MagicMock реальная БД не требуется.
""" """
@ -14,7 +17,9 @@ from __future__ import annotations
import os import os
import sys 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 # psycopg v3 driver required; stub DATABASE_URL before any app import
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -29,6 +34,7 @@ import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402 from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # 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 from app.services.account_quota import ( # noqa: E402
LIMIT_EXHAUSTED_MESSAGE, LIMIT_EXHAUSTED_MESSAGE,
MONTHLY_LIMIT, MONTHLY_LIMIT,
@ -36,6 +42,7 @@ from app.services.account_quota import ( # noqa: E402
get_status, get_status,
increment, increment,
is_unlimited, is_unlimited,
user_limit,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -43,23 +50,54 @@ from app.services.account_quota import ( # noqa: E402
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _db_with_used(used: int) -> MagicMock: class _Row:
"""DB session mock whose execute().fetchone() returns row with .used = N.""" """Row stand-in поддерживающий и .used/.monthly_limit, и индексный доступ row[0]."""
row = MagicMock()
row.used = used def __init__(self, value: int) -> None:
execute_result = MagicMock() self.used = value
execute_result.fetchone.return_value = row self.monthly_limit = value
self._t = (value,)
def __getitem__(self, i: int) -> int:
return self._t[i]
def _result(row: _Row | None) -> MagicMock:
result = MagicMock()
result.fetchone.return_value = row
return result
def _override_result(limit: int | None) -> MagicMock:
"""Mock результата запроса account_quota_overrides.monthly_limit (user_limit())."""
return _result(None if limit is None else _Row(limit))
def _used_result(used: int | None) -> MagicMock:
"""Mock результата запроса account_estimate_usage.used (или UPSERT RETURNING used)."""
return _result(None if used is None else _Row(used))
def _db_with_used(used: int, *, override_limit: int | None = None) -> MagicMock:
"""DB session mock для get_status/check_and_raise: ровно 2 execute() —
(1) user_limit() override lookup, (2) account_estimate_usage.used lookup."""
db = MagicMock() db = MagicMock()
db.execute.return_value = execute_result db.execute.side_effect = [_override_result(override_limit), _used_result(used)]
return db return db
def _db_no_row() -> MagicMock: def _db_no_row(*, override_limit: int | None = None) -> MagicMock:
"""DB session mock where no row exists yet (first estimate of the month).""" """DB session mock где строки usage ещё нет (первая оценка месяца)."""
execute_result = MagicMock()
execute_result.fetchone.return_value = None
db = MagicMock() db = MagicMock()
db.execute.return_value = execute_result db.execute.side_effect = [_override_result(override_limit), _used_result(None)]
return db
def _db_for_increment(*, override_limit: int | None, upsert_used: int | None) -> MagicMock:
"""DB session mock для increment(): (1) user_limit() override lookup,
(2) UPSERT ... RETURNING used (None если WHERE used < lim не матчит)."""
db = MagicMock()
db.execute.side_effect = [_override_result(override_limit), _used_result(upsert_used)]
return db return db
@ -127,7 +165,7 @@ def test_check_and_raise_pilot_not_blocked_at_14() -> None:
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None: def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется). """used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
Логика: used >= MONTHLY_LIMIT block. После 15-й успешной оценки Логика: used >= limit block. После 15-й успешной оценки
increment делает used=15, поэтому следующий запрос (16-й) блокируется. increment делает used=15, поэтому следующий запрос (16-й) блокируется.
""" """
db = _db_with_used(MONTHLY_LIMIT) db = _db_with_used(MONTHLY_LIMIT)
@ -153,7 +191,7 @@ def test_check_and_raise_pilot_blocked_exact_detail() -> None:
def test_check_and_raise_pilot_blocked_over_limit() -> None: def test_check_and_raise_pilot_blocked_over_limit() -> None:
"""used=20 тоже блокируется.""" """used=20 тоже блокируется (нет override → глобальный лимит)."""
db = _db_with_used(20) db = _db_with_used(20)
from fastapi import HTTPException from fastapi import HTTPException
@ -168,14 +206,15 @@ def test_check_and_raise_pilot_blocked_over_limit() -> None:
def test_increment_pilot_calls_upsert() -> None: def test_increment_pilot_calls_upsert() -> None:
"""increment для pilot → выполняет db.execute (UPSERT) и db.commit.""" """increment для pilot → выполняет user_limit lookup + UPSERT (2 execute), 1 commit."""
db = MagicMock() db = _db_for_increment(override_limit=None, upsert_used=1)
increment(db, "user1") result = increment(db, "user1")
db.execute.assert_called_once() assert result is True
assert db.execute.call_count == 2
db.commit.assert_called_once() db.commit.assert_called_once()
# Проверяем что SQL содержит ON CONFLICT ... DO UPDATE # Второй вызов — UPSERT; проверяем что SQL содержит ON CONFLICT ... DO UPDATE
call_args = db.execute.call_args call_args = db.execute.call_args_list[1]
sql_text = str(call_args[0][0]) # first positional arg — text() object sql_text = str(call_args[0][0]) # first positional arg — text() object
assert "ON CONFLICT" in sql_text assert "ON CONFLICT" in sql_text
assert "used" in sql_text assert "used" in sql_text
@ -190,9 +229,9 @@ def test_increment_none_username_is_noop() -> None:
def test_increment_pilot_first_estimate_of_month() -> None: def test_increment_pilot_first_estimate_of_month() -> None:
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT.""" """Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
db = MagicMock() db = _db_for_increment(override_limit=None, upsert_used=1)
increment(db, "user5") increment(db, "user5")
db.execute.assert_called_once() assert db.execute.call_count == 2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -212,7 +251,7 @@ def test_get_status_none_username() -> None:
def test_get_status_admin() -> None: def test_get_status_admin() -> None:
"""admin → unlimited, remaining=15 вне зависимости от used.""" """admin → unlimited, remaining=limit вне зависимости от used."""
db = _db_with_used(7) db = _db_with_used(7)
status = get_status(db, "admin") status = get_status(db, "admin")
assert status["unlimited"] is True assert status["unlimited"] is True
@ -396,18 +435,7 @@ def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class _Row: class _AtomicResult:
"""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:
def __init__(self, row: _Row | None) -> None: def __init__(self, row: _Row | None) -> None:
self._row = row self._row = row
@ -416,28 +444,33 @@ class _Result:
class _AtomicQuotaFakeDB: class _AtomicQuotaFakeDB:
"""In-memory fake, моделирующий атомарный conditional UPSERT (#747). """In-memory fake, воспроизводящий user_limit() override-lookup + атомарный
conditional UPSERT (#747):
Воспроизводит семантику `INSERT ... ON CONFLICT DO UPDATE SET used=used+1 - execute() без ключа "lim" в params user_limit() override lookup
WHERE used < :lim RETURNING used`: возвращает override_limit (или None increment() падает на глобальный
- строки ещё нет (used=None) INSERT used=1, RETURNING row (WHERE не для INSERT); MONTHLY_LIMIT как :lim в следующем вызове).
- used < lim used+=1, RETURNING row; - execute() с ключом "lim" UPSERT: строки ещё нет (used=None) INSERT used=1,
- used >= lim конфликтная строка НЕ обновлена, RETURNING пуст (None). RETURNING row (WHERE не применяется к INSERT); used < lim used+=1, RETURNING
row; used >= lim конфликтная строка НЕ обновлена, RETURNING пуст (None).
""" """
def __init__(self, *, used: int | None) -> None: def __init__(self, *, used: int | None, override_limit: int | None = None) -> None:
self.used = used self.used = used
self.override_limit = override_limit
self.commits = 0 self.commits = 0
def execute(self, _stmt: object, params: dict) -> _Result: def execute(self, _stmt: object, params: dict) -> _AtomicResult:
if "lim" not in params:
row = _Row(self.override_limit) if self.override_limit is not None else None
return _AtomicResult(row)
lim = params["lim"] lim = params["lim"]
if self.used is None: if self.used is None:
self.used = 1 self.used = 1
elif self.used < lim: elif self.used < lim:
self.used += 1 self.used += 1
else: else:
return _Result(None) return _AtomicResult(None)
return _Result(_Row(self.used)) return _AtomicResult(_Row(self.used))
def commit(self) -> None: def commit(self) -> None:
self.commits += 1 self.commits += 1
@ -472,3 +505,207 @@ def test_increment_atomic_unlimited_noop_returns_true() -> None:
assert increment(db, "admin") is True assert increment(db, "admin") is True
assert db.commits == 0 assert db.commits == 0
assert db.used == MONTHLY_LIMIT # не тронут 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 заменяет глобальный лимит полностью
не является дополнительным потолком поверх него.
"""
db = _AtomicQuotaFakeDB(used=5, override_limit=5)
assert increment(db, "user_low_override") is False
assert db.used == 5 # не выросло
# ---------------------------------------------------------------------------
# (g) account_quota_overrides — персональный лимит (замена 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 = _db_with_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 = _db_with_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."""
db = _db_with_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 = _db_with_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 = _db_for_increment(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 = _db_for_increment(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 _db_no_row()
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}" 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: def _strip_sql(s: str) -> str:
"""Drop -- line comments and collapse whitespace — leaves only the executable SQL. """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, s,
) )
assert _strip_sql(_normalise_ppm2(_drop_segment_guard(seed_derivation))) in _strip_sql( def _drop_city_guard(s: str) -> str:
_normalise_ppm2(_drop_segment_guard(_REDERIVE_SQL)) """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 ──────────────────────────────────────────────────────────── # ── 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,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

@ -832,3 +832,177 @@ def test_render_full_table_handles_empty_sample() -> None:
out = bt._render_full_table(m) out = bt._render_full_table(m)
assert "n/a" in out # None metrics render as n/a, no crash assert "n/a" in out # None metrics render as n/a, no crash
assert "BACKTEST" in out 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,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_price_trend=lambda *a, **k: None,
_fetch_dkp_corridor=lambda *a, **k: None, _fetch_dkp_corridor=lambda *a, **k: None,
_fetch_house_imv_anchor=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 # #2043 (BE-1): GET-rehydrate also recomputes cv / source_counts from the
# persisted analogs. Empty analogs in the fixture → None / {} (real behaviour). # persisted analogs. Empty analogs in the fixture → None / {} (real behaviour).
_cv_from_ppm2=lambda *a, **k: None, _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 is not None, "Должен построить якорь без гейта"
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе" assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
# Гейт: settings.estimate_sb_low_conf_gate_enabled=True # Гейт low-confidence якоря применяется в estimate_quality (выше уровня
# Это применяется в estimate_quality (выше уровня _compute_same_building_anchor), # _compute_same_building_anchor),
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить. # поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
assert anchor_raw["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: def test_fix4_premium_comp_survives_post_weight_clip() -> None:
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается. """Видовой компл (высокий ppm²) с правильными весами не выкидывается.
Логика: при post-weight clip (estimate_sb_clip_after_weight=True) MAD-clip Логика: post-weight MAD-clip применяется к ppm² ПОСЛЕ weighting. Если видовой
применяется к ppm² ПОСЛЕ weighting. Если видовой компл близок по площади/ компл близок по площади/комнатам его вес высок, и он не должен быть outlier
комнатам его вес высок, и он не должен быть outlier после clip. после clip.
""" """
# 4 стандартных компла + 1 видовой (выше на 30%) # 4 стандартных компла + 1 видовой (выше на 30%)
base = 200_000 base = 200_000
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)] comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
with patch("app.services.estimator.settings") as mock_settings: 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_mad_k_small_n = 2.5
mock_settings.estimate_sb_small_n_threshold = 10 mock_settings.estimate_sb_small_n_threshold = 10
mock_settings.avito_imv_thin_market_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", anchor_tier: str | None = "A",
): ):
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price.""" """estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
from app.core.config import settings
from app.services.estimator import estimate_quality from app.services.estimator import estimate_quality
db = MagicMock() db = MagicMock()
@ -322,7 +321,6 @@ def _run_estimate_anchor_only(
async def _run(): async def _run():
with ( 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.geocode", new=AsyncMock(return_value=_make_fake_geo())),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), 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.match_house_readonly", return_value=None),

View file

@ -5,22 +5,19 @@
blend, rehydrate, миграция), который наполнит median/sources_used без единого blend, rehydrate, миграция), который наполнит median/sources_used без единого
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high, реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
38.45M, yandex_valuation). Mainline уже честен это belt-and-suspenders. 38.45M, yandex_valuation). Mainline уже честен это belt-and-suspenders.
Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True).
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import sys import sys
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock
# pydantic Settings требует DATABASE_URL при инициализации. # pydantic Settings требует DATABASE_URL при инициализации.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# WeasyPrint stubbed in CI. # WeasyPrint stubbed in CI.
sys.modules.setdefault("weasyprint", MagicMock()) sys.modules.setdefault("weasyprint", MagicMock())
import app.services.estimator as est # noqa: E402
from app.services.estimator import _enforce_zero_analog_low # noqa: E402 from app.services.estimator import _enforce_zero_analog_low # noqa: E402
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование. # Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
@ -108,23 +105,8 @@ def test_none_explanation_returns_empty_string_not_none() -> None:
assert isinstance(explanation, str) assert isinstance(explanation, str)
def test_flag_default_true() -> None: def test_enforce_zero_analog_low_always_downgrades() -> None:
"""estimate_confidence_floor_no_analogs по умолчанию True (гард активен).""" """_enforce_zero_analog_low всегда понижает high→low при n_analogs=0 (гард всегда ON)."""
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'а)
confidence, _ = _enforce_zero_analog_low( confidence, _ = _enforce_zero_analog_low(
"high", "high",
0, 0,
@ -133,19 +115,3 @@ def test_flag_off_does_not_downgrade() -> None:
sources_used=["yandex_valuation"], sources_used=["yandex_valuation"],
) )
assert confidence == "low" # сама функция не смотрит на settings 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); - all-clear (нет причин recommended False);
- граничные значения порогов (>= high_value, >= wide_ratio); - граничные значения порогов (>= high_value, >= wide_ratio);
- div-guard на диапазон (range_low None/0 без деления на ноль); - div-guard на диапазон (range_low None/0 без деления на ноль).
- выключенный флаг estimate_manual_review_enabled (False, []).
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг метаданные: сериализация Плюс smoke-проверка дефолтов AggregatedEstimate (флаг метаданные: сериализация
без изменений, когда ручная оценка не рекомендована). без изменений, когда ручная оценка не рекомендована).
@ -36,14 +35,12 @@ ELITE_REASON = (
def _settings( def _settings(
*, *,
enabled: bool = True,
high_value: int = 20_000_000, high_value: int = 20_000_000,
wide_ratio: float = 1.9, wide_ratio: float = 1.9,
elite_ppm2: int = 250_000, elite_ppm2: int = 250_000,
) -> SimpleNamespace: ) -> SimpleNamespace:
"""Duck-typed заглушка Settings — helper читает только эти поля.""" """Duck-typed заглушка Settings — helper читает только эти поля."""
return SimpleNamespace( return SimpleNamespace(
estimate_manual_review_enabled=enabled,
estimate_manual_review_high_value_rub=high_value, estimate_manual_review_high_value_rub=high_value,
estimate_manual_review_wide_range_ratio=wide_ratio, estimate_manual_review_wide_range_ratio=wide_ratio,
estimate_manual_review_elite_ppm2=elite_ppm2, 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 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) ─────────────────────────────────── # ── invariant recommended == bool(reasons) ───────────────────────────────────

View file

@ -1,7 +1,7 @@
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend. """Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id
с разными ext_item_id не double-count'ится в месячной медиане. не double-count'ится в месячной медиане (dedup всегда включён).
""" """
from __future__ import annotations 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: def test_price_trend_dedup_sql_uses_distinct_on() -> None:
"""estimate_price_trend_dedup_enabled дефолт True.""" """SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup)."""
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."""
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}]) mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
with patch("app.services.estimator.settings") as mock_settings: 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 mock_settings.estimate_price_trend_max_age_months = 6
_fetch_price_trend(mock_db, target_house_id=42, min_points=1) _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() assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
def test_price_trend_dedup_disabled_no_distinct_on() -> None: def test_price_trend_returns_result_with_dedup() -> None:
"""Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path).""" """Функция корректно возвращает точки тренда (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 = 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."""
rows = [ rows = [
{"month": "2025-11", "ppm2": 148_000}, {"month": "2025-11", "ppm2": 148_000},
{"month": "2025-12", "ppm2": 152_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) mock_db = _make_mock_db([], rows)
with patch("app.services.estimator.settings") as mock_settings: 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 mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3) 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 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: def test_price_trend_source1_preferred_over_dedup_path() -> None:
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается.""" """Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
source1_rows = [ source1_rows = [
@ -130,7 +86,6 @@ def test_price_trend_source1_preferred_over_dedup_path() -> None:
mock_db = _make_mock_db(source1_rows, []) mock_db = _make_mock_db(source1_rows, [])
with patch("app.services.estimator.settings") as mock_settings: 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 mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3) 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: def test_confidence_medium_via_4_unique_addresses() -> None:
# 4 unique addresses → medium branch (independent of IQR). # #R2-H2: 4 unique addresses AND IQR/median < 0.35 → medium. avg = 4/4 = 1.0 (no downgrade).
# Wide IQR ensures it is NOT "high". 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"]) listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence( level, _ = estimator._compute_confidence(
n_analogs=4, n_analogs=4,
median_ppm2=100, median_ppm2=100,
q1=70, q1=70,
q3=130, # IQR/median = 0.60 → not high q3=130, # IQR/median = 0.60
fallback_radius_used=False, fallback_radius_used=False,
listings=listings, 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: def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None:
@ -313,7 +344,6 @@ def _clamp(
tier: str | None, tier: str | None,
slack: float = 0.25, slack: float = 0.25,
min_n: int = 10, min_n: int = 10,
enabled: bool = True,
median_price: int = 0, median_price: int = 0,
range_low: int = 0, range_low: int = 0,
range_high: int = 0, range_high: int = 0,
@ -328,7 +358,6 @@ def _clamp(
anchor_tier=tier, anchor_tier=tier,
slack=slack, slack=slack,
min_n=min_n, min_n=min_n,
enabled=enabled,
) )
@ -374,15 +403,6 @@ def test_corridor_clamp_low_n_is_noop() -> None:
assert new_ppm2 == 296_000 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: def test_corridor_clamp_tier_none_clamps() -> None:
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C. # anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
new_ppm2, _, _, _, clamped = _clamp( new_ppm2, _, _, _, clamped = _clamp(

View file

@ -379,7 +379,6 @@ def _run_estimate_qi(
dadata_cadnum: str | None, dadata_cadnum: str | None,
qi_lookup_result: tuple[float, int] | None, qi_lookup_result: tuple[float, int] | None,
*, *,
flag_enabled: bool = True,
anchor_tier_override: str | None = None, anchor_tier_override: str | None = None,
): ):
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.""" """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate."""
@ -442,10 +441,6 @@ def _run_estimate_qi(
"app.services.estimator._lookup_quarter_indexes", "app.services.estimator._lookup_quarter_indexes",
side_effect=_fake_lookup_indexes, side_effect=_fake_lookup_indexes,
), ),
patch(
"app.services.estimator.settings.estimate_quarter_index_enabled",
flag_enabled,
),
): ):
return await estimate_quality(payload, db) return await estimate_quality(payload, db)
@ -488,7 +483,6 @@ def test_quarter_index_correction_applied() -> None:
analogs=analogs_no_cadnum, analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350", dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.2, 30), qi_lookup_result=(1.2, 30),
flag_enabled=True,
) )
expected_median = round(base_median * 1.2) expected_median = round(base_median * 1.2)
assert est.median_price_rub == expected_median assert est.median_price_rub == expected_median
@ -497,21 +491,6 @@ def test_quarter_index_correction_applied() -> None:
assert "квартал" in est.confidence_explanation.lower() assert "квартал" in est.confidence_explanation.lower()
def test_quarter_index_flag_off_exact_old_behavior() -> None:
"""При estimate_quarter_index_enabled=False — точное старое поведение."""
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
]
est = _run_estimate_qi(
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.2, 30),
flag_enabled=False,
)
base_median = round(_BASE_PPM2 * _AREA)
assert est.median_price_rub == base_median
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Guard-2: >0.6 аналогов в целевом квартале → no-op # Guard-2: >0.6 аналогов в целевом квартале → no-op
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -534,7 +513,6 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
analogs=analogs, analogs=analogs,
dadata_cadnum=f"{_TARGET_QUARTER}:350", dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
flag_enabled=True,
) )
# Медиана НЕ должна изменяться # Медиана НЕ должна изменяться
assert est.median_price_rub == base_median assert est.median_price_rub == base_median
@ -555,7 +533,6 @@ def test_sparse_fallback_no_row_noop() -> None:
analogs=analogs_no_cadnum, analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350", dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=None, # sparse qi_lookup_result=None, # sparse
flag_enabled=True,
) )
assert est.median_price_rub == base_median assert est.median_price_rub == base_median
@ -575,7 +552,6 @@ def test_bimodal_guard_skips_high_index_small_n() -> None:
analogs=analogs_no_cadnum, analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350", dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
flag_enabled=True,
) )
assert est.median_price_rub == base_median assert est.median_price_rub == base_median
@ -594,7 +570,6 @@ def test_bimodal_guard_allows_high_index_large_n() -> None:
analogs=analogs_no_cadnum, analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350", dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
flag_enabled=True,
) )
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал). # Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859). # factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
@ -620,6 +595,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
payload = _make_payload_qi() payload = _make_payload_qi()
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350") dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
fake_comps = [
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
for _ in range(3)
]
fake_anchor = { fake_anchor = {
"anchor_ppm2": _BASE_PPM2 * 1.5, "anchor_ppm2": _BASE_PPM2 * 1.5,
"anchor_sold_ppm2": _BASE_PPM2 * 1.4, "anchor_sold_ppm2": _BASE_PPM2 * 1.4,
@ -631,11 +610,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
"comp_max_ppm2": _BASE_PPM2 * 1.7, "comp_max_ppm2": _BASE_PPM2 * 1.7,
"used_uplift": False, "used_uplift": False,
"haircut": 0.05, "haircut": 0.05,
# display-consistency fix: contract now requires "comps" — the final
# post-MAD-clip survivors (see _compute_same_building_anchor docstring).
"comps": fake_comps,
} }
fake_comps = [
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
for _ in range(3)
]
analogs_no_cadnum = [ analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)

View file

@ -4,8 +4,8 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах (source, source_id)-дубли делят один address и выживают на разных rn_addr рангах
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius нет. раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius нет.
Эти тесты проверяют SQL-фрагмент (rn_dup-окно всегда; `AND rn_dup = 1` фильтр за Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ
флагом) во ВСЕХ четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard). окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
тексте (как test_estimator_price_trend_dedup.py). тексте (как test_estimator_price_trend_dedup.py).
@ -14,7 +14,7 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
from __future__ import annotations from __future__ import annotations
import os import os
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -23,7 +23,7 @@ import pytest
import app.services.estimator as est import app.services.estimator as est
def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]: def _capture_tier_sql() -> list[str]:
"""Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира. """Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира.
target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх
@ -40,63 +40,42 @@ def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
db.execute.side_effect = side_effect db.execute.side_effect = side_effect
with patch.object(est.settings, "estimate_radius_dedup_enabled", dedup_enabled): est._fetch_analogs(
est._fetch_analogs( db,
db, lat=56.83,
lat=56.83, lon=60.6,
lon=60.6, rooms=2,
rooms=2, area=50.0,
area=50.0, radius_m=2000,
radius_m=2000, full_address="г Екатеринбург, ул Малышева, д 30",
full_address="г Екатеринбург, ул Малышева, д 30", year_built=2010,
year_built=2010, house_type="монолит",
house_type="монолит", total_floors=20,
total_floors=20, target_house_id=123,
target_house_id=123, )
)
return captured return captured
# ---------------------------------------------------------------------------
# Флаг по умолчанию
# ---------------------------------------------------------------------------
def test_radius_dedup_flag_default_true() -> None:
from app.core.config import settings
assert settings.estimate_radius_dedup_enabled is True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SQL-фрагмент во всех тирах # SQL-фрагмент во всех тирах
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_all_four_tiers_render() -> None: def test_all_four_tiers_render() -> None:
sqls = _capture_tier_sql(dedup_enabled=True) sqls = _capture_tier_sql()
assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W" assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W"
def test_rn_dup_window_present_in_every_tier_when_enabled() -> None: def test_rn_dup_window_present_in_every_tier() -> None:
sqls = _capture_tier_sql(dedup_enabled=True) sqls = _capture_tier_sql()
for i, sql in enumerate(sqls): for i, sql in enumerate(sqls):
assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна" assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра" assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра"
def test_dup_filter_dropped_when_disabled_but_window_stays() -> None:
"""Флаг OFF: фильтр `AND rn_dup = 1` убран, окно остаётся (безвредно)."""
sqls = _capture_tier_sql(dedup_enabled=False)
assert len(sqls) == 4
for i, sql in enumerate(sqls):
assert "AS rn_dup" in sql, f"tier#{i}: окно должно оставаться даже при OFF"
assert "rn_dup = 1" not in sql, f"tier#{i}: фильтр должен исчезнуть при OFF"
def test_rn_dup_key_namespaced_and_freshest() -> None: def test_rn_dup_key_namespaced_and_freshest() -> None:
"""Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC.""" """Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC."""
sql = _capture_tier_sql(dedup_enabled=True)[0] sql = _capture_tier_sql()[0]
assert "PARTITION BY source" in sql assert "PARTITION BY source" in sql
assert "'id:' || source_id::text" in sql assert "'id:' || source_id::text" in sql
assert "'url:' || source_url" in sql assert "'url:' || source_url" in sql
@ -117,7 +96,7 @@ def test_no_bind_param_double_colon_cast() -> None:
import re import re
bad = re.compile(r":[a-z_]+::[a-z]") bad = re.compile(r":[a-z_]+::[a-z]")
for i, sql in enumerate(_capture_tier_sql(dedup_enabled=True)): for i, sql in enumerate(_capture_tier_sql()):
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type" assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"

View file

@ -8,7 +8,6 @@
- radius median выше dkp_low × factor no-op (медиана не изменена) - radius median выше dkp_low × factor no-op (медиана не изменена)
- dkp_raw is None no-op (нет базы для floor) - dkp_raw is None no-op (нет базы для floor)
- anchor-путь (anchor_tier != None) не затронут floor'ом - anchor-путь (anchor_tier != None) не затронут floor'ом
- флаг estimate_radius_floor_enabled=False no-op
""" """
from __future__ import annotations from __future__ import annotations
@ -75,7 +74,6 @@ def _run_estimate(
analogs: list[dict[str, Any]], analogs: list[dict[str, Any]],
dkp_raw: dict[str, Any] | None, dkp_raw: dict[str, Any] | None,
*, *,
radius_floor_enabled: bool = True,
radius_floor_factor: float = 0.8, radius_floor_factor: float = 0.8,
) -> Any: ) -> Any:
from app.core.config import settings from app.core.config import settings
@ -109,7 +107,6 @@ def _run_estimate(
), ),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
patch.object(settings, "estimate_radius_floor_enabled", radius_floor_enabled),
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor), patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
): ):
return await estimate_quality(payload, db) return await estimate_quality(payload, db)
@ -188,62 +185,3 @@ def test_no_dkp_raw_no_floor() -> None:
assert ( assert (
est.median_price_per_m2 < 100_000 est.median_price_per_m2 < 100_000
), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" ), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
# ── тест 4: флаг OFF → no-op ─────────────────────────────────────────────────
def test_radius_floor_disabled_no_op() -> None:
"""estimate_radius_floor_enabled=False → floor не применяется."""
analogs = [
_make_listing(price_per_m2=75_000.0),
_make_listing(price_per_m2=80_000.0),
_make_listing(price_per_m2=85_000.0),
]
dkp_raw = {
"count": 15,
"low_ppm2": 150_000,
"median_ppm2": 180_000,
"high_ppm2": 220_000,
"period_months": 12,
}
# Без флага — median не должна подняться
from app.core.config import settings
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
async def _run() -> Any:
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(analogs), False, "S"),
),
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)),
patch.object(settings, "estimate_radius_floor_enabled", False),
):
return await estimate_quality(payload, db)
est = anyio.run(_run)
assert (
est.median_price_per_m2 < 100_000
), f"Флаг OFF: median_ppm2={est.median_price_per_m2} не должна расти"

View file

@ -47,11 +47,8 @@ def _payload(): # type: ignore[no-untyped-def]
return _h._make_payload(area=60.0, rooms=2) return _h._make_payload(area=60.0, rooms=2)
def _run(*, flag: bool, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def] def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
with ( with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
patch.object(settings, "estimate_wide_corridor_disclosure_enabled", flag),
patch.object(settings, "estimate_wide_corridor_threshold", threshold),
):
return _h._run_estimate( return _h._run_estimate(
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps, anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
anchor_tier=anchor_tier, anchor_tier=anchor_tier,
@ -63,15 +60,10 @@ _DISCLOSURE_MARKER = "разбит на секции"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Флаг по умолчанию # Порог по умолчанию
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_disclosure_flag_default_on() -> None:
# Включено после валидации порога на prod-данных (corridor_pct median≈0.48).
assert settings.estimate_wide_corridor_disclosure_enabled is True
def test_threshold_default_1_2() -> None: def test_threshold_default_1_2() -> None:
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом); # 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост. # genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
@ -83,21 +75,21 @@ def test_threshold_default_1_2() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_tier_a_wide_corridor_flag_on_downgrades_and_discloses() -> None: def test_tier_a_wide_corridor_downgrades_and_discloses() -> None:
"""Tier A + corridor_pct > threshold + флаг ON → confidence понижен + disclosure.""" """Tier A + corridor_pct > threshold → confidence понижен + disclosure."""
on = _run(flag=True, anchor_tier="A") on = _run(anchor_tier="A")
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
assert pct > 0.6, "fixture должен давать коридор > 60%" assert pct > 0.6, "fixture должен давать коридор > 60%"
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "") assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
# confidence понижен относительно flag-OFF baseline (medium → low). # confidence понижен относительно no-fire baseline (порог выше коридора).
off = _run(flag=False, anchor_tier="A") off = _run(anchor_tier="A", threshold=99.0)
assert _downgrade_confidence(off.confidence) == on.confidence assert _downgrade_confidence(off.confidence) == on.confidence
assert on.confidence != off.confidence assert on.confidence != off.confidence
def test_flag_off_no_change() -> None: def test_threshold_above_corridor_no_change() -> None:
"""Флаг OFF → ничего не делает (ни confidence, ни explanation).""" """Порог выше фактического коридора → ничего не делает (ни confidence, ни explanation)."""
off = _run(flag=False, anchor_tier="A") off = _run(anchor_tier="A", threshold=99.0)
assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "") assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "")
@ -108,7 +100,7 @@ def test_flag_off_no_change() -> None:
def test_corridor_fires_when_above_threshold() -> None: def test_corridor_fires_when_above_threshold() -> None:
"""corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает).""" """corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает)."""
on = _run(flag=True, anchor_tier="A", threshold=1.0) on = _run(anchor_tier="A", threshold=1.0)
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
assert pct > 1.0 assert pct > 1.0
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "") assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
@ -117,7 +109,7 @@ def test_corridor_fires_when_above_threshold() -> None:
def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None: def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
"""Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной """Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной
атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6.""" атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6."""
on = _run(flag=True, anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045 on = _run(anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2" assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2"
assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "") assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "")
@ -125,8 +117,8 @@ def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
def test_does_not_touch_point_median_range() -> None: def test_does_not_touch_point_median_range() -> None:
"""Disclosure НЕ трогает point/median/range — только confidence+explanation.""" """Disclosure НЕ трогает point/median/range — только confidence+explanation."""
off = _run(flag=False, anchor_tier="A") off = _run(anchor_tier="A", threshold=99.0)
on = _run(flag=True, anchor_tier="A") on = _run(anchor_tier="A")
assert on.median_price_rub == off.median_price_rub assert on.median_price_rub == off.median_price_rub
assert on.range_low_rub == off.range_low_rub assert on.range_low_rub == off.range_low_rub
assert on.range_high_rub == off.range_high_rub assert on.range_high_rub == off.range_high_rub
@ -134,13 +126,13 @@ def test_does_not_touch_point_median_range() -> None:
def test_below_threshold_no_disclosure() -> None: def test_below_threshold_no_disclosure() -> None:
"""corridor_pct ниже порога → без изменений (порог поднят выше фактического).""" """corridor_pct ниже порога → без изменений (порог поднят выше фактического)."""
high_threshold = _run(flag=True, anchor_tier="A", threshold=5.0) high_threshold = _run(anchor_tier="A", threshold=5.0)
assert _DISCLOSURE_MARKER not in (high_threshold.confidence_explanation or "") assert _DISCLOSURE_MARKER not in (high_threshold.confidence_explanation or "")
def test_non_tier_a_not_affected() -> None: def test_non_tier_a_not_affected() -> None:
"""Не-Tier-A (Tier C) → disclosure не применяется (даже с флагом ON).""" """Не-Tier-A (Tier C) → disclosure не применяется."""
c = _run(flag=True, anchor_tier="C") c = _run(anchor_tier="C")
assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "") assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "")

View file

@ -1,18 +1,33 @@
"""Тесты для ЕКБ bbox-хелперов geocoder (#1871). """Тесты для ЕКБ/oblast66 bbox-хелперов geocoder (#1871, oblast-66 expansion).
Покрывают: Покрывают:
- is_within_ekb_bbox_wide ingest-guard (avito_detail координаты); - is_within_ekb_bbox_wide ingest-guard (avito_detail координаты);
- is_within_ekb_bbox geocoder tight-фильтр (поведение НЕ изменилось после DRY-рефактора); - is_within_ekb_bbox geocoder tight-фильтр (поведение НЕ изменилось после DRY-рефактора);
- инвариант WIDE TIGHT. - инвариант WIDE TIGHT;
- OBLAST66_BBOX / is_within_oblast66_bbox генеральный bbox всей области (region 66),
инвариант OBLAST66 TIGHT, дальние города области, известное ограничение (Тюмень
внутри bbox координатно компенсируется region cross-check на accept-сайтах);
- _has_oblast_marker word-boundary matching (не substring);
- _nominatim_query / _yandex_lookup accept-сайты two-pass tie-break (tight ЕКБ
приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
""" """
from unittest.mock import patch
import httpx
import pytest import pytest
from app.services.geocoder import ( from app.services.geocoder import (
EKB_BBOX_TIGHT, EKB_BBOX_TIGHT,
EKB_BBOX_WIDE, EKB_BBOX_WIDE,
OBLAST66_BBOX,
_has_oblast_marker,
_names_non_ekb_city,
_nominatim_query,
_yandex_lookup,
is_within_ekb_bbox, is_within_ekb_bbox,
is_within_ekb_bbox_wide, is_within_ekb_bbox_wide,
is_within_oblast66_bbox,
) )
# Центр ЕКБ (Плотинка) — внутри обоих bbox. # Центр ЕКБ (Плотинка) — внутри обоих bbox.
@ -86,3 +101,225 @@ def test_wide_strictly_contains_tight() -> None:
for lon in (t_lon_min, t_lon_max): for lon in (t_lon_min, t_lon_max):
assert is_within_ekb_bbox(lat, lon) is True assert is_within_ekb_bbox(lat, lon) is True
assert is_within_ekb_bbox_wide(lat, lon) is True assert is_within_ekb_bbox_wide(lat, lon) is True
# ── OBLAST66_BBOX (region 66 expansion) ─────────────────────────────────────
def test_oblast66_strictly_contains_tight() -> None:
"""Инвариант: OBLAST66_BBOX ⊇ EKB_BBOX_TIGHT — генерализация не режет ЕКБ.
Мирроор test_wide_strictly_contains_tight, но для областного bbox.
"""
t_lat_min, t_lat_max, t_lon_min, t_lon_max = EKB_BBOX_TIGHT
o_lat_min, o_lat_max, o_lon_min, o_lon_max = OBLAST66_BBOX
assert o_lat_min <= t_lat_min
assert o_lat_max >= t_lat_max
assert o_lon_min <= t_lon_min
assert o_lon_max >= t_lon_max
for lat in (t_lat_min, t_lat_max):
for lon in (t_lon_min, t_lon_max):
assert is_within_ekb_bbox(lat, lon) is True
assert is_within_oblast66_bbox(lat, lon) is True
@pytest.mark.parametrize(
"lat,lon,label",
[
(60.6935, 60.4235, "Ивдель"),
(59.6297, 60.5541, "Серов"),
(58.0378, 65.2753, "Тавда"),
(56.6152, 57.7666, "Красноуфимск"),
],
)
def test_oblast66_accepts_far_towns(lat: float, lon: float, label: str) -> None:
"""Дальние города области (вне EKB tight/wide) — приняты oblast66-bbox."""
assert is_within_oblast66_bbox(lat, lon) is True, f"{label} должен быть внутри OBLAST66_BBOX"
def test_oblast66_bbox_admits_tyumen_by_design() -> None:
"""OBLAST66_BBOX — генеральный (щедрый) bbox, координатно admits Тюмень.
Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
false-positive у границы, чем false-negative на корректном адресе области).
Реальное отсечение Тюмени происходит на accept-сайтах через region
cross-check см. test_nominatim_query_rejects_out_of_region_only_candidate /
test_yandex_lookup_rejects_out_of_region_only_candidate ниже.
"""
assert is_within_oblast66_bbox(57.1530, 65.5343) is True
# ── _has_oblast_marker — word/phrase-boundary matching (не substring) ───────
@pytest.mark.parametrize(
"text,expected",
[
("екатеринбург, малышева 30", True),
("нижний тагил, ленина 10", True),
("свердловская область, серова 27", True),
("каменск-уральский, мира 1", True),
("заречный, мира 5", True), # ЗАТО-город (без district-префикса)
# false-positives, которые substring-match ловил ошибочно (#oblast66 bug):
("серова 27", False), # НЕ город "серов"
("ирбитская 5", False), # НЕ город "ирбит"
("асбестовский пер., 3", False), # НЕ город "асбест"
("невьянский пер., 10", False), # НЕ город "невьянск"
("богдановича, 22", False), # НЕ город "богданович"
("мкр заречный, мира 5", False), # район внутри города, не город-ЗАТО
("микрорайон заречный, мира 5", False),
("малышева 30", False), # обычный ЕКБ bare-адрес
],
)
def test_has_oblast_marker(text: str, expected: bool) -> None:
assert _has_oblast_marker(text) is expected
# ── _names_non_ekb_city — gate для EKB-only локальных tier'ов (#11) ─────────
@pytest.mark.parametrize(
"address,expected",
[
("Нижний Тагил, проспект Ленина, 1", True),
("г. Екатеринбург, проспект Ленина, 1", False),
("проспект Ленина, 1", False),
("Серова 27", False), # word-boundary — НЕ город "Серов"
("мкр Заречный, ул. Ленина 5", False), # район внутри города, не ЗАТО Заречный
("Каменск-Уральский, ул. Ленина 1", True),
# явное "Екатеринбург" перевешивает омоним соседнего города в названии
# ЖК/района внутри города (реальный false-positive из прод-кэша, #11):
("24, опалихинская улица, заречный, Верх-Исетский район, Екатеринбург", False),
# word-boundary на "екатеринбург" — "Екатеринбургское шоссе" НЕ город
# Екатеринбург (реальная улица в городах-спутниках, напр. Первоуральске):
("Первоуральск, Екатеринбургское шоссе, 5", True),
# расширенный gazetteer (deep-review fast-follow):
("Верхняя Пышма, ул. Ленина 5", True),
("Сухой Лог, ул. Мира 3", True),
],
)
def test_names_non_ekb_city(address: str, expected: bool) -> None:
assert _names_non_ekb_city(address) is expected
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
async def test_nominatim_query_prefers_tight_ekb_over_oblast_rank0() -> None:
"""rank0 — Серов (внутри OBLAST66, вне tight), rank1 — ЕКБ (tight).
Two-pass tie-break должен выбрать rank1 (tight), а не rank0, несмотря на то
что Nominatim ранжировал его вторым.
"""
payload = [
{"lat": "59.6297", "lon": "60.5541", "address": {"state": "Свердловская область"}},
{"lat": "56.838", "lon": "60.605", "address": {"state": "Свердловская область"}},
]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
item = await _nominatim_query(client, "Серова 27")
assert item is not None
assert float(item["lat"]) == pytest.approx(56.838)
assert float(item["lon"]) == pytest.approx(60.605)
async def test_nominatim_query_rejects_out_of_region_only_candidate() -> None:
"""Единственный кандидат — Тюмень (координатно внутри OBLAST66, но регион другой)
region cross-check отсекает его None (не просто "первый как есть")."""
payload = [{"lat": "57.1522", "lon": "65.5272", "address": {"state": "Тюменская область"}}]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
item = await _nominatim_query(client, "какой-то мусорный адрес")
assert item is None
async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
"""Нет tight-совпадения, `address.state` отсутствует (Nominatim не всегда его
отдаёт) fallback на oblast-bbox (Серов), region cross-check не блокирует."""
payload = [{"lat": "59.6297", "lon": "60.5541"}]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
item = await _nominatim_query(client, "Серов, Ленина 1")
assert item is not None
assert float(item["lat"]) == pytest.approx(59.6297)
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _yandex_client_factory(transport: httpx.MockTransport):
"""Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup."""
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
return factory
def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict:
meta: dict = {"precision": "exact", "text": "тест"}
if admin_area is not None:
meta["AddressDetails"] = {
"Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}}
}
return {
"GeoObject": {
"Point": {"pos": f"{lon} {lat}"},
"metaDataProperty": {"GeocoderMetaData": meta},
}
}
def _yandex_payload(members: list[dict]) -> dict:
return {"response": {"GeoObjectCollection": {"featureMember": members}}}
async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None:
"""Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5."""
members = [
_yandex_geo_object("59.6297", "60.5541", "Свердловская область"), # Серов, rank0
_yandex_geo_object("56.838", "60.605", "Свердловская область"), # ЕКБ tight, rank1
]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("Серова 27", "fake-key")
assert result is not None
assert result.lat == pytest.approx(56.838)
assert result.lon == pytest.approx(60.605)
async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None:
"""Единственный кандидат — Тюмень (bbox admits координатно, регион другой) →
region cross-check отсекает даже в ultimate as-is fallback None."""
members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("какой-то мусорный адрес", "fake-key")
assert result is None

View file

@ -26,6 +26,7 @@ sys.modules.setdefault("weasyprint", _wp_mock)
from app.services.geocoder import ( # noqa: E402 from app.services.geocoder import ( # noqa: E402
GeocodeResult,
GeocodeSuggestion, GeocodeSuggestion,
_cadastral_house_match, _cadastral_house_match,
_parse_street_house, _parse_street_house,
@ -264,6 +265,50 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None:
mock_forward.assert_called_once() mock_forward.assert_called_once()
# ── geocode() non-EKB city gate (#11) ────────────────────────────────────────
async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
"""Non-EKB oblast-адрес (Нижний Тагил) → EKB-only локальные тиры (geoportal/
cadastral) НЕ вызываются, результат идёт от oblast-aware Nominatim (#11).
Без гейта «проспект Ленина, 1» коллизирует с одноимённым ЕКБ-домом через
geoportal/cadastral и снапается в ЕКБ вместо Нижнего Тагила.
"""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=57.905,
lon=59.950,
full_address="проспект Ленина, 1, Нижний Тагил",
provider="nominatim",
confidence="approximate",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match") as mock_geoportal,
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=nominatim_result,
) as mock_nominatim,
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("Нижний Тагил, проспект Ленина, 1", db)
assert result is not None
assert result.lat == pytest.approx(57.905)
assert result.lon == pytest.approx(59.950)
mock_geoportal.assert_not_called()
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
# ── suggest() wiring ───────────────────────────────────────────────────────── # ── suggest() wiring ─────────────────────────────────────────────────────────
@ -328,3 +373,39 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
mock_house.assert_called_once() mock_house.assert_called_once()
mock_forward.assert_called_once() mock_forward.assert_called_once()
mock_yandex.assert_not_called() mock_yandex.assert_not_called()
async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
"""Non-EKB oblast query (Нижний Тагил) → EKB-only cadastral Tier 1
(`_cadastral_house_match` / `_cadastral_forward_sync`) NOT called; falls
through to external tiers, same root cause as `geocode()` (#11)."""
db = MagicMock()
nominatim_hit = [
GeocodeSuggestion(
label="проспект Ленина, 1",
full_address="проспект Ленина, 1, Нижний Тагил",
lat=57.905,
lon=59.950,
kind="house",
)
]
with (
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock,
return_value=nominatim_hit,
) as mock_nominatim,
):
mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
assert len(results) == 1
assert results[0].lat == pytest.approx(57.905)
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()

View file

@ -16,10 +16,13 @@ from app.services.matching.conflict_resolution import (
update_canonical_fields, update_canonical_fields,
) )
from app.services.matching.normalize import ( from app.services.matching.normalize import (
EKB_CITY_TOKEN,
address_fingerprint, address_fingerprint,
has_city_token,
has_house_number, has_house_number,
house_number_token, house_number_token,
normalize_address, normalize_address,
resolve_city_token,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -168,6 +171,68 @@ def test_fingerprint_all_none():
assert len(fp) == 32 assert len(fp) == 32
# ---------------------------------------------------------------------------
# has_city_token — обл.66 city-token detection (Tier-2b oblast guard)
# ---------------------------------------------------------------------------
def test_has_city_token_detects_ekb():
assert has_city_token("екатеринбург улица ленина 5") is True
def test_has_city_token_detects_multiword_oblast_city():
assert has_city_token("нижний тагил улица ленина 100") is True
assert has_city_token("каменск уральский улица мира 7") is True
def test_has_city_token_bare_street_is_false():
# The collision-prone case: a bare street+number carries no city token.
assert has_city_token("улица ленина 100") is False
assert has_city_token("уральская улица 62к1") is False
def test_has_city_token_no_false_positive_on_street_named_after_city():
# 'улица серова' must NOT match the city token 'серов' (word-bounded, not substring).
assert has_city_token("улица серова 5") is False
assert has_city_token("первоуральская улица 3") is False
def test_has_city_token_handles_empty():
assert has_city_token("") is False
assert has_city_token(None) is False
# ---------------------------------------------------------------------------
# resolve_city_token — which обл.66 city (or None); drives the Tier-2a oblast guard
# ---------------------------------------------------------------------------
def test_resolve_city_token_returns_ekb():
assert resolve_city_token("екатеринбург улица ленина 5") == EKB_CITY_TOKEN
def test_resolve_city_token_returns_non_ekb_city():
assert resolve_city_token("нижний тагил улица ленина 100") == "нижний тагил"
assert resolve_city_token("свердловская область каменск уральский улица мира 7") == (
"каменск уральский"
)
def test_resolve_city_token_bare_street_is_none():
# The dominant Avito SERP format — no city token → None (guard stays dormant).
assert resolve_city_token("улица ленина 100") is None
def test_resolve_city_token_no_false_positive_on_street_named_after_city():
assert resolve_city_token("улица серова 5") is None
assert resolve_city_token("первоуральская улица 3") is None
def test_resolve_city_token_handles_empty():
assert resolve_city_token("") is None
assert resolve_city_token(None) is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# match_or_create_house — mock DB tier routing # match_or_create_house — mock DB tier routing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -622,6 +687,204 @@ def test_insert_alias_executes_when_house_number_present():
db.execute.assert_called_once() db.execute.assert_called_once()
def test_insert_alias_conflict_only_rebinds_same_house():
"""Oblast guard (#2): ON CONFLICT refreshes fingerprint/source ONLY for the same
house. A different house colliding on a bare common-street normalized_address must
not rewrite the owner's fingerprint (that would downgrade a coord-bearing alias to a
coord-less one and let the next no-coord card mis-bucket via Tier 2a)."""
from app.services.matching.houses import _insert_alias
db = MagicMock()
_insert_alias(db, house_id=1, address="улица Ленина 100", fp="c" * 32, source="avito")
sql = " ".join(str(db.execute.call_args.args[0]).split())
assert "ON CONFLICT (normalized_address) DO UPDATE" in sql
# fingerprint/source updated only when the existing owner == the incoming house.
assert "house_address_aliases.house_id = EXCLUDED.house_id" in sql
# ---------------------------------------------------------------------------
# match_or_create_house — Tier-2b oblast geo/city guard (bug #2)
# ---------------------------------------------------------------------------
def test_tier2b_coord_present_uses_geo_guard_and_matches_within_radius():
"""Coords present → Tier-2b runs the ST_DWithin JOIN guard; a house within the
radius is accepted as a fingerprint match (0.9)."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss (Tier 1)
None, # fingerprint miss (Tier 2a)
{"house_id": 77}, # Tier 2b JOIN+ST_DWithin hit (same place, within radius)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2b-geo", address="улица Ленина 100", lat=57.910, lon=59.980
)
assert (house_id, conf, method) == (77, 0.9, "fingerprint")
# The Tier-2b lookup must be the geo-guarded JOIN, not a bare normalized_address SELECT.
tier2b_sql = _executed_sqls(db)[3]
assert "ST_DWithin" in tier2b_sql
assert "JOIN houses" in tier2b_sql
def test_tier2b_coord_present_cross_city_falls_through_to_new():
"""A bare street+number oblast card WITH coords whose only same-street alias is a
far-away (cross-city) house: the ST_DWithin guard filters it out (JOIN miss) the
card falls through to a New house instead of mis-bucketing into the ЕКБ house."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
None, # fingerprint miss (Tier 2a)
None, # Tier 2b JOIN+ST_DWithin miss (candidate house is >3km away)
None, # geo miss (Tier 3 — coords present)
{"id": 909}, # INSERT RETURNING id (New house)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2b-xcity", address="улица Ленина 100", lat=57.910, lon=59.980
)
assert (house_id, conf, method) == (909, 1.0, "new")
def test_tier2b_no_coords_no_city_token_skips_and_creates_new():
"""No coords AND a bare common-street (no city token) → Tier-2b is SKIPPED entirely
(no normalized_address lookup). The card falls through to a New house conservative:
do not mis-bucket a bare 'улица ленина 100' into a same-named ЕКБ alias."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
None, # fingerprint miss (Tier 2a); Tier 2b SKIPPED (no coords, no city token)
{"id": 910}, # INSERT RETURNING id (New house) — no Tier 3 (no coords)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2b-bare", address="улица Ленина 100"
)
assert (house_id, conf, method) == (910, 1.0, "new")
assert not any(
"normalized_address = :na" in s for s in _executed_sqls(db)
), "bare common-street with no coords/city must not run a Tier-2b lookup"
def test_tier2b_no_coords_with_city_token_matches():
"""No coords but the normalized_address carries a city token → self-disambiguating,
so Tier-2b matches on normalized_address alone (0.9). ЕКБ cross-source dedup for
city-qualified addresses is preserved."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
None, # fingerprint miss (Tier 2a)
{"house_id": 33}, # Tier 2b normalized_address hit (city token present)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2b-city", address="Екатеринбург, улица Ленина, 100"
)
assert (house_id, conf, method) == (33, 0.9, "fingerprint")
tier2b_sql = _executed_sqls(db)[3]
assert "normalized_address = :na" in tier2b_sql
assert "ST_DWithin" not in tier2b_sql
# ---------------------------------------------------------------------------
# match_or_create_house — Tier-2a coord-less oblast city guard (#2500 follow-up)
# ---------------------------------------------------------------------------
def test_tier2a_coord_less_non_ekb_city_skips_both_lookups_and_creates_new():
"""A coord-less card whose address resolves a NON-ЕКБ city (Н.Тагил) must NOT match a
globally-unique alias by fingerprint OR normalized_address (either would corrupt ЕКБ
house data). The guard skips both alias lookups New house."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss (Tier 1)
# Tier 2a (fingerprint) SKIPPED, Tier 2b (normalized_address) SKIPPED,
# Tier 3 SKIPPED (no coords) — guard forces fall-through to New.
{"id": 701}, # INSERT RETURNING id (New house)
None, # _upsert_house_source
None, # _insert_alias (address carries a house number → executes)
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2a-nt", address="Нижний Тагил, ул. Ленина, 100"
)
assert (house_id, conf, method) == (701, 1.0, "new")
sqls = _executed_sqls(db)
assert not any(
"fingerprint = :fp" in s for s in sqls
), "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup"
assert not any(
"normalized_address = :na" in s for s in sqls
), "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup"
def test_tier2a_coord_less_ekb_city_still_matches():
"""EKB happy-path: a coord-less card that resolves ЕКБ runs Tier-2a EXACTLY as before —
the guard does not fire (екатеринбург == EKB_CITY_TOKEN)."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
{"house_id": 88}, # Tier 2a fingerprint HIT (guard did NOT skip it)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2a-ekb", address="Екатеринбург, ул. Ленина, 5"
)
assert (house_id, conf, method) == (88, 0.9, "fingerprint")
assert any(
"fingerprint = :fp" in s for s in _executed_sqls(db)
), "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup"
def test_tier2a_coord_less_bare_street_still_runs_tier2a():
"""EKB happy-path (dominant): a coord-less BARE card (no city token — today's Avito SERP
format) resolves to None guard dormant Tier-2a fingerprint dedup UNCHANGED."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
{"house_id": 99}, # Tier 2a fingerprint HIT (bare card → guard did not skip)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2a-bare", address="ул. Ленина, 5"
)
assert (house_id, conf, method) == (99, 0.9, "fingerprint")
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# match_or_create_listing — mock DB tier routing # match_or_create_listing — mock DB tier routing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -122,7 +122,10 @@ def test_insufficient_data_page_contains_address_and_report_num() -> None:
est = _zero_estimate() est = _zero_estimate()
html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC) html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
assert "Ленина" in html # address assert "Ленина" in html # address
assert "EKБ-" in html # report number prefix # #pdf-honesty: № отчёта city-aware — у оценки без target_address город не распознан
# → нейтральный префикс «МЕРА» (раньше был захардкожен «EKБ» для любого объекта).
assert "МЕРА-" in html
assert "EKБ-" not in html # старый mixed-script хардкод убран
def test_generate_pdf_insufficient_skips_offer_page() -> None: def test_generate_pdf_insufficient_skips_offer_page() -> None:
@ -197,20 +200,23 @@ def test_cover_disclaimer_absent_when_none() -> None:
def test_cover_renders_logo_img_when_valid_url() -> None: def test_cover_renders_logo_img_when_valid_url() -> None:
est = _estimate() # #4-pages-fix: _page_header больше не встроен в _build_cover — он рендерится
html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA) # ОДИН раз в generate_trade_in_pdf как running-header (position:running(...),
assert "<img" in html # печатается WeasyPrint'ом в @top-center margin-box на каждой странице), см.
assert "gk-praktika.ru" in html # trade_in_pdf.py::generate_trade_in_pdf. Логотип/wordmark проверяем напрямую
# через _page_header, не через _build_cover (симметрично уже существовавшему
# паттерну в test_cover_falls_back_to_name_when_logo_none ниже).
header_html = mod._page_header(_PRAKTIKA, "EKБ-0001-0000000001", datetime.now(UTC).date())
assert "<img" in header_html
assert "gk-praktika.ru" in header_html
def test_cover_falls_back_to_name_when_logo_none() -> None: def test_cover_falls_back_to_name_when_logo_none() -> None:
est = _estimate()
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
assert "TRADE-IN" in html.upper()
# Logo-specific check via _page_header directly — не проверяем отсутствие <img> во # Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как # всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
# matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда. # matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда.
header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date()) header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date())
assert "TRADE-IN" in header_html.upper()
assert "<img" not in header_html assert "<img" not in header_html

View file

@ -278,3 +278,45 @@ async def test_fetch_serp_html_routes_through_browser() -> None:
assert html == "<html><body>real serp listing</body></html>" assert html == "<html><body>real serp listing</body></html>"
scraper._browser.fetch.assert_awaited_once() scraper._browser.fetch.assert_awaited_once()
# ── 6. SERP browser sidecar timeout → transient retry, not bare exception ────
# #2472: BrowserFetcher.fetch (browser_fetcher.py) retries once internally on
# httpx.HTTPError/TransportError, then re-raises. Before this fix, non-
# HTTPStatusError exceptions (httpx.TimeoutException/TransportError — e.g.
# ReadTimeout/ConnectError/RemoteProtocolError) propagated unhandled through
# _fetch_serp_html_browser straight into pipeline.run_avito_full_load's generic
# `except Exception` → mark_failed(str(exc)) (str(ReadTimeout())=="" → empty
# error, and mark_failed does NOT persist done_buckets, unlike mark_banned).
@pytest.mark.asyncio
async def test_fetch_serp_html_browser_timeout_routes_transient_not_bare_exception() -> None:
"""httpx.ReadTimeout from browser.fetch() must NOT propagate as an unhandled
exception. It's classified transient (status=0, no soft-ban keyword match)
and, once the bounded retry budget is exhausted, surfaces as
AvitoRateLimitedError the same graceful banned-path a sidecar
HTTPStatusError takes never as a bare/generic exception."""
from unittest.mock import patch
import httpx
from scraper_kit.avito_exceptions import AvitoRateLimitedError
from scraper_kit.providers.avito import serp as serp_module
from scraper_kit.providers.avito.serp import AvitoScraper
from app.services.scraper_adapters import RealScraperConfig
scraper = AvitoScraper(RealScraperConfig())
assert scraper._cffi is None
scraper._browser = AsyncMock()
scraper._browser.fetch = AsyncMock(side_effect=httpx.ReadTimeout(""))
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
with pytest.raises(AvitoRateLimitedError):
await scraper._fetch_serp_html(
"https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
)
# 1 initial attempt + 2 transient retries (_AVITO_SIDECAR_TRANSIENT_RETRIES) = 3,
# no IP rotation involved (RealScraperConfig has no rotate_url configured in tests).
assert scraper._browser.fetch.await_count == 3

View file

@ -44,6 +44,9 @@ def _reset_auth_cache() -> None:
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) _PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
# MIRROR of app.main (#R2-H3): keep in sync with the real rbac_guard.
_EXTERNAL_PREFIX = "/trade-in"
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
def _build_test_app() -> FastAPI: def _build_test_app() -> FastAPI:
@ -76,6 +79,18 @@ def _build_test_app() -> FastAPI:
status_code=403, status_code=403,
content={"detail": "admin only"}, content={"detail": "admin only"},
) )
# #R2-H3 mirror: enforce roles.yaml scope on non-bootstrap paths.
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
try:
allowed = auth_mod.is_path_allowed(role, external_path)
except Exception:
allowed = True
if not allowed:
return JSONResponse(
status_code=403,
content={"detail": "forbidden for role"},
)
return await call_next(request) return await call_next(request)
app.include_router(me_router.router, prefix="/api/v1", tags=["me"]) app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
@ -84,6 +99,18 @@ def _build_test_app() -> FastAPI:
async def admin_dummy() -> dict: async def admin_dummy() -> dict:
return {"ok": True} return {"ok": True}
@app.get("/api/v1/search")
async def search_dummy() -> dict:
return {"ok": True}
@app.get("/api/v1/trade-in/dummy")
async def tradein_dummy() -> dict:
return {"ok": True}
@app.get("/api/v1/brand/dummy")
async def brand_dummy() -> dict:
return {"ok": True}
@app.get("/health") @app.get("/health")
async def health() -> dict: async def health() -> dict:
return {"status": "ok"} return {"status": "ok"}
@ -105,9 +132,7 @@ def test_get_role_known_users() -> None:
assert auth_mod.get_role("admin") == "admin" assert auth_mod.get_role("admin") == "admin"
assert auth_mod.get_role("kopylov") == "pilot" assert auth_mod.get_role("kopylov") == "pilot"
for n in range(1, 11): for n in range(1, 11):
# user2 = «Брусника»: пробный доступ закрыт 2026-07-09 (roles.yaml) assert auth_mod.get_role(f"user{n}") == "pilot"
expected = "expired" if n == 2 else "pilot"
assert auth_mod.get_role(f"user{n}") == expected
def test_get_role_unknown_user_raises() -> None: def test_get_role_unknown_user_raises() -> None:
@ -288,6 +313,50 @@ def test_rbac_guard_unknown_user_blocked_on_non_admin_path(client: TestClient) -
assert "not in roles config" in resp.json()["detail"].lower() assert "not in roles config" in resp.json()["detail"].lower()
# --- #R2-H3: roles.yaml scope enforced on ALL non-admin paths (not just /admin/*) ---
def test_rbac_expired_denied_on_non_admin_api(client: TestClient) -> None:
"""#R2-H3: revoked (role=expired, paths:[] deny:/**) НЕ достаёт non-admin API.
Раньше rbac_guard гейтил только /admin/* expired имел полный non-admin доступ
(POST /search экспорт листингов и т.д.). Теперь scope-blocked 403."""
for user in ("praktika",): # role=expired (trial отозван; user2 восстановлен 2026-07-13)
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
resp = client.get(path, headers={"X-Authenticated-User": user})
assert resp.status_code == 403, f"{user} {path}: {resp.status_code}"
assert "forbidden for role" in resp.json()["detail"].lower()
def test_rbac_expired_allowed_on_bootstrap(client: TestClient) -> None:
"""expired ДОЛЖЕН достучаться до /me (получить role=expired → trial-экран) и
/brand/* (брендинг trial-экрана) иначе UX сломан. Bootstrap-исключение."""
resp_me = client.get("/api/v1/me", headers={"X-Authenticated-User": "praktika"})
assert resp_me.status_code == 200, resp_me.text
assert resp_me.json()["role"] == "expired"
resp_brand = client.get("/api/v1/brand/dummy", headers={"X-Authenticated-User": "praktika"})
assert resp_brand.status_code == 200, resp_brand.text
def test_rbac_pilot_allowed_on_tradein_api(client: TestClient) -> None:
"""pilot по roles.yaml имеет /trade-in/api/v1/** → доступ к trade-in/search сохраняется."""
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
resp = client.get(path, headers={"X-Authenticated-User": "kopylov"})
assert resp.status_code == 200, f"pilot {path}: {resp.text}"
def test_rbac_admin_allowed_everywhere(client: TestClient) -> None:
for path in ("/api/v1/search", "/api/v1/trade-in/dummy", "/api/v1/admin/dummy"):
resp = client.get(path, headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 200, f"admin {path}: {resp.text}"
def test_rbac_analyst_allowed_non_admin_denied_admin(client: TestClient) -> None:
ok = client.get("/api/v1/search", headers={"X-Authenticated-User": "analysttest"})
assert ok.status_code == 200, ok.text
denied = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "analysttest"})
assert denied.status_code == 403
def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient) -> None: def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient) -> None:
"""Любой non-public path без X-Authenticated-User → 401.""" """Любой non-public path без X-Authenticated-User → 401."""
resp = client.get("/api/v1/me") resp = client.get("/api/v1/me")

View file

@ -0,0 +1,140 @@
"""Tests for RequestAuditMiddleware (app/core/request_audit.py).
Тестируем middleware в изоляции на минимальном FastAPI-приложении (как
tests/test_ratelimit.py) не тянем тяжёлый app.main. schedule_event
и should_log_login мокируются, реальная запись в user_events не требуется
(она уже покрыта tests/test_user_events.py).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core.request_audit import RequestAuditMiddleware
@pytest.fixture
def client() -> TestClient:
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/api/v1/ping")
def ping() -> dict[str, bool]:
return {"ok": True}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
return TestClient(app)
def test_authenticated_api_request_schedules_api_request_event(client: TestClient) -> None:
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
mock_schedule.assert_called_once()
kwargs = mock_schedule.call_args.kwargs
assert kwargs["event_type"] == "api_request"
assert kwargs["username"] == "alice"
assert kwargs["path"] == "/api/v1/ping"
assert kwargs["method"] == "GET"
def test_login_event_scheduled_when_should_log_login_true(client: TestClient) -> None:
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=True),
):
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
assert event_types == ["api_request", "login"]
def test_login_event_not_scheduled_when_should_log_login_false(client: TestClient) -> None:
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
assert event_types == ["api_request"]
def test_no_username_header_skips_audit(client: TestClient) -> None:
with patch("app.core.request_audit.schedule_event") as mock_schedule:
resp = client.get("/api/v1/ping")
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_public_path_skips_audit_even_with_username(client: TestClient) -> None:
with patch("app.core.request_audit.schedule_event") as mock_schedule:
resp = client.get("/health", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_non_api_path_skips_audit(client: TestClient) -> None:
"""Path not under /api/ (and not one of the FastAPI-generated docs paths) is skipped."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/other")
def other() -> dict[str, bool]:
return {"ok": True}
with patch("app.core.request_audit.schedule_event") as mock_schedule:
resp = TestClient(app).get("/other", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_audit_failure_does_not_break_response(client: TestClient) -> None:
"""A raising schedule_event must never turn a 200 into a 500 (dispatch swallows it)."""
with patch("app.core.request_audit.schedule_event", side_effect=RuntimeError("boom")):
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_admin_path_excluded_from_api_request_but_login_kept() -> None:
"""/api/v1/admin/* — ops-действия (просмотр дашбордов аудита/аналитики), не
поведение пилота: api_request НЕ пишем (иначе дашборд зашумляет активность),
а login (вход админа с IP) остаётся валидным аудитом."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/api/v1/admin/analytics")
def analytics() -> dict[str, bool]:
return {"ok": True}
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=True),
):
resp = TestClient(app).get(
"/api/v1/admin/analytics", headers={"X-Authenticated-User": "admin"}
)
assert resp.status_code == 200
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
assert event_types == ["login"]

View file

@ -7,6 +7,7 @@
Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через inspect.getsource), Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через inspect.getsource),
плюс содержимое backfill-миграции 077 без живого FDW / DB-фикстуры. плюс содержимое backfill-миграции 077 без живого FDW / DB-фикстуры.
""" """
import inspect import inspect
import os import os
from pathlib import Path from pathlib import Path
@ -45,9 +46,67 @@ def test_import_populates_source_id() -> None:
assert '"source_id": str(row["source_id_src"])' in _IMPORT_SRC assert '"source_id": str(row["source_id_src"])' in _IMPORT_SRC
def test_import_keeps_on_conflict_dedup_hash() -> None: def test_import_uses_on_conflict_do_update_for_corrections() -> None:
"""Дедуп по-прежнему через ON CONFLICT (dedup_hash) DO NOTHING.""" """Fix D: дедуп по dedup_hash, но ON CONFLICT DO UPDATE (не DO NOTHING) —
assert "ON CONFLICT (dedup_hash) DO NOTHING" in _IMPORT_SRC
исправленный/переопубликованный квартал обновляет сырые факты Росреестра.
"""
assert "ON CONFLICT (dedup_hash) DO UPDATE SET" in _IMPORT_SRC
assert "DO NOTHING" not in _IMPORT_BODY
# Обновляются изменяемые сырые факты...
for col in (
"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",
):
assert col in _IMPORT_SRC, f"missing DO UPDATE SET column: {col!r}"
# ...но идемпотентность через IS DISTINCT FROM (неизменные строки не трогаются).
assert "IS DISTINCT FROM EXCLUDED.price_rub" in _IMPORT_SRC
# xmax-дискриминатор insert vs update.
assert "RETURNING (xmax = 0)" in _IMPORT_SRC
def test_import_do_update_never_overwrites_enrichment_columns() -> None:
"""Fix D: обогащение (lat/lon/geom/geocode/cadastral_number...) не в SET-списке.
Эти колонки заполняются пост-импортом (geocode_deals_*) повторный импорт не
должен их затирать. Отсутствие в SET DO UPDATE гарантирует сохранность.
"""
conflict_clause = _IMPORT_SRC.split("ON CONFLICT (dedup_hash) DO UPDATE SET", 1)[1]
set_and_where = conflict_clause.split("RETURNING", 1)[0]
for enrich in (
"lat =",
"lon =",
"geom =",
"geocode_tried_at =",
"cadastral_number =",
"total_floors =",
"house_type =",
):
assert enrich not in set_and_where, f"enrichment column leaked into UPDATE: {enrich!r}"
def test_import_separates_errors_from_dedup_skips() -> None:
"""Fix C: INSERT-ошибки считаются отдельным счётчиком, не как dedup-skip."""
assert '"rows_errored": 0' in _IMPORT_SRC
assert '"rows_updated": 0' in _IMPORT_SRC
assert "batch_errored += 1" in _IMPORT_SRC
# Per-row except-ветка (после лога "INSERT failed", до конца батча) инкрементит
# ТОЛЬКО errored — больше НЕ skip (это был баг silent-green: ошибки маскировались
# под дедуп).
per_row_except = _IMPORT_SRC.split("INSERT failed", 1)[1].split("last_id = batch_max_id", 1)[0]
assert "batch_errored += 1" in per_row_except
assert "batch_skipped += 1" not in per_row_except
# Порог ошибок → run failed, а не silent-green.
assert "DKP_IMPORT_ERROR_RATE_THRESHOLD" in _IMPORT_SRC
assert "marking run failed" in _IMPORT_SRC
def test_import_uses_psycopg_v3_casts_not_double_colon() -> None: def test_import_uses_psycopg_v3_casts_not_double_colon() -> None:
@ -89,12 +148,18 @@ def test_migration_077_converts_md5_to_plain_key() -> None:
assert "BEGIN;" in sql and "COMMIT;" in sql assert "BEGIN;" in sql and "COMMIT;" in sql
def test_migration_077_filter_matches_live_import() -> None: def test_migration_077_shared_filter_matches_live_import() -> None:
"""Фильтр src CTE байт-в-байт совпадает с живым импортом → все строки сконвертируются.""" """Дедуп-релевантные фильтры src CTE миграции 077 совпадают с живым импортом.
Исключение city ILIKE (см. test_live_import_dropped_ekb_city_filter ниже):
077 backfill'ил ЕКБ-строки под EKB-only scope того времени; живой импорт расширен
на всю Свердловскую область (region_code=66, все города), поэтому city-фильтр из
живого импорта СНЯТ намеренно. Остальные клозы обязаны совпадать байт-в-байт,
иначе 077 конвертировал бы не тот набор строк.
"""
sql = _MIGRATION_077.read_text("utf-8") sql = _MIGRATION_077.read_text("utf-8")
for clause in ( for clause in (
"region_code = 66", "region_code = 66",
"city ILIKE '%катеринбург%'",
"realestate_type_code = '002001003000'", "realestate_type_code = '002001003000'",
"area BETWEEN 18 AND 200", "area BETWEEN 18 AND 200",
"deal_price BETWEEN 1000000 AND 100000000", "deal_price BETWEEN 1000000 AND 100000000",
@ -103,3 +168,17 @@ def test_migration_077_filter_matches_live_import() -> None:
): ):
assert clause in sql, f"missing filter clause in migration: {clause!r}" assert clause in sql, f"missing filter clause in migration: {clause!r}"
assert clause in _IMPORT_SRC, f"missing filter clause in import: {clause!r}" assert clause in _IMPORT_SRC, f"missing filter clause in import: {clause!r}"
def test_live_import_dropped_ekb_city_filter() -> None:
"""Живой импорт БОЛЬШЕ не фильтрует по городу — Mera расширена на всю обл. 66.
Историческая миграция 077 (уже применена на прод, трогать нельзя) сохраняет свой
EKB-only city-фильтр; живой импорт нет. Guard против случайного возврата
ЕКБ-скоупа, который снова отрезал бы +47k не-ЕКБ сделок.
"""
sql = _MIGRATION_077.read_text("utf-8")
# 077 — исторический EKB-scope, city-фильтр там остаётся неизменным.
assert "city ILIKE '%катеринбург%'" in sql
# Живой импорт city-фильтр снял намеренно (region_code=66 = вся область).
assert "ILIKE '%катеринбург%'" not in _IMPORT_SRC

View file

@ -3,10 +3,17 @@
Coverage: Coverage:
(a) _next_quarter: boundary cases (Q4 Q1 of next year, mid-year). (a) _next_quarter: boundary cases (Q4 Q1 of next year, mid-year).
(b) latest_loaded_quarter: derives correct (year, quarter) from a known deal_date. (b) latest_loaded_quarter: derives correct (year, quarter) from a known deal_date.
(c) poll_rosreestr_new_quarter available=True path (mocked httpx + fake DB). (c) rosreestr_dataset_url: cp1251 percent-encoding matches the real portal URL.
(d) poll_rosreestr_new_quarter available=False path. (d) check_new_quarter_available new /data-sets/ autoindex flow:
(e) Network error available=False (never raises into caller). - real application/zip + large Content-Length True
(f) No DB data fallback baseline 2025Q4, checks 2026Q1. - soft-404 regression: file found but Content-Type text/html False
(this is the exact bug that broke the old /opendata/ flat-URL poller)
- tiny Content-Length (placeholder/error file) False
- quarter folder missing from autoindex False (not yet published)
- dataset file missing from quarter folder False
- network error / timeout False, never raises
(e) poll_rosreestr_new_quarter orchestration: available=True/False end-to-end,
no DB data fallback, network error propagation.
""" """
from __future__ import annotations from __future__ import annotations
@ -15,6 +22,7 @@ import os
import sys import sys
from datetime import date from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import quote
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -33,6 +41,46 @@ from app.services.rosreestr_poll import ( # noqa: E402
rosreestr_dataset_url, rosreestr_dataset_url,
) )
# ---------------------------------------------------------------------------
# cp1251 href fixtures — built the same way the real portal encodes folder/file
# names (percent-encoded cp1251 bytes), so tests exercise the actual decode path.
# ---------------------------------------------------------------------------
def _cp1251_href(name: str) -> str:
return quote(name.encode("cp1251"), safe="")
def _autoindex_html(*folder_names: str) -> str:
"""Build a fake Apache-autoindex-style HTML listing for /data-sets/."""
links = ['<a href="../">../</a>']
for name in folder_names:
href = _cp1251_href(name) + "/"
links.append(f'<a href="{href}">{name}/</a>')
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
def _folder_html(*file_names: str) -> str:
"""Build a fake Apache-autoindex-style HTML listing for a quarter folder."""
links = ['<a href="../">../</a>']
for name in file_names:
href = _cp1251_href(name)
links.append(f'<a href="{href}">{name}</a>')
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
_SDELKI_FILE_2026_Q1 = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv.zip"
_KADASTR_FILE_2026_Q1 = "dataset_КАДАСТРСТОИМОСТЬ_r-r_01-92_y_2026_q_1.csv.zip"
def _mock_response(*, status_code: int = 200, text: str = "", headers: dict | None = None):
resp = MagicMock()
resp.status_code = status_code
resp.text = text
resp.headers = headers or {}
return resp
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# (a) _next_quarter # (a) _next_quarter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -62,10 +110,25 @@ def test_period_start_to_quarter_from_string() -> None:
assert _period_start_to_quarter("2026-07-01") == (2026, 3) assert _period_start_to_quarter("2026-07-01") == (2026, 3)
# ---------------------------------------------------------------------------
# (c) rosreestr_dataset_url — cp1251 percent-encoding must match the real portal.
# ---------------------------------------------------------------------------
def test_rosreestr_dataset_url_matches_verified_real_encoding() -> None:
"""Encoding must byte-match the live-verified rosreestr.gov.ru/data-sets/ URL."""
url = rosreestr_dataset_url(2026, 1)
assert url == (
"https://rosreestr.gov.ru/data-sets/"
"1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./"
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"
)
def test_rosreestr_dataset_url_contains_year_quarter() -> None: def test_rosreestr_dataset_url_contains_year_quarter() -> None:
url = rosreestr_dataset_url(2026, 2) url = rosreestr_dataset_url(2026, 2)
assert "2026" in url assert "2026" in url
assert "_2" in url or "q_2" in url # quarter embedded assert "q_2" in url
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -125,22 +188,181 @@ def test_latest_loaded_quarter_returns_none_when_empty() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# (c) poll_rosreestr_new_quarter — available=True # (d) check_new_quarter_available — /data-sets/ autoindex flow
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_poll_available_true_returns_correct_next_quarter() -> None: async def test_check_available_true_for_real_zip() -> None:
"""If latest loaded is Q1 2026 and head returns 200, returns available=True for Q2 2026.""" """Folder found, file found, HEAD returns application/zip + large Content-Length → True."""
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026 index_html = _autoindex_html("1 квартал 2026г.", "4 квартал 2025г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1, _KADASTR_FILE_2026_Q1)
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.head.return_value = mock_response mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = _mock_response(
headers={"content-type": "application/zip", "content-length": "9500000"}
)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is True
assert mock_client.get.call_count == 2
assert mock_client.head.call_count == 1
@pytest.mark.asyncio
async def test_check_available_false_soft_404_text_html() -> None:
"""Regression guard: HTTP 200 with Content-Type text/html (Bitrix soft-404) → False.
This is the exact failure mode that broke the old /opendata/ flat-URL poller
it treated bare HTTP 200 as availability. Even if a matching file href is found,
a non-zip Content-Type must never be treated as available.
"""
index_html = _autoindex_html("1 квартал 2026г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = _mock_response(
status_code=200,
headers={"content-type": "text/html; charset=utf-8", "content-length": "1234"},
)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_available_false_tiny_content_length() -> None:
"""application/zip but tiny Content-Length (placeholder/error file) → False."""
index_html = _autoindex_html("1 квартал 2026г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = _mock_response(
headers={"content-type": "application/zip", "content-length": "512"}
)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_available_false_quarter_folder_missing() -> None:
"""Autoindex doesn't have the target quarter folder yet → False, no further GET/HEAD."""
index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not published yet
mock_client = AsyncMock()
mock_client.get.return_value = _mock_response(text=index_html)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
assert mock_client.get.call_count == 1 # never fetched a (non-existent) folder
mock_client.head.assert_not_called()
@pytest.mark.asyncio
async def test_check_available_false_dataset_file_missing_in_folder() -> None:
"""Quarter folder exists but has no dataset_СДЕЛКИ file (only КАДАСТРСТОИМОСТЬ) → False."""
index_html = _autoindex_html("1 квартал 2026г.")
folder_html = _folder_html(_KADASTR_FILE_2026_Q1) # no СДЕЛКИ file
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
mock_client.head.assert_not_called()
@pytest.mark.asyncio
async def test_check_available_false_on_index_404() -> None:
"""Unexpected non-200 on the base autoindex → False, no raise."""
mock_client = AsyncMock()
mock_client.get.return_value = _mock_response(status_code=404, text="")
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_new_quarter_available_timeout_returns_false() -> None:
"""Timeout → returns False, no exception."""
import httpx
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.TimeoutException("timeout")
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_new_quarter_available_network_error_returns_false() -> None:
"""Network error mid-flow (folder GET) → returns False, no exception."""
import httpx
index_html = _autoindex_html("1 квартал 2026г.")
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
httpx.ConnectError("simulated network error"),
]
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
# ---------------------------------------------------------------------------
# (e) poll_rosreestr_new_quarter — orchestration
# ---------------------------------------------------------------------------
def _patched_client_for(index_html: str, folder_html: str, head_response) -> AsyncMock:
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = head_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
return mock_client
@pytest.mark.asyncio
async def test_poll_available_true_returns_correct_next_quarter() -> None:
"""If latest loaded is Q1 2026 and the Q2 2026 dataset is a real zip, available=True."""
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026 → next = Q2 2026
index_html = _autoindex_html("2 квартал 2026г.")
file_name = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv.zip"
folder_html = _folder_html(file_name)
head_response = _mock_response(
headers={"content-type": "application/zip", "content-length": "9500000"}
)
mock_client = _patched_client_for(index_html, folder_html, head_response)
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client): with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type] result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
@ -152,21 +374,15 @@ async def test_poll_available_true_returns_correct_next_quarter() -> None:
assert result["latest_loaded_quarter"] == 1 assert result["latest_loaded_quarter"] == 1
# ---------------------------------------------------------------------------
# (d) poll_rosreestr_new_quarter — available=False (404)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_poll_available_false_on_404() -> None: async def test_poll_available_false_when_folder_not_published() -> None:
"""404 from server → available=False.""" """Quarter folder not yet published (autoindex has no matching entry) → available=False."""
db = _FakeDB(date(2026, 3, 15)) # Q1 2026 db = _FakeDB(date(2026, 3, 15)) # Q1 2026 → next = Q2 2026
mock_response = MagicMock() index_html = _autoindex_html("1 квартал 2026г.") # Q2 2026 missing
mock_response.status_code = 404
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.head.return_value = mock_response mock_client.get.return_value = _mock_response(text=index_html)
mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
@ -178,11 +394,6 @@ async def test_poll_available_false_on_404() -> None:
assert result["quarter"] == 2 assert result["quarter"] == 2
# ---------------------------------------------------------------------------
# (e) Network error → available=False (never raises into caller)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_poll_network_error_returns_false_no_raise() -> None: async def test_poll_network_error_returns_false_no_raise() -> None:
"""Network error → available=False, no exception propagated.""" """Network error → available=False, no exception propagated."""
@ -191,7 +402,7 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
db = _FakeDB(date(2026, 1, 20)) # Q1 2026 db = _FakeDB(date(2026, 1, 20)) # Q1 2026
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.head.side_effect = httpx.ConnectError("simulated network error") mock_client.get.side_effect = httpx.ConnectError("simulated network error")
mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
@ -202,18 +413,6 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
assert result["available"] is False assert result["available"] is False
@pytest.mark.asyncio
async def test_check_new_quarter_available_timeout_returns_false() -> None:
"""Timeout → returns False, no exception."""
import httpx
mock_client = AsyncMock()
mock_client.head.side_effect = httpx.TimeoutException("timeout")
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
assert result is False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# (f) No DB data → fallback baseline checks 2026Q1 # (f) No DB data → fallback baseline checks 2026Q1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -224,11 +423,10 @@ async def test_poll_fallback_when_no_db_data() -> None:
"""No rosreestr deals in DB → fallback 2025Q4 baseline, checks 2026Q1.""" """No rosreestr deals in DB → fallback 2025Q4 baseline, checks 2026Q1."""
db = _FakeDB(None) db = _FakeDB(None)
mock_response = MagicMock() index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not yet published
mock_response.status_code = 404 # not yet available
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.head.return_value = mock_response mock_client.get.return_value = _mock_response(text=index_html)
mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.__aexit__ = AsyncMock(return_value=False)
@ -238,6 +436,7 @@ async def test_poll_fallback_when_no_db_data() -> None:
# Fallback: 2025Q4 → check 2026Q1 # Fallback: 2025Q4 → check 2026Q1
assert result["year"] == 2026 assert result["year"] == 2026
assert result["quarter"] == 1 assert result["quarter"] == 1
assert result["available"] is False
# latest_loaded_year/quarter should be None (no DB data) # latest_loaded_year/quarter should be None (no DB data)
assert result["latest_loaded_year"] is None assert result["latest_loaded_year"] is None
assert result["latest_loaded_quarter"] is None assert result["latest_loaded_quarter"] is None

View file

@ -426,7 +426,6 @@ def _run_estimate(
*, *,
anchor_comps: list[dict[str, Any]], anchor_comps: list[dict[str, Any]],
anchor_tier: str | None, anchor_tier: str | None,
flag_enabled: bool = True,
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"), ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
payload=None, payload=None,
radius_analogs: list[dict[str, Any]] | None = None, radius_analogs: list[dict[str, Any]] | None = None,
@ -439,7 +438,6 @@ def _run_estimate(
ST_DWithin не нашёл радиусных аналогов (median=0) якорь обязан сработать ST_DWithin не нашёл радиусных аналогов (median=0) якорь обязан сработать
по same-building комплам независимо. по same-building комплам независимо.
""" """
from app.core.config import settings
from app.services.estimator import estimate_quality from app.services.estimator import estimate_quality
db = MagicMock() db = MagicMock()
@ -448,7 +446,6 @@ def _run_estimate(
async def _run(): async def _run():
with ( with (
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
patch( patch(
"app.services.estimator.geocode", "app.services.estimator.geocode",
new=AsyncMock(return_value=geo_result or _make_fake_geo()), new=AsyncMock(return_value=geo_result or _make_fake_geo()),
@ -546,14 +543,6 @@ def test_estimate_economy_no_regression() -> None:
assert est.median_price_per_m2 >= 100_000 assert est.median_price_per_m2 >= 100_000
def test_estimate_flag_off_unchanged_radius_result() -> None:
"""(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает."""
est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False)
# Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k.
assert est_off.median_price_per_m2 == 210_000
assert est_off.median_price_rub == int(210_000 * 146.2)
def test_estimate_expected_sold_distinct_after_anchor() -> None: def test_estimate_expected_sold_distinct_after_anchor() -> None:
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а """(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
expected_sold = headline × per-rooms ratio DISTINCT, строго ниже median. expected_sold = headline × per-rooms ratio DISTINCT, строго ниже median.

View file

@ -0,0 +1,227 @@
"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.
Покрывает:
1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
- fresh: age <= max_age_days (алерта нет);
- stale: age > max_age_days (алерт);
- граница порога (== max_age_days нет алерта; +1 день алерт).
2. check_sber_freshness с FakeDB (fresh / stale / emptymark_failed / кастомный lag).
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
4. Регистрацию в kit product_handlers (registry остаётся зелёным).
"""
from __future__ import annotations
import os
import re
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.core.config import settings
from app.services.product_handlers import build_product_handlers
from app.tasks import sber_freshness_monitor as mon
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
_MAY_2026 = date(2026, 5, 1)
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
def _now(y: int, m: int, d: int) -> datetime:
return datetime(y, m, d, tzinfo=UTC)
def test_fresh_within_max_age() -> None:
"""age=30 ≤ max_age_days=60 — свежий, алерта нет."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
assert v.stale is False
assert v.age_days == 30
assert v.latest_period == _MAY_2026
def test_stale_beyond_max_age() -> None:
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
assert v.stale is True
assert v.age_days == 72
def test_threshold_boundary_exact_no_alert() -> None:
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
assert v.age_days == 60
assert v.stale is False
def test_threshold_boundary_next_day_alert() -> None:
"""Порог + 1 день (age=61) — первый алерт."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
assert v.age_days == 61
assert v.stale is True
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
class _Row:
def __init__(self, latest: date | None) -> None:
self.latest = latest
class _FakeResult:
def __init__(self, latest: date | None) -> None:
self._latest = latest
def first(self) -> _Row:
return _Row(self._latest)
class _FakeDB:
def __init__(self, latest: date | None) -> None:
self._latest = latest
self.rolled_back = False
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
return _FakeResult(self._latest)
def rollback(self) -> None:
self.rolled_back = True
def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
calls: dict[str, Any] = {"done": None, "failed": None, "heartbeat": 0}
monkeypatch.setattr(
mon.runs_mod,
"update_heartbeat",
lambda *a, **k: calls.__setitem__("heartbeat", calls["heartbeat"] + 1),
)
monkeypatch.setattr(
mon.runs_mod,
"mark_done",
lambda _db, run_id, counters: calls.__setitem__("done", dict(counters)),
)
monkeypatch.setattr(
mon.runs_mod,
"mark_failed",
lambda _db, run_id, err, counters: calls.__setitem__("failed", err),
)
return calls
def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
assert calls["done"] == out
assert calls["failed"] is None
def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# @2026-07-12 (прод): age=72 > 60 → алерт.
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
assert out["alert"] == 1
assert out["age_days"] == 72
assert out["latest_month"] == 5
# Монитор НЕ падает при алерте — прогон done, а не failed.
assert calls["done"] == out
assert calls["failed"] is None
def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _patch_runs(monkeypatch)
db = _FakeDB(None)
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
assert out["alert"] == 0
assert calls["done"] is None
assert calls["failed"] is not None
def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
out = mon.check_sber_freshness(
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
) # type: ignore[arg-type]
assert out["alert"] == 1
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
_patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
assert out_exact["age_days"] == threshold
assert out_exact["alert"] == 0
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
assert out_over["alert"] == 1
# ── Миграция 180 ──────────────────────────────────────────────────────────────
def test_migration_180_exists() -> None:
assert _MIGRATION_180.is_file(), f"missing migration: {_MIGRATION_180}"
def test_migration_180_seeds_source() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "'sber_freshness_monitor'" in sql
def test_migration_180_enabled_true() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "true" in sql
def test_migration_180_is_idempotent() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "ON CONFLICT (source) DO NOTHING" in sql
def test_migration_180_is_transactional() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_180_window_9_to_10_utc() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert re.search(r"\b9\b", sql), "window_start_hour 9 missing"
assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"
def test_migration_180_lag_allowance_25() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "lag_allowance_days" in sql
assert "25" in sql
def test_migration_180_no_psycopg_trap() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert not re.search(r":\w+::", sql)
# ── Регистрация в kit registry ─────────────────────────────────────────────────
def test_kit_product_handler_registered() -> None:
# ctx не нужен для сборки dict ключей — build_product_handlers его не замыкает.
handlers = build_product_handlers(ctx=None) # type: ignore[arg-type]
assert "sber_freshness_monitor" in handlers

View file

@ -28,11 +28,15 @@ from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration import scheduler as kit_sched from scraper_kit.orchestration import scheduler as kit_sched
from scraper_kit.orchestration.pipeline import CITY_ANCHORS
from scraper_kit.orchestration.scheduler import ( from scraper_kit.orchestration.scheduler import (
Handler, Handler,
SchedulerContext, SchedulerContext,
_claim_run, _claim_run,
_dispatch, _dispatch,
_job_avito_city_sweep,
_job_cian_city_sweep,
_job_yandex_city_sweep,
build_registry, build_registry,
reap_zombies, reap_zombies,
reschedule_after_minutes, reschedule_after_minutes,
@ -74,14 +78,20 @@ _PRODUCT_SOURCES: set[str] = {
"proxy_healthcheck", "proxy_healthcheck",
} }
# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно # kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно.
# "*_city_sweep_*" — wildcard-семья oblast B1 rollout (region 66, dormant, #179):
# per-city source (avito_city_sweep_nizhniy_tagil и т.п.) резолвится на тот же
# handler, что EKB source, через resolve_handler-префикс (см. _default_kit_handlers).
_KIT_NATIVE_SOURCES = { _KIT_NATIVE_SOURCES = {
"avito_city_sweep", "avito_city_sweep",
"avito_city_sweep_*",
"avito_full_load", "avito_full_load",
"avito_full_load_exhaustive", "avito_full_load_exhaustive",
"avito_newbuilding_sweep", "avito_newbuilding_sweep",
"yandex_city_sweep", "yandex_city_sweep",
"yandex_city_sweep_*",
"cian_city_sweep", "cian_city_sweep",
"cian_city_sweep_*",
"cian_full_load", "cian_full_load",
"domclick_city_sweep", "domclick_city_sweep",
} }
@ -156,7 +166,10 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None:
def test_kit_native_handler_set() -> None: def test_kit_native_handler_set() -> None:
"""Ровно 8 kit-native sweep-обработчиков зарегистрированы встроенно.""" """Ровно 11 kit-native sweep-обработчиков зарегистрированы встроенно (8 + 3 oblast
"*_city_sweep_*" wildcard, #179).
"""
registry = build_registry() registry = build_registry()
assert set(registry) == _KIT_NATIVE_SOURCES assert set(registry) == _KIT_NATIVE_SOURCES
for src in _KIT_NATIVE_SOURCES: for src in _KIT_NATIVE_SOURCES:
@ -434,3 +447,87 @@ async def test_dispatch_post_claim_hook_runs() -> None:
await asyncio.gather(*list(ctx._inflight_tasks)) await asyncio.gather(*list(ctx._inflight_tasks))
post.assert_called_once() post.assert_called_once()
assert post.call_args[0][1] == 300 # run_id assert post.call_args[0][1] == 300 # run_id
# ── 6. B1 oblast rollout — city param → city anchors, wildcard dispatch ──────────
def _oblast_ctx() -> SchedulerContext:
return SchedulerContext(
config=MagicMock(),
matcher=MagicMock(),
enrichment=MagicMock(),
session_factory=MagicMock(),
runs=MagicMock(),
)
async def test_job_avito_city_sweep_with_city_param_uses_city_anchors() -> None:
"""params={"city": "nizhniy_tagil"} → Н.Тагил anchors переданы в run_avito_city_sweep."""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nizhniy_tagil"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] == CITY_ANCHORS["nizhniy_tagil"]
assert kwargs["anchors"] == [(57.910, 59.980, "Н.Тагил центр")]
async def test_job_cian_city_sweep_with_city_param_uses_city_anchors() -> None:
"""params={"city": "kamensk_uralskiy"} → anchors города переданы в run_cian_city_sweep."""
with patch.object(kit_sched, "run_cian_city_sweep", AsyncMock()) as mock_run:
await _job_cian_city_sweep(MagicMock(), 1, {"city": "kamensk_uralskiy"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] == CITY_ANCHORS["kamensk_uralskiy"]
async def test_job_yandex_city_sweep_with_city_param_uses_city_anchors() -> None:
"""params={"city": "serov"} → anchors города переданы в run_yandex_city_sweep."""
with patch.object(kit_sched, "run_yandex_city_sweep", AsyncMock()) as mock_run:
await _job_yandex_city_sweep(MagicMock(), 1, {"city": "serov"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] == CITY_ANCHORS["serov"]
async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> None:
"""Без city (EKB-schedule, back-compat) — anchors=None передан (run_* сам берёт EKB_ANCHORS)."""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] is None
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None:
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None,
НЕ падает с KeyError.
"""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] is None
def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
"""resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*"."""
registry = build_registry()
assert (
resolve_handler("avito_city_sweep_nizhniy_tagil", registry)
is registry["avito_city_sweep_*"]
)
assert resolve_handler("cian_city_sweep_serov", registry) is registry["cian_city_sweep_*"]
assert (
resolve_handler("yandex_city_sweep_pervouralsk", registry)
is registry["yandex_city_sweep_*"]
)
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]

View file

@ -67,16 +67,13 @@ def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None:
relaxation (впускает novostroyki только если в доме есть вторичка/NULL). relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
Python-side gating источник содержит и флаг настройки, и hardened Python-side gating источник содержит hardened secondary_count/primary_count
secondary_count/primary_count gate (#1774, после code-review). gate (#1774, после code-review).
""" """
src = inspect.getsource(est_mod._fetch_anchor_comps) src = inspect.getsource(est_mod._fetch_anchor_comps)
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C). # (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard" assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
# (b) Tier A теперь gated Python-side (#1774). # (b) Tier A теперь gated Python-side (#1774).
assert (
"estimate_sb_tier_a_allow_primary_if_secondary_present" in src
), "Tier A no longer references the #1774 gating flag — gated relaxation missing"
# Hardened gate: secondary_count ≥ primary_count (заменил has_secondary). # Hardened gate: secondary_count ≥ primary_count (заменил has_secondary).
assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)" assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)"
assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)" assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)"

View file

@ -1,9 +1,21 @@
"""Unit-тесты consumer-PII scrubber для GlitchTip (#396).""" """Unit-тесты consumer-PII scrubber для GlitchTip (#396) + Telegram bot-токен
redaction (#tgsupport review — CRITICAL: токен утекал в GlitchTip двумя
независимыми векторами, воспроизведёнными ревьюером на реальном событии
sentry_sdk: stack-frame locals (`include_local_variables=True`) и httpx-span
`data` (`HttpxIntegration`). Тесты ниже конструируют event-словари ровно в той
форме, в которой их реально отдаёт sentry_sdk (frames[].vars, spans[].data),
чтобы regression на этот конкретный CRITICAL не проскочил молча."""
import os import os
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.observability.sentry_scrub import scrub_pii_event from app.observability.sentry_scrub import (
redact_telegram_bot_token,
scrub_pii_event,
)
def test_redacts_pii_in_request_data() -> None: def test_redacts_pii_in_request_data() -> None:
@ -88,3 +100,162 @@ def test_returns_event_not_none() -> None:
out = scrub_pii_event(event, {}) out = scrub_pii_event(event, {})
assert out is not None assert out is not None
assert out is event assert out is event
# ── Telegram bot-token redaction (#tgsupport review, CRITICAL) ──────────────
_LEAKED_TOKEN_URL = "https://api.telegram.org/bot8663867262:AAExampleSecretPartAbCdEf123/getMe"
def test_redacts_token_in_stack_frame_locals() -> None:
"""Вектор #1 (ревьюер): `include_local_variables=True` кладёт locals
`TelegramClient._request` (`self`, `url`) в traceback frame `vars`."""
event = {
"exception": {
"values": [
{
"type": "NetworkError",
"stacktrace": {
"frames": [
{
"function": "_request",
"vars": {
"url": _LEAKED_TOKEN_URL,
"self": {"_base": _LEAKED_TOKEN_URL.rsplit("/", 1)[0]},
"method": "getMe",
},
}
]
},
}
]
}
}
out = redact_telegram_bot_token(event, {})
assert out is not None
frame_vars = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_vars["url"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_vars["self"]["_base"]
assert frame_vars["url"] == "https://api.telegram.org/bot[REDACTED]/getMe"
# Не PII/секрет — остаётся как есть.
assert frame_vars["method"] == "getMe"
def test_redacts_token_in_httpx_span_data() -> None:
"""Вектор #2 (ревьюер): `HttpxIntegration` кладёт полный request URL в span
`data` независимо от traceback `traces_sample_rate=0.0` спасает сейчас, но
редактор belt-and-suspenders на случай если трейсинг когда-нибудь включат."""
event = {
"spans": [
{
"op": "http.client",
"description": "POST " + _LEAKED_TOKEN_URL,
"data": {"url": _LEAKED_TOKEN_URL, "http.method": "POST"},
}
]
}
out = redact_telegram_bot_token(event, {})
assert out is not None
span = out["spans"][0]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in span["data"]["url"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in span["description"]
assert span["data"]["http.method"] == "POST"
def test_redacts_token_in_breadcrumb_message() -> None:
"""httpx-логгер (INFO, до нашего getLogger('httpx').setLevel(WARNING) в
tgbot_main) может всплыть breadcrumb'ом с полным URL через LoggingIntegration."""
event = {
"breadcrumbs": {
"values": [
{
"category": "httpx",
"message": f'HTTP Request: POST {_LEAKED_TOKEN_URL} "HTTP/1.1 401"',
}
]
}
}
out = redact_telegram_bot_token(event, {})
assert out is not None
msg = out["breadcrumbs"]["values"][0]["message"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in msg
assert "/bot[REDACTED]/getMe" in msg
def test_redact_token_leaves_unrelated_urls_and_strings_untouched() -> None:
event = {"extra": {"public_url": "https://gendsgn.ru/trade-in", "region": "66"}}
out = redact_telegram_bot_token(event, {})
assert out is not None
assert out["extra"]["public_url"] == "https://gendsgn.ru/trade-in"
assert out["extra"]["region"] == "66"
def test_redact_token_handles_non_dict_event() -> None:
assert redact_telegram_bot_token(None, {}) is None # type: ignore[arg-type]
# Голая форма `<id>:<secret>` без префикса `/bot` — так токен выглядит в локали
# `token` конструктора TelegramClient. Штатно в event не попадает
# (include_local_variables=False в tgbot_main), но редактор обязан покрывать и
# этот случай: иначе рубеж против утечки токена всего один — флаг SDK.
_LEAKED_TOKEN_BARE = "8663867262:AAFakeSecretPartForTestsOnly1234567"
def test_redacts_bare_token_without_bot_prefix() -> None:
event = {
"logentry": {"message": f"init failed for {_LEAKED_TOKEN_BARE}"},
"exception": {
"values": [{"stacktrace": {"frames": [{"vars": {"token": _LEAKED_TOKEN_BARE}}]}}]
},
}
out = redact_telegram_bot_token(event, {})
assert out is not None
assert "AAFakeSecretPartForTestsOnly1234567" not in repr(out)
frame = out["exception"]["values"][0]["stacktrace"]["frames"][0]
assert frame["vars"]["token"] == "[REDACTED]"
@pytest.mark.parametrize(
"benign",
[
"chat_id:12345",
"ratio 3:4",
"2026-07-16T10:00:00",
"postgresql://user:pass@postgres:5432/tradein",
],
)
def test_bare_token_redaction_leaves_benign_colon_strings_untouched(benign: str) -> None:
"""Голая регулярка не должна бить по любым `x:y` — иначе диагностика ослепнет."""
out = redact_telegram_bot_token({"logentry": {"message": benign}}, {})
assert out is not None
assert out["logentry"]["message"] == benign
def test_composed_before_send_scrubs_pii_and_token_together() -> None:
"""Композиция, реально используемая в `app.tgbot_main._before_send`: PII-scrub
(ключ-based) И token-redaction (regex full-text) применяются оба, не заменяя
друг друга разные классы секретов, разные механизмы обнаружения."""
event = {
"request": {"data": {"client_phone": "+79991234567"}},
"exception": {
"values": [
{
"stacktrace": {
"frames": [{"vars": {"url": _LEAKED_TOKEN_URL}}],
}
}
]
},
}
def composed_before_send(evt, hint):
scrubbed = scrub_pii_event(evt, hint)
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint)
out = composed_before_send(event, {})
assert out is not None
assert out["request"]["data"]["client_phone"] == "[REDACTED]"
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url

View file

@ -0,0 +1,104 @@
"""Per-city SERP location URL builders (#12 fix EKB-leak в oblast city-sweep).
Проблема: avito/cian/yandex провайдеры хардкодили ЕКБ-локацию (путь /ekaterinburg/,
CIAN_EKB_REGION_ID, _EKB_RGID) мимо anchor'а oblast-города → city-sweep целевого
города (напр. Н.Тагил) фактически скрейпил ЕКБ, n_analogs=0. Фикс новые
kw-параметры (avito: target_city_slug уже был #2487, теперь и в ПУТИ URL;
cian: city_region_id; yandex: city_rgid), все дефолт None ЕКБ-поведение
byte-identical прежнему (back-compat critical).
No network / no DB чистые URL-билдеры на stub-конфиге.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.avito.serp import NOVOSTROYKA_SLUG, AvitoScraper
from scraper_kit.providers.cian.serp import CIAN_EKB_REGION_ID, CianScraper
from scraper_kit.providers.yandex.serp import _EKB_RGID, YandexRealtyScraper
_STUB_CFG = SimpleNamespace()
def _query(url: str) -> dict[str, list[str]]:
return parse_qs(urlparse(url).query)
# ── Avito: city-slug в ПУТЬ URL (не только SERP-фильтр) ────────────────────────
def test_avito_web_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
url = s._build_web_url(57.910, 59.980, radius_km=2, page=1)
assert "/nizhniy_tagil/" in url
assert "/ekaterinburg/" not in url
def test_avito_citywide_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="kamensk-uralskiy") # type: ignore[arg-type]
url = s._build_citywide_url(page=2)
assert "/kamensk-uralskiy/" in url
assert "/ekaterinburg/" not in url
def test_avito_newbuilding_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="verhnyaya_pyshma") # type: ignore[arg-type]
url = s._build_newbuilding_url(page=1)
assert "/verhnyaya_pyshma/" in url
assert "/ekaterinburg/" not in url
assert NOVOSTROYKA_SLUG in url
def test_avito_rooms_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="serov") # type: ignore[arg-type]
url = s._build_rooms_url("2k", page=1)
assert "/serov/" in url
assert "/ekaterinburg/" not in url
def test_avito_urls_default_to_ekaterinburg_when_no_target_city_slug() -> None:
"""Back-compat: target_city_slug=None → путь /ekaterinburg/ (ЕКБ full_load/pipeline)."""
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
assert "/ekaterinburg/" in s._build_web_url(56.84, 60.60, radius_km=1, page=1)
assert "/ekaterinburg/" in s._build_citywide_url(page=1)
assert "/ekaterinburg/" in s._build_newbuilding_url(page=1)
assert "/ekaterinburg/" in s._build_rooms_url("2k", page=1)
# ── Cian: city_region_id → region= (subdomain НЕ трогается) ────────────────────
def test_cian_build_url_uses_city_region_id() -> None:
s = CianScraper(_STUB_CFG, city_region_id=4886) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["region"] == ["4886"]
assert urlparse(url).hostname == "ekb.cian.ru"
def test_cian_build_url_defaults_to_ekb_region_when_no_city_region_id() -> None:
"""Back-compat: city_region_id=None → region=CIAN_EKB_REGION_ID (4743)."""
s = CianScraper(_STUB_CFG, city_region_id=None) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["region"] == [str(CIAN_EKB_REGION_ID)]
assert urlparse(url).hostname == "ekb.cian.ru"
# ── Yandex: city_rgid → rgid= ───────────────────────────────────────────────────
def test_yandex_build_url_uses_city_rgid() -> None:
s = YandexRealtyScraper(_STUB_CFG, city_rgid=328286) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["rgid"] == ["328286"]
def test_yandex_build_url_defaults_to_ekb_rgid_when_no_city_rgid() -> None:
"""Back-compat: city_rgid=None → rgid=_EKB_RGID (559132)."""
s = YandexRealtyScraper(_STUB_CFG, city_rgid=None) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["rgid"] == [str(_EKB_RGID)]

View file

@ -105,12 +105,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
_row(source="cian", source_id="c2", listing_segment="novostroyki"), _row(source="cian", source_id="c2", listing_segment="novostroyki"),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with ( with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
comps, tier = _fetch(db) comps, tier = _fetch(db)
assert tier == "A" assert tier == "A"
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
@ -135,12 +130,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
_row(source="cian", source_id="c5", listing_segment="novostroyki"), _row(source="cian", source_id="c5", listing_segment="novostroyki"),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with ( with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
comps, tier = _fetch(db) comps, tier = _fetch(db)
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip. # primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
assert tier is None assert tier is None
@ -162,42 +152,13 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
_row(source="cian", source_id="c4", listing_segment="novostroyki"), _row(source="cian", source_id="c4", listing_segment="novostroyki"),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with ( with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
comps, tier = _fetch(db) comps, tier = _fetch(db)
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D). # Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
assert tier is None assert tier is None
assert comps == [] assert comps == []
def test_tier_a_flag_off_excludes_novostroyki_even_with_secondary() -> None:
"""Flag OFF → оригинальное поведение #1186: novostroyki ВСЕГДА исключены в Tier A,
даже если в доме есть вторичка. Остаются только 4 vtorichka/NULL."""
rows = [
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
_row(source="avito", source_id="a2", listing_segment=None),
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
_row(source="yandex", source_id="y2", listing_segment=None),
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
]
db = _db_mock(rows)
with (
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", False
),
):
comps, tier = _fetch(db)
assert tier == "A"
# novostroyki отброшены → только 4 вторички.
assert len(comps) == 4
def test_tier_a_dedup_same_source_id_collapses() -> None: def test_tier_a_dedup_same_source_id_collapses() -> None:
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки — """#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
@ -216,12 +177,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None), _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with ( with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
comps, tier = _fetch(db) comps, tier = _fetch(db)
assert tier == "A" assert tier == "A"
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
@ -257,12 +213,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
), ),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with ( with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
comps, tier = _fetch(db) comps, tier = _fetch(db)
assert tier == "A" assert tier == "A"
# source_id-primary схлопывает несмотря на разные url → 5 comps. # source_id-primary схлопывает несмотря на разные url → 5 comps.
@ -281,12 +232,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0), _row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with ( with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
comps, tier = _fetch(db) comps, tier = _fetch(db)
assert tier == "A" assert tier == "A"
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.

View file

@ -4,6 +4,8 @@
- happy path: consent=True + валидный телефон -> 200, INSERT + commit - happy path: consent=True + валидный телефон -> 200, INSERT + commit
- consent=False -> 422 (Literal[True] guard) - consent=False -> 422 (Literal[True] guard)
- невалидный формат телефона -> 422 - невалидный формат телефона -> 422
- телефон без цифр / слишком мало цифр -> 422 (digit-guard, #2376 hardening)
- source="landing" (мёртвая воронка) -> 422 (литерал убран из схемы)
- estimate_id, которого нет в trade_in_estimates -> 404 - estimate_id, которого нет в trade_in_estimates -> 404
""" """
@ -53,12 +55,15 @@ def _insert_result(lead_id: str) -> MagicMock:
def test_lead_happy_path(client: TestClient, db: MagicMock) -> None: def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
from app.api.v1 import lead as lead_module
lead_id = str(uuid4()) lead_id = str(uuid4())
db.execute.return_value = _insert_result(lead_id) db.execute.return_value = _insert_result(lead_id)
r = client.post( r = client.post(
"/api/v1/trade-in/lead", "/api/v1/trade-in/lead",
json={"phone": "+7 (912) 345-67-89", "consent": True}, json={"phone": "+7 (912) 345-67-89", "consent": True},
headers={"x-forwarded-for": "203.0.113.7, 10.0.0.1"},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
body = r.json() body = r.json()
@ -71,6 +76,27 @@ def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
assert params["consent"] is True assert params["consent"] is True
assert params["source"] == "result" assert params["source"] == "result"
assert params["estimate_id"] is None assert params["estimate_id"] is None
# 152-ФЗ proof-of-consent (migration 182): client_ip / policy version / text
# snapshot must reach the INSERT params, not just the audit log (#2497 TODO).
assert params["client_ip"] == "203.0.113.7" # first hop of X-Forwarded-For
assert params["consent_policy_version"] == lead_module._CONSENT_POLICY_VERSION
assert params["consent_text_snapshot"] == lead_module._CONSENT_TEXT_SNAPSHOT
def test_lead_client_ip_falls_back_to_peer_when_no_xff(client: TestClient, db: MagicMock) -> None:
# No X-Forwarded-For header -> falls back to request.client.host (TestClient
# reports "testclient"), still persisted rather than silently None.
lead_id = str(uuid4())
db.execute.return_value = _insert_result(lead_id)
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True},
)
assert r.status_code == 200, r.text
params = db.execute.call_args.args[1]
assert params["client_ip"] == "testclient"
def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None: def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None:
@ -121,10 +147,41 @@ def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> N
"phone": "+79123456789", "phone": "+79123456789",
"consent": True, "consent": True,
"estimate_id": estimate_id, "estimate_id": estimate_id,
"source": "landing",
}, },
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
params = db.execute.call_args.args[1] params = db.execute.call_args.args[1]
assert params["estimate_id"] == estimate_id assert params["estimate_id"] == estimate_id
assert params["source"] == "landing" assert params["source"] == "result"
def test_lead_digit_free_phone_422(client: TestClient, db: MagicMock) -> None:
# "(()) -- .." проходит regex-маску (только +/скобки/дефисы/точки/пробелы),
# но содержит 0 цифр -> должно отклоняться digit-валидатором.
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "(()) -- ..", "consent": True},
)
assert r.status_code == 422, r.text
assert not db.execute.called
def test_lead_too_few_digits_phone_422(client: TestClient, db: MagicMock) -> None:
# 6 цифр < минимума 10 -> 422 (маску проходит по длине, digit-guard режет).
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "123-456", "consent": True},
)
assert r.status_code == 422, r.text
assert not db.execute.called
def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) -> None:
# "landing"-воронка недостижима (rbac_guard закрывает /lead) — литерал убран
# из схемы, поэтому явный source="landing" теперь 422.
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True, "source": "landing"},
)
assert r.status_code == 422, r.text
assert not db.execute.called

View file

@ -0,0 +1,292 @@
"""Tests for app.services.user_events — record_event / schedule_event / should_log_login.
Coverage:
(a) record_event inserts exactly one row via a mocked SessionLocal, commits, and the
SQL uses CAST(:x AS type) (never the psycopg-v3-broken `:x::type` form).
(b) record_event swallows exceptions a DB failure (connect or execute) never raises.
(c) schedule_event in a sync context (no running event loop) calls record_event inline.
(d) schedule_event in an async context schedules a non-blocking background task that
still calls through, and a failure inside it is logged, not raised.
(e) should_log_login: True once per (user, ip, ua, day), False on repeat; independent
per user / ip / user_agent; prunes the in-process set past capacity.
(f) optional real-DB round trip against app.core.db.SessionLocal (the exact session
record_event uses) self-skips without a reachable, non-placeholder Postgres,
mirroring tests/test_house_dedup_merge.py's live-DB pattern.
"""
from __future__ import annotations
import asyncio
import os
import re
from typing import Any
from unittest.mock import patch
# 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 app.services import user_events as ue
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeResult:
def fetchone(self) -> None:
return None
class _FakeSession:
"""SessionLocal() stand-in supporting the `with SessionLocal() as db:` pattern."""
def __init__(self) -> None:
self.executed: list[tuple[str, dict[str, Any] | None]] = []
self.committed = False
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult:
self.executed.append((str(stmt), params))
return _FakeResult()
def commit(self) -> None:
self.committed = True
def __enter__(self) -> _FakeSession:
return self
def __exit__(self, *exc: object) -> bool:
return False
# ---------------------------------------------------------------------------
# (a) record_event — successful insert
# ---------------------------------------------------------------------------
def test_record_event_inserts_and_commits() -> None:
fake = _FakeSession()
with patch.object(ue, "SessionLocal", return_value=fake):
ue.record_event(
event_type="estimate_request",
username="user1",
ip="1.2.3.4",
user_agent="pytest-agent",
path="/api/v1/trade-in/estimate",
method="POST",
estimate_id="11111111-1111-1111-1111-111111111111",
payload={"address": "ул. Ленина, 1", "area_m2": 50.0, "rooms": 2},
)
assert fake.committed is True
assert len(fake.executed) == 1
sql, params = fake.executed[0]
assert params is not None
assert "INSERT INTO user_events" in sql
assert params["event_type"] == "estimate_request"
assert params["username"] == "user1"
assert params["ip"] == "1.2.3.4"
assert params["estimate_id"] == "11111111-1111-1111-1111-111111111111"
# payload сериализован в JSON-строку (CAST(:payload AS jsonb) на стороне SQL).
assert isinstance(params["payload"], str)
assert "Ленина" in params["payload"]
def test_record_event_cast_not_doublecolon() -> None:
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
fake = _FakeSession()
with patch.object(ue, "SessionLocal", return_value=fake):
ue.record_event(event_type="login", username="user1")
sql, _ = fake.executed[0]
assert not re.search(r":\w+::\w", sql)
assert "CAST(:ip AS inet)" in sql
assert "CAST(:estimate_id AS uuid)" in sql
assert "CAST(:payload AS jsonb)" in sql
def test_record_event_default_payload_is_empty_dict() -> None:
fake = _FakeSession()
with patch.object(ue, "SessionLocal", return_value=fake):
ue.record_event(event_type="login", username="user2")
_, params = fake.executed[0]
assert params is not None
assert params["payload"] == "{}"
assert params["ip"] is None
assert params["estimate_id"] is None
# ---------------------------------------------------------------------------
# (b) record_event never raises
# ---------------------------------------------------------------------------
def test_record_event_swallows_session_open_errors() -> None:
class _BrokenSession:
def __enter__(self) -> _BrokenSession:
raise RuntimeError("db unreachable")
def __exit__(self, *exc: object) -> bool:
return False
with patch.object(ue, "SessionLocal", return_value=_BrokenSession()):
# Should NOT raise.
ue.record_event(event_type="login", username="user1")
def test_record_event_swallows_execute_errors() -> None:
class _ExplodingSession(_FakeSession):
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult:
raise RuntimeError("syntax error at or near ':'")
with patch.object(ue, "SessionLocal", return_value=_ExplodingSession()):
ue.record_event(event_type="login", username="user1")
# ---------------------------------------------------------------------------
# (c)/(d) schedule_event
# ---------------------------------------------------------------------------
def test_schedule_event_sync_context_calls_record_event_inline() -> None:
"""No running loop (plain sync call, e.g. from a script/test) → inline call."""
with patch.object(ue, "record_event") as mock_record:
ue.schedule_event(event_type="login", username="user1", ip="9.9.9.9")
mock_record.assert_called_once_with(event_type="login", username="user1", ip="9.9.9.9")
async def test_schedule_event_async_context_is_non_blocking_and_calls_through() -> None:
"""Running loop present → backgrounded via asyncio.to_thread; does not block the
calling coroutine and eventually calls record_event."""
with patch.object(ue, "record_event") as mock_record:
ue.schedule_event(event_type="api_request", username="user1", path="/api/v1/x")
# schedule_event must return immediately without awaiting the background task.
# Give the scheduled task a chance to run before asserting.
await asyncio.sleep(0.05)
mock_record.assert_called_once_with(
event_type="api_request", username="user1", path="/api/v1/x"
)
async def test_schedule_event_background_failure_is_logged_not_raised() -> None:
"""A record_event failure inside the background task must not propagate/crash the
caller the done-callback swallows it (logs a warning)."""
with patch.object(ue, "record_event", side_effect=RuntimeError("boom")):
# Must not raise synchronously (the task runs in the background).
ue.schedule_event(event_type="login", username="user1")
await asyncio.sleep(0.05)
# Reaching here without an unhandled-exception bubbling up confirms the
# done-callback caught it.
# ---------------------------------------------------------------------------
# (e) should_log_login dedup
# ---------------------------------------------------------------------------
def test_should_log_login_true_once_then_false_same_day() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is False
def test_should_log_login_independent_per_ip() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert ue.should_log_login("alice", "2.2.2.2", "chrome") is True
def test_should_log_login_independent_per_user() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert ue.should_log_login("bob", "1.1.1.1", "chrome") is True
def test_should_log_login_independent_per_user_agent() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert ue.should_log_login("alice", "1.1.1.1", "firefox") is True
def test_should_log_login_none_ip_and_ua_are_valid_keys() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", None, None) is True
assert ue.should_log_login("alice", None, None) is False
def test_should_log_login_prunes_when_over_capacity() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
for i in range(ue._LOGIN_DEDUP_MAX):
ue._LOGIN_DEDUP_SEEN.add(f"filler-{i}")
assert len(ue._LOGIN_DEDUP_SEEN) == ue._LOGIN_DEDUP_MAX
# Next call must prune (clear the whole set) before adding, not grow unbounded.
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert len(ue._LOGIN_DEDUP_SEEN) == 1
ue._LOGIN_DEDUP_SEEN.clear()
# ---------------------------------------------------------------------------
# (f) optional real-DB round trip (self-skips without a reachable test DB)
# ---------------------------------------------------------------------------
def _db_reachable() -> bool:
"""True if app.core.db's engine (the one record_event actually uses) points at a
real, non-placeholder Postgres and is reachable mirrors the live-DB skip pattern
in tests/test_house_dedup_merge.py."""
dsn = os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return False
try:
from sqlalchemy import text as _t
from app.core.db import engine
with engine.connect() as conn:
conn.execute(_t("SELECT 1"))
return True
except Exception:
return False
@pytest.mark.skipif(not _db_reachable(), reason="no reachable Postgres test DB")
def test_real_record_event_inserts_row() -> None:
"""End-to-end on the real DB record_event() is bound to (app.core.db.SessionLocal):
persists exactly the given fields and is queryable back."""
from sqlalchemy import text as _t
from app.core.db import SessionLocal
ue.record_event(
event_type="pytest_probe",
username="pytest_user_events_test",
ip="203.0.113.7",
user_agent="pytest-real-db",
path="/api/v1/trade-in/estimate",
method="POST",
payload={"probe": True},
)
with SessionLocal() as db:
try:
row = db.execute(
_t(
"SELECT event_type, username, ip_address, user_agent, payload "
"FROM user_events WHERE username = :u ORDER BY id DESC LIMIT 1"
),
{"u": "pytest_user_events_test"},
).fetchone()
assert row is not None
assert row.event_type == "pytest_probe"
assert str(row.ip_address) == "203.0.113.7"
assert row.payload == {"probe": True}
finally:
db.execute(
_t("DELETE FROM user_events WHERE username = :u"),
{"u": "pytest_user_events_test"},
)
db.commit()

View file

@ -27,7 +27,8 @@ docker exec "$DST_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -c "
-- запуск упал между CREATE и финальным DROP под старым кодом (без source_id). -- запуск упал между CREATE и финальным DROP под старым кодом (без source_id).
DROP TABLE IF EXISTS deals_ros_staging; DROP TABLE IF EXISTS deals_ros_staging;
CREATE TABLE deals_ros_staging ( CREATE TABLE deals_ros_staging (
dedup_hash text PRIMARY KEY, source_id text, address text, rooms int, area_m2 numeric, dedup_hash text PRIMARY KEY, source_id text, address text, region_code int, city text,
rooms int, area_m2 numeric,
floor int, year_built int, price_rub bigint, price_per_m2 int, deal_date date floor int, year_built int, price_rub bigint, price_per_m2 int, deal_date date
); );
" "
@ -38,7 +39,9 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
SELECT SELECT
'ros:dkp:' || id::text AS dedup_hash, 'ros:dkp:' || id::text AS dedup_hash,
id::text AS source_id, id::text AS source_id,
'Екатеринбург, ' || trim(street) AS address, trim(city) || ', ' || trim(street) AS address,
region_code AS region_code,
trim(city) AS city,
CASE CASE
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1 WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3 WHEN area < 62 THEN 2 WHEN area < 85 THEN 3
@ -51,7 +54,7 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
period_start_date AS deal_date period_start_date AS deal_date
FROM rosreestr_deals FROM rosreestr_deals
WHERE region_code = 66 WHERE region_code = 66
AND city ILIKE '%катеринбург%' AND city IS NOT NULL AND trim(city) <> ''
AND realestate_type_code = '002001003000' AND realestate_type_code = '002001003000'
AND area BETWEEN 18 AND 200 AND area BETWEEN 18 AND 200
AND deal_price BETWEEN 1000000 AND 100000000 AND deal_price BETWEEN 1000000 AND 100000000
@ -71,10 +74,10 @@ docker exec "$DST_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -c "
DELETE FROM deals WHERE address = 'Екатеринбург, реальная сделка'; DELETE FROM deals WHERE address = 'Екатеринбург, реальная сделка';
INSERT INTO deals ( INSERT INTO deals (
source, dedup_hash, source_id, address, rooms, area_m2, floor, source, dedup_hash, source_id, address, region_code, city, rooms, area_m2, floor,
year_built, price_rub, price_per_m2, deal_date year_built, price_rub, price_per_m2, deal_date
) )
SELECT 'rosreestr', dedup_hash, source_id, address, rooms, area_m2, floor, SELECT 'rosreestr', dedup_hash, source_id, address, region_code, city, rooms, area_m2, floor,
year_built, price_rub, price_per_m2, deal_date year_built, price_rub, price_per_m2, deal_date
FROM deals_ros_staging FROM deals_ros_staging
ON CONFLICT (dedup_hash) DO NOTHING; ON CONFLICT (dedup_hash) DO NOTHING;

View file

@ -201,6 +201,49 @@ services:
networks: networks:
- tradein-net - tradein-net
# Telegram support-bot bridge: long-polling worker (asyncio/httpx), тот же
# backend-образ что и scraper/backend (один Dockerfile, другая команда).
# Ничего не слушает (long-polling исходящий к Telegram API) → без expose/ports.
# НЕ подписан на gendesign_shared — не проксируется Caddy, только tradein-net
# (нужен доступ к postgres). TELEGRAM_* переменные — ТОЛЬКО из backend/.env.runtime
# (env_file ниже); пусто/нет токена = бот молча не стартует (см. tgbot_main.py).
# ⚠️ НЕ дублировать TELEGRAM_* в блоке environment: (см. предупреждение у backend
# выше про environment: перекрывающий env_file при пустой host-env).
tgbot:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-tgbot
# mem: нет прод-замера (новый сервис, 2026-07-16) — консервативный старт по
# аналогии с профилем scraper (лёгкая asyncio-оркестрация, без headless-браузера
# и без больших payload'ов). Пересмотреть после первого docker stats на проде.
mem_limit: 256m
memswap_limit: 256m
logging: *default-logging
command: ["python", "-m", "app.tgbot_main"]
env_file:
- path: ./backend/.env.runtime
required: false
environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
ENVIRONMENT: "production"
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
# Этот процесс — не scheduler_main; false на всякий случай, если общий
# Settings()-объект образа где-то читает флаг при импорте (defense-in-depth,
# аналогично backend). tgbot_main.py не должен зависеть от этого значения.
SCHEDULER_ENABLE: "false"
depends_on:
postgres:
condition: service_healthy
# unless-stopped (как и весь остальной стек): только always/unless-stopped
# гарантируют автозапуск после ребута VPS. Kill-switch при пустом
# TELEGRAM_BOT_TOKEN реализован idle-блокировкой в tgbot_main.py, а НЕ exit(0) —
# иначе эта политика дала бы рестарт-луп (перезапускает независимо от кода).
restart: unless-stopped
# >_DRAIN_TIMEOUT_S (100s) в tgbot_main.py, иначе docker убьёт по дефолтным 10с
# раньше, чем докрутится long-poll (до 30с), и кооперативный drain не сработает.
stop_grace_period: 120s
networks:
- tradein-net
frontend: frontend:
image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest} image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest}
container_name: tradein-frontend container_name: tradein-frontend

View file

@ -0,0 +1,252 @@
"use client";
/**
* Активность (Feature 3) поведенческая аналитика по user_events: сколько
* событий, кто активен, что чаще ищут/дёргают. Названо «Активность», не
* «Аналитика» этот label уже занят market-analytics overlay (АНАЛИТИКА ДОМА,
* v2/AnalyticsView.tsx) внутри самой оценки, здесь другой домен (юзер-активность).
*
* Admin-only тот же паттерн gating, что и /admin/audit (см. комментарий там).
*/
import { useMemo, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import "@/components/trade-in/trade-in.css";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { Topbar } from "@/components/trade-in/Topbar";
import { HTTPError } from "@/lib/api";
import { useUserAnalytics } from "@/lib/admin-audit-api";
const DAYS_OPTIONS = [7, 30, 90] as const;
type DaysOption = (typeof DAYS_OPTIONS)[number];
function formatDay(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
} catch {
return iso;
}
}
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function KpiCard({ label, value }: { label: string; value: number }) {
return (
<div className="admin-kpi-card">
<div className="admin-kpi-label">{label}</div>
<div className="admin-kpi-value">{value.toLocaleString("ru-RU")}</div>
</div>
);
}
interface DailyChartPoint {
day: string;
events: number;
users: number;
label: string;
}
export default function AdminAnalyticsPage() {
const [days, setDays] = useState<DaysOption>(30);
const analyticsQ = useUserAnalytics(days);
const chartData = useMemo<DailyChartPoint[]>(
() => (analyticsQ.data?.daily ?? []).map((p) => ({ ...p, label: formatDay(p.day) })),
[analyticsQ.data],
);
if (analyticsQ.error instanceof HTTPError && analyticsQ.error.status === 403) {
return <NoAccessScreen variant="user" />;
}
return (
<>
<Topbar active="analytics" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Активность</h1>
<p className="scraper-subtitle">
Поведенческая аналитика по всем аккаунтам: события, активные
пользователи, частые поиски и API-пути.
</p>
<div className="admin-days-filter">
{DAYS_OPTIONS.map((d) => (
<button
key={d}
type="button"
className={days === d ? "is-active" : undefined}
onClick={() => setDays(d)}
>
{d} дн.
</button>
))}
</div>
{analyticsQ.isPending && <p className="scraper-hint">Загрузка</p>}
{analyticsQ.isError && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {analyticsQ.error.message}
</p>
)}
{analyticsQ.data && (
<>
<div className="admin-kpi-grid">
<KpiCard label="Всего событий" value={analyticsQ.data.summary.total_events} />
<KpiCard
label="Уникальных пользователей"
value={analyticsQ.data.summary.distinct_users}
/>
<KpiCard label="События за 24ч" value={analyticsQ.data.summary.events_last_24h} />
<KpiCard
label="Активны за 24ч"
value={analyticsQ.data.summary.active_users_last_24h}
/>
</div>
<section className="scraper-section">
<h2>Динамика по дням</h2>
<p className="scraper-hint">События и уникальные пользователи в день.</p>
{chartData.length < 2 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Недостаточно данных для графика нужно минимум 2 дня наблюдений.
</p>
) : (
<div aria-hidden="true" style={{ width: "100%", height: 260 }}>
<ResponsiveContainer>
<LineChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid stroke="var(--border-card, #e6e8ec)" strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
<Tooltip labelFormatter={(label: string) => label} />
<Line
type="monotone"
dataKey="events"
stroke="var(--accent, #1d4ed8)"
strokeWidth={2}
dot={{ r: 2 }}
name="События"
/>
<Line
type="monotone"
dataKey="users"
stroke="var(--success, #0a7a3a)"
strokeWidth={2}
dot={{ r: 2 }}
name="Пользователи"
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</section>
<div className="admin-two-col">
<section className="scraper-section">
<h2>Топ поисковых адресов</h2>
{analyticsQ.data.top_searches.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Пока нет данных.
</p>
) : (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Адрес</th>
<th scope="col">Поисков</th>
</tr>
</thead>
<tbody>
{analyticsQ.data.top_searches.map((row, i) => (
<tr key={i}>
<td>{row.address ?? "—"}</td>
<td>{row.n}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="scraper-section">
<h2>Топ API-путей</h2>
{analyticsQ.data.top_paths.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Пока нет данных.
</p>
) : (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Путь</th>
<th scope="col">Запросов</th>
</tr>
</thead>
<tbody>
{analyticsQ.data.top_paths.map((row, i) => (
<tr key={i}>
<td>{row.path ?? "—"}</td>
<td>{row.n}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
<section className="scraper-section">
<h2>Активность по аккаунтам</h2>
{analyticsQ.data.by_account.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Пока нет данных.
</p>
) : (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Аккаунт</th>
<th scope="col">События</th>
<th scope="col">Поиски</th>
<th scope="col">Последняя активность</th>
</tr>
</thead>
<tbody>
{analyticsQ.data.by_account.map((row) => (
<tr key={row.username}>
<td>{row.username}</td>
<td>{row.events}</td>
<td>{row.searches}</td>
<td className="run-muted">{formatDateTime(row.last_seen)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)}
</main>
</>
);
}

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