Compare commits

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

120 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
bot-backend
f4c26d474a test(rbac): user2 expected role expired after brusnika trial cutoff
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
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 1m8s
CI / openapi-codegen-check (pull_request) Successful in 2m33s
CI / backend-tests (pull_request) Successful in 15m22s
Follow-up к смене user2 pilot→expired в roles.yaml. test_get_role_known_users
гонял user1..user10 == pilot циклом; user2 теперь expired. На PR #2472 этот
тест был skipped (paths-filter не матчил tradein-mvp/**, менялся только
auth/roles.yaml) → упал уже на полном деплое. Правка в обоих зеркалах
(tradein + main backend).
2026-07-09 19:16:59 +03:00
3fe1216317 Merge pull request 'fix(tradein/auth): expire brusnika (user2) trial access' (#2472) from fix/expire-brusnika-trial-access into main 2026-07-09 16:07:54 +00:00
bot-backend
3c4a33ca32 fix(tradein/auth): expire brusnika (user2) trial access
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-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 / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Аналогично praktika (#1977): user2 pilot→expired в roles.yaml. RouteGuard
уже short-circuit'ит на role=expired → NoAccessScreen variant="trial"
(«Пробный доступ закончился», контакт @ArtemKopylov87). Логин остаётся в
caddy/users.caddy.snippet, чтобы дойти до фронта и увидеть экран. Инфра
expired-роли + trial-экран уже в проде — здесь только смена роли.
2026-07-09 19:06:05 +03:00
f987e819b2 fix(honesty): true competitors/neighbors totals + permits truncation disclosure (#2464 cluster B) (#2471)
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m6s
Deploy / build-worker (push) Successful in 3m13s
Deploy / deploy (push) Successful in 1m38s
2026-07-08 08:31:02 +00:00
9a6b56016b fix(analytics): de-inflate domrf_kn snapshot counts (8.49×) + backfill UPSERT columns (#2464) (#2470)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m1s
Deploy / build-worker (push) Successful in 3m22s
Deploy / deploy (push) Successful in 1m40s
2026-07-08 07:30:12 +00:00
d3f3370bed fix(site_finder): SAVEPOINT supply/market + convert 2 bare rollbacks (#2464 Wave 2) (#2469)
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m30s
Deploy / build-worker (push) Successful in 3m45s
Deploy / deploy (push) Successful in 1m54s
2026-07-08 06:46:09 +00:00
01360e9c3d fix(site_finder): SAVEPOINT-isolate gate-lookup db.execute swallows (#2464 Wave 2) (#2468)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-07-08 06:46:03 +00:00
22f3c44dc2 fix(site_finder): SAVEPOINT-isolate quarter-dump lookup db.execute (#2464 Wave 2) (#2467)
All checks were successful
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m42s
Deploy / build-worker (push) Successful in 3m53s
Deploy / deploy (push) Successful in 1m54s
2026-07-08 06:30:32 +00:00
82fdabccba fix(forecasting): SAVEPOINT-isolate §22 db.execute failures + close RELEASE-trap (#2464) (#2466)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m35s
Deploy / build-worker (push) Successful in 3m1s
Deploy / deploy (push) Successful in 1m34s
2026-07-08 05:41:35 +00:00
39dd63333e fix(site_finder): SAVEPOINT-isolate DB errors across cluster A (#2464) (#2465)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 3m29s
Deploy / build-worker (push) Successful in 4m48s
Deploy / deploy (push) Successful in 1m43s
2026-07-07 12:50:11 +00:00
1fa99812af fix(concept): §7 massing-сцена осознаёт весь MultiPolygon (соседи уезжали до 1.6км) (#2180) (#2463)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m23s
2026-07-07 08:10:27 +00:00
53b160ff0a fix(scrapers): rate-limit/backoff/circuit-breaker для ОКН EGRKN-скрапера (#2445-D3) (#2461)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m13s
Deploy / deploy (push) Successful in 1m41s
Deploy / build-worker (push) Successful in 3m19s
2026-07-05 20:09:18 +00:00
464519c69b fix(workers): izyatie per-doc isolation + newbuilding-crossload breadcrumb (#2445-D5/D6) (#2460)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-07-05 20:09:12 +00:00
7315d04396 fix(scrapers): WAF-guard эндпоинтов + throttled catalog BrowserSession (#2445-D1/D2) (#2459)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-07-05 20:09:06 +00:00
d1fa450f48 fix(backend): reservation_ingest пейсинг + короткоживущая DB-сессия (#2445-D4) (#2458)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-07-05 20:09:00 +00:00
be4b2ee1fa fix(export): недостающая §13.7 «Уверенность» в PDF-экспортёре отчёта (#2445-C2) (#2456)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m6s
Deploy / build-worker (push) Successful in 4m33s
Deploy / deploy (push) Successful in 2m25s
2026-07-05 19:40:38 +00:00
f3988add0a fix(generative): устаревший VAT/office caveat в concept PDF (#2445-C1) (#2455)
Some checks failed
Deploy / changes (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
2026-07-05 19:40:30 +00:00
c0c666cccc fix(site-finder): remount analysis-дерева при смене cad (#2445-B4) (#2454)
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m31s
Deploy / deploy (push) Successful in 1m32s
2026-07-05 19:20:36 +00:00
de5da6ec35 fix(frontend): нейтральный fallback для gate/velocity enum-lookup (#2445-B2/B3) (#2451)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-07-05 19:20:28 +00:00
1bbd4c99e2 fix(generative): отклонять спроецированные координаты до pyproj CRSError → 422 (#2445-B1) (#2453)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-07-05 19:20:19 +00:00
82e0b8b0a3 fix(site-finder): true-counts вместо молча-усечённых len(list) в connection-points/utility (#2445-A1/A2) (#2450)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m9s
Deploy / build-frontend (push) Successful in 3m27s
Deploy / build-worker (push) Successful in 4m19s
Deploy / deploy (push) Successful in 1m55s
2026-07-05 18:54:49 +00:00
b40995780b fix(site-finder): раскрыть proxy-fraction в EKB-median velocity-бенчмарке (#2445-A4) (#2448)
Some checks failed
Deploy / changes (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
2026-07-05 18:54:42 +00:00
c8c96a87ee fix(site-finder): competitors avg-price по per-object снапшоту, не глобальному (#2445-A5) (#2447)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-07-05 18:54:36 +00:00
9b6aba42dc fix(site-finder): L2 hidden-supply не обнуляет неизвестный flat_count (#2445-A3) (#2446)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
2026-07-05 18:54:14 +00:00
5b3799a23a feat(scrapers): wire per-flat DOM.РФ catalog scraper + propagate catalog_url_hash (#2442) (#2444)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 3m26s
Deploy / build-frontend (push) Successful in 3m38s
Deploy / deploy (push) Successful in 2m28s
Deploy / build-worker (push) Successful in 4m20s
2026-07-05 17:00:58 +00:00
eea9c99e73 feat(scrapers): extract + download DOM.РФ flat plan images (#2440) (#2441)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m2s
Deploy / build-worker (push) Successful in 3m20s
Deploy / deploy (push) Successful in 1m35s
2026-07-05 16:23:29 +00:00
9c46689da1 fix(devops): переносим deploy-obsidian.yml в .forgejo/workflows/ (#2416) (#2439)
All checks were successful
Deploy Obsidian / deploy-obsidian (push) Successful in 1m59s
2026-07-05 15:34:55 +00:00
994eba0a16 fix(tradein/cian): extract_all_states — discover new .concat() format, not just legacy .push() (#2438)
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 47s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m8s
2026-07-04 21:03:32 +00:00
7c1d3693e2 fix(tradein/cian): персист bti_data/valuation house_info в houses + дедуп УК (#2435) (#2437)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m34s
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 6m36s
2026-07-04 20:21:32 +00:00
f93ef5374d feat(tradein/domclick): production Layer B detail-backfill orchestrator (#2000) (#2436)
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 47s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / deploy (push) Successful in 1m32s
2026-07-04 19:49:07 +00:00
b2d42a631a feat(tradein/pdf): редизайн отчёта в реальном языке МЕРА (v2 HUD) (#2434)
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 46s
Deploy Trade-In / build-backend (push) Successful in 57s
Deploy Trade-In / deploy (push) Successful in 47s
2026-07-04 18:41:21 +00:00
ced616d150 feat(tradein/domclick): cookie-injection MVP для обхода QRATOR-блока (#2000) (#2433)
All checks were successful
Deploy Trade-In / test (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m13s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 7m16s
2026-07-04 17:49:40 +00:00
acc1e05357 fix(tradein/estimator): восстановить и канонизировать корпус дома в дедупе (регрессия #2429) (#2432)
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 / deploy (push) Successful in 47s
Deploy Trade-In / test (push) Successful in 43s
Deploy Trade-In / build-backend (push) Successful in 56s
2026-07-04 17:08:40 +00:00
102bdbda47 fix(tradein/domclick): органический заход через SERP перед card-fetch (anti-QRATOR) (#2430)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 52s
Deploy Trade-In / build-browser (push) Successful in 2m16s
Deploy Trade-In / build-backend (push) Successful in 1m45s
Deploy Trade-In / deploy (push) Successful in 8m15s
2026-07-04 16:39:47 +00:00
752c3656a3 fix(tradein/estimator): дом «2» вместо «15» при глухом названии станции метро в avito-адресе (#2429)
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 55s
Deploy Trade-In / deploy (push) Successful in 46s
2026-07-04 15:50:49 +00:00
cfed042712 fix(tradein/v2): гонка при клике по строке «Предыдущие оценки» — отчёт не загружался (#2428)
All checks were successful
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 / build-frontend (push) Successful in 2m3s
Deploy Trade-In / deploy (push) Successful in 46s
2026-07-04 14:47:01 +00:00
5cbf3485a0 fix(tradein/pdf): честный embedded-шрифт (PT Sans/PT Serif) + matplotlib-график вместо div (#2427)
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 49s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 1m1s
2026-07-04 14:39:37 +00:00
294 changed files with 27764 additions and 3045 deletions

View file

@ -27,7 +27,7 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh``.github/workflows/deploy-obsidian.yml`
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md``.forgejo/workflows/deploy-obsidian.yml`
- `docs/**` alone → НЕ триггерит деплой
## После изменения .env на VPS

View file

@ -9,6 +9,14 @@ name: Deploy Obsidian
#
# Не пересобирает никаких Docker-образов (CouchDB официальный с DockerHub).
# Не трогает main-стек (backend / frontend / postgres / worker / beat / caddy).
#
# ИСТОРИЯ (2026-07-05): жил в .github/workflows/ с момента миграции с GitHub
# (16.05.2026), помечен в README как «остался на GitHub» — но живого зеркала
# на github.com с настроенными секретами не оказалось: 0 запусков за всю
# историю Forgejo Actions (12000+ прогонов остальных workflow), контейнер
# не пересоздавался с 17.05 до ручного SSH-фикса 04.07. Перенесён сюда —
# единственная директория, которую реально исполняет этот инстанс.
# См. issue #2416.
on:
push:
@ -17,7 +25,7 @@ on:
- "docker-compose.obsidian.yml"
- "scripts/setup-couchdb.sh"
- "docs/obsidian-livesync.md"
- ".github/workflows/deploy-obsidian.yml"
- ".forgejo/workflows/deploy-obsidian.yml"
workflow_dispatch:
concurrency:

View file

@ -483,7 +483,11 @@ jobs:
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
# admin-triggered scrape внутри backend, если backend в этом деплое
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
SERVICES="browser backend frontend"
# tgbot: тот же backend-образ (rebuild уже покрыт filters.backend —
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
# с browser/backend/frontend, отдельного graceful-drain не требует.
SERVICES="browser backend frontend tgbot"
SCRAPER_STOP_TS=""
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"

View file

@ -161,7 +161,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
- [`.github/workflows/deploy-obsidian.yml`](.github/workflows/deploy-obsidian.yml) — obsidian (**остался на GitHub**): триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты).
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*
**Forgejo Secrets / Variables:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе).

View file

@ -70,7 +70,7 @@ users:
admin: admin
kopylov: pilot
user1: pilot
user2: pilot
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
user3: pilot
user4: pilot
user5: pilot

View file

@ -1152,7 +1152,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
)
from app.workers.tasks.etl_newbuilding_crossload import etl_newbuilding_crossload
result = etl_newbuilding_crossload.apply_async()
result = etl_newbuilding_crossload.apply_async(kwargs={"triggered_by": "manual"})
return {"task_id": result.id, "queued_at": "now"}
@ -1789,6 +1789,20 @@ def trigger_ekburg_permits(
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
_WAF_COOLDOWN_GUARD_MSG = (
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
"передай i_understand_waf_risk=true в теле запроса."
)
class TriggerKnCatalogObjectsRequest(BaseModel):
region_code: int = Field(default=66, ge=1, le=99)
max_objects: int | None = Field(default=None, ge=1, le=2000)
@ -1800,6 +1814,14 @@ class TriggerKnCatalogObjectsRequest(BaseModel):
"что уже скраплено сегодня."
),
)
i_understand_waf_risk: bool = Field(
default=False,
description=(
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper бьёт по "
"тому же /сервисы/* BrowserSession path family, что вызвал бан."
),
)
@router.post("/kn-catalog-objects")
@ -1816,7 +1838,14 @@ def trigger_kn_catalog_objects(
- max_objects=None дефолтный лимит таска (300).
- max_objects=3 smoke-тест.
- force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
re-trigger без явного подтверждения оператора запрещён.
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
kwargs: dict[str, Any] = {
@ -1834,3 +1863,65 @@ def trigger_kn_catalog_objects(
"force": payload.force,
"queued_at": "now",
}
class TriggerKnCatalogFlatsRequest(BaseModel):
region_code: int = Field(default=66, ge=1, le=99)
max_flats: int | None = Field(default=None, ge=1, le=5000)
force: bool = Field(
default=False,
description=(
"True — игнорировать фильтр свежести ('catalog_updated_at свежий') и "
"грузить ВСЕ квартиры последнего snapshot с непустым catalog_url_hash "
"('Загрузить все'). По умолчанию пропускает то, что скраплено < 30 дней назад."
),
)
i_understand_waf_risk: bool = Field(
default=False,
description=(
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper ездит по "
"тому же /сервисы/* BrowserSession path family, что и catalog-objects."
),
)
@router.post("/kn-catalog-flats")
def trigger_kn_catalog_flats(
payload: TriggerKnCatalogFlatsRequest,
) -> dict[str, Any]:
"""Manual trigger для catalog-FLAT scraper (#2442): цена/статус/отделка/потолки/
дата обновления + plan-изображения квартир из SSR-страницы каталога.
Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL. До тех пор пока
#2442 Task 1 (elemId → catalog_url_hash) не задеплоен и свежий kn-sweep не
наполнил hash вернёт 0 обработанных строк (ожидаемо, не баг).
- max_flats=None дефолтный лимит таска (300).
- max_flats=3 smoke-тест.
- force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
того же WAF-бана.
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
kwargs: dict[str, Any] = {
"region_code": payload.region_code,
"force": payload.force,
}
if payload.max_flats is not None:
kwargs["max_flats"] = payload.max_flats
result = scrape_kn_catalog_flats.apply_async(kwargs=kwargs)
return {
"task_id": result.id,
"region_code": payload.region_code,
"max_flats": payload.max_flats,
"force": payload.force,
"queued_at": "now",
}

View file

@ -857,6 +857,20 @@ _NEIGHBORS_SUMMARY_SQL = text("""
ORDER BY distance_m ASC
LIMIT 30
),
neighbors_total AS (
-- #2464 cluster B: честный COUNT(*) по ВСЕЙ 100м-выборке — БЕЗ LIMIT 30
-- (тот же WHERE, что у `neighbors` выше). count_buildings_100m раньше
-- считался как len(neighbors), тихо капаясь на 30 даже когда соседей
-- в радиусе больше.
SELECT COUNT(*) AS n
FROM cad_buildings b
WHERE ST_DWithin(
b.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
100
)
AND b.cad_num != CAST(:our_cad AS text)
),
overlap_rows AS (
SELECT cad_num,
building_name,
@ -884,7 +898,8 @@ _NEIGHBORS_SUMMARY_SQL = text("""
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
FROM overlap_rows o),
'[]'::json
) AS overlap_rows
) AS overlap_rows,
(SELECT n FROM neighbors_total) AS neighbors_total_count
""")
@ -901,6 +916,11 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
один сетевой round-trip (~47ms) на каждый analyze сами вычисления не
меняются. Формат возвращаемого dict идентичен прежнему.
#2464 cluster B: `count_buildings_100m` — честный COUNT(*) по всей 100м-выборке
(CTE `neighbors_total`, БЕЗ LIMIT), а не len(neighbors) (капалось на LIMIT 30).
`neighbors_truncated` True если в радиусе больше соседей, чем показано в
списке `neighbors`.
SQL module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
"""
@ -915,6 +935,9 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
)
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
# #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) —
# НЕ len(neighbor_rows), которое капалось на 30 даже когда соседей больше.
neighbors_total_count = int(row["neighbors_total_count"]) if row else 0
except Exception as e:
logger.warning("neighbors query failed: %s", e)
return {"data_available": False, "note": f"neighbors query failed: {e}"}
@ -959,36 +982,46 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
]
has_existing = len(overlap_buildings) > 0
# neighbor_rows — до 30 (SQL CTE `neighbors` LIMIT), список ниже дополнительно
# режется до 20 для payload. `neighbors_truncated` сравнивает честный total с
# тем, что РЕАЛЬНО показано (len(neighbors_list)) — а не с промежуточным
# neighbor_rows (до 30) — иначе флаг мог соврать False на 21..30 соседях,
# где список уже обрезан до 20, а total ещё не превысил len(neighbor_rows).
neighbors_list = [
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
]
return {
"data_available": True,
"radius_m": 100,
"count_buildings_100m": len(neighbor_rows),
# #2464 cluster B: честный total (neighbors_total CTE, без LIMIT) — НЕ
# len(neighbor_rows) (капалось на LIMIT 30 у CTE `neighbors`).
"count_buildings_100m": neighbors_total_count,
"neighbors_truncated": neighbors_total_count > len(neighbors_list),
"avg_floors_100m": avg_floors,
"max_floors_100m": max_floors,
"median_cost_per_m2_100m": median_cost,
"neighbors": [
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
],
"neighbors": neighbors_list,
"has_existing_buildings": has_existing,
"overlap_buildings": overlap_buildings,
"note": (
@ -1157,6 +1190,70 @@ def _compute_confidence(
}
def _build_market_pulse(
competitor_rows: list[dict[str, Any]],
competitors_total: int,
velocity_data: dict[str, Any] | None,
) -> dict[str, Any]:
"""OBJ-3 aggregate: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
в `competitor_rows` (детальный список для карты/карточек), но исключаются
из расчётов рыночных метрик (market_avg_price_per_m2, top_sellers).
#2464 cluster B: `competitors_total` — честный total (передаётся вызывающим
кодом из отдельного COUNT(*) БЕЗ LIMIT, см. `analyze_parcel` 5a), НЕ
len(competitor_rows) (которое капалось на LIMIT 20 запроса конкурентов, и
заодно раздувало `coverage_pct` competitors_priced/20 вместо
competitors_priced/true_total). PURE вызывающий код передаёт честный total
отдельно, эта функция его не пересчитывает.
"""
competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
competitors_priced = len(competitors_with_price)
if competitors_with_price:
prices = [float(c["avg_price_per_m2_rub"]) for c in competitors_with_price]
market_avg_price = round(sum(prices) / len(prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
with_sales = [
c
for c in competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
top_sellers = sorted(
with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in top_sellers
]
else:
market_avg_price = None
top_sellers_list = []
coverage_pct = (
round(competitors_priced * 100.0 / competitors_total, 1) if competitors_total > 0 else 0.0
)
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
return {
"avg_velocity_m2": avg_velocity,
"market_avg_price_per_m2": market_avg_price,
"competitors_total": competitors_total,
"competitors_with_price": competitors_priced,
"coverage_pct": coverage_pct,
"top_sellers": top_sellers_list,
}
# #93 — on-demand cadastre fetch tuning constants.
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
#
@ -2256,6 +2353,34 @@ def analyze_parcel(
.all()
)
# 5a) #2464 cluster B: честный COUNT(*) конкурентов в радиусе 3км — БЕЗ LIMIT.
# competitor_rows выше капнут `LIMIT 20` (топ-20 ближайших/строящихся для карты и
# детального списка) — market_pulse.competitors_total ниже раньше = len(competitor_rows),
# тихо капаясь на 20 даже когда в радиусе ЖК больше. Лёгкий COUNT переиспользует
# ТОТ ЖЕ latest_obj + ST_DWithin фильтр, но БЕЗ obj_lots_latest/obj_pricing джойнов
# (нужны только для цен детального списка, не для total) — на порядок дешевле
# полного запроса конкурентов (см. #1964 EXPLAIN про стоимость lots-дедупа).
_competitors_total_true = (
db.execute(
text("""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT COUNT(*) FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
"""),
{"wkt": geom_wkt},
).scalar()
or 0
)
# 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес
# в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
# + по кварталам сдачи. Константы — см. PIPELINE_* выше.
@ -3680,56 +3805,12 @@ def analyze_parcel(
except Exception as _ve:
logger.warning("velocity compute failed for %s: %s", cad_num, _ve)
# OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
# Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
# в competitor list для карты, но исключаются из расчётов рыночных метрик.
_competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
_competitors_total = len(competitor_rows)
_competitors_priced = len(_competitors_with_price)
if _competitors_with_price:
_prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price]
_market_avg_price = round(sum(_prices) / len(_prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
_with_sales = [
c
for c in _competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
_top_sellers = sorted(
_with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
_top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in _top_sellers
]
else:
_market_avg_price = None
_top_sellers_list = []
_coverage_pct = (
round(_competitors_priced * 100.0 / _competitors_total, 1)
if _competitors_total > 0
else 0.0
# #2464 cluster B: competitors_total — честный total из отдельного COUNT(*)
# (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса
# конкурентов. См. _build_market_pulse docstring для полной логики агрегата.
market_pulse: dict[str, Any] = _build_market_pulse(
competitor_rows, int(_competitors_total_true), velocity_data
)
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
_avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
market_pulse: dict[str, Any] = {
"avg_velocity_m2": _avg_velocity,
"market_avg_price_per_m2": _market_avg_price,
"competitors_total": _competitors_total,
"competitors_with_price": _competitors_priced,
"coverage_pct": _coverage_pct,
"top_sellers": _top_sellers_list,
}
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается

View file

@ -120,13 +120,18 @@ class Settings(BaseSettings):
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
#
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch()
# ТОЛЬКО для KN-sweep BrowserSession (другие скраперы — nspd/catalog —
# продолжают использовать модульный дефолт _BROWSER_CONCURRENCY=8 без
# изменений). 2 — эмпирически безопасный потолок против volume-бана.
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch().
# Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2
# (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat /
# catalog-object scrapers) тоже переиспользуют эти же значения — они бьют
# по тому же /сервисы/* path family, что и вызвало WAF hard-ban 2026-05-24
# (#2443). nspd и прочие скраперы вне этого path family продолжают
# использовать модульный дефолт _BROWSER_CONCURRENCY=8 без изменений.
# 2 — эмпирически безопасный потолок против volume-бана.
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
scrape_kn_browser_concurrency: int = 2
# Окно случайной паузы (мс) между запросами KN-sweep. Шире дефолта
# Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog-
# flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта
# (6001500), чтобы размазать запросы во времени и не триггерить rate-ban.
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
# ширим до 12003000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.

View file

@ -65,6 +65,11 @@ class ConnectionPointsSummary(BaseModel):
in_protection_zone: bool
protection_zones_intersecting: int
total_structures_in_radius: int
# Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без
# LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL).
# ADDITIVE (default False) — старые клиенты не ломаются.
protection_zones_truncated: bool = False
structures_truncated: bool = False
class ConnectionPointsResponse(BaseModel):
@ -100,6 +105,10 @@ class UtilityInfrastructureSummary(BaseModel):
nearest_distance_m: float | None
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
nearest_by_kind: dict[str, float | None]
# Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же
# ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200).
# ADDITIVE (default False) — старые клиенты не ломаются.
features_truncated: bool = False
class UtilityInfrastructureResponse(BaseModel):

View file

@ -504,6 +504,15 @@ def developer_history(
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
# #2464 cluster E: без дедупа каждый ЖК возвращался ×N раз (одна строка на
# snapshot_date, ретенции нет) — портфель девелопера раздувался в разы (прод-замер
# ~7.6-9.0× per-developer). DISTINCT ON (obj_id) + snapshot_date DESC NULLS LAST —
# latest-snapshot-per-obj_id (тот же паттерн, что cmp_rows/latest_obj CTE в
# recommend_mix ниже в этом файле). dev_id — стабильный идентификатор
# (не меняется между снапшотами одного ЖК) → фильтруется ДО DISTINCT ON, как
# region_cd/district_name в сиблингах. Итоговый ORDER BY ready_dt вынесен во внешний
# SELECT: DISTINCT ON требует, чтобы его собственный ORDER BY начинался с ключа
# дедупа (obj_id, snapshot_date), не с ready_dt.
rows = (
db.execute(
text(
@ -511,8 +520,15 @@ def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
SELECT obj_id, comm_name, addr, region_cd, flat_count,
square_living, ready_dt, obj_class, escrow,
problem_flag, latitude, longitude, is_ekb
FROM domrf_kn_objects
WHERE dev_id = :dev
FROM (
SELECT DISTINCT ON (obj_id)
obj_id, comm_name, addr, region_cd, flat_count,
square_living, ready_dt, obj_class, escrow,
problem_flag, latitude, longitude, is_ekb
FROM domrf_kn_objects
WHERE dev_id = :dev
ORDER BY obj_id, snapshot_date DESC NULLS LAST
) latest
ORDER BY ready_dt DESC NULLS LAST
"""
),
@ -1797,12 +1813,38 @@ def _active_competitors_count(
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
def _q(where_extras: str, params: dict[str, Any]) -> int:
# #2464 cluster E: domrf_kn_objects хранит МНОЖЕСТВО snapshot_date на obj_id
# (UNIQUE(obj_id, snapshot_date), ретенции нет) — прод-замер 4090 строк / 482
# distinct obj_id для site_status='Строящиеся' (8.49× инфляция). Наивный
# COUNT(*) считал каждый исторический снапшот отдельно. Fix: DISTINCT ON
# (obj_id) + snapshot_date DESC NULLS LAST даёт latest-snapshot-per-obj_id
# (сиблинг-паттерн: _L3_FUTURE_SQL #1212 в site_finder/supply_layers.py).
#
# ВСЕ волатильные предикаты применяются ПОСЛЕ DISTINCT ON (во внешнем WHERE),
# а не внутри CTE — иначе DISTINCT ON вернул бы «последний снапшот, ПРОШЕДШИЙ
# фильтр», а не истинно последний снапшот объекта. Прод-замер per-obj_id
# variance across snapshots (authoritative, coordinator 2026-07-08):
# dev_id=0, region_cd=0 (СТАБИЛЬНЫ) · district_name=1, obj_class=29,
# site_status=11 (ВОЛАТИЛЬНЫ).
# → В CTE остаётся ТОЛЬКО region_cd (стабилен + ограничивает стоимость дедупа
# как partition-scope). {where_extras} (district_name/obj_class) и
# site_status='Строящиеся' — во внешнем WHERE, на истинно-последней строке.
# Пример бага, который это чинит: ЖК сменил класс Комфорт→Бизнес; при
# фильтре obj_class внутри CTE взяли бы старый Комфорт-снапшот и посчитали
# его активным Комфортом, хотя его актуальный класс уже Бизнес.
n = db.execute(
text(
f"""
SELECT COUNT(*) FROM domrf_kn_objects
WHERE region_cd = :rc
AND site_status = 'Строящиеся'
WITH latest AS (
SELECT DISTINCT ON (obj_id)
obj_id, site_status, district_name,
obj_class, obj_class_fallback
FROM domrf_kn_objects
WHERE region_cd = :rc
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT COUNT(*) FROM latest
WHERE site_status = 'Строящиеся'
{where_extras}
"""
),
@ -2233,14 +2275,28 @@ def _competitors_two_dim(
db.execute(
text(
f"""
WITH active AS (
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name
WITH latest AS (
-- #2464 cluster E: сначала истинно-последний снапшот на obj_id,
-- ТОЛЬКО по стабильному region_cd (variance=0). Волатильные
-- предикаты (site_status=11, district_name=1, obj_class=29 per
-- прод-замер) НЕ внутри DISTINCT ON, иначе взяли бы «последний
-- снапшот, прошедший фильтр», а не истинно последний (ЖК,
-- сменивший класс/район/статус, засчитался бы по устаревшему
-- снапшоту). Зеркалит _q() в _active_competitors_count выше.
SELECT DISTINCT ON (obj_id)
obj_id, latitude, longitude, district_name,
site_status, obj_class, obj_class_fallback
FROM domrf_kn_objects
WHERE region_cd = :rc
AND site_status = 'Строящиеся'
ORDER BY obj_id, snapshot_date DESC NULLS LAST
),
active AS (
-- Волатильные фильтры на истинно-последней строке.
SELECT obj_id, latitude, longitude, district_name
FROM latest
WHERE site_status = 'Строящиеся'
AND district_name = :dn
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
),
centroid AS (
SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt

View file

@ -89,6 +89,8 @@ _NO_DATA = "нет данных"
# Кап видимых строк тепло/вода-таблиц (зеркало _HEAT_ROW_CAP / _WATER_ROW_CAP в HTML).
_HEAT_ROW_CAP = 15
_WATER_ROW_CAP = 25
# Кап видимых строк таблицы разрешений §6 (зеркало _PERMITS_ROW_CAP в HTML).
_PERMITS_ROW_CAP = 10
# Титул документа + заголовки секций (зеркало _TITLE_* в full_report_html).
_TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА"
@ -1008,6 +1010,12 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
`permits_nearby.get_permits_nearby`). Пусто / total_count=0 честная плашка-фраза
(полное предложение, не аббревиатура). Зеркало `_build_permits_nearby` в
full_report_html.
#2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby
считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30. Здесь
список дополнительно режется до `_PERMITS_ROW_CAP` (10) для компактности раньше
это резалось МОЛЧА. Дисклоузим разницу `total_count - показано` строкой
«и ещё N », как тепло/вода-таблицы выше (`_build_connection_capacity`).
"""
nearby = _as_dict(result.get("permits_nearby"))
total = nearby.get("total_count")
@ -1030,7 +1038,7 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
_add_kv_table(doc, kv)
rows: list[list[Any]] = []
for item in _as_list(nearby.get("items"))[:10]:
for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]:
data = _as_dict(item)
rows.append(
[
@ -1040,6 +1048,11 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
data.get("distance_m"),
]
)
hidden = total - len(rows)
if hidden > 0:
rows.append(
[f"… и ещё {hidden} записей (полный список в веб-версии §6)", _DASH, _DASH, _DASH]
)
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
_add_data_table(doc, headers, rows)

View file

@ -96,6 +96,8 @@ _NON_EKB_GENERIC: tuple[str, ...] = (
# Кап видимых строк тепло/вода-таблиц (сверх — «и ещё K систем …»).
_HEAT_ROW_CAP = 15
_WATER_ROW_CAP = 25
# Кап видимых строк таблицы разрешений §6 (сверх — «и ещё K записей …»).
_PERMITS_ROW_CAP = 10
# Усечение длинных бюрократических имён систем.
_SYSTEM_NAME_MAX = 120
@ -1495,6 +1497,13 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
`result` analyze-payload (НЕ forecast): читает `permits_nearby` (см.
`permits_nearby.get_permits_nearby`). Пусто / total_count=0 честная плашка-фраза
(полное предложение, не аббревиатура). Все динамические строки через `html.escape`.
#2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby
считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30
(`items_truncated`). Здесь список дополнительно режется до `_PERMITS_ROW_CAP`
(10) для компактности PDF раньше это резалось МОЛЧА. Дисклоузим разницу
`total_count - показано` строкой «и ещё N », как тепло/вода-таблицы выше
(`_build_connection_capacity`).
"""
nearby = _as_dict(result.get("permits_nearby"))
total = nearby.get("total_count")
@ -1514,7 +1523,7 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
]
rows: list[list[Any]] = []
for item in _as_list(nearby.get("items"))[:10]:
for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]:
data = _as_dict(item)
rows.append(
[
@ -1524,6 +1533,16 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
data.get("distance_m"),
]
)
hidden = total - len(rows)
if hidden > 0:
rows.append(
[
f"… и ещё {hidden} записей (полный список в веб-версии §6)",
_DASH,
_DASH,
_DASH,
]
)
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
return _kv_table(kv) + _data_table(headers, rows)

View file

@ -32,7 +32,9 @@ from typing import Any
logger = logging.getLogger(__name__)
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
# Тот же набор из шести содержательных секций, что рисует excel.py (#991).
# Шесть секций зеркалят excel.py (#991); §13.7 «Уверенность» — доп. секция, портирована
# из report_docx/report_md для parity между экспортёрами (audit epic #2445 item C2;
# excel.py её пока не несёт — отдельный gap, вне scope этой правки).
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
_TITLE_SUMMARY: str = "Сводка"
@ -41,6 +43,7 @@ _TITLE_FUTURE_MARKET: str = "Будущий рынок"
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
_TITLE_SCENARIOS: str = "Сценарии"
_TITLE_SCORING: str = "Скоринг"
_TITLE_CONFIDENCE: str = "Уверенность"
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
@ -308,15 +311,21 @@ def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
def _build_summary(report: dict[str, Any]) -> str:
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст."""
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст.
Уровень уверенности здесь только сводный badge (`overall_confidence`), зеркало
report_docx/report_md._build_summary. Полный разбор (rationale + факторы-драйверы)
живёт в отдельной секции §13.7 «Уверенность» (`_build_confidence`) раньше
(до parity-фикса #2445 C2) он дублировался здесь тонкой 2-строчной таблицей, что
расходилось с docx/md и не переживало dict-значный фактор; убрано, чтобы не было
двух версий одних и тех же данных в одном документе.
"""
exec_summary = _as_dict(report.get("exec_summary"))
meta = _as_dict(report.get("meta"))
confidence = _as_dict(report.get("confidence"))
headline = exec_summary.get("headline")
verdict = exec_summary.get("verdict")
key_numbers = _as_dict(exec_summary.get("key_numbers"))
factors = _as_dict(confidence.get("factors"))
cad = _esc(meta.get("cad_num"))
district = _esc(meta.get("district"))
@ -329,10 +338,6 @@ def _build_summary(report: dict[str, Any]) -> str:
("Сформировано", meta.get("generated_at")),
("Версия схемы", meta.get("schema_version")),
]
confidence_pairs: list[tuple[str, Any]] = [
("Уровень", _level_ru(confidence.get("level"))),
("Обоснование", confidence.get("rationale")),
]
overall_conf = _esc(_level_ru(exec_summary.get("overall_confidence")))
return f"""
@ -351,12 +356,6 @@ def _build_summary(report: dict[str, Any]) -> str:
<h3>Ключевые числа</h3>
{_dict_kv_table(key_numbers)}
<h3>Уверенность отчёта</h3>
{_kv_table(confidence_pairs)}
<h3>Факторы уверенности</h3>
{_dict_kv_table(factors)}
<h3>Контекст</h3>
{_kv_table(context_pairs)}
</div>
@ -624,6 +623,43 @@ def _build_scoring(report: dict[str, Any]) -> str:
<h3>Специальные индексы</h3>
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
</div>
"""
def _build_confidence(report: dict[str, Any]) -> str:
"""§13.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица).
Parity fix (audit epic #2445 item C2): report_docx/report_md уже несли эту секцию
(§22.7/§13.7) PDF был единственным экспортёром без нее. Портировано 1-в-1 (та же
4-колоночная таблица «Фактор/Значение/Уровень/Комментарий»), но через HTML-примитивы
report_pdf (`_data_table`), а не python-docx/Markdown API.
"""
confidence = _as_dict(report.get("confidence"))
level = _level_ru(confidence.get("level"))
rationale = confidence.get("rationale")
factors = _as_dict(confidence.get("factors"))
# Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive:
# если значение — dict, раскладываем на value/level/note; иначе кладём как есть
# (зеркало report_docx._build_confidence / report_md._build_confidence).
factor_rows: list[list[Any]] = []
for name, payload in factors.items():
if isinstance(payload, dict):
factor_rows.append(
[name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")]
)
else:
factor_rows.append([name, payload, _DASH, _DASH])
return f"""
<div class="section" id="confidence">
<h2>{html.escape(_TITLE_CONFIDENCE)}</h2>
<span class="badge">Уровень: {_esc(level)}</span>
<p class="verdict">{_esc(rationale)}</p>
<h3>Факторы уверенности</h3>
{_data_table(["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)}
<div class="footer">{html.escape(_FOOTER_NOTE)}</div>
</div>
@ -631,7 +667,8 @@ def _build_scoring(report: dict[str, Any]) -> str:
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
# `_SHEET_BUILDERS` у excel.py — тот же набор из шести содержательных секций §13).
# `_SHEET_BUILDERS` у excel.py + report_docx/report_md — Сводка → Рынок сейчас →
# Будущий рынок → Продукт ТЗ → Сценарии → Скоринг → Уверенность §13.7, #2445 C2).
_SECTION_BUILDERS: tuple[Any, ...] = (
_build_summary,
_build_market_now,
@ -639,11 +676,12 @@ _SECTION_BUILDERS: tuple[Any, ...] = (
_build_product_tz,
_build_scenarios,
_build_scoring,
_build_confidence,
)
def _build_html(report: dict[str, Any]) -> str:
"""Склеить HTML-документ из шести секций §13. PURE (только строки). Graceful."""
"""Склеить HTML-документ из семи секций §13 (+ §13.7 Уверенность). PURE. Graceful."""
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
return f"""<!DOCTYPE html>
<html lang="ru">
@ -666,10 +704,10 @@ def export_report_pdf(report: Any) -> bytes:
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
рынок / Продукт ТЗ / Сценарии / Скоринг тот же набор, что и `export_report_xlsx`).
Шапки таблиц с заливкой, RU-метки, числа округлены, None "". На блоке «Сводка»
заметный ADVISORY-маркер (отчёт советующий). ВСЕ динамические строки экранируются
`html.escape`.
рынок / Продукт ТЗ / Сценарии / Скоринг / Уверенность §13.7 parity с
report_docx/report_md, #2445 C2). Шапки таблиц с заливкой, RU-метки, числа
округлены, None "". На блоке «Сводка» заметный ADVISORY-маркер (отчёт
советующий). ВСЕ динамические строки экранируются `html.escape`.
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/

View file

@ -371,12 +371,20 @@ def _month_grid(start: date, end: date) -> list[date]:
def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}."""
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}.
SAVEPOINT (#2464 cluster A finding #2): `db` — общая §22-сессия отчёта; при сбое
этого запроса БЕЗ SAVEPOINT транзакция Postgres остаётся aborted и следующие
запросы get_monthly_macro (inflation, mortgage) + все ПОСЛЕДУЮЩИЕ §9.x-слои на
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
SAVEPOINT (ROLLBACK TO SAVEPOINT), внешняя транзакция остаётся рабочей.
"""
try:
rows = db.execute(
_KEY_RATE_MONTHLY_SQL,
{"itype": "key_rate", "region": "rf", "since": since},
).all()
with db.begin_nested():
rows = db.execute(
_KEY_RATE_MONTHLY_SQL,
{"itype": "key_rate", "region": "rf", "since": since},
).all()
except Exception:
logger.exception("get_monthly_macro: key_rate query failed")
return {}
@ -389,9 +397,14 @@ def _query_inflation_monthly(db: Session, *, since: date) -> dict[date, float]:
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) берём как есть
через reuse get_macro_series (свой SQL не пишем). _month_start страховка.
Сбой/пустой ряд {} (НЕ crash), inflation_yoy тогда None по всей сетке.
SAVEPOINT (#2464 cluster A finding #2): см. `_query_key_rate_monthly` — та же
общая §22-сессия, тот же риск отравления транзакции для последующих запросов
(mortgage-поля + §9.x-слои). `with db.begin_nested():` изолирует сбой в SAVEPOINT.
"""
try:
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
with db.begin_nested():
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
except Exception:
logger.exception("get_monthly_macro: inflation_yoy query failed")
return {}
@ -404,11 +417,20 @@ def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date,
Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу
в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда
не валит остальные (graceful: пустой подсловарь).
SAVEPOINT (#2464 cluster A finding #2): все 5 полей читаются на ОДНОЙ `db`-Session
(get_monthly_macro вызывается внутри общей §22-сессии отчёта). Без SAVEPOINT сбой
ОДНОГО поля оставляет транзакцию Postgres aborted каждое СЛЕДУЮЩЕЕ поле в этом
же цикле тоже падает (хотя его данные были бы доступны), а `except` здесь молча
отдаёт [] по каждому, маскируя каскад под «нормальную» построчную деградацию.
`with db.begin_nested():` SAVEPOINT на КАЖДОЕ поле: сбой откатывает только его
SAVEPOINT (ROLLBACK TO SAVEPOINT), сессия остаётся рабочей для следующего поля.
"""
out: dict[str, dict[date, float]] = {}
for indicator_type, field in _MORTGAGE_FIELDS:
try:
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
with db.begin_nested():
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
except Exception:
logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type)
series = []

View file

@ -124,7 +124,7 @@ def _primary_horizon(horizons: Sequence[int]) -> int:
return horizons[0] if horizons else _PREFERRED_PRIMARY_HORIZON
def _safe_call(label: str, fn: Any) -> Any:
def _safe_call(label: str, db: Session, fn: Any) -> Any:
"""Вызвать §9.x-сервис graceful: сбой → None + logger.exception (не crash отчёта).
Зеркало product_scoring._safe_call: любой §9.x-слой может бросить (тонкие данные / нет
@ -133,15 +133,29 @@ def _safe_call(label: str, fn: Any) -> Any:
широкий Exception (изоляция одного слоя от отчёта) с ОБЯЗАТЕЛЬНЫМ logger.exception
НЕ молчаливое глотание. §9.x уже graceful внутри; это belt-and-suspenders на шве.
SAVEPOINT (#2464 cluster A finding #1): все §9.x-слои шарят ОДИН `db`-Session на
отчёт (module docstring `forecast_request_cache.py`). Без обёртки сбойный
`db.execute` внутри слоя оставляет транзакцию Postgres в состоянии aborted
(«current transaction is aborted, commands ignored until end of transaction
block») КАЖДЫЙ последующий слой на той же сессии тоже падает, хотя его данные
были бы доступны. `with db.begin_nested():` заводит SAVEPOINT НА ВЕСЬ вызов слоя
(слой может делать несколько `db.execute` внутри себя например §9.6 внутри
§9.8/§11); при исключении SAVEPOINT откатывается автоматически (ROLLBACK TO
SAVEPOINT), внешняя транзакция остаётся рабочей для следующего слоя. НЕ
`db.rollback()` тот откатил бы ВСЮ внешнюю транзакцию (см. `backend.md` §
SAVEPOINT pattern, established anti-pattern).
Args:
label: имя слоя для лога (диагностика какой §9.x-вызов деградировал).
db: общая §22-сессия (для SAVEPOINT вокруг вызова слоя).
fn: нулевой-аргумент thunk вокруг §9.x-вызова.
Returns:
Результат `fn()` или None при исключении.
"""
try:
return fn()
with db.begin_nested():
return fn()
except Exception:
logger.exception("orchestrator: §9.x layer %s failed → section degraded", label)
return None
@ -334,21 +348,25 @@ def _build_site_finder_report_impl(
# ── 2. §9.x-слои, каждый graceful через _safe_call (ГЕТЕРОГЕННЫЕ сигнатуры) ──
market_metrics = _safe_call(
"market_metrics",
db,
lambda: compute_market_metrics(db, district=district, premise_kind=_PREMISE_KIND),
)
supply_rows = _safe_call(
"supply_layers",
db,
lambda: compute_all_layers(db, district=district, premise_kind=_PREMISE_KIND),
)
supply_layers = _summarize_supply_layers(supply_rows)
future_supply = _safe_call(
"future_supply",
db,
lambda: compute_future_supply_pressure(
db, district=district, horizon_months=primary, premise_kind=_PREMISE_KIND
),
)
forecasts = _safe_call(
"demand_supply_forecast",
db,
lambda: compute_demand_supply_forecast(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
@ -359,6 +377,7 @@ def _build_site_finder_report_impl(
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
scenarios_result = _safe_call(
"scenarios",
db,
lambda: compute_scenarios(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
@ -371,18 +390,21 @@ def _build_site_finder_report_impl(
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
product_scores = _safe_call(
"score_card",
db,
lambda: compute_score_card(
db, spec=spec, district=district, cad_num=cad_num, horizon_months=primary
),
)
special_indices = _safe_call(
"special_indices",
db,
lambda: compute_special_indices(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
recommendation_overlay = _safe_call(
"forecast_overlay",
db,
lambda: build_forecast_overlay(
db,
district=district,
@ -393,7 +415,7 @@ def _build_site_finder_report_impl(
)
# ── Макро-свежесть (audit MEDIUM): только лог; проводка в отчёт — 3b ─────────
macro = _safe_call("monthly_macro", lambda: get_monthly_macro(db))
macro = _safe_call("monthly_macro", db, lambda: get_monthly_macro(db))
macro_as_of = _macro_as_of(macro)
if macro_as_of is not None:
logger.info(

View file

@ -835,13 +835,15 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
compute_poi_weighted_top7. Нет геометрии / нет POI / сбой None (infra_fit unavailable).
"""
try:
coords = (
db.execute(
_PARCEL_CENTROID_SQL, {"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)}
with db.begin_nested():
coords = (
db.execute(
_PARCEL_CENTROID_SQL,
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
)
.mappings()
.first()
)
.mappings()
.first()
)
except Exception:
logger.exception(
"product_scoring: centroid lookup failed (cad_num=%s) → infra n/a", cad_num
@ -850,9 +852,10 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
if not coords or coords.get("lat") is None or coords.get("lon") is None:
return None
try:
response = compute_poi_weighted_top7(
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
)
with db.begin_nested():
response = compute_poi_weighted_top7(
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
)
except Exception:
logger.exception("product_scoring: poi_score failed (cad_num=%s) → infra n/a", cad_num)
return None

View file

@ -549,6 +549,12 @@ def _query_source_a(
Graceful {} при сбое/пустых данных. price_bucket в spec для Source A
игнорируется (агрегат не несёт per-lot цены) фиксируется логом.
SAVEPOINT (#2464 cluster A finding #4): `db` — общая §22-сессия отчёта (может
переиспользоваться другими §9.x-слоями после этого вызова). Без SAVEPOINT сбойный
`db.execute` оставляет транзакцию Postgres aborted все ПОСЛЕДУЮЩИЕ запросы на
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
SAVEPOINT при сбое, оставляя внешнюю транзакцию рабочей.
"""
if spec.price_bucket is not None:
logger.info(
@ -567,7 +573,8 @@ def _query_source_a(
"room_bucket": spec.room_bucket,
}
try:
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
with db.begin_nested():
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
except Exception:
logger.exception("build_sales_series: source A query failed")
return {}
@ -582,6 +589,9 @@ def _query_source_b(
Graceful {} при сбое/пустых данных. Передаёт bucket-пороги/-метки в SQL
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
правилом и в БД, и в Python.
SAVEPOINT (#2464 cluster A finding #4): см. `_query_source_a` — та же общая
§22-сессия, тот же риск отравления транзакции для последующих слоёв/запросов.
"""
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
# None → EKB-wide (без district-фильтра).
@ -613,7 +623,8 @@ def _query_source_b(
"p_unknown": PRICE_BUCKET_UNKNOWN,
}
try:
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
with db.begin_nested():
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
except Exception:
logger.exception("build_sales_series: source B query failed")
return {}

View file

@ -994,16 +994,29 @@ def _query_parcel_centroid(db: Session, *, cad_num: str) -> tuple[float, float]
Нет геометрии / сбой None (гео-веса упадут на floor; overlap считается по остальным
осям, индекс НЕ деградирует целиком). Параметризовано (psycopg v3). Детерминированно.
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): этот helper
зовётся из `_build_cannibalization` builder, обёрнутый внешним SAVEPOINT в
`compute_special_indices._run`. БЕЗ собственного `db.begin_nested():` сбойный
`db.execute` тут проглатывается локально ( None), оставляя транзакцию Postgres
aborted; тогда ВНЕШНИЙ `_run`-savepoint выходит «нормально» и делает RELEASE
SAVEPOINT, а RELEASE в aborted-tx САМ падает (Postgres в aborted допускает только
ROLLBACK / ROLLBACK TO SAVEPOINT) внешний except ловит уже RELEASE-ошибку, но
транзакция так и не откачена отравление каскадит в следующий §25-индекс. Savepoint
ИМЕННО ЗДЕСЬ (в точке перехвата) откатывает сбой (ROLLBACK TO SAVEPOINT), поэтому
внешний RELEASE проходит. Тот же принцип, что saturation.py: savepoint у db.execute
в точке, где ошибка ловится.
"""
try:
row = (
db.execute(
_PARCEL_CENTROID_SQL,
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
with db.begin_nested():
row = (
db.execute(
_PARCEL_CENTROID_SQL,
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
)
.mappings()
.first()
)
.mappings()
.first()
)
except Exception:
logger.exception("cannibalization: centroid query failed for cad_num=%s", cad_num)
return None
@ -1657,9 +1670,20 @@ def compute_special_indices(
segment = spec.as_dict()
def _run(key: str, builder: Any) -> SpecialIndex:
"""Выполнить builder одного индекса в собственном try/except (graceful)."""
"""Выполнить builder одного индекса в собственном try/except (graceful).
SAVEPOINT (#2464 cluster A finding #3): все шесть builder'ов делят ОДНУ `db`-
Session (общая сессия §22-отчёта). Без SAVEPOINT сбойный `db.execute` внутри
builder'а оставляет транзакцию Postgres aborted — КАЖДЫЙ следующий индекс на
той же сессии тоже падает (хотя его данные были бы доступны), что маскируется
под шесть независимых деградаций. `with db.begin_nested():` заводит SAVEPOINT
на ВЕСЬ builder() (некоторые builder'ы делают несколько db.execute внутри
себя) сбой откатывает только его SAVEPOINT, сессия остаётся рабочей для
следующего индекса.
"""
try:
return builder() # type: ignore[no-any-return]
with db.begin_nested():
return builder() # type: ignore[no-any-return]
except Exception:
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
logger.exception(

View file

@ -66,9 +66,7 @@ def _strategy_label(strategy: str) -> str:
def _teap_table(variants: Sequence[ConceptVariant]) -> str:
"""HTML-таблица ТЭП по всем вариантам (строки — показатели, колонки — стратегии)."""
headers = "".join(
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
)
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
rows: list[tuple[str, list[str]]] = [
("Пятно застройки, кв.м", [_fmt_int(v.teap.built_area_sqm) for v in variants]),
("Общая площадь (GFA), кв.м", [_fmt_int(v.teap.total_floor_area_sqm) for v in variants]),
@ -107,9 +105,7 @@ def _fmt_irr(financial: FinancialModel) -> str:
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF)."""
headers = "".join(
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
)
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
rows: list[tuple[str, list[str]]] = [
(
"Выручка — жильё, млн руб",
@ -188,9 +184,13 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
"НДС — реализация жилья и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ), "
"входной НДС по СМР встроен в себестоимость; НДС начисляется только на нежилую часть "
"(машиноместа). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
"— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
"входной НДС по СМР встроен в себестоимость; НДС начисляется на нежилые части — "
"машиноместа и коммерцию/офисы 1-го этажа (встроенный НДС в добавленной стоимости "
"каждой части). Налог на прибыль — 25% (с 2025). Цены и себестоимость — рыночные "
"ориентиры. Коммерция/офисы 1-го этажа учтены по нормативной доле от общей площади "
"и продаются с умеренной наценкой к цене жилья того же класса; себестоимость СМР "
"нежилого — по той же ставке, что и жильё (отдельной строки затрат нет, повторного "
"учёта в затратах нет).</p>"
"</body></html>"
)

View file

@ -30,6 +30,7 @@ from dataclasses import dataclass
from typing import Any
from pyproj import CRS, Transformer
from pyproj.exceptions import CRSError
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
from shapely.geometry.base import BaseGeometry
from shapely.ops import transform as shapely_transform
@ -53,6 +54,12 @@ MIN_BUILDABLE_AREA_SQM: float = 50.0
# удержать время в бюджете (<=10 c/вариант). MVP-упрощение.
MAX_GRID_CELLS: int = 20_000
# Валидные диапазоны WGS84 lon/lat (градусы). Используются, чтобы отловить участок,
# присланный в проекции (метры, напр. МСК-66/UTM), ДО того как _metric_transformers
# соберёт AEQD с lat_0/lon_0 далеко за пределами градусов -> pyproj.CRSError.
_LON_RANGE: tuple[float, float] = (-180.0, 180.0)
_LAT_RANGE: tuple[float, float] = (-90.0, 90.0)
# WGS84 (вход контракта).
_WGS84 = CRS.from_epsg(4326)
@ -170,6 +177,27 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
return polygon
def _assert_wgs84_bounds(polygon: Polygon) -> None:
"""Проверить, что bbox полигона лежит в валидных диапазонах WGS84 lon/lat.
Root-cause фикс: фронтовый баг / плохой геокодер / demo-payload иногда шлёт
``parcel_geojson`` в проекции (метры МСК-66/UTM, напр. [500000, 6200000]) вместо
WGS84 lon/lat. Такой полигон синтаксически валиден (это просто Polygon), но
дальше он бы дошёл до :func:`_metric_transformers`, где centroid.y (~6_200_000)
подставится в ``+lat_0=`` -> pyproj.CRS.from_proj4 упадёт CRSError (opaque 500).
Ловим здесь, ДО построения проекции, с понятным сообщением (422).
"""
minx, miny, maxx, maxy = polygon.bounds
lon_ok = _LON_RANGE[0] <= minx and maxx <= _LON_RANGE[1]
lat_ok = _LAT_RANGE[0] <= miny and maxy <= _LAT_RANGE[1]
if not (lon_ok and lat_ok):
raise ParcelGeometryError(
"parcel_geojson coordinates out of WGS84 lon/lat range "
f"(lon∈{_LON_RANGE}, lat∈{_LAT_RANGE}, got bounds={polygon.bounds}) — "
"coordinates appear to be projected (e.g. МСК-66/UTM metres), not WGS84 lon/lat"
)
def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transformer]:
"""Построить пару трансформеров WGS84<->метрический AEQD вокруг центроида участка.
@ -177,10 +205,16 @@ def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transform
координат и точен на масштабе квартала не нужен выбор UTM-зоны.
"""
centroid = polygon_wgs84.centroid
metric_crs = CRS.from_proj4(
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
)
try:
metric_crs = CRS.from_proj4(
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
)
except CRSError as exc:
# Belt-and-suspenders: _assert_wgs84_bounds должен был отловить это раньше,
# но на случай иной координатной патологии не даём CRSError утечь наружу
# непойманным 500 — это невалидная геометрия участка, т.е. 422.
raise ParcelGeometryError(f"cannot build metric CRS for parcel centroid: {exc}") from exc
to_metric = Transformer.from_crs(_WGS84, metric_crs, always_xy=True)
to_wgs84 = Transformer.from_crs(metric_crs, _WGS84, always_xy=True)
return to_metric, to_wgs84
@ -253,9 +287,11 @@ def parse_parcel(
"""Stage 1a: ConceptInput -> :class:`Parcel` (метрика + buildable + grid).
Raises:
ParcelGeometryError: полигон невалиден или пятно застройки вырождается.
ParcelGeometryError: полигон невалиден, координаты не в WGS84 lon/lat, или
пятно застройки вырождается.
"""
polygon_wgs84 = _parse_polygon(payload.parcel_geojson)
_assert_wgs84_bounds(polygon_wgs84)
to_metric, to_wgs84 = _metric_transformers(polygon_wgs84)
def _fwd(xs: Any, ys: Any) -> tuple[Any, Any]:

View file

@ -33,15 +33,19 @@ Wiring (отдельный PR):
from __future__ import annotations
import asyncio
import json
import logging
import re
from datetime import date
from html.parser import HTMLParser
from typing import Any
from urllib.parse import urljoin
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.scrapers.flat_plans import download_plan_image, upsert_flat_plans
from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep
logger = logging.getLogger(__name__)
@ -87,6 +91,51 @@ _STATUS_KW_RE = re.compile(
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
# ── plan-image extraction ─────────────────────────────────────────────────────
#
# Страница квартиры каталога DOM.РФ — Next.js SSR (тот же стек что и страница
# объекта, см. domrf_catalog_object.py). План квартиры рендерится через next/image,
# поэтому реальный URL картинки живёт в JSON-блоке <script id="__NEXT_DATA__">
# внутри pageProps, а НЕ в готовом <img src>. Стратегия extraction (в порядке
# приоритета, best-effort — DOM.РФ может переименовать ключи без уведомления):
#
# 1. __NEXT_DATA__ pageProps: ищем значение под одним из кандидат-ключей
# (planImage / planImageUrl / layoutImage / floorPlan / scheme / image / photo).
# 2. Fallback: bare <img>, чей src/alt/class намекают на планировку
# (plan / layout / scheme / планировк), либо <img> рядом с текстом «Планировка».
#
# URL нормализуется в абсолютный через urljoin(BASE_URL, ...): DOM.РФ отдаёт файлы
# и абсолютными (https://xn--80az8a.../api/ext/file/... — см. documents.py), и
# относительными (/_next/image?url=..., /api/ext/file/...). Мусорные картинки
# (data:-inline base64, captcha, лого застройщика, миниатюры) отсекаются.
# Ключи в pageProps, под которыми может лежать URL планировки. Проверяются по
# порядку; первое непустое строковое значение с image-like URL выигрывает.
_NEXT_PLAN_KEY_RE = re.compile(
r"(?:plan|layout|scheme|планировк|floor)",
re.IGNORECASE,
)
# Признак «это картинка» в значении: расширение файла ИЛИ file-API DOM.РФ.
_IMAGE_URL_RE = re.compile(
r"(?:\.(?:png|jpe?g|webp|svg)(?:\?|$)|/api/ext/file/|/_next/image)",
re.IGNORECASE,
)
# Токены, по которым отсекаем НЕ-планировочные картинки (лого, аватары, иконки,
# captcha, миниатюры, банеры). Проверяются по src/alt/class.
_IMAGE_NOISE_RE = re.compile(
r"(?:logo|avatar|icon|captcha|robot|sprite|banner|placeholder|thumb|"
r"favicon|badge|marker|pin)",
re.IGNORECASE,
)
# Признак «это планировка» в атрибутах <img> (src / alt / class).
_IMAGE_PLAN_HINT_RE = re.compile(
r"(?:plan|layout|scheme|планировк)",
re.IGNORECASE,
)
def _classify_status_kw(matched_text: str) -> str | None:
"""Классифицировать совпавшее ключевое слово в STATUS_*.
@ -217,8 +266,19 @@ class _TextCollector(HTMLParser):
# Список записей: (class_hint, full_text)
self.blocks: list[tuple[str, str]] = []
self._buf: list[str] = []
# Отдельный, аддитивный сбор <img>-атрибутов (issue #2440): нужен для
# extraction plan_image_url как fallback к __NEXT_DATA__. НЕ влияет на
# text-extraction — img остаётся void-тегом, фрейм в стек не пушится.
# Формат: (attrs_dict, index_в_blocks) — index привязывает картинку к
# позиции в потоке текстовых блоков для proximity-поиска «Планировка».
self.images: list[tuple[dict[str, str], int]] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag == "img":
# Собираем атрибуты img ДО early-return (void-тег): index = текущая
# длина blocks, т.е. картинка «привязана» к ближайшему предыдущему
# закрытому текстовому блоку. Text-extraction ниже не меняется.
self.images.append(({k: (v or "") for k, v in attrs}, len(self.blocks)))
if tag in self._VOID_TAGS:
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
attr_dict = {k: (v or "") for k, v in attrs}
@ -266,6 +326,134 @@ def _find_text_near(
return None
def _normalize_plan_url(url: str | None) -> str | None:
"""Нормализовать URL картинки планировки в абсолютный, отсеять мусор.
- data:-inline (base64) отбрасываем (это не удалённый файл, часто captcha/robot).
- Относительный путь ('/api/ext/file/...', '/_next/image?url=...') absolute
через urljoin(BASE_URL, ...).
- Пустое/None None.
"""
if not url:
return None
u = url.strip()
if not u or u.lower().startswith("data:"):
return None
if u.startswith("//"):
# protocol-relative → https
return "https:" + u
if u.startswith("http://") or u.startswith("https://"):
return u
return urljoin(BASE_URL + "/", u.lstrip("/"))
def _extract_plan_from_next_data(html: str) -> str | None:
"""Извлечь URL планировки из <script id="__NEXT_DATA__"> pageProps.
Страница квартиры каталога Next.js SSR: план рендерится через next/image,
URL картинки живёт в JSON-блоке. Рекурсивно обходим pageProps и ищем строковое
значение под ключом, матчащим _NEXT_PLAN_KEY_RE, чьё значение выглядит как
image-URL (_IMAGE_URL_RE) и НЕ является шумом (_IMAGE_NOISE_RE).
Возвращает первый подходящий URL (нормализованный) или None.
"""
match = re.search(
r'<script\s+id=["\']__NEXT_DATA__["\'][^>]*>(.+?)</script>',
html,
re.DOTALL,
)
if not match:
return None
try:
blob = json.loads(match.group(1).strip())
except (json.JSONDecodeError, ValueError):
return None
page_props = blob.get("props", {}).get("pageProps")
root: Any = page_props if isinstance(page_props, dict) else blob
# BFS по вложенному dict/list; ключ+значение проверяем на plan-hint.
stack: list[Any] = [root]
seen = 0
while stack and seen < 20_000: # cap: защита от патологически глубокого JSON
node = stack.pop()
seen += 1
if isinstance(node, dict):
for key, value in node.items():
if (
isinstance(value, str)
and _NEXT_PLAN_KEY_RE.search(str(key))
and _IMAGE_URL_RE.search(value)
and not _IMAGE_NOISE_RE.search(value)
):
normalized = _normalize_plan_url(value)
if normalized:
return normalized
if isinstance(value, dict | list):
stack.append(value)
elif isinstance(node, list):
stack.extend(node)
return None
def _extract_plan_from_imgs(
images: list[tuple[dict[str, str], int]], blocks: list[tuple[str, str]]
) -> str | None:
"""Fallback: выбрать <img> планировки из собранных _TextCollector.images.
Приоритет:
1. <img>, чей src/alt/class содержит plan/layout/scheme/планировк прямое
совпадение, самый надёжный сигнал.
2. <img> рядом (в потоке DOM) с текстовым блоком «Планировка».
Шумовые картинки (лого/иконки/captcha/миниатюры) отсекаются по _IMAGE_NOISE_RE,
inline data:-картинки и пустые src через _normalize_plan_url.
"""
# Уровень 1: явный plan-hint в атрибутах.
for attrs, _idx in images:
src = attrs.get("src", "")
alt = attrs.get("alt", "")
cls = attrs.get("class", "")
haystack = f"{src} {alt} {cls}"
if _IMAGE_NOISE_RE.search(haystack):
continue
if _IMAGE_PLAN_HINT_RE.search(haystack):
normalized = _normalize_plan_url(src)
if normalized:
return normalized
# Уровень 2: <img>, чей index стоит сразу после блока «Планировка».
plan_block_indices = [
i for i, (_cls, text) in enumerate(blocks) if re.search(r"планировк", text, re.IGNORECASE)
]
if plan_block_indices:
for attrs, idx in images:
src = attrs.get("src", "")
if _IMAGE_NOISE_RE.search(f"{src} {attrs.get('alt', '')} {attrs.get('class', '')}"):
continue
# картинка «принадлежит» блоку «Планировка», если её index в пределах
# ±2 текстовых блоков от лейбла.
if any(abs(idx - pbi) <= 2 for pbi in plan_block_indices):
normalized = _normalize_plan_url(src)
if normalized:
return normalized
return None
def extract_plan_image_url(html: str, collector: _TextCollector) -> str | None:
"""Извлечь URL картинки планировки из SSR-HTML страницы квартиры.
Two-strategy (issue #2440):
1. __NEXT_DATA__ pageProps (primary DOM.РФ Next.js SSR).
2. bare <img> fallback (по plan-hint атрибутам либо proximity к «Планировка»).
Возвращает абсолютный URL или None. Best-effort структура DOM.РФ хрупкая.
"""
url = _extract_plan_from_next_data(html)
if url:
return url
return _extract_plan_from_imgs(collector.images, collector.blocks)
def parse_catalog_flat(html: str) -> dict[str, Any]:
"""Извлечь поля из SSR-HTML страницы квартиры DOM.РФ.
@ -277,6 +465,7 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
- ceiling_height_m (float) Высота потолков в метрах
- section_no (int) Номер подъезда / секции
- catalog_updated_at (date) Дата обновления информации на странице
- plan_image_url (str) URL картинки планировки (issue #2440)
Парсинг хрупкий по природе (SSR HTML DOM.РФ меняется без уведомлений).
Все extraction best-effort KeyError/AttributeError обёрнуты внутри.
@ -468,6 +657,14 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
except ValueError:
pass
# ── Шаг 4: plan_image_url (issue #2440) ──────────────────────────────────
# __NEXT_DATA__ pageProps (primary) → bare <img> fallback. collector.images
# собран на Шаге 1 тем же feed(). None если план не найден (напр. проданная
# квартира без планировки на странице).
plan_url = extract_plan_image_url(html, collector)
if plan_url:
result["plan_image_url"] = plan_url
logger.debug(
"parse_catalog_flat: extracted fields=%s",
list(result.keys()),
@ -544,16 +741,63 @@ def upsert_catalog_data(
# ── Per-flat scrape orchestration ─────────────────────────────────────────────
async def _handle_plan_image(
db: Session,
session: BrowserSession,
ods_id: str,
obj_id: int,
plan_url: str,
snapshot_date: date,
outcome: dict[str, Any],
) -> None:
"""Записать URL планировки в flat_plans, скачать бинарник, дописать метаданные.
Порядок (issue #2440):
1. upsert_flat_plans с одним только plan_image_url URL зафиксирован даже
если скачивание упадёт (частичный прогресс не теряется).
2. download_plan_image reuse BrowserSession (cookies/TLS/throttle).
3. повторный upsert_flat_plans с local_path/размерами COALESCE внутри
upsert защищает уже скачанные файлы от перезаписи.
Мутирует outcome['plan_downloaded']. Ошибки логируются внутри download/upsert,
наружу не пробрасываются планировка не должна ронять catalog-scrape квартиры.
"""
plan_row: dict[str, Any] = {
"ods_id": ods_id,
"obj_id": obj_id,
"plan_image_url": plan_url,
}
upsert_flat_plans(db, obj_id, [plan_row], snapshot_date)
meta = await download_plan_image(session, plan_url, ods_id, obj_id)
if meta:
outcome["plan_downloaded"] = True
upsert_flat_plans(db, obj_id, [{**plan_row, **meta}], snapshot_date)
async def scrape_one_flat(
session: BrowserSession,
db: Session,
ods_id: str,
catalog_url_hash: str,
obj_id: int | None = None,
snapshot_date: date | None = None,
) -> dict[str, Any]:
"""Scrape одной квартиры: fetch HTML → parse → upsert.
"""Scrape одной квартиры: fetch HTML → parse → upsert (+ планировка).
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated}.
Ошибки fetch/parse логируются, не бросаются caller обрабатывает результат.
Помимо catalog-полей (price/status/finishing/...) извлекает plan_image_url
(issue #2440): пишет URL в domrf_kn_flat_plans через upsert_flat_plans, затем
скачивает бинарник (download_plan_image) и повторным upsert'ом дописывает
local_path/размеры (COALESCE-защита уже скачанных файлов внутри upsert).
Скачивание inline в рамках того же BrowserSession: reuse cookies/TLS,
download_binary несёт свой лёгкий throttle (jitter 200500 мс), отдельная
beat-задача не заводится (требование #2440). obj_id/snapshot_date нужны для
записи в flat_plans; если не переданы plan-upsert пропускается с warning.
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated,
plan_found, plan_downloaded}. Ошибки fetch/parse/download логируются, не
бросаются caller обрабатывает результат.
"""
outcome: dict[str, Any] = {
"ods_id": ods_id,
@ -561,6 +805,8 @@ async def scrape_one_flat(
"success": False,
"fields_extracted": 0,
"updated": False,
"plan_found": False,
"plan_downloaded": False,
"error": None,
}
@ -590,6 +836,19 @@ async def scrape_one_flat(
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
# ── plan_image_url → domrf_kn_flat_plans (issue #2440) ───────────────────
plan_url = data.get("plan_image_url")
if plan_url:
outcome["plan_found"] = True
if obj_id is not None and snapshot_date is not None:
await _handle_plan_image(db, session, ods_id, obj_id, plan_url, snapshot_date, outcome)
else:
logger.warning(
"catalog scrape ods_id=%s: plan_image_url found but obj_id/snapshot_date "
"missing — flat_plans upsert skipped",
ods_id,
)
# success отражает прохождение пайплайна fetch+parse без исключения; реально
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
@ -617,11 +876,12 @@ async def scrape_catalog_batch(
region_code: int = 66,
headed: bool = False,
load_state: str | None = None,
snapshot_date: date | None = None,
) -> dict[str, Any]:
"""Scrape пачки квартир каталога DOM.РФ.
`flats` список dict'ов с ключами {ods_id, catalog_url_hash}.
Типовой источник: SELECT ods_id, catalog_url_hash FROM domrf_kn_flats
`flats` список dict'ов с ключами {ods_id, catalog_url_hash [, obj_id]}.
Типовой источник: SELECT ods_id, obj_id, catalog_url_hash FROM domrf_kn_flats
WHERE catalog_url_hash IS NOT NULL
AND (catalog_updated_at IS NULL OR catalog_updated_at < NOW() - INTERVAL '30 days')
LIMIT :batch_size.
@ -629,18 +889,25 @@ async def scrape_catalog_batch(
Использует один BrowserSession на весь пакет (bootstrapped 1 раз).
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
obj_id/snapshot_date нужны для записи планировки в domrf_kn_flat_plans (issue
#2440). snapshot_date по умолчанию = сегодня (дата первичного обнаружения URL).
Returns:
{total, success, updated, failed, fields_total}
{total, success, updated, failed, fields_total, plans_found, plans_downloaded}
- success: прошли fetch+parse без исключения
- updated: реально затронули строку в БД (issue #1610) — отражает
фактическое число записанных квартир, в отличие от success
- plans_found / plans_downloaded: планировки найдены / скачаны (#2440)
"""
snap = snapshot_date or date.today()
stats: dict[str, Any] = {
"total": len(flats),
"success": 0,
"updated": 0,
"failed": 0,
"fields_total": 0,
"plans_found": 0,
"plans_downloaded": 0,
}
if not flats:
@ -648,9 +915,10 @@ async def scrape_catalog_batch(
return stats
logger.info(
"scrape_catalog_batch: starting %d flats region=%d",
"scrape_catalog_batch: starting %d flats region=%d snapshot_date=%s",
len(flats),
region_code,
snap,
)
async with BrowserSession(
@ -659,21 +927,42 @@ async def scrape_catalog_batch(
load_state=load_state,
# auth=None — страницы каталога публичные, Basic auth не нужен
auth=None,
# #2445 D2 anti-ban: тот же /сервисы/* path family, что вызвал DOM.РФ WAF
# hard-ban 2026-05-24 (#2443). Без явного override BrowserSession тихо
# наследует модульный дефолт stealth.py (concurrency=8, jitter 600-1500ms).
# Переиспользуем throttled настройки, принятые scrape_kn.py после #1945
# (settings.scrape_kn_* — общий anti-ban рычаг, НЕ специфичный для KN-sweep,
# несмотря на имя).
concurrency=settings.scrape_kn_browser_concurrency,
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
) as session:
# Warm-up: WAF DOM.РФ (2026-05-24) требует session cookies для SSR-страниц.
# Зеркалит scrape_catalog_objects. Idempotent на весь batch.
await session.warm_up()
for flat in flats:
ods_id = flat.get("ods_id", "")
catalog_url_hash = flat.get("catalog_url_hash", "")
obj_id_raw = flat.get("obj_id")
obj_id = int(obj_id_raw) if obj_id_raw is not None else None
if not ods_id or not catalog_url_hash:
logger.warning("scrape_catalog_batch: skip flat with missing ods_id/hash: %r", flat)
stats["failed"] += 1
continue
outcome = await scrape_one_flat(session, db, ods_id, catalog_url_hash)
outcome = await scrape_one_flat(
session, db, ods_id, catalog_url_hash, obj_id=obj_id, snapshot_date=snap
)
if outcome["success"]:
stats["success"] += 1
stats["fields_total"] += outcome["fields_extracted"]
if outcome["updated"]:
stats["updated"] += 1
if outcome.get("plan_found"):
stats["plans_found"] += 1
if outcome.get("plan_downloaded"):
stats["plans_downloaded"] += 1
else:
stats["failed"] += 1
@ -689,11 +978,14 @@ async def scrape_catalog_batch(
raise
logger.info(
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d "
"fields_total=%d plans_found=%d plans_downloaded=%d",
stats["total"],
stats["success"],
stats["updated"],
stats["failed"],
stats["fields_total"],
stats["plans_found"],
stats["plans_downloaded"],
)
return stats

View file

@ -24,6 +24,7 @@ from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep
logger = logging.getLogger(__name__)
@ -440,6 +441,16 @@ async def scrape_catalog_objects(
region_code=region_code,
# Страницы каталога публичные — Basic auth не нужен
auth=None,
# #2445 D2 anti-ban: этот scraper ходит по тому же /сервисы/* path family,
# что вызвал DOM.РФ WAF hard-ban 2026-05-24 (#2443). Без явного override
# BrowserSession тихо наследует модульный дефолт stealth.py (concurrency=8,
# jitter 600-1500ms) — тот самый throttle-less профиль, что и раньше
# триггерил volume-ban. Переиспользуем throttled настройки, принятые
# scrape_kn.py после #1945 (settings.scrape_kn_* — общий anti-ban рычаг,
# НЕ специфичный для KN-sweep, несмотря на имя).
concurrency=settings.scrape_kn_browser_concurrency,
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
) as session:
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies.
# DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих cookies.

View file

@ -432,6 +432,14 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8]
flat_id = int.from_bytes(digest, "big") % (2**63 - 1)
# catalog_url_hash — тот же elemId (hash-часть URL квартиры в каталоге DOM.РФ):
# https://наш.дом.рф/сервисы/каталог-квартир/квартира/{catalog_url_hash}.
# Читаем ЗДЕСЬ (а не только в fallback-ветке flat_id выше) — при наличии flatId
# ветка fallback не выполняется и elem остаётся непрочитанным. Пустую строку
# нормализуем в None: `or None` зеркалит трактовку `if elem:` в fallback-ветке
# и общий null-handling соседних optional-полей (_g отдаёт None при отсутствии).
catalog_url_hash = _g(row, "elemId") or None
price_rub = _g(row, "price")
price_per_m2 = _g(row, "pricePerSquareMeter")
total_area = _g(row, "totalArea")
@ -476,7 +484,10 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
"ceiling_height_m": None, # заполнит catalog scraper 22d
"key_handover_dt": None, # заполнит catalog scraper 22d
"catalog_updated_at": None, # заполнит catalog scraper 22d
"catalog_url_hash": None, # заполнит catalog scraper 22d
# catalog_url_hash приходит из kn-API (elemId) уже здесь — нужен catalog
# scraper'у (22d) для построения URL страницы квартиры. Ранее хардкодился
# в None, из-за чего flat-catalog scraper не мог выбрать ни одной строки.
"catalog_url_hash": catalog_url_hash,
}
@ -524,14 +535,29 @@ UPSERT_OBJECT_SQL = text(
:snapshot_date
)
ON CONFLICT (obj_id, snapshot_date) DO UPDATE SET
-- #2464 cluster E: hobj_id/short_addr/dev_inn/region_cd/floor_min/floor_max/
-- problem_flag/green_house были в INSERT (populated every call by
-- _norm_object) но пропущены в DO UPDATE SET re-run скрапера для
-- уже существующего (obj_id, snapshot_date) оставлял эти 8 колонок stale.
-- Все 8 direct EXCLUDED (не COALESCE): freshly computed каждый вызов,
-- в т.ч. problem_flag должен ОБНУЛЯТЬСЯ, если проблема на объекте снята
-- (COALESCE зафиксировал бы устаревший "проблемный" статус навсегда).
hobj_id = EXCLUDED.hobj_id,
comm_name = EXCLUDED.comm_name,
addr = EXCLUDED.addr,
short_addr = EXCLUDED.short_addr,
region_cd = EXCLUDED.region_cd,
dev_id = EXCLUDED.dev_id,
dev_name = EXCLUDED.dev_name,
dev_inn = EXCLUDED.dev_inn,
floor_min = EXCLUDED.floor_min,
floor_max = EXCLUDED.floor_max,
flat_count = EXCLUDED.flat_count,
square_living = EXCLUDED.square_living,
ready_dt = EXCLUDED.ready_dt,
problem_flag = EXCLUDED.problem_flag,
site_status = EXCLUDED.site_status,
green_house = EXCLUDED.green_house,
escrow = EXCLUDED.escrow,
obj_class = COALESCE(EXCLUDED.obj_class, domrf_kn_objects.obj_class),
wall_type = COALESCE(EXCLUDED.wall_type, domrf_kn_objects.wall_type),
@ -910,6 +936,9 @@ UPSERT_PHOTO_SQL = text(
:local_path, :thumb_path, :downloaded_at
)
ON CONFLICT (obj_id, obj_file_id) DO UPDATE SET
-- #2464 cluster F: build_type в INSERT column list (populated every call)
-- но пропущен здесь re-run скрапера оставлял build_type stale.
build_type = EXCLUDED.build_type,
ord_num = EXCLUDED.ord_num,
photo_url = EXCLUDED.photo_url,
photo_dttm = EXCLUDED.photo_dttm,

View file

@ -33,15 +33,36 @@ Issue #297 sub-task 22c.
from __future__ import annotations
import io
import logging
from datetime import date
from typing import Any
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
from sqlalchemy import text
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from datetime import date
from app.services.scrapers.stealth import BrowserSession
logger = logging.getLogger(__name__)
# Базовая директория для скачанных бинарников планировок. Зеркалит конвенцию
# domrf_kn.py:PHOTOS_DIR_DEFAULT (data/raw/domrf_photos) — тот же raw-корень,
# отдельная папка. Файлы: {base}/{obj_id}/{safe_ods_id}.{ext}.
FLAT_PLANS_DIR_DEFAULT = Path("data/raw/domrf_flat_plans")
# Разрешённые расширения картинок планировок. png — дефолт (DOM.РФ отдаёт планы
# растром), svg на всякий случай (векторные схемы встречаются).
_ALLOWED_PLAN_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".svg")
# Замена небезопасных для имени файла символов в ods_id (напр. "65136/1/1.4.3").
_ODS_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
# ── payload parsing ───────────────────────────────────────────────────────────
@ -212,30 +233,131 @@ def upsert_flat_plans(
return inserted
# ── download stub ─────────────────────────────────────────────────────────────
# ── image download ────────────────────────────────────────────────────────────
def download_plan_image_stub(
def _safe_ods_filename(ods_id: str) -> str:
"""Превратить ods_id (напр. '65136/1/1.4.3') в безопасное имя файла.
Слэши и прочие небезопасные символы '_'. Результат используется как
basename в {base_dir}/{obj_id}/{safe}.{ext}.
"""
return _ODS_UNSAFE_RE.sub("_", ods_id).strip("_") or "plan"
def _ext_from_url(url: str) -> str:
"""Определить расширение файла по URL планировки. Default .png.
DOM.РФ отдаёт планы преимущественно PNG; расширение берём из path, если
оно в белом списке, иначе .png (file-API URL /api/ext/file/... часто без
расширения контент-тип уточнится по факту, но png безопасный дефолт).
"""
path = urlsplit(url).path.lower()
for ext in _ALLOWED_PLAN_EXTS:
if path.endswith(ext):
return ".jpg" if ext == ".jpeg" else ext
return ".png"
def _image_dims(data: bytes) -> tuple[int | None, int | None]:
"""Получить (width_px, height_px) картинки через Pillow. None при ошибке.
Pillow (PIL) уже зависимость репо (pyproject: pillow>=10.4.0, используется в
app.services.photos.thumbs). SVG Pillow не парсит вернёт (None, None),
это ОК (размеры для векторных планов необязательны).
"""
try:
from PIL import Image
with Image.open(io.BytesIO(data)) as im:
return int(im.width), int(im.height)
except Exception as exc:
logger.debug("flat plan image dims parse failed: %s", exc)
return None, None
async def download_plan_image(
session: BrowserSession,
plan_image_url: str,
ods_id: str,
dest_dir: str | None = None,
) -> str:
"""Заглушка для скачивания бинарника планировки.
obj_id: int,
base_dir: Path | None = None,
) -> dict[str, Any] | None:
"""Скачать бинарник планировки квартиры и вернуть метаданные для upsert.
Реальная реализация отдельный Celery task (22d-track, issue #299).
До реализации бросает NotImplementedError, чтобы случайный вызов
не прошёл незаметно.
Реиспользует BrowserSession.download_binary тот же паттерн что download_photos
в domrf_kn.py: тянет файл через browser context (те же cookies / TLS-fingerprint,
что прошли WAF), с лёгким throttle (jitter 200500 мс встроен в download_binary).
Файл сохраняется в {base_dir}/{obj_id}/{safe_ods_id}.{ext}. Если файл уже есть
на диске повторно НЕ качаем (idempotent, экономит WAF-бюджет), но всё равно
возвращаем метаданные (размеры/размер читаются с диска).
Args:
plan_image_url: URL картинки планировки.
session: Активный BrowserSession (bootstrapped + warm_up).
plan_image_url: Абсолютный URL картинки планировки.
ods_id: Идентификатор квартиры (для имени файла).
dest_dir: Директория для сохранения (None = MEDIA_ROOT/flat_plans/).
obj_id: ID объекта DOM.РФ (подпапка).
base_dir: Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT).
Raises:
NotImplementedError: всегда до реализации.
Returns:
dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at
для передачи в upsert_flat_plans. None при ошибке скачивания.
"""
raise NotImplementedError(
"download_plan_image_stub: скачивание планировок не реализовано. "
"Реальный downloader — отдельный Celery task (issue #299, 22d-track). "
f"URL={plan_image_url!r} ods_id={ods_id!r} dest_dir={dest_dir!r}"
root = base_dir or FLAT_PLANS_DIR_DEFAULT
obj_dir = root / str(obj_id)
ext = _ext_from_url(plan_image_url)
local = obj_dir / f"{_safe_ods_filename(ods_id)}{ext}"
# Idempotent: уже скачан — читаем метаданные с диска, не жжём WAF-запрос.
if local.exists() and local.stat().st_size > 0:
data = local.read_bytes()
width_px, height_px = _image_dims(data)
logger.debug("flat plan already on disk ods_id=%s path=%s", ods_id, local)
return {
"local_path": str(local),
"width_px": width_px,
"height_px": height_px,
"size_bytes": local.stat().st_size,
"downloaded_at": datetime.now(UTC),
}
try:
data = await session.download_binary(plan_image_url)
except Exception as exc:
logger.warning(
"download_plan_image failed ods_id=%s obj_id=%s url=%s: %s",
ods_id,
obj_id,
plan_image_url,
exc,
)
return None
if not data:
logger.warning("download_plan_image empty body ods_id=%s url=%s", ods_id, plan_image_url)
return None
obj_dir.mkdir(parents=True, exist_ok=True)
try:
local.write_bytes(data)
except OSError as exc:
logger.warning("download_plan_image write failed ods_id=%s path=%s: %s", ods_id, local, exc)
return None
width_px, height_px = _image_dims(data)
logger.info(
"download_plan_image ok ods_id=%s obj_id=%s bytes=%d dims=%sx%s",
ods_id,
obj_id,
len(data),
width_px,
height_px,
)
return {
"local_path": str(local),
"width_px": width_px,
"height_px": height_px,
"size_bytes": len(data),
"downloaded_at": datetime.now(UTC),
}

View file

@ -19,12 +19,21 @@ searchMap отдаёт в properties **только {id}** — название/
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
WAF-hygiene (#2445 item D3): sibling-скрапер без backoff словил бан IP по WAF
DOM.РФ 24.05.2026 (#2443) — okn-mk.mkrf.ru имеет тот же risk shape (гос. CMS,
десятки последовательных запросов без паузы). Идиома пэйсинга/backoff/ретраев
портирована из ``gisogd66.py`` (``_RETRY_BACKOFF_S`` + сон между попытками) и
``objective.py`` (явная обработка 429/Retry-After): rate-limit между запросами
(``_wait_rate_limit``), экспоненциальный backoff на 429/5xx с капом
(``_do_request``/``_do_detail_request``), уважение заголовка Retry-After.
"""
from __future__ import annotations
import logging
import re
import time
import urllib.parse
from typing import Any
@ -42,6 +51,47 @@ _DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show"
_REQUEST_TIMEOUT = 30.0
_DETAIL_TIMEOUT = 10.0
# ── WAF-hygiene: пэйсинг + backoff (#2445 item D3) ────────────────────────────
# Вежливая пауза между последовательными запросами (мс) — тот же порядок величины,
# что и у objective.py (_DEFAULT_RATE_MS=500) / nspd_client.py (DEFAULT_RATE_MS=600).
_RATE_LIMIT_MS = 400
# Ретраи на 429/5xx (helper делает суммарно до 1 + _MAX_RETRIES попыток).
_MAX_RETRIES = 3
# Экспоненциальный backoff на 429/5xx: base * 2**attempt, капается _MAX_BACKOFF_S
# (аналогично objective.py: 30 * 2**attempt, cap 300s — здесь эндпоинт легче,
# поэтому база/потолок меньше, но идиома та же).
_RETRY_BACKOFF_BASE_S = 2.0
_MAX_BACKOFF_S = 60.0
# process-локальная метка времени последнего запроса (обеих эндпоинтов) —
# rate-limit общий, чтобы 33 list-запроса и до 1500 detail-запросов не долбили
# WAF без пауз даже при чередовании вызовов.
_last_request_ts: float = 0.0
def _wait_rate_limit() -> None:
"""Выдержать паузу ``_RATE_LIMIT_MS`` с последнего запроса к okn-mk.mkrf.ru.
Общий для searchMap и detail обе цели один и тот же WAF-хост.
"""
global _last_request_ts
elapsed_ms = (time.time() - _last_request_ts) * 1000.0
if elapsed_ms < _RATE_LIMIT_MS:
time.sleep((_RATE_LIMIT_MS - elapsed_ms) / 1000.0)
_last_request_ts = time.time()
def _backoff_seconds(attempt: int, retry_after_hdr: str | None) -> float:
"""Секунды ожидания перед повтором после 429/5xx.
Retry-After (если валиден) уважается первым, но капается _MAX_BACKOFF_S
аналогично objective.py. Иначе экспоненциальный backoff от attempt (0-based).
"""
if retry_after_hdr and retry_after_hdr.isdigit():
return min(float(retry_after_hdr), _MAX_BACKOFF_S)
return min(_RETRY_BACKOFF_BASE_S * (2**attempt), _MAX_BACKOFF_S)
# Browser headers -- required: without User-Agent the server returns 403.
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его
# автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется.
@ -99,7 +149,7 @@ def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]:
Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0].
"""
form_data = _build_form_data(address, category_type)
features = _do_request(form_data, verify=True)
features = _do_request(form_data, verify=True, attempt=0)
return features
@ -123,19 +173,37 @@ def _do_request(
form_data: list[tuple[str, str]],
*,
verify: bool,
attempt: int = 0,
) -> list[dict[str, Any]]:
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False."""
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False.
WAF-hygiene: rate-limit перед каждой попыткой (``_wait_rate_limit``), на 429/5xx
экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз (аналогично objective.py).
"""
# urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами
# (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple]
# конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает
# «expected bytes, tuple found». content= передаёт тело как raw str.
body = urllib.parse.urlencode(form_data)
_wait_rate_limit()
try:
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client:
resp = client.post(
_SEARCHMAP_URL,
content=body,
)
if resp.status_code == 429 or resp.status_code >= 500:
if attempt < _MAX_RETRIES:
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
logger.warning(
"okn_egrkn_client: HTTP %d от ЕГРКН (попытка %d/%d), retry через %.1fs",
resp.status_code,
attempt + 1,
_MAX_RETRIES,
wait,
)
time.sleep(wait)
return _do_request(form_data, verify=verify, attempt=attempt + 1)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc)
@ -145,7 +213,7 @@ def _do_request(
logger.warning(
"okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc
)
return _do_request(form_data, verify=False)
return _do_request(form_data, verify=False, attempt=attempt)
logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc)
return []
except Exception as exc:
@ -275,17 +343,36 @@ def fetch_okn_detail(source_id: str) -> dict[str, Any]:
"""
if not source_id:
return {}
return _do_detail_request(source_id, verify=True)
return _do_detail_request(source_id, verify=True, attempt=0)
def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap."""
def _do_detail_request(source_id: str, *, verify: bool, attempt: int = 0) -> dict[str, Any]:
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap.
WAF-hygiene: rate-limit перед каждой попыткой (общий с searchMap один WAF-хост),
на 429/5xx экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз.
"""
params = {"id": source_id}
_wait_rate_limit()
try:
with httpx.Client(
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
) as client:
resp = client.get(_DETAIL_URL, params=params)
if resp.status_code == 429 or resp.status_code >= 500:
if attempt < _MAX_RETRIES:
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
logger.warning(
"okn_egrkn_client: detail HTTP %d source_id=%s "
"(попытка %d/%d), retry через %.1fs",
resp.status_code,
source_id,
attempt + 1,
_MAX_RETRIES,
wait,
)
time.sleep(wait)
return _do_detail_request(source_id, verify=verify, attempt=attempt + 1)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.warning(
@ -299,7 +386,7 @@ def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
logger.warning(
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
)
return _do_detail_request(source_id, verify=False)
return _do_detail_request(source_id, verify=False, attempt=attempt)
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
return {}
except Exception as exc:

View file

@ -5,6 +5,12 @@ HTML server-rendered (нет JS-рендеринга), httpx достаточн
TLS: сайт использует российский УЦ verify=False (аналогично ekburg_permits #242).
Politeness (#2445 D4): пауза ``_RATE_S`` перед каждым запросом (search / карточка
документа / PDF) тот же идиом, что nspd_client/objective/gisogd66 (300-500мс). Этот
VPS уже словил WAF-бан от DOM.РФ 24.05.2026 (#2443) за непейсованные последовательные
запросы; pravo.gov66.ru сайт той же формы (гос-портал region, строгий WAF). Ретрай с
backoff на 429/5xx идиом gisogd66._get_json.
Публичный API:
search_documents(query, law_type, since) list[dict]
get_document_pdf_url(doc_id) str | None
@ -15,6 +21,7 @@ from __future__ import annotations
import logging
import re
import time
from typing import Any
import httpx
@ -28,6 +35,14 @@ _SEARCH_URL = f"{_BASE_URL}/search/"
# Тайм-аут щедрый: сервер иногда медленный.
_TIMEOUT = httpx.Timeout(60.0, connect=15.0)
# Politeness pacing (#2445 D4): пауза ПЕРЕД каждым запросом к pravo.gov66.ru — тот же
# идиом (300-500мс), что nspd_client/objective/gisogd66. Не непейсованный WAF-риск,
# аналогичный DOM.РФ-бану #2443.
_RATE_S = 0.4
# Ретраи на 429/5xx: экспоненциальный backoff, суммарно до 1 + _MAX_RETRIES попыток.
_MAX_RETRIES = 2
_RETRY_BACKOFF_S = 2.0
# Числовые коды типов документов (select#id_law_type на сайте).
# Текстовое значение «Постановление» — неверный параметр, сервер не матчит → 0 хитов.
_LAW_TYPE_POSTANOVLENIE = "1" # Постановление Правительства Свердловской области
@ -41,6 +56,43 @@ _RE_PDF_PATH = re.compile(r"/media/pravo/(?!ecp/)[^\s\"']+\.pdf", re.IGNORECASE)
_RE_PDF_ECP_PATH = re.compile(r"/media/pravo/ecp/[^\s\"']+\.pdf", re.IGNORECASE)
def _get_with_backoff(
client: httpx.Client,
url: str,
*,
params: dict[str, str] | None = None,
) -> httpx.Response:
"""GET с politeness-паузой перед запросом + backoff-ретраем на 429/5xx.
Пауза ``_RATE_S`` ПЕРЕД каждой попыткой (в т.ч. первой): пейсинг
последовательных запросов к pravo.gov66.ru, не только ретраев (#2445 D4).
429/5xx ретраятся до ``_MAX_RETRIES`` раз с экспоненциальным backoff;
прочие статусы (включая 4xx кроме 429) сразу ``raise_for_status()``.
"""
last_exc: Exception | None = None
for attempt in range(_MAX_RETRIES + 1):
time.sleep(_RATE_S)
resp = client.get(url, params=params)
if resp.status_code == 429 or resp.status_code >= 500:
last_exc = httpx.HTTPStatusError(
f"{resp.status_code} on {url}", request=resp.request, response=resp
)
if attempt >= _MAX_RETRIES:
raise last_exc
logger.warning(
"pravo_gov66_client: %s (попытка %d) на %s — backoff",
resp.status_code,
attempt + 1,
url,
)
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
continue
resp.raise_for_status()
return resp
assert last_exc is not None
raise last_exc
def search_documents(
query: str,
law_type: str = _LAW_TYPE_POSTANOVLENIE,
@ -65,8 +117,7 @@ def search_documents(
try:
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
resp = client.get(_SEARCH_URL, params=params)
resp.raise_for_status()
resp = _get_with_backoff(client, _SEARCH_URL, params=params)
html = resp.text
except Exception as exc:
logger.error("pravo_gov66_client.search_documents: ошибка сети q=%r: %s", query, exc)
@ -117,8 +168,7 @@ def get_document_pdf_url(doc_id: str) -> str | None:
doc_url = f"{_BASE_URL}/{doc_id}/"
try:
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
resp = client.get(doc_url)
resp.raise_for_status()
resp = _get_with_backoff(client, doc_url)
html = resp.text
except Exception as exc:
logger.error(
@ -151,6 +201,10 @@ def _extract_pdf_url(html: str) -> str | None:
def fetch_pdf(url: str) -> bytes:
"""Загружает PDF по URL.
Пейсинг + backoff на 429/5xx через ``_get_with_backoff`` (#2445 D4) — caller
(reservation_ingest) НЕ должен добавлять свой sleep перед этим вызовом, пауза
уже внутри клиента.
Args:
url: абсолютный URL PDF-файла.
@ -161,8 +215,7 @@ def fetch_pdf(url: str) -> bytes:
httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully).
"""
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
resp = _get_with_backoff(client, url)
return resp.content

View file

@ -33,9 +33,11 @@ USER_AGENT = (
# Максимум одновременных in-page fetch() на одну BrowserSession;
# поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant.
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется catalog/nspd/прочими скраперами. KN-sweep с
# 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2.
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется nspd/прочими скраперами вне /сервисы/* path
# family. KN-sweep с 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2; после
# WAF hard-ban 2026-05-24 (#2443) domrf_catalog.py / domrf_catalog_object.py — тот же
# /сервисы/* path family — тоже переопределяют на throttled настройки (#2445 D2).
_BROWSER_CONCURRENCY = 8
# Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через

View file

@ -971,28 +971,31 @@ def _fetch_supply_only_layouts(
Returns:
Список SupplyOnlyLayoutRow (может быть пустым). Ошибка SQL не роняет ответ
логируется, откатывается транзакция, возвращается [] (блок аддитивен).
логируется, savepoint откатывается, возвращается [] (блок аддитивен).
"""
if not project_names:
return []
try:
rows = (
db.execute(
_SUPPLY_ONLY_LOTS_SQL,
{
"names": project_names,
"premise_kind": _SUPPLY_ONLY_PREMISE_KIND,
"price_min": _SUPPLY_ONLY_PRICE_MIN,
"price_max": _SUPPLY_ONLY_PRICE_MAX,
},
# SAVEPOINT — db shared с caller (GET .../best-layouts / §22-отчёт,
# Depends(get_db)). Голый db.rollback() тут НЕЛЬЗЯ: он откатил бы ВСЮ outer
# tx (всю несохранённую работу этого запроса) и orphan'ит outer
# SessionTransaction (см. velocity.py:170). begin_nested откатывает ТОЛЬКО
# этот savepoint — outer tx остаётся usable, блок аддитивно деградирует в [].
with db.begin_nested():
rows = (
db.execute(
_SUPPLY_ONLY_LOTS_SQL,
{
"names": project_names,
"premise_kind": _SUPPLY_ONLY_PREMISE_KIND,
"price_min": _SUPPLY_ONLY_PRICE_MIN,
"price_max": _SUPPLY_ONLY_PRICE_MAX,
},
)
.mappings()
.all()
)
.mappings()
.all()
)
except Exception:
# Аддитивный блок — не роняем весь ответ. rollback: следующего execute нет,
# но транзакция в aborted-state сломала бы commit/дальнейшую работу caller'а.
db.rollback()
logger.exception(
"best_layouts: supply-only lots query failed for %d projects — empty block",
len(project_names),
@ -1120,28 +1123,37 @@ def get_best_layouts(
# больше не теряет сделки, привязанные к 1-й через mapping).
core_group_to_project: dict[str, str] = {}
try:
corpus_names = [str(row[0]) for row in db.execute(_OBJECTIVE_PROJECT_NAMES_SQL).all()]
lots_names = [str(row[0]) for row in db.execute(_OBJECTIVE_LOTS_PROJECT_NAMES_SQL).all()]
project_names = _merged_project_name_pool(corpus_names, lots_names)
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
preliminary = match_groups_to_projects(complex_groups, project_names, None)
candidate_names = sorted(set(preliminary.values()))
developers_by_project: dict[str, list[str]] = {}
if candidate_names:
developers_by_project = {
str(row[0]): [str(d) for d in (row[1] or []) if d]
for row in db.execute(_OBJECTIVE_DEVELOPERS_SQL, {"names": candidate_names}).all()
}
core_group_to_project = match_groups_to_projects(
complex_groups, project_names, developers_by_project
)
# SAVEPOINT — db shared с caller (GET .../best-layouts / §22-отчёт,
# Depends(get_db)). Три db.execute здесь перемежаются с ЧИСТО-Python
# match_groups_to_projects / _merged_project_name_pool (без db) → оборачиваем
# весь блок одним savepoint. Голый db.rollback() НЕЛЬЗЯ: он откатил бы ВСЮ
# outer tx + orphan'ит outer SessionTransaction (см. velocity.py:170).
# begin_nested откатывает только этот savepoint → следующий execute (velocity
# SQL, Step 3) не упадёт InFailedSqlTransaction, «fallback на mapping-only»
# состоится.
with db.begin_nested():
corpus_names = [str(row[0]) for row in db.execute(_OBJECTIVE_PROJECT_NAMES_SQL).all()]
lots_names = [
str(row[0]) for row in db.execute(_OBJECTIVE_LOTS_PROJECT_NAMES_SQL).all()
]
project_names = _merged_project_name_pool(corpus_names, lots_names)
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
preliminary = match_groups_to_projects(complex_groups, project_names, None)
candidate_names = sorted(set(preliminary.values()))
developers_by_project: dict[str, list[str]] = {}
if candidate_names:
developers_by_project = {
str(row[0]): [str(d) for d in (row[1] or []) if d]
for row in db.execute(
_OBJECTIVE_DEVELOPERS_SQL, {"names": candidate_names}
).all()
}
core_group_to_project = match_groups_to_projects(
complex_groups, project_names, developers_by_project
)
except Exception:
# Не роняем весь ответ — core-attribution аддитивна к mapping-источнику.
# rollback: при реальной DB-ошибке транзакция в aborted-state — без отката
# следующий execute (velocity SQL) упал бы InFailedSqlTransaction, и
# «fallback на mapping-only» не состоялся бы.
db.rollback()
logger.exception(
"best_layouts: core-attribution failed for cad_num=%s — fallback to mapping-only",
cad_num,
@ -1204,18 +1216,24 @@ def get_best_layouts(
# 0 мес» / «% продано —». Per-object latest snapshot встроен в _SUPPLY_BATCH_SQL
# (flats_latest CTE) → отдельный pre-compute MAX больше не нужен.
try:
supply_rows = (
db.execute(
_SUPPLY_BATCH_SQL,
{
"center_lon": center_lon,
"center_lat": center_lat,
"radius_m": radius_m,
},
# SAVEPOINT — db shared с caller (GET .../best-layouts, Depends(get_db), sync
# get_best_layouts мостится через run_in_threadpool). Голый db.rollback() тут
# НЕЛЬЗЯ — orphan'ит outer SessionTransaction (см. velocity.py:170); begin_nested
# откатывает только эту savepoint, оставляя db usable для Step 9b
# (_fetch_supply_only_layouts) на этой же сессии.
with db.begin_nested():
supply_rows = (
db.execute(
_SUPPLY_BATCH_SQL,
{
"center_lon": center_lon,
"center_lat": center_lat,
"radius_m": radius_m,
},
)
.mappings()
.all()
)
.mappings()
.all()
)
except Exception:
logger.warning("best_layouts: supply query failed, supply=0 fallback")
supply_rows = []

View file

@ -449,16 +449,42 @@ _COMPETITORS_SQL = text("""
# scraper UPSERT per snapshot). Без фильтра snapshot_date AVG усреднял
# ИСТОРИЮ цен (stale на растущем рынке). Берём ТОЛЬКО latest snapshot — зеркало
# паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON.
#
# === PER-OBJECT snapshot fix (#2445 A5, byte-identical bug к #1956 в best_layouts) ===
# domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в СВОЙ день,
# UNIQUE-снимок (obj_id, snapshot_date), см. migration 174_domrf_kn_flats_obj_
# snapshot_idx.sql. Прежний фильтр брал ОДНУ глобальную дату
# `f.snapshot_date = MAX(snapshot_date по всей таблице)` — на этой единственной
# глобал-max дате присутствует обычно лишь 1 объект (скрейпнутый последним) →
# у ВСЕХ остальных конкурентов в радиусе 0 строк flats → avg_price_per_m2=NULL
# → _price_similarity(None, ...) молча возвращает нейтраль 0.5, тихо деградируя
# relevance-ранжирование конкурентов без единого флага.
# Фикс (зеркало best_layouts.flats_latest, #1956): flats_latest берёт для КАЖДОГО
# obj_id его СОБСТВЕННЫЙ последний снимок (DISTINCT ON (f.obj_id) ... ORDER BY
# f.obj_id, snapshot_date DESC, id DESC), затем self-join к domrf_kn_flats по
# (obj_id, snapshot_date) достаёт квартиры именно этого снимка. AVG считается
# per-object на СВЕЖЕЙ дате объекта, а не на единственной глобал-max дате.
# Слоение с OBJ-3 (#307) не меняется: этот запрос остаётся ПЕРВЫМ источником
# (domrf), _OBJECTIVE_PRICE_FALLBACK_SQL — additive fallback ТОЛЬКО для obj_id,
# у которых и после фикса нет domrf-цены (см. get_competitors шаг 4a). Ожидаемый
# эффект: domrf возвращает реальные цены для БОЛЬШЕ конкурентов → fallback нужен
# реже, но композиция (missing_price_ids / price_source) не меняется.
_AVG_PRICE_SQL = text("""
WITH flats_latest AS (
SELECT DISTINCT ON (f.obj_id)
f.obj_id, f.snapshot_date
FROM domrf_kn_flats f
WHERE f.obj_id = ANY(:obj_ids)
ORDER BY f.obj_id, f.snapshot_date DESC, f.id DESC
)
SELECT
f.obj_id,
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
AS avg_price_per_m2
FROM domrf_kn_flats f
WHERE f.obj_id = ANY(:obj_ids)
AND f.snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_flats
)
FROM flats_latest fl
JOIN domrf_kn_flats f
ON f.obj_id = fl.obj_id
AND f.snapshot_date = fl.snapshot_date
GROUP BY f.obj_id
""")
@ -773,7 +799,12 @@ def get_competitors(
avg_price_map: dict[int, float] = {}
price_source_map: dict[int, str] = {}
try:
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
# SAVEPOINT — db shared с caller (POST .../competitors, Depends(get_db)). Голый
# db.rollback() orphan'ит outer SessionTransaction (velocity.py:170) — тем более
# здесь ЕЩЁ ДВА db.execute идут следом в этой же функции (sold-count,
# objective-fallback) на том же db; без savepoint сбой здесь отравил бы их тоже.
with db.begin_nested():
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
for r in price_rows:
oid = int(r["obj_id"])
price = _row_get(r, "avg_price_per_m2")
@ -792,11 +823,16 @@ def get_competitors(
# flats_sold → None → нейтральный stage.
sold_count_map: dict[int, int] = {}
try:
sold_rows = (
db.execute(_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND})
.mappings()
.all()
)
# SAVEPOINT — db shared, см. avg_price блок выше (иначе отравляет objective
# price fallback ниже на том же db).
with db.begin_nested():
sold_rows = (
db.execute(
_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND}
)
.mappings()
.all()
)
for r in sold_rows:
oid = int(r["obj_id"])
sold = _row_get(r, "flats_sold")
@ -812,17 +848,21 @@ def get_competitors(
missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map]
if missing_price_ids:
try:
obj_price_rows = (
db.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL,
{
"obj_ids": missing_price_ids,
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
# SAVEPOINT — db shared, см. avg_price блок выше. Последний из трёх
# db.execute в get_competitors — тоже под savepoint, чтобы дальнейшие
# запросы analyze на этом же db (persist_analysis_run и т.п.) не отравились.
with db.begin_nested():
obj_price_rows = (
db.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL,
{
"obj_ids": missing_price_ids,
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
)
.mappings()
.all()
)
.mappings()
.all()
)
for r in obj_price_rows:
oid = int(r["obj_id"])
price = _row_get(r, "median_price_per_m2")

View file

@ -260,25 +260,29 @@ def _query_gas_city_grs(db: Session) -> dict:
"""
outlet_counts = _query_gas_outlet_counts(db)
try:
rows = (
db.execute(
text("""
SELECT grs_name,
SUM(design_capacity_th_m3_h) AS design_sum,
SUM(free_capacity_th_m3_h) AS free_sum,
AVG(free_capacity_pct) AS pct_avg,
MAX(upgrade_due) AS upgrade_due,
COUNT(*) AS outputs_count
FROM gas_grs_capacity
WHERE grs_name ILIKE '%свердловск%'
OR grs_name ILIKE '%екатеринбург%'
GROUP BY grs_name
ORDER BY grs_name
""")
# SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)).
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, outer tx остаётся usable.
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT grs_name,
SUM(design_capacity_th_m3_h) AS design_sum,
SUM(free_capacity_th_m3_h) AS free_sum,
AVG(free_capacity_pct) AS pct_avg,
MAX(upgrade_due) AS upgrade_due,
COUNT(*) AS outputs_count
FROM gas_grs_capacity
WHERE grs_name ILIKE '%свердловск%'
OR grs_name ILIKE '%екатеринбург%'
GROUP BY grs_name
ORDER BY grs_name
""")
)
.mappings()
.all()
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 181 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
@ -333,25 +337,27 @@ def _query_gas_outlet_counts(db: Session) -> dict:
Таблицы может не быть (миграция 184 не применена) все нули (graceful, аддитивно).
"""
try:
row = (
db.execute(
text("""
SELECT COUNT(*) AS outlets_total,
COUNT(*) FILTER (
WHERE free_capacity_mln_m3 < 0
) AS outlets_deficit,
COUNT(*) FILTER (
WHERE free_capacity_needs_calc
) AS outlets_needs_calc
FROM gas_grs_outlet_points
WHERE period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
""")
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
row = (
db.execute(
text("""
SELECT COUNT(*) AS outlets_total,
COUNT(*) FILTER (
WHERE free_capacity_mln_m3 < 0
) AS outlets_deficit,
COUNT(*) FILTER (
WHERE free_capacity_needs_calc
) AS outlets_needs_calc
FROM gas_grs_outlet_points
WHERE period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
""")
)
.mappings()
.first()
)
.mappings()
.first()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
@ -384,39 +390,41 @@ def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
distance_m, lat, lon}] отсортировано по distance ASC.
"""
try:
rows = (
db.execute(
text("""
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
free_capacity_needs_calc AS needs_calc,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM gas_grs_outlet_points
WHERE geom IS NOT NULL
AND period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT CAST(:limit AS int)
"""),
{
"wkt": parcel_wkt,
"radius_m": _GAS_OUTLET_RADIUS_M,
"limit": _GAS_OUTLET_LIMIT,
},
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
free_capacity_needs_calc AS needs_calc,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM gas_grs_outlet_points
WHERE geom IS NOT NULL
AND period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT CAST(:limit AS int)
"""),
{
"wkt": parcel_wkt,
"radius_m": _GAS_OUTLET_RADIUS_M,
"limit": _GAS_OUTLET_LIMIT,
},
)
.mappings()
.all()
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
@ -451,23 +459,25 @@ def _query_heat_latest(db: Session) -> dict:
"total_reserve_gcal_h": float|None}.
"""
try:
rows = (
db.execute(
text("""
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
FROM heat_system_reserves h
WHERE h.period IS NOT NULL
AND h.period = (
SELECT MAX(h2.period) FROM heat_system_reserves h2
WHERE h2.period IS NOT NULL
AND h2.org = h.org
)
ORDER BY h.org, h.system_name
""")
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
FROM heat_system_reserves h
WHERE h.period IS NOT NULL
AND h.period = (
SELECT MAX(h2.period) FROM heat_system_reserves h2
WHERE h2.period IS NOT NULL
AND h2.org = h.org
)
ORDER BY h.org, h.system_name
""")
)
.mappings()
.all()
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 182 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.

View file

@ -251,14 +251,19 @@ def get_overlaps_for_scoring(
"""
_parcel_cad = parcel_cad or ""
try:
rows = (
db.execute(
text(_OVERLAPS_SQL),
{"wkt": parcel_geom_wkt, "user_id": user_id, "parcel_cad": _parcel_cad},
# SAVEPOINT — db shared с caller (POST .../analyze, Depends(get_db)). Голый
# db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, оставляя db usable для
# остальных запросов analyze_parcel на этой же сессии.
with db.begin_nested():
rows = (
db.execute(
text(_OVERLAPS_SQL),
{"wkt": parcel_geom_wkt, "user_id": user_id, "parcel_cad": _parcel_cad},
)
.mappings()
.all()
)
.mappings()
.all()
)
return [
{
"id": int(r["id"]),

View file

@ -142,14 +142,22 @@ def get_developer_attribution(
- БД-ошибке, включая ещё не задеплоенную миграцию 149 (analyze не падает).
"""
try:
rows = (
db.execute(
_DEVELOPER_FOR_PARCEL_SQL,
{"cad_num": cad_num, "radius": radius_m},
# SAVEPOINT — db получена от caller (shared request-scoped Session,
# analyze_parcel в parcels.py). Голый db.rollback() здесь НЕЛЬЗЯ — он
# orphan'ит outer SessionTransaction и роняет persist_analysis_run сразу
# после (см. velocity.py:170 / connection_capacity_lookup._query_nearby_
# network_zones). Ошибка внутри begin_nested() откатывает ТОЛЬКО эту
# savepoint до propagate — outer-транзакция остаётся usable для
# последующих db.execute (#2464 cluster A finding 1).
with db.begin_nested():
rows = (
db.execute(
_DEVELOPER_FOR_PARCEL_SQL,
{"cad_num": cad_num, "radius": radius_m},
)
.mappings()
.all()
)
.mappings()
.all()
)
except (OperationalError, ProgrammingError, DataError) as exc:
# Миграция 149 ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning(

View file

@ -68,12 +68,22 @@ def _admin_names(db: Session) -> frozenset[str]:
На ошибке БД возвращает пустой set (logged) резолвер тогда трактует ЛЮБОЙ
вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): резолвер
зовётся §22-форсайт-билдерами (sales_series `_query_source_a/_b`,
`_query_artificial_demand`) на ОБЩЕЙ сессии отчёта, которые сами обёрнуты во
внешние SAVEPOINT'ы. БЕЗ `db.begin_nested():` здесь сбойный `db.execute`
проглатывается ( frozenset()), оставляя транзакцию aborted; тогда внешний
SAVEPOINT при RELEASE падает (RELEASE запрещён в aborted-tx) и отравление
каскадит. Savepoint в точке перехвата откатывает сбой (ROLLBACK TO SAVEPOINT),
сессия остаётся рабочей.
"""
global _ADMIN_NAMES_CACHE
if _ADMIN_NAMES_CACHE is not None:
return _ADMIN_NAMES_CACHE
try:
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
with db.begin_nested():
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
_ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
except Exception:
# Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова.
@ -122,8 +132,13 @@ def resolve_objective_districts(db: Session, district: str | None) -> list[str]
return [district]
# district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
# SAVEPOINT на внутреннем swallow-сайте (#2464 cluster A, RELEASE-trap): см.
# `_admin_names` — тот же общий-сессионный вызов из §22-билдеров под внешним
# SAVEPOINT. `db.begin_nested():` откатывает сбой в точке перехвата, чтобы
# внешний RELEASE не упал в aborted-tx (RELEASE запрещён в aborted).
try:
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
with db.begin_nested():
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
micros = [str(m) for m in rows]
except Exception:
logger.exception(

View file

@ -74,6 +74,7 @@ def _future_supply_key(
"""
return (district, horizon_months, premise_kind)
Confidence = Literal["high", "medium", "low"]
# Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД),
@ -439,7 +440,12 @@ def _query_supply_rows(db: Session, params: Mapping[str, Any]) -> Sequence[Mappi
(worker ещё не наполнил м.125) или сбой БД не должны валить расчёт индекса.
"""
try:
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
# SAVEPOINT — db shared с caller (compute_future_supply_pressure в цепочке
# §22-форсайта / analyze). Голый db.rollback() orphan'ит outer SessionTransaction
# (см. velocity.py:170) — begin_nested откатывает только эту savepoint, оставляя
# db usable для последующих db.execute на этой же сессии.
with db.begin_nested():
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
except Exception:
logger.exception(
"future_supply: supply-layers view query failed (district=%s)",

View file

@ -81,20 +81,22 @@ def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
[] при отсутствии пересечений или если таблица пуста/не существует.
"""
try:
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
return [
{
"num_oks_krt": row.num_oks_krt,
"status": row.status,
"full_name": row.full_name,
"area": float(row.area) if row.area is not None else None,
}
for row in rows
]
with db.begin_nested():
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
except Exception as exc: # таблица не существует / DB-сбой → graceful
logger.warning("ird_analyze: _krt_at DB query failed: %s", exc)
return []
return [
{
"num_oks_krt": row.num_oks_krt,
"status": row.status,
"full_name": row.full_name,
"area": float(row.area) if row.area is not None else None,
}
for row in rows
]
def _zone_regulation_at(
db: Session, client: EKBGeoportalClient, lon: float, lat: float

View file

@ -130,7 +130,8 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
return []
try:
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
with db.begin_nested():
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_krt_requisites: таблица недоступна, skip: %s", exc)
return []
@ -173,9 +174,10 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
# Нет exact-матча — geom-only поиск по КРТ-топонимам
try:
no_match_rows = (
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
)
with db.begin_nested():
no_match_rows = (
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
)
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_krt_requisites: no-match fallback недоступен: %s", exc)
return []

View file

@ -263,9 +263,7 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
# ── competition + raw demand-velocity ← market_metrics (ОДИН вызов, два сигнала) ─
try:
metrics = compute_market_metrics(
db, district=district, window_months=_MARKET_WINDOW_MONTHS
)
metrics = compute_market_metrics(db, district=district, window_months=_MARKET_WINDOW_MONTHS)
# competition ← overstock_index (доступное/затоваренное конкурирующее
# предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с
# velocity и инвертирован относительно «свободной конкуренции»).
@ -300,6 +298,15 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
logger.exception(
"location_indices: infra (POI) failed (district=%s) → infra None", district
)
# Plain rollback OK here (unlike begin_nested at the pass-2 upsert loop below):
# compute_location_indices только вызывается из refresh_locations, единственный
# caller которой — Celery-таск location_refresh.py, открывающий СВОЙ SessionLocal()
# (owned session, не shared request/report-scoped). На момент этого блока (pass 1,
# read-only) в транзакции ещё нет незакоммиченных write'ов — откатывать нечего,
# rollback() не сиротит внешний SessionTransaction (в отличие от velocity.py:170).
# Без rollback аборченная Postgres-транзакция валит СЛЕДУЮЩИЙ район в цикле pass 1
# (см. #2464) — как и все db.execute() в pass 2 upsert.
db.rollback()
logger.info(
"location_indices: district=%s infra=%s competition=%s raw_velocity=%s future_supply=%s",

View file

@ -613,7 +613,13 @@ def compute_market_metrics(
def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
"""Текущий сток. На ошибке/пустых данных → все счётчики 0 (graceful)."""
try:
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
# / analyze). Голый db.rollback() orphan'ит outer SessionTransaction (см.
# velocity.py:170) — begin_nested откатывает только эту savepoint, outer tx
# остаётся usable для последующих db.execute (_query_sales_window/_price_sensitivity)
# на этой же сессии.
with db.begin_nested():
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
except Exception:
logger.exception(
"market_metrics: stock query failed (districts=%s)", params.get("districts")
@ -650,7 +656,12 @@ def _query_sales_window(
затирать прежние счётчики. На ошибке/пусто (0, 0.0, {}).
"""
try:
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
# SAVEPOINT — db shared с caller (см. _query_stock выше). CRITICAL: без него
# сбой здесь оставляет транзакцию aborted, и следующий db.execute в этой же
# цепочке (_price_sensitivity → _elasticity_coef) падает "current transaction
# is aborted" вместо честного graceful-fallback.
with db.begin_nested():
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
except Exception:
logger.exception(
"market_metrics: sales-window query failed (districts=%s)", params.get("districts")
@ -700,14 +711,19 @@ def _price_sensitivity(
micros = resolve_objective_districts(db, district)
elasticity_window = max(window_months, 24)
try:
elast = _elasticity_coef(
db,
region_code=_EKB_REGION_CODE,
district_name=district,
target_class=None,
elasticity_window_months=elasticity_window,
districts=micros if micros is not None else [],
)
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
# / analyze). _elasticity_coef делает свой db.execute внутри без try/except —
# оборачиваем ВЕСЬ вызов, чтобы сбой откатывал только эту savepoint (не outer
# SessionTransaction, см. velocity.py:170), и db оставалась usable дальше.
with db.begin_nested():
elast = _elasticity_coef(
db,
region_code=_EKB_REGION_CODE,
district_name=district,
target_class=None,
elasticity_window_months=elasticity_window,
districts=micros if micros is not None else [],
)
except Exception:
logger.exception(
"market_metrics: elasticity reuse failed (district=%s micros=%s)",
@ -877,7 +893,12 @@ def compute_offer_price_trend(
"price_max": _OFFER_PRICE_MAX,
}
try:
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
# SAVEPOINT — db shared с caller (POST .../analyze / §22-отчёт, Depends(get_db)).
# 8с-гвард (parcels.py) может отменить этот запрос (QueryCanceled) → без savepoint
# транзакция остаётся aborted и следующий db.execute падает "current transaction
# is aborted". begin_nested откатывает только этот savepoint, db остаётся usable.
with db.begin_nested():
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
except Exception as exc:
# Ожидаемый путь: 8с-гвард (parcels.py) отменяет запрос на холодном кэше
# (~2/сутки, профилирование 2026-07-04) — блок аддитивный, analyze успешен.

View file

@ -74,10 +74,11 @@ def parcel_okn_objects(
if not parcel_wkt:
return []
try:
rows = db.execute(
_OKN_DWITHIN_SQL,
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
).fetchall()
with db.begin_nested():
rows = db.execute(
_OKN_DWITHIN_SQL,
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
).fetchall()
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_okn_objects: okn_objects недоступна, skip: %s", exc)
return []

View file

@ -169,7 +169,12 @@ def _query_current(db: Session) -> list[OwnProject]:
)
return []
try:
rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
# SAVEPOINT — db shared с caller (get_own_portfolio в цепочке §25.3 / analyze).
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, оставляя db usable для
# _query_future (следующий вызов на этой же сессии в get_own_portfolio).
with db.begin_nested():
rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
except Exception:
logger.exception("own_portfolio: current (domrf) query failed → []")
return []
@ -200,7 +205,11 @@ def _query_future(db: Session) -> list[OwnProject]:
Сбой/отсутствие таблицы (cold-start до миграции 148) [] (НЕ crash).
"""
try:
rows = db.execute(_FUTURE_SQL).mappings().all()
# SAVEPOINT — db shared с caller (см. _query_current выше). begin_nested
# откатывает только эту savepoint (НЕ голый db.rollback(), см. velocity.py:170),
# оставляя db usable для остальных потребителей той же сессии (§25.3 / analyze).
with db.begin_nested():
rows = db.execute(_FUTURE_SQL).mappings().all()
except Exception:
logger.exception("own_portfolio: future (own_planned_project) query failed → []")
return []

View file

@ -45,7 +45,12 @@ def parcel_pat_subzones(db: Session, parcel_wkt: str | None) -> list[dict[str, A
return []
try:
rows = db.execute(_PAT_INTERSECTS_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
# SAVEPOINT — db shared с caller (analyze_parcel, shared request-scoped Session).
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, не мешая persist_analysis_run
# и прочим db.execute дальше по /analyze (#2464 cluster A finding 7).
with db.begin_nested():
rows = db.execute(_PAT_INTERSECTS_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / структура изменилась — graceful degrade.
logger.warning("parcel_pat_subzones: pat_subzones недоступна, skip: %s", exc)

View file

@ -109,7 +109,8 @@ def parcel_ppt_tep(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
if not parcel_wkt:
return []
try:
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
with db.begin_nested():
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning("parcel_ppt_tep: ekb_ppt_tep / planning_projects недоступна, skip: %s", exc)

View file

@ -137,11 +137,12 @@ def resolve_cad_for_domrf(
cad_num search).
"""
try:
row = (
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
.mappings()
.first()
)
with db.begin_nested():
row = (
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
.mappings()
.first()
)
except Exception:
# SQL/PostGIS-сбой — не наш профиль данных. Логируем и отдаём None
# (один конкурент без parking_ratio лучше, чем 500 на весь lazy-endpoint).

View file

@ -355,31 +355,32 @@ def _get_zoning(
if layer_counts is not None and layer_counts.get("territorial_zones_count", 1) == 0:
return None
try:
row = db.execute(
text(
"""
SELECT feat.value->'properties' AS zone_props
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'territorial_zones'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
with db.begin_nested():
row = db.execute(
text(
"""
SELECT feat.value->'properties' AS zone_props
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'territorial_zones'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
4326
),
ST_Centroid(ST_GeomFromText(:wkt, 4326))
)
LIMIT 1
"""
),
{"q": quarter, "wkt": parcel_wkt},
).first()
ST_Centroid(ST_GeomFromText(:wkt, 4326))
)
LIMIT 1
"""
),
{"q": quarter, "wkt": parcel_wkt},
).first()
if row is None:
return None
@ -453,45 +454,46 @@ def _get_zouit_overlaps(
# geography для корректных м². Делитель — площадь участка; guard от деления на
# ноль через NULLIF(...,0) → NULL → coverage_pct None в Python. Zone-4326
# выражение совпадает с ST_Intersects ниже (transform 3857→4326).
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
4326
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'zouit_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'zouit_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
4326
),
4326
),
ST_GeomFromText(:wkt, 4326)
)
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
ST_GeomFromText(:wkt, 4326)
)
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -547,31 +549,32 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
# схлопывает в одну, оставляя наименьший id (ORDER BY reg_numb_border, id).
# reg_numb_border — TEXT NOT NULL (0 NULL на проде), так что рег-номер есть у
# каждой строки и дедуп идёт по реальному идентификатору зоны.
rows = db.execute(
text(
"""
SELECT DISTINCT ON (reg_numb_border)
type_zone,
category_name,
name_by_doc AS name,
reg_numb_border,
id AS zouit_id,
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson,
ST_Area(
ST_Intersection(
CAST(geom AS geometry),
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY reg_numb_border, id
"""
),
{"wkt": parcel_wkt},
).fetchall()
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT DISTINCT ON (reg_numb_border)
type_zone,
category_name,
name_by_doc AS name,
reg_numb_border,
id AS zouit_id,
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson,
ST_Area(
ST_Intersection(
CAST(geom AS geometry),
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY reg_numb_border, id
"""
),
{"wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -634,11 +637,28 @@ def _get_engineering_nearby(
if layer_counts is not None and layer_counts.get("engineering_count", 1) == 0:
return []
try:
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
ST_Distance(
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'engineering_structures'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -646,31 +666,15 @@ def _get_engineering_nearby(
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'engineering_structures'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 20
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _ENGINEERING_RADIUS_M},
).fetchall()
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 20
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _ENGINEERING_RADIUS_M},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -750,53 +754,54 @@ def _get_risk_zones(
if layer_counts is not None and layer_counts.get("risks_count", 1) == 0:
return []
try:
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Area(
ST_Intersection(
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
)
) AS intersection_area_sqm
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'risk_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
)
) AS geom_wkt,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
)
) AS intersection_area_sqm
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'risk_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
4326
),
ST_GeomFromText(:wkt, 4326)
)
ORDER BY layer, intersection_area_sqm DESC NULLS LAST
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
ST_GeomFromText(:wkt, 4326)
)
ORDER BY layer, intersection_area_sqm DESC NULLS LAST
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -870,21 +875,38 @@ def _get_opportunity_parcels(
if layer_counts is not None and layer_counts.get("opportunity_count", 1) == 0:
return []
try:
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Distance(
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'opportunity_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -892,31 +914,15 @@ def _get_opportunity_parcels(
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'opportunity_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 30
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M},
).fetchall()
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 30
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -974,30 +980,20 @@ def _get_red_lines(
if layer_counts is not None and layer_counts.get("red_lines_count", 1) == 0:
return []
try:
rows = db.execute(
text(
"""
SELECT ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
ST_GeomFromText(:wkt, 4326)
) AS does_intersect,
ST_Length(
ST_Intersection(
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -1006,9 +1002,36 @@ def _get_red_lines(
4326
),
ST_GeomFromText(:wkt, 4326)
)::geography
) AS intersection_length_m,
ST_Distance(
) AS does_intersect,
ST_Length(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
ST_GeomFromText(:wkt, 4326)
)::geography
) AS intersection_length_m,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'red_lines'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -1016,31 +1039,15 @@ def _get_red_lines(
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'red_lines'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography,
:nearby_m
)
ORDER BY distance_m ASC
LIMIT 50
"""
),
{"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M},
).fetchall()
ST_GeomFromText(:wkt, 4326)::geography,
:nearby_m
)
ORDER BY distance_m ASC
LIMIT 50
"""
),
{"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -1138,6 +1145,8 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
"in_protection_zone": False,
"protection_zones_intersecting": 0,
"total_structures_in_radius": 0,
"protection_zones_truncated": False,
"structures_truncated": False,
},
"dump_available": False,
"dump_fetched_at": fetched_at_iso,
@ -1166,20 +1175,25 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
_trigger_harvest(quarter)
return _empty_unavailable(dump_fetched_at)
structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m)
zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
structures, structures_total = _get_engineering_structures_by_boundary(
db, quarter, parcel_wkt, radius_m
)
zouit_overlaps, zouit_total = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
nearest_dist: float | None = structures[0]["distance_to_boundary_m"] if structures else None
protection_count = len(zouit_overlaps)
return {
"engineering_structures": structures,
"zouit_engineering_overlaps": zouit_overlaps,
"summary": {
"nearest_structure_distance_m": nearest_dist,
"in_protection_zone": protection_count > 0,
"protection_zones_intersecting": protection_count,
"total_structures_in_radius": len(structures),
"in_protection_zone": zouit_total > 0,
# Epic #2445 A1: истинные счётчики по ПОЛНОЙ выборке (COUNT(*) без LIMIT),
# НЕ len(list) — раньше тихо капались на 100/50 (LIMIT в SQL row-fetch).
"protection_zones_intersecting": zouit_total,
"total_structures_in_radius": structures_total,
"protection_zones_truncated": zouit_total > len(zouit_overlaps),
"structures_truncated": structures_total > len(structures),
},
"dump_available": True,
"dump_fetched_at": dump_fetched_at,
@ -1227,36 +1241,12 @@ def _get_parcel_wkt(db: Session, cad_num: str) -> str | None:
return None
def _get_engineering_structures_by_boundary(
db: Session,
quarter: str,
parcel_wkt: str,
radius_m: int,
) -> list[dict[str, Any]]:
"""Engineering structures из dump в radius_m от boundary участка.
_ENGINEERING_STRUCTURES_ITEMS_LIMIT = 50
Использует ST_Distance к boundary (не centroid) для корректного расстояния.
Geometry трансформируется из EPSG:3857 (хранение dump) 4326.
"""
try:
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
# Условие радиус-выборки engineering_structures — используется И в row-fetch (с ORDER BY +
# LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на 50, но
# summary.total_structures_in_radius обязан считаться по ВСЕЙ выборке, не по len(list)).
_ENGINEERING_STRUCTURES_WHERE = """
AND feat.value->>'layer' = 'engineering_structures'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
@ -1271,12 +1261,67 @@ def _get_engineering_structures_by_boundary(
ST_GeomFromText(:wkt, 4326)::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 50
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m},
).fetchall()
"""
def _get_engineering_structures_by_boundary(
db: Session,
quarter: str,
parcel_wkt: str,
radius_m: int,
) -> tuple[list[dict[str, Any]], int]:
"""Engineering structures из dump в radius_m от boundary участка.
Использует ST_Distance к boundary (не centroid) для корректного расстояния.
Geometry трансформируется из EPSG:3857 (хранение dump) 4326.
Returns:
(items, total_count) items ограничен _ENGINEERING_STRUCTURES_ITEMS_LIMIT
(ближайшие), total_count ЧЕСТНЫЙ COUNT(*) по всей радиус-выборке (epic #2445
A1: раньше summary.total_structures_in_radius = len(items) тихо капался на 50).
"""
params = {"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m}
try:
with db.begin_nested():
total_count = (
db.execute(
text(
"SELECT COUNT(*)"
" FROM nspd_quarter_dumps d,"
" jsonb_array_elements(d.features_json) AS feat(value)"
" WHERE d.quarter_cad = :q" + _ENGINEERING_STRUCTURES_WHERE
),
params,
).scalar()
or 0
)
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q"""
+ _ENGINEERING_STRUCTURES_WHERE
+ """
ORDER BY distance_m ASC
LIMIT :items_limit
"""
),
{**params, "items_limit": _ENGINEERING_STRUCTURES_ITEMS_LIMIT},
).fetchall()
except (OperationalError, ProgrammingError, DataError) as e:
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
logger.warning(
@ -1284,7 +1329,7 @@ def _get_engineering_structures_by_boundary(
quarter,
e,
)
return []
return [], 0
result: list[dict[str, Any]] = []
for r in rows:
@ -1349,7 +1394,7 @@ def _get_engineering_structures_by_boundary(
"source": "nspd_36328",
}
)
return result
return result, int(total_count)
def _clean_struct_str(value: Any) -> str | None:
@ -1390,24 +1435,12 @@ def _build_structure_characteristics(opts: dict[str, Any]) -> str | None:
return " · ".join(parts) if parts else None
def _get_zouit_engineering_overlaps(
db: Session,
quarter: str,
parcel_wkt: str,
) -> list[dict[str, Any]]:
"""ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок.
_ZOUIT_ENGINEERING_ITEMS_LIMIT = 100
Использует слой 'zouit_engineering' из dump.
"""
try:
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
# Условие пересечения zouit_engineering с участком — используется И в row-fetch (с
# ORDER BY + LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на
# 100, но summary.protection_zones_intersecting обязан считаться по ВСЕЙ выборке).
_ZOUIT_ENGINEERING_WHERE = """
AND feat.value->>'layer' = 'zouit_engineering'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
@ -1421,11 +1454,58 @@ def _get_zouit_engineering_overlaps(
),
ST_GeomFromText(:wkt, 4326)
)
LIMIT 100
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
"""
def _get_zouit_engineering_overlaps(
db: Session,
quarter: str,
parcel_wkt: str,
) -> tuple[list[dict[str, Any]], int]:
"""ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок.
Использует слой 'zouit_engineering' из dump.
Returns:
(items, total_count) items ограничен _ZOUIT_ENGINEERING_ITEMS_LIMIT,
детерминированно отсортирован (epic #2445 A1: раньше не было ORDER BY перед
LIMIT 100 недетерминированные 100 строк на каждый запрос). total_count
ЧЕСТНЫЙ COUNT(*) по всей intersects-выборке (раньше summary.
protection_zones_intersecting = len(items) тихо капался на 100).
"""
params = {"q": quarter, "wkt": parcel_wkt}
try:
with db.begin_nested():
total_count = (
db.execute(
text(
"SELECT COUNT(*)"
" FROM nspd_quarter_dumps d,"
" jsonb_array_elements(d.features_json) AS feat(value)"
" WHERE d.quarter_cad = :q" + _ZOUIT_ENGINEERING_WHERE
),
params,
).scalar()
or 0
)
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q"""
+ _ZOUIT_ENGINEERING_WHERE
+ """
ORDER BY feat.value->'properties'->>'reg_numb_border' NULLS LAST,
feat.value->>'geometry'
LIMIT :items_limit
"""
),
{**params, "items_limit": _ZOUIT_ENGINEERING_ITEMS_LIMIT},
).fetchall()
except (OperationalError, ProgrammingError, DataError) as e:
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
logger.warning(
@ -1433,7 +1513,7 @@ def _get_zouit_engineering_overlaps(
quarter,
e,
)
return []
return [], 0
result: list[dict[str, Any]] = []
for r in rows:
@ -1466,7 +1546,7 @@ def _get_zouit_engineering_overlaps(
"source": "nspd_37578",
}
)
return result
return result, int(total_count)
# ── Harvest trigger ───────────────────────────────────────────────────────────

View file

@ -99,7 +99,8 @@ def parcel_riasurt_gate(
return dict(RIASURT_EMPTY_GATE)
try:
rows = db.execute(_RIASURT_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
with db.begin_nested():
rows = db.execute(_RIASURT_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError, DataError) as exc:
logger.warning("parcel_riasurt_gate: таблица/запрос недоступны, skip: %s", exc)
return dict(RIASURT_EMPTY_GATE)

View file

@ -237,7 +237,15 @@ def compute_district_saturation(db: Session, district_name: str) -> dict[str, An
psycopg v3: bind через CAST(:dn AS text). Read-only.
"""
try:
row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first()
# SAVEPOINT — db shared с caller (analyze_parcel, parcels.py). Caller УЖЕ
# оборачивает вызов этой функции в свой db.begin_nested(), но это НЕ спасает:
# если db.execute здесь падает и мы её же тут глотаем (return None, не re-raise),
# исключение никогда не долетает до caller-savepoint → её ROLLBACK TO SAVEPOINT
# никогда не срабатывает, а сам Postgres-connection уже aborted. Нужна СОБСТВЕННАЯ
# savepoint здесь — откатывается на выходе из `with` ДО того, как исключение
# поймано ниже, оставляя outer-транзакцию (caller'а) usable.
with db.begin_nested():
row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first()
except Exception:
logger.exception("saturation: query failed (district=%s)", district_name)
return None

View file

@ -46,6 +46,9 @@ Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics): пус
м.108-audit). Поэтому при free_flats IS NULL hidden НЕ вычислим (flat_count в одиночку
бессмыслен). Такие объекты ИСКЛЮЧАЮТСЯ из L2 (preferred path из ТЗ чище, чем
выдуманный sold-proxy) coverage снижается confidence падает с medium до low.
Тот же exclusion И при flat_count IS NULL (#2445-A3: заполняется независимо от
free_flats, м.124) иначе GREATEST(0, COALESCE(flat_count,0) free_flats) молча
давал 0 «скрытых» для объекта с генуинно неизвестным flat_count, раздувая coverage.
L2 district-resolution: используем domrf_kn_objects.district_name НАПРЯМУЮ (м.57
spatial-join по lat/long в полигоны ekb_districts_geom). Это самый прямой путь
БЕЗ complexes/complex_sources join (которые сами строятся из этой же колонки, м.73).
@ -244,6 +247,21 @@ _L1_OPEN_SQL = text(
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
#
# #2445-A3 (audit): flat_count ТОЖЕ nullable и заполняется НЕЗАВИСИМО от free_flats
# (м.124 — "NULL = не заполнено"). Раньше фильтр гейтил ТОЛЬКО free_flats IS NOT NULL:
# при flat_count IS NULL (частично забэкфилленный снапшот) GREATEST(0, COALESCE(flat_count,
# 0) - free_flats) = GREATEST(0, -free_flats) = 0 — объект молча давал 0 hidden units,
# ХОТЯ его истинный hidden НЕИЗВЕСТЕН (не 0), И при этом всё равно попадал в
# n_with_free_flats → раздувал coverage-деноминатор (denominator inflation). Зеркалит баг,
# которого уже избегает pure-хелпер _hidden_units() (возвращает None при flat_count IS
# NULL) — но тот хелпер не вызывается из этого SQL-пути (только из тестов), поэтому баг
# жил в проде. Fix: гейтим ОБА (COUNT-деноминатор и SUM) на
# flat_count IS NOT NULL AND free_flats IS NOT NULL — объект с любым из двух NULL
# полностью исключается (как из числителя coverage, так и из hidden_units), а не
# засчитывается как "0 скрытых". Сиблинг-конвенция: 60_v_zk_rosreestr_velocity.sql /
# 64_v_zk_rosreestr_velocity.sql уже гейтят `o.flat_count IS NOT NULL AND o.flat_count > 0`
# на этой же таблице.
#
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
@ -268,10 +286,14 @@ _L2_HIDDEN_SQL = text(
SELECT
district_name,
COUNT(*) AS n_objects_total,
COUNT(*) FILTER (WHERE free_flats IS NOT NULL) AS n_with_free_flats,
COUNT(*) FILTER (
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
) AS n_with_free_flats,
COALESCE(SUM(
GREATEST(0, COALESCE(flat_count, 0) - free_flats)
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
GREATEST(0, flat_count - free_flats)
) FILTER (
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
), 0) AS hidden_units
FROM latest
WHERE ready_dt IS NULL
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
@ -397,8 +419,11 @@ def compute_layer2_hidden(
Per dom.рф-объект hidden = max(0, flat_count free_flats); Σ к району. ОБЕ колонки
на одной строке domrf_kn_objects НЕ нужен domrfobjective маппинг (см. docstring
модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки не вычислить;
documented limitation) снижают coverage confidence mediumlow.
модуля). Объекты с free_flats IS NULL ИЛИ flat_count IS NULL полностью исключаются
(#2445-A3: обе колонки nullable и заполняются независимо — объект с известным
free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой
объект НЕ считается «покрытым» и не входит в coverage-числитель) снижают coverage
confidence mediumlow.
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
units_estimate всегда 0 (clamp). Пустые данные []. Не бросает.
@ -413,11 +438,16 @@ def compute_layer2_hidden(
out: list[SupplyLayerRow] = []
for r in rows:
n_total = int(r["n_objects_total"] or 0)
# n_with_free_flats (SQL) = объекты, где ОБЕ free_flats и flat_count известны
# (#2445-A3) — это единственные объекты, для которых hidden вообще вычислим.
# Имя колонки оставлено как есть (ripple в SQL/тестах/logger); семантика теперь
# "покрыто и вычислимо", не только "free_flats заполнен".
n_with = int(r["n_with_free_flats"] or 0)
# hidden_units приходит из SQL уже клампленным (GREATEST(0, ...)); страхуемся.
hidden = max(0, int(r["hidden_units"] or 0))
if n_with <= 0:
# Нет ни одного объекта с free_flats → нечего объявлять скрытым (а не «0»).
# Нет ни одного объекта с ВЫЧИСЛИМЫМ hidden (оба flat_count и free_flats
# известны) → нечего объявлять скрытым (а не «0»).
continue
confidence = _l2_confidence(n_total, n_with)
out.append(
@ -434,7 +464,7 @@ def compute_layer2_hidden(
confidence=confidence,
method=(
f"domrf_kn_objects: Σ max(0, flat_count free_flats); "
f"coverage {n_with}/{n_total} objects with free_flats"
f"coverage {n_with}/{n_total} objects with flat_count & free_flats known"
),
snapshot_date=snapshot_date,
)
@ -540,9 +570,18 @@ def _safe_rows(
Зеркало market_metrics._query_* тонкие данные/сбой БД не должны валить расчёт
остальных слоёв или worker'а целиком.
SAVEPOINT (``db.begin_nested()``): ``compute_all_layers`` вызывает эту функцию 3×
(L1/L2/L3) на ОДНОЙ Session без savepoint сбой L1 отравил бы транзакцию и L2/L3
молча получили бы [] тоже (плюс любой db.execute ПОСЛЕ compute_all_layers на этом же
db, включая caller'а — worker owns SessionLocal(), но при вызове из shared
request-scoped db, напр. forecasting/orchestrator.build_site_finder_report,
отравился бы весь оставшийся запрос). Голый db.rollback() здесь НЕЛЬЗЯ см.
velocity.py:170.
"""
try:
return db.execute(sql, dict(params)).mappings().all()
with db.begin_nested():
return db.execute(sql, dict(params)).mappings().all()
except Exception:
# L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
district_dbg = params.get("district", params.get("districts"))

View file

@ -657,9 +657,30 @@ def get_nearby_utility_infrastructure(
if parcel_wkt is None:
raise ValueError(f"Участок {cad_num!r} не найден в БД")
# Условие радиус-выборки — общее для честного COUNT(*) (без LIMIT) и row-fetch
# (с ORDER BY + LIMIT). Epic #2445 A2: summary.total_features раньше = len(features),
# тихо капался на `limit` (default 200) даже когда в радиусе фич больше.
where_clause = """
WHERE ST_DWithin(
u.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
"""
params = {"wkt": parcel_wkt, "radius_m": radius_m}
total_features = (
db.execute(
text("SELECT COUNT(*) FROM osm_utility_infrastructure_ekb u" + where_clause),
params,
).scalar()
or 0
)
rows = (
db.execute(
text("""
text(
"""
SELECT osm_id, osm_type, infrastructure_kind, name, source_tag,
u.tags AS tags,
ST_Distance(
@ -667,16 +688,14 @@ def get_nearby_utility_infrastructure(
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_AsGeoJSON(u.geom) AS geojson
FROM osm_utility_infrastructure_ekb u
WHERE ST_DWithin(
u.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
FROM osm_utility_infrastructure_ekb u"""
+ where_clause
+ """
ORDER BY distance_m ASC
LIMIT :lim
"""),
{"wkt": parcel_wkt, "radius_m": radius_m, "lim": limit},
"""
),
{**params, "lim": limit},
)
.mappings()
.all()
@ -706,11 +725,15 @@ def get_nearby_utility_infrastructure(
}
)
total_features_int = int(total_features)
return {
"features": features,
"summary": {
"total_features": len(features),
# Epic #2445 A2: честный COUNT(*) по всей радиус-выборке — НЕ len(features)
# (которое капалось на `limit`, default 200).
"total_features": total_features_int,
"nearest_distance_m": nearest_overall,
"nearest_by_kind": nearest_by_kind,
"features_truncated": total_features_int > len(features),
},
}

View file

@ -95,6 +95,11 @@ class VelocityResult:
proxy_used: bool = False
# proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none.
proxy_sqm_per_deal: float | None = None
# Audit #2445 A4: доля report_month-строк ЕКБ-бенчмарка (_get_ekb_median), где
# deals_total_vol_m2 IS NULL и объём фабриковался через count×45.0 (тот же proxy,
# что и rosreestr_fallback, но внутри city-wide нормализующей медианы). 0.0 —
# медиана целиком из реальных deals_total_vol_m2 (или бенчмарк не считался/fallback-константа).
ekb_median_proxy_fraction: float = 0.0
# RANK 1 (DCF velocity schedule): абсорбция ОДНОГО типового конкурента района,
# м²/мес = monthly_velocity_sqm / max(n_with_sales, 1). Это НЕ суммарная скорость
# района (monthly_velocity_sqm), а темп выбытия одного участника рынка — им и
@ -103,6 +108,14 @@ class VelocityResult:
# per-project absorption там ill-defined и НЕ должен драйвить график) и на
# degenerate/zero путях.
project_absorption_sqm_per_month: float | None = None
# #2464 cluster B: True когда запрос конкурентов (Step 1, `LIMIT 200`) упёрся
# в лимит — competitors_count тогда "минимум 200" (реальных ЖК в радиусе может
# быть больше), а не честный total. Тот же silent-cap класс, что чинили в
# utility_infrastructure_loader (`features_truncated`, epic #2445 A2); здесь —
# флаг, а не второй COUNT(*): 200 конкурентов-ЖК в 3км радиусе — крайне
# редкий edge (плотная застройка), доп. geo-запрос на каждый analyze того не
# стоит — честная disclosure флагом дешевле.
competitors_truncated: bool = False
def as_dict(self) -> dict[str, Any]:
return {
@ -120,11 +133,13 @@ class VelocityResult:
"objective_coverage_pct": self.objective_coverage_pct,
"proxy_used": self.proxy_used,
"proxy_sqm_per_deal": self.proxy_sqm_per_deal,
"ekb_median_proxy_fraction": round(self.ekb_median_proxy_fraction, 3),
"project_absorption_sqm_per_month": (
round(self.project_absorption_sqm_per_month, 1)
if self.project_absorption_sqm_per_month is not None
else None
),
"competitors_truncated": self.competitors_truncated,
}
@ -307,10 +322,20 @@ def compute_velocity(
# SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными).
coverage_pct = round(mapped_ratio * 100, 1)
ekb_median = (
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
)
# Audit #2445 A4: _get_ekb_median возвращает (median, proxy_row_fraction) — доля
# строк бенчмарка, где объём фабриковался через count*45.0. При DB-fallback
# (None — нет данных) proxy_fraction неизвестна → 0.0 (используем calibrated
# константу, а не contaminated медиану, так что disclosure не нужен).
_ekb_median_result = _get_ekb_median(db, months_window=months_window)
if _ekb_median_result is not None:
ekb_median, ekb_median_proxy_fraction = _ekb_median_result
else:
ekb_median = _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
ekb_median_proxy_fraction = 0.0
n_comps = len(comp_rows)
# #2464 cluster B: Step 1 query капнут `LIMIT 200` (см. выше) — если выборка
# уперлась в лимит, реальных конкурентов в радиусе может быть больше.
n_comps_truncated = n_comps >= 200
sample_no_data = sorted(
[
{
@ -342,6 +367,8 @@ def compute_velocity(
ekb_median=ekb_median,
sample_competitors=sample_no_data,
objective_coverage_pct=coverage_pct,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
competitors_truncated=n_comps_truncated,
)
if rr_result is not None:
return rr_result
@ -363,7 +390,9 @@ def compute_velocity(
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
competitors_truncated=n_comps_truncated,
)
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
@ -470,6 +499,8 @@ def compute_velocity(
ekb_median=ekb_median,
sample_competitors=sample_no_data,
objective_coverage_pct=coverage_pct,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
competitors_truncated=n_comps_truncated,
)
if rr_result is not None:
return rr_result
@ -503,7 +534,9 @@ def compute_velocity(
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
competitors_truncated=n_comps_truncated,
)
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
@ -580,7 +613,9 @@ def compute_velocity(
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
project_absorption_sqm_per_month=project_absorption,
competitors_truncated=n_comps_truncated,
)
@ -592,6 +627,8 @@ def _compute_rosreestr_fallback(
ekb_median: float,
sample_competitors: list[dict[str, Any]],
objective_coverage_pct: float = 0.0,
ekb_median_proxy_fraction: float = 0.0,
competitors_truncated: bool = False,
) -> VelocityResult | None:
"""Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.
@ -681,13 +718,18 @@ def _compute_rosreestr_fallback(
objective_coverage_pct=objective_coverage_pct,
proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется)
proxy_sqm_per_deal=avg_area_per_deal,
# Audit #2445 A4: пробрасываем proxy-долю бенчмарка (ekb_median посчитан
# ДО вызова этого fallback) — даже здесь denominator может быть частично
# contaminated count*45.0-строками.
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
# RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции —
# per-project absorption ill-defined → None, НЕ драйвит DCF-график.
project_absorption_sqm_per_month=None,
competitors_truncated=competitors_truncated,
)
def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float] | None:
"""ЕКБ-wide медиана monthly velocity (м²/мес) per ЖК — benchmark.
Источник: objective_corpus_room_month (актуальные данные, обновляется еженедельно).
@ -697,6 +739,24 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
Учитываются только ЖК с 3 месяцами данных за окно (стабильный сигнал).
Fallback к _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH если нет данных в БД.
Audit #2445 A4: report_month-строки с deals_total_count>0, но
deals_total_vol_m2 IS NULL, фабрикуют объём через COALESCE(..., count*45.0)
тот же proxy-приём, что и в rosreestr_fallback (avg_area_per_deal), только
здесь он был необъявленным и контаминировал city-wide бенчмарк, к которому
нормализуется velocity_score КАЖДОГО участка. Строки не исключаются (иначе
продающийся, но без м²-данных ЖК просто выпадает из бенчмарка тоже
искажение), но доля таких строк считается и возвращается вызывающему коду
для disclosure (см. VelocityResult.ekb_median_proxy_fraction), а не
проглатывается молча как чистые данные.
Возвращает (median, proxy_row_fraction) либо None если данных нет.
proxy_row_fraction доля (project, room, month)-строк (среди вошедших в
медиану project'ов, ≥3 отчётных месяца), где объём взят из count*45.0,
а не из реального deals_total_vol_m2. Считается на уровне строк
objective_corpus_room_month (таблица хранит строку per project×room×month
см. data/sql/100_fix_mv_layout_velocity_weighted_avg.sql), НЕ на уровне
report_month, чтобы не менять исходный HAVING-гейт «3 месяца данных».
"""
try:
with db.begin_nested():
@ -704,23 +764,38 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
db.execute(
text(
"""
WITH per_project AS (
WITH source_rows AS (
SELECT
project_name,
SUM(COALESCE(deals_total_vol_m2,
deals_total_count * 45.0)) AS total_sqm,
COUNT(DISTINCT report_month) AS months_data
report_month,
COALESCE(deals_total_vol_m2,
deals_total_count * 45.0) AS row_sqm,
(deals_total_vol_m2 IS NULL) AS is_proxy_row
FROM objective_corpus_room_month
WHERE report_month >= (CURRENT_DATE - CAST(:window_interval AS interval))
AND deals_total_count > 0
),
per_project AS (
SELECT
project_name,
SUM(row_sqm) AS total_sqm,
COUNT(DISTINCT report_month) AS months_data,
COUNT(*) AS rows_data,
COUNT(*) FILTER (WHERE is_proxy_row) AS proxy_rows
FROM source_rows
GROUP BY project_name
HAVING COUNT(DISTINCT report_month) >= 3
),
per_project_velocity AS (
SELECT total_sqm / months_data AS velocity
SELECT total_sqm / months_data AS velocity,
rows_data,
proxy_rows
FROM per_project
)
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median,
COALESCE(SUM(proxy_rows), 0) AS proxy_rows_total,
COALESCE(SUM(rows_data), 0) AS rows_total
FROM per_project_velocity
"""
),
@ -734,6 +809,10 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
# SAVEPOINT auto-rollback'нут
return None
if row and row["median"] is not None:
return float(row["median"])
return None
if row is None or row["median"] is None:
return None
rows_total = int(row["rows_total"] or 0)
proxy_rows_total = int(row["proxy_rows_total"] or 0)
proxy_fraction = (proxy_rows_total / rows_total) if rows_total > 0 else 0.0
return float(row["median"]), proxy_fraction

View file

@ -470,9 +470,15 @@ def backfill_ekb_zone_regulations(
upserted += 1
except Exception as exc:
# Per-zone изоляция: один сбойный фетч/апсёрт не должен ронять весь прогон.
logger.warning(
"backfill_ekb_zone_regulations: зона idx=%s failed: %s", zone_index, exc
)
# rollback() (не begin_nested): db здесь — СОБСТВЕННАЯ сессия таска
# backfill_zone_regulations.py (SessionLocal() + try/finally close), не
# shared request-scoped Session — orphan'ить нечего, следующая итерация
# цикла (get_cached_zone_regulation / upsert_zone_regulation) на этом же db
# иначе поймает "current transaction is aborted" после сбойного db.commit()
# выше (#2464 cluster A finding 6; сиблинг — write_db.rollback() в
# get_or_fetch_zone_regulation, тоже owned-сессия).
db.rollback()
logger.warning("backfill_ekb_zone_regulations: зона idx=%s failed: %s", zone_index, exc)
failed += 1
continue

View file

@ -420,6 +420,24 @@ def build_beat_schedule() -> dict:
# "options": {"queue": "celery"},
# }
# Catalog-FLAT scrape (#2442 Task 2) — наполняет per-flat catalog-поля
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
#
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 UTC.
# schedule["scrape-kn-catalog-flats-weekly"] = {
# "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
# "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC
# "kwargs": {"region_code": 66, "max_flats": 300},
# "options": {"queue": "celery"},
# }
# Quarter price index refresh (#762) — monthly on the 5th at 05:00 MSK.
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
# Chains: mv_quarter_price_per_m2 → mv_quarter_price_index (in order, both CONCURRENTLY).

View file

@ -50,6 +50,7 @@ celery_app = Celery(
include=[
"app.workers.tasks.scrape_kn",
"app.workers.tasks.scrape_kn_catalog_objects",
"app.workers.tasks.scrape_kn_catalog_flats",
"app.workers.tasks.refresh_analytics",
"app.workers.tasks.scrape_objective",
"app.workers.tasks.objective_etl",

View file

@ -3,25 +3,116 @@
Расписание: 03:30 МСК (hardcoded в beat_schedule, как cbr/poi/supply-layers).
Идемпотентен: UPSERT ON CONFLICT (source, ext_house_id).
Если TRADEIN_DATABASE_URL не задан warn-log, возвращает {"disabled": True}.
Breadcrumb (issue #2445 D6): прогон логируется в objective_scrape_runs (тот же
журнал, что используют соседние nightly/weekly-таски см.
app/workers/tasks/objective_etl.py) с group_name='newbuilding-crossload', чтобы
сбой был виден оператору в admin-таблице логов, а не только в Celery FAILURE +
GlitchTip. Таблица переиспользуется как есть без новой миграции; поля
rows_lots/rows_history/requests_count не про Objective-домен здесь, а про
source_rows/upserted/skipped cross-load'а (см. комментарии у _finish_run).
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.etl.newbuilding_crossload import run_crossload
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
_GROUP_NAME = "newbuilding-crossload"
def _start_run(db: Session, triggered_by: str) -> int:
"""Создать breadcrumb-строку в objective_scrape_runs, статус 'running'."""
row = db.execute(
text(
"""
INSERT INTO objective_scrape_runs (group_name, triggered_by, status)
VALUES (:gn, :tb, 'running')
RETURNING run_id
"""
),
{"gn": _GROUP_NAME, "tb": triggered_by},
).scalar_one()
db.commit()
return int(row)
def _finish_run(
db: Session, run_id: int, *, status: str, error: str | None = None, **counts: int
) -> None:
"""Обновить breadcrumb-строку финальным статусом ('done' | 'failed') + счётчиками."""
sets = ["finished_at = NOW()", "status = :status", "error = :error"]
params: dict[str, Any] = {"rid": run_id, "status": status, "error": error}
for k, v in counts.items():
sets.append(f"{k} = :{k}")
params[k] = v
db.execute(
text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
params,
)
db.commit()
@celery_app.task(name="tasks.etl_newbuilding_crossload.etl_newbuilding_crossload")
def etl_newbuilding_crossload() -> dict:
def etl_newbuilding_crossload(triggered_by: str = "beat") -> dict:
"""Cross-load tradein.houses → gendesign.newbuilding_listings (#976).
Вызывается beat'ом в 03:30 МСК и вручную через POST /api/v1/admin/scrape/newbuilding-crossload.
Args:
triggered_by: 'beat' | 'manual' | 'api' для журнала objective_scrape_runs.
Breadcrumb-строка в objective_scrape_runs логирует start/success/failure
на исключении из run_crossload() помечает run 'failed' и ре-рейзит, чтобы
Celery ушёл в FAILURE и GlitchTip продолжил ловить сбой (issue #2445 D6).
"""
logger.info("task etl_newbuilding_crossload: start")
result = run_crossload()
logger.info("task etl_newbuilding_crossload: done — %s", result)
return result
db = SessionLocal()
run_id: int | None = None
try:
run_id = _start_run(db, triggered_by)
logger.info("task etl_newbuilding_crossload: start run_id=%s", run_id)
result = run_crossload(db=db)
if result.get("disabled"):
_finish_run(db, run_id, status="done", error="TRADEIN_DATABASE_URL not set")
logger.info("task etl_newbuilding_crossload: done (disabled) run_id=%s", run_id)
return {"run_id": run_id, **result}
_finish_run(
db,
run_id,
status="done",
error=None,
rows_history=result.get("source_rows", 0),
rows_lots=result.get("upserted", 0),
requests_count=result.get("skipped", 0),
)
logger.info("task etl_newbuilding_crossload: done run_id=%s%s", run_id, result)
return {"run_id": run_id, **result}
except Exception as e:
logger.exception("task etl_newbuilding_crossload: failed run_id=%s: %s", run_id, e)
if run_id is not None:
try:
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
except Exception:
# Breadcrumb-flush не должен скрыть исходное исключение — Celery
# всё равно должен уйти в FAILURE + GlitchTip его поймает.
logger.exception(
"task etl_newbuilding_crossload: failed to write failure breadcrumb "
"for run_id=%s",
run_id,
)
raise
finally:
db.close()

View file

@ -151,42 +151,59 @@ def ingest_izyatie_ocr() -> dict[str, int]:
logger.info("ingest_izyatie_ocr: загружаем %r%s", doc_title[:60], doc_url)
# Весь per-document body (fetch → OCR → extract → upsert → commit) — в
# одном try/except. Сбой ЛЮБОГО шага (битый PDF на fetch, Tesseract-crash
# на OCR, баг парсера на неожиданном OCR-тексте, ошибка upsert/commit)
# логируется и переходит к следующему документу вместо падения всей
# еженедельной пачки (issue #2445 D5). db.rollback() откатывает
# незакоммиченные изменения текущей итерации, чтобы они не отравили
# commit следующего документа.
try:
pdf_bytes = fetch_pdf(doc_url)
ocr_text = ocr_pdf_text(pdf_bytes)
if not ocr_text.strip():
logger.warning(
"ingest_izyatie_ocr: OCR вернул пустой текст для %s "
"(tesseract не установлен, битый PDF или чистый лист?)",
doc_url,
)
processed_docs += 1
continue
records = extract_izyatie_records(ocr_text, doc_title, doc_url)
logger.info(
"ingest_izyatie_ocr: %r%d кад-номеров",
doc_title[:60],
len(records),
)
if not records:
processed_docs += 1
continue
count = _upsert_records(db, records)
db.commit()
total_parcels += count
processed_docs += 1
except Exception as exc:
logger.error(
"ingest_izyatie_ocr: не удалось загрузить PDF %s: %s",
logger.warning(
"ingest_izyatie_ocr: пропуск документа %r (%s): %s",
doc_title[:60],
doc_url,
exc,
)
try:
db.rollback()
except Exception as rollback_exc:
logger.warning(
"ingest_izyatie_ocr: rollback после сбоя документа %s тоже упал: %s",
doc_url,
rollback_exc,
)
continue
ocr_text = ocr_pdf_text(pdf_bytes)
if not ocr_text.strip():
logger.warning(
"ingest_izyatie_ocr: OCR вернул пустой текст для %s "
"(tesseract не установлен, битый PDF или чистый лист?)",
doc_url,
)
processed_docs += 1
continue
records = extract_izyatie_records(ocr_text, doc_title, doc_url)
logger.info(
"ingest_izyatie_ocr: %r%d кад-номеров",
doc_title[:60],
len(records),
)
if not records:
processed_docs += 1
continue
count = _upsert_records(db, records)
db.commit()
total_parcels += count
processed_docs += 1
finally:
db.close()

View file

@ -25,6 +25,13 @@ UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, raw_props, fe
``db.begin_nested()``.
Beat: еженедельно в воскресенье в 04:30 МСК до ird-harvest (понедельник 05:00).
WAF-hygiene (#2445 item D3): пэйсинг и 429/5xx backoff между HTTP-запросами живут
в ``okn_egrkn_client`` (``_wait_rate_limit`` общий для 33 list-проходов и до
``_DETAIL_FETCH_LIMIT`` detail-запросов, один WAF-хост). Здесь, в
``_enrich_with_detail``, circuit breaker: ``_DETAIL_FAIL_BREAKER`` детейл-фейлов
ПОДРЯД прерываем detail-loop (не долбим WAF до конца лимита при устойчивом бане),
аналогично ``gisogd66.py`` (``_CARD_FAIL_BREAKER``).
"""
from __future__ import annotations
@ -89,6 +96,12 @@ _DETAIL_FETCH_LIMIT = 1500
# Логгировать прогресс detail-fetch каждые N объектов.
_DETAIL_PROGRESS_EVERY = 100
# Circuit breaker (#2445 item D3, аналогично gisogd66._CARD_FAIL_BREAKER): столько
# detail-фейлов ПОДРЯД (raise ИЛИ пустой detail считается "не подряд", см. ниже)
# → прерываем detail-loop. WAF-бан обычно даёт устойчивую серию ошибок; долбить
# оставшиеся до ~1500 detail-запросов бессмысленно и рискованно для IP-репутации.
_DETAIL_FAIL_BREAKER = 20
_UPSERT_SQL = text("""
INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at)
VALUES (
@ -145,6 +158,7 @@ def _enrich_with_detail(
*,
detail_fetcher: Any = fetch_okn_detail,
limit: int = _DETAIL_FETCH_LIMIT,
fail_breaker: int = _DETAIL_FAIL_BREAKER,
) -> int:
"""Обогатить raw_props detail-полями (name, address, …).
@ -154,15 +168,25 @@ def _enrich_with_detail(
Записи без detail-полей сохраняются с тем же ``raw_props={id}`` детали
могут быть недоступны (объект удалён из реестра / detail-endpoint лежит).
Пэйсинг и 429/5xx backoff на уровне HTTP-клиента (``okn_egrkn_client``),
общий rate-limit между списком и detail-запросами. Circuit breaker здесь
(#2445 item D3): ``fail_breaker`` вызовов ``detail_fetcher`` ПОДРЯД,
завершившихся исключением (сетевая ошибка / устойчивый WAF-бан после
исчерпания ретраев клиента) прерываем цикл, не долбим оставшийся лимит.
Успешный вызов (даже с пустым detail объект без карточки) сбрасывает
счётчик подряд-фейлов.
Args:
to_upsert: список row-dict для UPSERT (мутируется in-place).
detail_fetcher: callable source_id dict (для DI в тестах).
limit: максимум detail-вызовов за прогон.
fail_breaker: детейл-фейлов ПОДРЯД до прерывания цикла.
Returns:
Число фич, для которых detail-поля были успешно подмёрджены.
"""
enriched = 0
consecutive_fail = 0
total = min(len(to_upsert), limit)
for idx, row in enumerate(to_upsert[:limit], 1):
try:
@ -174,6 +198,19 @@ def _enrich_with_detail(
exc,
)
detail = {}
consecutive_fail += 1
if consecutive_fail >= fail_breaker:
logger.error(
"okn_objects_sync: %d detail-фейлов подряд — прерываю "
"detail-loop (устойчивый бан/сбой WAF), обработано %d/%d",
fail_breaker,
idx,
total,
)
break
continue
else:
consecutive_fail = 0
if not detail:
continue
@ -224,6 +261,10 @@ def sync_okn_objects() -> dict[str, int]:
# Очередь для UPSERT: row-dict для исполнения _UPSERT_SQL.
to_upsert: list[dict[str, Any]] = []
# Пэйсинг между 33 list-запросами (#2445 item D3): fetch_okn_points → _do_request
# → okn_egrkn_client._wait_rate_limit перед КАЖДЫМ POST (общий rate-limit с
# detail-запросами, один WAF-хост okn-mk.mkrf.ru). Ничего дополнительно спать
# здесь не нужно — пэйсинг/backoff инкапсулированы в клиенте.
for address, category_type in _PASSES:
category = _CATEGORY_MAP[category_type]
logger.info(

View file

@ -10,12 +10,17 @@ Beat НЕ настроен: постановления выходят редко
celery call tasks.reservation_ingest.ingest_reservations
Conventions (зеркало ird_harvest.py / backend.md):
SessionLocal() + try/finally close.
Session lifecycle (#2445 D4): PDF fetch происходит БЕЗ открытой сессии — pravo.gov66
иногда медленный (таймаут до 60с), держать connection-pool slot на всё время внешнего
запроса нельзя. Короткоживущий ``SessionLocal()`` открывается ТОЛЬКО на запись строк
одного документа (open upsert commit close per item), не на весь цикл.
SAVEPOINT per-row (``with db.begin_nested():``) битая строка не валит батч.
CAST(:x AS type) НИКОГДА :x::text (psycopg v3).
ON CONFLICT (cad_num, act_number) обновляет метаданные + fetched_at.
httpx.Client (не requests). Сбой одного дока не валит прогон.
q-запросы 2-3 слова (движок pravo.gov66 строгий AND/фраза, 1 или 4+ 0 хитов).
Pacing/backoff к pravo.gov66.ru внутри pravo_gov66_client (_get_with_backoff),
НЕ здесь: каждый search/page/pdf запрос уже пейсится на уровне клиента.
"""
from __future__ import annotations
@ -97,8 +102,11 @@ def _fetch_pdf_safe(pdf_url: str) -> bytes | None:
return None
def _upsert_records(db: Session, records_meta: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation. Возвращает число обработанных строк."""
def _upsert_records_in_session(db: Session, records_meta: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation на уже открытой сессии.
Возвращает число upsert'нутых строк.
"""
count = 0
for row in records_meta:
try:
@ -114,6 +122,27 @@ def _upsert_records(db: Session, records_meta: list[dict[str, Any]]) -> int:
return count
def _upsert_records(records_meta: list[dict[str, Any]]) -> int:
"""Открывает короткоживущую сессию ТОЛЬКО на запись одного документа → commit → close.
Session lifecycle (#2445 D4): вызывается ПОСЛЕ внешнего PDF-fetch, никогда во время
него держать открытую транзакцию/connection-pool slot на время сетевого запроса
к pravo.gov66.ru (таймаут до 60с) недопустимо (см. WAF-инцидент #2443).
"""
if not records_meta:
return 0
db: Session = SessionLocal()
try:
count = _upsert_records_in_session(db, records_meta)
db.commit()
return count
except Exception:
db.rollback()
raise
finally:
db.close()
def _enumerate_documents(since: str | None = None) -> list[dict[str, Any]]:
"""Обходит все поисковые запросы и возвращает дедуплицированный список документов.
@ -182,117 +211,111 @@ def ingest_reservations(
def _ingest_explicit_docs(docs: list[dict[str, Any]]) -> dict[str, int]:
"""Ингестирует явно переданный список документов (back-compat с ручным вызовом)."""
"""Ингестирует явно переданный список документов (back-compat с ручным вызовом).
Session lifecycle (#2445 D4): PDF fetch (сетевой, может занять до 60с) выполняется
БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою короткоживущую сессию
только на запись строк одного документа (commit+close сразу после).
"""
total_parcels = 0
db: Session = SessionLocal()
try:
for doc in docs:
url = doc.get("url", "")
act_number = doc.get("act_number")
basis_act = doc.get("basis_act", "")
doc_purpose = doc.get("purpose")
doc_kind = doc.get("kind", "изъятие")
for doc in docs:
url = doc.get("url", "")
act_number = doc.get("act_number")
basis_act = doc.get("basis_act", "")
doc_purpose = doc.get("purpose")
doc_kind = doc.get("kind", "изъятие")
logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)
logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)
pdf_bytes = _fetch_pdf_safe(url)
if pdf_bytes is None:
logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
continue
pdf_bytes = _fetch_pdf_safe(url) # без открытой сессии — только сетевой запрос
if pdf_bytes is None:
logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
continue
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(explicit): url=%s%d КН", url, len(records))
if not records:
continue
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(explicit): url=%s%d КН", url, len(records))
if not records:
continue
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind or doc_kind,
"basis_act": basis_act,
"act_number": rec.act_number or act_number,
"act_date": rec.act_date,
"purpose": rec.purpose or doc_purpose,
"doc_url": url,
"source": "page_pdf",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
count = _upsert_records(db, rows)
db.commit()
total_parcels += count
logger.info(
"ingest_reservations(explicit): готово docs=%d parcels=%d",
len(docs),
total_parcels,
)
finally:
db.close()
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind or doc_kind,
"basis_act": basis_act,
"act_number": rec.act_number or act_number,
"act_date": rec.act_date,
"purpose": rec.purpose or doc_purpose,
"doc_url": url,
"source": "page_pdf",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
logger.info(
"ingest_reservations(explicit): готово docs=%d parcels=%d",
len(docs),
total_parcels,
)
return {"docs": len(docs), "parcels": total_parcels}
def _ingest_enumerated_docs(enumerated: list[dict[str, Any]]) -> dict[str, int]:
"""Ингестирует документы, найденные через enumerate с pravo.gov66."""
"""Ингестирует документы, найденные через enumerate с pravo.gov66.
Session lifecycle (#2445 D4): оба сетевых вызова на документ (get_document_pdf_url,
fetch_pdf) выполняются БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою
короткоживущую сессию только на запись строк одного документа.
"""
total_parcels = 0
fetched_docs = 0
db: Session = SessionLocal()
try:
for hit in enumerated:
doc_id: str = hit["doc_id"]
title: str = hit.get("title", "")
doc_page_url: str = hit["url"]
for hit in enumerated:
doc_id: str = hit["doc_id"]
title: str = hit.get("title", "")
doc_page_url: str = hit["url"]
logger.info("ingest_reservations(enum): doc_id=%s title=%r", doc_id, title)
logger.info("ingest_reservations(enum): doc_id=%s title=%r", doc_id, title)
pdf_url = get_document_pdf_url(doc_id)
if pdf_url is None:
logger.warning(
"ingest_reservations(enum): doc_id=%s — PDF не найден на странице", doc_id
)
continue
pdf_url = get_document_pdf_url(doc_id) # сеть, без открытой сессии
if pdf_url is None:
logger.warning(
"ingest_reservations(enum): doc_id=%s — PDF не найден на странице", doc_id
)
continue
pdf_bytes = _fetch_pdf_safe(pdf_url)
if pdf_bytes is None:
logger.warning(
"ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id
)
continue
pdf_bytes = _fetch_pdf_safe(pdf_url) # сеть, без открытой сессии
if pdf_bytes is None:
logger.warning("ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id)
continue
fetched_docs += 1
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(enum): doc_id=%s%d КН", doc_id, len(records))
if not records:
continue
fetched_docs += 1
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(enum): doc_id=%s%d КН", doc_id, len(records))
if not records:
continue
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind,
"basis_act": title,
"act_number": rec.act_number,
"act_date": rec.act_date,
"purpose": rec.purpose,
"doc_url": doc_page_url,
"source": "pravo_gov66",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
count = _upsert_records(db, rows)
db.commit()
total_parcels += count
logger.info(
"ingest_reservations(enum): готово fetched=%d parcels=%d",
fetched_docs,
total_parcels,
)
finally:
db.close()
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind,
"basis_act": title,
"act_number": rec.act_number,
"act_date": rec.act_date,
"purpose": rec.purpose,
"doc_url": doc_page_url,
"source": "pravo_gov66",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
logger.info(
"ingest_reservations(enum): готово fetched=%d parcels=%d",
fetched_docs,
total_parcels,
)
return {"docs": fetched_docs, "parcels": total_parcels}

View file

@ -0,0 +1,214 @@
"""Celery task: periodic catalog-FLAT scrape для DOM.РФ (issue #2442 Task 2).
Дополняет per-flat catalog-поля (price_rub, price_per_m2, status, finishing_type,
ceiling_height_m, section_no, catalog_updated_at) + plan-изображения квартир в
domrf_kn_flats из SSR-страниц каталога:
https://наш.дом.рф/сервисы/каталог-квартир/квартира/{catalog_url_hash}
kn-API отдаёт цену для меньшинства квартир (~8.5% NULL-free); остальные поля
(отделка, потолки, дата обновления) живут только на публичной странице квартиры.
Оркестрация здесь: выборка batch'а из БД + вызов `scrape_catalog_batch`, которая
сама держит один BrowserSession + встроенный jitter_sleep (rate-limit/stealth).
Selector logic (аналог object-level scraper):
- catalog_url_hash IS NOT NULL берём только строки, где kn-sweep уже записал
реальный hash. До #2442 Task 1 + свежего kn-sweep это 0 строк (hash хардкодился
в NULL) пустой batch ожидаем/не баг.
- catalog_updated_at IS NULL квартира ещё не скрапилась из каталога, грузим.
- catalog_updated_at < CURRENT_DATE - INTERVAL '30 days' данные протухли, refresh.
- force=True игнорирует «уже свежий» фильтр, грузит все строки последнего
snapshot с непустым hash.
Sequencing dependency (важно): эта задача НЕ дублирует object-level scrape и
начнёт возвращать непустые batch'и ТОЛЬКО после того, как:
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) WAF-cooldown на
VPS IP; включается вручную после проверки targeted-тестом.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import date
from typing import Any
from sqlalchemy import text
from app.core.db import SessionLocal
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Выбор квартир на catalog-скрап одним запросом (obj-scraper-паттерн: obj_id+snapshot
# атомарно, здесь ods_id+catalog_url_hash). snapshot_date = MAX(...) для того же
# региона фиксирует последний snapshot и не тянет исторические строки — UPSERT в
# scrape_catalog_batch пишет `WHERE ods_id = :ods_id` (без snapshot-скоупа), поэтому
# отбираем только актуальный snapshot, чтобы не скрапить устаревшие ods_id.
#
# catalog_url_hash IS NOT NULL — жёсткое условие: без hash URL страницы не собрать.
# Пока Task 1 не задеплоен и kn-sweep не наполнил hash — 0 строк (ожидаемо).
#
# Фильтр свежести (:force = false): catalog_updated_at IS NULL (никогда не скрапили)
# ИЛИ старше 30 дней. При :force = true фильтр снимается — грузим все строки
# последнего snapshot с непустым hash.
#
# LIMIT :max_flats: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
# max_flats=None (force «Загрузить все» без явного потолка) грузим ВСЕ подходящие
# строки, а не молча режем до _DEFAULT_MAX_FLATS.
_SELECT_TARGETS_SQL = text(
"""
SELECT ods_id, catalog_url_hash, snapshot_date
FROM domrf_kn_flats
WHERE region_cd = :region_code
AND catalog_url_hash IS NOT NULL
AND ods_id IS NOT NULL
AND (
CAST(:force AS boolean)
OR catalog_updated_at IS NULL
OR catalog_updated_at < CURRENT_DATE - CAST(:stale_days AS integer)
)
AND snapshot_date = (
SELECT MAX(snapshot_date)
FROM domrf_kn_flats
WHERE region_cd = :region_code
)
ORDER BY catalog_updated_at NULLS FIRST
LIMIT :max_flats
"""
)
# Лимит по умолчанию если max_flats не задан явно (обычный beat/ad-hoc pass).
_DEFAULT_MAX_FLATS = 300
# Считаем данные протухшими старше этого числа дней (refresh-порог).
_STALE_DAYS = 30
@celery_app.task(
bind=True,
name="tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
time_limit=3600,
)
def scrape_kn_catalog_flats(
self: Any,
region_code: int = 66,
max_flats: int | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Periodic catalog-flat scrape (цены/отделка/планировки квартир из SSR каталога).
Args:
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
max_flats: Максимум квартир за один run. Если не задан: при force=True
лимита нет (грузим все), при force=False _DEFAULT_MAX_FLATS (300).
force: Если True игнорирует фильтр свежести («catalog_updated_at свежий»)
и (при не заданном max_flats) снимает лимит, грузя ВСЕ квартиры
последнего snapshot с непустым catalog_url_hash (admin «Загрузить все»).
По умолчанию False пропускает то, что скраплено < 30 дней назад, и
режет batch до 300.
Returns:
dict с ключами: region_code, force, snapshot_date, flats_count + поля из
scrape_catalog_batch (total, success, updated, failed, fields_total).
Concurrency:
No Redis lock consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
маловероятно. Если случится:
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
Add Redis lock если WAF блокирует наблюдается или beat включается на overlap.
"""
from app.services.scrapers.domrf_catalog import scrape_catalog_batch
# Явный max_flats всегда уважается. Без него:
# force=True («Загрузить все») → лимита нет (limit=None → SQL LIMIT NULL = все строки);
# force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_FLATS.
if max_flats is not None:
limit: int | None = max_flats
elif force:
limit = None
else:
limit = _DEFAULT_MAX_FLATS
db = SessionLocal()
try:
rows = (
db.execute(
_SELECT_TARGETS_SQL,
{
"region_code": region_code,
"max_flats": limit,
"force": force,
"stale_days": _STALE_DAYS,
},
)
.mappings()
.all()
)
flats: list[dict[str, Any]] = [
{"ods_id": r["ods_id"], "catalog_url_hash": r["catalog_url_hash"]} for r in rows
]
except Exception as exc:
logger.error("scrape_kn_catalog_flats: failed to fetch flats: %s", exc)
db.close()
raise
if not flats:
logger.info(
"scrape_kn_catalog_flats: nothing to do for region=%d (force=%s) — "
"likely no catalog_url_hash populated yet (see #2442 sequencing)",
region_code,
force,
)
db.close()
return {
"region_code": region_code,
"force": force,
"flats_count": 0,
"total": 0,
"success": 0,
"updated": 0,
"failed": 0,
"fields_total": 0,
}
# snapshot_date из первой строки — все строки одинаковые (WHERE snapshot_date =
# MAX(snapshot_date)). Атомарно: один SELECT, без race с kn-scraper.
snapshot_date_val: date = rows[0]["snapshot_date"]
logger.info(
"scrape_kn_catalog_flats: region=%d snapshot_date=%s flats=%d limit=%s force=%s",
region_code,
snapshot_date_val,
len(flats),
"ALL" if limit is None else limit,
force,
)
try:
stats = asyncio.run(
scrape_catalog_batch(
db=db,
flats=flats,
region_code=region_code,
)
)
except Exception as exc:
logger.error("scrape_kn_catalog_flats: scrape failed: %s", exc)
raise
finally:
db.close()
result: dict[str, Any] = {
"region_code": region_code,
"force": force,
"snapshot_date": str(snapshot_date_val),
"flats_count": len(flats),
**stats,
}
logger.info("scrape_kn_catalog_flats done: %s", result)
return result

View file

@ -0,0 +1,108 @@
"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
smoke-тест/re-trigger не углубил бан.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects"
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
result = MagicMock()
result.id = task_id
return result
def test_kn_catalog_objects_refuses_without_override() -> None:
"""Без i_understand_waf_risk (default False) — 400, task НЕ dispatched."""
with patch(
"app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async"
) as mock_apply_async:
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
mock_apply_async.assert_not_called()
def test_kn_catalog_objects_dispatches_with_override() -> None:
"""С i_understand_waf_risk=true — таск dispatched как раньше."""
with patch(
"app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async",
return_value=_mock_apply_async_result("obj-task-1"),
) as mock_apply_async:
resp = TestClient(app).post(
OBJECTS_ENDPOINT,
json={"region_code": 66, "max_objects": 3, "i_understand_waf_risk": True},
)
assert resp.status_code == 200
body = resp.json()
assert body["task_id"] == "obj-task-1"
assert body["max_objects"] == 3
mock_apply_async.assert_called_once()
call_kwargs = mock_apply_async.call_args[1]["kwargs"]
assert call_kwargs["region_code"] == 66
assert call_kwargs["max_objects"] == 3
def test_kn_catalog_flats_refuses_without_override() -> None:
"""Без i_understand_waf_risk (default False) — 400, task НЕ dispatched."""
with patch(
"app.workers.tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats.apply_async"
) as mock_apply_async:
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
mock_apply_async.assert_not_called()
def test_kn_catalog_flats_dispatches_with_override() -> None:
"""С i_understand_waf_risk=true — таск dispatched как раньше."""
with patch(
"app.workers.tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats.apply_async",
return_value=_mock_apply_async_result("flat-task-1"),
) as mock_apply_async:
resp = TestClient(app).post(
FLATS_ENDPOINT,
json={"region_code": 66, "max_flats": 3, "i_understand_waf_risk": True},
)
assert resp.status_code == 200
body = resp.json()
assert body["task_id"] == "flat-task-1"
assert body["max_flats"] == 3
mock_apply_async.assert_called_once()
call_kwargs = mock_apply_async.call_args[1]["kwargs"]
assert call_kwargs["region_code"] == 66
assert call_kwargs["max_flats"] == 3
def test_kn_catalog_objects_explicit_false_still_refuses() -> None:
"""i_understand_waf_risk=false явно (не только default) — тоже 400."""
with patch(
"app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async"
) as mock_apply_async:
resp = TestClient(app).post(
OBJECTS_ENDPOINT, json={"region_code": 66, "i_understand_waf_risk": False}
)
assert resp.status_code == 400
mock_apply_async.assert_not_called()

View file

@ -7,26 +7,26 @@
Стратегия mock: аналогична test_analyze_market_price.py DB mock через
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
Порядок db.execute calls в analyze_parcel (с #29 G2):
Порядок db.execute calls в analyze_parcel (с #29 G2, #2464 cluster B):
0. UNION ALL geom + source .mappings().first()
1. WKT query .mappings().first()
2. District .mappings().first()
3. POI rows .mappings().all()
4. Competitor rows .mappings().all()
5. Pipeline rows .mappings().all()
6. Centroid lat/lon .mappings().first()
7. Noise rows .mappings().all()
8. Hydrology .mappings().all()
9. Utilities .mappings().all()
10. parcel_meta (cad_parcels) .mappings().first() NEW #29 G2
11. Market trend .mappings().first()
12. Zoning (begin_nested) .mappings().first()
13. Success recommendation (begin_nested) .mappings().all()
14. Market price (begin_nested) .mappings().first()
15. Recent permits (begin_nested) .mappings().all()
16. _geotech_risk (industrial count) .scalar()
17. _neighbors_summary (neighbor_rows) .mappings().all()
18. _neighbors_summary (overlap_row) .mappings().first()
5. competitors_total honest COUNT(*) .scalar() NEW #2464 cluster B
6. Pipeline rows .mappings().all()
7. Centroid lat/lon .mappings().first()
8. Noise rows .mappings().all()
9. Hydrology .mappings().all()
10. Utilities .mappings().all()
11. parcel_meta (cad_parcels) .mappings().first() NEW #29 G2
12. Market trend .mappings().first()
13. Zoning (begin_nested) .mappings().first()
14. Success recommendation (begin_nested) .mappings().all()
15. Market price (begin_nested) .mappings().first()
16. Recent permits (begin_nested) .mappings().all()
17. _geotech_risk (industrial count) .scalar()
18. _neighbors_summary (single statement, neighbors+overlap+total) .mappings().first()
"""
from __future__ import annotations
@ -82,20 +82,23 @@ def _make_db_for_analyze(
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
("first", pm_mock), # 10: parcel_meta ← #29 G2
("first", None), # 11: market trend
("first", None), # 12: zoning (begin_nested)
("all", []), # 13: success recommendation (begin_nested)
("first", None), # 14: market price (begin_nested)
("all", []), # 15: recent permits (begin_nested)
("scalar", 0), # 16: geotech_risk
("all", []), # 17: neighbors
("first", None), # 18: overlap
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("all", []), # 6: pipeline rows
("first", centroid_row), # 7: centroid
("all", []), # 8: noise rows
("all", []), # 9: hydrology rows
("all", []), # 10: utilities rows
("first", pm_mock), # 11: parcel_meta ← #29 G2
("first", None), # 12: market trend
("first", None), # 13: zoning (begin_nested)
("all", []), # 14: success recommendation (begin_nested)
("first", None), # 15: market price (begin_nested)
("all", []), # 16: recent permits (begin_nested)
("scalar", 0), # 17: geotech_risk
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:

View file

@ -125,21 +125,24 @@ def _make_db_for_analyze() -> MagicMock:
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("all", []), # 6: pipeline rows
("first", centroid_row), # 7: centroid
("all", []), # 8: noise rows
("all", []), # 9: hydrology rows
("all", []), # 10: utilities rows
# (далее nspd_zoning merge не ходит в db.execute — резолвер замокан)
("first", None), # 10: parcel_meta
("first", None), # 11: market trend
("first", None), # 12: zoning (begin_nested)
("all", []), # 13: success recommendation
("first", None), # 14: market price
("all", []), # 15: recent permits
("scalar", 0), # 16: geotech_risk
("all", []), # 17: neighbors
("first", None), # 18: overlap
("first", None), # 11: parcel_meta
("first", None), # 12: market trend
("first", None), # 13: zoning (begin_nested)
("all", []), # 14: success recommendation
("first", None), # 15: market price
("all", []), # 16: recent permits
("scalar", 0), # 17: geotech_risk
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:

View file

@ -277,20 +277,23 @@ def _make_db_for_analyze() -> MagicMock:
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
("first", None), # 10: parcel_meta
("first", None), # 11: market trend
("first", None), # 12: zoning (begin_nested)
("all", []), # 13: success recommendation (begin_nested)
("first", None), # 14: market price (begin_nested)
("all", []), # 15: recent permits (begin_nested)
("scalar", 0), # 16: geotech_risk
("all", []), # 17: neighbors
("first", None), # 18: overlap
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("all", []), # 6: pipeline rows
("first", centroid_row), # 7: centroid
("all", []), # 8: noise rows
("all", []), # 9: hydrology rows
("all", []), # 10: utilities rows
("first", None), # 11: parcel_meta
("first", None), # 12: market trend
("first", None), # 13: zoning (begin_nested)
("all", []), # 14: success recommendation (begin_nested)
("first", None), # 15: market price (begin_nested)
("all", []), # 16: recent permits (begin_nested)
("scalar", 0), # 17: geotech_risk
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
@ -599,3 +602,94 @@ def test_service_update_custom_poi_no_commit_when_not_found() -> None:
db.commit.assert_not_called()
assert result is None
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
#
# get_overlaps_for_scoring: db shared с caller (POST .../analyze, Depends(get_db)).
# Раньше db.execute-сбой ловился в bare ``except Exception as e`` без
# SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
# следующий db.execute на этой же сессии (остальные шаги analyze_parcel) падает
# "current transaction is aborted". Фикс — db.execute обёрнут в
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
# на той же mock-сессии.
def _overlap_row(data: dict[str, Any]) -> MagicMock:
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
return m
def test_get_overlaps_for_scoring_returns_shaped_rows() -> None:
"""Happy-path: db.execute успешен → список dict с ожидаемыми ключами."""
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
row = _overlap_row(
{
"id": 42,
"name": "Мой парк",
"category": "park",
"weight": 2.0,
"lon": 60.607,
"lat": 56.840,
"distance_m": 500.0,
}
)
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = [row]
out = get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION, parcel_cad="66:41:0:1")
assert out == [
{
"id": 42,
"name": "Мой парк",
"category": "park",
"weight": 2.0,
"lon": 60.607,
"lat": 56.840,
"distance_m": 500.0,
}
]
def test_get_overlaps_for_scoring_uses_begin_nested_savepoint() -> None:
"""Фикс: db.execute обёрнут в db.begin_nested(), не голый вызов."""
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION)
assert db.begin_nested.call_count == 1
def test_get_overlaps_for_scoring_query_failure_degrades_to_empty_list() -> None:
"""Сбой db.execute → [] (graceful), не крашит analyze_parcel."""
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
def test_get_overlaps_for_scoring_session_usable_after_failure() -> None:
"""db.execute сбоит один раз, но db остаётся usable для следующего вызова на
этой же сессии (имитирует custom-POI overlaps остальные шаги analyze_parcel
на одной shared Session)."""
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]

View file

@ -0,0 +1,226 @@
"""Тесты: epic #2464 cluster B — silent-cap honesty fixes в analyze_parcel.
Mock-based не требуют живой БД.
Покрывает:
1. `_build_market_pulse` (market_pulse.competitors_total / coverage_pct)
раньше competitors_total = len(competitor_rows) (LIMIT 20 в SQL), теперь
принимает честный total отдельным параметром (см. analyze_parcel 5a
COUNT(*) БЕЗ LIMIT) и считает coverage_pct против него.
2. `_neighbors_summary` (count_buildings_100m / neighbors_truncated)
раньше count_buildings_100m = len(neighbor_rows) (LIMIT 30 в CTE
`neighbors`), теперь честный total из CTE `neighbors_total` (БЕЗ LIMIT).
Reference fix pattern: `features_truncated` в
`app/services/site_finder/utility_infrastructure_loader.py` (epic #2445 A2).
"""
from __future__ import annotations
from unittest.mock import MagicMock
from app.api.v1 import parcels as parcels_module
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
def _competitor_row(
obj_id: int,
avg_price_per_m2_rub: float | None,
units_sold: int | None = None,
) -> dict:
return {
"obj_id": obj_id,
"comm_name": f"ЖК-{obj_id}",
"dev_name": "TestDev",
"avg_price_per_m2_rub": avg_price_per_m2_rub,
"units_sold": units_sold,
}
# ── _build_market_pulse: competitors_total / coverage_pct honesty ────────────
class TestBuildMarketPulseHonesty:
"""market_pulse.competitors_total / coverage_pct — честный total (#2464 cluster B)."""
def test_below_cap_total_matches_len(self) -> None:
"""Ниже LIMIT (мало конкурентов в радиусе) — true total == len(rows), zero
behavior change: старое поведение (len(competitor_rows)) совпадает с новым."""
rows = [_competitor_row(i, 150_000.0) for i in range(1, 6)] # 5 конкурентов
pulse = parcels_module._build_market_pulse(rows, competitors_total=5, velocity_data=None)
assert pulse["competitors_total"] == 5
assert pulse["competitors_with_price"] == 5
assert pulse["coverage_pct"] == 100.0
def test_at_cap_total_is_honest_not_len_of_rows(self) -> None:
"""#2464 cluster B core regression: competitor_rows капнут LIMIT 20 (детальный
список), но реальных ЖК в радиусе 45 (честный COUNT). competitors_total ДОЛЖЕН
отражать 45, а НЕ len(rows)=20 (старый баг silent cap репортился как total)."""
rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)] # ровно LIMIT 20
true_total = 45
pulse = parcels_module._build_market_pulse(
rows, competitors_total=true_total, velocity_data=None
)
assert pulse["competitors_total"] == true_total
assert pulse["competitors_total"] != len(
rows
), "regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)"
def test_coverage_pct_computed_against_true_total_not_capped_list(self) -> None:
"""coverage_pct = priced / TRUE total — раньше делилось на len(rows) (капнутый
список), что давало ложные 100% покрытия даже когда реальное покрытие ниже."""
# Все 20 показанных строк имеют цену → раньше coverage_pct считался бы 100%.
rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)]
true_total = 40 # реально в радиусе 40 ЖК, из них с ценой только эти 20.
pulse = parcels_module._build_market_pulse(
rows, competitors_total=true_total, velocity_data=None
)
assert pulse["competitors_with_price"] == 20
# 20/40*100 = 50.0, НЕ 100.0 (старая формула len(rows)-денутый would дать 100%).
assert pulse["coverage_pct"] == 50.0
def test_zero_competitors_honest_zero_coverage(self) -> None:
"""Нет конкурентов в радиусе → coverage_pct=0.0 (не деление на ноль)."""
pulse = parcels_module._build_market_pulse([], competitors_total=0, velocity_data=None)
assert pulse["competitors_total"] == 0
assert pulse["competitors_with_price"] == 0
assert pulse["coverage_pct"] == 0.0
assert pulse["market_avg_price_per_m2"] is None
assert pulse["top_sellers"] == []
def test_unpriced_competitors_excluded_from_market_avg_but_counted_in_total(self) -> None:
"""ЖК без маппинга (avg_price_per_m2_rub=None) остаются в competitor_rows
(детальный список для карты), но не влияют на market_avg_price_per_m2."""
rows = [
_competitor_row(1, 100_000.0),
_competitor_row(2, None),
_competitor_row(3, 200_000.0),
]
pulse = parcels_module._build_market_pulse(rows, competitors_total=3, velocity_data=None)
assert pulse["competitors_with_price"] == 2
assert pulse["market_avg_price_per_m2"] == 150_000
def test_velocity_data_feeds_avg_velocity_m2(self) -> None:
"""avg_velocity_m2 берётся из velocity_data.monthly_velocity_sqm, если есть."""
rows = [_competitor_row(1, 100_000.0)]
pulse = parcels_module._build_market_pulse(
rows, competitors_total=1, velocity_data={"monthly_velocity_sqm": 750.5}
)
assert pulse["avg_velocity_m2"] == 750.5
def test_top_sellers_top5_sorted_by_units_sold(self) -> None:
"""top_sellers — топ-5 по units_sold среди приценённых конкурентов."""
rows = [_competitor_row(i, 100_000.0, units_sold=i * 10) for i in range(1, 8)]
pulse = parcels_module._build_market_pulse(rows, competitors_total=7, velocity_data=None)
assert len(pulse["top_sellers"]) == 5
sold = [s["units_sold"] for s in pulse["top_sellers"]]
assert sold == sorted(sold, reverse=True)
assert sold[0] == 70 # obj_id=7 продал больше всех
# ── _neighbors_summary: count_buildings_100m / neighbors_truncated honesty ───
def _neighbor_row(cad_num: str, distance_m: float = 10.0) -> dict:
return {
"cad_num": cad_num,
"building_name": None,
"floors": None,
"year_built": None,
"cost_value": None,
"area": None,
"readable_address": None,
"purpose": None,
"status": None,
"distance_m": distance_m,
}
def _make_neighbors_db(neighbors_json: list, neighbors_total_count: int, overlap_json=None):
row = {
"neighbors": neighbors_json,
"overlap_rows": overlap_json or [],
"neighbors_total_count": neighbors_total_count,
}
result = MagicMock()
result.mappings.return_value.first.return_value = row
db = MagicMock()
db.execute.return_value = result
return db
class TestNeighborsSummaryHonesty:
"""count_buildings_100m / neighbors_truncated — честный total (#2464 cluster B)."""
def test_below_cap_count_matches_len_no_truncation(self) -> None:
"""Ниже LIMIT 30 — true total == len(neighbors), neighbors_truncated=False
(zero behavior change для типичного случая)."""
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(5)]
db = _make_neighbors_db(neighbors, neighbors_total_count=5)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert summary["data_available"] is True
assert summary["count_buildings_100m"] == 5
assert summary["neighbors_truncated"] is False
def test_at_cap_count_is_honest_total_not_len_of_list(self) -> None:
"""#2464 cluster B core regression: `neighbors` CTE капнут LIMIT 30, но
реальных соседей в радиусе 47 (честный COUNT из `neighbors_total`).
count_buildings_100m ДОЛЖЕН отражать 47, а НЕ len(neighbors)=30."""
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)] # ровно LIMIT 30
true_total = 47
db = _make_neighbors_db(neighbors, neighbors_total_count=true_total)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert summary["count_buildings_100m"] == true_total
assert summary["count_buildings_100m"] != len(
neighbors
), "regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)"
assert summary["neighbors_truncated"] is True
def test_neighbors_list_itself_unaffected_by_count_fix(self) -> None:
"""Constraint: детальный список `neighbors` (payload для UI, уже капнут до 20
в существующем коде отдельно от SQL LIMIT 30) остаётся прежним по составу/
порядку фикс трогает только count/флаг, НЕ список."""
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)]
db = _make_neighbors_db(neighbors, neighbors_total_count=47)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert len(summary["neighbors"]) == 20
assert summary["neighbors"][0]["cad_num"] == "66:41:0000000:0"
def test_neighbors_truncated_reflects_actual_shown_list_not_intermediate_30(self) -> None:
"""neighbors_truncated должен сравнивать честный total с тем, что РЕАЛЬНО
показано в `neighbors` (капнуто до 20), а НЕ с промежуточным neighbor_rows
(до 30 из SQL) иначе флаг может ложно молчать (False) на 21..30-м соседе,
где список уже обрезан до 20, а SQL ещё не упёрся в свой LIMIT 30."""
# SQL вернул 25 строк (не уперся в LIMIT 30) — но список для UI режется до 20.
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(25)]
db = _make_neighbors_db(neighbors, neighbors_total_count=25)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert len(summary["neighbors"]) == 20
assert summary["count_buildings_100m"] == 25
# 25 (total) > 20 (реально показано) → truncated=True, даже хотя SQL LIMIT 30
# не был исчерпан.
assert summary["neighbors_truncated"] is True
def test_query_failure_degrades_gracefully(self) -> None:
"""DB-ошибка → data_available=False, note с текстом ошибки (unchanged behavior)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert summary == {"data_available": False, "note": "neighbors query failed: boom"}

View file

@ -545,6 +545,109 @@ def test_competitors_no_price_anywhere_is_none() -> None:
app.dependency_overrides.clear()
# ── SAVEPOINT-регрессия (#2464 cluster A finding 3) ──────────────────────────
def test_competitors_avg_price_failure_does_not_poison_later_queries() -> None:
"""avg_price query падает → sold-count и objective-fallback (следом на ТОЙ ЖЕ
Session) всё равно отрабатывают, а не ловят "current transaction is aborted".
Раньше avg_price/sold-count/objective-fallback ловили db.execute-сбой без
SAVEPOINT (db.begin_nested()) сбой ЛЮБОГО из трёх отравлял shared db для
ОСТАЛЬНЫХ двух (и для persist_analysis_run дальше по /analyze). Фикс оборачивает
каждый в свою begin_nested(); здесь ловим именно avg_price и проверяем, что
objective-fallback (третий execute) всё равно наполняет цену.
"""
coord_result = MagicMock()
coord_result.mappings.return_value.first.return_value = _coord_row()
obj_result = MagicMock()
obj_result.mappings.return_value.all.return_value = [_obj_row(obj_id=1)]
sold_result = MagicMock()
sold_result.mappings.return_value.all.return_value = []
obj_price_result = MagicMock()
obj_price_result.mappings.return_value.all.return_value = [
_obj_price_row(obj_id=1, price=142_000.0)
]
db = MagicMock()
db.execute.side_effect = [
coord_result,
obj_result,
RuntimeError("simulated avg_price DB failure"), # avg_price query сбоит
sold_result,
obj_price_result, # objective-fallback ДОЛЖЕН всё равно выполниться
]
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
# avg_price сбойнул (без domrf-цены), но objective-fallback наполнил её —
# это возможно ТОЛЬКО если сбой avg_price не отравил сессию для fallback.
assert comp["avg_price_per_m2"] == pytest.approx(142_000.0)
assert comp["price_source"] == "objective"
# Все 5 запланированных execute реально дошли (sold-count и fallback не
# были пропущены/не упали каскадом из-за отравленной сессии).
assert db.execute.call_count == 5
# avg_price/sold-count/objective-fallback — каждый под своим SAVEPOINT.
assert db.begin_nested.call_count == 3
finally:
app.dependency_overrides.clear()
def test_competitors_multi_object_radius_yields_per_object_prices() -> None:
"""Регрессия #2445 A5 (byte-identical к best_layouts #1956): несколько
конкурентов в радиусе КАЖДЫЙ получает свою domrf-цену, а не «1 объект на
global-max снимке, у остальных NULL».
Мок здесь не исполняет реальный SQL (_AVG_PRICE_SQL строковые инварианты
см. test_competitors_avg_price_sql.py), но фиксирует ОЖИДАЕМЫЙ КОНТРАКТ:
_make_db может вернуть price_rows для ВСЕХ obj_id разом (per-object latest
snapshot делает это возможным на живой БД) раньше баг гарантировал, что
price_rows содержал не более 1 строки на весь набор конкурентов.
"""
rows = [
_obj_row(obj_id=1, distance_m=100.0),
_obj_row(obj_id=2, distance_m=200.0),
_obj_row(obj_id=3, distance_m=300.0),
]
price_rows = [
_price_row(obj_id=1, price=140_000.0),
_price_row(obj_id=2, price=155_000.0),
_price_row(obj_id=3, price=162_000.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
assert len(comps) == 3
expected = {1: 140_000.0, 2: 155_000.0, 3: 162_000.0}
for obj_id, price in expected.items():
assert comps[obj_id]["avg_price_per_m2"] == pytest.approx(price), (
f"obj_id={obj_id}: все конкуренты должны получить СВОЮ domrf-цену, "
"не only-one-object-on-global-max-snapshot регрессия"
)
assert comps[obj_id]["price_source"] == "domrf"
finally:
app.dependency_overrides.clear()
def test_competitors_is_active_flag() -> None:
"""is_active=True для русского 'Строящиеся' (реальная prod-форма #1213),
False для 'Сданные'/null/прочих."""

View file

@ -1,14 +1,19 @@
"""Тесты pravo_gov66_client — offline, без живой сети.
Покрывают: парсинг search-HTML (doc_id/title/url), парсинг страницы документа
(PDF-ссылка), fallback на ecp-вариант, отсутствие PDF.
(PDF-ссылка), fallback на ecp-вариант, отсутствие PDF, pacing/backoff (#2445 D4)
между последовательными запросами к pravo.gov66.ru.
"""
from __future__ import annotations
import httpx
import pytest
from app.services.scrapers.pravo_gov66_client import (
_LAW_TYPE_POSTANOVLENIE,
_extract_pdf_url,
_get_with_backoff,
_parse_search_html,
)
@ -169,3 +174,96 @@ class TestExtractPdfUrl:
url = _extract_pdf_url(_DOC_HTML_ECP_ONLY)
assert url is not None
assert url.startswith("https://www.pravo.gov66.ru/")
# ── Тесты pacing/backoff (#2445 D4) — offline через httpx.MockTransport ───────
def _client_with_responses(responses: list[httpx.Response]) -> httpx.Client:
"""httpx.Client на MockTransport, отдающий responses по очереди (без реальной сети)."""
calls = iter(responses)
def _handler(request: httpx.Request) -> httpx.Response:
return next(calls)
return httpx.Client(transport=httpx.MockTransport(_handler))
class TestGetWithBackoff:
def test_sleeps_before_request(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Пейсинг: time.sleep вызывается ПЕРЕД запросом, даже на первой/единственной попытке."""
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses([httpx.Response(200, text="ok")])
_get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
assert len(sleeps) == 1
def test_paces_between_successive_fetches(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Несколько последовательных fetch → time.sleep между КАЖДЫМ (не только ретраи)."""
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses(
[
httpx.Response(200, text="a"),
httpx.Response(200, text="b"),
httpx.Response(200, text="c"),
]
)
for i in range(3):
_get_with_backoff(client, f"https://www.pravo.gov66.ru/{i}/")
assert len(sleeps) == 3
def test_retries_on_429_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""429 → backoff sleep + ретрай → 200 успешно, без исключения."""
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses(
[httpx.Response(429, text="rate limited"), httpx.Response(200, text="ok")]
)
resp = _get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
assert resp.status_code == 200
# 1 pacing-sleep перед 1й попыткой + 1 backoff-sleep перед ретраем + 1 pacing перед 2й.
assert len(sleeps) >= 2
def test_retries_on_5xx_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses(
[httpx.Response(503, text="unavailable"), httpx.Response(200, text="ok")]
)
resp = _get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
assert resp.status_code == 200
def test_raises_after_exhausting_retries_on_429(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("app.services.scrapers.pravo_gov66_client.time.sleep", lambda s: None)
# _MAX_RETRIES=2 → до 3 попыток, все 429.
client = _client_with_responses([httpx.Response(429, text="x") for _ in range(3)])
with pytest.raises(httpx.HTTPStatusError):
_get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
def test_4xx_other_than_429_raises_immediately_without_retry(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""404 не ретраится — сразу raise_for_status(), задача не тратит время на 3 попытки."""
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses([httpx.Response(404, text="not found")])
with pytest.raises(httpx.HTTPStatusError):
_get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
# Только 1 pacing-sleep перед единственной попыткой, никаких backoff-ретраев.
assert len(sleeps) == 1

View file

@ -479,7 +479,11 @@ def test_permits_nearby_non_empty_summary_and_list() -> None:
def test_permits_nearby_items_capped_at_ten() -> None:
"""items[:10] — таблица документов не превышает шапку + 10 строк (зеркало HTML)."""
"""items[:10] — показываются первые 10 строк документов (зеркало HTML).
#2464 cluster B: сверх 10 добавляется honest-строка «и ещё N записей», поэтому
таблица = шапка + 10 документов + 1 disclosure = 12 строк.
"""
items = [
{
"doc_group": "RS",
@ -501,15 +505,76 @@ def test_permits_nearby_items_capped_at_ten() -> None:
}
doc = _new_doc()
_build_permits_nearby(doc, result)
# Вторая таблица (документы): шапка + не более 10 строк.
# Вторая таблица (документы): шапка + 10 показанных + 1 disclosure-строка.
docs_table = doc.tables[1]
assert len(docs_table.rows) == 11
assert len(docs_table.rows) == 12
text = _all_text(doc)
assert "РНС-0" in text
assert "РНС-9" in text
assert "РНС-10" not in text
def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
"""#2464 cluster B: total_count (честный COUNT апстрима, БЕЗ SQL LIMIT) > показанных
10 honest-строка «и ещё N записей (полный список в веб-версии §6)», а не молчаливый
срез как тепло/вода-таблицы §3 (`_build_connection_capacity`)."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС-{i}",
"date_doc": "2025-01-01",
"approved_organization": "Орган",
"distance_m": float(i),
}
for i in range(12)
]
result = {
"permits_nearby": {
# total_count=15 > показанных 10 → hidden = 5 (апстрим уже капнул items на 30,
# здесь режем до 10; disclosure считается от честного total_count).
"total_count": 15,
"rs_count": 15,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
}
}
doc = _new_doc()
_build_permits_nearby(doc, result)
text = _all_text(doc)
assert "… и ещё 5 записей (полный список в веб-версии §6)" in text
def test_permits_nearby_no_disclosure_when_all_shown() -> None:
"""total_count <= 10 (всё влезло) → БЕЗ строки «и ещё» (zero behavior change для
маленьких выборок): таблица = шапка + N документов, без disclosure-строки."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС-{i}",
"date_doc": "2025-01-01",
"approved_organization": "Орган",
"distance_m": float(i),
}
for i in range(7)
]
result = {
"permits_nearby": {
"total_count": 7,
"rs_count": 7,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
}
}
doc = _new_doc()
_build_permits_nearby(doc, result)
docs_table = doc.tables[1]
assert len(docs_table.rows) == 8 # шапка + 7, без disclosure
text = _all_text(doc)
assert "и ещё" not in text
def test_section_6_wires_permits_nearby_from_analyze() -> None:
"""_build_section_6(doc, forecast, result) прокидывает result → блок разрешений виден."""
doc = _new_doc()

View file

@ -468,6 +468,67 @@ def test_permits_nearby_org_name_escaped() -> None:
assert "<b>hack</b>" not in out
def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
"""#2464 cluster B: >10 items (upstream cap) → «и ещё N записей» дисклоузится,
вместо молчаливого среза до 10 (total_count честный total апстрима,
permits_nearby.get_permits_nearby считает его COUNT'ом БЕЗ SQL LIMIT)."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС{i}",
"date_doc": "2025-01-01",
"approved_organization": "Администрация",
"distance_m": float(i * 10),
}
for i in range(12)
]
result = {
"permits_nearby": {
"total_count": 15, # > len(items)=12: апстрим уже капнул items на 30,
# здесь ещё раз режем до 10 — итоговый hidden = 15-10 = 5.
"rs_count": 15,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
"items_truncated": True,
}
}
out = _build_permits_nearby(result)
assert "РНС №9" in out # 10-й показанный (index 9, row cap=10)
assert "РНС №10" not in out # 11-й — за кэпом, НЕ показан напрямую
assert "и ещё 5 записей" in out
assert "веб-версии §6" in out
def test_permits_nearby_no_truncation_note_when_all_shown() -> None:
"""total_count совпадает с показанными (<=10) → БЕЗ приписки «и ещё» (zero behavior
change для маленьких выборок, как раньше)."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС{i}",
"date_doc": "2025-01-01",
"approved_organization": "Администрация",
"distance_m": float(i * 10),
}
for i in range(7)
]
result = {
"permits_nearby": {
"total_count": 7,
"rs_count": 7,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
"items_truncated": False,
}
}
out = _build_permits_nearby(result)
assert "и ещё" not in out
for i in range(7):
assert f"РНС{i}" in out
def test_section_6_wires_permits_nearby_from_analyze() -> None:
"""analyze_result прокинут в §6 → блок разрешений рядом виден в part_b HTML."""
analyze = {

View file

@ -24,6 +24,7 @@ import pytest
from app.services.exporters.report_pdf import (
_ADVISORY_MARKER,
_TITLE_CONFIDENCE,
_TITLE_FUTURE_MARKET,
_TITLE_MARKET_NOW,
_TITLE_PRODUCT_TZ,
@ -141,7 +142,8 @@ _skip_if_no_weasyprint = pytest.mark.skipif(
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
)
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
# Семь ожидаемых заголовков блоков (по одному на содержательную секцию §13, включая
# §13.7 «Уверенность» — parity fix #2445 C2, зеркало _EXPECTED_TITLES в test_report_docx/md).
_EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_SUMMARY,
_TITLE_MARKET_NOW,
@ -149,6 +151,7 @@ _EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_PRODUCT_TZ,
_TITLE_SCENARIOS,
_TITLE_SCORING,
_TITLE_CONFIDENCE,
)
@ -357,12 +360,101 @@ class TestContractKeysRendered:
assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
def test_confidence_factors_rendered(self) -> None:
# confidence.factors должны попасть в HTML (ключ→значение).
# confidence.factors должны попасть в HTML (ключ→значение) через §13.7-секцию.
html_str = _build_html(_normalize(_full_report()))
assert "Факторы уверенности" in html_str
assert "data_coverage" in html_str
# ── Parity fix #2445 C2: §13.7 «Уверенность» — дедикейтед секция (было только в docx/md) ─
# report_docx.py / report_md.py уже несли полную секцию с 4-колоночной таблицей
# (Фактор/Значение/Уровень/Комментарий) и defensive dict-vs-flat разбором факторов;
# PDF был единственным экспортёром без неё (только тонкий 2-строчный уровень/rationale
# в «Сводке» + плоский dict-дамп факторов). Эти тесты ловят регресс парности.
class TestConfidenceSectionParity:
def test_confidence_section_title_present(self) -> None:
# Секция §13.7 присутствует как отдельный заголовок (не только внутри «Сводки»).
html_str = _build_html(_normalize(_full_report()))
assert _TITLE_CONFIDENCE in html_str
assert 'id="confidence"' in html_str
def test_confidence_section_has_four_column_table(self) -> None:
# 4-колоночная таблица факторов — зеркало report_docx/report_md._build_confidence.
html_str = _build_html(_normalize(_full_report()))
assert "Фактор" in html_str
assert "Значение" in html_str
assert "Уровень" in html_str
assert "Комментарий" in html_str
def test_confidence_level_and_rationale_rendered(self) -> None:
report = SiteFinderReport(
confidence=ReportConfidence(
level="medium",
rationale="Источники advisory-capped; данные средней плотности.",
factors={"data_coverage": 0.6},
),
)
html_str = _build_html(_normalize(report))
assert "средняя" in html_str # _level_ru("medium")
assert "Источники advisory-capped" in html_str
def test_dict_shaped_factor_renders_without_crashing(self) -> None:
# Ровно баг, от которого защищает docx isinstance(payload, dict)-ветка: значение
# фактора — САМО dict {value, level, note} (реальная форма §15/#990, см. frontend
# mock parcel-forecast.json), а не плоский скаляр. Плоский _dict_kv_table в старой
# версии PDF-«Сводки» такое не ожидал — падал/рендерил мусор. Новая §13.7-секция
# (_build_confidence) разбирает dict-payload как в report_docx/report_md.
report = SiteFinderReport(
confidence=ReportConfidence(
level="low",
rationale="Low потому что мало истории.",
factors={
"history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
"analog_count": {"value": 32, "level": "high", "note": "32 ЖК — достаточно"},
"advisory_capped": False, # плоский (не-dict) фактор — тоже должен уцелеть
},
),
)
# Не должно бросить исключение (регресс-guard); плюс проверяем реальный рендер.
html_str = _build_html(_normalize(report))
assert "history_months" in html_str
assert "6 мес — мало" in html_str
assert "analog_count" in html_str
assert "advisory_capped" in html_str
@_skip_if_no_weasyprint
def test_dict_shaped_factor_produces_valid_pdf(self) -> None:
# То же самое, но конец-в-конец через WeasyPrint (не только HTML-строка).
report = SiteFinderReport(
confidence=ReportConfidence(
level="low",
rationale="Low потому что мало истории.",
factors={
"history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
},
),
)
payload = export_report_pdf(report)
assert payload.startswith(b"%PDF")
assert len(payload) > 0
def test_summary_no_longer_duplicates_confidence_rationale(self) -> None:
# Тонкий 2-строчный «уровень+rationale» блок убран из «Сводки» — теперь живёт
# только в §13.7, чтобы не было двух версий одних и тех же данных в документе.
# Rationale-текст должен встретиться РОВНО один раз (в секции confidence).
report = SiteFinderReport(
confidence=ReportConfidence(
level="medium",
rationale="Уникальная-строка-обоснования-для-проверки-дублей",
factors={"data_coverage": 0.6},
),
)
html_str = _build_html(_normalize(report))
assert html_str.count("Уникальная-строка-обоснования-для-проверки-дублей") == 1
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────

View file

@ -13,18 +13,25 @@ psycopg v3 правило проверяется явно: bind-параметр
from __future__ import annotations
import contextlib
import datetime as dt
import os
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.forecasting.macro_series import (
_CONFOUNDER_DATES,
_MORTGAGE_FIELDS,
_REGIME_EPS_PP,
MonthlyMacro,
_carry_forward,
_month_grid,
_query_inflation_monthly,
_query_key_rate_monthly,
_query_mortgage_monthly,
_shift_months,
classify_regime,
get_monthly_macro,
@ -476,3 +483,110 @@ class TestGetMonthlyMacroGraceful:
out = get_monthly_macro(db, months_back=1)
assert len(out) == 2
assert all(m.mortgage_rate_weighted is None for m in out)
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #2, `_query_mortgage_monthly`): сбой ОДНОГО
# mortgage-поля НЕ должен «отравить» общую §22-сессию для остальных 4 полей / для
# последующих §9.x-слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()`
# сбойный `db.execute` (внутри `get_macro_series`) оставляет транзакцию Postgres
# aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeMortgageResult:
"""Мини-результат db.execute() — покрывает `.all()` (см. `get_macro_series`)."""
def all(self) -> list[Any]:
return []
class _PoisonableSession:
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
`execute()` бросает РОВНО один раз (на первое поле) и, как реальный Postgres,
«отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже
несвязанный) тоже бросает `current transaction is aborted`. `begin_nested()`
зеркалит `Session.begin_nested()`: при исключении откатывает ТОЛЬКО SAVEPOINT
(сбрасывает poison-флаг) и пробрасывает исключение дальше как настоящий
`ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (откатил бы всю внешнюю
транзакцию).
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeMortgageResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated mortgage field query failure")
return _FakeMortgageResult()
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для следующего поля.
self.poisoned = False
raise
class TestQueryMortgageMonthlySavepoint:
"""`_query_mortgage_monthly` оборачивает КАЖДОЕ поле в `db.begin_nested()`."""
def test_first_field_failure_leaves_session_usable_for_later_fields(self) -> None:
db = _PoisonableSession()
out = _query_mortgage_monthly(db, since=dt.date(2024, 1, 1))
# все 5 полей присутствуют в результате (контракт функции не изменён).
assert set(out.keys()) == {field for _itype, field in _MORTGAGE_FIELDS}
# сбойное (первое) поле → пустой fallback-словарь, НЕ crash.
first_field = _MORTGAGE_FIELDS[0][1]
assert out[first_field] == {}
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбойного поля проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
class TestQueryKeyRateMonthlySavepoint:
"""`_query_key_rate_monthly` оборачивает db.execute в `db.begin_nested()`.
Тот же poisoning-класс, что и mortgage-цикл: key_rate ПЕРВЫЙ запрос
get_monthly_macro; без SAVEPOINT его сбой отравил бы транзакцию для inflation +
mortgage + всех последующих §9.x-слоёв на общей §22-сессии.
"""
def test_db_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = _query_key_rate_monthly(db, since=dt.date(2024, 1, 1))
# сбой запроса → пустой ресэмпл (fallback, НЕ crash).
assert out == {}
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
class TestQueryInflationMonthlySavepoint:
"""`_query_inflation_monthly` оборачивает db.execute (через get_macro_series) в SAVEPOINT.
Тот же poisoning-класс: inflation ВТОРОЙ запрос get_monthly_macro; без SAVEPOINT
его сбой отравил бы транзакцию для mortgage-полей + последующих §9.x-слоёв.
"""
def test_db_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = _query_inflation_monthly(db, since=dt.date(2024, 1, 1))
# сбой запроса → пустой ряд (fallback, НЕ crash).
assert out == {}
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")

View file

@ -371,3 +371,101 @@ class TestNormalizeDistrict:
# list / int / прочие неподдерживаемые типы → None + warning.
assert _normalize_district([1, 2, 3]) is None
assert _normalize_district(42) is None
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #1, orchestrator.py `_safe_call`): один сбойный
# §9.x-слой НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ слоёв. Без
# `db.begin_nested()` сбойный `db.execute` внутри слоя оставляет транзакцию Postgres
# aborted («current transaction is aborted, commands ignored until end of
# transaction block») — каждый ПОСЛЕДУЮЩИЙ слой на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeResult:
"""Мини-результат db.execute() — покрывает `.mappings().first()`/`.all()`."""
def mappings(self) -> _FakeResult:
return self
def first(self) -> None:
return None
def all(self) -> list[Any]:
return []
class _PoisonableSession:
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
`execute()` бросает РОВНО один раз (на первый вызов) и, как реальный Postgres,
«отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже
совершенно несвязанный) тоже бросает `current transaction is aborted`.
`begin_nested()` зеркалит `Session.begin_nested()`: при исключении откатывает
ТОЛЬКО SAVEPOINT (сбрасывает poison-флаг) и пробрасывает исключение дальше как
настоящий `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (который откатил бы
всю внешнюю транзакцию established anti-pattern, `backend.md`).
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated §9.x layer query failure")
return _FakeResult()
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для следующего слоя.
self.poisoned = False
raise
class TestSafeCallSavepointDoesNotPoisonSession:
"""`_safe_call` оборачивает вызов слоя в `db.begin_nested()` (SAVEPOINT)."""
def test_layer_db_failure_leaves_session_usable_for_later_layers(self) -> None:
db = _PoisonableSession()
mocks = _patched_layers()
# market_metrics — единственный слой в этом тесте, реально трогающий `db`:
# его мок сам вызывает db.execute (через _PoisonableSession бросает один раз).
# Остальные слои остаются sentinel-моками из _patched_layers() (db не трогают).
mocks["compute_market_metrics"] = MagicMock(
side_effect=lambda *a, **kw: db.execute("SELECT 1")
)
captured: dict[str, Any] = {}
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
captured.update(kwargs)
return SiteFinderReport()
with _patch_layers(mocks, _fake_assemble):
report = build_site_finder_report(
db,
analyze=_analyze([{"obj_class": "Комфорт"}]),
cad_num="66:41:0702048:27",
district="Ленинский",
horizons=[6, 12, 18],
)
assert isinstance(report, SiteFinderReport)
# упавший слой → None в слоте (fallback), остальные слои — sentinel'ы, целы.
assert captured["market_metrics"] is None
assert captured["scenarios"] == ["SC"]
assert captured["forecasts"] == ["F6", "F12"]
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбойного слоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")

View file

@ -40,6 +40,7 @@ from app.services.forecasting.product_scoring import (
_count_positive_usp,
_min_confidence,
_overlay_confidence,
_poi_weight_sum,
_quarter_from_cad,
_saturating,
_score_commercial,
@ -991,3 +992,98 @@ class TestMortgageSensitivityViaRegimeAdapter:
reg_mock.assert_not_called()
assert card.scores["mortgage_sensitivity"].value is None
assert card.scores["mortgage_sensitivity"].confidence == "low"
# ── #2464 cluster A: _poi_weight_sum SAVEPOINT-регрессия (session-poisoning) ────
# ОБА db.execute-сайта внутри _poi_weight_sum раньше ловили сбой БЕЗ SAVEPOINT/rollback:
# 1) центроид-lookup (db.execute(_PARCEL_CENTROID_SQL, ...));
# 2) compute_poi_weighted_top7(...) — poi_score.py сам делает raw db.execute БЕЗ своего
# try/except, поэтому сбой ПРОБРАСЫВАЕТСЯ наверх и глотается вторым except здесь.
# На реальном Postgres любой из них отравляет §22-report session (compute_score_card делит
# одну db-сессию с остальными §22-скорерами): следующий db.execute на ТОЙ ЖЕ сессии падал
# "current transaction is aborted". Фикс: ОБА сайта обёрнуты в db.begin_nested() (swallow
# site для poi — это второй except _poi_weight_sum, там и ставим savepoint).
class TestPoiWeightSumSavepoint:
def test_centroid_query_failure_returns_none(self) -> None:
"""DB-сбой на центроид-lookup → None (fallback → infra_fit unavailable)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("connection refused")
assert _poi_weight_sum(db, cad_num=_CAD) is None
def test_uses_begin_nested_savepoint(self) -> None:
"""Фикс: центроид-запрос обёрнут в db.begin_nested(), не голый db.execute."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
_poi_weight_sum(db, cad_num=_CAD)
assert db.begin_nested.call_count == 1
def test_session_usable_after_centroid_failure(self) -> None:
"""execute сбоит на 1-м вызове (центроид), db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует §22 shared report-session."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _poi_weight_sum(db, cad_num=_CAD) is None
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_poi_call_failure_returns_none(self) -> None:
"""Второй сайт: центроид резолвится, но compute_poi_weighted_top7 бросает →
None (документированный fallback, infra_fit unavailable)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, side_effect=RuntimeError("poi_score raw db.execute failed")):
assert _poi_weight_sum(db, cad_num=_CAD) is None
def test_uses_begin_nested_for_poi_call(self) -> None:
"""Фикс второго сайта: вызов compute_poi_weighted_top7 обёрнут в db.begin_nested().
При успешном центроиде оба сайта под savepoint begin_nested вызван дважды."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, return_value=_poi_response_stub([0.01])):
_poi_weight_sum(db, cad_num=_CAD)
assert db.begin_nested.call_count == 2
def test_session_usable_after_poi_failure(self) -> None:
"""poi_score (второй сайт) бросает → db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует §22 shared report-session
(без savepoint здесь была бы 'current transaction is aborted' на следующем скоре)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, side_effect=RuntimeError("poi boom")):
assert _poi_weight_sum(db, cad_num=_CAD) is None
# begin_nested вызван для обоих сайтов (центроид успешен, poi сбоит внутри savepoint).
assert db.begin_nested.call_count == 2
# Следующий (не связанный) вызов на той же сессии отрабатывает — не отравлена.
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_centroid_success_then_poi_computed(self) -> None:
"""Happy path: центроид резолвится → compute_poi_weighted_top7 вызван, Σ weight отдан.
Оба db-сайта под savepoint begin_nested вызван дважды."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, return_value=_poi_response_stub([0.03, 0.02])):
out = _poi_weight_sum(db, cad_num=_CAD)
assert out == pytest.approx(0.05)
assert db.begin_nested.call_count == 2

View file

@ -15,10 +15,12 @@ psycopg v3 правило проверяется явно: bind-параметр
from __future__ import annotations
import contextlib
import datetime as dt
import math
import os
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@ -667,6 +669,85 @@ class TestBuildSalesSeriesGraceful:
assert out.units == [0]
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #4, `_query_source_a`/`_query_source_b`): сбой
# db.execute НЕ должен «отравить» общую §22-сессию для ПОСЛЕДУЮЩИХ слоёв/запросов,
# переиспользующих ту же `db`-Session. Без `db.begin_nested()` сбойный `db.execute`
# оставляет транзакцию Postgres aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии
# тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeSalesResult:
"""Мини-результат db.execute() — покрывает `.mappings().all()`."""
def mappings(self) -> _FakeSalesResult:
return self
def all(self) -> list[Any]:
return []
class _PoisonableSession:
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
`execute()` бросает РОВНО один раз и, как реальный Postgres, «отравляет» сессию:
БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже несвязанный) тоже
бросает `current transaction is aborted`. `begin_nested()` зеркалит
`Session.begin_nested()`: при исключении откатывает ТОЛЬКО SAVEPOINT (сбрасывает
poison-флаг) и пробрасывает исключение дальше как настоящий `ROLLBACK TO
SAVEPOINT`, НЕ общий `db.rollback()` (откатил бы всю внешнюю транзакцию).
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeSalesResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated sales query failure")
return _FakeSalesResult()
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для последующих запросов.
self.poisoned = False
raise
class TestSourceQuerySavepointDoesNotPoisonSession:
"""`_query_source_a`/`_query_source_b` оборачивают db.execute в SAVEPOINT."""
def test_source_a_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = build_sales_series(db, spec=SegmentSpec(), source="corpus_room_month", months_back=1)
# сбой запроса → zero-filled сетка, low confidence (fallback, НЕ crash).
assert out.confidence == "low"
assert out.units == [0, 0]
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
def test_source_b_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = build_sales_series(db, spec=SegmentSpec(), source="objective_lots", months_back=1)
assert out.confidence == "low"
assert out.units == [0, 0]
db.execute("SELECT 1")
class TestDistrictResolution:
"""Step 2: spec.district (админ-имя ЕКБ) резолвится в МИКРО-набор в SQL-фильтре.

View file

@ -16,6 +16,7 @@ test_market_metrics.
from __future__ import annotations
import contextlib
from datetime import date
from typing import Any
from unittest.mock import MagicMock, patch
@ -65,6 +66,7 @@ from app.services.forecasting.special_indices import (
_price_bucket_to_band,
_price_overlap,
_query_artificial_demand,
_query_parcel_centroid,
_timing_overlap,
_unit_mix_similarity,
_void_index,
@ -1570,6 +1572,178 @@ class TestComputeSpecialIndicesGraceful:
assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.25)
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #3, `_run` внутри `compute_special_indices`):
# сбой ОДНОГО §25-индекса НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ
# индексов/слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()`
# сбойный `db.execute` (внутри `_query_artificial_demand`) оставляет транзакцию
# Postgres aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeIndicesResult:
"""Мини-результат db.execute() — покрывает `.mappings().first()`."""
def mappings(self) -> _FakeIndicesResult:
return self
def first(self) -> None:
return None
class _PoisonableSession:
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
`execute()` бросает РОВНО один раз (Artificial Demand SQL) и, как реальный
Postgres, «отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()`
(даже несвязанный) тоже бросает `current transaction is aborted`.
`begin_nested()` зеркалит `Session.begin_nested()`: при исключении откатывает
ТОЛЬКО SAVEPOINT (сбрасывает poison-флаг) и пробрасывает исключение дальше как
настоящий `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()`.
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeIndicesResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated artificial_demand query failure")
return _FakeIndicesResult()
@contextlib.contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для следующего индекса.
self.poisoned = False
raise
class TestRunSavepointDoesNotPoisonSession:
"""`_run` (внутренний gate compute_special_indices) оборачивает builder в SAVEPOINT."""
def test_artificial_demand_db_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
# district=None/cad_num=None → Cannibalization/Competitor Strength деградируют
# БЕЗ обращения к db (см. test_no_cad_num_degrades_competitor_indices); own
# portfolio патчим в [] (own_developer_ids-гейт не задействуем) — единственный
# builder, реально трогающий db в этом тесте, это Artificial Demand.
with _full_stack_patch(), patch(f"{_MOD}.get_own_portfolio", return_value=[]):
card = compute_special_indices(db, spec=_SPEC, district=None, cad_num=None)
# упавший индекс деградировал в fallback (НЕ крашнул карточку).
assert card.indices[KEY_ARTIFICIAL_DEMAND].method == _METHOD_UNAVAILABLE
assert card.indices[KEY_ARTIFICIAL_DEMAND].value is None
assert len(card.indices) == 6
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбойного индекса проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
# ──────────────────────────────────────────────────────────────────────────────
# RELEASE-SAVEPOINT trap (#2464 cluster A, second-order): `_query_parcel_centroid` —
# ВНУТРЕННИЙ helper, зовётся из `_build_cannibalization` ПОД внешним SAVEPOINT
# `_run`. Если он глотает db-сбой БЕЗ своего savepoint, tx остаётся aborted; внешний
# `_run`-savepoint выходит «нормально» → RELEASE SAVEPOINT → RELEASE в aborted-tx САМ
# падает (Postgres допускает лишь ROLLBACK / ROLLBACK TO SAVEPOINT) → внешний except
# ловит RELEASE-ошибку, но tx не откачена → отравление каскадит в следующий индекс.
# Тест воспроизводит ИМЕННО RELEASE-trap: `_ReleaseTrapSession` моделирует, что
# RELEASE в aborted-tx падает (без внутреннего savepoint тест краснеет).
# ──────────────────────────────────────────────────────────────────────────────
class _FakeReleaseResult:
"""Мини-результат db.execute() — покрывает `.mappings().first()`."""
def mappings(self) -> _FakeReleaseResult:
return self
def first(self) -> None:
return None
class _ReleaseTrapSession:
"""Fake Session, моделирующая Postgres SAVEPOINT/RELEASE/ROLLBACK в aborted-tx.
`execute()` бросает РОВНО раз (помечает tx aborted). `begin_nested()`:
ВХОД в poisoned-tx SAVEPOINT запрещён бросает;
ВЫХОД с исключением ROLLBACK TO SAVEPOINT (снимает poison), пробрасывает;
ВЫХОД нормально RELEASE SAVEPOINT, и если poisoned RELEASE ПАДАЕТ (trap).
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeReleaseResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated centroid query failure")
return _FakeReleaseResult()
@contextlib.contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
try:
yield
except Exception:
self.poisoned = False # ROLLBACK TO SAVEPOINT
raise
else:
if self.poisoned:
# RELEASE SAVEPOINT в aborted-tx ПАДАЕТ — тот самый trap.
raise RuntimeError(
"current transaction is aborted, "
"commands ignored until end of transaction block"
)
def _outer_savepoint_call(db: Any, fn: Any, *, fallback: Any) -> Any:
"""Зеркало `_run`/`_safe_call`: fn() под ВНЕШНИМ savepoint (RELEASE на норм-выходе)."""
try:
with db.begin_nested():
return fn()
except Exception:
return fallback
class TestReleaseSavepointTrapParcelCentroid:
"""`_query_parcel_centroid` НЕ отравляет внешний SAVEPOINT (RELEASE-trap)."""
def test_centroid_failure_survives_outer_release(self) -> None:
db = _ReleaseTrapSession() # первый execute (centroid) падает
result = _outer_savepoint_call(
db,
lambda: _query_parcel_centroid(db, cad_num="66:41:0303161:123"),
fallback="OUTER_FALLBACK",
)
# centroid проглочен → его fallback (None), НЕ внешний OUTER_FALLBACK: значит
# внутренний savepoint откатил сбой и внешний RELEASE прошёл чисто.
assert result is None
# КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
db.execute("SELECT 1")
# ──────────────────────────────────────────────────────────────────────────────
# §25.3 Cannibalization dispatch — TRUE own-portfolio vs PROXY fallback
# ──────────────────────────────────────────────────────────────────────────────

View file

@ -116,6 +116,33 @@ def test_concepts_degenerate_parcel_returns_422() -> None:
assert response.status_code == 422
def test_concepts_projected_coordinates_returns_422_not_500() -> None:
# Регресс эпик #2445 B1: parcel_geojson в проекции (МСК-66/UTM-like метры) вместо
# WGS84 lon/lat раньше падал неперехваченным pyproj.CRSError -> opaque 500. Должен
# быть чистый 422 (ParcelGeometryError), как и для прочих невалидных геометрий.
projected = {
"type": "Polygon",
"coordinates": [
[
[500_000.0, 6_200_000.0],
[500_450.0, 6_200_000.0],
[500_450.0, 6_200_310.0],
[500_000.0, 6_200_310.0],
[500_000.0, 6_200_000.0],
]
],
}
response = _post(
{
"parcel_geojson": projected,
"housing_class": "comfort",
"target_floors": 9,
"development_type": "mid_rise",
}
)
assert response.status_code == 422, response.text
def test_concepts_response_matches_contract_keys() -> None:
response = _post(
{

View file

@ -119,6 +119,25 @@ def test_pdf_html_build_graceful_on_empty() -> None:
assert "нет вариантов" in html
def test_pdf_caveat_does_not_contradict_office_revenue_row() -> None:
"""Эпик #2445 C1: каскад включает нежилую выручку/НДС — caveat не должен это отрицать.
Payload ``comfort`` (office_share=0.05 в teap.py), поэтому строка "Выручка —
нежилое (1-й этаж)" в таблице ненулевая: caveat ниже НЕ может утверждать, что
коммерция/офисы "не учитываются" или что НДС "только машиноместа".
"""
variants = geometry.generate(_payload())
html = pdf._build_html(variants)
assert "Выручка — нежилое (1-й этаж), млн руб" in html
assert "НДС (нежилое: паркинг + коммерция), млн руб" in html
# Старые (стейл) формулировки, отрицающие модель нежилого, — не должны вернуться.
assert "не учитываются" not in html
assert "только на нежилую часть" not in html
assert "только машиноместа" not in html
# Caveat честно описывает нежилое как учтённое (не «нет в ТЭП»).
assert "нет в ТЭП" not in html
@pytest.mark.skipif(
not _weasyprint_available(),
reason="WeasyPrint native libs unavailable on this host",

View file

@ -117,6 +117,24 @@ def test_linestring_rejected() -> None:
geometry.parse_parcel(payload)
def test_projected_coordinates_rejected_with_clear_message() -> None:
# Регресс эпик #2445 B1: координаты в проекции (МСК-66/UTM-like метры, напр.
# [500000, 6200000]) вместо WGS84 lon/lat — синтаксически валидный Polygon, но
# centroid.y (~6_200_000) далеко за пределами ±90° -> раньше падало неперехваченным
# pyproj.exceptions.CRSError внутри _metric_transformers (opaque 500). Теперь
# ловится ДО построения проекции с понятным ParcelGeometryError (-> 422).
projected = [
[500_000.0, 6_200_000.0],
[500_450.0, 6_200_000.0],
[500_450.0, 6_200_310.0],
[500_000.0, 6_200_310.0],
[500_000.0, 6_200_000.0],
]
payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [projected]})
with pytest.raises(ParcelGeometryError, match="WGS84"):
geometry.parse_parcel(payload)
# Второй, заведомо меньший контур (~90 m x 45 m) поодаль от основного участка.
_SMALL_CONTOUR = [
[60.610, 56.840],

View file

@ -0,0 +1,72 @@
"""Тест для #2445 D2: scrape_catalog_batch() (per-flat catalog scraper, #2442)
должен конструировать BrowserSession с throttled concurrency/jitter
(settings.scrape_kn_*), а НЕ с модульным дефолтом stealth.py (concurrency=8,
jitter 600-1500ms).
Этот scraper ходит по тому же /сервисы/* path family, что вызвал DOM.РФ WAF
hard-ban 2026-05-24 (#2443) — без явного override BrowserSession тихо унаследовал
бы дефолт, углубляя риск повторного бана.
"""
from __future__ import annotations
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.core.config import settings
class _FakeBrowserSession:
"""Async context manager stub — избегаем реального Playwright/network."""
def __init__(self, *args: object, **kwargs: object) -> None:
self.init_kwargs = kwargs
self.warm_up = AsyncMock()
async def __aenter__(self) -> _FakeBrowserSession:
return self
async def __aexit__(self, *exc: object) -> None:
return None
@pytest.mark.asyncio
async def test_scrape_catalog_batch_passes_throttled_browsersession_kwargs() -> None:
from app.services.scrapers import domrf_catalog as mod
captured: dict[str, object] = {}
def _factory(*args: object, **kwargs: object) -> _FakeBrowserSession:
captured.update(kwargs)
return _FakeBrowserSession(*args, **kwargs)
db = MagicMock()
flats = [{"ods_id": "abc123", "catalog_url_hash": "hash1", "obj_id": 1}]
fake_outcome = {
"success": True,
"fields_extracted": 3,
"updated": True,
"plan_found": False,
"plan_downloaded": False,
}
with (
patch.object(mod, "BrowserSession", side_effect=_factory) as mock_cls,
patch.object(mod, "scrape_one_flat", new=AsyncMock(return_value=fake_outcome)),
):
stats = await mod.scrape_catalog_batch(
db=db,
flats=flats,
region_code=66,
snapshot_date=date(2026, 5, 17),
)
mock_cls.assert_called_once()
assert captured["concurrency"] == settings.scrape_kn_browser_concurrency
assert captured["jitter_min_ms"] == settings.scrape_kn_request_jitter_min_ms
assert captured["jitter_max_ms"] == settings.scrape_kn_request_jitter_max_ms
# Sanity: эти throttled значения — НЕ модульный дефолт stealth.py.
assert captured["concurrency"] != 8
assert stats["success"] == 1

View file

@ -0,0 +1,63 @@
"""Тест для #2445 D2: scrape_catalog_objects() должен конструировать BrowserSession
с throttled concurrency/jitter (settings.scrape_kn_*), а НЕ с модульным дефолтом
stealth.py (concurrency=8, jitter 600-1500ms).
Этот scraper ходит по тому же /сервисы/* path family, что вызвал DOM.РФ WAF
hard-ban 2026-05-24 (#2443) — без явного override BrowserSession тихо унаследовал
бы дефолт, углубляя риск повторного бана.
"""
from __future__ import annotations
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.core.config import settings
class _FakeBrowserSession:
"""Async context manager stub — избегаем реального Playwright/network."""
def __init__(self, *args: object, **kwargs: object) -> None:
self.init_kwargs = kwargs
self.warm_up = AsyncMock()
async def __aenter__(self) -> _FakeBrowserSession:
return self
async def __aexit__(self, *exc: object) -> None:
return None
@pytest.mark.asyncio
async def test_scrape_catalog_objects_passes_throttled_browsersession_kwargs() -> None:
from app.services.scrapers import domrf_catalog_object as mod
captured: dict[str, object] = {}
def _factory(*args: object, **kwargs: object) -> _FakeBrowserSession:
captured.update(kwargs)
return _FakeBrowserSession(*args, **kwargs)
db = MagicMock()
with (
patch.object(mod, "BrowserSession", side_effect=_factory) as mock_cls,
patch.object(mod, "scrape_catalog_object", new=AsyncMock(return_value=True)),
):
stats = await mod.scrape_catalog_objects(
db=db,
obj_ids=[65136],
snapshot_date=date(2026, 5, 17),
region_code=66,
)
mock_cls.assert_called_once()
assert captured["concurrency"] == settings.scrape_kn_browser_concurrency
assert captured["jitter_min_ms"] == settings.scrape_kn_request_jitter_min_ms
assert captured["jitter_max_ms"] == settings.scrape_kn_request_jitter_max_ms
# Sanity: эти throttled значения — НЕ модульный дефолт stealth.py.
assert captured["concurrency"] != 8
assert stats["succeeded"] == 1

View file

@ -0,0 +1,211 @@
"""Тесты для extraction plan_image_url из страницы квартиры DOM.РФ (issue #2440).
Покрывает:
- __NEXT_DATA__ pageProps (primary): URL плана под plan-hint ключом.
- bare <img> fallback: plan-hint в атрибутах / proximity к «Планировка».
- нормализация URL (относительный absolute; data:-inline / шум отбрасываются).
- интеграция с parse_catalog_flat: ключ plan_image_url в результате.
- _TextCollector НЕ ломает text-extraction при наличии <img> (регрессия #1608).
Фикстуры инлайновые (минимальный реалистичный HTML), без live-fetch DOM.РФ
каталог-квартир это Next.js SSR (тот же стек что страница объекта в
domrf_catalog_object.py, где план живёт в __NEXT_DATA__, а не в <img src>).
"""
from __future__ import annotations
import json
import pytest
from app.services.scrapers.domrf_catalog import (
_normalize_plan_url,
_TextCollector,
extract_plan_image_url,
parse_catalog_flat,
)
from app.services.scrapers.stealth import BASE_URL
# Абсолютный file-API URL DOM.РФ (реальный паттерн, см. documents.py:17).
PLAN_ABS_URL = f"{BASE_URL}/api/ext/file/plan-12345.png"
# Относительный next/image URL (Next.js image optimizer).
PLAN_REL_NEXT = "/_next/image?url=%2Fapi%2Fext%2Ffile%2Fplan-777.png&w=1200&q=75"
def _html_with_next_data(page_props: dict) -> str:
"""SSR HTML с __NEXT_DATA__ блоком, содержащим pageProps."""
blob = json.dumps({"props": {"pageProps": page_props}})
return (
"<!doctype html><html><head>"
f'<script id="__NEXT_DATA__" type="application/json">{blob}</script>'
"</head><body>"
"<h1>Квартира 2-комнатная</h1>"
"<div>4 500 000 ₽</div>"
"</body></html>"
)
def _collector_for(html: str) -> _TextCollector:
c = _TextCollector()
c.feed(html)
return c
# ── __NEXT_DATA__ primary path ────────────────────────────────────────────────
def test_plan_from_next_data_plan_image_key() -> None:
"""URL под ключом planImage в pageProps → извлекается (absolute)."""
html = _html_with_next_data({"planImage": PLAN_ABS_URL, "buildingClass": "Комфорт"})
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_plan_from_next_data_layout_key_nested() -> None:
"""URL под вложенным ключом layoutImageUrl → извлекается (BFS обход)."""
html = _html_with_next_data(
{"flat": {"info": {"layoutImageUrl": PLAN_ABS_URL}}, "price": 4_500_000}
)
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_plan_from_next_data_relative_url_normalized() -> None:
"""Относительный next/image URL → абсолютный через BASE_URL."""
html = _html_with_next_data({"floorPlanUrl": PLAN_REL_NEXT})
got = extract_plan_image_url(html, _collector_for(html))
assert got is not None
assert got.startswith(BASE_URL)
assert "plan-777" in got
def test_plan_from_next_data_ignores_non_plan_keys() -> None:
"""Картинка под НЕ-plan ключом (developerLogo) не должна ловиться."""
html = _html_with_next_data(
{"developerLogo": f"{BASE_URL}/api/ext/file/logo.png", "buildingClass": "Бизнес"}
)
assert extract_plan_image_url(html, _collector_for(html)) is None
def test_plan_from_next_data_ignores_noise_url() -> None:
"""plan-ключ, но URL содержит logo/thumb (шум) → отбрасывается."""
html = _html_with_next_data({"planThumb": f"{BASE_URL}/api/ext/file/plan-thumb.png"})
# 'thumb' в URL → _IMAGE_NOISE_RE → None
assert extract_plan_image_url(html, _collector_for(html)) is None
# ── bare <img> fallback ───────────────────────────────────────────────────────
def test_plan_from_img_class_hint() -> None:
"""Нет __NEXT_DATA__: <img class='flat-plan__image'> → извлекается по class."""
html = (
"<!doctype html><html><body>"
f'<img class="flat-plan__image" alt="" src="{PLAN_ABS_URL}">'
"</body></html>"
)
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_plan_from_img_src_hint() -> None:
"""<img> без plan-класса, но src содержит 'plan' → извлекается."""
html = f'<!doctype html><html><body><img src="{PLAN_ABS_URL}"></body></html>'
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_plan_from_img_proximity_to_label() -> None:
"""<img> без plan-hint в атрибутах, но рядом с блоком «Планировка»."""
plain = f"{BASE_URL}/api/ext/file/imgabc.png"
html = (
"<!doctype html><html><body>"
"<div>Планировка</div>"
f'<img alt="" src="{plain}">'
"</body></html>"
)
got = extract_plan_image_url(html, _collector_for(html))
assert got == plain
def test_plan_from_img_skips_logo_and_captcha() -> None:
"""Лого/captcha <img> игнорируются, даже если стоят первыми."""
html = (
"<!doctype html><html><body>"
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
'<img class="robot" src="data:image/jpeg;base64,AAAA">'
"<div>Планировка квартиры</div>"
f'<img class="plan-view" src="{PLAN_ABS_URL}">'
"</body></html>"
)
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_no_plan_returns_none() -> None:
"""Нет ни __NEXT_DATA__ плана, ни plan-img → None."""
html = (
"<!doctype html><html><body>"
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
"<p>Площадь 45 м²</p>"
"</body></html>"
)
assert extract_plan_image_url(html, _collector_for(html)) is None
# ── URL normalization ─────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"raw,expected_prefix",
[
(PLAN_ABS_URL, PLAN_ABS_URL),
("/api/ext/file/plan.png", f"{BASE_URL}/api/ext/file/plan.png"),
("//cdn.example/plan.png", "https://cdn.example/plan.png"),
],
)
def test_normalize_plan_url_variants(raw: str, expected_prefix: str) -> None:
assert _normalize_plan_url(raw) == expected_prefix
@pytest.mark.parametrize("raw", ["", None, "data:image/png;base64,AAAA", " "])
def test_normalize_plan_url_rejects_junk(raw: str | None) -> None:
assert _normalize_plan_url(raw) is None
# ── integration: parse_catalog_flat ───────────────────────────────────────────
def test_parse_catalog_flat_includes_plan_image_url() -> None:
"""parse_catalog_flat кладёт plan_image_url в результат при наличии плана."""
html = _html_with_next_data({"planImage": PLAN_ABS_URL, "price": 4_500_000})
result = parse_catalog_flat(html)
assert result.get("plan_image_url") == PLAN_ABS_URL
def test_parse_catalog_flat_omits_plan_when_absent() -> None:
"""Нет плана → ключ plan_image_url отсутствует (а не None)."""
html = "<!doctype html><html><body><p>Площадь 45 м²</p></body></html>"
result = parse_catalog_flat(html)
assert "plan_image_url" not in result
# ── regression: <img> capture must not break text-extraction (#1608) ──────────
def test_img_capture_preserves_text_blocks() -> None:
"""Наличие <img> (void-тег) не рассинхронизирует стек — текст блоков цел."""
html = (
"<!doctype html><html><body>"
'<div class="price-block">4 500 000 ₽</div>'
f'<img src="{PLAN_ABS_URL}">'
'<div class="status-badge">Продана</div>'
"</body></html>"
)
collector = _collector_for(html)
texts = [t for _cls, t in collector.blocks]
assert any("4 500 000" in t for t in texts)
assert any("Продана" in t for t in texts)
# и картинка захвачена в отдельный список
assert len(collector.images) == 1
assert collector.images[0][0].get("src") == PLAN_ABS_URL
# parse_catalog_flat всё ещё корректно извлекает status/price при наличии img
result = parse_catalog_flat(html)
assert result.get("status") == "sold"
assert result.get("price_rub") == 4_500_000

View file

@ -55,3 +55,34 @@ class TestFlatIdFallbackDeterministic:
out = _norm_flat({"elemId": "x" * 1000}, region_cd=66)
assert out["id"] is not None
assert 0 <= out["id"] < 2**63
class TestCatalogUrlHash:
"""catalog_url_hash ОБЯЗАН пробрасываться из elemId (#2442 Task 1).
Ранее хардкодился в None flat-catalog scraper выбирал 0 строк
(WHERE catalog_url_hash IS NOT NULL). Теперь берётся из того же elemId,
что и fallback flat_id, и живёт независимо от того, пришёл ли flatId.
"""
def test_hash_populated_from_elem_id(self) -> None:
row = {"elemId": "abc-hash-123"}
out = _norm_flat(row, region_cd=66)
assert out["catalog_url_hash"] == "abc-hash-123"
def test_hash_populated_even_when_flat_id_present(self) -> None:
# flatId присутствует → fallback-ветка flat_id НЕ выполняется, но hash
# всё равно должен прочитаться (регрессия, которую чинит Task 1).
row = {"flatId": 999, "elemId": "hash-with-flatid"}
out = _norm_flat(row, region_cd=66)
assert out["id"] == 999
assert out["catalog_url_hash"] == "hash-with-flatid"
def test_hash_none_when_elem_id_absent(self) -> None:
out = _norm_flat({"flatId": 1}, region_cd=66)
assert out["catalog_url_hash"] is None
def test_hash_empty_string_normalized_to_none(self) -> None:
# Пустую строку трактуем как отсутствие (не пишем "" в БД).
out = _norm_flat({"flatId": 1, "elemId": ""}, region_cd=66)
assert out["catalog_url_hash"] is None

View file

@ -0,0 +1,107 @@
"""#2464 cluster E + F — UPSERT DO UPDATE SET column-omission regression tests.
Both UPSERT_OBJECT_SQL and UPSERT_PHOTO_SQL had columns populated on every
INSERT (fresh scrape values, see _norm_object / catalog scraper) but silently
omitted from `ON CONFLICT ... DO UPDATE SET`. Re-running the scraper for an
already-seen (obj_id, snapshot_date) / (obj_id, obj_file_id) row left those
columns stale forever (INSERT wins once, DO UPDATE never refreshes them).
SQL-string assertions against the module-level `text(...)` constants no live
DB needed; the INSERT column list is the source of truth for "must this column
also be kept fresh on conflict", so each test also cross-checks against the
INSERT list to prevent the same class of omission from creeping back in for a
newly-added column.
"""
from __future__ import annotations
import re
from app.services.scrapers.domrf_kn import UPSERT_OBJECT_SQL, UPSERT_PHOTO_SQL
_OBJECT_PREVIOUSLY_OMITTED = [
"problem_flag",
"green_house",
"floor_min",
"floor_max",
"hobj_id",
"short_addr",
"dev_inn",
"region_cd",
]
def _insert_columns(sql: str, table: str) -> list[str]:
"""Extract the `INSERT INTO <table> (col1, col2, ...)` column list."""
m = re.search(rf"INSERT INTO {table}\s*\(([^)]+)\)", sql)
assert m, f"could not locate INSERT INTO {table} (...) column list"
return [c.strip() for c in m.group(1).split(",")]
def _do_update_set_columns(sql: str) -> set[str]:
"""Extract every `col = ` LHS from the `DO UPDATE SET` clause."""
m = re.search(r"DO UPDATE SET(.*)", sql, re.DOTALL)
assert m, "could not locate ON CONFLICT ... DO UPDATE SET clause"
body = m.group(1)
return set(re.findall(r"(?:^|,)\s*(\w+)\s*=", body, re.MULTILINE))
class TestUpsertObjectSqlDoUpdateSet:
def test_previously_omitted_columns_now_present(self) -> None:
sql = str(UPSERT_OBJECT_SQL)
updated = _do_update_set_columns(sql)
missing = [c for c in _OBJECT_PREVIOUSLY_OMITTED if c not in updated]
assert not missing, f"still missing from DO UPDATE SET: {missing}"
def test_previously_omitted_columns_use_direct_excluded_not_coalesce(self) -> None:
# These 8 are freshly computed every scrape call (per _norm_object) --
# COALESCE would permanently pin a stale value once ever set (most
# visibly wrong for problem_flag: a resolved problem must be able to
# clear back to NULL, not get stuck "still problematic" forever).
sql = " ".join(str(UPSERT_OBJECT_SQL).split())
for col in _OBJECT_PREVIOUSLY_OMITTED:
assert (
f"{col} = EXCLUDED.{col}" in sql
), f"{col} should be direct `= EXCLUDED.{col}` (not COALESCE):\n{sql}"
# guard against a COALESCE(EXCLUDED.col, ...) formulation, which would
# permanently pin a stale value once ever set instead of refreshing it.
assert (
f"COALESCE(EXCLUDED.{col}," not in sql
), f"{col} must not be wrapped in COALESCE (needs to be able to clear to NULL)"
def test_all_insert_columns_covered_by_do_update_set_or_conflict_target(self) -> None:
# Locks in the class of bug (INSERT populates a column, DO UPDATE SET
# silently drops it) so it can't recur for a column added later.
sql = str(UPSERT_OBJECT_SQL)
insert_cols = set(_insert_columns(sql, "domrf_kn_objects"))
updated_cols = _do_update_set_columns(sql)
conflict_target = {"obj_id", "snapshot_date"} # immutable conflict key, not updated
missing = insert_cols - updated_cols - conflict_target
assert not missing, f"INSERT columns missing from DO UPDATE SET: {sorted(missing)}"
def test_no_double_colon_cast(self) -> None:
sql = str(UPSERT_OBJECT_SQL)
assert re.search(r":[a-z_]+::[a-z]", sql) is None
class TestUpsertPhotoSqlDoUpdateSet:
def test_build_type_now_present(self) -> None:
sql = str(UPSERT_PHOTO_SQL)
updated = _do_update_set_columns(sql)
assert "build_type" in updated
def test_build_type_uses_direct_excluded(self) -> None:
sql = " ".join(str(UPSERT_PHOTO_SQL).split())
assert "build_type = EXCLUDED.build_type" in sql
def test_all_insert_columns_covered_by_do_update_set_or_conflict_target(self) -> None:
sql = str(UPSERT_PHOTO_SQL)
insert_cols = set(_insert_columns(sql, "domrf_kn_photos"))
updated_cols = _do_update_set_columns(sql)
conflict_target = {"obj_id", "obj_file_id"}
missing = insert_cols - updated_cols - conflict_target
assert not missing, f"INSERT columns missing from DO UPDATE SET: {sorted(missing)}"
def test_no_double_colon_cast(self) -> None:
sql = str(UPSERT_PHOTO_SQL)
assert re.search(r":[a-z_]+::[a-z]", sql) is None

View file

@ -0,0 +1,136 @@
"""Тесты для download_plan_image() из flat_plans.py (issue #2440).
Покрывает:
- успешное скачивание: файл на диске, width/height/size_bytes через Pillow.
- idempotency: повторный вызов не качает заново (download_binary не дёргается).
- ошибка скачивания None, файл не создаётся.
- _safe_ods_filename / _ext_from_url helpers.
Использует фейковый BrowserSession с in-memory PNG (Pillow генерит 3x2 картинку)
без сети и без реального Playwright.
"""
from __future__ import annotations
import io
from pathlib import Path
from typing import Any
import pytest
from PIL import Image
from app.services.scrapers.flat_plans import (
_ext_from_url,
_safe_ods_filename,
download_plan_image,
)
def _png_bytes(w: int = 3, h: int = 2) -> bytes:
buf = io.BytesIO()
Image.new("RGB", (w, h), (10, 20, 30)).save(buf, format="PNG")
return buf.getvalue()
class _FakeSession:
"""Минимальный BrowserSession-дублёр: download_binary отдаёт заданные байты."""
def __init__(self, payload: bytes | None, raise_exc: Exception | None = None) -> None:
self._payload = payload
self._raise = raise_exc
self.calls = 0
async def download_binary(self, url: str) -> bytes:
self.calls += 1
if self._raise is not None:
raise self._raise
assert self._payload is not None
return self._payload
# ── helpers ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"ods,expected",
[
("65136/1/1.4.3", "65136_1_1.4.3"),
("plain", "plain"),
("a//b", "a_b"),
("///", "plan"),
],
)
def test_safe_ods_filename(ods: str, expected: str) -> None:
assert _safe_ods_filename(ods) == expected
@pytest.mark.parametrize(
"url,expected",
[
("https://x/plan.png", ".png"),
("https://x/plan.JPG", ".jpg"),
("https://x/plan.jpeg", ".jpg"),
("https://x/api/ext/file/abc", ".png"), # no ext → default png
("https://x/plan.svg", ".svg"),
],
)
def test_ext_from_url(url: str, expected: str) -> None:
assert _ext_from_url(url) == expected
# ── download ──────────────────────────────────────────────────────────────────
def _read(path: str) -> bytes:
"""Sync file read — вынесено из async-теста, чтобы не тревожить ASYNC240."""
return Path(path).read_bytes()
def _exists(path: Path) -> bool:
return path.exists()
async def test_download_writes_file_and_dims(tmp_path: Path) -> None:
png = _png_bytes(3, 2)
sess: Any = _FakeSession(png)
meta = await download_plan_image(
sess, "https://x/api/ext/file/plan1.png", "65136/1/1.4.3", 65136, base_dir=tmp_path
)
assert meta is not None
assert meta["width_px"] == 3
assert meta["height_px"] == 2
assert meta["size_bytes"] == len(png)
assert meta["downloaded_at"] is not None
p = Path(meta["local_path"])
assert _read(str(p)) == png
# путь: {base}/{obj_id}/{safe_ods}.png
assert p.parent.name == "65136"
assert p.name == "65136_1_1.4.3.png"
async def test_download_idempotent_skips_second_fetch(tmp_path: Path) -> None:
png = _png_bytes(4, 4)
sess: Any = _FakeSession(png)
m1 = await download_plan_image(sess, "https://x/plan.png", "1/2/3", 42, base_dir=tmp_path)
assert m1 is not None
assert sess.calls == 1
# второй вызов: файл уже на диске → download_binary НЕ дёргается
m2 = await download_plan_image(sess, "https://x/plan.png", "1/2/3", 42, base_dir=tmp_path)
assert m2 is not None
assert sess.calls == 1 # не увеличилось
assert m2["local_path"] == m1["local_path"]
assert m2["size_bytes"] == len(png)
async def test_download_failure_returns_none(tmp_path: Path) -> None:
sess: Any = _FakeSession(None, raise_exc=RuntimeError("binary http 403"))
meta = await download_plan_image(sess, "https://x/plan.png", "9/9/9", 99, base_dir=tmp_path)
assert meta is None
# файл не создан
assert not _exists(tmp_path / "99")
async def test_download_empty_body_returns_none(tmp_path: Path) -> None:
sess: Any = _FakeSession(b"")
meta = await download_plan_image(sess, "https://x/plan.png", "9/9/9", 99, base_dir=tmp_path)
assert meta is None

View file

@ -174,7 +174,7 @@ class TestExtractIzyatieRecords:
def test_raw_excerpt_for_first_record_only(self) -> None:
"""raw_excerpt заполняется только для первой записи."""
text = "Изъять: 66:41:0101001:123 участок 1;\n" "66:41:0101001:456 участок 2."
text = "Изъять: 66:41:0101001:123 участок 1;\n66:41:0101001:456 участок 2."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert len(records) == 2
assert records[0]["raw_excerpt"] is not None
@ -506,3 +506,201 @@ class TestIngestIzyatieOcr:
# Первый документ пропущен (ошибка), второй обработан (пустой OCR).
assert result["docs"] == 1
assert result["parcels"] == 0
def test_ocr_failure_on_one_doc_does_not_abort_batch(self) -> None:
"""OCR (Tesseract) падает на документе 2 из 3 — документы 1 и 3
всё равно обрабатываются и коммитятся (issue #2445 D5).
Раньше только fetch_pdf был защищён try/except падение
ocr_pdf_text пробрасывалось из цикла и валило остаток пачки.
"""
from app.workers.tasks.izyatie_ocr_ingest import ingest_izyatie_ocr
mock_docs = [
{"title": "Документ 1", "url": "https://example.com/file/1"},
{"title": "Документ 2 (сломанный OCR)", "url": "https://example.com/file/2"},
{"title": "Документ 3", "url": "https://example.com/file/3"},
]
def ocr_side_effect(pdf_bytes: bytes) -> str:
if pdf_bytes == b"pdf-2":
raise Exception("Tesseract crashed on malformed PDF")
return "66:41:0101001:123"
def fetch_side_effect(url: str) -> bytes:
return b"pdf-" + url.rsplit("/", 1)[-1].encode()
mock_records = [
{
"cad_num": "66:41:0101001:123",
"reservation_kind": "изъятие",
"basis_act": "test",
"act_number": None,
"act_date": None,
"purpose": None,
"doc_url": "https://example.com/file/x",
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
}
]
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
with (
patch(
"app.services.scrapers.izyatie_client.list_izyatie_documents",
return_value=mock_docs,
),
patch(
"app.services.scrapers.izyatie_client.fetch_pdf",
side_effect=fetch_side_effect,
),
patch(
"app.services.scrapers.izyatie_ocr.ocr_pdf_text",
side_effect=ocr_side_effect,
),
patch(
"app.services.scrapers.izyatie_ocr.extract_izyatie_records",
return_value=mock_records,
),
patch("app.workers.tasks.izyatie_ocr_ingest.SessionLocal", return_value=mock_db),
):
result = ingest_izyatie_ocr()
# Документ 2 упал на OCR и не засчитан; 1 и 3 успешно обработаны и закоммичены.
assert result["docs"] == 2
assert result["parcels"] == 2
# db.commit() вызывался ровно для 1 и 3 (документ 2 не дошёл до commit).
assert mock_db.commit.call_count == 2
# rollback() вызван один раз — после сбоя документа 2.
assert mock_db.rollback.call_count == 1
def test_extract_failure_on_one_doc_does_not_abort_batch(self) -> None:
"""Баг парсера в extract_izyatie_records на документе 2 из 3 — документы
1 и 3 всё равно обрабатываются (issue #2445 D5)."""
from app.workers.tasks.izyatie_ocr_ingest import ingest_izyatie_ocr
mock_docs = [
{"title": "Документ 1", "url": "https://example.com/file/1"},
{"title": "Документ 2 (баг парсера)", "url": "https://example.com/file/2"},
{"title": "Документ 3", "url": "https://example.com/file/3"},
]
good_record = [
{
"cad_num": "66:41:0101001:999",
"reservation_kind": "изъятие",
"basis_act": "test",
"act_number": None,
"act_date": None,
"purpose": None,
"doc_url": "https://example.com/file/x",
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
}
]
def extract_side_effect(ocr_text: str, doc_title: str, doc_url: str) -> list[dict]:
if doc_url.endswith("/2"):
raise ValueError("unexpected OCR text shape crashed the parser")
return good_record
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
with (
patch(
"app.services.scrapers.izyatie_client.list_izyatie_documents",
return_value=mock_docs,
),
patch(
"app.services.scrapers.izyatie_client.fetch_pdf",
return_value=b"fake pdf",
),
patch(
"app.services.scrapers.izyatie_ocr.ocr_pdf_text",
return_value="66:41:0101001:999",
),
patch(
"app.services.scrapers.izyatie_ocr.extract_izyatie_records",
side_effect=extract_side_effect,
),
patch("app.workers.tasks.izyatie_ocr_ingest.SessionLocal", return_value=mock_db),
):
result = ingest_izyatie_ocr()
assert result["docs"] == 2
assert result["parcels"] == 2
assert mock_db.commit.call_count == 2
assert mock_db.rollback.call_count == 1
def test_upsert_failure_on_one_doc_does_not_abort_batch(self) -> None:
"""db.commit()/upsert падает на документе 2 из 3 — документы 1 и 3
всё равно обрабатываются и коммитятся, а session откатывается после
сбоя вместо отравления следующего commit (issue #2445 D5)."""
from app.workers.tasks.izyatie_ocr_ingest import ingest_izyatie_ocr
mock_docs = [
{"title": "Документ 1", "url": "https://example.com/file/1"},
{"title": "Документ 2 (сбой commit)", "url": "https://example.com/file/2"},
{"title": "Документ 3", "url": "https://example.com/file/3"},
]
record = [
{
"cad_num": "66:41:0101001:1",
"reservation_kind": "изъятие",
"basis_act": "test",
"act_number": None,
"act_date": None,
"purpose": None,
"doc_url": "https://example.com/file/x",
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
}
]
commit_calls = 0
def commit_side_effect() -> None:
nonlocal commit_calls
commit_calls += 1
if commit_calls == 2:
raise Exception("db commit failed: connection reset")
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
mock_db.commit.side_effect = commit_side_effect
with (
patch(
"app.services.scrapers.izyatie_client.list_izyatie_documents",
return_value=mock_docs,
),
patch(
"app.services.scrapers.izyatie_client.fetch_pdf",
return_value=b"fake pdf",
),
patch(
"app.services.scrapers.izyatie_ocr.ocr_pdf_text",
return_value="66:41:0101001:1",
),
patch(
"app.services.scrapers.izyatie_ocr.extract_izyatie_records",
return_value=record,
),
patch("app.workers.tasks.izyatie_ocr_ingest.SessionLocal", return_value=mock_db),
):
result = ingest_izyatie_ocr()
# Документ 2 не засчитан (commit упал), 1 и 3 обработаны успешно.
assert result["docs"] == 2
assert result["parcels"] == 2
# commit() вызван 3 раза (для каждого документа), 2-й вызов упал.
assert mock_db.commit.call_count == 3
# rollback() вызван ровно один раз — после сбойного commit документа 2.
assert mock_db.rollback.call_count == 1

View file

@ -8,6 +8,9 @@
- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=).
- fetch_okn_detail: HTML карточки {name, address, }; graceful при ошибках.
- _parse_detail_html: <dl>/<dt>/<dd> и <tr>/<td> структура парсится; маркеры label.
- WAF-hygiene (#2445 item D3): rate-limit sleep между запросами (общий searchMap+detail),
экспоненциальный backoff на 429/5xx, Retry-After учитывается и капается, ретраи
исчерпываются на устойчивой ошибке без raise.
"""
from __future__ import annotations
@ -109,6 +112,9 @@ def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock:
resp.status_code = status
resp.json.return_value = payload
resp.raise_for_status = MagicMock()
# headers по умолчанию пуст (dict, не MagicMock) — иначе resp.headers.get(...)
# в backoff-коде вернул бы MagicMock вместо None и сломал бы Retry-After parsing.
resp.headers = {}
if status >= 400:
import httpx
@ -124,6 +130,8 @@ def _mock_html_response(html: str, status: int = 200) -> MagicMock:
resp.status_code = status
resp.text = html
resp.raise_for_status = MagicMock()
# См. _mock_response — headers должен быть настоящим dict, не MagicMock.
resp.headers = {}
if status >= 400:
import httpx
@ -170,16 +178,21 @@ def test_fetch_okn_points_empty_features() -> None:
def test_fetch_okn_points_http_error_returns_empty() -> None:
"""HTTP 500 → пустой список (не raise)."""
"""HTTP 500 → пустой список (не raise) после исчерпания ретраев backoff."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response({}, status=500)
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Свердловская", 1)
assert result == []
# 500 ретраится _MAX_RETRIES раз перед сдачей — backoff-sleep вызывается.
assert mock_sleep.call_count >= 1
def test_fetch_okn_points_invalid_json_returns_empty() -> None:
@ -437,13 +450,16 @@ def test_fetch_okn_detail_returns_parsed_fields_on_success() -> None:
def test_fetch_okn_detail_http_error_returns_empty() -> None:
"""HTTP 500 на detail → пустой dict, не raise."""
"""HTTP 500 на detail → пустой dict, не raise (после исчерпания ретраев backoff)."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response("", status=500)
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep"),
):
result = fetch_okn_detail("001")
assert result == {}
@ -482,3 +498,149 @@ def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
assert len(clients) == 2
assert clients[0]["verify"] is True
assert clients[1]["verify"] is False
# ── WAF-hygiene: rate-limit + 429/5xx backoff (#2445 item D3) ────────────────
def test_fetch_okn_points_sleeps_between_requests_for_rate_limit() -> None:
"""Второй вызов fetch_okn_points почти сразу после первого → time.sleep вызван
(rate-limit держит паузу между последовательными запросами к WAF-хосту).
"""
payload = _make_geojson_response([_make_feature("rl-001", 56.83, 60.61)])
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response(payload)
import app.services.scrapers.okn_egrkn_client as client_mod
# Форсируем "последний запрос был только что" — следующий вызов обязан поспать.
client_mod._last_request_ts = client_mod.time.time()
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
fetch_okn_points("Екатеринбург", 1)
mock_sleep.assert_called_once()
(slept_s,) = mock_sleep.call_args.args
assert 0 < slept_s <= client_mod._RATE_LIMIT_MS / 1000.0
def test_fetch_okn_detail_shares_rate_limit_with_searchmap() -> None:
"""Rate-limit общий между searchMap и detail — один WAF-хост okn-mk.mkrf.ru."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
import app.services.scrapers.okn_egrkn_client as client_mod
client_mod._last_request_ts = client_mod.time.time()
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
fetch_okn_detail("test-id")
mock_sleep.assert_called_once()
def test_do_request_backs_off_on_429_then_succeeds() -> None:
"""429 → экспоненциальный backoff (time.sleep) → retry → успех на второй попытке."""
features = [_make_feature("429-001", 56.83, 60.61)]
payload = _make_geojson_response(features)
responses = [_mock_response({}, status=429), _mock_response(payload)]
# Первая ответка raise_for_status не должна вызываться — код читает status_code
# напрямую и уходит в backoff-branch до raise_for_status().
call_count = {"n": 0}
def _make_client(**kwargs: Any) -> MagicMock:
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
def _post(url: str, **post_kwargs: Any) -> MagicMock:
resp = responses[call_count["n"]]
call_count["n"] += 1
return resp
mc.post.side_effect = _post
return mc
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Екатеринбург", 1)
assert len(result) == 1
assert result[0]["id"] == "429-001"
assert call_count["n"] == 2
# Backoff перед повтором вызван хотя бы раз (плюс возможные rate-limit sleeps).
assert mock_sleep.call_count >= 1
def test_do_request_gives_up_after_max_retries_on_persistent_429() -> None:
"""Устойчивый 429 → ретраи исчерпаны (_MAX_RETRIES), возврат [] без raise."""
import app.services.scrapers.okn_egrkn_client as client_mod
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response({}, status=429)
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Екатеринбург", 1)
assert result == []
# attempt=0..._MAX_RETRIES-1 включительно ретраятся → _MAX_RETRIES backoff-sleeps
# (плюс rate-limit sleeps на каждую из _MAX_RETRIES+1 попыток).
assert mock_sleep.call_count >= client_mod._MAX_RETRIES
def test_do_request_retry_after_header_respected_and_capped() -> None:
"""Retry-After заголовок уважается для расчёта backoff (капается _MAX_BACKOFF_S)."""
import app.services.scrapers.okn_egrkn_client as client_mod
huge_retry_after = str(int(client_mod._MAX_BACKOFF_S) + 500)
resp_429 = _mock_response({}, status=429)
resp_429.headers = {"Retry-After": huge_retry_after}
resp_ok = _mock_response(_make_geojson_response([_make_feature("ra-001", 56.83, 60.61)]))
resp_ok.headers = {}
responses = [resp_429, resp_ok]
call_count = {"n": 0}
def _make_client(**kwargs: Any) -> MagicMock:
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
def _post(url: str, **post_kwargs: Any) -> MagicMock:
resp = responses[call_count["n"]]
call_count["n"] += 1
return resp
mc.post.side_effect = _post
return mc
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Екатеринбург", 1)
assert len(result) == 1
# Backoff-sleep вызывался хотя бы раз с длительностью <= _MAX_BACKOFF_S
# (Retry-After капается потолком, а не используется как есть).
backoff_calls = [c.args[0] for c in mock_sleep.call_args_list]
assert any(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)
assert all(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)

View file

@ -40,6 +40,7 @@ from app.services.site_finder.best_layouts import (
MAX_BUCKET_SHARE_PCT,
NAMELESS_CLUSTER_M,
_cap_and_redistribute,
_fetch_supply_only_layouts,
get_best_layouts,
group_radius_objects,
match_groups_to_projects,
@ -1224,3 +1225,193 @@ def test_supply_only_sql_uses_price_sanity_and_on_sale_filter() -> None:
assert "'studio'" in sql_text and "'4+'" in sql_text
# psycopg3: CAST(:x AS type), НЕ :x::type
assert "::text[]" not in sql_text.replace("CAST(:names AS text[])", "")
# ── SAVEPOINT regression (#2464 cluster A) ────────────────────────────────────
#
# Step 5 (_SUPPLY_BATCH_SQL) db shared с caller (GET .../best-layouts,
# Depends(get_db); sync get_best_layouts мостится через run_in_threadpool). Раньше
# db.execute-сбой ловился в bare ``except Exception`` без SAVEPOINT/rollback — на
# реальном Postgres это отравляет db shared с caller: следующий db.execute на этой
# же сессии (напр. Step 9b _fetch_supply_only_layouts) падает "current transaction
# is aborted". Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не
# эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful
# supply=0 fallback при сбое, (2) что begin_nested() реально вызван вокруг именно
# этого db.execute, (3) что db остаётся usable для последующих вызовов на этой же
# mock-сессии (get_best_layouts не крашит, ранжирование по-прежнему строится).
def test_supply_query_failure_degrades_to_zero_supply() -> None:
"""Сбой Step 5 (_SUPPLY_BATCH_SQL) → supply_units_in_radius=0 для всех строк,
остальной pipeline (velocity/ranking) не ломается."""
id_rows = [_obj_id_row(1)]
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
real_dispatch = db.execute.side_effect
def _execute_raise_on_supply(stmt, params=None, *args, **kwargs):
if stmt is _SUPPLY_BATCH_SQL:
raise RuntimeError("simulated DB failure")
return real_dispatch(stmt, params, *args, **kwargs)
db.execute.side_effect = _execute_raise_on_supply
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].supply_units_in_radius == 0
assert resp.top_layouts[0].sold_pct_of_supply is None
assert resp.top_layouts[0].is_oversold is False
# velocity per-month всё ещё честно посчитан (сбой изолирован в supply-блоке).
assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
def test_supply_query_uses_begin_nested_savepoint() -> None:
"""Фикс: Step 5 запрос обёрнут в db.begin_nested(), не голый db.execute."""
id_rows = [_obj_id_row(1)]
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
supply_rows = [_supply_row("2", "40-60", 80)]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
req = _request(time_window="last_quarter")
get_best_layouts(db, CAD_NUM, req)
assert db.begin_nested.call_count >= 1
def test_supply_query_failure_session_usable_for_supply_only_step() -> None:
"""db остаётся usable после Step 5-сбоя: Step 9b (_fetch_supply_only_layouts,
отдельный db.execute на той же сессии) по-прежнему выполняется без исключения.
Сетап зеркалит test_supply_only_group_without_velocity_appears_in_block: две
группы, «Траектория» с velocity top_layouts, «Символ» без velocity должна
дойти до Step 9b (_fetch_supply_only_layouts) на той же db, что и Step 5.
"""
id_rows = [
_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60),
_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601),
]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])]
supply_only_rows = [_supply_only_row("Символ", room_bucket="1", area_bin="25-40")]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
project_names=["Траектория", "Символ"],
developers_by_project={},
supply_only_rows=supply_only_rows,
)
real_dispatch = db.execute.side_effect
def _execute_raise_on_supply(stmt, params=None, *args, **kwargs):
if stmt is _SUPPLY_BATCH_SQL:
raise RuntimeError("simulated DB failure")
return real_dispatch(stmt, params, *args, **kwargs)
db.execute.side_effect = _execute_raise_on_supply
req = _request(time_window="last_quarter")
# Не должно бросить InFailedSqlTransaction-подобное исключение — Step 9b
# выполняется на той же db без проблем (в mock — просто control-flow guard).
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts[0].supply_units_in_radius == 0
# Step 9b реально выполнился на этой же db — supply-only блок не пуст.
assert len(resp.supply_only_layouts) == 1
assert resp.supply_only_layouts[0].project_name == "Символ"
# ── SAVEPOINT regression: ex-bare-rollback sites (#2464 cluster A) ──────────────
#
# ДВА места в best_layouts.py раньше делали ГОЛЫЙ db.rollback() на SHARED сессии в
# except после сбоя db.execute — это ХУЖЕ простого swallow: bare rollback на общей
# сессии откатывает ВСЮ outer-транзакцию /analyze-запроса (всю несохранённую работу
# ранее в этом же запросе) и orphan'ит outer SessionTransaction (velocity.py:170).
# Фикс — savepoint: db.execute обёрнут в ``with db.begin_nested():``, а bare
# db.rollback() УДАЛЁН (auto-rollback savepoint'а откатывает только сбойный statement,
# не весь запрос). Тесты проверяют: (a) документированный fallback сохранён, (b)
# begin_nested() использован И db.rollback() НЕ вызван (весь смысл — outer tx выживает),
# (c) follow-up db.execute на той же сессии проходит.
class TestFetchSupplyOnlyLayoutsSavepoint:
"""Ex-rollback site #1: _fetch_supply_only_layouts (Step 9b)."""
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_fetch_supply_only_layouts(db, ["ЖК Резерв"])
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_and_no_bare_rollback(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
out = _fetch_supply_only_layouts(db, ["ЖК Резерв"])
assert out == [] # документированный fallback
assert db.begin_nested.call_count == 1 # savepoint использован
db.rollback.assert_not_called() # bare rollback удалён — outer tx выживает
def test_empty_project_names_skips_db_entirely(self) -> None:
# Пустой вход → SQL не выполняется, savepoint не открывается (штатный ранний []).
db = MagicMock()
assert _fetch_supply_only_layouts(db, []) == []
db.execute.assert_not_called()
db.begin_nested.assert_not_called()
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _fetch_supply_only_layouts(db, ["ЖК Резерв"]) == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_core_attribution_failure_uses_savepoint_no_bare_rollback() -> None:
"""Ex-rollback site #2: Step 2b core-attribution db.execute сбоит → SAVEPOINT
(не bare rollback) get_best_layouts деградирует в mapping-only.
Раньше bare db.rollback() здесь откатил бы ВСЮ outer tx /analyze-запроса. Теперь
begin_nested откатывает только сбойный savepoint, а velocity через obj_id (mapping)
по-прежнему считается на той же сессии top_layouts не пуст, outer tx выживает.
"""
id_rows = [_obj_id_row(1)]
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
supply_rows = [_supply_row("2", "40-60", 80)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
project_names=["Символ"],
developers_by_project={},
)
real_dispatch = db.execute.side_effect
def _raise_on_project_names(stmt, params=None, *args, **kwargs):
# Первый из трёх Step 2b запросов — сбой на нём отравил бы сессию без savepoint.
if stmt is _OBJECTIVE_PROJECT_NAMES_SQL:
raise RuntimeError("simulated DB failure")
return real_dispatch(stmt, params, *args, **kwargs)
db.execute.side_effect = _raise_on_project_names
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
# savepoint использован, bare rollback НЕ вызван (outer tx /analyze выживает).
assert db.begin_nested.call_count >= 1
db.rollback.assert_not_called()
# fallback на mapping-only: velocity через obj_id всё равно посчитан на той же
# сессии (Step 3 velocity execute не заблокирован aborted-транзакцией).
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].room_bucket == "2"
assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
# core-match не дал имён (сбой) → matched-only supply-блок пуст, но ответ валиден.
assert resp.data_quality.groups_matched_by_core == 0

View file

@ -0,0 +1,76 @@
"""Пин SQL-формы competitors._AVG_PRICE_SQL (эпик #2445, пункт A5).
Byte-identical бага к best_layouts._SUPPLY_BATCH_SQL (#1956): domrf_kn_flats —
ПО-ОБЪЕКТНЫЙ time-series (каждый ЖК скрейпится в свой день, UNIQUE(obj_id,
snapshot_date), migration 174_domrf_kn_flats_obj_snapshot_idx.sql). Прежний
запрос фильтровал по ОДНОЙ глобальной дате
`f.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_flats)` на этой
единственной дате обычно присутствует лишь 1 объект (скрейпнутый последним),
поэтому у ВСЕХ остальных конкурентов в радиусе получалось 0 строк flats
avg_price_per_m2 = NULL _price_similarity(None, ...) молча возвращал
нейтраль 0.5, тихо деградируя relevance-ранжирование без единого флага.
FIX (порт #1956 в этот файл): flats_latest CTE — DISTINCT ON (f.obj_id) ...
ORDER BY f.obj_id, snapshot_date DESC, id DESC берёт для КАЖДОГО obj_id его
СОБСТВЕННЫЙ последний снимок, затем self-join к domrf_kn_flats по
(obj_id, snapshot_date) достаёт квартиры именно этого снимка.
Эти тесты строковые инварианты на скомпилированном SQL (без живой БД), чтобы
будущий рефактор случайно не вернул глобал-max-date регрессию.
"""
from __future__ import annotations
import os
import re
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.site_finder.competitors import _AVG_PRICE_SQL
_SQL = str(_AVG_PRICE_SQL)
_SQL_FLAT = re.sub(r"\s+", " ", _SQL).lower()
def test_avg_price_uses_flats_latest_per_object_cte() -> None:
"""flats_latest CTE обязателен — без него avg_price снова считается на
единственной глобал-max дате (регрессия #2445 A5, byte-identical к #1956)."""
assert "flats_latest" in _SQL_FLAT, "нет flats_latest CTE → возврат к global-max-date багу"
assert "distinct on (f.obj_id)" in _SQL_FLAT, "flats не дедупятся per-object"
def test_avg_price_orders_latest_snapshot_first() -> None:
"""ORDER BY последним снапшотом вперёд (snapshot_date DESC, id DESC) — DISTINCT ON
берёт первую строку группы собственный последний снимок объекта."""
assert "snapshot_date desc" in _SQL_FLAT
assert "id desc" in _SQL_FLAT
m = re.search(r"order by f\.obj_id, f\.snapshot_date desc, f\.id desc", _SQL_FLAT)
assert m is not None, "DISTINCT ON должен сортировать per obj_id, затем latest snapshot"
def test_avg_price_no_global_max_snapshot_date() -> None:
"""Регрессия #2445 A5: единственный глобальный MAX(snapshot_date) по всей
таблице не должен вернуться это и есть исходный баг (1 объект на весь
global-max день, остальные competitors получают avg_price=NULL)."""
assert "select max(snapshot_date) from domrf_kn_flats" not in _SQL_FLAT
assert "(select max(snapshot_date)" not in _SQL_FLAT
def test_avg_price_joins_flats_by_obj_id_and_snapshot_date() -> None:
"""Self-join к domrf_kn_flats обязан фильтровать И по obj_id, И по
snapshot_date дедуплицированного flats_latest иначе снова fan-out по
истории снимков объекта (см. #1210)."""
assert re.search(r"join\s+domrf_kn_flats\s+f\b", _SQL_FLAT)
assert "f.obj_id = fl.obj_id" in _SQL_FLAT
assert "f.snapshot_date = fl.snapshot_date" in _SQL_FLAT
def test_avg_price_keeps_price_not_null_filter() -> None:
"""AVG(...) FILTER (WHERE price_per_m2 IS NOT NULL) сохранён (Issue #112/227
status='sold' фильтр убран, но NULL-цены по-прежнему исключаются из AVG)."""
assert "avg(f.price_per_m2) filter (where f.price_per_m2 is not null)" in _SQL_FLAT
def test_avg_price_no_double_colon_cast() -> None:
"""psycopg v3 / backend.md: никаких :name::type (игнорируется парсером)."""
assert not re.search(r":[a-z_]+::[a-z]", _SQL)

View file

@ -0,0 +1,138 @@
"""SAVEPOINT-регрессия для connection_capacity_lookup.py (#2464 cluster A finding 2).
gas/heat/gas-outlet query helpers (_query_gas_city_grs, _query_gas_outlet_counts,
_query_gas_outlet_points, _query_heat_latest) когда-то ловили db.execute-сбой в
bare ``except Exception`` без SAVEPOINT/rollback на реальном Postgres это отравляло
db shared с caller (GET .../connection-capacity, Depends(get_db)): следующий db.execute
в этом же запросе падал с "current transaction is aborted". Сиблинг
``_query_nearby_network_zones`` (в этом же файле) уже был правильным (db.begin_nested())
эти тесты проверяют, что остальные функции приведены в соответствие.
MagicMock-сессия не эмулирует реальный aborted-transaction Postgres тесты
проверяют (1) graceful fallback при сбое, (2) что begin_nested() реально вызван
вокруг db.execute (SAVEPOINT-обёртка присутствует в коде), (3) что db остаётся
usable для следующего вызова на том же mock-сессии (call_count растёт, ошибка не
проглатывает control flow).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.site_finder.connection_capacity_lookup import (
_query_gas_city_grs,
_query_gas_outlet_counts,
_query_gas_outlet_points,
_query_heat_latest,
)
_WKT = "POLYGON((60.8 56.7, 60.9 56.7, 60.9 56.8, 60.8 56.8, 60.8 56.7))"
def _mock_db_raising_once(final_rows: list[dict] | None = None) -> MagicMock:
"""db.execute кидает на 1-м вызове, на 2-м успешно отдаёт final_rows."""
db = MagicMock()
ok_result = MagicMock()
ok_result.mappings.return_value.all.return_value = final_rows or []
ok_result.mappings.return_value.first.return_value = (final_rows or [None])[0]
db.execute.side_effect = [RuntimeError("simulated DB failure"), ok_result]
return db
class TestGasCityGrsSavepoint:
def test_query_failure_degrades_gracefully(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("gas_grs_capacity недоступна")
out = _query_gas_city_grs(db)
assert out["city_grs"] == []
assert out["total_free_th_m3_h"] is None
def test_uses_begin_nested_savepoint(self) -> None:
"""Фикс: query обёрнут в db.begin_nested(), не голый db.execute."""
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
db.execute.return_value.mappings.return_value.first.return_value = None
_query_gas_city_grs(db)
# 2 execute на функцию: _query_gas_outlet_counts (city-level счётчики) +
# сам gas_grs_capacity query — оба теперь под begin_nested.
assert db.begin_nested.call_count >= 1
def test_session_usable_after_failure(self) -> None:
"""db.execute сбоит один раз (city_grs), но db остаётся usable для
следующего (не связанного) вызова на этой же сессии."""
db = _mock_db_raising_once()
# outlet_counts вызывается ПЕРВЫМ внутри _query_gas_city_grs — заставим его
# тоже отдать пустой результат нормально, а основной query — кинуть.
db.execute.side_effect = [
MagicMock(
mappings=MagicMock(return_value=MagicMock(first=lambda: None))
), # outlet_counts
RuntimeError("main query failed"), # gas_grs_capacity query
]
out = _query_gas_city_grs(db)
assert out["city_grs"] == []
# Следующий вызов на том же db.execute отрабатывает (session не отравлена).
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.side_effect = None
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestGasOutletCountsSavepoint:
def test_query_failure_degrades_to_zeros(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("gas_grs_outlet_points недоступна")
out = _query_gas_outlet_counts(db)
assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
_query_gas_outlet_counts(db)
assert db.begin_nested.call_count == 1
class TestGasOutletPointsSavepoint:
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("gas_grs_outlet_points (радиус) недоступна")
assert _query_gas_outlet_points(db, _WKT) == []
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_query_gas_outlet_points(db, _WKT)
assert db.begin_nested.call_count == 1
class TestHeatLatestSavepoint:
def test_query_failure_degrades_gracefully(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("heat_system_reserves недоступна")
out = _query_heat_latest(db)
assert out == {"systems": [], "total_reserve_gcal_h": None}
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_query_heat_latest(db)
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self) -> None:
"""execute сбоит один раз, db остаётся usable для следующего вызова —
имитирует heat gas_outlet_points nearby_network_zones в
get_connection_capacity() на одной shared Session."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
out = _query_heat_latest(db)
assert out["systems"] == []
db.execute.side_effect = None
ok_result = MagicMock()
ok_result.mappings.return_value.all.return_value = [{"marker": "next-query-ok"}]
db.execute.return_value = ok_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "next-query-ok"}]

View file

@ -289,3 +289,33 @@ def test_unexpected_error_also_degrades_to_none() -> None:
db.execute.side_effect = RuntimeError("boom")
result = get_developer_attribution(db, "66:41:0303161:1")
assert result is None
# ── SAVEPOINT-регрессия (#2464 cluster A finding 1) ──────────────────────────
def test_db_error_uses_savepoint_and_session_stays_usable() -> None:
"""Фикс: db.execute обёрнут в db.begin_nested() (SAVEPOINT), НЕ голый rollback.
db здесь shared request-scoped Session (Depends(get_db) в parcels.py analyze_parcel);
без SAVEPOINT сбой fn_developer_for_parcel отравил бы persist_analysis_run сразу
после (parcels.py:4116). Мокаем db.execute: 1-й вызов (внутри
get_developer_attribution) кидает, 2-й (имитация persist_analysis_run следом на
том же db) отрабатывает нормально.
"""
db = MagicMock()
ok_result = MagicMock()
ok_result.mappings.return_value.all.return_value = [{"marker": "persist-ok"}]
db.execute.side_effect = [
ProgrammingError("stmt", {}, Exception("fn_developer_for_parcel does not exist")),
ok_result,
]
result = get_developer_attribution(db, "66:41:0303161:1")
assert result is None
assert db.begin_nested.call_count == 1 # SAVEPOINT вокруг резолвера
# Сессия осталась usable для следующего (не связанного) db.execute.
next_call = db.execute("SELECT 1")
assert next_call.mappings().all() == [{"marker": "persist-ok"}]
assert db.execute.call_count == 2

View file

@ -17,7 +17,10 @@ admin-names (.scalars().all()) + micro-lookup (.scalars().all()). КРИТИЧН
from __future__ import annotations
import contextlib
import os
from collections.abc import Callable, Iterator
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -26,6 +29,7 @@ import pytest
from app.services.site_finder import district_resolver
from app.services.site_finder.district_resolver import (
_admin_names,
_reset_admin_cache,
resolve_objective_districts,
)
@ -220,3 +224,129 @@ class TestResolverSqlShape:
# 8 админов = ekb_districts с непустым geom ('не определён' без geom исключён).
sql = str(district_resolver._ADMIN_NAMES_SQL)
assert "geom IS NOT NULL" in sql
# ──────────────────────────────────────────────────────────────────────────────
# RELEASE-SAVEPOINT trap (#2464 cluster A, second-order): резолвер зовётся §22-
# билдерами (sales_series `_query_source_a/_b`, `_query_artificial_demand`) на общей
# сессии отчёта ПОД внешним SAVEPOINT. Если внутренний swallow (_admin_names /
# micro-lookup) съедает db-сбой БЕЗ своего savepoint, транзакция остаётся aborted;
# внешний savepoint выходит «нормально» и делает RELEASE SAVEPOINT, который САМ падает
# в aborted-tx (Postgres допускает лишь ROLLBACK / ROLLBACK TO SAVEPOINT) — внешний
# except ловит RELEASE-ошибку, но tx так и не откачена → отравление каскадит. Fix =
# savepoint В ТОЧКЕ ПЕРЕХВАТА. Тест воспроизводит ИМЕННО RELEASE-trap (не плоский
# swallow): `_ReleaseTrapSession` моделирует, что RELEASE в aborted-tx падает.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeResult:
"""Мини-результат db.execute() — покрывает `.scalars().all()` и `.mappings().first()`."""
def __init__(self, rows: list[Any]) -> None:
self._rows = list(rows)
def scalars(self) -> _FakeResult:
return self
def mappings(self) -> _FakeResult:
return self
def all(self) -> list[Any]:
return list(self._rows)
def first(self) -> Any:
return self._rows[0] if self._rows else None
class _ReleaseTrapSession:
"""Fake Session, моделирующая Postgres SAVEPOINT/RELEASE/ROLLBACK в aborted-tx.
- `execute()` на `fail_on_call`-м вызове бросает РОВНО раз и помечает tx aborted
(poisoned). Пока poisoned любой execute бросает 'current transaction is aborted'.
- `begin_nested()` моделирует SAVEPOINT:
ВХОД в poisoned-tx SAVEPOINT запрещён бросает (как реальный Postgres);
ВЫХОД с исключением ROLLBACK TO SAVEPOINT (снимает poison), пробрасывает;
ВЫХОД нормально RELEASE SAVEPOINT, и если tx poisoned RELEASE ПАДАЕТ
(это и есть RELEASE-trap; без внутреннего savepoint тест краснеет).
"""
def __init__(
self, *, fail_on_call: int = 1, results: dict[int, list[Any]] | None = None
) -> None:
self.poisoned = False
self.calls = 0
self.fail_on_call = fail_on_call
self._results = results or {}
def execute(self, *args: Any, **kwargs: Any) -> _FakeResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if self.calls == self.fail_on_call:
self.poisoned = True
raise RuntimeError("simulated inner helper query failure")
return _FakeResult(self._results.get(self.calls, []))
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
if self.poisoned:
# SAVEPOINT запрещён в aborted-tx.
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — разрешён в aborted, снимает poison.
self.poisoned = False
raise
else:
if self.poisoned:
# RELEASE SAVEPOINT в aborted-tx ПАДАЕТ — тот самый trap.
raise RuntimeError(
"current transaction is aborted, "
"commands ignored until end of transaction block"
)
def _outer_savepoint_call(db: Any, fn: Callable[[], Any], *, fallback: Any) -> Any:
"""Зеркало `_run`/`_safe_call`: обернуть fn() во ВНЕШНИЙ savepoint (RELEASE на норм-выходе).
Именно этот RELEASE падает в aborted-tx, если внутренний helper отравил сессию без
своего savepoint возвращается `fallback` (сигнал, что trap сработал).
"""
try:
with db.begin_nested():
return fn()
except Exception:
return fallback
class TestReleaseSavepointTrap:
"""Внутренние swallow-сайты резолвера НЕ отравляют внешний SAVEPOINT (RELEASE-trap)."""
def test_admin_names_failure_survives_outer_release(self) -> None:
_reset_admin_cache()
db = _ReleaseTrapSession(fail_on_call=1) # admin-names lookup падает
result = _outer_savepoint_call(db, lambda: _admin_names(db), fallback="OUTER_FALLBACK")
# _admin_names вернул СВОЙ fallback (пустой frozenset) — внешний RELEASE прошёл,
# значит внутренний savepoint откатил сбой (иначе result был бы OUTER_FALLBACK).
assert result == frozenset()
# КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
db.execute("SELECT 1")
def test_micro_lookup_failure_survives_outer_release(self) -> None:
_reset_admin_cache()
# call 1 = admin-names (успех, "Кировский" распознан); call 2 = micro-lookup (падает).
db = _ReleaseTrapSession(fail_on_call=2, results={1: ["Кировский"]})
result = _outer_savepoint_call(
db,
lambda: resolve_objective_districts(db, "Кировский"),
fallback="OUTER_FALLBACK",
)
# micro-lookup проглочен → EKB-wide fallback (None), НЕ внешний OUTER_FALLBACK.
assert result is None
# КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
db.execute("SELECT 1")

View file

@ -37,6 +37,7 @@ from app.services.site_finder.future_supply import (
_monthly_absorption_units,
_months_between,
_months_of_pressure,
_query_supply_rows,
_round_or_none,
_saturating_index,
compute_future_supply_pressure,
@ -635,3 +636,61 @@ class TestComputeGraceful:
res = compute_future_supply_pressure(db, district=None)
assert isinstance(res, FutureSupplyPressure)
assert res.district is None
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
#
# db shared с caller (compute_future_supply_pressure в цепочке §22-форсайта /
# analyze). Раньше _query_supply_rows ловил db.execute-сбой в bare
# ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это отравляет
# db shared с caller: следующий db.execute на этой же сессии (напр.
# compute_market_metrics, вызываемый следом в compute_future_supply_pressure)
# падает "current transaction is aborted". Фикс — db.execute обёрнут в
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
# на той же mock-сессии.
class TestQuerySupplyRowsSavepoint:
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_query_supply_rows(db, {"district": "X", "names": ["X"]})
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []
def test_session_usable_after_failure(self) -> None:
"""db.execute сбоит один раз (supply-layers view), но db остаётся usable
для следующего вызова на этой же сессии (имитирует
_query_supply_rows compute_market_metrics в compute_future_supply_pressure)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestComputeFutureSupplyPressureSessionUsableAfterFailure:
"""End-to-end: view-запрос сбоит → SAVEPOINT (не bare rollback) → reused
compute_market_metrics (отдельный db.execute на той же сессии) не задет."""
def test_view_failure_does_not_poison_market_metrics_call(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("view gone")
with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)) as (mm, _rs):
res = compute_future_supply_pressure(db, district="Сбой")
assert db.begin_nested.call_count >= 1
# compute_market_metrics (reused) вызван несмотря на сбой view-запроса —
# сессия не отравлена (в реальном Postgres это была бы "aborted transaction").
assert mm.called
assert res.hidden_units == 0
assert res.index == 0.0

View file

@ -286,6 +286,71 @@ class TestComputeLocationIndices:
assert idx.raw_unit_velocity == 13.0
# ── 2b. infra/POI swallow site: session-poisoning regression (#2464 cluster A) ──
# _district_poi_score/_city_avg_poi_score (analytics_queries.py) do db.execute with no
# try/except of their own; compute_location_indices' bare `except Exception` used to
# swallow a failure WITHOUT rolling back. On real Postgres that leaves the transaction
# "aborted", poisoning every later db.execute on the SAME session — here that's the
# REMAINING districts in refresh_locations' pass-1 loop (all on one Session).
#
# Fix = plain db.rollback() (NOT begin_nested SAVEPOINT): compute_location_indices'
# only caller is refresh_locations, whose only caller (location_refresh.py Celery task)
# opens its OWN SessionLocal() — an owned, not request/report-shared, session. At this
# point in pass 1 no writes are pending yet (all UPSERTs happen later in pass 2), so
# rollback() has nothing to lose and doesn't orphan an outer SessionTransaction (unlike
# velocity.py:170). This is DIFFERENT from the pass-2 upsert loop below, which MUST use
# begin_nested (SAVEPOINT) — a plain rollback there would revert already-upserted rows
# from earlier districts in the same uncommitted transaction.
class TestInfraPoiRollbackRegression:
def test_infra_failure_rolls_back_and_falls_back_to_none(self) -> None:
db = MagicMock()
with (
patch(_MM, return_value=_metrics(0.5, 20.0)),
patch(_FSP, return_value=_fsp(0.4)),
patch(_DPOI, side_effect=RuntimeError("current transaction is aborted")),
patch(_CPOI, return_value=10.0),
):
idx = compute_location_indices(db, "Кировский")
# documented fallback: infra_index None, остальные индексы (isolated per-block)
# всё равно посчитаны.
assert idx.infra_index is None
assert idx.competition_index == 0.5
assert idx.future_supply_index == 0.4
db.rollback.assert_called_once()
# Pass-1 (read-only) — begin_nested здесь НЕ нужен (тот используется только
# в pass-2 upsert loop, где rollback() потерял бы предыдущие районы).
db.begin_nested.assert_not_called()
def test_session_usable_for_next_district_after_infra_failure(self) -> None:
# Симулирует refresh_locations pass 1: район 1 сбоит на infra, район 2 идёт
# следом на ТОЙ ЖЕ session. Без rollback (до фикса) реальный Postgres отдал бы
# "current transaction is aborted" на district 2 (session-poisoning, #2464).
db = MagicMock()
calls = {"n": 0}
def _dpoi_side_effect(_db: object, *, district_name: str) -> float:
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("db boom on district 1")
return 8.0
with (
patch(_MM, return_value=_metrics(0.5, 20.0)),
patch(_FSP, return_value=_fsp(0.4)),
patch(_DPOI, side_effect=_dpoi_side_effect),
patch(_CPOI, return_value=10.0),
):
idx1 = compute_location_indices(db, "Кировский")
idx2 = compute_location_indices(db, "Ленинский")
assert idx1.infra_index is None
db.rollback.assert_called_once()
# Второй район на той же сессии успешно считает infra (8/10 = 0.8) — session
# осталась usable после rollback (не отравлена).
assert idx2.infra_index == 0.8
# ── 3. refresh_locations: idempotent upsert + SAVEPOINT + counters ──────────────
@ -335,9 +400,7 @@ class TestRefreshLocations:
)
with (
patch(_ADMIN, return_value=districts),
patch(
"app.services.site_finder.locations.compute_location_indices", return_value=ns
),
patch("app.services.site_finder.locations.compute_location_indices", return_value=ns),
):
first = refresh_locations(_refresh_db())
second = refresh_locations(_refresh_db())
@ -469,9 +532,7 @@ class TestRefreshDemandCityRelative:
# ── Hard correctness edge case #1: ВСЕ velocities None → ВСЕ demand None ────
def test_all_velocities_none_yields_all_demand_none(self) -> None:
captured = self._run(
{"Кировский": None, "Ленинский": None, "Академический": None}
)
captured = self._run({"Кировский": None, "Ленинский": None, "Академический": None})
# Нет опоры → demand None у всех. НИКОГДА не подменяем 0.
for d in ("Кировский", "Ленинский", "Академический"):
assert captured[d]["demand_index"] is None
@ -480,9 +541,7 @@ class TestRefreshDemandCityRelative:
def test_mixed_none_and_present(self) -> None:
# Опора = MAX среди НЕ-None (= 200). Район без данных → demand None; районы с
# данными нормируются относительно 200, без учёта None в опоре.
captured = self._run(
{"Кировский": 200.0, "Ленинский": 50.0, "Академический": None}
)
captured = self._run({"Кировский": 200.0, "Ленинский": 50.0, "Академический": None})
assert captured["Академический"]["demand_index"] is None # нет выборки
assert captured["Кировский"]["demand_index"] == 1.0 # сам горячий (=опора)
assert captured["Ленинский"]["demand_index"] == 0.25 # 50 / 200
@ -502,8 +561,6 @@ class TestRefreshDemandCityRelative:
def test_all_equal_velocities_all_one(self) -> None:
# Все одинаково горячи → опора = их общее значение → 1.0 у каждого.
captured = self._run(
{"Кировский": 90.0, "Ленинский": 90.0, "Академический": 90.0}
)
captured = self._run({"Кировский": 90.0, "Ленинский": 90.0, "Академический": 90.0})
for d in ("Кировский", "Ленинский", "Академический"):
assert captured[d]["demand_index"] == 1.0

View file

@ -33,6 +33,8 @@ from app.services.site_finder.market_metrics import (
_months_of_supply,
_overstock_index,
_price_sensitivity,
_query_sales_window,
_query_stock,
_room_bucket,
_sell_through_pct,
compute_market_metrics,
@ -1010,3 +1012,161 @@ class TestOfferPriceTrendCompute:
assert d["delta_pct"] == 3.3
assert d["lots_latest"] == 12
assert d["complexes_covered"] == 1
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
#
# db shared с caller (compute_market_metrics в цепочке §22-форсайта / analyze).
# Раньше _query_stock/_query_sales_window/_price_sensitivity ловили db.execute-сбой
# в bare ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это
# отравляет db shared с caller: следующий db.execute на этой же сессии падает
# "current transaction is aborted". Фикс — db.execute (и, для _price_sensitivity,
# весь вызов _elasticity_coef) обёрнут в ``with db.begin_nested():``. MagicMock не
# эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful
# fallback при сбое, (2) что begin_nested() реально вызван, (3) что db остаётся
# usable для следующего вызова на той же mock-сессии.
class TestQueryStockSavepoint:
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
_query_stock(db, {"districts": ["X"]})
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_zero_counts(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
out = _query_stock(db, {"districts": ["X"]})
assert out == {
"n_lots": 0,
"n_sold": 0,
"n_available": 0,
"obj_count": 0,
"n_long_unsold": 0,
}
def test_session_usable_after_failure(self) -> None:
"""db.execute сбоит один раз (stock), но db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует
_query_stock _query_sales_window _price_sensitivity chain."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
out = _query_stock(db, {"districts": ["X"]})
assert out["n_lots"] == 0
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestQuerySalesWindowSavepoint:
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_query_sales_window(db, {"districts": ["X"]})
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
units, area, by_room = _query_sales_window(db, {"districts": ["X"]})
assert (units, area, by_room) == (0, 0.0, {})
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
units, _area, _by_room = _query_sales_window(db, {"districts": ["X"]})
assert units == 0
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestPriceSensitivitySavepoint:
"""_price_sensitivity делает db.execute КОСВЕННО (через _elasticity_coef) —
SAVEPOINT обёрнут вокруг всего вызова, а не голого db.execute."""
def test_uses_begin_nested_savepoint(self, _patch_resolver: MagicMock) -> None:
_patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
db = MagicMock()
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
_price_sensitivity(db, district="Автовокзал", window_months=6)
assert db.begin_nested.call_count == 1
def test_elasticity_failure_still_degrades_gracefully(self, _patch_resolver: MagicMock) -> None:
_patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
db = MagicMock()
with patch(_ELAST, side_effect=RuntimeError("simulated DB failure")):
value, source = _price_sensitivity(db, district="Автовокзал", window_months=6)
assert (value, source) == (None, None)
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self, _patch_resolver: MagicMock) -> None:
"""Сбой elasticity-цепочки не отравляет db — следующий вызов на этой же
сессии (например, offer-price-trend) отрабатывает нормально."""
_patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
db = MagicMock()
with patch(_ELAST, side_effect=RuntimeError("boom")):
_price_sensitivity(db, district="Автовокзал", window_months=6)
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestComputeMarketMetricsSessionUsableAfterFirstFailure:
"""End-to-end (via compute_market_metrics): stock-запрос сбоит → SAVEPOINT
(не bare rollback) sales-window запрос по-прежнему выполняется на той же
сессии (не блокируется "current transaction is aborted")."""
def test_stock_failure_does_not_poison_sales_window(self, _patch_resolver: MagicMock) -> None:
db = MagicMock()
sales_result = MagicMock()
sales_result.mappings.return_value.all.return_value = _FULL_SALES
db.execute.side_effect = [RuntimeError("boom on stock"), sales_result]
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
m = compute_market_metrics(db, district="Автовокзал", window_months=6)
assert db.begin_nested.call_count >= 1
assert m.n_lots == 0 # stock fallback (graceful, не crash)
assert m.confidence == "low"
# sales-window execute РЕАЛЬНО выполнился на той же db (2-й вызов не был
# заблокирован сбоем 1-го) — session осталась usable после stock-savepoint.
assert db.execute.call_count >= 2
class TestOfferPriceTrendSavepoint:
"""compute_offer_price_trend: db shared с caller (POST .../analyze / §22-отчёт).
db.execute обёрнут в begin_nested 8с-гвард QueryCanceled не отравляет сессию."""
def test_uses_begin_nested_savepoint(self) -> None:
db = _mock_offer_db([])
compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_and_no_bare_rollback(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("query canceled (8s guard)")
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert res.points == [] # документированный fallback
assert res.has_data is False
assert db.begin_nested.call_count == 1
db.rollback.assert_not_called() # никакого bare rollback на shared сессии
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("db down")
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert res.points == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]

View file

@ -0,0 +1,107 @@
"""Тесты parcel_okn_objects (#1066, #1159) — ОКН-объекты рядом с участком.
БД не дёргается: фейковая сессия с .execute().fetchall() (позиционные колонки).
Проверяем happy path, graceful на отсутствие WKT/таблицы, и SAVEPOINT-регрессию
(#2464 cluster A): db.execute обёрнут в begin_nested, сбой не отравляет сессию.
"""
from __future__ import annotations
import contextlib
from typing import Any
from sqlalchemy.exc import OperationalError
from app.services.site_finder.okn_lookup import parcel_okn_objects
_WKT = "POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))"
_OKN_ROW = (
"66-001", # source_id
"федеральный", # category
42, # distance_m
"Дом купца Иванова", # name
"ул. Ленина, 1", # address
)
class _FakeDB:
"""Фейковая сессия: execute().fetchall() (позиционные строки) + begin_nested()."""
def __init__(self, rows: list[tuple[Any, ...]] | Exception) -> None:
self._rows = rows
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
if isinstance(self._rows, Exception):
raise self._rows
class _Result:
def __init__(self, rows: list[tuple[Any, ...]]) -> None:
self._rows = rows
def fetchall(self) -> list[tuple[Any, ...]]:
return self._rows
return _Result(self._rows)
def test_returns_okn_objects() -> None:
"""Один ОКН-объект в радиусе → проброс полей."""
db = _FakeDB([_OKN_ROW])
out = parcel_okn_objects(db, _WKT)
assert len(out) == 1
r = out[0]
assert r["source_id"] == "66-001"
assert r["category"] == "федеральный"
assert r["distance_m"] == 42
assert r["name"] == "Дом купца Иванова"
assert r["address"] == "ул. Ленина, 1"
def test_empty_wkt_returns_empty() -> None:
"""parcel_wkt=None → [] без вызова DB."""
db = _FakeDB([_OKN_ROW])
assert parcel_okn_objects(db, None) == []
assert db.begin_nested_calls == 0
def test_no_objects_in_radius_returns_empty() -> None:
"""Нет объектов в радиусе → []."""
assert parcel_okn_objects(_FakeDB([]), _WKT) == []
def test_missing_table_is_graceful() -> None:
"""okn_objects ещё не задеплоена (OperationalError) → [] (analyze не падает)."""
db = _FakeDB(OperationalError("stmt", {}, Exception("relation okn_objects does not exist")))
assert parcel_okn_objects(db, _WKT) == []
def test_unexpected_exception_is_graceful() -> None:
"""Любая неожиданная ошибка БД → [] (analyze не падает)."""
db = _FakeDB(RuntimeError("connection refused"))
assert parcel_okn_objects(db, _WKT) == []
# ── #2464 cluster A: SAVEPOINT-регрессия (session-poisoning) ────────────────────
def test_uses_savepoint_and_session_reusable_after_failure() -> None:
"""db.execute обёрнут в begin_nested (SAVEPOINT) — сбой не отравляет сессию:
follow-up вызов на ТОЙ ЖЕ db-сессии успевает (не "current transaction is aborted")."""
db = _FakeDB(RuntimeError("connection refused"))
result = parcel_okn_objects(db, _WKT)
assert result == []
assert db.begin_nested_calls == 1
db._rows = [_OKN_ROW]
result2 = parcel_okn_objects(db, _WKT)
assert len(result2) == 1
assert result2[0]["source_id"] == "66-001"
assert db.begin_nested_calls == 2

View file

@ -23,7 +23,12 @@ from typing import Any
from unittest.mock import MagicMock
from app.services.site_finder import own_portfolio
from app.services.site_finder.own_portfolio import OwnProject, get_own_portfolio
from app.services.site_finder.own_portfolio import (
OwnProject,
_query_current,
_query_future,
get_own_portfolio,
)
def _row(data: dict[str, Any]) -> MagicMock:
@ -272,3 +277,81 @@ def test_future_query_failure_returns_empty_gracefully(monkeypatch) -> None:
out = get_own_portfolio(db)
assert out == []
# ── SAVEPOINT regression (#2464 cluster A) ───────────────────────────────────────
#
# db shared с caller (get_own_portfolio в цепочке §25.3 / analyze). Раньше
# _query_current/_query_future ловили db.execute-сбой в bare ``except Exception``
# без SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
# следующий db.execute на этой же сессии падает "current transaction is aborted".
# Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не эмулирует
# реальный aborted-transaction Postgres — тесты проверяют (1) graceful fallback при
# сбое, (2) что begin_nested() реально вызван вокруг db.execute, (3) что db остаётся
# usable для следующего вызова на той же mock-сессии.
class TestQueryCurrentSavepoint:
def test_uses_begin_nested_savepoint(self, monkeypatch) -> None:
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.return_value = _result([])
_query_current(db)
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_list(self, monkeypatch) -> None:
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert _query_current(db) == []
def test_session_usable_after_failure(self, monkeypatch) -> None:
"""db.execute сбоит один раз, но db остаётся usable для следующего вызова
на этой же сессии (имитирует _query_current _query_future в
get_own_portfolio на одной shared Session)."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _query_current(db) == []
db.execute.side_effect = None
next_result = _result([{"marker": "ok"}])
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestQueryFutureSavepoint:
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
_query_future(db)
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert _query_future(db) == []
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _query_future(db) == []
db.execute.side_effect = None
next_result = _result([{"marker": "ok"}])
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_get_own_portfolio_current_failure_savepoint_isolated(monkeypatch) -> None:
"""End-to-end (via get_own_portfolio): current db.execute сбоит → SAVEPOINT
(не bare rollback) future по-прежнему считается на той же сессии."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = [RuntimeError("boom on domrf"), _result([_future_row()])]
out = get_own_portfolio(db)
assert db.begin_nested.call_count >= 1
assert len(out) == 1
assert out[0].source == "future"

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import importlib
import pathlib
from contextlib import contextmanager
from typing import Any
import pytest
@ -211,6 +212,10 @@ class _Result:
class _FakeDB:
"""begin_nested — реальный (не-swallow) CM: зеркалит SAVEPOINT-фикс #2464
cluster A finding 7 (parcel_pat_subzones); исключение из db.execute должно
долетать до except в проде, не глушиться на выходе из ``with``."""
def __init__(
self,
rows: list[dict[str, Any]] | None = None,
@ -218,8 +223,14 @@ class _FakeDB:
) -> None:
self._rows = rows or []
self._raise = raise_exc
self.execute_calls = 0
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
self.execute_calls += 1
if self._raise is not None:
raise self._raise
return _Result(self._rows)
@ -270,6 +281,27 @@ def test_pat_lookup_graceful_programming_error() -> None:
assert result == []
def test_pat_lookup_db_error_leaves_session_usable_for_next_query() -> None:
"""#2464 cluster A finding 7: db shared с caller (analyze_parcel) — сбой здесь
не должен отравлять session для persist_analysis_run и прочих db.execute дальше
по /analyze. Симулируем: execute кидает ОДИН раз, затем следующий (не связанный)
execute на ТОЙ ЖЕ session отрабатывает нормально."""
from sqlalchemy.exc import OperationalError
db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("relation does not exist")))
result = parcel_pat_subzones(db, _WKT)
assert result == []
assert db.execute_calls == 1
# Сессия осталась usable: сбрасываем "raise" (имитируя, что savepoint откатился
# и не поломала outer-транзакцию) и проверяем, что db.execute снова отрабатывает.
db._raise = None
db._rows = [{"subzone_no": 3, "name": "N", "restriction": "R", "aerodrome": "A"}]
next_result = parcel_pat_subzones(db, _WKT)
assert next_result == [{"subzone_no": 3, "name": "N", "restriction": "R", "aerodrome": "A"}]
assert db.execute_calls == 2
# ── Резолв пути к JSON (#1150) ──────────────────────────────────────────────

View file

@ -7,11 +7,13 @@ happy path + graceful-фоллбэки (здание не найдено / не
from __future__ import annotations
import contextlib
from typing import Any
from unittest.mock import MagicMock, patch
from app.schemas.nspd_bulk import ObjectsListing
from app.services.scrapers.nspd_client import NspdLiteWafError
from app.services.site_finder.premises_lookup import resolve_cad_for_domrf
MODULE = "app.services.site_finder.premises_lookup"
@ -73,9 +75,7 @@ def test_get_building_premises_snake_case_objdoc() -> None:
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)),
):
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
{"objdoc_id": 777}
)
mock_client_cls.return_value.search_by_cad.return_value = _search_result({"objdoc_id": 777})
out = get_building_premises("66:41:0106036:183")
assert out is listing
@ -148,3 +148,60 @@ def test_get_building_premises_waf_returns_none() -> None:
assert out is None
mock_run.assert_not_called()
# ── #2464 cluster A: resolve_cad_for_domrf SAVEPOINT-регрессия ──────────────────
# db.execute внутри resolve_cad_for_domrf раньше глотал сбой БЕЗ SAVEPOINT/rollback —
# на реальном Postgres это отравляет shared session (lazy-эндпоинт может дёргать
# resolve_cad_for_domrf на несколько конкурентов подряд, ОДНА и та же db-сессия):
# следующий db.execute падал "current transaction is aborted". Фикс: db.execute
# обёрнут в db.begin_nested().
class _ResolveCadFakeDB:
"""Фейковая сессия: execute().mappings().first() + begin_nested()."""
def __init__(self, row: dict[str, Any] | Exception | None) -> None:
self._row = row
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any]) -> Any:
if isinstance(self._row, Exception):
raise self._row
row = self._row
class _Result:
def mappings(self) -> _Result:
return self
def first(self) -> dict[str, Any] | None:
return row
return _Result()
def test_resolve_cad_for_domrf_db_failure_returns_none() -> None:
"""DB-сбой → None (документированный fallback), analyze не падает."""
db = _ResolveCadFakeDB(RuntimeError("connection refused"))
assert resolve_cad_for_domrf(db, lat=56.8, lon=60.6) is None # type: ignore[arg-type]
def test_resolve_cad_for_domrf_uses_savepoint_and_session_reusable_after_failure() -> None:
"""begin_nested обёрнут вокруг db.execute — сбой не отравляет сессию: follow-up
вызов resolve_cad_for_domrf на ТОЙ ЖЕ db-сессии успевает."""
db = _ResolveCadFakeDB(RuntimeError("boom"))
out = resolve_cad_for_domrf(db, lat=56.8, lon=60.6) # type: ignore[arg-type]
assert out is None
assert db.begin_nested_calls == 1
db._row = {"cad_num": "66:41:0106036:183", "objdoc_id": 40995027, "distance_m": 12.5}
out2 = resolve_cad_for_domrf(db, lat=56.8, lon=60.6) # type: ignore[arg-type]
assert out2 is not None
assert out2.cad_num == "66:41:0106036:183"
assert db.begin_nested_calls == 2

View file

@ -0,0 +1,224 @@
"""SAVEPOINT-регрессия для quarter_dump_lookup.py (#2464 cluster A).
_get_zoning / _get_zouit_overlaps / _get_cad_zouit_overlaps / _get_engineering_nearby /
_get_risk_zones / _get_opportunity_parcels / _get_red_lines / _get_engineering_structures_
by_boundary когда-то ловили db.execute-сбой в bare ``except Exception`` (или
``except (OperationalError, ProgrammingError, DataError)``) БЕЗ SAVEPOINT/rollback на
реальном Postgres это отравляет db shared с caller'ом (`get_quarter_dump_data(db, ...)` →
вызывается из `/analyze` эндпоинта, `Depends(get_db)`): следующий `db.execute` в этом же
запросе (сиблинг `_get_*` хелпер, либо downstream `get_or_fetch_zone_regulation`, либо
`begin_nested` "9f cad_parcels" блок) падает с "current transaction is aborted" /
PendingRollbackError. Референс-корректный сиблинг `connection_capacity_lookup.py::
_query_nearby_network_zones` (уже был обёрнут в `db.begin_nested()`).
MagicMock-сессия не эмулирует реальный aborted-transaction Postgres тесты проверяют
(1) graceful fallback при сбое (документированный контракт функции не меняется),
(2) что begin_nested() реально вызван вокруг db.execute (SAVEPOINT-обёртка присутствует
в коде), (3) что db остаётся usable для следующего вызова на той же mock-сессии ошибка
не проглатывает control flow и не роняет остальную сессию.
Покрывает представительное подмножество (per #2464 audit): _get_zoning, _get_zouit_overlaps,
_get_engineering_structures_by_boundary и _get_zouit_engineering_overlaps (последние два
2 db.execute на функцию, каждый под своей savepoint, epic #2445 A1 honest-count + row-fetch).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from sqlalchemy.exc import OperationalError
from app.services.site_finder.quarter_dump_lookup import (
_get_engineering_structures_by_boundary,
_get_zoning,
_get_zouit_engineering_overlaps,
_get_zouit_overlaps,
)
def _op_error(msg: str) -> OperationalError:
"""OperationalError фабрика — единственный тип, который ловит except
(OperationalError, ProgrammingError, DataError) в _get_engineering_structures_
by_boundary (bare RuntimeError туда НЕ провалится это намеренно узкий except)."""
return OperationalError(msg, {}, Exception(msg))
_QUARTER = "66:41:0204016"
_WKT = "POLYGON((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))"
class TestGetZoningSavepoint:
"""_get_zoning: db.execute().first(), except Exception → return None."""
def test_query_failure_degrades_to_none(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("nspd_quarter_dumps query failed")
assert _get_zoning(db, _QUARTER, _WKT) is None
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.first.return_value = None
_get_zoning(db, _QUARTER, _WKT)
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self) -> None:
"""db.execute сбоит один раз, но db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует sibling _get_*
хелпер, вызванный сразу после в get_quarter_dump_data()."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
result = _get_zoning(db, _QUARTER, _WKT)
assert result is None
# Следующий вызов на том же db.execute отрабатывает (сессия не отравлена).
db.execute.side_effect = None
next_result = MagicMock()
next_result.first.return_value = ("marker",)
db.execute.return_value = next_result
assert db.execute("SELECT 1").first() == ("marker",)
class TestGetZouitOverlapsSavepoint:
"""_get_zouit_overlaps: db.execute().fetchall(), except Exception → return []."""
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("nspd zouit query failed")
result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3})
assert result == []
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.fetchall.return_value = []
_get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3})
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self) -> None:
"""Сбой на dump-path (zouit_count>0) не отравляет сессию для следующего
sibling-запроса (например _get_engineering_nearby сразу после)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3})
assert result == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.fetchall.return_value = [("marker",)]
db.execute.return_value = next_result
assert db.execute("SELECT 1").fetchall() == [("marker",)]
class TestGetEngineeringStructuresByBoundarySavepoint:
"""_get_engineering_structures_by_boundary: 2 db.execute (COUNT + row-fetch) в одном
try, except (OperationalError, ProgrammingError, DataError) return ([], 0).
Каждый db.execute нуждается в СОБСТВЕННОЙ savepoint (RELEASE SAVEPOINT trap): если
COUNT сбоит, ROLLBACK TO SAVEPOINT должен откатить только COUNT, не всю функцию
и наоборот, если COUNT прошёл, а row-fetch сбоил, savepoint row-fetch'а изолирует
именно его сбой.
"""
def test_count_query_failure_degrades_to_empty(self) -> None:
db = MagicMock()
db.execute.side_effect = _op_error("COUNT(*) query failed")
items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert items == []
assert total == 0
def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None:
"""COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 42
db.execute.side_effect = [count_result, _op_error("row-fetch failed")]
items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert items == []
assert total == 0
def test_uses_two_separate_begin_nested_savepoints(self) -> None:
"""COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested)."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 0
rows_result = MagicMock()
rows_result.fetchall.return_value = []
db.execute.side_effect = [count_result, rows_result]
_get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert db.begin_nested.call_count == 2
def test_session_usable_after_count_failure(self) -> None:
"""COUNT сбоит → db остаётся usable для следующего sibling-запроса
(например _get_zouit_engineering_overlaps сразу после в get_connection_points)."""
db = MagicMock()
db.execute.side_effect = _op_error("boom")
items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert (items, total) == ([], 0)
db.execute.side_effect = None
next_result = MagicMock()
next_result.fetchall.return_value = [("marker",)]
db.execute.return_value = next_result
assert db.execute("SELECT 1").fetchall() == [("marker",)]
class TestGetZouitEngineeringOverlapsSavepoint:
"""_get_zouit_engineering_overlaps: 2 db.execute (COUNT + row-fetch) в одном try,
except (OperationalError, ProgrammingError, DataError) return ([], 0).
Идентичная 2-запросная форма _get_engineering_structures_by_boundary вызывается
сразу после него в get_connection_points() на той же shared session, поэтому каждый
db.execute обёрнут в свою savepoint (RELEASE SAVEPOINT trap, epic #2445 A1).
"""
def test_count_query_failure_degrades_to_empty(self) -> None:
db = MagicMock()
db.execute.side_effect = _op_error("COUNT(*) query failed")
items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert items == []
assert total == 0
def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None:
"""COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 137
db.execute.side_effect = [count_result, _op_error("row-fetch failed")]
items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert items == []
assert total == 0
def test_uses_two_separate_begin_nested_savepoints(self) -> None:
"""COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested)."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 0
rows_result = MagicMock()
rows_result.fetchall.return_value = []
db.execute.side_effect = [count_result, rows_result]
_get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert db.begin_nested.call_count == 2
def test_session_usable_after_failure(self) -> None:
"""Сбой не отравляет сессию — get_connection_points после этого хелпера ещё
строит summary и (в /analyze-контексте) сессия shared с downstream-запросами."""
db = MagicMock()
db.execute.side_effect = _op_error("boom")
items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert (items, total) == ([], 0)
db.execute.side_effect = None
next_result = MagicMock()
next_result.fetchall.return_value = [("marker",)]
db.execute.return_value = next_result
assert db.execute("SELECT 1").fetchall() == [("marker",)]

View file

@ -6,6 +6,7 @@
from __future__ import annotations
import contextlib
from typing import Any
from sqlalchemy.exc import ProgrammingError
@ -57,6 +58,13 @@ class _Result:
class _FakeDB:
def __init__(self, rows: list[_Row] | Exception) -> None:
self._rows = rows
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
if isinstance(self._rows, Exception):
@ -144,3 +152,18 @@ def test_gate_empty_wkt_returns_empty() -> None:
db = _FakeDB([])
gate = parcel_riasurt_gate(db, None, "66:35:0101001:5") # type: ignore[arg-type]
assert gate["applicable"] is False
def test_gate_uses_savepoint_and_session_reusable_after_failure() -> None:
"""#2464 cluster A: db.execute обёрнут в begin_nested (SAVEPOINT) — сбой не
отравляет сессию. Follow-up вызов на ТОЙ ЖЕ db-инстансе успевает."""
db = _FakeDB(ProgrammingError("stmt", {}, Exception("no table")))
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
assert gate == riasurt_lookup.RIASURT_EMPTY_GATE
assert db.begin_nested_calls == 1
# follow-up на той же сессии (таблица теперь "доступна").
db._rows = []
gate2 = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
assert gate2["applicable"] is True
assert db.begin_nested_calls == 2

View file

@ -379,6 +379,51 @@ class TestLayer2Hidden:
sql = _executed_sql(db)
assert "free_flats IS NOT NULL" in sql
def test_sql_excludes_null_flat_count_from_both_filters(self) -> None:
# #2445-A3 (audit fix): flat_count IS NULL — заполняется НЕЗАВИСИМО от free_flats
# (м.124 "NULL = не заполнено"). Раньше GREATEST(0, COALESCE(flat_count,0) -
# free_flats) молча давало 0 hidden для объекта с генуинно неизвестным
# flat_count, И этот объект всё равно засчитывался в n_with_free_flats (гейт был
# только free_flats IS NOT NULL) → раздутый coverage-деноминатор. Fix гейтит ОБЕ
# агрегатные колонки (COUNT-денoминатор и SUM) на flat_count IS NOT NULL тоже —
# зеркалит _hidden_units() (возвращает None при flat_count IS None) и sibling
# convention 60_v_zk_rosreestr_velocity.sql / 64_v_zk_rosreestr_velocity.sql
# ("o.flat_count IS NOT NULL AND o.flat_count > 0" на этой же таблице).
db = _mock_db([])
compute_layer2_hidden(db, district="X")
sql = _executed_sql(db)
norm = " ".join(sql.split())
# Оба FILTER-предложения (n_with_free_flats COUNT и hidden_units SUM) должны
# гейтить flat_count IS NOT NULL наравне с free_flats IS NOT NULL.
assert (
norm.count("flat_count IS NOT NULL") == 2
), f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
assert (
norm.count("free_flats IS NOT NULL") == 2
), f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
def test_row_with_null_flat_count_and_positive_free_flats_excluded_end_to_end(
self,
) -> None:
# End-to-end (mocked-row) mirror of test_none_flat_count_returns_none, but at the
# SQL-aggregate boundary: simulate what the CORRECTED SQL would return for a
# district containing exactly one object with free_flats=5 but flat_count IS NULL
# (partially-backfilled snapshot). That object must be excluded from BOTH
# n_with_free_flats (coverage numerator) and hidden_units (SUM) by the SQL itself
# — so the row the Python layer receives already reflects n_with=0, hidden=0 for
# a district containing only that one ambiguous object.
rows = [
{
"district_name": "X",
"n_objects_total": 1, # the one flat_count-NULL object still counts here
"n_with_free_flats": 0, # excluded: flat_count IS NULL despite free_flats=5
"hidden_units": 0, # excluded from SUM too — NOT GREATEST(0, -5) miscounted
}
]
db = _mock_db(rows)
# n_with <= 0 → whole row skipped (no fabricated "0 hidden" district row).
assert compute_layer2_hidden(db, district="X") == []
def test_sql_dedups_latest_snapshot(self) -> None:
# КРИТИЧНО: domrf_kn_objects = UNIQUE(obj_id, snapshot_date), накапливает снапшоты.
# Без DISTINCT ON (obj_id) + snapshot_date DESC наивный SUM умножил бы hidden на
@ -659,3 +704,25 @@ class TestComputeAllLayers:
db.execute.side_effect = [l1_fail, l2_ok, l3_ok]
out = compute_all_layers(db, district="A")
assert out == []
def test_l1_failure_uses_savepoint_not_bare_rollback(self) -> None:
"""#2464 cluster A finding 5: _safe_rows оборачивает db.execute в begin_nested()
(не голый db.rollback()) L1 падает, но L2/L3 execute на ТОЙ ЖЕ Session должны
быть достижимы (session не отравлена/не orphan'ена outer-транзакция)."""
db = MagicMock()
l1_fail = RuntimeError("l1 down")
l2_ok = MagicMock()
l2_ok.mappings.return_value.all.return_value = [
{"district_name": "A", "n_objects_total": 1, "n_with_free_flats": 1, "hidden_units": 1}
]
l3_ok = MagicMock()
l3_ok.mappings.return_value.all.return_value = []
db.execute.side_effect = [l1_fail, l2_ok, l3_ok]
out = compute_all_layers(db, district="A")
# L1 деградировал в [], но L2 (следующий вызов на этом же db) успешно вернул строку —
# begin_nested() внутри _safe_rows не даёт сбою L1 отравить L2/L3.
assert db.execute.call_count == 3
assert db.begin_nested.call_count == 3 # SAVEPOINT на каждый из L1/L2/L3
assert any(r.layer == 2 for r in out)

View file

@ -0,0 +1,380 @@
"""#2464 cluster E — domrf_kn_objects snapshot dedup regression tests.
domrf_kn_objects retains MULTIPLE historical rows per obj_id (one per
snapshot_date, UNIQUE(obj_id, snapshot_date), no retention). Prod measurement:
4090 rows / 482 distinct obj_id for site_status='Строящиеся' (8.49x inflation).
Three read-side queries counted/returned every retained snapshot instead of the
latest one per obj_id:
- _active_competitors_count (via its _q closure)
- developer_portfolio
- _competitors_two_dim (its `active` CTE)
All fixed with the established repo dedup pattern: DISTINCT ON (obj_id) ...
ORDER BY obj_id, snapshot_date DESC NULLS LAST (sibling: _L3_FUTURE_SQL #1212 in
app/services/site_finder/supply_layers.py, cmp_rows/latest_obj CTE in
analytics_queries.recommend_mix).
VOLATILE-FILTER PLACEMENT (authoritative prod measurement, coordinator
2026-07-08 per-obj_id variance across snapshots): dev_id=0, region_cd=0
(STABLE) vs district_name=1, obj_class=29, site_status=11 (VOLATILE). Volatile
predicates MUST be applied to the true-latest row (outer/downstream WHERE), NOT
pre-filtered inside the DISTINCT ON CTE else DISTINCT ON returns "the latest
snapshot that still MATCHED the filter", not the object's true latest snapshot
(a ЖК that sold out / changed class / moved districts would be miscounted by a
stale snapshot). Only the stable region_cd stays inside the CTE as the dedup
partition-scope. dev_id (developer_portfolio) is likewise stable kept in-CTE.
Mock-based mirrors the SQL-shape assertion convention used in
tests/services/site_finder/test_supply_layers.py (TestLayer2Hidden.
test_sql_dedups_latest_snapshot) since there's no SQLite stand-in for
Postgres-only `DISTINCT ON` syntax and no throwaway-schema DML fixture in this
repo's integration-test conventions (tests/integration/conftest.py is
EXPLAIN-only / read-only against a real tunnel DB safe for plan-checks, not
for INSERT fixtures). A second class (`TestDedupAlgorithmSpec`) hand-replicates
the exact dedup+filter semantics the SQL implements in pure Python and asserts
the two behaviors the epic calls out explicitly: multi-snapshot obj_id counted
once, and a sold-out object with only an OLD 'Строящиеся' snapshot excluded.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from app.services.analytics_queries import _active_competitors_count, developer_portfolio
# ── shared helpers (mirrors test_supply_layers.py _mock_db/_executed_sql) ─────
def _mock_scalar_db(value: int) -> MagicMock:
"""Session stand-in: every db.execute(...).scalar() returns `value`."""
db = MagicMock()
db.execute.return_value.scalar.return_value = value
return db
def _mock_mapping_db(rows: list[dict[str, Any]]) -> MagicMock:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = rows
return db
def _executed_sql(db: MagicMock, call_index: int = 0) -> str:
args, _kwargs = db.execute.call_args_list[call_index]
return str(args[0])
def _executed_params(db: MagicMock, call_index: int = 0) -> dict:
args, _kwargs = db.execute.call_args_list[call_index]
return args[1]
def _split_latest_cte(sql: str) -> tuple[str, str]:
"""Split whitespace-normalized SQL into (CTE body, outer body).
CTE body = `WITH latest AS ( ... )` up to the `)` that closes the CTE,
which is the first `)` after the ORDER BY feeding DISTINCT ON. Outer body =
everything after. Lets tests assert WHICH side a predicate lives on.
"""
norm = " ".join(sql.split())
cte_open = norm.index("WITH latest AS (")
order_by_idx = norm.index("ORDER BY obj_id, snapshot_date DESC NULLS LAST")
cte_close = norm.index(")", order_by_idx)
return norm[cte_open:cte_close], norm[cte_close:]
# ── _active_competitors_count SQL shape ────────────────────────────────────
class TestActiveCompetitorsCountSqlShape:
def test_dedups_via_distinct_on_obj_id_latest_snapshot(self) -> None:
# n never reaches the >=2 early-return threshold with scalar()==0, so
# all 3 cascade tiers execute — call_index 0 is tier1 (district+class).
db = _mock_scalar_db(0)
_active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
sql = _executed_sql(db, 0)
norm = " ".join(sql.split())
assert "DISTINCT ON (obj_id)" in norm
assert "ORDER BY obj_id, snapshot_date DESC NULLS LAST" in norm
def test_site_status_filter_is_volatile_applied_after_distinct_on(self) -> None:
# Regression guard for the exact bug shape #2464 warns about: a
# sold-out object whose ONLY 'Строящиеся' row is an old snapshot must
# not be counted. That requires site_status to be filtered on the
# OUTER (post-DISTINCT-ON) query, never inside the `latest` CTE's
# WHERE — else DISTINCT ON would pick "the latest snapshot that still
# matched site_status='Строящиеся'" instead of the object's true
# latest snapshot. (Prod per-obj_id variance: site_status=11.)
db = _mock_scalar_db(0)
_active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
cte_body, outer_body = _split_latest_cte(_executed_sql(db, 0))
# site_status legitimately appears in the CTE's SELECT list (it's the
# column DISTINCT ON needs to expose) -- what must NOT appear is a
# filter predicate on it inside the CTE's WHERE.
assert (
"site_status = 'Строящиеся'" not in cte_body
), f"site_status must not pre-filter the DISTINCT ON CTE (volatile field):\n{cte_body}"
assert "site_status = 'Строящиеся'" in outer_body
def test_district_and_class_are_volatile_applied_after_distinct_on(self) -> None:
# Companion to the site_status guard: authoritative prod measurement
# (coordinator 2026-07-08) — per-obj_id variance across snapshots is
# district_name=1 and obj_class=29 (NON-zero → VOLATILE), unlike
# dev_id/region_cd (0 → stable). So district_name/obj_class predicates
# must ALSO be applied on the true-latest row (outer WHERE), never
# pre-filtered inside the DISTINCT ON CTE — else a ЖК that changed
# class/district gets counted by a stale snapshot. Tier-1
# (district+class) is call_index 0 when target_class is given.
db = _mock_scalar_db(0)
_active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
cte_body, outer_body = _split_latest_cte(_executed_sql(db, 0))
# CTE WHERE must scope on ONLY the stable region_cd — no volatile predicate.
assert (
"district_name = :dn" not in cte_body
), f"district_name (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
assert (
"COALESCE(obj_class, obj_class_fallback) = :cls" not in cte_body
), f"obj_class (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
# Both live in the outer WHERE, applied to the deduped true-latest row.
assert "district_name = :dn" in outer_body
assert "COALESCE(obj_class, obj_class_fallback) = :cls" in outer_body
def test_cte_where_scopes_only_stable_region_cd(self) -> None:
# Belt-and-suspenders: the DISTINCT ON CTE's WHERE partition-scope is
# exactly `region_cd = :rc` and nothing else (all volatile predicates
# relocated). Guards against a future re-introduction of a volatile
# pre-filter that would silently pick a stale-but-matching snapshot.
db = _mock_scalar_db(0)
_active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
cte_body, _outer = _split_latest_cte(_executed_sql(db, 0))
# extract the CTE's WHERE clause (between WHERE and the feeding ORDER BY,
# which _split_latest_cte leaves at the tail of the CTE body).
where_start = cte_body.index("WHERE ") + len("WHERE ")
where_end = cte_body.index("ORDER BY", where_start)
where_clause = cte_body[where_start:where_end].strip()
assert (
where_clause == "region_cd = :rc"
), f"CTE WHERE must scope on ONLY stable region_cd, got: {where_clause!r}"
def test_no_double_colon_cast(self) -> None:
import re
db = _mock_scalar_db(0)
_active_competitors_count(db, region_code=66, district_name="Ленинский", target_class=None)
sql = _executed_sql(db, 0)
assert re.search(r":[a-z_]+::[a-z]", sql) is None
def test_cascade_still_stops_early_when_tier_hits_threshold(self) -> None:
# Sanity: fallback cascade logic (n>=2 -> stop) is untouched by the dedup fix.
db = _mock_scalar_db(5)
n, scope = _active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
assert n == 5
assert scope == "district+class"
assert db.execute.call_count == 1
# ── developer_portfolio SQL shape ──────────────────────────────────────────
class TestDeveloperPortfolioSqlShape:
def test_dedups_via_distinct_on_obj_id_latest_snapshot(self) -> None:
db = _mock_mapping_db([])
developer_portfolio(db, "6208_0")
sql = _executed_sql(db, 0)
norm = " ".join(sql.split())
assert "DISTINCT ON (obj_id)" in norm
assert "ORDER BY obj_id, snapshot_date DESC NULLS LAST" in norm
def test_preserves_final_ready_dt_ordering(self) -> None:
# DISTINCT ON's own ORDER BY must start with (obj_id, snapshot_date) —
# the caller-facing ready_dt ordering has to live in the OUTER SELECT.
db = _mock_mapping_db([])
developer_portfolio(db, "6208_0")
sql = _executed_sql(db, 0)
norm = " ".join(sql.split())
assert norm.rstrip().endswith("ORDER BY ready_dt DESC NULLS LAST")
def test_dev_id_filter_unchanged(self) -> None:
db = _mock_mapping_db([])
developer_portfolio(db, "6208_0")
params = _executed_params(db, 0)
assert params == {"dev": "6208_0"}
def test_returned_columns_unchanged(self) -> None:
# Fix must not add/drop columns from the caller-facing shape.
rows = [
{
"obj_id": 1,
"comm_name": "ЖК Тест",
"addr": "ул. Тест, 1",
"region_cd": 66,
"flat_count": 100,
"square_living": 5000.0,
"ready_dt": None,
"obj_class": "Комфорт",
"escrow": True,
"problem_flag": None,
"latitude": 56.8,
"longitude": 60.6,
"is_ekb": True,
}
]
db = _mock_mapping_db(rows)
out = developer_portfolio(db, "6208_0")
assert len(out) == 1
assert set(out[0].keys()) == {
"obj_id",
"comm_name",
"addr",
"region_cd",
"flat_count",
"square_living",
"ready_dt",
"obj_class",
"escrow",
"problem_flag",
"lat",
"lon",
"is_ekb",
}
# ── _competitors_two_dim `active`/`latest` CTE shape ───────────────────────
def _mock_two_dim_db(radius_n: int, district_only_n: int) -> MagicMock:
"""Session stand-in for _competitors_two_dim: call 0 = centroid lookup
(returns a non-null WKT so the main query runs), call 1 = the radius/
district aggregate. radius_n/district_only_n chosen so total_weighted >= 1
keeps execution off the single-dim fallback path (which would add calls)."""
db = MagicMock()
centroid_res = MagicMock()
centroid_res.mappings.return_value.first.return_value = {"centroid_wkt": "POINT(60.6 56.8)"}
main_res = MagicMock()
main_res.mappings.return_value.first.return_value = {
"radius_n": radius_n,
"district_only_n": district_only_n,
}
db.execute.side_effect = [centroid_res, main_res]
return db
class TestCompetitorsTwoDimActiveCte:
"""#2464 sibling: _competitors_two_dim's `active` CTE had site_status /
district_name / obj_class ALL inside its DISTINCT ON same latest-matching-
not-latest bug. Fix dedups to true-latest per obj_id first (region_cd-only
CTE scope), then applies the volatile predicates."""
def _main_sql(self, db: MagicMock) -> str:
# call_index 1 is the radius/district aggregate (0 is the centroid lookup).
return " ".join(_executed_sql(db, 1).split())
def test_dedups_via_distinct_on_before_volatile_filters(self) -> None:
from app.services.analytics_queries import _competitors_two_dim
db = _mock_two_dim_db(radius_n=3, district_only_n=0)
_competitors_two_dim(db, region_code=66, district_name="Ленинский", target_class="Комфорт")
sql = self._main_sql(db)
assert "DISTINCT ON (obj_id)" in sql
assert "ORDER BY obj_id, snapshot_date DESC NULLS LAST" in sql
def test_volatile_filters_are_post_distinct_on(self) -> None:
from app.services.analytics_queries import _competitors_two_dim
db = _mock_two_dim_db(radius_n=3, district_only_n=0)
_competitors_two_dim(db, region_code=66, district_name="Ленинский", target_class="Комфорт")
sql = self._main_sql(db)
# Split the DISTINCT ON CTE (`latest`) from everything downstream. Its
# WHERE must scope on ONLY stable region_cd; site_status/district_name/
# obj_class predicates live in the downstream `active` CTE.
latest_open = sql.index("WITH latest AS (")
order_by_idx = sql.index("ORDER BY obj_id, snapshot_date DESC NULLS LAST")
latest_close = sql.index(")", order_by_idx)
latest_body = sql[latest_open:latest_close]
downstream = sql[latest_close:]
assert "site_status = 'Строящиеся'" not in latest_body
assert "district_name = :dn" not in latest_body
assert "COALESCE(obj_class, obj_class_fallback) = :cls" not in latest_body
assert "site_status = 'Строящиеся'" in downstream
assert "district_name = :dn" in downstream
assert "COALESCE(obj_class, obj_class_fallback) = :cls" in downstream
def test_output_shape_preserved(self) -> None:
from app.services.analytics_queries import _competitors_two_dim
db = _mock_two_dim_db(radius_n=3, district_only_n=2)
radius_n, district_only_n, total_weighted, scope = _competitors_two_dim(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
assert radius_n == 3
assert district_only_n == 2
# 3*1.0 + 2*0.6 = 4.2
assert total_weighted == 4.2
assert scope == "district_2d"
# ── Pure-Python spec replica: exact dedup + volatile-filter algorithm ─────
#
# MagicMock can't execute real SQL (and DISTINCT ON has no SQLite equivalent),
# so this class locks in the ALGORITHM the SQL is required to implement:
# "take the row with MAX(snapshot_date) per obj_id, THEN filter on that row's
# site_status" — as opposed to the buggy "filter rows on site_status, then
# take MAX(snapshot_date) among survivors". It's a spec/regression guard, not
# a live-DB execution test (see module docstring for why).
def _latest_snapshot_active_count(rows: list[dict[str, Any]]) -> int:
"""Reference implementation of what the fixed `_q` SQL computes."""
latest_by_obj: dict[int, dict[str, Any]] = {}
for row in rows:
obj_id = row["obj_id"]
cur = latest_by_obj.get(obj_id)
if cur is None or (row["snapshot_date"] or "") > (cur["snapshot_date"] or ""):
latest_by_obj[obj_id] = row
return sum(1 for r in latest_by_obj.values() if r["site_status"] == "Строящиеся")
class TestDedupAlgorithmSpec:
def test_multi_snapshot_same_obj_id_counted_once(self) -> None:
rows = [
{"obj_id": 1, "snapshot_date": "2026-04-27", "site_status": "Строящиеся"},
{"obj_id": 1, "snapshot_date": "2026-05-25", "site_status": "Строящиеся"},
{"obj_id": 1, "snapshot_date": "2026-06-28", "site_status": "Строящиеся"},
]
assert _latest_snapshot_active_count(rows) == 1
def test_sold_out_object_with_old_active_snapshot_not_counted(self) -> None:
# obj_id=2's LATEST snapshot says "Реализован" (sold out) even though
# older snapshots said "Строящиеся" — must NOT count as active.
rows = [
{"obj_id": 2, "snapshot_date": "2026-04-27", "site_status": "Строящиеся"},
{"obj_id": 2, "snapshot_date": "2026-05-25", "site_status": "Строящиеся"},
{"obj_id": 2, "snapshot_date": "2026-06-28", "site_status": "Реализован"},
]
assert _latest_snapshot_active_count(rows) == 0
def test_mixed_objects_only_latest_active_ones_counted(self) -> None:
rows = [
# obj 1: still active across all 3 snapshots -> counts once.
{"obj_id": 1, "snapshot_date": "2026-04-27", "site_status": "Строящиеся"},
{"obj_id": 1, "snapshot_date": "2026-06-28", "site_status": "Строящиеся"},
# obj 2: sold out on the latest snapshot -> excluded.
{"obj_id": 2, "snapshot_date": "2026-04-27", "site_status": "Строящиеся"},
{"obj_id": 2, "snapshot_date": "2026-06-28", "site_status": "Реализован"},
# obj 3: single snapshot, active -> counts once.
{"obj_id": 3, "snapshot_date": "2026-06-28", "site_status": "Строящиеся"},
]
assert _latest_snapshot_active_count(rows) == 2

View file

@ -11,6 +11,8 @@ skip geoportal при отсутствии centroid.
from __future__ import annotations
import contextlib
from types import SimpleNamespace
from typing import Any
from app.services.site_finder import ird_analyze
@ -668,3 +670,63 @@ def test_geoportal_client_uses_settings_timeout(monkeypatch: Any) -> None:
assert constructed == [7]
# ---------------------------------------------------------------------------
# _krt_at SAVEPOINT-регрессия (#2464 cluster A, session-poisoning)
# ---------------------------------------------------------------------------
#
# db.execute внутри _krt_at раньше глотал сбой БЕЗ SAVEPOINT/rollback — на реальном
# Postgres это отравляет shared report-session (build_ird_analyze_block ходит по
# ОДНОЙ db-сессии через несколько lookup'ов): следующий db.execute на той же сессии
# падал "current transaction is aborted". Фикс: db.execute обёрнут в db.begin_nested().
class _KrtAtFetchResult:
def __init__(self, rows: list[SimpleNamespace]) -> None:
self._rows = rows
def fetchall(self) -> list[SimpleNamespace]:
return self._rows
class _KrtAtFakeDB:
"""Фейковая сессия для юнит-теста _krt_at: execute().fetchall() + begin_nested()."""
def __init__(self, rows_or_exc: list[SimpleNamespace] | Exception) -> None:
self._rows_or_exc = rows_or_exc
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any]) -> _KrtAtFetchResult:
if isinstance(self._rows_or_exc, Exception):
raise self._rows_or_exc
return _KrtAtFetchResult(self._rows_or_exc)
def test_krt_at_query_failure_returns_empty_list() -> None:
"""DB-сбой (таблица не задеплоена / QueryCanceled) → [] (documented fallback)."""
db = _KrtAtFakeDB(RuntimeError("relation ekb_krt_geometry does not exist"))
assert ird_analyze._krt_at(db, 60.6, 56.8) == []
def test_krt_at_uses_savepoint_and_session_reusable_after_failure() -> None:
"""begin_nested обёрнут вокруг db.execute — сбой не отравляет сессию: follow-up
вызов _krt_at на ТОЙ ЖЕ db-сессии успевает."""
db = _KrtAtFakeDB(RuntimeError("boom"))
result = ird_analyze._krt_at(db, 60.6, 56.8)
assert result == []
assert db.begin_nested_calls == 1
db._rows_or_exc = [
SimpleNamespace(num_oks_krt=7, status="В работе", full_name="КРТ ул. Тверитина", area=3.5)
]
result2 = ird_analyze._krt_at(db, 60.6, 56.8)
assert len(result2) == 1
assert result2[0]["num_oks_krt"] == 7
assert result2[0]["area"] == 3.5
assert db.begin_nested_calls == 2

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