Compare commits

...

311 commits

Author SHA1 Message Date
b22edc6d6f Merge pull request 'chore(claude): token-diet — worker-агенты на sonnet (opus только deep-code-reviewer)' (#2292) from chore/claude-token-diet into main 2026-07-03 15:49:38 +00:00
bot-backend
25186a78ac chore(claude): token-diet — worker-агенты на sonnet (opus только deep-code-reviewer), forgejo MCP deferred в main-окне, analyst throttle ready>=10
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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
2026-07-03 18:46:58 +03:00
666962185c fix(tradein/estimator): парсер адресов — имя-перед-типом, glued-суффикс, микрорайоны (#2269) (#2290)
All checks were successful
Deploy Trade-In / test (push) Successful in 1m30s
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 / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 45s
2026-07-03 13:03:25 +00:00
d6d4a1c94a fix(site-finder): «Скачать отчёт» без пустых скобок на fast-path из кэша (#2289)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m16s
Deploy / deploy (push) Successful in 1m18s
2026-07-03 11:32:04 +00:00
2f127c4316 feat(site-finder): кнопка «Полный отчёт (PDF)» (#2259 PR-E, финал эпика) (#2288)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m28s
Deploy / deploy (push) Successful in 1m17s
2026-07-03 11:18:31 +00:00
034d9f9bca feat(reports): Celery-джоба полного PDF-отчёта + кэш + 3 endpoint'а (#2259 PR-D) (#2287)
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 3m12s
Deploy / build-worker (push) Successful in 4m8s
Deploy / build-frontend (push) Successful in 4m46s
Deploy / deploy (push) Has been cancelled
2026-07-03 11:13:06 +00:00
0b3ec7f91b feat(exporters): серверный статик-рендер карт для PDF-отчёта (#2259 PR-C Maps) (#2286)
All checks were successful
Deploy / deploy (push) Successful in 1m59s
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 3m47s
Deploy / build-worker (push) Successful in 4m45s
2026-07-03 10:29:23 +00:00
e7881cf45c feat(exporters): PURE HTML §4–§7 полного PDF-отчёта (#2259 PR-B Sections) (#2285)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m5s
Deploy / build-worker (push) Successful in 3m50s
Deploy / deploy (push) Successful in 1m55s
2026-07-03 10:10:43 +00:00
c0c18f4ecc feat(exporters): PURE HTML-агрегатор §1–§3 + альтернативы (#2259 PR-A Foundation) (#2284)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m56s
Deploy / build-worker (push) Successful in 3m56s
Deploy / deploy (push) Successful in 1m49s
2026-07-03 09:52:32 +00:00
85435fbf1b fix(site-finder): SET LOCAL литералом — bind-параметр ломал nearby_network_zones (#2283)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m57s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m47s
2026-07-03 08:44:38 +00:00
8a42fba836 feat(site-finder): блок «Сети рядом с участком» в §3 (Фаза C2-UI) (#2282)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m22s
Deploy / deploy (push) Successful in 1m17s
2026-07-03 08:23:59 +00:00
7c12812f81 feat(site-finder): позитив-разрез сетевых ЗОУИТ «сеть рядом с участком» (Фаза C2/C3) (#2281)
Some checks failed
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m49s
Deploy / build-worker (push) Successful in 4m31s
Deploy / build-frontend (push) Successful in 5m19s
Deploy / deploy (push) Has been cancelled
2026-07-03 08:18:24 +00:00
99ee09e230 feat(site-finder): теплотрассы из OSM в слой инженерной инфраструктуры (Фаза C1) (#2280)
All checks were successful
Deploy / changes (push) Successful in 21s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m53s
Deploy / build-worker (push) Successful in 3m11s
Deploy / deploy (push) Successful in 1m34s
2026-07-03 08:05:44 +00:00
dfc1ee8be0 feat: ревью-инструмент авто-маппингов Объектив↔ДОМ.РФ (/admin/mapping-review) (#2278)
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m47s
Deploy / build-worker (push) Successful in 5m42s
Deploy / build-frontend (push) Successful in 5m55s
Deploy / deploy (push) Has been cancelled
2026-07-03 07:59:13 +00:00
039954580e Merge pull request 'fix(tradein/estimator): кросс-source дедуп street+house union-find + savepoint вокруг ratio-lookup (#2265)' (#2273) from fix/tradein-dedup-addr-key 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 1m35s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 47s
2026-07-03 07:51:47 +00:00
bot-backend
6f81ee73d7 fix(tradein/estimator): полный сьют зелёный после дедуп-ключа (#2265)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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 / 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 2m20s
2026-07-03 10:48:15 +03:00
703f6f60c0 Merge pull request 'fix(tradein/v2/a11y): немодальный SectionOverlay + контраст токенов + headings — a11y round 3 (#2264)' (#2279) from fix/tradein-v2-a11y-round3 into main
Some checks failed
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 3m27s
Deploy Trade-In / deploy (push) Has been cancelled
2026-07-03 07:47:09 +00:00
4a9ddb2a9a Merge pull request 'chore(design-sync): durable-инпуты DS-панели tradein — 6 floor-card превью, шрифты Manrope/Plex, source-kit fork-фикс (#2267)' (#2274) from chore/design-sync-tradein into main 2026-07-03 07:38:41 +00:00
bot-backend
a3cab9023f fix(tradein/v2/a11y): немодальный SectionOverlay + контраст токенов + headings — a11y round 3 (#2264)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 16s
CI / changes (pull_request) Successful in 16s
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 1m13s
- SectionOverlay: убран aria-modal (честный non-modal fullscreen-view); focus-trap
  теперь охватывает живой верхний nav + оверлей (query по артборду, фильтр [inert]),
  Tab из nav возвращается в оверлей, не в спрятанный main/footer.
- Контраст 6 muted/disabled токенов затемнён до >=4.6:1 vs worst-case #dce6f1
  (hue сохранён, lightness-only): muted2/3/4, hint, disabledValue/Label.
- accent -> accentDeep как мелкий текст: номер секции оверлея (15px), счётчик
  "Мои отчёты" (10px).
- Семантика заголовков: sr-only h1 «Мера — оценка квартиры» + h2 (оверлей,
  ResultPanel, ObjectSummary, placeholder-панели).
- HeroBar «КОЭФ. ЛОКАЦИИ»: role=button + tabIndex + Enter/Space + aria-label.
- Фокус после успешной оценки переносится на результат-регион (role=region,
  tabIndex=-1, aria-labelledby); live-region «Оценка готова» сохранён.
- Инпут focus-ring усилен (2px solid accentDeep).
- combobox адреса: aria-live анонс количества подсказок; aria-required на
  адрес + площадь; декоративные inline-SVG помечены aria-hidden.
2026-07-03 10:37:42 +03:00
bot-backend
9cdc7cef21 fix(tradein/estimator): дедуп — нумерованные улицы + house-no guard на street-union (#2265, review follow-up)
Some checks failed
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 / 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) Failing after 1m51s
2026-07-03 10:37:26 +03:00
fc5c4b1e40 Merge pull request 'chore(tradein): полное выключение источника n1 — миграция 165 + вычистка backend/ops/frontend (#2204)' (#2276) from chore/tradein-remove-n1-source into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Successful in 37s
Deploy Trade-In / test (push) Successful in 1m49s
Deploy Trade-In / build-frontend (push) Successful in 2m56s
Deploy Trade-In / build-backend (push) Successful in 1m13s
Deploy Trade-In / deploy (push) Successful in 2m53s
2026-07-03 07:33:28 +00:00
bot-backend
9a9f13d513 fix(tradein/estimator): savepoint+retry вокруг ratio-lookup — transient не гасит expected_sold (#2265)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
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) Failing after 1m36s
2026-07-03 10:19:57 +03:00
bot-backend
a08e263c60 fix(tradein/estimator): кросс-source дедуп — устойчивый адресный ключ street+house (#2265) 2026-07-03 10:19:19 +03:00
bot-backend
ca0a51c876 chore(tradein/ui): вычистить источник n1 из source-registry и UI (#2204)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / 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) Successful in 1m36s
CI Trade-In / backend-tests (pull_request) Successful in 1m57s
2026-07-03 10:16:53 +03:00
68429967ab Merge pull request 'fix(tradein/scraper-kit): scale cian anchor watchdog for proxy-pool browser fetches (#2160)' (#2272) from fix/tradein-cian-anchor-timeout-pool into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m39s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 2m38s
2026-07-03 07:09:50 +00:00
bot-backend
dd050af16d chore(tradein): полное выключение источника n1 — миграция 165 + вычистка backend/ops (#2204) 2026-07-03 10:08:56 +03:00
bot-backend
8a10d943d7 fix(tradein/scraper-kit): scale cian anchor watchdog for proxy-pool browser fetches (#2160)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
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 1m51s
Fixed ANCHOR_TIMEOUT_SEC=240 guillotined every cian anchor when running via
USE_PROXY_POOL_BROWSER=true: each SERP fetch goes through camoufox with relaunch
on proxy swap (13-45s/page), and an anchor = 4 room-buckets x pages_per_anchor
SERP fetches + detail_top_n details + houses-enrich >> 240s. save_listings never
ran, lots_fetched=0, and after 3 consecutive skips the run was mark_banned.

Introduces _cian_anchor_timeout_s() mirroring the avito/yandex/domclick scaling
pattern: max(ANCHOR_TIMEOUT_SEC, buckets*pages*(delay+per_serp) + details*per_detail
+ houses_budget). Defaults -> 1580s worst-case watchdog (hang guard, not expected
duration). Applied to both the scraper-kit copy (strangler target) and the active
app.services.scrape_pipeline copy that run 581 actually hit.
2026-07-03 10:05:06 +03:00
f891f8f00f feat(eias-heat): +6 тепло-организаций ЕКБ — ненулевые резервы ×10 (#2119) (#2270)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m14s
Deploy / build-worker (push) Successful in 3m19s
Deploy / deploy (push) Successful in 1m43s
2026-07-03 07:02:03 +00:00
bbe596747d Merge pull request 'fix(tradein/data): yandex url-«дубли» — каноникализация URL (скрейпер + миграция 164) + manifest 162 (#2235, #2216)' (#2271) from fix/tradein-yandex-url-dedup 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 1m38s
Deploy Trade-In / build-backend (push) Successful in 1m44s
Deploy Trade-In / deploy (push) Successful in 3m3s
2026-07-03 06:53:27 +00:00
bot-backend
dd8b5769e5 fix(tradein/migrations): дописать 162 в _manifest_applied — stale manifest из #2228 (#2216)
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 1m44s
2026-07-03 09:41:44 +03:00
c44bb49809 Merge pull request 'fix(tradein/data): external_valuations.house_id — резолв в write-path + идемпотентный бэкфилл (#2236)' (#2263) from fix/tradein-extval-house-id into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 19s
Deploy Trade-In / build-frontend (push) Successful in 1m3s
Deploy Trade-In / build-browser (push) Successful in 1m7s
Deploy Trade-In / test (push) Successful in 2m4s
Deploy Trade-In / build-backend (push) Successful in 1m11s
Deploy Trade-In / deploy (push) Successful in 3m20s
2026-07-03 06:40:21 +00:00
bot-backend
33e3503f97 fix(tradein/data): yandex url-дубли — дедуп-миграция + инвариант + корень (#2235) 2026-07-03 09:39:29 +03:00
fc6542416d feat(exporters): §4.2 supply-only блок в ТЗ-PDF (#2177) (#2260)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m50s
Deploy / build-worker (push) Successful in 5m25s
Deploy / deploy (push) Successful in 2m27s
2026-07-03 06:38:23 +00:00
e1c5f2184c fix(tradein/scraper-kit): wire browser proxy-pool into yandex/cian SERP + persist activation flags (#2160) (#2268)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / build-frontend (push) Successful in 43s
Deploy Trade-In / build-browser (push) Successful in 44s
Deploy Trade-In / test (push) Successful in 1m58s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-07-03 06:36:56 +00:00
bot-backend
c2096249d5 fix(tradein/data): dry-run бэкфилла — savepoint-паритет с write-веткой (#2236)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 18s
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 2m58s
2026-07-03 09:28:27 +03:00
21608eced1 fix(site-finder): три честности-фикса — B1 ГРС «выход №N» + околонуль + Росреестр-квартал (#2257)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Successful in 3m35s
Deploy / build-worker (push) Successful in 5m31s
Deploy / build-frontend (push) Successful in 7m23s
Deploy / deploy (push) Successful in 2m17s
2026-07-03 06:27:27 +00:00
5f6db8dbb7 Merge pull request 'fix(tradein/security): admin.py — generic detail + error-id вместо сырого текста исключений (#2234)' (#2261) from fix/tradein-admin-exception-leak 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 / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m25s
Deploy Trade-In / build-backend (push) Successful in 1m54s
Deploy Trade-In / deploy (push) Successful in 1m32s
2026-07-03 06:27:22 +00:00
bot-backend
291717fa2f fix(tradein/data): external_valuations.house_id — резолв в write-path + идемпотентный бэкфилл (#2236) 2026-07-03 09:22:44 +03:00
bot-backend
efa0bafa18 fix(tradein/security): 404 IMV-адрес — warning без traceback, не exception-шум (#2234)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 14s
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 2m25s
2026-07-03 09:20:44 +03:00
bot-backend
c65a73ca9a fix(tradein/security): admin.py — generic detail + error-id вместо сырого текста исключений (#2234) 2026-07-03 09:16:34 +03:00
910bfed5f1 Merge pull request 'fix(tradein/security): photo-upload — чанковое чтение с капом 10MB + Caddy request_body 12MB (#2233)' (#2258) from fix/tradein-photo-upload-stream-cap into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 38s
Deploy Trade-In / build-frontend (push) Successful in 40s
Deploy Trade-In / test (push) Successful in 1m48s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 56s
2026-07-03 06:15:57 +00:00
e440209d91 feat(site-finder): «Пересчитать §7 с этой программой» из блока альтернатив (#2181 хвост) (#2256)
All checks were successful
Deploy / changes (push) Successful in 15s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 4m35s
Deploy / deploy (push) Successful in 1m28s
2026-07-03 06:11:58 +00:00
bot-backend
edbdd28286 fix(tradein/security): photo-upload — чанковое чтение с капом 10MB + Caddy request_body 12MB (#2233)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 29s
CI / changes (pull_request) Successful in 20s
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 2m16s
2026-07-03 09:06:51 +03:00
14d76fb136 Merge pull request 'feat(tradein/ui): честное раскрытие покрытия диапазона в hero (#2209)' (#2232) from fix/tradein-range-disclosure into main
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 3m3s
Deploy Trade-In / deploy (push) Successful in 1m14s
2026-07-03 06:06:31 +00:00
29a3cf9483 Merge pull request 'fix(tradein): оставить domklik активным — отключить его TTL-деактивацию (#2204)' (#2253) from fix/tradein-keep-domklik-active into main
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 10s
Deploy Trade-In / test (push) Successful in 1m36s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 51s
2026-07-03 06:00:21 +00:00
bot-backend
bbd47ae536 fix(tradein): оставить domklik активным — отключить его TTL-деактивацию (#2204)
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 1m39s
Продуктовое решение (user, 2026-07-03): domklik остаётся активным источником;
TTL-деактивация по scraped_at (заведена миграцией 160 этой ночью) отключается
до возобновления регулярных domclick-свипов. deactivate_stale_n1 не тронут
(n1 мёртв — деактивация остаётся). Миграция 163 идемпотентна (enabled=false).

Refs #2204
2026-07-03 08:52:56 +03:00
6f5bdffbe4 feat(site-finder): OSM-геокод городских ТП/РП ЕКБ (#2119 B2, финальный хвост) (#2252)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m51s
Deploy / build-worker (push) Successful in 3m0s
Deploy / deploy (push) Successful in 1m36s
2026-07-03 05:22:02 +00:00
15757685c9 feat(site-finder): Celery + §3 точки выхода газосети (#2119 B2, PR-4/4 — финал формы-6) (#2251)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m17s
Deploy / build-worker (push) Successful in 3m19s
Deploy / build-frontend (push) Successful in 3m54s
Deploy / deploy (push) Successful in 1m34s
2026-07-03 04:26:41 +00:00
55b13e2b13 fix(dadata): фолбэк на suggest/address при отключённой фиче CLEAN (#2250)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m54s
Deploy / build-worker (push) Successful in 2m56s
Deploy / deploy (push) Successful in 1m33s
2026-07-03 03:42:40 +00:00
cc9516ab3d feat(ekgas): геокод точек выхода через DaData (#2119 B2, PR-3/4) (#2249)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m6s
Deploy / build-worker (push) Successful in 3m8s
Deploy / deploy (push) Successful in 1m34s
2026-07-03 03:19:18 +00:00
c438c9a9e8 fix(ekgas): label-детект сетки формы 6 — живой файл сдвинут (#2119 B2) (#2248)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m54s
Deploy / deploy (push) Successful in 1m27s
Deploy / build-worker (push) Successful in 3m9s
2026-07-03 02:51:03 +00:00
af70d50f0d feat(site-finder): лоадер точек выхода ГРС «Екатеринбурггаз» — форма 6 (#2119 B2, PR-2/4) (#2247)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m46s
Deploy / build-worker (push) Successful in 2m51s
Deploy / deploy (push) Successful in 1m28s
2026-07-03 02:25:12 +00:00
7c73bbfbeb feat(db): схема gas_grs_outlet_points — форма 6 Екатеринбурггаза (#2119 B2, PR-1/4) (#2246)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 30s
Deploy / build-worker (push) Successful in 30s
Deploy / deploy (push) Successful in 1m16s
2026-07-03 02:15:50 +00:00
830d9f429a fix(etl): гео-проход — префикс «г Екатеринбург» адресам без города (#2177 остаток) (#2245)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m46s
Deploy / build-worker (push) Successful in 3m0s
Deploy / deploy (push) Successful in 1m37s
2026-07-03 01:41:19 +00:00
031bafde86 feat(etl): гео-проход auto_core_geo_v6 для tier_b/ambiguous mapping (#2177 остаток) (#2244)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m43s
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m27s
2026-07-03 01:19:59 +00:00
5888b0b9d3 perf(db): physflat-covering индекс v2 с price_per_m2_rub в INCLUDE (#2177 follow-up) (#2243)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 29s
Deploy / build-worker (push) Successful in 29s
Deploy / deploy (push) Successful in 1m26s
2026-07-03 00:49:08 +00:00
4b6d1cbc06 fix(report): авторетрай §7 с densify — «не вместила ни одной секции» больше не 422 при загрузке (#2242)
All checks were successful
Deploy / build-frontend (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m29s
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
2026-07-03 00:31:25 +00:00
1bba3db7eb fix(best-layouts): lots-проекты в пуле core-матчинга — supply-only реально наполняется (#2177) (#2241)
All checks were successful
Deploy / build-backend (push) Successful in 1m40s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m24s
2026-07-03 00:14:19 +00:00
9cfefc0ca4 feat(site-finder): supply-only fallback в §4.2 — предложение без темпа продаж (#2177 шаг 3) (#2240)
Some checks failed
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m48s
Deploy / build-worker (push) Failing after 1m54s
Deploy / build-frontend (push) Successful in 3m31s
Deploy / deploy (push) Has been skipped
2026-07-02 23:18:42 +00:00
fc55c2c0b7 feat(best-layouts): атрибуция velocity по ядру комплекса (#2177 шаг 2b) (#2239)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m54s
Deploy / build-worker (push) Successful in 2m59s
Deploy / build-frontend (push) Successful in 3m34s
Deploy / deploy (push) Successful in 1m32s
2026-07-02 22:41:25 +00:00
4c23c222f6 fix(vodokanal): column-aligned парсинг DOCX — реальные резервы вместо № п/п (#2119/#2126) (#2238)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m48s
Deploy / build-backend (push) Successful in 1m33s
Deploy / deploy (push) Successful in 1m27s
2026-07-02 22:03:15 +00:00
e9c8d05e1d feat(etl): auto_core_dev_v5 — core-name+dev backfill mapping'а (#2177 шаг 2) (#2237)
Some checks failed
Deploy / changes (push) Successful in 7s
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Successful in 2m8s
Deploy / build-frontend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
2026-07-02 21:59:16 +00:00
8ea52b9b7a fix(tradein/ops): health-check валит деплой + mem-лимиты + log-rotation (#2214) (#2231)
All checks were successful
Deploy Trade-In / build-browser (push) Successful in 32s
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Successful in 34s
Deploy Trade-In / test (push) Successful in 1m40s
Deploy Trade-In / build-backend (push) Successful in 26s
Deploy Trade-In / deploy (push) Successful in 45s
2026-07-02 21:29:30 +00:00
bot-backend
9de867631b feat(tradein/ui): честное раскрытие покрытия диапазона в hero (#2209)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 / changes (pull_request) Successful in 8s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
Хинт диапазона expected_sold дополнен замеренной точностью: «фактическая
цена продажи попадает в диапазон в ~80% случаев (июль 2026)» — по живому
бэктесту на реальных ДКП Росреестра (n=276, coverage 81.5%, engine=full).
Раскрытие вместо молчаливой выдачи Q1-Q3 за прогнозный интервал —
acceptance-пункт «явно раскрыть точность/покрытие» из #2209.

Refs #2209
2026-07-03 00:23:03 +03:00
9f28092356 fix(tradein/estimator): min-width floor ±12% на ценовой диапазон (#2209) (#2230)
All checks were successful
Deploy Trade-In / test (push) Successful in 1m31s
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 / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 46s
2026-07-02 21:21:28 +00:00
5fb5675871 fix(tradein/security): defense-in-depth trusted-header auth + rate-limiter fix (#2213) (#2229)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 35s
Deploy Trade-In / build-frontend (push) Successful in 38s
Deploy / build-worker (push) Successful in 42s
Deploy / build-backend (push) Successful in 43s
Deploy / build-frontend (push) Successful in 42s
Deploy / deploy (push) Successful in 1m32s
Deploy Trade-In / test (push) Successful in 1m50s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 51s
2026-07-02 21:15:58 +00:00
0793571c4a fix(tradein/deploy): миграции ДО подъёма app-контейнеров + manifest-инвариант (#2216) (#2228)
All checks were successful
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 20s
Deploy Trade-In / build-browser (push) Successful in 29s
Deploy Trade-In / build-frontend (push) Successful in 30s
2026-07-02 21:03:37 +00:00
19ecd941f3 fix(tradein/ui): единый source-registry + честные знаменатели/лейблы источников (#2211) (#2227)
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 3m3s
Deploy Trade-In / deploy (push) Successful in 1m4s
2026-07-02 20:57:26 +00:00
ae483468c9 feat(site-finder): оптимизатор программ — «Как участок сходится» при отрицательном вердикте (#2181) (#2222)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m40s
Deploy / build-worker (push) Successful in 3m54s
Deploy / build-frontend (push) Successful in 4m55s
Deploy / deploy (push) Successful in 1m35s
2026-07-02 20:57:12 +00:00
ac19c4f7b3 feat(tradein/monitor): алерт на staleness данных deals по max(deal_date) (#2212) (#2226)
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 8s
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 1m2s
2026-07-02 20:50:37 +00:00
4ce0f51bb8 perf(tradein): снять блокировку event loop на POST /estimate (#2207) (#2225)
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 1m35s
Deploy Trade-In / build-backend (push) Successful in 1m27s
Deploy Trade-In / deploy (push) Successful in 1m5s
2026-07-02 20:39:48 +00:00
47f45fc3d2 fix(tradein/scrapers): bump scraped_at при ре-подтверждении живым + бэкфилл (#2206) (#2224)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m29s
Deploy Trade-In / build-backend (push) Successful in 1m25s
Deploy Trade-In / deploy (push) Successful in 1m48s
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
2026-07-02 20:30:01 +00:00
72d26b35fa fix(tradein): не запекать preview-флаг в прод-образ + noindex/баннер (#2205) (#2223)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-browser (push) Successful in 33s
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / deploy (push) Successful in 57s
2026-07-02 20:21:58 +00:00
f5b0076e6f fix(tradein): deactivate_stale для domklik/n1 + честная freshness по scraped_at (#2204) (#2221)
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 1m30s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 2m57s
2026-07-02 20:10:34 +00:00
e6000cb029 ci(tradein): pre-merge гейт — backend pytest + frontend type-check/lint (#2208) (#2220)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Successful in 34s
Deploy Trade-In / test (push) Successful in 1m38s
Deploy Trade-In / build-backend (push) Successful in 1m29s
Deploy Trade-In / build-frontend (push) Successful in 3m21s
Deploy Trade-In / deploy (push) Successful in 1m18s
2026-07-02 19:56:22 +00:00
b028377584 fix(best-layouts): покрытие §4.2 в комплексах, не в сырых obj_id (#2177, шаг 1/3) (#2198)
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-backend (push) Successful in 2m51s
Deploy / build-worker (push) Successful in 4m5s
Deploy / build-frontend (push) Successful in 5m8s
Deploy / deploy (push) Successful in 1m37s
2026-07-02 19:34:11 +00:00
a7ad1764c1 feat(concept): 3D-макет окружения — соседние здания из кадастра в §7 (#2180) (#2210)
Some checks failed
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
2026-07-02 19:33:44 +00:00
ca473a0012 feat(proxy-pool): P4 — browser dynamic pool-proxy (acquire→/fetch→relaunch) + orchestration wiring, ship-dark (#2164)
All checks were successful
Deploy Trade-In / build-browser (push) Successful in 2m13s
Deploy Trade-In / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 2m29s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
2026-07-02 19:20:29 +00:00
d8ea4f2fd7 Merge pull request 'fix(tradein/deploy): scraper+backend paths-filter — matching/dedup/scraper-kit trigger rebuilds (#2188)' (#2199) from fix/tradein-deploy-scraper-paths-filter into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 8s
Deploy Trade-In / build-frontend (push) Successful in 30s
Deploy Trade-In / build-browser (push) Successful in 31s
Deploy Trade-In / test (push) Successful in 1m41s
Deploy Trade-In / build-backend (push) Successful in 34s
Deploy Trade-In / deploy (push) Successful in 59s
2026-07-02 19:04:20 +00:00
bot-backend
8fd2be97e4 fix(tradein/deploy): scraper+backend paths-filter — matching/dedup/scraper-kit trigger rebuilds (#2188)
All checks were successful
CI / changes (pull_request) Successful in 6s
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
2026-07-02 22:03:15 +03:00
0937da4260 feat(proxy-pool): P3 — kit curl-paths acquire/release from pool behind USE_PROXY_POOL_CURL, ship-dark (#2163)
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 8s
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 1m22s
2026-07-02 18:56:36 +00:00
ee47e331f3 Merge pull request 'fix(tradein/dedup): drop geo-guard in fias-pass — same-fias identity outranks proximity (#2187)' (#2195) from fix/tradein-dedup-fias-pass-no-geoguard into main
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 51s
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 51s
2026-07-02 18:51:54 +00:00
a9d06c9c44 Merge pull request 'fix(tradein/matching): P1 extended — never create a house from a numberless address (no cadastral)' (#2194) from fix/tradein-no-numberless-house-insert into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / test (push) Successful in 1m28s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m22s
Deploy Trade-In / deploy (push) Successful in 1m10s
2026-07-02 18:46:20 +00:00
bot-backend
e84b6e553a fix(tradein/scraper-kit): mirror numberless None-guards in avito houses provider copy
All checks were successful
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
2026-07-02 21:44:46 +03:00
bcdec5ebd4 feat(rewire): scheduler_main → kit-scheduler behind USE_KIT_SCHEDULER flag, ship-dark (#2192)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 58s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 37s
Deploy Trade-In / test (push) Successful in 1m38s
Deploy Trade-In / build-backend (push) Successful in 50s
2026-07-02 18:41:12 +00:00
c0566705a1 Merge pull request 'fix(tradein/v2): keep top nav active over overlays, stronger «К оценке» button, backdrop-close' (#2193) from fix/tradein-v2-overlay-nav-backdrop into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-07-02 18:39:27 +00:00
bot-backend
03ece36abd fix(tradein/dedup): drop geo-guard in fias-pass — same-fias identity outranks proximity (#2187)
All checks were successful
CI / changes (pull_request) Successful in 10s
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
2026-07-02 21:35:20 +03:00
139bf16a8a fix(eias-heat): не терять резерв при одинаковых именах систем (#2119 B2) (#2191)
All checks were successful
Deploy / build-backend (push) Successful in 2m3s
Deploy / build-worker (push) Successful in 3m25s
Deploy / deploy (push) Successful in 1m40s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
2026-07-02 18:34:42 +00:00
bot-backend
0eb033d77d fix(tradein/matching): P1 extended — never create a house from a numberless address (no cadastral)
All checks were successful
CI / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
2026-07-02 21:33:52 +03:00
a3db6e4158 feat(site-finder): §4.3 тренд из цен предложения Объектива при устаревших сделках (#2178) (#2190)
Some checks failed
Deploy / changes (push) Successful in 5s
Deploy / deploy (push) Blocked by required conditions
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
2026-07-02 18:33:16 +00:00
bot-backend
35a0e29122 fix(tradein/v2): keep top nav active over overlays, stronger «К оценке», backdrop-close
All checks were successful
CI / changes (pull_request) Successful in 6s
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
Three UX fixes on the /trade-in/v2 overlays (feedback):

1. TopNav stays interactive while a section overlay is open. The a11y
   audit #2081 put inert on the whole content wrap (which contains
   TopNav), so opening an overlay disabled the top tabs. Move inert off
   the wrap onto <main>/<footer> only; the nav is inert just for the
   full-screen LocationDrawer. Users can now switch sections directly
   from an open overlay. role=dialog/aria-modal/Esc/focus-trap kept.

2. «← К ОЦЕНКЕ» made a clear filled primary button (solid accentDeep bg,
   white text ≥4.9:1 AA, shadow, hover lift) instead of the weak ghost
   pill. Uses existing accent tokens only.

3. Backdrop click closes the overlay. Add a transparent sibling layer
   below the panel (z below it, starting under the nav so tabs stay
   clickable); clicks in the margin close it. Clicks inside the panel /
   on its buttons never reach it (sibling, not ancestor) so they don't
   close. Esc + focus restore preserved.
2026-07-02 21:28:47 +03:00
3b9dfdb44e Merge pull request 'feat(tradein/frontend): pass suggest fias_id as target_fias_id in estimate request' (#2186) from feat/tradein-fias-estimate-frontend into main
All checks were successful
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 53s
Deploy Trade-In / changes (push) Successful in 8s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m58s
2026-07-02 18:08:27 +00:00
b2d794587f Merge pull request 'feat(tradein/estimator): кросс-source физдедуп аналогов за флагом (#2087 H4)' (#2173) from fix/tradein-analog-dedup-cv into main
All checks were successful
Deploy Trade-In / test (push) Successful in 1m29s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / changes (push) Successful in 7s
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 51s
2026-07-02 18:04:39 +00:00
1c520cadb7 Merge pull request 'fix(tradein/dedup): fias-key clustering + cross-fias merge guard + identity carry-over (#1772 follow-up)' (#2185) from fix/tradein-dedup-by-fias into main
All checks were successful
Deploy Trade-In / test (push) Successful in 1m27s
Deploy Trade-In / build-backend (push) Successful in 51s
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 / deploy (push) Successful in 52s
2026-07-02 18:00:58 +00:00
bot-backend
333a36f049 feat(tradein/frontend): pass suggest fias_id as target_fias_id in estimate request
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 / changes (pull_request) Successful in 7s
2026-07-02 20:56:50 +03:00
bot-backend
3505a4e3e5 feat(tradein/estimator): включить кросс-source физдедуп аналогов по умолчанию (#2087 H4, #2173)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
estimate_dedup_analogs_enabled: False → True. Бэктест #1966 OFF vs ON
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median
width/cv без изменений) — включение меняет только user-visible n_analogs:
перестаёт быть раздутым кросс-постингом одного физлота на avito+cian+domklik
×3. Откат — ENV ESTIMATE_DEDUP_ANALOGS_ENABLED=false.

Тесты разведены default vs explicit (не ослаблены):
- test_estimator_dedup_cross_source_2087: добавлен test_dedup_default_is_on
  (без monkeypatch — дедуп активен по умолчанию); test_dedup_flag_off_is_noop
  оставляет ЯВНЫЙ OFF-override.
- test_backtest_regression_gate: пиним флаг OFF — гейт это байт-идентичный
  replay frozen OFF-capture (recorded call-sequence фикстуры без дедупа); с
  ON _dedup_cross_source триммит listings до quarter_indexes_lookup и
  control-flow расходится с записью.
- test_estimator_n_analogs_priced (autouse) + test_radius_path_n_analogs_unchanged:
  пиним OFF — инвариант «n_analogs = число priced-аналогов»/radius-passthrough
  ортогонален дедупу, а синтетические аналоги делят адрес/этаж/площадь и
  схлопнулись бы как кросс-посты.

Refs #2087, #2173
2026-07-02 20:56:39 +03:00
d87ebcc30e Merge pull request 'feat(tradein/matching): fias_exact tier + estimate fias persist + suggest fias_id contract' (#2184) from feat/tradein-fias-tier-matching 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 1m34s
Deploy Trade-In / build-backend (push) Successful in 54s
Deploy Trade-In / deploy (push) Successful in 55s
2026-07-02 17:56:13 +00:00
cdc1e03b32 fix(eias-heat): column-aligned парсинг листа ТП по реальной сетке (#2119 B2) (#2176)
All checks were successful
Deploy / deploy (push) Successful in 1m45s
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m54s
Deploy / build-worker (push) Successful in 3m7s
2026-07-02 17:55:16 +00:00
bot-backend
c4b1992de5 fix(tradein/dedup): fias-key clustering + cross-fias merge guard + identity carry-over (#1772 follow-up)
All checks were successful
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
2026-07-02 20:53:25 +03:00
b5f8f7b437 feat(report): §7 открыт по умолчанию + выделение участка на подложке + шире карта (#2179) (#2183)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been cancelled
2026-07-02 17:52:21 +00:00
bot-backend
67a852caf1 feat(tradein/matching): fias_exact tier + estimate fias persist + suggest fias_id contract
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 / changes (pull_request) Successful in 10s
2026-07-02 20:49:04 +03:00
509cecfa6a feat(scraper-kit): copy scheduler with registry-dispatch (25+ trigger_* → dict), strangler (#2136)
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 1m30s
Deploy Trade-In / build-backend (push) Successful in 1m21s
Deploy Trade-In / deploy (push) Successful in 59s
2026-07-02 17:44:03 +00:00
94114adca1 Merge pull request 'fix(tradein/estimator): не отравлять asking→sold ratio-кэш транзиентной ошибкой' (#2175) from fix/tradein-asking-sold-ratio-cache-poison into main
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 53s
Deploy Trade-In / deploy (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 8s
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 1m26s
2026-07-02 17:37:12 +00:00
bot-backend
3909db745b fix(tradein/estimator): не отравлять asking→sold ratio-кэш транзиентной ошибкой
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
Блок «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» (expected_sold, «с учётом торга») молча гас в «—» у
части оценок с полноценным headline (прод: high-confidence, 17 аналогов, цена
есть — expected_sold NULL). Причина: `_get_asking_sold_ratio` gracefully ловит
ЛЮБУЮ ошибку БД → (None, None), но запись в `_asking_sold_ratio_cache` шла
БЕЗУСЛОВНО (после try/except) → один транзиентный сбой (poisoned tx от
вышестоящего graceful-except, коннект-хиккап) отравлял кэш бакета `(None, None)`
на весь TTL (300с) и гасил expected_sold для ВСЕХ оценок этого rooms-бакета на
воркере до истечения TTL.

Фикс: ранний `return None, None` из except БЕЗ записи в кэш → следующая оценка
ретраит. Кэшируем только успешный lookup (ratio может быть None, если строки
реально нет — стабильный факт БД, безопасно кэшировать).

Прод-диагностика (30 дней, 304 оценки): 39 пустых expected_sold, из них 37 —
легитимно n_analogs=0 (нет аналогов), а 2 — этот баг (headline есть, ratio-кэш
отравлён). Таблица asking_to_sold_ratios полна по всем бакетам + global fallback
0.843 → при исправном lookup ratio всегда резолвится.

Юнит-тесты: транзиентная ошибка → None БЕЗ кэша; error→retry не залипает;
успех кэшируется.
2026-07-02 20:34:10 +03:00
abdb413a99 feat(scraper-kit): copy remaining sweep orchestrators (yandex/cian/domclick/full-load), strangler (#2135 F2)
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / deploy (push) Successful in 59s
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 1m27s
Deploy Trade-In / build-backend (push) Successful in 1m32s
2026-07-02 17:29:05 +00:00
15224da3b5 fix(eias-heat): ссылка TemplatePrinter из onclick + период «N кв. YYYY» (#2119 B2) (#2172)
All checks were successful
Deploy / build-backend (push) Successful in 1m48s
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m26s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
2026-07-02 17:24:26 +00:00
bot-backend
3ad1550363 feat(tradein/estimator): кросс-source физдедуп аналогов за флагом (#2087 H4)
All checks were successful
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
Один физический лот кросс-постится на avito+cian+domklik (разные source и
source_id) → existing radius-дедуп по (source, source_id) его НЕ ловит →
n_analogs И source_counts раздуты кросс-постами (аудит #2087: лот
80м²/265000₽/м² = N1+Домклик+Циан ×3; «14 аналогов» → ~6-7 уникальных).

_dedup_cross_source схлопывает дубли по физическому ключу (building_cadastral
| нормализованный address + floor + area-bucket round(m²) + price-bucket
round(₽/100k)) ДО подсчёта n_analogs/median/cv, оставляя свежайшего (scraped_at)
представителя. За флагом estimate_dedup_analogs_enabled (default OFF =
байт-идентично; регресс-гейт зелёный).

Бэктест #1966 (400 ДКП, full spine, OFF vs ON, тот же сэмпл): MAPE 13.89%→13.89%,
coverage 83.33%→83.33%, bias −3.83%→−3.83%, median width 0.743→0.743, median cv
0.0988→0.0988, avg n_analogs 27.64→27.57 (дедуп отработал 107× на 335 оценках).
Кросс-посты имеют identical price → нулевой вклад в дисперсию → cv/коридор НЕ
сужаются: это фикс ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут, source_counts по
физлотам), accuracy-нейтральный, а НЕ рычаг cv-сужения (рычаг cv→коридор —
estimate_sb_clip_after_weight, уже default ON). Default OFF — narrowing не
продемонстрирован; флаг готов к canary/монкипатч-бэктесту.

Refs #2087
2026-07-02 20:22:46 +03:00
8a177df0e2 feat(scraper-kit): copy sweep pipeline orchestration (avito) with protocol injection, strangler (#2135)
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / changes (push) Successful in 8s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Successful in 1m0s
2026-07-02 17:08:40 +00:00
f7063d49b5 fix(scraper-kit): sync RealScraperConfig adapter with ScraperConfig protocol (cookie_encryption_key)
All checks were successful
Deploy Trade-In / changes (push) Successful in 26s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 50s
Deploy Trade-In / build-backend (push) Successful in 1m29s
Deploy Trade-In / test (push) Successful in 1m56s
Deploy Trade-In / deploy (push) Successful in 2m45s
2026-07-02 16:46:05 +00:00
e501dedf78 feat(site-finder): ЕЭСК-резервы городских ПС/ТП + тепло из ФГИС ФАС (#2119 B2) (#2165)
All checks were successful
Deploy / build-frontend (push) Successful in 4m38s
Deploy / deploy (push) Successful in 1m37s
Deploy / build-backend (push) Successful in 2m48s
Deploy / build-worker (push) Successful in 3m59s
Deploy / changes (push) Successful in 9s
2026-07-02 16:44:57 +00:00
d7f4873a53 feat(proxy-pool): pool service (acquire/release/health) + healthcheck scheduler task (#2162)
Some checks failed
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Failing after 1m36s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 33s
2026-07-02 16:41:39 +00:00
62fa1f732a refactor(scraper-kit): unify price bisection engine across providers + dedup (#2134)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
2026-07-02 16:41:04 +00:00
b8ed46d2dc Merge pull request 'fix(tradein/frontend): redirect legacy /trade-in root to canonical /v2' (#2166) from fix/tradein-canonical-v2-redirect into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Failing after 1m37s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m29s
Deploy Trade-In / deploy (push) Has been skipped
2026-07-02 16:33:08 +00:00
90e4a741f7 feat(proxy-pool): scrape_proxies table + admin bulk-load/list endpoints (#2161)
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 33s
Deploy Trade-In / test (push) Failing after 1m32s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
2026-07-02 16:31:21 +00:00
f937f5a168 fix(tradein/frontend): redirect legacy /trade-in root to canonical /v2
All checks were successful
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Оценщики клиента должны попадать на новую витрину /trade-in/v2 (wired на
real data), а не на легаси-корень (app/page.tsx, HeroSummary). Добавляю
декларативный redirects() в next.config: source "/" -> destination "/v2".

- permanent:false -> 307 (обратимо, не кэшируется навсегда)
- basePath авто-префиксит -> редирект /trade-in -> /trade-in/v2
- query-string Next пробрасывает автоматически (destination без своего
  query) -> /trade-in/?id=<uuid> -> /trade-in/v2?id=<uuid>, restore
  оценки по ?id= не ломается (v2 читает ?id= через window.location.search)
- легаси page.tsx / компоненты остаются в репо (только редирект, обратимо)
2026-07-02 19:26:55 +03:00
86a22dfc8f Merge pull request 'fix(tradein/v2): честная подача интервала оценки — одна цифра-герой + честный разброс' (#2159) from fix/tradein-v2-hero-honest-interval into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Failing after 1m31s
Deploy Trade-In / build-frontend (push) Successful in 2m14s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
2026-07-02 16:20:06 +00:00
d63122d3e4 fix(tradein/v2): честная подача интервала оценки — одна цифра-герой + честный разброс
All checks were successful
CI / changes (pull_request) Successful in 8s
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
Оценщик жаловался на «большой интервал» между рекомендованной ценой и
оценкой. Бэктест: ширину коридора косметически не сузить (реальная рыночная
дисперсия + asking→sold gap). Чиним подачу, не число (математика не тронута).

Витрина /trade-in/v2 (активная, wired на real data через mapResultPanel):
- deltaLabel «К РЫНКУ» → «К ОБЪЯВЛ.» (mappers.ts + fixtures.ts): −N% —
  разрыв «объявление → ожидаемая сделка», а не «скидка от нас».
- ResultPanel: честная подпись под ценовыми карточками — «Диапазоны
  показывают разброс цен на рынке, а не погрешность оценки». Широкий
  диапазон не читается как неуверенность расчёта.
- Иерархия expected_sold=headline / median=baseline / ДКП=справочно уже
  была (audit H2, #2062/#2081) — не трогаем.

Легаси-витрина / (HeroSummary), тот же принцип и устранение конфликта термина:
- hero-duo: expected_sold — цифра-герой («Оценка · ожидаемая цена продажи»,
  34px, accent), median — вторичный «Рекомендованная цена в объявлении»
  (20px, приглушённый) вместо двух равнозначных 28px-блоков «Выставить»/
  «Получить». Новый модификатор .hero-duo--sold-primary.
- Диапазон sold подписан «рыночный разброс» + honest hint «Диапазон отражает
  разброс цен по рынку, а не погрешность оценки».
- Бейдж «−N%» → «−N% к объявлению» + title (разрыв asking↔сделка, не скидка).
- OfferCard: «Оценка по рынку» → «Цена в объявлении» (median ≠ «оценка»;
  слово «оценка» на витрине = expected_sold).

Проверка: tsc --noEmit чисто; next build чисто (lint+types, 13 роутов).
2026-07-02 19:16:37 +03:00
3be8062062 feat(scraper-kit): copy yandex providers with protocol injection, strangler (#2133 yandex)
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Failing after 1m31s
Deploy Trade-In / build-frontend (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) Has been skipped
2026-07-02 16:13:32 +00:00
19c0be5d5a feat(scraper-kit): copy cian providers with protocol injection, strangler (#2133 cian)
Some checks failed
Deploy Trade-In / test (push) Failing after 1m30s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (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-frontend (push) Has been skipped
2026-07-02 16:11:31 +00:00
b7abaeb569 feat(scraper-kit): copy domclick provider with protocol injection, strangler (#2133 domclick)
Some checks failed
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 1m28s
Deploy Trade-In / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Has been cancelled
2026-07-02 16:08:09 +00:00
df5e9003df feat(scraper-kit): copy avito providers with protocol injection, strangler (#2133 avito)
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 1m30s
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 1m1s
2026-07-02 15:58:00 +00:00
97acf5273c docs(claude): delegation.md — routing-таблица, effort-правила, экономика пачек (#2154) 2026-07-02 15:55:00 +00:00
5c17d21f6c feat(scraper-kit): copy core layer (base/save_listings/transport) with protocol injection, strangler (#2132)
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 1m27s
Deploy Trade-In / build-backend (push) Successful in 1m22s
Deploy Trade-In / deploy (push) Successful in 1m8s
2026-07-02 15:43:15 +00:00
9d71d94586 Merge pull request 'fix(tradein/estimator): честный asking_to_sold_ratio — бейдж «−N% к рынку» больше не врёт' (#2152) from fix/tradein-honest-asking-to-sold-ratio into main
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m28s
Deploy Trade-In / deploy (push) Successful in 52s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m5s
2026-07-02 15:37:00 +00:00
e4a2d58c0c Merge pull request 'chore(claude): актуализация .claude-конфигов — политика 2026-06-27, delegation-лимиты, stale refs' (#2146) from chore/claude-config-refresh into main
Reviewed-on: #2146
2026-07-02 15:31:48 +00:00
ecc3ab5aab fix(tradein/estimator): честный asking_to_sold_ratio — бейдж «−N% к рынку» больше не врёт
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Оценщик клиента жаловался на «большой интервал между рекомендованной ценой
и оценкой». Разбор: бейдж «−23% к рынку» (web HeroSummary + PDF, формула
round((1−ratio)×100)) систематически завышал скидку.

Root cause: сохранённый asking_to_sold_ratio — это СЫРОЙ per-rooms/tier дисконт
из ratio_resolver, но фактический expected_sold сдвинут относительно median×ratio
последующими корректировками: hedonic year+area (#2002, factor ∈ [0.75, 1.30], ON
by default), le_asking-clamp и corridor-clamp. Пример с прода (451de30b): median
7.75M × raw 0.771 = 5.97M, hedonic ×1.226 → expected_sold 7.32M — но stored ratio
остался 0.771, тогда как фактическое expected_sold/median = 0.945. Бейдж показывал
«−23%» вместо честных «−5%».

Fix: после финализации expected_sold пересчитываем сохранённый asking_to_sold_ratio
как реальное expected_sold_price/median_price (честный дескриптор). Сам expected_sold
(выкуп) НЕ трогаем — hedonic-uplift остаётся прибит к sale-модели, buyout не падает
до наивного median×raw. Порог _RATIO_DESCRIPTOR_EPS=1e-4 отсекает шум округления:
без сдвига (hedonic OFF, нет клампа) табличный ratio сохраняется байт-в-байт →
регрессия на не-зажатых оценках отсутствует.

Стор asking_to_sold_ratio — чисто ДЕСКРИПТОР (web/PDF/history badge), НЕ калибровочный
вход: калибровочный ratio живёт в таблице asking_to_sold_ratios (refresh-task, читает
resolver) — не тронута. Backtest #1966 скорит expected_sold_per_m2 (не stored ratio) —
не затронут (expected_sold без изменений).

Tests: 3 новых в test_estimator_price_spine.py (инвариант при hedonic-uplift +
corridor-clamp; byte-identical регрессия без сдвига); поправлен
test_global_fallback_basis_carried_through (hedonic OFF для сырого ratio).
Full suite: 2749 passed (кроме pre-existing test_search_cache_hit).

Refs #2141
2026-07-02 18:26:19 +03:00
6391587572 feat(scraper-kit): protocol boundaries (matcher/config/session/jobs) + backend adapters (#2131)
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 1m27s
Deploy Trade-In / build-backend (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 1m0s
2026-07-02 15:25:38 +00:00
b7d35cfee0 chore(claude): security hardening — dangerous-commands hook, settings в git, restricted DB, throttle (#2150) 2026-07-02 15:23:11 +00:00
abef3e6e02 feat(market): revoke gendesign_reader direct SELECT on raw tables (#2138 step 3/3)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m31s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 54s
2026-07-02 15:11:55 +00:00
1c184959a8 feat(etl): read tradein data via market.v_* contract views (#2138 step 2/3)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m15s
Deploy / build-worker (push) Successful in 3m12s
Deploy / deploy (push) Successful in 1m34s
2026-07-02 15:00:38 +00:00
03b3cb9fe4 feat(market): grant gendesign_reader SELECT on contract views (#2138 step 1/3)
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 1m30s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 50s
2026-07-02 14:47:49 +00:00
0a6281b6e2 fix(site-finder): ГРС-парсер переписан под реальную структуру таблицы ГТЕ (#2145)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-worker (push) Successful in 3m15s
Deploy / deploy (push) Successful in 1m30s
2026-07-02 14:45:42 +00:00
f02067c7af chore(claude): CLAUDE.md — уточнить always-load природу delegation.md (review nit)
All checks were successful
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
2026-07-02 17:34:04 +03:00
227b939778 Merge pull request 'fix(tradein/v2): канонический sources_used — стабильный счётчик «ИСТОЧНИКОВ X/7» (#2087 M1)' (#2144) from fix/tradein-v2-source-counter-canonical 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 1m33s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 50s
2026-07-02 14:31:15 +00:00
6965fd0484 chore(claude): git-pr.md — согласовать остаточные ссылки с parallel-default 2026-07-02 17:29:13 +03:00
79e4528eeb chore(claude): port delegation limits to repo rules, fix git-pr staleness
- new rules/delegation.md (always-loaded): subagent budget, oversized escalation,
  единые пороги дробления, parallel-default — портировано из memory 2026-06-27
- git-pr.md: parallel-default вместо sequential (supersedes), list_pull_reviews,
  фактические worktree settings, typos
- CLAUDE.md: merge-политика 2026-06-27, tradein/delegation в списке rules,
  auto-* vs Task-spawn различие
2026-07-02 17:28:05 +03:00
b29b69343b chore(claude): sync agent specs with 2026-06-27 policy, fix stale refs
- code-reviewer: self-merge policy 2026-06-27 vs ложный BLOCK; origin/gh → forgejo/MCP
- phase-5-verdict: self-extending guard в 'когда НЕ мержить'
- database-expert: prod DDL только через data/sql + deploy, не execute_sql
- auto-*: model-текст и интервалы тиков синхронизированы с фактом
- qa-tester: chrome-devtools → playwright; devops: Forgejo Actions
2026-07-02 17:25:27 +03:00
18b22c9df8 fix(tradein/v2): canonical sources_used — kill «ИСТОЧНИКОВ X/7» POST↔GET desync
All checks were successful
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
Единый helper estimator._canonical_sources(analogs, valuation_flags) — источник
правды для sources_used, зовётся идентично на POST (estimate_quality) и
GET-rehydrate (get_estimate) из ТЕХ ЖЕ persisted analogs + оценочных флагов.

Root cause: три расходящиеся деривации — POST брал sources_used из top-N
analogs_lots, GET возвращал persisted-колонку (радиусный набор + quarter_index),
source_counts на GET считался из persisted analogs. Источник (напр. cian) мог
попасть в source_counts, но не в sources_used → счётчик «X/7» прыгал 4→5 между
POST-ответом и reload(GET).

- sources_used = {листинговые из persisted analogs} ∪ {avito_imv/yandex_valuation/
  cian_valuation}. Детерминированно отсортирован.
- source_counts на POST теперь тоже из analogs_lots (не полной metadata-выборки)
  → инвариант source_counts.keys() ⊆ sources_used на POST и GET.
- POST персистит канонический sources_used в колонку (history/PDF консистентны для
  новых строк); GET рехайдрейтит его же helper'ом — чинит и СТАРЫЕ строки
  (листинговая часть пересобирается из analogs, quarter_index/радиусный шум
  отбрасывается фильтром оценочных флагов).

Оценочные флаги персистятся в колонке sources_used и читаются оттуда на GET —
реконструкция не требуется.

Repro 451de30b: до — sources_used=[avito,avito_imv,domklik,yandex], source_counts
имеет cian (не в sources_used); после — sources_used=[avito,avito_imv,cian,
domklik,yandex] (5/7), counts.keys() ⊆ sources_used.

Part of #2087 (M1).
2026-07-02 17:22:20 +03:00
8d7feabf9f chore(scraper-kit): docker-context wiring + restore backend workspace dep (#2137)
All checks were successful
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 30s
Deploy Trade-In / test (push) Successful in 1m29s
Deploy Trade-In / build-backend (push) Successful in 1m21s
Deploy Trade-In / deploy (push) Successful in 1m2s
2026-07-02 14:21:38 +00:00
ddf6cb7ce3 chore(scraper-kit): scaffold internal package + uv workspace, build-safe (#2128)
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 / build-backend (push) Successful in 1m22s
Deploy Trade-In / deploy (push) Successful in 1m24s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Successful in 1m31s
2026-07-02 14:06:07 +00:00
32d1818fd4 fix(scrapers/avito): unify _unix_to_date timezone across serp/houses/imv (#2129)
Some checks failed
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 1m32s
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-backend (push) Successful in 59s
2026-07-02 14:03:11 +00:00
e2bb5061f9 feat(market): contract views market.v_* over producer tables (#2130)
Some checks failed
Deploy Trade-In / build-browser (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 / build-frontend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-02 14:02:59 +00:00
5f661bd0b7 Merge pull request 'feat(tradein): #2043 BE-1 (cv/source_counts/created_at + history/cache-KPI) + #2044 BE-2 (radius_m query-params)' (#2140) from feat/tradein-v2-be1-be2-contract into main
Some checks failed
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 1m38s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Has been cancelled
2026-07-02 13:59:08 +00:00
3bf2c1a48a feat(tradein/api): #2044 BE-2 — optional radius_m on house-analytics + sell-time-sensitivity
All checks were successful
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
radius_m (schema + estimate_quality comp-search) уже в main (e23dabe4). Здесь —
остаток: два derived-роута принимают radius_m как optional query-param.

- GET /estimate/{id}/house-analytics и /sell-time-sensitivity: += radius_m
  (int|None). None → байт-идентичная авто-логика (расширение до 300 м только при
  <8 in-house записей). Задан → явный радиус расширения выборки домов, клампится
  в [100,5000], возвращается в radius_m ответа.

Refs #2044
2026-07-02 16:51:11 +03:00
24b107ddb5 feat(tradein): #2043 BE-1 — surface cv/source_counts/created_at + /history.sources_used + cache-KPI
Данные уже считаются в estimator — отдаём наружу для /trade-in/v2 (снимает
approximate-флаги CV / счётчиков источников / даты отчёта / «ИСТОЧНИКОВ N/7»).

- AggregatedEstimate += cv (float|None), source_counts (dict[str,int]), created_at.
- estimator: _cv_from_ppm2 / _source_counts helpers. cv прокинут через
  PricingResult — anchor-путь берёт CV комплов (anchor["cv"]), radius-путь — CV
  радиусной ₽/м²-выборки. source_counts считается по ПОЛНОЙ выборке (metadata_lots)
  до top-N отсечки. created_at = момент создания.
- POST /estimate возвращает все три; GET /estimate/{id} пересчитывает cv/
  source_counts из сохранённых analogs (best-effort) + created_at из колонки.
- /history: += sources_used (jsonb) в проекции → колонка «ИСТОЧНИКОВ N/7».
- /cache-stats: += avg_median_price (по median_price>0) + repeat_address_pct
  (доля строк с неуникальным address). Честный best-effort по persisted-оценкам.

Refs #2043
2026-07-02 16:50:18 +03:00
102ef8392b fix(site-finder): ГРС-лоадер — HARICA-интермедиат в CA-бандл (неполная цепочка Газпрома) (#2125)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m52s
Deploy / build-worker (push) Successful in 2m58s
Deploy / deploy (push) Successful in 1m43s
2026-07-02 13:49:14 +00:00
da0b8f0374 fix(site-finder): ГРС-лоадер — Russian Trusted CA для домена Газпрома (#2124)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 1m42s
Deploy / build-worker (push) Successful in 3m3s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m30s
2026-07-02 13:25:30 +00:00
5839680998 feat(site-finder): газ — свободная мощность ГРС + пометка ЕЭСК в §3 (#2119 Фаза B1) (#2123)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m2s
Deploy / build-worker (push) Successful in 3m17s
Deploy / build-frontend (push) Successful in 3m57s
Deploy / deploy (push) Successful in 1m31s
2026-07-02 12:54:05 +00:00
2bb3ceb9de fix(site-finder): секции отчёта изначально раскрыты («Свернуть» для компакта) (#2122)
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 3m20s
Deploy / deploy (push) Successful in 1m16s
2026-07-02 10:43:32 +00:00
e1a75d98d4 fix(site-finder): Россети-раскрытие — URL-энкоженные href, ссылки на xlsx не находились (#2121)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m53s
Deploy / build-worker (push) Successful in 2m58s
Deploy / deploy (push) Successful in 1m33s
2026-07-02 09:36:14 +00:00
4758ea5e2b feat(site-finder): резервы мощности для ТП — электро+вода на карте §3 (#2119 Фаза A) (#2120)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m16s
Deploy / build-worker (push) Successful in 3m27s
Deploy / build-frontend (push) Successful in 3m47s
Deploy / deploy (push) Successful in 1m30s
2026-07-02 08:36:51 +00:00
4a7ce91db1 fix(site-finder): §4.3 график «По кварталам» — бары вылезали за край карточки (#2118)
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 / deploy (push) Successful in 1m12s
Deploy / build-frontend (push) Successful in 3m14s
2026-06-30 17:15:56 +00:00
babb2a3473 fix(site-finder): §4.3 — укомпановать 3 колонки в единообразные карточки (#2117)
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 3m28s
Deploy / deploy (push) Successful in 1m15s
2026-06-30 16:51:52 +00:00
a2ec1fbc90 fix(site-finder): §4.3 подпись — убрать сырое имя поля planned_commissioning (#2115)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m7s
Deploy / build-worker (push) Successful in 2m57s
Deploy / deploy (push) Successful in 1m38s
2026-06-30 15:54:58 +00:00
e52dc47067 feat(site-finder): прогрессивное раскрытие секций отчёта §1–§6 («Раскрыть всё») (#2116)
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 3m34s
Deploy / deploy (push) Successful in 1m22s
2026-06-30 15:31:33 +00:00
663f04b777 fix(site-finder): попап конкурента — NUMERIC-поля приходят строкой (цена/площадь не рендерились) (#2114)
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 3m58s
Deploy / deploy (push) Successful in 1m22s
2026-06-30 14:01:00 +00:00
02b5f0b5d1 feat(site-finder): max-info попапы — конкурент / §3-таблица / польз.точка / соседи (#2111) (#2113)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m26s
Deploy / build-worker (push) Successful in 3m41s
Deploy / deploy (push) Successful in 1m30s
Deploy / build-frontend (push) Successful in 4m2s
2026-06-30 13:45:43 +00:00
6d6e0ce0ca feat(site-finder): §3 «Сети и точки подключения» — попапы показывают характеристики (#2111) (#2112)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m2s
Deploy / build-worker (push) Successful in 3m29s
Deploy / build-frontend (push) Successful in 3m44s
Deploy / deploy (push) Successful in 1m30s
2026-06-30 13:16:07 +00:00
b024864518 feat(site-finder): §1 POI-точки карты показывают максимум информации (#2110)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m3s
Deploy / build-worker (push) Successful in 3m15s
Deploy / build-frontend (push) Successful in 3m51s
Deploy / deploy (push) Successful in 1m27s
2026-06-30 12:42:13 +00:00
daf4ebefc2 feat(concept): 3D-масса корпусов на карте с тенями (§7) (#1953) (#2109)
All checks were successful
Deploy / deploy (push) Successful in 1m14s
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
2026-06-30 11:58:47 +00:00
53cfd3c3b8 feat(networks): человеко-язык типов §3 + реальные имена НСПД-точек (#1953) (#2107)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m55s
Deploy / build-worker (push) Successful in 3m4s
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m31s
2026-06-30 11:34:29 +00:00
4ffda3299c fix(report): §4.3 выравнивание колонок (уплыло) + двойная точка в вердикте (#1953) (#2108)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m24s
Deploy / deploy (push) Successful in 1m15s
2026-06-30 11:29:33 +00:00
fe85cbb7ae fix(report): sticky-сайдбар навигации — overflow-x: clip вместо hidden (#958) (#2106)
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 3m7s
Deploy / deploy (push) Successful in 1m15s
2026-06-30 11:05:54 +00:00
806428abc9 fix(report): instant nav jump + scroll-position scrollspy (#958) (#2105)
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 3m23s
Deploy / deploy (push) Successful in 1m15s
2026-06-30 10:45:33 +00:00
02f49e795c feat(report): nav scrollspy + offset-jump, чат в плавающее окно справа (#958) (#2104)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m20s
Deploy / deploy (push) Successful in 1m16s
2026-06-30 10:29:02 +00:00
de4704f6cd fix(concept): step="any" — stepMismatch блокировал submit «Рассчитать концепцию» (#1953) (#2103)
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 3m8s
Deploy / deploy (push) Successful in 1m16s
2026-06-30 10:09:55 +00:00
975c400536 feat(section7): editable section footprint + корпус plural/program hint (#1953) (#2102)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 1m57s
Deploy / build-frontend (push) Successful in 3m34s
Deploy / build-worker (push) Successful in 3m41s
Deploy / deploy (push) Successful in 1m32s
2026-06-30 09:48:11 +00:00
e359fb4b95 feat(section7): unify «Концепция» into one engine driven by building_program (#1953) (#2101)
All checks were successful
Deploy / deploy (push) Successful in 1m14s
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m13s
2026-06-30 08:35:55 +00:00
5f84008cb3 fix(site-finder): §4.2 «Качество данных» — inline-стили (Tailwind flex/gap не применяется) (#1953) (#2100)
All checks were successful
Deploy / build-frontend (push) Successful in 3m28s
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Successful in 1m15s
2026-06-30 07:33:11 +00:00
005ce2f4ad fix(site-finder): §4.2 «Качество данных» — воздух вокруг разделителя (#1953) (#2099)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m22s
Deploy / deploy (push) Successful in 1m19s
2026-06-30 07:13:16 +00:00
f448b550b5 feat(site-finder): §7 «Концепция» — методики + синхронизация подписей (#1953, batch D) (#2098)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m30s
Deploy / deploy (push) Successful in 1m14s
2026-06-29 12:24:21 +00:00
d9915c7834 fix(site-finder): unify §3 «Сети» table↔map source + enrich popups (#1953, batch C) (#2097)
All checks were successful
Deploy / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m4s
Deploy / changes (push) Successful in 7s
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Successful in 1m21s
2026-06-29 11:56:11 +00:00
1337fadb49 fix(forecast): §6 honesty layer — pegged deficit, non-residential caveat, segment/pipeline note (#1953) (#2096)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m21s
Deploy / deploy (push) Successful in 1m14s
2026-06-29 11:26:35 +00:00
86f0797499 fix(site-finder): report microcopy batch A — gate titles, breadcrumb area, §4.2/4.3 (#1953) (#2095)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m8s
Deploy / deploy (push) Successful in 1m19s
2026-06-29 08:29:34 +00:00
a35d42f1c7 fix(site-finder): invest-score advisory flag + velocity data-honesty + h1 a11y (#1926) (#2094)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 5m22s
Deploy / build-frontend (push) Successful in 6m11s
Deploy / build-worker (push) Successful in 7m53s
Deploy / deploy (push) Successful in 2m23s
2026-06-29 06:42:50 +00:00
caffd9b28d Merge pull request 'fix(tradein/v2): убрать средний скаттер «ЦЕНА × СРОК ПРОДАЖИ» из результата' (#2093) from fix/tradein-v2-drop-result-scatter into main
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 / deploy (push) Successful in 50s
Deploy Trade-In / build-frontend (push) Successful in 1m58s
2026-06-28 23:21:11 +00:00
267e5dfc5b fix(tradein/v2): убрать средний скаттер «ЦЕНА × СРОК ПРОДАЖИ» из результата
All checks were successful
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
По фидбэку: куцый inline-скаттер в 02 РЕЗУЛЬТАТ мало что говорил и дублировал
полный price×time график в Аналитике. Удалён; ряд диапазонов теперь grid 1fr 1fr —
два range-бара (объявления/сделки) на всю ширину, читаются прямее. Полный
точечный график остаётся в оверлее Аналитики (таб 06).

- ResultPanel.tsx: удалён scatter-блок (title + SVG + легенда + «Подробнее»),
  grid ряда «1fr 168px 1fr» -> «1fr 1fr»; убраны ставшие мёртвыми токены
  scatterDeal / line3.
- data.scatterMini в контракте оставлен (маппер всё ещё считает, просто не
  потребляется) — чистку контракта можно сделать отдельно.

tsc + next build green.
2026-06-29 02:20:45 +03:00
11a7018e78 Merge pull request 'feat(tradein/sale-share): тумблер «Сейчас / За 45 дней» + фильтр «мин. объявлений»' (#2092) from feat/tradein-sale-share-45d-toggle into main
All checks were successful
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
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 1m52s
Deploy Trade-In / deploy (push) Successful in 53s
2026-06-28 23:13:14 +00:00
0ea850c957 feat(tradein/sale-share): тумблер «Сейчас / За 45 дней» + фильтр «мин. объявлений»
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 / changes (pull_request) Successful in 7s
#3: переключатель окна метрики (now/45д) — меняет показываемый %
(sale_share_pct↔sale_share_pct_45d), счётчик (active_secondary↔listings_45d) и
сортировку (share_desc↔share_45d_desc, active_desc↔count_45d_desc). 45д-данные
уже были в API — фронт их не показывал (потому тумблер и не находился).
#2: поле «Мин. объявлений в доме» — client-side фильтр по оконному счётчику.

Frontend-only (бэк/API/deps не тронуты). typecheck clean.
NB: маркеры карты пока по now-метрике (мелкий follow-up).
2026-06-29 02:12:04 +03:00
4863e67f27 Merge pull request 'fix(tradein/v2): источники N1+Restate вон, Домклик в текст; disabled-палитра до AA (#2081 follow-up)' (#2091) from fix/tradein-v2-audit-round2b into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m48s
Deploy Trade-In / deploy (push) Successful in 50s
2026-06-28 23:03:48 +00:00
c362b8592a fix(tradein/v2): источники N1+Restate вон, Домклик в текст; disabled-палитра до AA (#2081)
All checks were successful
CI / changes (pull_request) Successful in 6s
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
- M9 correction: #2086 ошибочно убрал Домклик из «как считаем» (по stale-ссылке на
  #1968 «drop dead domklik»), но live-проверка под админом показала Домклик живым
  (реально даёт лоты). Возвращён в текст. N1.RU + RESTATE убраны из SOURCE_SLOTS
  (мёртвые — всегда «нет данных», не учитываем) → плитки и счётчик «N / 7» → «N / 5».
  TOTAL_SOURCES = SOURCE_SLOTS.length (единый источник истины: плитки/счётчик/текст
  не расходятся). Итог: Циан, Я.Недвижимость, Авито, Домклик (+ Росреестр сделки).
- C1 residual: disabledValue («—» no-data) #c2cdd9 1.4:1 -> #656b71 4.61:1;
  disabledLabel («нет данных») #aab7c5 1.7:1 -> #636b73 4.62:1. Аудит #2081 флагал
  «нет данных», #2086 дотемнил только muted-шкалу — disabled-палитру пропустил.

tsc + next build green. Найдено live-проверкой /trade-in/v2 под админом (реальная оценка).
2026-06-29 02:03:16 +03:00
69845ddcee fix(tradein/avito-detail): живой прогрев сессии + батч-на-сессию — снимает 403 на curl-backfill (#1551)
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 1m27s
Deploy Trade-In / build-backend (push) Successful in 51s
Deploy Trade-In / deploy (push) Successful in 1m37s
2026-06-28 22:34:38 +00:00
cbe56cab0e Merge pull request 'fix(tradein): cadastre ЖКХ-fallback + COALESCE merge-key + median-floors guard' (#2090) from feat/tradein-zhkh-cadastre-plus 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 10s
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-06-28 22:34:15 +00:00
078dadc440 fix(tradein): cadastre ЖКХ-fallback + COALESCE merge-key + median-floors guard
All checks were successful
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
Серия sale-share data-quality (по находкам юзера):
- house_dedup_merge: cluster_key → tradein_canon_addr(COALESCE(short_address,
  full_address, address)) — ловит дубли с район-noise в address (Вайнера 66).
- zhkh_flats_loader: cadastre-fallback (searchByAddress по cadastreNumber,
  guid-независимо) — резолвит дома где gar_house_guid ≠ ЖКХ houseGuid
  (Переходный 6: guid→0, cadastre→165). CLI --cadastre.
- мигр.153: плаузибилити-гард по медиане этажности листингов — denom >=
  GREATEST(total_floors, median(listings.total_floors), 8); отсекает gar-недосчёт
  башен без ЖКХ (gar=9 vs листинги 19-26 эт → NULL).

ruff clean, pytest 36 green.
2026-06-29 01:32:48 +03:00
169201bb8c Merge pull request 'feat(tradein/sale-share): standalone «Поиск домов» — de-Mera + gendsgn.ru/sale-share' (#2088) from feat/tradein-sale-share-standalone into main
All checks were successful
Deploy / changes (push) Successful in 7s
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 / build-frontend (push) Successful in 33s
Deploy / build-backend (push) Successful in 36s
Deploy / build-worker (push) Successful in 35s
Deploy / deploy (push) Successful in 1m21s
Deploy Trade-In / build-frontend (push) Successful in 2m7s
Deploy Trade-In / deploy (push) Successful in 50s
2026-06-28 22:23:52 +00:00
bb54de6000 feat(tradein/sale-share): standalone «Поиск домов» — de-Mera + gendsgn.ru/sale-share
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Поиск домов (доля квартир в продаже) вынесен в отдельный продукт, отвязан от
бренда «Мера» (Мера = оценка вторички). Только UI + 1 строка Caddy, бэк не тронут.

- app/sale-share/layout.tsx: своя metadata (title «Поиск домов — gendsgn»).
- components/trade-in/SaleShareHeader.tsx: своя шапка — продукт «Поиск домов»,
  без MeraMark и Mera-табов; UserMenu (auth) сохранён.
- app/sale-share/page.tsx: Topbar → SaleShareHeader; убраны «Мера» из
  breadcrumb/footer. Функционал/API (/trade-in/api/...) не тронут.
- Caddyfile: gendsgn.ru/sale-share → redir /trade-in/sale-share (короткий адрес;
  basePath=/trade-in → true vanity-URL требует отдельного app).
2026-06-29 01:09:26 +03:00
89fb666d88 Merge pull request 'fix(tradein/v2): UI/UX аудит round 2 — AA-контраст, честность чисел, a11y (#2081)' (#2086) from fix/tradein-v2-audit-round2 into main
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 1m55s
Deploy Trade-In / deploy (push) Successful in 52s
2026-06-28 22:03:38 +00:00
f5a7f9b437 fix(tradein/v2): UI/UX audit round 2 — AA contrast, honesty, a11y (#2081)
All checks were successful
CI / changes (pull_request) Successful in 8s
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
C1 (tokens): darken muted scale to WCAG AA (>=4.5:1 vs worst-case canvas
  #e6eef7); reclassify primary labels muted->body2 across components.
H1 (mappers): planning chip derived from rooms actually present in the
  analog set («Все планировки» when heterogeneous) — no number shifted.
H2 (ResultPanel): make «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» the headline (accent
  border/tint/glow + larger value); demote ДКП·Росреестр to a muted
  СПРАВОЧНО reference tile. All three values + mapResultPanel wiring intact.
H3 (mappers): parseAddress keeps the house number for prefix-less
  addresses («Белинского, 86»); prefixed path unchanged.
M4 (page/HeroBar/Footer): gate dead controls (КАК РАССЧИТАНО / PDF /
  ДЕЙСТВИТЕЛЕН ДО / footer validity) on hasEstimate (mounted && estimate && !insufficient).
M5 (LocationDrawer): real dialog — role=dialog/aria-modal/aria-labelledby
  + focus-trap + <button> close + Esc preventDefault/stopPropagation.
M6 (ParamsPanel): address autocomplete -> combobox ARIA + Arrow/Home/End/
  Enter/Escape keyboard nav (mirrors Dd listbox).
M7 (Sources/Cache/Analytics/History): ARIA table semantics over the grids;
  Analytics scroll region keyboard-focusable (clears the serious
  scrollable-region-focusable axe violation); chart SVGs get role=img+title+aria-label.
M8 (CacheView): normalize the ВРЕМЯ column (normTime) to consistent minute precision.
M9 (LocationDrawer): honest «как считаем» source list (Циан, Я.Недвижимость,
  Авито, N1.ru + Росреестр) — drop dead Домклик/Restate per #1968.
M10 (ParamsPanel/mapMarkers): subject pin shows the subject's own area; drop
  analog pins overlapping the subject center (was misread as subject data).
M11 (ObjectSummary/SourcesView): label «медиана объявлений»; hide all-«—» columns/rows.
M13 (page): clamp artboard scale >=0.88 so micro-type doesn't drop below ~8px.
L1 (Analytics): suppress fake «X–Y дн» range when n<2.
L2 (Analytics): drop zero-data series from price-history subtitle.
L7 (TopNav): replace hardcoded #6f8195 icon strokes with tokens.muted.

tsc + next build green. No FE test infra in tradein-mvp/frontend — parseAddress
verified via standalone harness (6/6). Backend data-contract items M1/M2/M3/H4
(source counter, ratio vs medians, bargain bases, cross-source dedup) filed
separately — not FE bugs. L6 (blueprint-grid axe-blindness) deferred: base
gradient + glass surfaces keep axe contrast «incomplete» regardless, so the
authoritative check stays the manual computed-color sweep.
2026-06-29 01:01:30 +03:00
31e7816967 Merge pull request 'fix(tradein/dedup): canon cluster-key + geo/digit guard в house-merge (P4c)' (#2085) from fix/tradein-house-dedup-canon into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / deploy (push) Successful in 51s
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 1m27s
Deploy Trade-In / build-backend (push) Successful in 55s
2026-06-28 21:53:15 +00:00
7cf6267b48 fix(tradein/dedup): canon cluster-key + geo/digit guard в house-merge
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
merge_duplicate_houses кластеризовал дома по exact lower(trim(address)) →
варианты написания одного здания (ул. Вайнера,66 vs улица Вайнера, 66) не
схлопывались, расщепляя долю в sale-share. Ключ кластера → tradein_canon_addr
(ул→улица, стрип город/район, КОРПУС сохранён). Гарды против over-merge:
- гео ≤250м (canon стрипует город; прод-дубли Мраморская 34к4 в 222м; города
  региона-66 в км+ → 250м безопасно от cross-town);
- canon обязан содержать цифру (номер дома) — исключает вырожденные
  «екатеринбург»/«сооружение».
Re-pointing/audit/dry-run/идемпотентность не тронуты. 26 passed.
2026-06-29 00:50:57 +03:00
a96af4c19c Merge pull request 'fix(tradein/sale-share): гард по этажности — отсечь near-neighbor mis-match' (#2084) from fix/tradein-floors-guard into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 51s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 53s
2026-06-28 21:32:23 +00:00
ebc3813648 fix(tradein/sale-share): гард по этажности — отсечь near-neighbor mis-match
All checks were successful
CI / changes (pull_request) Successful in 8s
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
Гео-фильтр ≤300м (мигр.150) широк для плотной застройки: к 5-этажному дому
(Мраморская 34к4) примагничены чужие листинги 9/16/19-этажных башен (Благодатская
15, Мраморская 6) на 229-282м — все <300м, фильтр не отсекал.

- мигр.152: +houses.zhkh_floors (parse zhkh_floors_raw "10-30"→max); гард во view
  во всех 5 FILTER: считать листинг только если abs(l.total_floors -
  COALESCE(zhkh_floors, total_floors)) <= 3 (или этажность неизвестна). Гео-фильтр
  300м сохранён.
- buildings_query.build_listings_query: тот же гард в детальной выдаче.

Прод-замер: отсекает 2754 листинга (12%) height-mismatched. Покрытие этажности
75% total_floors / 73% ЖКХ. ruff clean, pytest 26 green.
2026-06-29 00:31:09 +03:00
1b515f4e94 Merge pull request 'fix(tradein/sale-share): дедуп карточки листингов + чистка голых алиасов (#4 + P4a)' (#2083) from fix/tradein-clean-bare-aliases into main
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 / test (push) Successful in 1m26s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-backend (push) Successful in 55s
Deploy Trade-In / deploy (push) Successful in 50s
2026-06-28 21:23:55 +00:00
b105e0a500 fix(tradein/sale-share): дедуп карточки листингов + чистка голых алиасов
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
#4 Карточка дома показывала cross-площадочные дубли (Искровцев 33: 2 квартиры
= 4 строки avito/domklik/cian). build_listings_query теперь дедуплит по сигнатуре
(rooms, round(area_m2), floor) — как v_building_sale_share мигр.148 — одна строка
на квартиру, представитель = самый дешёвый, +колонка sources[] (площадки).
Гео-фильтр ≤300м (мигр.150) сохранён. +BuildingListing.sources в схеме.

P4a Мигр.151: DELETE 6338/21720 голых алиасов house_address_aliases (без номера
дома) — сток, в который сваливались безномерные листинги (Tier-2b). Матчер уже
захардненен (P1 #2082), голые не регенерируют. Алиасы — регенерируемый кэш,
house_id_fk/house_sources не тронуты.

ruff clean, pytest test_buildings_api 26 green.
2026-06-29 00:22:20 +03:00
ec4c7e9e6a Merge pull request 'fix(tradein/matching): не сваливать листинги в чужой дом (P1-P3 хардненинг матчера)' (#2082) from fix/tradein-house-match-hardening 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 1m33s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 1m9s
2026-06-28 21:07:39 +00:00
384c4e7ac3 fix(tradein/matching): не сваливать листинги в чужой дом (P1-P3)
All checks were successful
CI / changes (pull_request) Successful in 8s
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
Три гарда в match_or_create_house (+ P2 в match_house_readonly):
- P1: не матчить/алиасить голую улицу (normalized_address без номера дома)
- P2: отклонять Tier-3 гео-кандидата, если номера домов различаются
- P3: не цементировать слабый гео-матч (0.7) как alias-ключ здания

Хелперы has_house_number/house_number_token (по хвостовому токену —
«улица 8 марта» это улица, не дом). Корень mis-bucketing: house 131237
стянул 8 разных Мамина-номеров + голую улицу. Юнит + поведенческие тесты,
603 matching/normalize/house зелёных. Аффектит только НОВЫЕ матчи;
чистка cemented-данных — отдельной миграцией (P4).
2026-06-29 00:06:13 +03:00
bd3562b6bd Merge pull request 'fix(tradein/sale-share): гео-фильтр числителя ≤300м — отсечь mis-bucketed листинги' (#2080) from feat/tradein-sale-share-geofilter into main
Some checks failed
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 55s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Failing after 10m2s
2026-06-28 20:25:48 +00:00
f617c323bd fix(tradein/sale-share): гео-фильтр числителя ≤300м — отсечь mis-bucketed листинги
All checks were successful
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
Листинги примагничиваются к НЕ ТОМУ дому (кривой house_id_fk): прод-замер 373
дома (5.5%) / 1685 листингов (7.2%) дальше 300м от своего дома. Пример — house
131237 «Мамина-Сибиряка 126» (ЖКХ=51 кв, знаменатель ВЕРНЫЙ): из 20 активных
листингов реально его ~2, остальные — Свердлова 32 / Азина 31 (до 2.2км) →
ложная доля 39% вместо ~4%.

- мигр.150: v_building_sale_share — числители (active/45д) + медианы считают
  только листинги ≤300м от geom дома (ST_DistanceSphere; листинг/дом без geom —
  keep). Знаменатель не тронут.
- buildings_query.build_listings_query: тот же гео-фильтр в детальной выдаче
  (карточка дома больше не показывает чужие листинги).

Симуляция на проде: меняется 351 дом, обнуляется 164 (чистые «мусорные вёдра»),
числитель −8.3%. Мамина-126: 20→2. Это митигация на уровне view; корневой фикс
house_id_fk-матчера — отдельно. ruff clean, pytest 24 green.
2026-06-28 23:24:05 +03:00
0f82b58265 Merge pull request 'fix(tradein/v2): a11y contrast — source badges ≥AA в оверлеях (#2062)' (#2079) from fix/tradein-v2-a11y-contrast into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m7s
Deploy Trade-In / deploy (push) Successful in 56s
2026-06-28 19:21:36 +00:00
848a44c629 fix(tradein/v2): a11y contrast — source badges ≥AA в оверлеях (#2062)
All checks were successful
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
Прод-QA P3 нашёл 10 axe color-contrast (serious) в оверлее 05: 9px source-бейджи tokens.muted (#6f8195) на badgeTint ≈3,3:1 < AA. SourcesView badge muted→body2 (≈6,3:1); HistoryView ДКП source badge accent→accentDeep (≈5:1, синий сохранён). Закрывает хвост item-9 (микро-лейблы <4.5:1) → axe 0 и в оверлеях.
2026-06-28 22:21:10 +03:00
565d5c906b Merge pull request 'fix(tradein/v2): a11y P3b — клавиатурная форма 01 (#2062)' (#2078) from fix/tradein-v2-a11y-form into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m34s
Deploy Trade-In / build-frontend (push) Successful in 2m19s
Deploy Trade-In / build-backend (push) Successful in 44s
Deploy Trade-In / deploy (push) Successful in 1m31s
2026-06-28 18:58:34 +00:00
32a58c91c5 fix(tradein/v2): a11y P3b — клавиатурная форма 01 (#2062)
All checks were successful
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
Закрывает headline аудита: «с клавиатуры/скринридера нельзя завершить оценку».
Визуально без изменений — UA-chrome кнопок сброшен явно (Tailwind preflight в
tradein-mvp не подключён, кнопки не наследуют font-family).

- Dd-дропдаун (КОМНАТ/ТИП/РЕМОНТ) + РАДИУС: trigger div→<button role=combobox
  aria-haspopup=listbox aria-expanded aria-controls aria-activedescendant
  aria-label>; панель role=listbox, опции role=option/aria-selected + active-
  highlight. Клавиши Arrow↑↓/Home/End/Enter/Space/Esc/Tab (общий comboKeyDown,
  паттерн aria-activedescendant — фокус остаётся на кнопке).
- БАЛКОН → role=radiogroup + два <button role=radio aria-checked> (roving
  tabindex + стрелки + Space/Enter); tri-state (null = ни один не выбран) сохранён.
- Инпуты: <label htmlFor>/id; aria-invalid + aria-describedby на ошибках
  адреса/площади (id ошибки).
- Кнопки зума карты: div→<button aria-label>, «−» реально disabled на полу зума.

Мышиный поток идентичен прежнему; клавиатура аддитивна. code-reviewer  (UA-chrome
покрыт, keyboard/mouse корректны).
2026-06-28 21:58:03 +03:00
ec03a43283 Merge pull request 'feat(tradein/zhkh): ЖКХ-приоритет знаменателя «квартир в доме» + loader' (#2077) from feat/tradein-zhkh-denominator into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
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 1m27s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-06-28 18:56:34 +00:00
77c01b6d3b feat(tradein/zhkh): ЖКХ-приоритет знаменателя «квартир в доме» + loader
All checks were successful
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
ГАР дико недосчитывает квартиры в МКД (прод-замер: Крауля 89А ГАР=11 vs
ЖКХ=429; 27 из 347 домов ГАР ≥2× недосчёт). ГИС ЖКХ точнее — резолвит ~90%
вторички с gar_house_guid.

- мигр.149: COALESCE знаменателя → ЖКХ ПЕРВЫЙ
  (zhkh_flat_count, gar_flat_count, total_units, flat_count) во всех 7 местах
  view v_building_sale_share; +houses.zhkh_year/zhkh_floors_raw; +transparency-
  колонки zhkh_flat_count/flat_count_source.
- app/services/zhkh_flats_loader.py + app/tasks/zhkh_flats_load.py: фетч ГИС ЖКХ
  searchByAddress (батч ≤100 по gar_house_guid == ЖКХ houseGuid, pageIndex=1,
  cookieless), идемпотентный UPDATE houses.zhkh_* (CAST(:x AS type) +
  IS DISTINCT FROM gate, SAVEPOINT-чанки, dry-run).

Безопасно к мержу: до ручного прогона loader'а zhkh_flat_count=NULL → COALESCE
падает на ГАР → поведение идентично до-149. ruff clean, pytest 8 green.
2026-06-28 21:55:05 +03:00
f60f2b24e6 Merge pull request 'fix(tradein/v2): a11y P3a — нативные контролы + модальный диалог + aria-live (#2062)' (#2076) from fix/tradein-v2-a11y-native into main
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 1m51s
Deploy Trade-In / deploy (push) Successful in 53s
2026-06-28 18:30:09 +00:00
7066117cb8 fix(tradein/v2): a11y P3a — нативные контролы + модальный диалог + aria-live (#2062)
All checks were successful
CI / changes (pull_request) Successful in 6s
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
CRITICAL-доступность (часть 1): клавиатура/скринридер. Без визуальных изменений
(chrome-reset кнопок вынесен в CSS-классы, :hover и font-size сохранены).

- Клик-only <div> → нативные <button>: nav-табы (+aria-current), user-меню
  (aria-haspopup/expanded + Esc-close), «Выйти», «Подробнее →» (ResultPanel,
  +aria-label по разделу), строки 03 СВОДКИ (ObjectSummary, +aria-label).
- SectionOverlay → настоящая модалка: role=dialog, aria-modal, aria-labelledby,
  Esc-close, фокус на диалог при открытии, focus-trap (Tab/Shift+Tab),
  возврат фокуса на триггер при закрытии; «← К ОЦЕНКЕ» → <button>.
- page.tsx: aria-live="polite" role=status — «Идёт расчёт… / Оценка готова /
  Недостаточно данных / Ошибка»; фон (content-wrap) inert когда открыт
  оверлей или drawer (overlay/drawer остаются интерактивны).

Остаётся P3b: ParamsPanel-форма (Dd→listbox+клавиши, БАЛКОН→radiogroup,
input <label>/aria) — заход «с клавиатуры можно завершить оценку».
2026-06-28 21:29:19 +03:00
0641183ecd fix(tradein): дедуп объявлений по квартире + over_100 NULL-фикс (500 на списке) (148) (#2075)
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 1m29s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m15s
2026-06-28 18:04:32 +00:00
efa2dc75ea Merge pull request 'feat(tradein/scheduler): cooperative drain coverage for sweeps/full-loads (phase 3a)' (#2074) from feat/tradein-sweep-drain-coverage 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 10s
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-06-28 18:04:02 +00:00
b45a819cb6 fix(tradein): канон срезает гео-префиксы (Россия/Екатеринбург/мкр/пр-т) + REINDEX — покрытие 38%→~70% (147) (#2073)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 31s
Deploy Trade-In / test (push) Successful in 1m33s
Deploy Trade-In / build-backend (push) Successful in 27s
Deploy Trade-In / deploy (push) Successful in 53s
2026-06-28 17:54:26 +00:00
3320805723 feat(tradein/scraper): cooperative SIGTERM-drain coverage for sweep/full-load loops (#1182 Phase 3a)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Extend the cooperative shutdown-drain (Phase 2) to the long sweep and
full-load loops in scrape_pipeline.py so they stop CLEANLY on SIGTERM
instead of being hard-cancelled at the 80s _drain_inflight timeout.

At every between-unit checkpoint that already polls scrape_runs.is_cancelled
(user-cancel) we add an ALONGSIDE shutdown_requested() branch:
  - anchor-loop sweeps (avito/yandex/cian city) + avito newbuilding +
    domclick pre-SERP: update_heartbeat + mark_done(partial) + return;
  - full-load on_bucket callbacks (cian/yandex/avito): raise a distinct
    RuntimeError("shutdown") sentinel, handled next to the existing
    "cancelled" sentinel -> mark_done(partial), skipping the detail phase;
  - cian full-load detail loop: break -> existing post-loop mark_done.

Drain finalizes as mark_done(partial), NOT mark_cancelled (that stays
user-cancel only). is_cancelled -> user-cancel semantics are unchanged, and
behaviour is identical when shutdown_requested() is False. No resume_cursor
or paused status yet (Phase 3b, deferred): this is drain-coverage only.

Tests: tests/test_sweep_drain.py covers both structural variants -
run_avito_city_sweep / run_cian_city_sweep (anchor-loop) and
run_avito_full_load (on_bucket sentinel): shutdown flips True after the
first unit, asserting the sweep stops at the checkpoint, calls mark_done
with partial counters, never calls mark_cancelled, and does not process
later anchors/buckets/phases.
2026-06-28 20:52:30 +03:00
eab3d928d6 Merge pull request 'fix(tradein/v2): UI/UX-аудит P2 — контент/честность/иерархия (#2062)' (#2072) from fix/tradein-v2-uiux-p2-content into main
Some checks failed
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 / test (push) Successful in 1m28s
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-06-28 17:52:28 +00:00
1722309372 fix(tradein/v2): UI/UX-аудит P2 — контент/честность/иерархия (#2062)
All checks were successful
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
- Опц. поля больше не шлют непроверенные дефолты в ценовую модель:
  ТИП/РЕМОНТ → «Не указано»→undefined, БАЛКОН tri-state (null пока не
  трогали) → undefined.
- Валидация у поля: красная рамка + сообщение под адресом/площадью,
  показываются ВСЕ блокеры (не только первый); низ — только серверная ошибка.
- Копирайт «вы»: «если знаете»; кэш-строка → «ДАННЫЕ АКТУАЛЬНЫ ·
  ДЕЙСТВИТЕЛЕН 24 Ч»; drawer без жаргона (убраны (asking)/POI).
- Даты сделок Росреестра → «янв 2026» (месяц, не фейк-день 01.01);
  бренды источников без method-суффиксов (РОСРЕЕСТР/АВИТО/ДОМКЛИК);
  «эталон»→«опорная цена».
- График истории цен: зум оси Y к реальному диапазону данных + динамические
  подписи оси; легенда серии показывается только при наличии линии.
- 3 цены весомее (font-weight 400); медиана-маркеры range-баров → тонкий тик
  (не выглядит как draggable-ручка); фото здания — синий duotone +
  десатурация (не крадёт первый взгляд у цен).
- Таблицы аналогов/сделок: площадь/комнаты/этаж вынесены из адресной ячейки
  в колонку ПАРАМЕТРЫ; ↗→› в СВОДКЕ.

Сознательно НЕ тронуто: перераспределение accent-синего (item 9) — axe уже 0,
а широкое снятие акцента конфликтует с «не ломать HUD-палитру»; монотонность
тиров срока продажи не форсируется (это были бы выдуманные данные).
2026-06-28 20:51:49 +03:00
2a38561175 feat(tradein): второй % «за 45 дней» + zhkh-знаменатель slot (146) (#2071)
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-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-06-28 17:51:38 +00:00
c8e7cb1c79 perf(objective_lots): inline DISTINCT ON for 3 view-fullscan consumers + cache landing stats (#1953) (#2067)
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m8s
Deploy / build-worker (push) Successful in 3m16s
Deploy / deploy (push) Successful in 5m41s
2026-06-28 17:25:41 +00:00
03ca46fe1d Merge pull request 'feat(tradein/scheduler): cooperative SIGTERM-drain — in-flight scrape unit commits before exit (phase 2)' (#2070) from feat/tradein-scheduler-graceful-drain into main
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 1m27s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 51s
2026-06-28 17:20:11 +00:00
566b2f9617 fix(tradein/scraper): drain detached run-children on SIGTERM, not just coordinator (#1182 Phase 2)
All checks were successful
CI / changes (pull_request) Successful in 6s
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
Pre-push review: _await_scheduler ждал только scheduler_loop COORDINATOR, но вся
scrape-работа крутится в detached asyncio.create_task детях (каждый trigger_* делал
`task = create_task(_run())` без join). На SIGTERM coordinator выходил из while True
и завершался → asyncio.run() teardown хард-кансельил ещё бегущих детей mid-await =
ровно #1182 failure mode. Кооперативный checkpoint спасал ребёнка лишь когда его
residual-время случайно перекрывало drain — вероятностно, не гарантированно.

Fix — coordinator теперь дренажит детей перед выходом:
- _spawn_tracked(coro): централизованный detached-spawn, кладёт задачу в module-level
  registry _inflight_tasks (strong-ref = RUF006 keep-alive) + done-callback ретривит
  exception и убирает из set'а. Заменил 26 одинаковых
  `task = create_task(_run()); task.add_done_callback(...)` сайтов.
- _drain_inflight(): один asyncio.wait по живым детям с бюджетом
  _CHILD_DRAIN_TIMEOUT_S=80s (< scheduler_main 100s < docker grace 120s). Кооперативные
  дети (avito_detail_backfill, rosreestr-executor) дочекивают карточку/батч + mark_done
  и резолвятся; некооперативные упираются в timeout и падают на внешний hard-cancel.
- scheduler_loop по выходу из tick-loop (только по SIGTERM) зовёт await _drain_inflight().

NB: raw asyncio.all_tasks()-minus-self здесь НЕЛЬЗЯ — в нашей топологии он захватывает
_run parent-task (блокирован на wait_for(coordinator)) и shutdown_waiter → циклическое
ожидание coordinator↔_run, всегда упирающееся в timeout. Точный registry это исключает.

Tests: tests/test_scheduler.py — detached cooperative child drained-not-cancelled,
non-cooperative child timeout→left for hard-cancel, no-op без детей, scheduler_loop→drain
wiring. Обновил 3 source-inspection теста под новый _spawn_tracked паттерн.
2026-06-28 20:13:53 +03:00
e76030588c Merge pull request 'fix(tradein/v2): a11y follow-up — «скоро» pill contrast + drawer aria-hidden (#2062)' (#2069) from fix/tradein-v2-uiux-a11y into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 1m10s
2026-06-28 17:12:13 +00:00
041589f2fc fix(tradein/v2): a11y — «скоро» pill AA contrast + drawer aria-hidden (#2062)
All checks were successful
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
axe-фоллоуап к #2066: «скоро» (КОЭФ.ЛОКАЦИИ) muted2→body2 (2,3→6,3:1 на badgeTint); LocationDrawer aria-hidden когда закрыт (off-canvas вне лендмарка → снимает axe «region»).
2026-06-28 20:11:43 +03:00
e148e61d49 Merge pull request 'feat(sitefinder): rosreestr cadastral-value loader + 0.7x denoise of quarter price index' (#2068) from feat/rosreestr-cadastral-value-denoise into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 36s
Deploy / build-worker (push) Successful in 36s
Deploy / deploy (push) Successful in 1m44s
2026-06-28 17:02:12 +00:00
6f76b565e5 fix(tradein/scraper): surface scheduler_loop crash in _await_scheduler done-branch
В ветке `task.done()` без shutdown глушилось реальное исключение задачи. Зовём
task.result() → пробрасываем настоящий сбой scheduler_loop наружу (loud crash,
как делал прежний `await task`), вместо тихого "exited cleanly".
2026-06-28 19:53:08 +03:00
848e6d3bdd feat(tradein/scraper): cooperative SIGTERM-drain for graceful deploy (#1182 Phase 2)
Деплой (docker recreate tradein-scraper) шлёт SIGTERM с stop_grace_period=120s
(Phase 1). Раньше scheduler_main отвечал hard task.cancel() → бегущий scrape-unit
получал CancelledError посреди браузер-карточки → карточка гибла без COMMIT.

Теперь SIGTERM/SIGINT лишь выставляют кооперативный флаг; tick-loop и длинные
задачи опрашивают его на СВОИХ существующих between-unit checkpoint'ах,
докоммичивают текущий unit и выходят сами.

- app/core/shutdown.py: новый standalone-модуль (asyncio.Event + request_shutdown
  / shutdown_requested / wait_for_shutdown), без app-зависимостей → нет циклов.
- scheduler_main._run: SIGTERM → request_shutdown() вместо task.cancel(); ждём
  добровольного drain'а, safety-net wait_for(100s < docker grace) с fallback на
  hard-cancel для некооперирующей задачи.
- scheduler_loop: проверка флага в начале тика и после каждого dispatch — не
  reap/claim новые run'ы во время shutdown, выходим из loop'а.
- avito_detail_backfill: break на границе карточки рядом с budget-guard; snapshot
  pending-query идемпотентен → следующий run сам резюмит остаток.
- import_rosreestr_dkp: расширен is_cancelled checkpoint — drain делает mark_done
  (partial), не mark_cancelled; user-cancel семантика без изменений.

Без SIGTERM поведение идентично прежнему. reap_zombies не трогает drained-run'ы
(они mark_done, не 'running').

Tests: tests/core/test_shutdown.py, scheduler_main drain+timeout-fallback,
avito_detail_backfill partial-drain. 26 + 29 scheduler passed.
2026-06-28 19:52:09 +03:00
4d063d6827 feat(sitefinder): rosreestr cadastral-value loader + 0.7x denoise of quarter price index
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 14m30s
Quarterly Rosreestr dataset_КАДАСТРСТОИМОСТЬ (cadastral value) gives a
per-cad-quarter cadastral ₽/m². Registered deals priced below 0.7x that
quarter's cadastral median are dropped from mv_quarter_price_per_m2 as
noise (data-entry junk / non-arm's-length deals that drag medians down).

- 178_schema_cadastral_value.sql: rosreestr_cadastral_value (+ staging),
  region-66-scoped, idempotent.
- 178b_load_cadastral_value.py: psycopg v3 staging COPY -> typed region-66
  INSERT, idempotent skip-if-loaded. Run MANUALLY by operator after deploy.
- 179_mv_quarter_price_cadastral_floor.sql: recreates BOTH MVs (CASCADE
  drops the dependent index MV). Adds cad_floor CTE + LEFT JOIN guard to
  mv_quarter_price_per_m2; mv_quarter_price_index recreated verbatim.

Safe to auto-apply on prod before any cadastral data exists: empty
rosreestr_cadastral_value -> cad_floor has 0 rows -> floor_ppm2 IS NULL for
every quarter -> predicate keeps ALL deals -> MV byte-identical to the
pre-floor (95) definition. Proven by a rolled-back dry-run (66:% EXCEPT
both directions = 0). With the experiment cad source loaded the floor
moves 480 of 1972 quarters (median 66788 -> 67350).

NN bumped from spec's 172/173 to 178/178b/179 (172-177 already on main).
2026-06-28 19:45:35 +03:00
77b4f7e64c Merge pull request 'chore(tradein/deploy): scraper deploy-decouple phase 0+1 — no recreate on infra-only deploys + 120s graceful stop' (#2065) from chore/tradein-scraper-deploy-decouple-p01 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Successful in 36s
Deploy Trade-In / test (push) Successful in 1m39s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / build-frontend (push) Successful in 4m50s
Deploy Trade-In / deploy (push) Successful in 3m10s
Reviewed-on: #2065
2026-06-28 16:35:38 +00:00
b8fd7771b8 Merge pull request 'fix(tradein/v2): UI/UX-аудит P1 quick-wins (#2062)' (#2066) from fix/tradein-v2-uiux-p1 into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
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) Has been cancelled
2026-06-28 16:34:41 +00:00
30e158ad2a fix(tradein/v2): UI/UX-аудит P1 quick-wins (#2062)
All checks were successful
CI / changes (pull_request) Successful in 8s
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
Честная полировка живого /trade-in/v2 без правок архитектуры (FE-only).

- Провенанс из data, без хардкодов: footer «ПОСТРОЕНО ПО N АНАЛОГАМ И M
  СДЕЛКАМ» из meta.builtOn; единый счётчик источников (activeSourceCount =
  залитые плитки) в шапке 02, СВОДКЕ и оверлее; убраны «6/10», «2/7»,
  «Avito·10», «из 2 источников».
- Единый ru-форматтер: запятая везде (CV/площадь/%), NBSP число+единица,
  один en-dash и одна точность для денежных диапазонов.
- CTA «ОЦЕНИТЬ КВАРТИРУ» → solid brand-blue + белый текст; :focus-visible
  HUD-glow на инпутах и кнопке.
- Лендмарки <nav>/<main>/<footer> (display:contents, 0 визуальных изменений);
  scale-fit артборда — на 1366×768 / 1440×900 ужимается, не режется.
- Мини-карта: честный зум (min=1, без пустых краёв при отдалении), убран
  мёртвый crosshair-двойник, расклеены налезающие метки цен.
- CRM-дропдаун явно задизейблен («скоро», без ▼); убран «ШАГ 1/1».
- Карточка 02 → «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ · с учётом торга» + строка-примирение
  с медианой ДКП; КОЭФ.ЛОКАЦИИ → muted-пилюля «скоро».
- Бейдж СВОДКИ «В РАСЧЁТЕ» → «АКТУАЛЬНО»; скрыты целиком-прочерк колонки
  (ИСТОЧНИКОВ в 07, ОБЪЯВЛЕНИЕ ДО СДЕЛКИ в 04) data-driven.
2026-06-28 19:33:12 +03:00
8aaa42f101 chore(tradein/deploy): don't recreate scraper on infra-only deploys + 120s graceful stop (phase 0+1)
All checks were successful
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
The tradein-scraper container runs the same image as backend
(python -m app.scheduler_main, in-app scheduler) and owns the in-flight
scrape sweeps (a browser card takes ~15-27s). Two problems compounded to
kill running jobs on every deploy:

Phase 0 (deploy-tradein.yml): SCRAPER_CHANGED also fired on the `infra`
paths-filter (compose / workflow / deploy/**), so any generic infra edit
recreated the scraper container and SIGKILLed the running job. Dropped the
`|| infra == 'true'` term — only real scraper-code paths (already covered
by the `scraper` paths-filter) or a manual workflow_dispatch recreate it.

Phase 1 (docker-compose.prod.yml): the scraper had no stop_grace_period,
so Docker's default 10s window SIGKILLed an in-flight card (15-27s) before
it could finish. Added stop_grace_period: 120s + explicit stop_signal:
SIGTERM so a running unit can finish + checkpoint. The cooperative-drain
handler that consumes SIGTERM lands in a later phase; this is the
foundation.
2026-06-28 19:30:14 +03:00
57ae2dfe00 fix(tradein): guard ГАР-undercount знаменателя в v_building_sale_share (145) (#2064)
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 1m29s
Deploy Trade-In / build-backend (push) Successful in 27s
Deploy Trade-In / deploy (push) Successful in 49s
2026-06-28 16:05:33 +00:00
579536b4ad feat(tradein): канонический матч ГАР↔houses (0%→41% yield) + tradein_canon_addr + --match-only (#2063)
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 1m31s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 54s
2026-06-28 15:50:07 +00:00
83ae6f8a47 fix(tradein): гарантировать lxml в backend-образе — bust кэша uv sync + assert import (#2061)
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 1m30s
Deploy Trade-In / build-backend (push) Successful in 7m12s
Deploy Trade-In / deploy (push) Successful in 1m19s
2026-06-28 15:16:04 +00:00
bcbbb53495 Merge pull request 'fix(tradein/v2): wire РАДИУС (backend radius_m) + map +/− zoom + building image + deglue word+word' (#2060) from feat/tradein-v2-audit-fixes-2 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
Deploy Trade-In / build-frontend (push) Successful in 4m10s
Deploy Trade-In / build-backend (push) Successful in 6m40s
Deploy Trade-In / deploy (push) Successful in 1m7s
2026-06-28 15:04:08 +00:00
e23dabe4f5 fix(tradein/v2): wire РАДИУС (backend radius_m) + map +/- zoom + building image + deglue word+word
All checks were successful
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
Live-browser audit (round 2): РАДИУС/+- dead, building photo missing.
- РАДИУС: backend BE-2 — optional radius_m (int 100–5000) on TradeInEstimateInput,
  threaded into estimate_quality comp search (base + fallback). None preserves the
  exact two-tier default (1000m primary / 2000m fallback) byte-identically. FE: РАДИУС
  dropdown with **Авто** default (sends no radius_m → legacy behaviour, NOT a silent
  500m narrowing) + 300/500/1000/2000 overrides; included as radius_m on submit; outer
  map ring tracks the value.
- Map +/− buttons: now a real zoom (transform: scale() on the map content layer, step
  .25, clamp .6–2.5); controls/bracket stay unscaled.
- building.png: next/image didn't apply basePath → 400; switched to plain <img> with
  NEXT_PUBLIC_BASE_PATH-prefixed src (→ 200) + onError fallback. HERO АДРЕС card шоу.
- deglueAddr: also splits word+word glue ('БольшаковаГеологическая'→'Большакова
  Геологическая'), still preserves house letters (14А, д.5К).

next build green (/v2 34.3 kB); estimator None-default byte-identical (ge=100 rules out
falsy-0); code-reviewer  after the Авто-default fix (avoided a default-radius regression).
2026-06-28 18:03:07 +03:00
123717994b feat(tradein): ГАР-loader знаменателя — apartments-per-house → gar_house_flats + match к houses (#2059)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m36s
Deploy Trade-In / build-frontend (push) Successful in 4m43s
Deploy Trade-In / build-backend (push) Successful in 4m44s
Deploy Trade-In / deploy (push) Successful in 2m15s
2026-06-28 14:54:43 +00:00
f8112b420d feat(tradein): страница «доля квартир дома в продаже» — % слайдер, адреса, карта (#2058)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
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) Has been cancelled
2026-06-28 14:53:38 +00:00
c97dd57e1c perf(objective_lots): drop 3 dead indexes (status/rooms_area/bank) — write-amp (#1953) (#2056)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 33s
Deploy / build-worker (push) Successful in 34s
Deploy / deploy (push) Successful in 8m34s
2026-06-28 14:39:05 +00:00
fb25180929 Merge pull request 'fix(tradein/v2): audit findings — de-fabricate location drawer, real map markers, validation, hero image, real user' (#2057) from feat/tradein-v2-audit-fixes into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m24s
Deploy Trade-In / deploy (push) Successful in 1m2s
2026-06-28 14:27:05 +00:00
5a2b6d91cd fix(tradein/v2): audit findings — de-fabricate location drawer, real map markers, validation, hero image, real user (#2036)
All checks were successful
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
Browser audit of the live /trade-in/v2 found honesty + UX defects; fixes:
- 🔴 LocationDrawer: removed FABRICATED coef 0.87 / fake 'base×coef=result' / invented
  POI factors / false 'OpenStreetMap' source. Now honest static 'ПОЯСНЕНИЕ К РАСЧЁТУ'
  (real methodology) + 'коэф. локации в разработке' note. + Escape-to-close.
- 🔴 Map (ParamsPanel): removed static fixture price markers; renders REAL analog
  markers via new mapMarkers(estimate) (projects lat/lon around target, cap 6).
- ParamsPanel: empty площадь/address now shows a validation message instead of
  silently blocking submit; numeric placeholders made clearly placeholders ('напр. 54').
- HeroBar: dead 'КАК РАССЧИТАНО' button now opens the info drawer; building.png next/image
  400 fixed (unoptimized + onError fallback — basePath wasn't applied to the optimizer URL).
- TopNav: real user from useMe() (name/initials/org/email) instead of fixture
  'Андрей Петров'; dead Профиль/Настройки/Помощь dimmed/disabled; Выйти → logout().
- mappers: de-glue analog addresses ('69/2Геологическая'→'69/2 Геологическая', preserves
  house letters like 14А); 'Продажи в доме'→'Продажи рядом' (radius data); торг sign fixed
  (backend (start−last)/start positive=reduction → renders '−3.3%').
- HistoryView: '0 ЛОТА' → correct plural via pluralRu.

next build green; code-reviewer  APPROVE.
2026-06-28 17:26:12 +03:00
f3e10a04ad feat(tradein): API домов «доли квартир в продаже» — /buildings/sale-share (+ listings, summary) (#2055)
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 1m39s
Deploy Trade-In / build-backend (push) Successful in 8m5s
Deploy Trade-In / deploy (push) Successful in 52s
2026-06-28 14:14:18 +00:00
94d01ed066 perf(objective_lots): inline-pushdown competitors._SOLD_COUNT_SQL + covering physflat index (#1953) (#2054)
Some checks failed
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 10m23s
Deploy / build-worker (push) Successful in 11m34s
Deploy / deploy (push) Failing after 8m8s
2026-06-28 14:07:49 +00:00
9e2d9ddbea feat(tradein): схема «доли квартир дома в продаже» — gar_house_flats + houses.gar_* + v_building_sale_share (143) (#2053)
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 1m34s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 52s
2026-06-28 13:48:41 +00:00
a100f03aa7 Merge pull request 'feat(tradein/v2): CacheView history + PDF + quota + address autocomplete (FE-5)' (#2052) from feat/tradein-v2-fe5-cache into main
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 2m56s
Deploy Trade-In / deploy (push) Successful in 54s
2026-06-28 13:15:17 +00:00
81ae21ada9 feat(tradein/v2): CacheView history + PDF link + quota + address autocomplete (#2042)
All checks were successful
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
FE-5, last FE wiring on the mappers pattern. Closes #2042.
- New hooks (lib/trade-in-api.ts): useEstimateHistory (GET /history), useGeocodeSuggest
  (GET /geocode/suggest?q=&limit=, debounced, enabled>=3 chars); useQuota reused.
- types/trade-in.ts: EstimateHistoryItem (area_m2 string, median_price), GeocodeSuggestion.
- mappers.ts: mapCache(history) -> {rows: CacheRow[], kpis: CacheKpi[]} — per-row status
  by 24h TTL; KPIs FE-derived from /history (всего/средняя цена/повторные %), NOT the
  global /cache-stats.
- CacheView: real previous-estimates list. ParamsPanel: address autocomplete dropdown →
  captures lat/lon into submit (cleared on manual edit). HeroBar: «Скачать PDF» → /estimate/
  {id}/pdf, UUID-gated + safeUrl scheme check + disabled when no estimate. TopNav: «Мои
  отчёты» from quota.used/history. page.tsx: invalidate history+quota on new estimate.
- Hydration-safe: PDF link gated behind a mounted flag (urlId is SSR-null/client-uuid).
- FE-1..4 untouched.

Verified: next build green (/v2 39.6 kB); tsx harness ran mapCache vs real /history (10
items: ВСЕГО 10 / СРЕДНЯЯ 8,85 млн / ПОВТОРНЫЕ 50%, statuses, null graceful).
code-reviewer APPROVE after hydration + reports-fallback fixes.
2026-06-28 16:14:34 +03:00
3421b21632 fix(scrapers): разблокировать domrf_kn flats — изоляция flats от poison-extras + throttle (#1945) (#2050)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 9m31s
Deploy / build-worker (push) Successful in 11m57s
Deploy / deploy (push) Successful in 3m51s
2026-06-28 13:03:43 +00:00
0c0efb887a Merge pull request 'feat(tradein/v2): wire overlays History/Analytics/Sources to real API (FE-2/3/4)' (#2051) from feat/tradein-v2-overlays into main
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 2m49s
Deploy Trade-In / deploy (push) Successful in 49s
2026-06-28 12:46:49 +00:00
267585e312 feat(tradein/v2): wire overlays History/Analytics/Sources to real API (#2039 #2040 #2041)
All checks were successful
CI / changes (pull_request) Successful in 6s
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
FE-2/3/4 on the FE-1 mappers architecture. Adds mapHistory/mapAnalytics/mapSources
(+helpers) to mappers.ts; HistoryView/AnalyticsView/SourcesView accept composite
data props (fixture default); SectionOverlay forwards overlay data; page.tsx adds
useEstimatePlacementHistory/useSalesVsListings/useEstimateSellTimeSensitivity and
feeds mapped data. FE-1 main dashboard untouched.

- History: house sales (placement-history); ДКП KPI/header (street-deals +
  sales-vs-listings); per-deal rows with REAL addr/date/listing-source/discount.
- Analytics: KPIs (экспозиция/торг/доля снятых), 4 sell-time tiers, price-history
  polylines computed from year×source, detail scatter.
- Sources: ad rows from analogs (real source/distance/url via safeUrl), deal rows
  from actual_deals (real source/tier/date), market KPIs.
- Un-hardcoded fabricated cells: DKP addr/date/source, deal date, deals footer count.
- FE-approx (TODO BE-1 #2043): CV, per-source counts, days_on_market->listing_date.

Verified: next build green (/v2 37.8 kB); tsx harness ran the 3 overlay mappers vs
real estimate 701795d8 (houseSales/KPIs/sellTime/priceHistory/scatter/ad+deal rows
correct). code-reviewer APPROVE after honesty fixes.
2026-06-28 15:46:07 +03:00
510cf1cb80 Merge pull request 'fix(tradein/v2): address parse (Cyrillic \b bug) + scatter from listing_date' (#2049) from fix/tradein-v2-fe1-address-scatter into main
All checks were successful
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 48s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m53s
2026-06-28 12:04:59 +00:00
ad5c1913cb fix(tradein/v2): address parse (Cyrillic \b bug) + scatter from listing_date (#2038)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
QA on real prod estimate 701795d8 surfaced two mapper defects:
- STREET_RE used JS \b which does NOT word-boundary Cyrillic → street token never
  matched → object.address fell back to the bare house number ('14/3') and city
  mis-parsed ('14/3, Россия'). Rewrote parseAddress to handle both OSM (house-first)
  and DaData (city-first) orders; street keyword delimited by space, not \b. Now
  'улица Яскина, 14/3' + city 'Екатеринбург'.
- days_on_market is null across prod lots → scatter-mini rendered empty. Active
  analogs now fall back to 'days listed so far' = today-listing_date (deals still
  require a real days_on_market — today-deal-date is not exposure). TODO BE-1.

Verified via tsx harness against the real estimate (25/25); next build green.
2026-06-28 15:04:28 +03:00
e2ff8958ca Merge pull request 'feat(tradein/v2) [FE-1]: wire main dashboard to real API (mappers + page shell)' (#2048) from feat/tradein-v2-fe1-data-shell into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 1m52s
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 48s
2026-06-28 11:50:52 +00:00
890daa4a31 feat(tradein/v2): wire main dashboard to real API (mappers + page shell) (#2038)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / openapi-codegen-check (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
FE-1 foundation for #2036. app/v2/page.tsx now owns data: ParamsPanel form ->
useEstimateMutation, restore-by-id via window.location.search (mirrors v1, avoids
useSearchParams Suspense build break), sub-hooks useStreetDeals/useEstimateHouseAnalytics;
loading/empty/insufficient/error states (honest placeholders, no fixtures-as-real).

New components/trade-in/v2/mappers.ts: pure AggregatedEstimate(+street-deals/analytics)
-> fixture-shape transforms — 3 price tiers (asking/expected_sold/DKP), ranges, scatter,
7 source slots, summary. CV + per-source counts are FE-approx pending BE-1 (#2043).

ParamsPanel -> controlled form (onSubmit/isPending/error/initialValues). ResultPanel/
ObjectSummary/HeroBar/Footer accept a composite data prop defaulting to the existing
fixture (pixel-perfect markup unchanged). next build green (/v2 34 kB).
2026-06-28 14:48:57 +03:00
d651dac99a Merge pull request 'fix(tradein/avito): reap dead 404 listings in detail backfill (don't trip block breaker)' (#2047) from fix/tradein-avito-detail-dead-listing-reaper 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 1m27s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 48s
2026-06-28 11:16:57 +00:00
a50af40ad6 fix(tradein/avito): reap dead 404 listings in detail backfill (don't trip block breaker)
All checks were successful
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
avito_detail_backfill (mode=browser) aborted prod run 458 with
attempted=5 enriched=0 blocked=5: the lat-null pending queue is dominated
by DEAD listings (404 — that's why they are stale coord-holes). In browser
mode a dead listing's 404 page ("Ошибка 404. Страница не найдена") has no
item-view, so _is_detail_soft_block returned True → AvitoBlockedError →
backfill counted it as `blocked` and the consecutive-block breaker aborted
the run before reaching live listings.

- New AvitoListingGoneError (subclass of common AvitoError, NOT of
  AvitoBlockedError/AvitoRateLimitedError) so the backfill block-trap
  does not catch it.
- New _is_detail_not_found() detector + browser-mode branch in fetch_detail,
  checked BEFORE firewall/soft-block (keyed on the 404 title so it does not
  swallow the generic soft-block deflect). Curl path unchanged (404 → 302/
  non-200 → ValueError → failed, as before).
- Backfill: new `gone` counter; dedicated except AvitoListingGoneError branch
  that is neutral to the consecutive-block breaker and marks the listing
  is_active=FALSE (SAVEPOINT) so it leaves the snapshot scope.

Tests: unit for _is_detail_not_found (404 True; soft-block/item-view/empty
False), browser-mode fetch_detail raising AvitoListingGoneError (not Blocked),
and backfill gone-row marks inactive without aborting the breaker.
2026-06-28 14:05:31 +03:00
7b8ffd4200 Merge pull request 'refactor(tradein/v2): перенос HUD v2 в tradein-mvp (прод-app) → /trade-in/v2' (#2037) from feat/tradein-v2-mvp-port into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
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 2m4s
Deploy Trade-In / deploy (push) Successful in 55s
Deploy / build-frontend (push) Successful in 3m34s
Deploy / deploy (push) Successful in 1m18s
Reviewed-on: #2037
2026-06-28 10:17:11 +00:00
fcb85deab8 refactor(tradein/v2): перенос HUD v2 в tradein-mvp frontend
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m3s
CI / openapi-codegen-check (pull_request) Successful in 1m59s
Прод-/trade-in/* обслуживает tradein-mvp/frontend (basePath=/trade-in,
standalone), а не главный frontend — v2 по ошибке попал в главный, где
прод его не отдаёт. Markup-only порт (fixtures, без API):

- компоненты v2 → tradein-mvp/frontend/src/components/trade-in/v2/
- роут src/app/v2/ (basePath даёт прод-URL /trade-in/v2)
- public/trade-in-v2/building.png
- HeroBar: <img> → next/image (плоский img не префиксит basePath → 404)
- удалён мёртвый v2 из главного frontend

next build (NEXT_PUBLIC_BASE_PATH=/trade-in) зелёный, роут /v2 prerendered.
2026-06-28 13:10:23 +03:00
fba41c0cce feat(tradein/v2): HUD-редизайн страницы оценки (Макс) → /trade-in/v2 (#2035)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m22s
Deploy / deploy (push) Successful in 1m20s
2026-06-28 09:58:53 +00:00
ca4637e5c8 fix(tradein/avito): detect HTTP-200 soft-block → rotate + coords-priority backfill ordering (#1950, #1967)
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 1m26s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 48s
Soft-block (HTTP 200 generic витрина без item-view) теперь детектится как блок → существующая 403/firewall reconnect-машинерия → ротация exit-IP вместо silent failed (blocked=0, enriched→0 каскад). + snapshot ORDER BY (lat IS NULL) DESC для приоритизации coord-holes (#1967, in-scope 321). Tests: test_avito_detail_soft_block.py. Full tradein suite 2591 passed.
2026-06-28 08:06:12 +00:00
2184453d8e Merge pull request 'feat(tradein/domclick): local-runner + ingest tooling (mergeable split from #2005)' (#2033) from feat/tradein-domclick-local-tooling into main
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m29s
Deploy Trade-In / build-backend (push) Successful in 55s
2026-06-28 07:29:41 +00:00
43b392b809 feat(tradein/domclick): local-runner + ingest tooling → main (split from parked Layer-B)
All checks were successful
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
Выделяет МЕРДЖАБЕЛЬНУЮ часть DomClick-тулинга из запаркованного PR #2005
(BLOCKED #2000 — прод Layer-B скрейпер через прокси режет QRATOR-403):

- scripts/domclick_local_runner.py — ручной offline-сборщик с домашнего IP:
  SERP-enumerate (фронт, BFF-JSON режет QRATOR) + card __SSR_STATE__ +
  offer-card v3 (AVM price_prediction + sold_similar). --enum-cache (enumerate
  1 раз → файл с sentinel → следующий запуск грузит кэш и пропускает enumerate),
  wait_for_selector (SERP-карточки догидрируются), resume по id. Self-contained.
- scripts/ingest_domclick_jsonl.py — JSONL → trade-in БД (save_listings +
  house-match в SAVEPOINT + save_detail_enrichment, идемпотентно, --limit/--dry-run).
- app/services/scrapers/domclick_detail.py + domclick_exceptions.py — card-SSR
  parse + save_detail_enrichment (нужны ингесту). Импортятся ТОЛЬКО ингестом
  (operator-run); прод-runtime их не дёргает (dormant в образе).
- tests/scrapers/test_domclick_detail.py + обновлённый scripts/README.md.

НЕ включает прод Layer-B (tasks/domclick_detail_backfill.py, scheduler-расписание,
data/sql/139_*) — остаётся запаркован на PR #2005 до чистого прокси (#2000).
БД-схема не меняется (detail-колонки уже в проде, миграции нет).

Live-validated: раннер качает ЕКБ-вторичку прямо сейчас (600+ карт, 0 блоков),
ингест залил их в прод-БД (house_matched, идемпотентно).
2026-06-28 10:26:52 +03:00
59ee8b672f Merge pull request 'fix(objective): physflat-dedup objective_lots — view + inline (request-path) + n_sold window-fix (#1964)' (#2032) from fix/objective-lots-dedup-1964 into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m48s
Deploy / build-worker (push) Successful in 2m51s
Deploy / deploy (push) Successful in 1m27s
2026-06-28 00:21:45 +00:00
74f1ffb500 perf(objective): request-path consumers dedup inline, not via whole-table view (#1964)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m49s
CI / backend-tests (pull_request) Successful in 13m58s
deep-review #1964: v_objective_lots_latest has NO premise/district filter inside,
so a consumer's outer WHERE cannot push below DISTINCT ON → the view materializes
the WHOLE table (Parallel Seq Scan + external Sort 1.76M rows, ~55MB spill) on
every query. For REQUEST-PATH consumers inside analyze_parcel this is a ~19x latency
regression vs the pre-#1964 raw-table plan.

DISPROVEN remedy (NOT applied): a full index on the physflat-key does NOT help —
DISTINCT ON selects ol.* (51 cols, width≈945) so index-only-unique is impossible;
the planner ignores the index (seq-scan+sort still cheaper) and even forced it is
~3.9 s. A 142MB index for zero request-path benefit + slower bulk-INSERT during
objective-scrape is wrong. Honors original #1964 decision "no new index".

Prod EXPLAIN (Академический / 3km radius, 2026-06-28):
  consumer            via view      inline (this commit)
  concepts median     5854 ms       1640 ms   (bitmap district + sort)
  parcels district    5854 ms       1640 ms
  parcels geo-median  6443 ms        122 ms   (NestedLoop geo->complex bitmap)
  parcels obj_pricing 5721 ms        441 ms   (project bitmap per nearby ЖК)

FIX: keep v_objective_lots_latest ONLY for batch/background/cached consumers
(supply_layers L1, competitors._SOLD_COUNT_SQL, special_indices [/forecast bg task
30-180s], admin, landing). Revert the 4 request-path consumers inside analyze_parcel
to inline DISTINCT ON (physflat-key, latest snapshot) with the filter pushed INTO the
CTE so the district/spatial/project index applies:
- concepts._OBJECTIVE_MEDIAN_SQL
- parcels.py district price block
- parcels.py geo-radius median (complex_id-scoped)
- parcels.py obj_pricing CTE (project_name-scoped; aggregates over deduped set)

Migration 175 header CORRECTED: accurately states the partial mig-173 index does NOT
serve the view (qual can't push below DISTINCT ON), the full index is disproven/not
added, and which consumers use the view vs inline. No DDL change (still view-only).

Tests: +guards (concepts/obj_pricing dedup inline, not view; obj_pricing physflat
DISTINCT ON; perf-pushdown scope preserved). 965 passed.
2026-06-28 05:00:47 +05:00
e67cb721bf fix(objective): physflat-dedup current-state consumers + honest n_sold window (#1964)
objective_lots — current-state UPSERT (UNIQUE objective_lot_id, 5 snapshot_date),
но Объектив присваивает ОДНОМУ физлоту (project,corpus,section,floor,lot_number)
несколько lot_id за пере-листинги → таблица раздута ~2.91× (прод: 1.75M квартир-
строк vs 603k физлотов). История — в отдельной objective_lots_history (не трогаем).

STEP 1: миграция 175 — VIEW v_objective_lots_latest (DISTINCT ON physflat-ключ,
последний снапшот). ol.* стабилен (51==51 колонок). DRY-RUN на проде: 603 049
квартир vs 1 753 283 raw.

STEP 2: репойнт current-state консьюмеров objective_lots → v_objective_lots_latest:
- supply_layers._L1_OPEN_SQL (L1 открытое предложение → дефицит-форсайт; прод:
  Юго-Западный комфорт 58 606 → 12 620)
- competitors._SOLD_COUNT_SQL (+ комментарий: COUNT(DISTINCT lot_id) СОХРАНЁН для
  fan-out-защиты маппинга, не COUNT(*) — view гарантирует physflat-дедуп, DISTINCT
  гарантирует mapping-fan-out-safety)
- parcels.py obj_pricing CTE (карточка конкурента units_sold/available)
- special_indices._ARTIFICIAL_DEMAND_SQL
- parcels.py district price block + geo-radius median (sample_size/n)
- concepts._OBJECTIVE_MEDIAN_SQL (гейт n≥10)
- landing KPI3 (% квартир с ценой)
- admin_scrape coverage: `lots` оставлен сырым (ETL-fidelity vs SQLite), добавлен
  `lots_physflat`
#1959 inline-дедуп в market_metrics НЕ рефакторим — добавлен комментарий об общем
physflat-ключе с view.

STEP 3: report_assembler._deal_count теперь = unit_velocity × window_months
(оконные продажи), НЕ кумулятивный n_sold. confidence_engine помечает фактор
«за 6 мес» и гейтит порогами окна (high≥50) — кумулятив (прод EKB ~380 921) делал
гейт бессмысленным и подпись лживой; оконное (~24 876 за 6 мес) честно.

Тесты: +guard'ы (L1/sold-count читают view, deal_count оконный). 963 passed.
2026-06-28 04:27:43 +05:00
91edef1524 Merge pull request 'fix: audit follow-up мелочи — индекс domrf_kn_flats + max_height_m coercion + hide empty reg-date (#1953)' (#2031) from fix/audit-followups-1953 into main
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 30s
Deploy / build-worker (push) Successful in 31s
Deploy / build-frontend (push) Successful in 3m11s
Deploy / deploy (push) Successful in 1m12s
2026-06-27 22:37:14 +00:00
8aec625134 fix(audit): index for flats_latest, coerce max_height_m, hide empty reg-date (#1953)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m2s
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI / backend-tests (pull_request) Successful in 14m7s
Three #1953 audit follow-ups:

- perf: add migration 174 — composite index idx_kn_flats_obj_snap on
  domrf_kn_flats (obj_id, snapshot_date DESC). Serves the #1956 flats_latest
  CTE in best_layouts.py (DISTINCT ON (obj_id) ORDER BY obj_id, snapshot_date
  DESC + self-join on (obj_id, snapshot_date)); previously only (obj_id)
  existed so Postgres sorted per object. Prod: 789 569 rows, idx ~5.7 MB,
  dry-run instant. Idempotent, self-wrapped BEGIN/COMMIT.

- frontend: route every max_height_m read through coerceFloat (same string-bug
  class as max_far #1962). max_height_m is NUMERIC → arrives as a string on the
  wire; ptica-adapt.ts read it raw at 4 sites and relied on formatInt/Math.round
  coercion. Widen the type in nspd.ts and fix the stale "real number" comment in
  nspd-regulation.ts.

- frontend: hide the «Дата регистрации» EGRN row entirely when
  registration_date is null (~97% of parcels) instead of rendering a bare «—».
2026-06-28 03:19:18 +05:00
2fb34112b5 Merge pull request 'feat(concept): picker «выбери дом» + каталог-эндпоинт — Stage 3b, финал #1965' (#2030) from feat/concept-house-picker-1965-stage3b into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m7s
Deploy / build-worker (push) Successful in 3m9s
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m26s
2026-06-27 22:08:57 +00:00
c55eb4f4d4 feat(concept): фронт-пикер типовых домов (#1965 Stage 3b)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m1s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (pull_request) Successful in 14m9s
Финальная часть эпика #1953: пользователь выбирает типовые дома
(тип × этажность × число секций) вместо авто max-FAR раскладки, формируя
building_program из Stage 3a.

Бэкенд:
- GET /api/v1/concepts/house-types — read-only каталог HOUSE_TYPES
  (section_type, label_ru, footprint w×d + sqm, default_floors, housing_class)
  как single source of truth; фронт ничего не хардкодит.
- Схема HouseTypeCatalog / HouseTypeCatalogItem в schemas/concept.py.
- Тесты эндпоинта: полнота каталога + совпадение ключей с available_section_types.

Кодген: api-types.ts перегенерён (dump OpenAPI → openapi-typescript →
project-local prettier 3.9.0); 2-й прогон без диффа.

Фронтенд:
- useHouseTypes() (TanStack useQuery, staleTime Infinity) в concept-api.ts;
  building_program в ConceptInput, placed_count/requested_count в ConceptVariant.
- HouseProgramPicker: toggle «Авто (max-FAR)» (default, program omit → greedy)
  vs «Выбрать дома» (список каталожных типов, count 1-50 / floors 1-40, дефолт
  из каталога; габариты/этажность/класс как подсказка). Смонтирован в
  Section7Concept и на странице /concept.
- Partial-fit заметка в ConceptVariantsResult: при placed<requested честное
  «Разместилось N из M секций — участок вмещает меньше» (нейтрально, не ошибка).
2026-06-28 02:49:44 +05:00
3b84609670 Merge pull request 'feat(concept): каталог типовых домов + program-placement (#1965 Stage 3a)' (#2029) from feat/concept-house-catalog-1965-stage3a into main
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 2m18s
Deploy / build-frontend (push) Successful in 3m24s
Deploy / build-worker (push) Successful in 3m37s
Deploy / deploy (push) Successful in 1m27s
2026-06-27 21:40:10 +00:00
e8b05d821e test(concept): turn greedy backward-compat into a real golden pin (#1965 Stage 3a)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m3s
CI / openapi-codegen-check (pull_request) Successful in 1m51s
CI / backend-tests (pull_request) Successful in 14m7s
The old test_no_program_reproduces_greedy_output_unchanged only compared
building_program=None (default) vs explicit None — both through the new
_Placer code — so it proved the two None branches agree but did NOT pin
the greedy geometry; it would still pass if the _Placer extraction had
drifted the output. test_placement.py only checks invariants, never
concrete counts/TEAP, so there was no anti-regression guard that the
greedy path is byte-identical after the refactor.

Replace it with two tests:
- test_greedy_output_matches_golden_pin: hard-coded literals per strategy
  on the fixed _BIG_PARCEL — (features, built_area_sqm, total_floor_area_sqm,
  apartments_count) — frozen from the current (== pre-refactor) output, so
  any future deterministic drift in greedy placement FAILS.
- test_explicit_none_program_equals_default_greedy: keeps the None-branch
  equivalence check (default vs explicit None go one greedy path).
2026-06-28 02:23:55 +05:00
2ebf7468cd fix(tradein/avito): hard-timeout on detail-backfill fetch — prevent zombie-hang (#1950)
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m25s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
fetch_detail wrapped in asyncio.wait_for(90s) + bounded _rotate_ip — a single hung fetch no longer zombies the whole run (run 423 hung 7.7h). New except TimeoutError (failed+continue), avito_detail_fetch_timeout_s setting, admin trigger POST /scrape/avito-detail-backfill. Restores detail-queue throughput (coords #1967 + house_type). Refs #1950
2026-06-27 20:29:27 +00:00
94cf1f6217 feat(concept): house-type catalog + program-driven placement (#1965 Stage 3a)
Extend the concept generator so ConceptInput can carry an optional
building_program (list of typed houses from a catalog). When present,
placement lays out EXACTLY that program — for each item, place `count`
sections of the catalog footprint at the item's floors — instead of the
greedy max-FAR coverage-cap sweep. When absent, the existing greedy
behavior is unchanged (byte-for-byte backward-compatible).

- catalog.py: hardcoded HOUSE_TYPES (panel_econom, monolith_comfort,
  tower_business, lowrise_comfort, townhouse) — sane-default catalog,
  promote to DB later; get_house_type / available_section_types lookups.
- schema: additive BuildingProgramItem {section_type, floors, count} and
  ConceptInput.building_program (default None -> greedy). ConceptVariant
  gains optional placed_count / requested_count (partial-fit signal).
- placement: shared _Placer (collision/STRtree/setback machine extracted
  from greedy sweep, reused — no duplication); place_program +
  place_program_variant; branch in place_all_strategies on
  building_program. Mixed-floor TEAP via exact per-floor-group aggregation
  (GFA = sum(area_i * floors_i), no rounding drift).
- partial fit: when the parcel can't fit all sections, place as many as
  fit and report placed_count < requested_count (no hard-422); zero-fit
  still raises ParcelGeometryError (-> 422).
- API: validate program section_type keys against the catalog (unknown ->
  422) before placement.
- tests: catalog integrity, greedy backward-compat, exact 2-item program +
  TEAP reflection, over-packed partial placement, API program path.
- regenerate frontend api-types.ts (OpenAPI codegen gate stays green).
2026-06-28 01:29:04 +05:00
44b9305d89 feat(tradein/estimator): elite-segment manual_review trigger by asking ppm² (#2002)
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 1m25s
Deploy Trade-In / build-backend (push) Successful in 51s
Deploy Trade-In / deploy (push) Successful in 48s
2026-06-27 20:15:38 +00:00
5b4c0dd1b2 Merge pull request 'feat(concept): живой 3D-massing → пересчёт финмодели в «7. Концепция» (#1965 Stage 2b)' (#2026) from feat/concept-live-massing-1965-stage2b into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / deploy (push) Successful in 1m14s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m15s
2026-06-27 20:10:41 +00:00
a0232e22f1 feat(concept): live 3D massing → financial recompute in «7. Концепция» (#1965 Stage 2b)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m1s
CI / openapi-codegen-check (pull_request) Successful in 1m54s
Wire the interactive 3D massing to a LIVE financial recompute inside the
Section «7. Концепция» of the light analysis report (epic #1953).

- concept-api.ts: add MassingProgram / MassingRecomputeOutput contracts +
  useRecomputeMassing() (POST /api/v1/concepts/recompute via shared apiFetch),
  and polygonCentroidWkt() helper for the DB-price fallback hint.
- MassingScene.tsx: export MassingModel + add optional onModelChange prop fired
  on the initial build and every floors/sections change (NOT on the sun/hour
  slider). No-op when absent — /ptica cockpit + preview/drawer unchanged.
- MassingEconomics.tsx (new): light-token KPI strip rendering recomputed ТЭП
  (GFA, продаваемая, квартиры, КСИТ факт/цель) + финмодель (выручка,
  себестоимость, прибыль, ROI, NPV, IRR, цена). Debounced ~250ms, latest-wins
  via monotonic request id, last-good values + subtle note on error, grey-fade
  skeleton on first load, КСИТ over-cap flag.
- Section7Concept.tsx: mount lazy MassingViewer (dynamic ssr:false) + the
  economics strip; map computeModel → MassingProgram (footprint/floors/sections
  ← model; site area ← polygon; class/type ← financial_estimate inferred,
  fallback comfort/mid_rise; land ← egrn cadastral; price/source ←
  financial_estimate; КСИТ-цель ← coerced nspd_zoning.max_far, fallback 3.5).
2026-06-28 01:01:53 +05:00
01b0dc74de Merge pull request 'feat(generative): POST /concepts/recompute — live финрасчёт по massing-program (#1965 Stage 2a)' (#2024) from feat/concept-recompute-1965-stage2a into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 1m54s
Deploy / build-worker (push) Successful in 3m22s
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m28s
2026-06-27 19:53:53 +00:00
ec0f113d4f chore(tradein/backtest): --resolve-house-id flag to measure Tier-S + IMV anchor (#2002)
All checks were successful
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 skipped
Deploy Trade-In / test (push) Successful in 1m27s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 53s
2026-06-27 19:44:38 +00:00
7e824ce6e4 chore(frontend): regenerate api-types.ts for /concepts/recompute (#1965 Stage 2a)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m11s
CI / backend-tests (pull_request) Successful in 14m1s
The new POST /api/v1/concepts/recompute endpoint + MassingProgram /
MassingRecomputeOutput schemas changed the backend OpenAPI; the generated
frontend/src/lib/api-types.ts was out of sync, failing the CI
openapi-codegen-check gate. Regenerated via the exact CI sequence
(openapi-typescript + prettier 3.9.0). Byte-stable on 2nd regen; tsc --noEmit clean.
2026-06-28 00:38:28 +05:00
18d012da1b fix(generative): forward genuine price_source through /recompute (#1965 Stage 2a)
Some checks failed
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Failing after 1m49s
CI / backend-tests (pull_request) Successful in 13m49s
Code-review follow-up: /recompute hardcoded price_source="objective_district_median"
for any body-supplied market_price_per_sqm, mislabeling the honesty-flag once
Stage 2b forwards a price whose genuine source differs (objective_geo_radius /
district_reference / class_norm from financial_estimate).

- schemas/concept.py: add optional price_source: str | None to MassingProgram.
- api/v1/concepts.py: on FAST path use payload.price_source if provided, else the
  default label (now a module-level constant _DEFAULT_PRERESOLVED_SOURCE). DB-fallback
  and class-norm paths keep their own resolved source unchanged.
- tests: assert body-provided price_source echoes through to financial.price_source
  (not overwritten), and the default label applies when the front omits it.
2026-06-28 00:33:59 +05:00
d38d5d43c5 feat(generative): LIVE financial recompute from massing program (#1965 Stage 2a)
Stage 2a of epic #1953: backend service + endpoint for live economic recompute
driven by the interactive 3D massing (Stage 2b debounced sliders).

- teap.py: add pure synthesize_teap_from_program(total_footprint_sqm, floors,
  site_area_sqm, housing_class, sections) — builds a TEAP from the SCALAR
  aggregate footprint × floors, mirroring synthesize_teap_from_buildability and
  reusing the same shared norm constants (_OFFICE_SHARE_OF_GFA / _EFFICIENCY_BY_CLASS
  / _AVG_APARTMENT_SQM / _PARKING_PER_APARTMENT) — single source of truth.
- schemas/concept.py: add MassingProgram (program contract, optional pre-resolved
  market_price_per_sqm + parcel_centroid_wkt) and MassingRecomputeOutput (teap + financial).
- api/v1/concepts.py: add POST /api/v1/concepts/recompute — synthesize TEAP → run
  the existing pure compute_financial. FAST path uses body market_price_per_sqm
  (no DB); else _lookup_market_price by centroid via run_in_threadpool; else class norm.
- tests: synthesize_teap_from_program (gfa math, parity with compute_teap, class
  efficiency, sections no-op) + endpoint (200, coherent output, price passthrough
  skips DB, DB fallback, class-norm default, floors validation).
2026-06-28 00:27:25 +05:00
fed33fce27 Merge pull request 'feat(site-finder): «7. Концепция» в light-отчёте — Stage 1 #1965' (#2023) from feat/concept-section-1965-stage1 into main
All checks were successful
Deploy / build-frontend (push) Successful in 3m10s
Deploy / deploy (push) Successful in 1m15s
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
2026-06-27 19:22:12 +00:00
e809063ca3 feat(site-finder): add «7. Концепция» section to light analysis report
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 58s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
Fold the generative concept generator (3 strategy variants: placement map +
ТЭП + finance) into the light analysis report (/site-finder/analysis/{cad}) as
a new section after «6. Прогноз», so Концепция lives in one place alongside the
report instead of a separate flow (epic #1953, #1965 Stage 1).

Frontend-only: reuses ConceptParamsForm / ConceptVariantsResult and the
/concepts mutation (useCreateConcept) as-is — no duplication, no backend change.
Inputs are seeded from the analysis the report already has (no re-entry of the
cadastre/polygon): polygon ← extractPolygon(geom_geojson); housing_class /
development_type ← financial_estimate inferred values; land_cost ←
egrn.cadastral_value_rub; falls back to comfort/mid_rise.

Runs on demand (heavy generative call — never auto on mount); result block
(Leaflet map) is lazy-mounted via dynamic({ ssr: false }) and the section is
collapsed by default. When financial_estimate is null (ЗОУИТ/СЗЗ/нет зоны) it
shows an honest banner but still allows a manual run, since /concepts is
independent of the financial_estimate gate. Graceful empty state when geometry
is missing/unsupported. Stage 2 (interactive 3D + /concepts/recompute) left as
a seam, not built.
2026-06-28 00:13:57 +05:00
3187419eb8 feat(tradein/ui): manual-review banner + premium-building badge on estimate (#2002)
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 1m53s
Deploy Trade-In / deploy (push) Successful in 52s
2026-06-27 19:09:46 +00:00
96d5794b5c Merge pull request 'fix(best-layouts): per-object latest snapshot для supply → срок продажи/% продано (#1956)' (#2020) from fix/best-layouts-per-object-snapshot-1956 into main
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m49s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m26s
Deploy / changes (push) Successful in 7s
2026-06-27 18:43:19 +00:00
e39fb86a9b feat(tradein): AI-curated premium-building overlay — class + false-positive demotion (#2002)
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 1m29s
Deploy Trade-In / deploy (push) Successful in 52s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 56s
2026-06-27 18:43:05 +00:00
0acd72a325 fix(best-layouts): per-object latest snapshot for supply (#1956)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Successful in 13m45s
_SUPPLY_BATCH_SQL джойнил domrf_kn_flats по ОДНОЙ глобальной дате
(f.snapshot_date = MAX(snapshot_date) по всей таблице). Но domrf_kn_flats —
ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в свой день. На единственной
глобал-max дате присутствует обычно 1 объект → у остальных 0 квартир →
supply_units_in_radius=0 для всех строк 4.2 Планировки → frontend показывал
«Срок продажи 0 мес» и «% продано —». Регрессия от #1944 (objects-first
дедуп snapshot'ов объектов, который сам по себе корректен).

Фикс: flats_latest CTE (DISTINCT ON (obj_id) ... ORDER BY obj_id,
snapshot_date DESC, id DESC) берёт для КАЖДОГО obj_id его собственный
последний снимок и джойнится к nearby. objects-first MATERIALIZED дедуп
(#1944) сохранён → fan-out по снимкам не возвращается. Глобальный
db.scalar(MAX(snapshot_date)) + :latest_snap bind удалены.

Прод (66:41:0205010:287, r=1км, 9 объектов): supply 0 (global-max) → 2675
(per-object, 4 объекта имеют flats на разных датах 2026-05-17/05-05; ни один
не на глобал-max 2026-06-22). Данные flats частично сломаны (#1945, отдельно),
но фикс корректно двигает supply с 0 к реальным per-object числам.

Тесты: новый guard test_supply_joins_flats_per_object_latest_snapshot;
обновлены mock-фабрики (db.scalar больше не вызывается).
2026-06-27 23:24:28 +05:00
6aff5a000d Merge pull request 'fix(forecast): segment + dedup demand/supply → индекс дефицита больше не −1.00 везде (#1959)' (#2018) from fix/deficit-segmentation-1959 into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m44s
Deploy / build-worker (push) Successful in 8m4s
Deploy / deploy (push) Successful in 1m39s
2026-06-27 18:18:09 +00:00
275e7e9618 feat(tradein/estimator): manual_review recommendation flag (#2002)
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 1m30s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 49s
2026-06-27 18:17:24 +00:00
3cafe22c15 docs(forecast): исправить устаревшие комментарии #1959 (deep-review follow-up)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 14m2s
Doc-only, без изменений логики (deep-code-reviewer APPROVE):
- market_metrics _STOCK_SQL/_SALES_WINDOW_SQL + docstring: убрать ложную
  отсылку «зеркало … из 100_*.sql» (индекс создаётся ТОЛЬКО в миграции 173).
- _SALES_WINDOW_SQL комментарий + compute_market_metrics docstring: room_bucket
  это Source-B room_area-вокабуляр («Студии 15-30»/«2-к 45-60»/«80+ м²», зеркало
  room_area_bucket_of), НЕ Source-A «студия/1/2/3» — приводим к фактическому CASE.
- market_metrics: добавить комментарий, что DISTINCT-ON дедуп БЕЗУСЛОВНЫЙ (все
  вызовы compute_market_metrics, не только форсайт-путь); deep-review подтвердил
  безопасность для ratio/saturated-velocity консьюмеров; дедуп сырой таблицы
  objective_lots для platform-wide остаётся #1964.
- recommendation.map_class docstring: Economy → «стандарт» (код уже маппит так).
2026-06-27 23:02:09 +05:00
c5227a164e feat(tradein/cian): promote kitchen/mortgage/apartments + normalize house_type (#2008)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m27s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m0s
cian house_type normalized via shared house_type_normalizer (monolithBrick->monolith_brick etc.) — fixes estimator soft-penalty false-mismatch (~1208 rows); backfill migration 141 + new columns is_apartments/is_rosreestr_checked. kitchen/mortgage/apartments promoted (dashboard/matching, not valuation — #2012). Refs #2008
2026-06-27 18:01:50 +00:00
41804ed70e fix(forecast): посегментный+дедуплицированный индекс дефицита (#1959)
Корень «−1.00 везде» (эпик #1953): compute_demand_supply_forecast брал
district-wide unit_velocity (847.5/мес, ВСЕ классы/комнаты) как спрос и
весь district-сток (~63k доступных) как предложение для КАЖДОЙ ячейки
what_to_build → один и тот же ratio во всех ячейках → все deficit_index
прижаты к −1.0. Плюс objective_lots — append-per-snapshot (~2.9× инфляция
строк), что симметрично раздувало обе базы → даже сегментация без дедупа
осталась бы вырожденной.

Фикс (blast radius — ТОЛЬКО forecast/deficit calc; platform-wide dedup = #1964):
- market_metrics.compute_market_metrics: +obj_class/+room_bucket (+cache key).
  _STOCK_SQL и _SALES_WINDOW_SQL дедуплят до ПОСЛЕДНЕГО снапшота на физлот
  (DISTINCT ON project_name,corpus_name,section,floor,lot_number ORDER BY …
  snapshot_date DESC,id DESC), затем агрегируют. Class-фильтр (LOWER=LOWER,
  class lowercase) + room-bucket (Source-B room_area-вокабуляр, зеркало
  sales_series.room_area_bucket_of → what_to_build фильтрует без перевода).
  ROLLUP/GROUPING сохранён; confidence считается на дедуплицированных counts.
- demand_supply_forecast: base_pace и open-сток теперь ПОСЕГМЕНТНЫЕ
  (market_metrics(obj_class,room_bucket)). При заданном сегменте L2/L3
  (hidden/future) ИСКЛЮЧЕНЫ из баланса — они класс/формат-агностичны, иначе
  двоились бы по всем ячейкам. +_market_room_bucket VOCAB-мост (валидирующий
  pass-through Source-B меток; неизвестное → None = без фильтра, не тихий 0-rows).
- what_to_build/_DEFAULT_CLASSES и recommendation Economy-маппинг: «эконом»→
  «стандарт» (в objective_lots эконома НЕТ, стандарт=483k → раньше ячейка
  матчила 0 строк и молча выпадала).
- report_assembler honesty-guard: если ВСЯ сетка прижата к ±1.0
  (degenerate-fallback) — не эмитим «строить»/«избегать», показываем
  «недостаточно гранулярных данных для посегментного вывода».
- data/sql/173_objective_lots_physflat_idx.sql: partial index под DISTINCT ON
  (Index Only Scan + Unique, без Sort на 1.75M строк; idempotent, BEGIN/COMMIT).

Prod-verify (parcel 66:41:0205010:287, Железнодорожный, h=24): ячейки
ДИФФЕРЕНЦИРУЮТ (12 measured, 7 distinct) вместо all −1.0; MOI комфорт/студия
38.5 vs стандарт/студия 244.3 (точное совпадение с ожидаемым).

Тесты: регрессия «ячейки различаются (не all −1.0)» + vocab-translation +
honesty-guard + посегментное предложение. ruff clean; no :name::type.
2026-06-27 22:50:58 +05:00
440bb2ca51 feat(tradein/yandex): normalize house_type + promote kitchen/ceiling to columns (#2007)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 53s
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 1m30s
Deploy Trade-In / build-backend (push) Successful in 54s
house_type normalized via shared house_type_normalizer (canon = _IMV_HOUSE_TYPE_MAP) — fixes estimator soft-penalty false-mismatch on ~70% yandex analogs; backfill migration 140 (SCREAMING->canon + 'other'->NULL). kitchen/ceiling promoted to columns (dashboard/matching, not valuation — see #2012); ceiling capped 2.0-6.0m to avoid numeric(3,2) overflow. Refs #2007
2026-06-27 17:45:11 +00:00
50684ce87e fix(tradein/estimator): remove dead tier-aware-ratio path — truncation artifact + footgun (#2002)
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 1m25s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / build-backend (push) Successful in 53s
2026-06-27 17:34:54 +00:00
4a93a9297c fix(tradein/avito): persist house_type + house_catalog_url in save_detail_enrichment (#2009)
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 10s
Deploy Trade-In / test (push) Successful in 1m33s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 48s
house_type (normalized) + house_catalog_url→house_url persisted in save_detail_enrichment; COALESCE existing-first for house_type, new-first for house_url. No migration (columns exist). Refs #2009
2026-06-27 17:30:34 +00:00
840d64a4a0 feat(tradein): premium_houses MV + premium_building flag (#2002)
All checks were successful
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 skipped
Deploy Trade-In / test (push) Successful in 1m33s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 51s
2026-06-27 16:40:39 +00:00
c45445b726 feat(tradein/estimator): hedonic year+area correction on expected_sold (#2002)
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 / build-backend (push) Successful in 54s
Deploy Trade-In / deploy (push) Successful in 47s
Deploy Trade-In / test (push) Successful in 1m25s
2026-06-27 16:06:08 +00:00
9448a945d4 feat(tradein/estimator): calibrated 80% prediction interval for expected_sold range (#1966)
All checks were successful
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 47s
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 / build-backend (push) Successful in 51s
2026-06-27 14:01:24 +00:00
40b7ca4242 Merge pull request 'feat(tradein/domclick): обогащение Layer A поиска — description, repair-инференс, minhash, seller' (#1999) from feat/tradein-domclick-search-enrich into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 50s
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 1m24s
2026-06-27 13:37:13 +00:00
54a5d13c48 feat(tradein/domclick): enrich Layer A SERP parser — description, repair-inference, minhash, seller
All checks were successful
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
Capture extra bff-search fields in _map_item (verified live):
- description → ScrapedLot.description + infer_repair_state_from_text → repair_state
  (DomClick search has no renovation-enum; text inference like cian #622)
- sha1 description_minhash (cross-source dedup)
- agency_name from seller (defensive str|dict)
- raw_payload: is_sber_collateral, has_discount, discount_value, duplicates_offer_count, seller

Additive only; existing Layer A behavior unchanged. Deep card detail (renovation-enum,
wallType, full price-history, owners_count) is card-only = QRATOR-blocked on prod proxy
→ separate effort (needs dedicated residential IP).
2026-06-27 16:35:25 +03:00
3cd1605c5c Merge pull request 'fix(site-finder): сети и точки подключения на карте Раздела 3 (#1961)' (#1998) from fix/report-map-points-1961 into main
All checks were successful
Deploy / changes (push) Successful in 11s
Deploy / build-worker (push) Has been skipped
Deploy / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m36s
Deploy / deploy (push) Successful in 1m29s
2026-06-27 13:00:14 +00:00
ef51bb8b62 Merge pull request 'fix(report): человеческий язык в Разделе 6 — дефицит/затоварка, 6.2/6.3/6.5 (#1963)' (#1988) from fix/report-microcopy-1963 into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
2026-06-27 12:59:28 +00:00
ec9ed56fad feat(tradein/backtest): hermetic estimator regression gate — frozen fixture + baseline (#1966 PR 3/3)
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 1m25s
Deploy Trade-In / build-backend (push) Successful in 55s
Deploy Trade-In / deploy (push) Successful in 54s
2026-06-27 12:55:35 +00:00
1bbc032316 fix(site-finder): show networks & connection points on Раздел 3 map (#1961)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m3s
CI / openapi-codegen-check (pull_request) Successful in 1m54s
The "Сети" section table summarised engineering networks and distances, but
users could not see those points on the map. Three root causes:

- Connection-points layer was always empty: NSPD engineering_structures
  (cat 36328) arrive as Polygon/MultiPolygon in EPSG:3857 (prod: 587 Polygon
  + 298 MultiPolygon, zero Point), but extractLatLon only handled Point in
  EPSG:4326. Extend it to compute the outer-ring centroid for
  Polygon/MultiPolygon and reproject 3857→4326 (frontend math, no backend
  change), so the 10+ connection points render as markers with popups.
- Map vs table coverage mismatch: the table counts "В 2 км" while the OSM
  utility-infrastructure layer fetched only 500 m (1 object inside 500 m vs
  153 within 2 km). Raise the hook default to a shared
  UTILITY_COVERAGE_RADIUS_M = 2000 so the map matches the table column.
- Coherence microcopy: clarify that the map shows the same networks as the
  table (2 km), plus NSPD connection points and protection zones.

Adds unit tests for extractLatLon polygon-centroid + 3857→4326 reprojection.

Refs #1961
2026-06-27 17:52:51 +05:00
e82761964d fix(report): human RU microcopy in Site Finder Section 6 (#1963)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m4s
CI / openapi-codegen-check (pull_request) Successful in 1m54s
CI / backend-tests (pull_request) Successful in 13m50s
Replace dev-jargon with plain RU across the §22 forecast report (6.2/6.3/6.5
+ deficit/затоварка legend). Source of truth = backend reason strings + the
frontend RU maps; text/render-only, no scoring/forecast math touched.

Backend (source of truth):
- scenarios.py _COLLAPSE_REASON_LOW_BETA: «β rate-sensitivity не прошёл gate …»
  → «чувствительность к ставке не оценена на коротком ряде ЕКБ → один базовый
  сценарий вместо трёх».
- confidence_engine.py _coverage_factor: drop «domrf↔objective» jargon, say it
  affects будущее предложение/конкуренцию. New _history_factor: «глубина истории
  N мес» + на что влияет + связь с 6.2 (короткий ряд → один сценарий).

Frontend (both Section-6 families — live analysis page + ptica cockpit):
- Deficit legend: −1 затоварка / 0 баланс / +1 острый дефицит + actionable
  трактовка; MOI tied to «сколько месяцев район распродаёт предложение».
- 6.2 heading «Почему один сценарий, а не три» over the collapse reason.
- 6.3 render confidence.rationale + weakest-link rule («скорее завысим
  недоверие, чем недооценим риск»); FACTOR_RU gains confounded_window/
  advisory_cap; factor notes shown.
- 6.5 «Вес»→«Оценка»; overall verdict vs 0.5; «Риск избытка предложения»→
  «Запас по предложению»; §-refs moved from reason into tooltip; 6 special-
  index 1-line «что это + куда лучше» descriptions; 0.00-score reasons shown.

Tests: confidence_engine (history/coverage notes), stripSectionRefs vitest.

Refs #1963
2026-06-27 17:42:27 +05:00
16f375782b Merge pull request 'fix(report): вывести ПЗЗ-градрегламент НСПД в Раздел 1 + coerceFloat КСИТ (#1962)' (#1987) from fix/report-nspd-zoning-1962 into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m59s
Deploy / deploy (push) Successful in 1m10s
2026-06-27 12:22:51 +00:00
63d685a105 fix(report): уникальный React key для ВРИ-списка (дубль-VRI warning) (#1962)
All checks were successful
CI / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m29s
CI / openapi-codegen-check (pull_request) Successful in 2m22s
2026-06-27 17:18:57 +05:00
727f9b1f05 Merge pull request 'fix(tradein/auth): always-available logout button on NoAccessScreen' (#1986) from fix/tradein-logout-on-noaccess into main
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
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 2m22s
Deploy Trade-In / deploy (push) Successful in 55s
Deploy / build-frontend (push) Successful in 3m50s
Deploy / deploy (push) Has been cancelled
2026-06-27 12:18:34 +00:00
a9dfe44f5c fix(tradein/auth): always-available logout button on NoAccessScreen
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 51s
CI / openapi-codegen-check (pull_request) Successful in 1m56s
Locked/no-access users (403/denied path/expired session/trial ended) were
trapped: NoAccessScreen had no logout control, and «Выйти» lived only inside
UserMenu, which does not render on the no-access screen. They could not switch
accounts without clearing browser basic-auth manually.

- Extract logout() into shared `lib/logout.ts` (single source of truth).
- UserMenu now imports it instead of a local copy (behaviour unchanged).
- NoAccessScreen renders an always-available «Выйти» accent button for every
  variant.

Mirrored across both frontends (tradein-mvp/frontend + main frontend) to keep
the MIRROR invariant in sync.
2026-06-27 15:16:42 +03:00
58e71dab01 fix(report): surface ПЗЗ-градрегламент НСПД в Раздел 1 (#1962)
Light report (Section1ParcelInfo) only rendered the dead top-level
zoning.data_available=false (closed-PKK6 path) and never showed the
resolved nspd_zoning, so the resolved regulation (synth #1891) was
invisible to the user («мы выгружали НСПД — где они?»).

- Add «Градрегламент (НСПД)»-card to Раздел 1 (right column, под ЕГРН):
  тер.зона, КСИТ/max_far, пред. высота, этажность, коэф. застройки,
  КСИТ-ёмкость/пятно (от площади ЕГРН), источник + список разрешённых
  ВРИ зоны. Empty-state когда регламент не определён (gap-зоны / не-ЕКБ).
  КСИТ-microcopy расшифровывает жаргон на первой странице.
- New pure mapper adaptNspdRegulation (nspd-regulation.ts) reused by
  ptica-adapt so light report и /ptica остаются consistent.
- coerceFloat: max_far/max_building_pct приходят с провода СТРОКАМИ
  ("2.4"/"80") — фикс латентного бага cockpit'а (typeof===number
  глушил реальный строковый КСИТ в massing/insolation).
- Widen NspdZoning.max_far/max_building_pct to string|number, add
  main_vri; expose nspd_zoning on ParcelAnalyzeResponse.
- Unit test для field-mapping + coercion + empty-state.
2026-06-27 17:10:20 +05:00
c1e0ccb2e8 Merge pull request 'fix(site-finder): фронт light-отчёта — encumbrance/obj_class/ЗОУИТ display + forecast axis & flat-state (#1954 #1955 #1957 #1958)' (#1985) from fix/report-frontend-display-1953 into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m59s
Deploy / deploy (push) Successful in 1m23s
2026-06-27 12:00:13 +00:00
6adba229f0 fix(site-finder): wire encumbrance/obj_class/ЗОУИТ display + forecast axis & flat-state (#1953)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 51s
CI / openapi-codegen-check (pull_request) Successful in 1m45s
Four frontend display fixes for the Site Finder analysis report (audit #1953);
all consume fields the (already-deployed) backend now returns.

- #1954: ЕГРН «Обременения» row was hardcoded «—». Wire top-level
  `encumbrance` block {has_zouit, zouit_count, zouit_types} via
  formatEncumbrance → «ЗОУИТ: N (types)» / «не выявлено (НСПД)».
- #1955: show obj_class per success bucket («Студии 15-30 · Комфорт») so
  rows with identical area-labels are distinct; fix top-row match + React key
  to (bucket, obj_class).
- #1957: NspdZouitOverlapsBlock renders human type_zone + reg_numb_border,
  adds a СЗЗ caveat banner (СанПиН 2.2.1/2.1.1.1200-03) and «СЗЗ вашего
  участка» highlight; switched raw hex to UI tokens.
- #1958: ForecastChart axis names → nameLocation:middle + nameGap (no legend
  overlap); grey flat-state callout when the deficit line is degenerate
  (scenarios_collapsed or all points clamped at ±1).

Tests: formatEncumbrance + isDeficitDegenerate (15 new). Real segment
differentiation is backend #1959 (separate).
2026-06-27 16:51:40 +05:00
b2d4f980f0 Merge pull request 'chore(design-sync): sync inputs for claude.ai/design trade-in DS' (#1984) from chore/design-sync-tradein into main
Reviewed-on: #1984
2026-06-27 11:42:14 +00:00
89c31e7449 Merge pull request 'fix(report): newbuild-consistent медиана + obj_class/Comfort dedup + recommend_mix regression (#1955 #1960)' (#1978) from fix/report-data-1953 into main
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m50s
Deploy / build-worker (push) Successful in 3m8s
Deploy / build-frontend (push) Successful in 3m44s
Deploy / deploy (push) Successful in 1m26s
2026-06-27 11:41:36 +00:00
d5e51ca809 Merge branch 'main' into fix/report-data-1953
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m36s
CI / openapi-codegen-check (pull_request) Successful in 2m28s
CI / backend-tests (pull_request) Successful in 14m16s
2026-06-27 16:26:39 +05:00
a184d38aa6 fix(analytics): recommend_mix success-boost regression from view 172 (#1955)
Some checks failed
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 1m0s
CI / openapi-codegen-check (pull_request) Failing after 1m51s
CI / backend-tests (pull_request) Successful in 13m47s
Миграция 172 убрала english 'Comfort' из v_bucket_success_score (NULL-class
→ 'не указан'), но _bucket_success_ranking в analytics_queries.py матчил
COALESCE(:cls, 'Comfort') — после 172 ни одна строка не равна 'Comfort' →
recommend_mix success-boost (#25, /analytics/recommend квартирография) тихо
возвращал [] (silent degradation). LIVE на проде с момента ручного применения
172 (graceful empty, без краша).

Fix:
- call site (recommend_mix): передаём target_class_db (уже переведённый через
  _class_to_db_vocab english→русский), а не сырой english target_class.
  Чинит и латентный pre-existing баг: Business/Elite никогда не матчили
  русский источник.
- SQL default 'Comfort' → 'Комфорт' (массовый класс ЕКБ, 723 объекта).
  NULL-class ('не указан') в default-путь намеренно не попадают
  (документировано в docstring).

Prod-verified: OLD 'Comfort' default → 0 строк; NEW 'Комфорт' default → 5.

Tests:
- _bucket_success_ranking: реальный запрос (не замокан) с русским default —
  SQL содержит 'Комфорт', не 'Comfort'; ranking непустой.
- recommend_mix: english 'Business' переводится в 'Бизнес' перед ranking
  (раньше тест патчил _bucket_success_ranking→[] и баг не ловил;
  helper теперь умеет patch_success_ranking=False).
- #1960: позитивный тест выбора quarter_rosreestr basis (deals≥5, все
  higher fallback'и None) → median_price_basis='quarter_rosreestr'.
2026-06-27 15:49:40 +05:00
453a1f08da fix(report): newbuild-consistent district median + obj_class dedup (#1953)
FIX A (#1955) «Что хорошо продаётся»: убран фантомный класс 'Comfort'.
- Миграция 172: v_bucket_success_score COALESCE(obj_class,'Comfort')
  → COALESCE(obj_class,'не указан'). Английский литерал заполнял 397 NULL
  и сливался отдельным классом от русского 'Комфорт' → визуальные дубли
  бакетов в UI. Источник уже канонически-русский (проверено на проде),
  synonym-mapping не нужен.
- parcels.py: obj_class протаскивается в success-ranking query + dict.
- TS SuccessRankingBucket.obj_class добавлен.

FIX B (#1960) «Медиана рынка» = 64k (квартальная росреестровская n=1 ДКП):
- district.median_price_per_m2 больше не COALESCE(median_12m, ekb_ref) в SQL.
  Basis-приоритет (newbuild-first): Objective по имени района →
  geo_radius (Objective в 3км) → ekb_districts reference →
  квартальная росреестровская медиана ТОЛЬКО при deals_count≥5.
  Для 66:41:0205010:287: 64k → 132690 (geo_radius, newbuild-consistent).
- median_price_basis добавлен в payload + TS type (nullable median).
- Frontend null-guards для нового nullable median.

Tests: +4 (geo_radius basis, objective-приоритет, deals-guard, obj_class
passthrough); обновлены district-моки в 9 analyze-тестах под новую
SQL-сигнатуру.
2026-06-27 15:10:49 +05:00
521 changed files with 100585 additions and 3689 deletions

View file

@ -351,7 +351,7 @@ Claude имеет ДВА лимита: 5h-rolling (сам сбрасываетс
- Пустая очередь + нет fixup-PR → выходить из loop, а не крутиться вхолостую.
- Тяжёлые batch-прогоны — вне пиковых часов (≈511 PT) при возможности.
- Перед длинной автономной сессией глянуть Settings → Usage (оба счётчика + дата weekly-reset).
- **Окна — на Sonnet, не Opus** (`start-bot.ps1` уже запускает с `--model sonnet`). Opus — только main-оркестратору. Sonnet-пул отдельный, недельный All-models (Opus) пул так не горит.
- **Окна — на Sonnet, не Opus** (`start-bot.ps1` уже запускает с `--model sonnet`; reviewer — opus). Opus — только main-оркестратору. Sonnet-пул отдельный, недельный All-models (Opus) пул так не горит.
- **Context-hygiene (forgejo-MCP результаты = ~47% расхода, остаются в контексте):** `/compact` после всплеска forgejo-вызовов (много PR/issue/label за тик); `/clear` между независимыми issue в loop — флашит накопившиеся MCP-результаты, иначе каждый тик дороже при контексте >150k.
## Error escalation

View file

@ -3,7 +3,7 @@ name: auto-analyst
description: "[DRAFT — autonomous loop only] Analyst в режиме /loop 15m. Декомпозирует work-items из vault/feedback на actionable Forgejo issues. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
status: draft
created_at: 2026-05-27
model: opus
model: sonnet
tools: Task, Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_get_recent_changes, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details
---
@ -12,7 +12,7 @@ tools: Task, Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp_
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Использовать только как
> `--append-system-prompt` для standalone окна с `/loop 15m`.
>
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
> Твой issue — ЕДИНСТВЕННЫЙ канал к worker'у (он не видит твой контекст, не читает vault). Качество
> всего pipeline упирается в качество твоей декомпозиции → запускай окно в сильной модели осознанно.
@ -43,7 +43,7 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
**Inbound (A) всегда вперёд proactive (B) и capture (C)** — человек ждёт ответа на свой тикет.
## Per-tick workflow (every 30 minutes)
## Per-tick workflow (every 15 minutes)
```
1. KILL-SWITCH check (см. _autonomous_pickup.md)
@ -68,8 +68,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
- По каждому scope узким запросом (labels=scope/X,status/Y — НЕ полный листинг):
ready / wip / review / qa / needs-fix → карта «что уже в работе у backend/frontend/db/qa»,
чтобы НЕ плодить дубль того, что воркер уже взял. Что закрылось: labels=status/done&since=30m.
- ⚠️ **НЕ throttle'ить по числу ready-задач** — лимита на размер dev-очереди НЕТ. Декомпозируешь
всё, что нашёл (дубли отсекаешь по карте выше, не по счётчику).
- ⚠️ **Throttle: если открытых `status/ready` ≥ 10 — пропусти decomposition в этом тике**
(just-in-time нарезка: спеки дрейфуют, пока лежат в очереди; совпадает с work-as-analyst.md).
3b. KNOWLEDGE CAPTURE (vault-write — режим C; ПОСЛЕ inbound, off hot-path):
Для каждого issue, перешедшего в `status/done` за окно (из `labels=status/done&since=30m` шага 3):
@ -185,7 +185,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
- **Дедуп + claim — ДО спавна** (шаги 2/3): саб-агенты не знают про queue-state, могут продублировать.
- Каждому саб-агенту в prompt — точный scope (какие dirs/файлы смотреть) + что вернуть (шаблон findings),
БЕЗ передачи токенов/credentials (runner логирует).
- Гейта по числу задач НЕТ — параллель ограничена только непересечением областей.
- Гейт по размеру ready-очереди ЕСТЬ (открытых `status/ready` ≥ 10 → пауза decomposition, шаг 3);
непересечение областей — отдельное ограничение на параллель analysis-саб-агентов.
## Запрос «поменяй лейблы» ⇒ также аудит тела issue

View file

@ -3,7 +3,7 @@ name: auto-backend
description: "[DRAFT — autonomous loop only] Backend engineer в режиме /loop dynamic. Polling Forgejo issues scope/backend, claim+work+push+PR. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
status: draft
created_at: 2026-05-27
model: opus
model: sonnet
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query
---
@ -12,7 +12,7 @@ tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_searc
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
> для standalone окна с `/loop dynamic`.
>
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn
> (который запрещён). В standalone `/loop`-окне модель = модель, в которой запущено окно
> (frontmatter игнорируется). Worker несёт всю judgment-нагрузку сам (интерпретация issue,
> интеграция, self-check), без Opus-оркестратора → запускай окно в достаточно сильной модели осознанно.

View file

@ -3,7 +3,7 @@ name: auto-code-reviewer
description: "[DRAFT — autonomous loop only] Code reviewer + merge authority в режиме /loop 2m. Читает PR diff, выносит verdict, мерджит APPROVE. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
status: draft
created_at: 2026-05-27
model: opus
model: sonnet
tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes
---
@ -19,7 +19,7 @@ tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsid
> **Forgejo API → ТОЛЬКО `mcp__forgejo__*` tools.**НЕ дёргай curl / python3 / `/tmp/*.json` руками —
> на Windows это даёт `http=404` + `FileNotFoundError /tmp/...` (incident 2026-05-31, PR #893). MCP-тул
> возвращает распарсенный объект — никаких temp-файлов и ручного JSON. Полный mapping в
> [[_autonomous_pickup]] § «Forgejo операции». Запуск окна: `scripts/start-bot.ps1 reviewer` (Sonnet).
> [[_autonomous_pickup]] § «Forgejo операции». Запуск окна: `scripts/start-bot.ps1 reviewer`.
>
> ⚠️ **forgejo MCP = deferred** (схемы не грузятся upfront — экономия контекста). В НАЧАЛЕ work-тика,
> если forgejo-тулзы ещё не загружены, выполни ОДИН раз:

View file

@ -3,7 +3,7 @@ name: auto-frontend
description: "[DRAFT — autonomous loop only] Frontend engineer в режиме /loop dynamic. Polling Forgejo issues scope/frontend, claim+work+push+PR. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
status: draft
created_at: 2026-05-27
model: opus
model: sonnet
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents
---
@ -12,7 +12,7 @@ tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_searc
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
> для standalone окна с `/loop dynamic`.
>
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
> В standalone `/loop`-окне модель = модель окна. Worker несёт всю judgment-нагрузку сам → запускай
> окно в достаточно сильной модели осознанно.

View file

@ -3,7 +3,7 @@ name: auto-qa-tester
description: "[DRAFT — autonomous loop only] QA tester в режиме /loop 5m. Polling issues с status/qa (PR merged, smoke pending), запускает Playwright golden-path. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
status: draft
created_at: 2026-05-27
model: opus
model: sonnet
tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__playwright__browser_navigate, mcp__playwright__browser_click, mcp__playwright__browser_type, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_evaluate, mcp__playwright__browser_wait_for, mcp__playwright__browser_close
---
@ -12,7 +12,7 @@ tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsid
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
> для standalone окна с `/loop 5m`.
>
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
> В standalone `/loop`-окне модель = модель окна → запускай осознанно.
> **Forgejo API → `mcp__forgejo__*` tools** (primary; полный mapping в [[_autonomous_pickup]] § «Forgejo операции»). curl — только fallback. Запуск окна: `scripts/start-bot.ps1 qa`.
@ -21,7 +21,7 @@ tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsid
QA в autonomous-pickup mode. Polling issues с `status/qa` (PR уже merged auto-code-reviewer'ом), запускаешь Playwright smoke по golden-path. OK → close issue + status/done. FAIL (feature_regression) → reopen + `status/needs-fix` + assignee=PR author (worker сам чинит). FAIL (prod_down) → `pause-bots` + needs-human.
## Per-tick workflow (every 10 minutes)
## Per-tick workflow (every 5 minutes)
```
1. KILL-SWITCH check (см. _autonomous_pickup.md)

View file

@ -3,7 +3,7 @@ name: auto-resolver
description: "[DRAFT — autonomous loop only] Human-proxy resolver в режиме /loop 15m. Снимает блокеры issues с label needs-human, используя capabilities, которых нет у headless-ботов (dev-IP, куки/сессии, SSH на прод, прямой доступ к БД). НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window НА МАШИНЕ ПОЛЬЗОВАТЕЛЯ."
status: draft
created_at: 2026-05-30
model: opus
model: sonnet
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details, mcp__playwright__browser_navigate, mcp__playwright__browser_snapshot, mcp__playwright__browser_evaluate, mcp__playwright__browser_click, mcp__playwright__browser_type, mcp__playwright__browser_close
---

View file

@ -2,7 +2,7 @@
name: backend-engineer
description: Backend Python engineer for GenDesign — FastAPI, SQLAlchemy 2.0, Celery, psycopg v3, scrapers, services. Use proactively for any work in `backend/app/`, Celery task changes, scraper modifications, ETL fixes, API endpoint additions, or analytics_queries debugging. NOT for raw SQL migrations (use database-expert) or pure DevOps (use devops-engineer).
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__obsidian__obsidian_patch_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details, mcp__postgres-tradein__explain_query
model: opus
model: sonnet
color: blue
---
@ -70,7 +70,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
## Запреты
- ❌ Не коммить (пользователь коммитит сам); пиши commit message в чат
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
- ❌ Не использовать `--no-verify` для обхода pre-commit
- ❌ Не запускать миграции SQL — это работа database-expert (можешь делегировать через Agent tool если задача требует)
- ❌ Не редактировать docker-compose / Caddyfile / .github/workflows/ — это работа devops-engineer

View file

@ -2,7 +2,7 @@
name: code-reviewer
description: "Code reviewer для GenDesign — проверяет staged/recent changes на безопасность, корректность, производительность, conformance с project conventions. Read-only — НЕ пишет код, НЕ коммитит, НЕ пушит. Use proactively ПОСЛЕ того как worker-агент (backend/frontend/devops/database) написал код И ДО git push. Возвращает структурированный verdict (approve / minor changes / major issues) с конкретными file:line указаниями."
tools: Read, Glob, Grep, Bash, WebFetch, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes
model: opus
model: sonnet
memory: project
color: red
---
@ -146,7 +146,7 @@ mcp__obsidian__obsidian_simple_search "<keyword>"
- Удаление prod данных без явного approval
- `--no-verify` / `--force` / `--amend` в push
- **Прямой push в main** — нарушение PR workflow (см. CLAUDE.md). Должен быть feature branch + PR.
- **Merge PR без user approval** — нарушение PR workflow.
- **Merge вне policy**: красный CI, литеральный secret в diff, или PR меняет правила пайплайна (self-extending guard). Self-merge при зелёном CI разрешён с 2026-06-27 (git-pr.md § Auto-merge policy) — сам по себе НЕ блокер.
### Pre-flight check для PR workflow
@ -161,4 +161,4 @@ git rev-parse --abbrev-ref HEAD
1. `git stash` или backup commit'ов
2. Создать ветку `git checkout -b feat/foo`
3. Перенести коммиты
4. `git push -u origin feat/foo` + `gh pr create`
4. `git push -u forgejo feat/foo` + `mcp__forgejo__create_pull_request` (gh CLI bypassed 2026-05-16)

View file

@ -2,7 +2,7 @@
name: database-expert
description: Database expert for GenDesign — PostgreSQL 16 + PostGIS 3.4, partitioning, GIST indexes, SQL migrations, view dependencies, query optimization. Use proactively for any work in `data/sql/NN_*.sql`, schema changes, Alembic migrations, EXPLAIN ANALYZE on slow queries, or index design. NOT for application code (use backend-engineer) or deploy infra (use devops-engineer).
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes, mcp__postgres-gendesign__analyze_workload_indexes, mcp__postgres-gendesign__analyze_db_health, mcp__postgres-gendesign__get_top_queries
model: opus
model: sonnet
color: purple
---
@ -26,7 +26,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
- PostgreSQL 16 + PostGIS 3.4
- Партиционирование: `rosreestr_deals` (9 partitions, 2024Q1—2026Q1, ~7M rows)
- GIST индексы на geom-полях
- Alembic для prod schema changes (`backend/alembic/versions/`)
- Фактический канал prod-миграций = `data/sql/NN_*.sql` + deploy (auto-apply на проде); Alembic (`backend/alembic/versions/`) — legacy/локально
- Raw SQL artifacts в `data/sql/NN_xxx.sql` для больших миграций / views / bootstrap
- psycopg v3 на стороне backend (НЕ psycopg2)
@ -70,7 +70,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
5. **Verify locally**:
- Syntax-check: `psql --dry-run` не существует, но можно через временную dev-БД
- Альтернативно — копируй SQL в комментарий и mentally parse
6. **Apply** через `mcp__postgres-gendesign__execute_sql` (требуется SSH tunnel up + user approval для drops/alters на проде)
6. **Apply**: prod schema changes ТОЛЬКО через `data/sql/NN_*.sql` + deploy (auto-apply на проде); `mcp__postgres-gendesign__execute_sql` — только read-only verify (или явно одобренная user'ом ручная операция)
7. **Verify post-apply**:
- `information_schema.columns` для column changes
- `pg_indexes` / `pg_views` для indexes/views (если разрешено читать pg_*)
@ -92,5 +92,5 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
- ❌ Изменять Alembic version files задним числом — только новые revisions
- ❌ Запускать миграцию на проде без backup confirmation (если она destructive)
- ❌ Использовать `psycopg2` в коде (только v3)
- ❌ Commit'ить сам — пиши commit message в чат
- ❌ Commit'ить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
- ❌ Писать knowledge в `memory/memory-gendesign.jsonl` (deprecated) — только Obsidian vault

View file

@ -118,7 +118,7 @@ Short skeleton:
## Forgejo API conventions
- `$FORGEJO_URL` = `https://git.gendsgn.ru`, `$FORGEJO_TOKEN` = в env (из `~/.claude/settings.json`)
- `$FORGEJO_URL` = `https://git.gendsgn.ru`, токен — `FORGEJO_ACCESS_TOKEN` / `FORGEJO_TOKEN_<ROLE>` из Windows User-scope env vars (выставляются ДО запуска claude; см. `_autonomous_pickup.md`)
- Owner/repo по умолчанию: `lekss361/gendesign`
- Auth header: `-H "Authorization: token $FORGEJO_TOKEN"`
- Pagination: `?page=1&limit=50` (max 50 на странице)

View file

@ -89,7 +89,7 @@
## Auto-merge policy (PR review mode)
При review открытых Forgejo PR — если verdict **✅ APPROVE** (нет 🔴/🟠/🟡):
**мержи сам, любой scope** (user override 2026-05-16: «после аппрув в ревью можешь мерджить все что угодно»).
**мержи сам, любой scope** (политика 2026-06-27, git-pr.md § Auto-merge policy).
### Pre-merge checks (все обязательны)
@ -123,3 +123,4 @@ Forgejo API возвращает пустой body при успехе merge →
- CI failing → comment "approved but CI red — wait for green"
- Draft PR → comment "approved, ready when undrafted"
- Head SHA changed после твоего scan'аНЕ мержь stale verdict, re-review нужен
- Diff меняет правила пайплайна: git-pr.md § Auto-merge policy, CLAUDE.md Critical rules, `_autonomous_pickup.md`, `auto-code-reviewer.md`, любой `work-as-*.md`НЕ merge, label `needs-human` (self-extending guard)

View file

@ -1,8 +1,8 @@
---
name: devops-engineer
description: DevOps engineer for GenDesign — Docker, docker-compose, Caddyfile, GitHub Actions workflows, SSH deploy, CouchDB stack, Obsidian LiveSync infra. Use proactively for any work in `docker-compose*.yml`, `Caddyfile`, `.github/workflows/`, `scripts/setup-*.sh`, `ops/`, or SSH-deploy issues. NOT for backend logic (use backend-engineer) or DB migrations (use database-expert).
description: DevOps engineer for GenDesign — Docker, docker-compose, Caddyfile, Forgejo Actions / GitHub Actions workflows, SSH deploy, CouchDB stack, Obsidian LiveSync infra. Use proactively for any work in `docker-compose*.yml`, `Caddyfile`, `.forgejo/workflows/**`, `.github/workflows/**`, `scripts/setup-*.sh`, `ops/`, or SSH-deploy issues. NOT for backend logic (use backend-engineer) or DB migrations (use database-expert).
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__obsidian__obsidian_patch_content
model: opus
model: sonnet
color: orange
---
@ -24,7 +24,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
- Beget VPS, Москва, 2 vCPU / 4 GB / 40 GB NVMe
- Docker + docker-compose (два стека: main + obsidian)
- Caddy 2 (auto-TLS Let's Encrypt)
- GitHub Actions (build → GHCR → SSH → compose up)
- Forgejo Actions — фактический CI/deploy (`.forgejo/workflows/**`; build → GHCR → SSH → compose up); legacy GitHub Actions в `.github/workflows/**`
- Docker images: backend (lean), worker (with-chromium), frontend, couchdb (вешний)
- Shared external network `gendesign_shared` для связи main↔obsidian стеков
@ -60,7 +60,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
## Запреты
- ❌ Не коммить сам — пиши commit message в чат
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
- ❌ Не push в main с `--force` (никогда)
- ❌ Не редактировать `backend/`/`frontend/` исходники (только infra-конфиги)
- ❌ Не выполнять prod SSH без явного approval пользователя (читать prod-логи — отдельно safety guard, нужен approval)

View file

@ -2,7 +2,7 @@
name: frontend-engineer
description: Frontend engineer for GenDesign — Next.js 15 app router, React 19, TypeScript 5 strict, Tailwind 4, TanStack Query. Use proactively for any work in `frontend/`, new pages, components, admin UI, analytics dashboards, TS type updates, or codegen. NOT for backend API contracts (use backend-engineer first).
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__obsidian__obsidian_patch_content
model: opus
model: sonnet
color: green
---
@ -62,7 +62,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
## Запреты
- ❌ Не коммить сам — пиши commit message в чат
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
- ❌ Не редактировать `backend/` — это работа backend-engineer
- ❌ Не использовать `any`
- ❌ Не использовать pages router (`src/pages/`) — только app router (`src/app/`)

View file

@ -2,7 +2,7 @@
name: qa-tester
description: "QA tester for GenDesign — runs post-deploy verification после успешного merge+deploy. Use proactively СРАЗУ после того как deploy.yml завершился success на main. Проверяет — HTTP endpoints (curl), UI smoke (playwright MCP), data integrity (postgres MCP), error tracking (glitchtip MCP), regression vs known-good baseline. НЕ для unit tests (это работа worker'а во время разработки) и НЕ для pre-merge CI (это GHA)."
tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__playwright__browser_navigate, mcp__playwright__browser_navigate_back, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_click, mcp__playwright__browser_fill_form, mcp__playwright__browser_type, mcp__playwright__browser_press_key, mcp__playwright__browser_wait_for, mcp__playwright__browser_close, mcp__playwright__browser_evaluate, mcp__glitchtip__glitchtip_issues, mcp__glitchtip__glitchtip_latest_event
model: opus
model: sonnet
memory: project
color: cyan
---
@ -50,7 +50,7 @@ POST-комменты — `--data-binary @file` (не inline `-d`: Windows ср
→ определи changed paths:
- `backend/app/api/v1/<X>.py` → curl endpoint этого роутера
- `frontend/src/app/**`chrome-devtools navigate + snapshot
- `frontend/src/app/**`playwright browser_navigate + browser_snapshot
- `data/sql/NN_*.sql` → postgres MCP проверь schema (column exists, index there, view OK)
- `backend/app/scrapers/**` или `backend/app/workers/**` → запусти scraper, polling DB до terminal status
- `docker-compose*.yml` / `Caddyfile` → smoke production URLs + проверь containers running

View file

@ -2,7 +2,7 @@
name: tech-analyst
description: 'Tech analyst / planner для GenDesign. Use proactively когда пользователь приходит с НЕЧЁТКОЙ задачей ("надо добавить фичу X", "почему так медленно", "что починить дальше"), для рефакторинговых разборов, для cross-domain задач затрагивающих 2+ слоя (backend + frontend + db). Read-only — НЕ пишет код. Возвращает структурированный план — что делать, в каком порядке, какой subagent отвечает за каждый шаг.'
tools: Read, Glob, Grep, WebSearch, WebFetch, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_get_recent_changes, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes, mcp__postgres-gendesign__analyze_db_health, mcp__postgres-gendesign__get_top_queries, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details, mcp__postgres-tradein__list_schemas, mcp__postgres-tradein__explain_query, Bash
model: opus
model: sonnet
color: yellow
---

View file

@ -14,7 +14,7 @@
"postgres-gendesign": {
"type": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"],
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
},
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint=http://localhost:9222"], "alwaysLoad": true },

View file

@ -15,7 +15,7 @@
"postgres-gendesign": {
"type": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"],
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
}
}

View file

@ -0,0 +1,48 @@
# Delegation & task sizing
> Без `paths:` — загружается в каждой сессии. Канон лимитов делегирования (портировано из memory-фидбека 2026-06-27, routing/effort — из верифицированного ресёрча 2026-07-02).
## Routing (кому отдавать)
| Задача | Агент |
|---|---|
| Разведка: найти файлы / понять структуру | **Explore** (Haiku, read-only, дёшев) — НЕ general-purpose |
| Спроектировать подход | **Plan** |
| Код | доменный worker (backend/frontend/database/devops) + `isolation: "worktree"` |
| Проверить результат | отдельный агент **в fresh context** — видит только diff и критерии, без bias автора |
- Продолжение работы существующего агента → `SendMessage` по agentId (контекст цел), НЕ новый спавн с пересказом.
- Масштаб пачки: 1-3 независимых → параллельные Agent-вызовы одним сообщением; конвейер / 10+ агентов → Workflow (pipeline-default, `schema`-выход, adversarial verify находок). Caps на выборках — логируй отброшенное, молчаливое усечение читается как «покрыто всё».
## Бюджет одного сабагента (hard limits)
- **1 сабагент = 1 узкий deliverable.** Ориентиры: ~≤10 мин работы, ~≤20 tool-calls, ~≤150k токенов.
Эмпирика 2026-06-27: агент-аудитор на 186k tok / 33 calls упал на StructuredOutput; 5 мелких параллельных прошли.
- Поверхность больше бюджета → **дели на N узких сабагентов** (parallel при непересекающихся файлах, sequential при зависимостях). НЕ один большой.
- Промпт сабагенту: конкретный deliverable + формат ответа + границы («что НЕ делать»). Расплывчатый scope = дубли и мусор.
- Windows: очень длинный промпт субагенту может упасть на лимите командной строки (~8191 символ) — ещё один довод за компактность.
## Эскалация oversized-задачи (worker)
Issue/задача выглядит больше одного захода (эвристика: >5 файлов, ИЛИ >500 строк diff, ИЛИ >2ч) → **НЕ исполнять целиком**:
- bot-pipeline: комментарий с планом сплита + label `status/needs-analysis`, снять claim
- interactive: вернуть main-сессии план сплита вместо результата
## Единые пороги дробления (analyst / main)
- Estimate S(<2h) / M(2-8h) / **L(>8h) → обязан дробиться дальше** (до S/M)
- Issue ≥1.5 дня → 3-4 sub-PR (Foundation → Schema → Workers → Integration), каждый ~200-500 строк — см. git-pr.md § Split big issues
- 1 sub-issue ≈ 1-2 worker-захода, single-scope (не смешивать backend+frontend в одном issue)
## Параллелизм
Default = parallel на непересекающихся файлах (per-task worktree). Sequential — только overlap / hot-files / зависимый стек (git-pr.md § Parallel vs sequential PRs).
**Усилие ∝ сложности**: простой факт-запрос = 1 агент / 3-10 tool-calls; сложный разбор = N узких параллельных. Ширина пачки дешева, толщина одного агента — дорога и хрупка. Параллельные сессии/окна: практический потолок 3-5 (bottleneck — review, не Claude).
## Effort / model per agent
- Наследовать по умолчанию; модель НЕ переопределять без нужды (Explore и так на Haiku).
- `effort: low/medium` — механика: точечные правки по списку, сбор данных, mass-grep, формат-конверсии.
- `effort: high+` — только verify/judge-этапы, архитектурный синтез, решения.
- Циклы/поллинг: prompt-cache живёт 5 мин — тик либо <~4.5 мин (кэш тёплый), либо сразу 20-30 мин; интервалы 5-15 мин = worst case (полный re-read контекста каждый тик без амортизации).

View file

@ -16,11 +16,11 @@ paths:
```
1. claude --bg --name "feat-<scope>" "task description"
→ supervisor создаёт worktree от forgejo/main автоматически
(settings worktree.baseRef=default, bgIsolation=git)
(settings worktree.baseRef=fresh, bgIsolation=worktree)
2. Session работает: код → commit → push → mcp__forgejo__create_pull_request
3. PR URL появляется в agent view как row с зелёной/жёлтой/красной ● status dot
4. User делает review через external Claude window (post-push)
5. После merge — `Ctrl+X dwa раза` в agent view = удаление session + worktree
5. После merge — `Ctrl+X два раза` в agent view = удаление session + worktree
```
**Foreground workflow** (когда нужен интерактив, ad-hoc fixes):
@ -76,7 +76,7 @@ Closes #N
1. `mcp__forgejo__get_pull_request` (или `curl -sH "$H" "$REPO/pulls/<N>"`) → читай `state`, `mergeable`, `head.sha`
2. `state == merged` → stop polling
3. Новый review/comment: `mcp__forgejo__list_pr_reviews` / `list_issue_comments`. Парсь marker `<!-- gendesign-review-bot: sha=<sha7> verdict=<approve|changes> -->`
3. Новый review/comment: `mcp__forgejo__list_pull_reviews` / `list_issue_comments`. Парсь marker `<!-- gendesign-review-bot: sha=<sha7> verdict=<approve|changes> -->`
- **SHA guard**: `marker.sha7 == head.sha[:7]` — иначе устаревший approval до fixup-push, игнорируй
- `verdict=approve` + SHA match → `mcp__forgejo__merge_pull_request` (squash + delete branch)
- `verdict=changes` → fixup commits + push в `forgejo feat/<scope>` + re-poll
@ -91,16 +91,20 @@ Closes #N
- Diff содержит литеральный secret/token/password/credential (40-char hex, API keys, JWT, и т.д.) — security tripwire.
- PR меняет правила самого пайплайна: блок `## Auto-merge policy` здесь, `Critical rules` в CLAUDE.md, `_autonomous_pickup.md` (claim/kill-switch/merge-FSM), `auto-code-reviewer.md` или любой `work-as-*.md`**self-extending guard** (расширение/снятие собственных merge-прав всегда через human, предотвращает bot-loop).
## Sequential PRs
## Parallel vs sequential PRs
**Одна задача → один PR → merge → следующая.** Параллельно ТОЛЬКО если scope'ы строго orthogonal (разные файлы).
**Default = параллельно**, если scope'ы НЕ пересекаются по файлам: каждая задача в своём worktree (`git worktree add` / isolation:"worktree"), своя ветка, свой PR.
Опасные файлы для конфликтов: `backend/app/api/v1/parcels.py`, `frontend/src/types/site-finder.ts`, OverviewTab/LandTab/MarketTab.
**Sequential обязателен когда:**
- Пересечение файлов / hot-files: `backend/app/api/v1/parcels.py`, `frontend/src/types/site-finder.ts`, OverviewTab/LandTab/MarketTab
- Зависимый стек sub-PR'ов (Foundation → Schema → Workers → Integration): PR N+1 только после merge PR N
## Split big issues
Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Integration**. Каждый ~200-500 lines. PR N+1 только после merge PR N.
Бюджет одного subagent-захода и правила эскалации oversized-задач: `.claude/rules/delegation.md`.
## Review workflow (no conflict)
- **Pre-push** (локально): spawn `code-reviewer` subagent на staged changes → lint pass (security, correctness, conventions). Блокирует push при 🔴 критикал.
@ -112,12 +116,12 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
- **Параллелизм**: 3-5 sessions одновременно (>5 = bottleneck на review, не на Claude per Anthropic метрика)
- **Pin (`Ctrl+T`)** для long-running sessions (scraper monitors, deploy watchers) — supervisor не убьёт через 1h idle
- **Cleanup**: `Ctrl+X dwa раза` после merge → session + worktree удалены атомарно
- **NEVER** parallel sessions на one file — каждая ест в own worktree, last-merge wins (используй sequential PR rule выше)
- **Cleanup**: `Ctrl+X два раза` после merge → session + worktree удалены атомарно
- **NEVER** parallel sessions на one file — каждая ест в own worktree, last-merge wins (см. § Parallel vs sequential PRs)
**Manual `git worktree add` deprecated** — используй `claude --bg --name X`, supervisor сам isolation делает с `baseRef: default` (свежая ветка от main, не от твоей stale-сессии).
**Manual `git worktree add` deprecated** — используй `claude --bg --name X`, supervisor сам isolation делает с `baseRef: fresh` (свежая ветка от main, не от твоей stale-сессии).
**Worktree cleanup** (cron weekly): `scripts/cleanup-merged-worktrees.sh` — удаляет worktrees для merged branches. Запускать вручную или wee­k­ly cron.
**Worktree cleanup** (cron weekly): `scripts/cleanup-merged-worktrees.sh` — удаляет worktrees для merged branches. Запускать вручную или weekly cron.
## Запреты
@ -126,4 +130,4 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
- ❌ `gh pr *` — bypassed 2026-05-16, используй Forgejo MCP или curl + `$FORGEJO_TOKEN`
- ❌ `--no-verify` / `--amend` / `--no-edit` / `--force` без явного approval
- ❌ `@claude` в PR comments — plain text only (`feedback_no_claude_mentions`)
- ❌ Параллельные PR на одни файлы (`feedback_sequential_prs`)
- ❌ Параллельные PR на одни файлы / hot-files (см. § Parallel vs sequential PRs)

144
.claude/settings.json Normal file
View file

@ -0,0 +1,144 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"mcp__obsidian__obsidian_list_files_in_vault",
"mcp__obsidian__obsidian_simple_search",
"mcp__obsidian__obsidian_get_file_contents",
"mcp__obsidian__obsidian_list_files_in_dir",
"mcp__postgres-gendesign__get_object_details",
"mcp__postgres-gendesign__explain_query",
"mcp__postgres-gendesign__list_objects",
"mcp__postgres-gendesign__list_schemas",
"mcp__postgres-gendesign__analyze_query_indexes",
"Bash(curl *)",
"Bash(powershell *)",
"Bash(gh pr comment:*)",
"Bash(powershell.exe:*)",
"Bash(powershell:*)",
"Bash(pwsh:*)",
"mcp__forgejo",
"mcp__forgejo__create_issue",
"mcp__forgejo__update_issue",
"mcp__forgejo__add_issue_labels",
"mcp__forgejo__remove_issue_labels",
"mcp__forgejo__issue_state_change",
"mcp__forgejo__create_issue_comment",
"mcp__forgejo__create_pull_request",
"mcp__forgejo__merge_pull_request",
"mcp__forgejo__create_pull_review",
"mcp__forgejo__get_pull_request_diff",
"mcp__forgejo__list_repo_issues",
"mcp__forgejo__list_repo_pull_requests",
"mcp__forgejo__get_pull_request_by_index",
"mcp__forgejo__list_pull_request_files",
"mcp__forgejo__list_pull_reviews",
"mcp__forgejo__list_repo_labels",
"mcp__forgejo__get_issue_by_index",
"mcp__forgejo__list_issue_comments",
"Write",
"Edit",
"Read",
"Glob",
"Grep",
"LS",
"Task",
"TodoWrite",
"EnterWorktree",
"WebFetch",
"WebSearch",
"NotebookEdit",
"mcp__obsidian",
"mcp__postgres-gendesign",
"mcp__playwright",
"mcp__a11y",
"mcp__lighthouse",
"mcp__shadcn",
"mcp__glitchtip",
"mcp__context7",
"mcp__fetch",
"Bash(*)"
],
"deny": [
"Bash(rm -rf *)",
"Bash(rm -rf /*)",
"Bash(git push --force *)",
"Bash(git push -f *)",
"Bash(git reset --hard *)",
"Bash(git commit --amend *)",
"Bash(git rebase --interactive *)",
"Bash(git commit --no-verify*)",
"Bash(git push --no-verify*)",
"Bash(git push --force-with-lease*)",
"Bash(git push --force-if-includes*)",
"Bash(docker compose down -v *)",
"Bash(docker volume rm *)",
"Read(./.env)",
"Read(./.env.*)",
"Read(./backend/.env)",
"Read(./backend/.env.*)",
"Read(**/.env)",
"Read(**/.env.*)"
],
"defaultMode": "auto"
},
"enabledMcpjsonServers": [
"obsidian",
"context7",
"fetch",
"forgejo",
"a11y",
"lighthouse",
"shadcn"
],
"worktree": {
"baseRef": "fresh",
"bgIsolation": "worktree"
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-secret-read.py"
},
{
"type": "command",
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-dangerous-commands.py"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-no-print.py"
},
{
"type": "command",
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-sql-pitfalls.py"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/session-preflight.py"
}
]
}
]
},
"statusLine": {
"type": "command",
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/statusline.py"
}
}

View file

@ -0,0 +1,135 @@
name: CI Trade-In
# Forgejo Actions pre-merge gate for the SUBPROJECT tradein-mvp/.
# WHY THIS FILE EXISTS (#2208): основной .forgejo/workflows/ci.yml гейтит
# ТОЛЬКО backend/** + frontend/** главного стека — tradein-PR проходили на
# пусто-зелёных чеках (paths-filter no-op), а pytest tradein жил лишь в
# post-merge deploy-tradein.yml. Итог: сломанный tradein-код мержился в main
# и обнаруживался только на деплое. Этот workflow добавляет РЕАЛЬНЫЙ pre-merge
# gate: tradein-backend pytest + tradein-frontend type-check/lint ДО мержа.
on:
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709,
# см. подробное обоснование в ci.yml). Кратко: раньше push+pull_request на один
# SHA давали разный github.ref → разные concurrency-группы → 2× прогон на
# дефицитных раннерах. В bot-пайплайне каждый коммит идёт через PR, так что
# pull_request гейтит его полностью; push-прогон был чистым дублем.
pull_request:
branches: [main]
concurrency:
# github.ref стабилен на весь PR (refs/pull/<N>/merge) → новый push в ветку PR
# отменяет предыдущий незавершённый прогон ЭТОГО PR вместо накопления.
group: ci-tradein-${{ github.ref }}
cancel-in-progress: true
jobs:
# Paths-filter: гейт бежит ТОЛЬКО когда поменялся tradein-код.
# PR не трогающий tradein-mvp/ → оба job'а no-op'ятся → дёшево.
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'tradein-mvp/backend/**'
- 'tradein-mvp/packages/**'
# workspace root: lock-only bump (uv lock --upgrade) или правка
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
- 'tradein-mvp/uv.lock'
- 'tradein-mvp/pyproject.toml'
- '.forgejo/workflows/ci-tradein.yml'
frontend:
- 'tradein-mvp/frontend/**'
- '.forgejo/workflows/ci-tradein.yml'
backend-tests:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.backend == 'true'
defaults:
run:
working-directory: ./tradein-mvp/backend
env:
# psycopg v3 требует parseable URL на импорте; реального коннекта нет —
# DB-тесты мокаются (mirror deploy-tradein.yml test-job).
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
steps:
- uses: actions/checkout@v4
- name: Install uv
# Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается
# на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI).
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Cache uv packages
# Кросс-прогонный кэш скачанных/собранных wheel'ов (~/.cache/uv).
# continue-on-error чтобы сбой cache-бэкенда раннера НИКОГДА не ронял gate.
# Ключ по workspace-локу tradein-mvp/uv.lock (tracked с воркспейса #2137).
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.cache/uv
key: uv-tradein-${{ runner.os }}-${{ hashFiles('tradein-mvp/uv.lock') }}
restore-keys: |
uv-tradein-${{ runner.os }}-
- name: Sync deps (incl. dev group — pytest)
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
# Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root
# вверх от cwd.
run: uv sync --frozen
- name: Run pytest (tradein-mvp/backend)
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
# leak из другого test-модуля, pre-existing. Второй исторический deselect
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в
# полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан
# совпадать с test-job в deploy-tradein.yml.
run: |
uv run pytest -q \
--deselect "tests/test_search_api.py::test_search_cache_hit"
frontend-checks:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.frontend == 'true'
defaults:
run:
working-directory: ./tradein-mvp/frontend
steps:
- uses: actions/checkout@v4
- name: Set up Node
# Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine).
# npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет
# package-lock.json (Dockerfile ставит через npm install), а cache=npm
# без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint.
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install deps (npm install, no lockfile)
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage):
# --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches;
# --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`):
# в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но
# Dockerfile ставит именно npm install) → `npm ci` упал бы.
run: npm install --legacy-peer-deps --no-audit --no-fund
- name: Type-check (tsc --noEmit)
# Blocking: любая TS-ошибка → job RED.
run: npm run type-check
- name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED.
run: npm run lint

View file

@ -103,6 +103,10 @@ jobs:
filters: |
backend:
- 'tradein-mvp/backend/**'
# scraper-kit вкомпилирован в backend-образ (build context tradein-mvp/,
# scheduler_main импортирует пакет) — kit-only изменение обязано
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
- 'tradein-mvp/packages/scraper-kit/**'
frontend:
- 'tradein-mvp/frontend/**'
browser:
@ -117,11 +121,19 @@ jobs:
- 'tradein-mvp/backend/app/services/scheduler.py'
- 'tradein-mvp/backend/app/scheduler_main.py'
- 'tradein-mvp/backend/app/tasks/**'
# #2188: scheduler исполняет matching/dedup при каждом scrape-тике —
# без этих путей scraper-контейнер оставался на старом коде
# (2026-07-02: fias-dedup доехал до tradein-backend, но не до
# tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть
# scheduler — его правки тоже обязаны пересоздавать контейнер.
- 'tradein-mvp/backend/app/services/matching/**'
- 'tradein-mvp/backend/app/services/house_dedup_merge.py'
- 'tradein-mvp/packages/scraper-kit/**'
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
# Tests use mocks + a stub DATABASE_URL — no real Postgres/Redis needed.
# 2 pre-existing order-dependent tests are deselected (see DESELECT note below).
# 1 pre-existing order-dependent test is deselected (see DESELECT note below).
test:
runs-on: ubuntu-latest
needs: changes
@ -146,20 +158,21 @@ jobs:
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Sync deps (incl. dev group — pytest)
# NB: tradein-mvp/backend/uv.lock is gitignored (see tradein-mvp/.gitignore),
# so --frozen can't work in CI (no lockfile in a fresh checkout). Resolve
# from pyproject.toml like the Dockerfile does (uv sync --no-dev there).
run: uv sync
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
# Dockerfile (uv sync --frozen --no-dev). Актуализировано в #2208.
run: uv sync --frozen
- name: Run pytest (tradein-mvp/backend)
# DESELECT (2026-05): two pre-existing tests fail only in whole-suite
# ordering (pass in isolation) due to global state leak from other test
# modules — not introduced here. Excluded so the gate is reliably green;
# tracked separately. Everything else (1087 tests) must pass.
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
# leak из другого test-модуля, pre-existing. Второй исторический deselect
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном
# прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
# совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
run: |
uv run pytest -q \
--deselect "tests/test_search_api.py::test_search_cache_hit" \
--deselect "tests/test_cian_valuation.py::test_cache_hit_returns_cached"
--deselect "tests/test_search_api.py::test_search_cache_hit"
build-backend:
runs-on: ubuntu-latest
@ -183,7 +196,10 @@ jobs:
- name: Build & push tradein-backend
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/backend
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
# для editable install (#2137). Dockerfile — в backend/.
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
@ -216,10 +232,13 @@ jobs:
context: ./tradein-mvp/frontend
push: true
# basePath=/trade-in baked-in во время build (Next.js)
# NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
# уходит в notFound (404), не индексируется и не краулится.
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_ENABLE_PREVIEW=1
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
@ -276,7 +295,13 @@ jobs:
env:
IMAGE_TAG: latest
GHCR_PAT: ${{ secrets.GHCR_PAT }}
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
# Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT
# recreate the scraper and SIGKILL a running multi-hour job. Only genuine
# scraper-code paths (the `scraper` paths-filter already covers
# app/services/scrapers/**, scrape_pipeline.py, scheduler.py,
# scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch —
# should trigger a scraper recreate. (infra term intentionally dropped.)
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }}
GITHUB_SHA: ${{ github.sha }}
with:
host: ${{ secrets.DEPLOY_HOST }}
@ -314,24 +339,44 @@ jobs:
export IMAGE_TAG="$IMAGE_TAG"
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — recreating tradein-scraper"
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
# нового кода → старые контейнеры продолжают работать на СТАРОМ коде +
# СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый
# код на старой схеме». Откат = просто ничего не поднимали.
# Применяем SQL миграции (если есть backend/data/sql/*.sql)
# (1) Только БД — чтобы прогнать миграции до нового app-кода.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres
# (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep).
echo "→ Ожидание готовности postgres..."
pg_ready=""
for i in $(seq 1 30); do
if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
pg_isready -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
pg_ready="yes"; break
fi
sleep 2
done
if [ -z "$pg_ready" ]; then
echo "ERROR: postgres не стал ready за отведённое время — прерываю деплой."
echo " Новый app-код НЕ поднят; старые контейнеры не тронуты."
exit 1
fi
echo "→ postgres ready."
# (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app.
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
# старте volume. Здесь — для повторных миграций после первого запуска.
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
sleep 5
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
# #2216) glob'ом не подхватывается.
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
# первый deploy после внедрения tracking на уже-наполненной prod-БД
@ -405,16 +450,27 @@ jobs:
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
fi
# Retry backend lifespan hook AFTER migrations applied.
# tradein-backend startup runs ensure_fdw_user_mapping which needs
# FOREIGN SERVER gendesign_remote (created by 060_postgres_fdw_extension.sql).
# Without restart, the first compose-up's startup hook failed with
# "server gendesign_remote does not exist" because migrations hadn't run yet.
# See PR #493 deploy/1156 for the incident details.
echo "→ Restarting tradein-backend so lifespan hook retries USER MAPPING setup"
docker restart tradein-backend
# Give backend time to come up before Caddy reload + health check below
sleep 5
# (4) Теперь — новый app-код: схема уже актуальна.
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps browser backend frontend
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — recreating tradein-scraper"
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с
# "server gendesign_remote does not exist" — FOREIGN SERVER создаёт
# 060_postgres_fdw_extension.sql, ещё не прогнанная на тот момент.
# Требовался рестарт для повторной попытки хука. Теперь backend
# стартует на шаге (4), т.е. ПОСЛЕ применения миграций (шаг 3) →
# lifespan-hook гарантированно видит применённые миграции (FOREIGN
# SERVER gendesign_remote существует) уже с первого старта. Рестарт удалён.
# Caddy reload — основной Caddyfile содержит inline tradein routes
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
@ -423,12 +479,28 @@ jobs:
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile || true
# Health check
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (#2214).
# Раньше цикл после 30 неуспешных попыток молча продолжал скрипт и
# доходил до записи success-маркера → мёртвый backend помечался
# «задеплоено». Теперь: флаг healthy выставляется ТОЛЬКО при HTTP 200
# на /health; после цикла — hard exit 1, если флаг пуст. exit 1
# происходит ДО записи .tradein-deployed-sha (маркер пишется последним,
# ниже) → следующий прогон changes-job возьмёт корректную базу.
# NB set -e: curl стоит в условии `if` (exempt из errexit) — неуспешная
# попытка НЕ фатальна, а лишь провоцирует следующую итерацию цикла.
healthy=""
for i in $(seq 1 30); do
docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://localhost:8000/health && break
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ backend healthy на /health."
# Cleanup старых образов
for repo in ghcr.io/lekss361/gendesign-tradein-backend \

View file

@ -199,6 +199,14 @@ jobs:
# but other callers may invoke the raw path).
chmod +x ops/*.sh 2>/dev/null || true
# Full PDF report bind-source (#2259 PR-D). docker создаёт отсутствующий
# bind-source как root:root — worker пишет PDF под uid 1000 → PermissionError
# → вечный «building». Создаём каталог заранее + chown под контейнерный uid.
# chown под non-root deploy-юзером требует sudo → fallback (|| true — если и
# sudo нет, каталог уже наш и chown не нужен).
mkdir -p reports
chown 1000:1000 reports 2>/dev/null || sudo chown 1000:1000 reports 2>/dev/null || true
# Sentry release tracking
mkdir -p backend
touch backend/.env.runtime

4
.gitignore vendored
View file

@ -87,6 +87,10 @@ sf_anton_snapshot.db-*
data/osrm/*
!data/osrm/.gitkeep
# Full PDF report bind-source (#2259 PR-D) — worker пишет PDF в ./reports:/app/reports.
# Артефакты рантайма, не коммитим (untracked и так выживает при deploy git reset --hard).
/reports/
# Log cruft at repo root
debug.log
.ds-sync/

View file

@ -17,6 +17,11 @@ repos:
- id: check-toml
- id: check-added-large-files
args: ["--maxkb=512"]
# #1966: the frozen backtest regression-gate fixture is gzipped prod
# inputs (~3 MB). It is re-extracted rarely (only when ground-truth
# refreshes), so it does not bloat history per estimator change — the
# per-change artifact is the 3 KB backtest_baseline.json.
exclude: ^tradein-mvp/backend/tests/fixtures/backtest_full_fixture\.json\.gz$
- id: check-merge-conflict
- id: detect-private-key

View file

@ -21,7 +21,7 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
2. **Branch + PR mandatory.** Никаких direct push в main. `feat/...` / `fix/...` / `refactor/...` / `docs/...` / `chore/...` → ветка от `forgejo/main``mcp__forgejo__create_pull_request` → PR URL пользователю. После PR create — сразу `Skill loop` polling. `gh` CLI bypassed (2026-05-16). **Полные правила: `.claude/rules/git-pr.md`**.
3. **Agent-first workflow.** Main session orchestrates ONLY — не пишет код inline. Любая задача >1 файл / >50 строк → subagent. Pre-push: spawn `code-reviewer` subagent на staged changes. Post-push review — внешнее окно Claude (НЕ дублировать).
3. **Agent-first workflow.** Main session orchestrates ONLY — не пишет код inline. Любая задача >1 файл / >50 строк → subagent. Pre-push: spawn `code-reviewer` subagent на staged changes. Post-push review — внешнее окно Claude (НЕ дублировать). Лимиты размера subagent-задачи: `.claude/rules/delegation.md`.
4. **psycopg v3 only.** `import psycopg2` → ModuleNotFoundError. `CAST(:x AS type)` в SQL — никогда `:x::type`. **Полные правила: `.claude/rules/backend.md` + `sql.md`**.
@ -41,14 +41,16 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
| `database-expert` | `data/sql/**.sql`, Alembic, EXPLAIN ANALYZE |
| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**`, `.forgejo/workflows/**` |
| `code-reviewer` | Pre-push lint (security, correctness, conventions) |
| `deep-code-reviewer` | Тщательный review критичных PR (миграции / auth / scrapers) + merge authority при ✅ APPROVE |
| `deep-code-reviewer` | Тщательный review критичных PR (миграции / auth / scrapers) + merge authority при ✅ APPROVE (не эксклюзивно: self-merge разрешён любой сессии с 2026-06-27) |
| `qa-tester` | Post-deploy smoke (playwright / curl / SQL) сразу после merge+deploy — rule #7 |
`auto-*` в `.claude/agents/` — standalone bot-персоны (`/work-as-*`), НЕ для Task-spawn; общий контракт — `_autonomous_pickup.md`.
**Routing:** тривиально (typo, 1-line) → main session. Single-domain clear → worker. Cross-domain / нечётко → `tech-analyst` first. Worker → `code-reviewer` → main commits → push → PR.
## Where to look
- **Path-scoped rules:** `.claude/rules/` (backend, frontend, sql, git-pr, deploy, ui-tokens, ui-conventions, ui-microcopy) — auto-loaded при работе с соответствующими файлами через `paths:` frontmatter
- **Path-scoped rules:** `.claude/rules/` (backend, frontend, sql, git-pr, deploy, tradein, ui-tokens, ui-conventions, ui-microcopy) — auto-loaded при работе с соответствующими файлами через `paths:` frontmatter; `delegation.md` — намеренно БЕЗ `paths:` (грузится в каждой сессии, не добавлять frontmatter)
- **Personal preferences:** `~/.claude/CLAUDE.md` (cross-project: no auto-commit, no Co-Authored-By, no @claude)
- **Workflow memory:** `~/.claude/projects/<repo>/memory/MEMORY.md` index
- **Vault MOCs:** `meta/`, `domains/<area>/<area>-MOC.md`, `decisions/`, `fixes/`, `limitations/`, `code/patterns/`
@ -56,7 +58,7 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
## Don't (cross-domain — specifics → `.claude/rules/`)
- ❌ Direct push в main / merge без approval
- ❌ Direct push в main; merge вне policy (красный CI / tripwires — git-pr.md § Auto-merge policy)
- ❌ `--no-verify` / `--amend` / `git push --force`
- ❌ Knowledge-файлы вне vault (`docs/research/`, `notes/`, `wiki/`)
- ❌ Hardcode credentials в коде / коммитах

View file

@ -16,15 +16,13 @@
# (basic_auth runs before handle), making /health and /preview/* exclusions
# ineffective. With route { }, handlers are matched top-to-bottom as written.
{
servers {
# ВКЛЮЧАЕТ raw Authorization/Cookie headers в access log
# (по default Caddy 2.x редактирует их как "REDACTED").
# Plain password будет в gendsgn.ru.log + auth_audit.log — оба root-only на VPS.
# См. forwarder.py _extract_attempted_username для использования.
log_credentials
}
}
# #2213: global `log_credentials` УБРАН. Он заставлял Caddy писать raw
# Authorization header (Base64 `user:password`, тривиально обратимый) в ОБА лог-файла
# (gendsgn.ru.log + auth_audit.log). Даже при root-only доступе и коротком retention
# это plaintext-пароли пилотов на диске — недопустимый риск против скромной выгоды.
# Trade-off: glitchtip-auth-forwarder больше не извлекает `attempted_username` из 401
# (тег деградирует в "(none)" — forwarder это уже обрабатывает gracefully, не падает).
# Событие basic_auth 401 (remote_ip / uri / method) по-прежнему уходит в GlitchTip.
gendsgn.ru {
encode zstd gzip
@ -88,9 +86,23 @@ gendsgn.ru {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
# gendsgn.ru/sale-share — короткий адрес standalone-продукта «Поиск домов».
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
@saleshare path /sale-share /sale-share/
handle @saleshare {
redir /trade-in/sale-share permanent
}
# Matcher `path /trade-in /trade-in/*` ловит И /trade-in (без слеша),
# И /trade-in/ + /trade-in/anything. Без обоих случаев `handle /trade-in/*`
# пропускал /trade-in без слеша → попадал в общий frontend → пустой ответ.
@ -99,6 +111,9 @@ gendsgn.ru {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
header_up X-Authenticated-User {http.auth.user.id}
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}

View file

@ -9,6 +9,13 @@ POST /api/v1/admin/etl/objective-backfill
POST /api/v1/admin/etl/nspd-denorm-backfill
Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps.
GET /api/v1/admin/etl/mapping-review
Список авто-маппингов objective_complex_mapping + обогащение для ревью.
POST /api/v1/admin/etl/mapping-review/{id}/approve
Подтвердить маппинг (is_reviewed=true + note append).
POST /api/v1/admin/etl/mapping-review/{id}/reject
Отклонить маппинг DELETE строки (иначе травит mv_layout_velocity §4.2).
Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
header removed 2026-05-23 двойная auth избыточна для pilot.
"""
@ -16,17 +23,24 @@ header removed 2026-05-23 — двойная auth избыточна для pilo
from __future__ import annotations
import logging
from typing import Annotated
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.services.etl.mapping_review import (
approve_mapping,
list_mapping_review,
reject_mapping,
)
from app.services.etl.objective_backfill import (
AUTO_ACCEPT_THRESHOLD,
AUTO_ACCEPT_THRESHOLD_V2,
REVIEW_THRESHOLD,
apply_core_matches,
auto_apply_matches,
find_core_matches,
find_match_candidates,
trigger_mv_refresh,
)
@ -104,6 +118,37 @@ def run_objective_backfill(
return result
@router.post("/objective-core-backfill")
def run_objective_core_backfill(
db: Annotated[Session, Depends(get_db)],
dry_run: Annotated[bool, Query(description="Preview без insertions (default True)")] = True,
refresh_mv: Annotated[bool, Query(description="REFRESH mv_layout_velocity после apply")] = True,
) -> dict[str, object]:
"""Core-name + dev-confirm проход (#2177 шаг 2, match_method='auto_core_dev_v5').
Нормализует ЯДРО имени обеих сторон (normalize_complex_name, #2198) и мапит
несопоставленные domrf ЕКБ-объекты на objective-проекты. Пишутся ТОЛЬКО tier_a
(ровно 1 кандидат + подтверждение застройщика). tier_b/ambiguous в counts.
Дефолт dry_run=True (безопасно). Реальный insert ?dry_run=false.
Returns dict:
inserted / conflict_skipped / error_skipped / tier_a_total из apply;
tier_b / ambiguous / skipped_taken счётчики отчёта;
mv_rows_after_refresh строк в MV после REFRESH (0 если dry-run / нет вставок).
"""
report = find_core_matches(db)
result: dict[str, object] = dict(apply_core_matches(db, report, dry_run=dry_run))
result.update(report.counts())
mv_rows = 0
if refresh_mv and not dry_run and result.get("inserted", 0):
mv_rows = trigger_mv_refresh(db)
logger.info("mv_layout_velocity refreshed after core-backfill: %d rows", mv_rows)
result["mv_rows_after_refresh"] = mv_rows
return result
@router.post("/nspd-denorm-backfill")
def run_nspd_denorm_backfill(
limit: Annotated[
@ -124,3 +169,68 @@ def run_nspd_denorm_backfill(
task = backfill_all_dumps.apply_async(kwargs={"limit": limit})
logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit)
return {"task_id": task.id, "status": "enqueued", "limit": limit}
# ════════════════════════════════════════════════════════════════════════════
# Mapping review — глазами подтвердить/отклонить авто-маппинги (feat/…-review)
# ════════════════════════════════════════════════════════════════════════════
@router.get("/mapping-review")
def get_mapping_review(
db: Annotated[Session, Depends(get_db)],
only_unreviewed: Annotated[
bool, Query(description="Только is_reviewed=false (авто-матчи без ревью)")
] = True,
limit: Annotated[int, Query(ge=1, le=500, description="Строк на странице")] = 100,
offset: Annotated[int, Query(ge=0, description="Смещение страницы")] = 0,
) -> dict[str, Any]:
"""Список строк objective_complex_mapping + обогащение для ревьюера.
Обогащение каждой строки:
- domrf_comm_name / domrf_dev_name latest snapshot по domrf_obj_id;
- objective_developers застройщики objective-проекта (агрегат
objective_lots.developer, scoped по project_name).
Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху).
Returns dict:
rows: list обогащённых строк (id, objective_complex_name,
objective_project_id, objective_group, domrf_obj_id, match_method,
match_score, is_reviewed, note, created_at, domrf_comm_name,
domrf_dev_name, objective_developers);
total: всего строк (с учётом only_unreviewed, БЕЗ limit/offset);
limit / offset / only_unreviewed: эхо параметров.
"""
return list_mapping_review(db, only_unreviewed=only_unreviewed, limit=limit, offset=offset)
@router.post("/mapping-review/{mapping_id}/approve")
def approve_mapping_review(
mapping_id: int,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Подтвердить маппинг: is_reviewed=true + append «approved <date>» в note.
404 если строки с таким id нет.
"""
result = approve_mapping(db, mapping_id)
if result is None:
raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден")
return result
@router.post("/mapping-review/{mapping_id}/reject")
def reject_mapping_review(
mapping_id: int,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2).
Удаляемая строка целиком логируется (logger.info) перед commit в note
писать некуда, строки не будет. 404 если id не найден.
"""
deleted = reject_mapping(db, mapping_id)
if deleted is None:
raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден")
return {"status": "deleted", "deleted": deleted}

View file

@ -684,12 +684,18 @@ def objective_coverage(
"""
from app.services.objective_etl import get_sqlite_info
# #1964: `lots` = СЫРОЙ COUNT(*) — намеренно. Этот счётчик сравнивается с
# SQLite-источником Антона (ETL-fidelity: «свежее ли SQLite, чем БД»), а SQLite
# хранит сырые строки → дедуп-view сломал бы сравнение. Рядом отдаём
# `lots_physflat` (v_objective_lots_latest — по физлоту) для прозрачности
# инфляции (~2.91×: сырых ~1.75M vs физлотов ~603k).
pg_row = (
db.execute(
text(
"""
SELECT
(SELECT COUNT(*) FROM objective_lots) AS lots,
(SELECT COUNT(*) FROM v_objective_lots_latest) AS lots_physflat,
(SELECT COUNT(*) FROM objective_corpus_room_month) AS crm,
(SELECT COUNT(*) FROM objective_complex_mapping) AS mappings,
(SELECT MAX(snapshot_date) FROM objective_lots) AS last_lot_snapshot,
@ -706,6 +712,7 @@ def objective_coverage(
return {
"pg": {
"lots": int(pg_row["lots"] or 0),
"lots_physflat": int(pg_row["lots_physflat"] or 0),
"corp_room_month": int(pg_row["crm"] or 0),
"mappings": int(pg_row["mappings"] or 0),
"last_lot_snapshot": pg_row["last_lot_snapshot"].isoformat()

View file

@ -8,9 +8,19 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.concept import ConceptInput, ConceptOutput
from app.schemas.concept import (
ConceptInput,
ConceptOutput,
HouseTypeCatalog,
HouseTypeCatalogItem,
MassingProgram,
MassingRecomputeOutput,
)
from app.services.generative import geometry
from app.services.generative.catalog import HOUSE_TYPES, available_section_types
from app.services.generative.financial import compute_financial
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
from app.services.generative.teap import synthesize_teap_from_program
logger = logging.getLogger(__name__)
@ -33,6 +43,11 @@ _DISTRICT_SEARCH_RADIUS_M: int = 5000
_PRICE_MIN_RUB: int = 30_000
_PRICE_MAX_RUB: int = 600_000
# Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда
# фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне
# (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median.
_DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median"
# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
@ -57,13 +72,33 @@ _DISTRICT_FOR_POINT_SQL = text(
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
#
# #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти objective_lot_id на
# один физлот через пере-листинги) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE
# были по пере-листингам, вес медианы смещался. Дедупим INLINE через DISTINCT ON
# (physflat-ключ, последний снапшот snapshot_date DESC, id DESC) — НЕ через общий
# physflat-VIEW. Причина (deep-review #1964, прод-EXPLAIN): view не несёт
# district-фильтр внутри, qual не проталкивается ниже DISTINCT ON → view сортирует
# ВСЮ таблицу 1.76M (~5.8 s, request-path внутри analyze_parcel). Inline с district
# В CTE → bitmap по району ~240k строк + sort (~1.7 s), индекс по district работает.
# Сегмент-фильтры (price NOT NULL / диапазон) объективны по физлоту → применяем
# ПОСЛЕ дедупа. Зеркало паттерна market_metrics._STOCK_SQL.
_OBJECTIVE_MEDIAN_SQL = text(
"""
WITH latest AS (
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
price_per_m2_rub
FROM objective_lots
WHERE district = CAST(:dn AS text)
ORDER BY project_name, corpus_name, section, floor, lot_number,
snapshot_date DESC, id DESC
)
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
COUNT(*) AS sample_size
FROM objective_lots
WHERE district = CAST(:dn AS text)
AND price_per_m2_rub IS NOT NULL
FROM latest
WHERE price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
"""
)
@ -163,6 +198,32 @@ def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str
return None, "class_norm"
@router.get("/house-types", response_model=HouseTypeCatalog)
async def list_house_types() -> HouseTypeCatalog:
"""Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера.
Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам
секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт
выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без
side-effects справочник захардкожен в коде (см. модуль ``catalog``). Так
фронт НЕ хардкодит габариты/этажности они приходят отсюда.
"""
return HouseTypeCatalog(
house_types=[
HouseTypeCatalogItem(
section_type=ht.section_type,
label_ru=ht.label_ru,
footprint_w_m=ht.footprint_w_m,
footprint_d_m=ht.footprint_d_m,
footprint_sqm=ht.footprint_sqm,
default_floors=ht.default_floors,
housing_class=ht.housing_class,
)
for ht in HOUSE_TYPES
]
)
@router.post("", response_model=ConceptOutput)
async def create_concept(
payload: ConceptInput,
@ -179,7 +240,26 @@ async def create_concept(
A degenerate parcel (setback consumes everything, malformed geometry) yields a
422 rather than empty variants that is a bad request, not a valid empty result.
Stage 3a (#1965): если задана ``building_program`` (типовые дома из каталога), кладём
РОВНО эту программу вместо жадной max-FAR раскладки (один вариант). Неизвестный
``section_type`` 422 (валидируется тут, до размещения). Если участок не вмещает всю
программу НЕ 422: кладём сколько влезло и отдаём честный ``placed_count`` < requested.
"""
# Stage 3a: валидируем ключи программы по каталогу ДО размещения — неизвестный
# section_type это bad request (422), а не 500 из KeyError в глубине placement.
if payload.building_program:
known = available_section_types()
unknown = sorted({item.section_type for item in payload.building_program} - known)
if unknown:
raise HTTPException(
status_code=422,
detail=(
f"unknown house type(s): {', '.join(unknown)}; "
f"available: {', '.join(sorted(known))}"
),
)
# Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
# стратегий). DB-lookup — синхронный SQLAlchemy → run_in_threadpool, чтобы не
# блокировать event loop. Падение lookup'а не должно ронять генерацию.
@ -187,9 +267,7 @@ async def create_concept(
price_source: str = "class_norm"
try:
wkt_point = await run_in_threadpool(_parcel_centroid_wkt, payload)
market_price, price_source = await run_in_threadpool(
_lookup_market_price, db, wkt_point
)
market_price, price_source = await run_in_threadpool(_lookup_market_price, db, wkt_point)
except ParcelGeometryError:
# Невалидная геометрия — пусть geometry.generate поднимет её ниже (один 422).
pass
@ -210,3 +288,70 @@ async def create_concept(
logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc
return ConceptOutput(variants=variants)
@router.post("/recompute", response_model=MassingRecomputeOutput)
async def recompute_massing(
payload: MassingProgram,
db: Annotated[Session, Depends(get_db)],
) -> MassingRecomputeOutput:
"""Stage 2a (#1965, эпик #1953) — LIVE-пересчёт экономики из 3D-массинга.
Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый
``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность),
мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` те же нормативные
константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial``
пересчитанный ТЭП + финмодель.
Цена продажи жилья:
* ``market_price_per_sqm`` в теле используется как есть (FAST path, БЕЗ БД)
фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. Источник
берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из
financial_estimate objective_geo_radius / objective_district_median /
district_reference / class_norm); если фронт его не прислал дефолтный лейбл.
НЕ хардкодим source иначе honest-флаг в UI/PDF врёт о происхождении цены.
* иначе, если передан ``parcel_centroid_wkt`` ``_lookup_market_price`` по центроиду
через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy
не должен блокировать event loop). Падение lookup'а → норматив класса, не 500.
* иначе (нет ни цены, ни центроида) норматив класса (``price_source="class_norm"``).
"""
# ── Цена продажи: предрезолвленная (fast) → DB-fallback → норматив класса ───────
market_price: float | None = payload.market_price_per_sqm
price_source: str = "class_norm"
if market_price is not None:
# FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke.
# Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если
# его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно
# форсит class_norm, но сюда мы попадаем только при заданной цене.)
price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE
elif payload.parcel_centroid_wkt:
# Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept).
# Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри
# _lookup_market_price, защитный пояс на непредвиденное оставляем тут.
try:
market_price, price_source = await run_in_threadpool(
_lookup_market_price, db, payload.parcel_centroid_wkt
)
except Exception as exc:
logger.warning("recompute market-price: unexpected lookup error, class_norm: %s", exc)
market_price, price_source = None, "class_norm"
teap = synthesize_teap_from_program(
total_footprint_sqm=payload.total_footprint_sqm,
floors=payload.floors,
site_area_sqm=payload.site_area_sqm,
housing_class=payload.housing_class,
sections=payload.sections,
)
# compute_financial — чистая, CPU-bound, лёгкая (без БД) → можно вызывать прямо в
# event loop (hot path; threadpool-overhead тут не нужен). price_source игнорируется
# внутри, когда market_price_per_sqm is None (форсится class_norm) — корректно.
financial = compute_financial(
teap=teap,
housing_class=payload.housing_class,
land_cost_rub=payload.land_cost_rub,
market_price_per_sqm=market_price,
price_source=price_source,
development_type=payload.development_type,
)
return MassingRecomputeOutput(teap=teap, financial=financial)

View file

@ -3,6 +3,8 @@
from __future__ import annotations
import logging
import threading
import time
from datetime import date
from typing import Annotated, Any
@ -18,6 +20,32 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# In-process TTL-кэш результата _query_stats (перф-батч #2, #1953-followup)
# ---------------------------------------------------------------------------
# /landing/stats бьётся на КАЖДЫЙ load хоумпейджа и НЕ кэширован. KPI 3 (доля квартир
# с ценой) делает полный агрегат по physflat-дедупу objective_lots: price_per_m2_rub
# нет в covering-INCLUDE и фильтра нет → ~6.7 s heap-скан 603k физлотов на каждый
# запрос. Данные дневной гранулярности (snapshot_date) → 1 ч staleness приемлем.
#
# Кэшируем ВЕСЬ dict (все 5 KPI), не только KPI 3 — там ещё pg_class deals + 2
# MAX(snapshot_date). Только УСПЕШНЫЙ результат (None/fallback НЕ кэшируем — пустая
# dev-БД / сбой запроса не должны залипать на час). Потокобезопасно (threading.Lock,
# удерживается только на check/store); analyze идёт в threadpool под Uvicorn. Стиль —
# зеркало app.services.weather_cache. time.monotonic устойчив к скачкам системных часов.
_STATS_TTL_S: float = 3600.0 # 1 час — дневная гранулярность данных
# (result_dict, expires_at_monotonic_seconds). Только успешный результат.
_STATS_CACHE: dict[str, Any] | None = None
_STATS_EXPIRES_AT: float = 0.0
_STATS_LOCK = threading.Lock()
def _now() -> float:
"""Монотонный таймер (тесты подменяют этот helper, а не сам `time`)."""
return time.monotonic()
# ---------------------------------------------------------------------------
# Response schema
# ---------------------------------------------------------------------------
@ -69,12 +97,14 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
deals_total = int(row_deals or 0)
# KPI 3: % objective_lots с ценой
# #1964: physflat-дедуп view — доля квартир с ценой по ФИЗИЧЕСКИМ лотам, а не
# по пере-листингам (сырой objective_lots раздут ~2.91× мульти lot_id/физлот).
row_price = db.execute(
text(
"SELECT"
" COUNT(*) FILTER (WHERE price_per_m2_rub IS NOT NULL) * 100.0"
" / NULLIF(COUNT(*), 0)"
" FROM objective_lots"
" FROM v_objective_lots_latest"
)
).scalar()
price_coverage_pct = round(float(row_price or 0.0), 1)
@ -127,6 +157,30 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
return None
def _query_stats_cached(db: Session) -> dict[str, Any] | None:
"""`_query_stats` с in-process TTL-кэшем (TTL=1 ч). См. блок-комментарий вверху.
Кэширует ТОЛЬКО успешный dict; None (сбой/пустая БД) не кэшируется следующий
запрос попробует снова. Lock удерживается лишь на check/store; сам DB-запрос идёт
ВНЕ lock'а (не сериализуем одновременные cold-start, last-write wins — запрос
идемпотентен, TTL длинный).
"""
global _STATS_CACHE, _STATS_EXPIRES_AT
now = _now()
with _STATS_LOCK:
if _STATS_CACHE is not None and _STATS_EXPIRES_AT > now:
return _STATS_CACHE
# MISS / истёк → DB-запрос ВНЕ lock'а.
result = _query_stats(db)
if result is None:
# Не кэшируем fallback — пусть следующий запрос повторит попытку.
return None
with _STATS_LOCK:
_STATS_CACHE = result
_STATS_EXPIRES_AT = _now() + _STATS_TTL_S
return result
@router.get("/landing/stats", response_model=LandingStatsOut)
def landing_stats(
db: Annotated[Session, Depends(get_db)],
@ -142,7 +196,7 @@ def landing_stats(
- paradox: строка-парадокс портфеля для hero CTA
- stale: true если данные недоступны (DB ошибка или пустая БД)
"""
result = _query_stats(db)
result = _query_stats_cached(db)
if result is None:
return LandingStatsOut(**_FALLBACK_DATA)
return LandingStatsOut(**result)

File diff suppressed because it is too large Load diff

View file

@ -114,6 +114,46 @@ class Settings(BaseSettings):
# Empty string = endpoint disabled.
scrape_admin_token: str = ""
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
# captcha — подтверждено): per-region sweep гонит ~1548 объектов × 11
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
#
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch()
# ТОЛЬКО для KN-sweep BrowserSession (другие скраперы — nspd/catalog —
# продолжают использовать модульный дефолт _BROWSER_CONCURRENCY=8 без
# изменений). 2 — эмпирически безопасный потолок против volume-бана.
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
scrape_kn_browser_concurrency: int = 2
# Окно случайной паузы (мс) между запросами KN-sweep. Шире дефолта
# (6001500), чтобы размазать запросы во времени и не триггерить rate-ban.
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
# ширим до 12003000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
scrape_kn_request_jitter_min_ms: int = 1200
scrape_kn_request_jitter_max_ms: int = 3000
# Рычаг 2 — прокси (ОПЦИОНАЛЬНО, default None → прямое подключение, поведение
# без изменений). Когда задан — KN-sweep BrowserSession запускает Chromium
# через этот прокси (формат http://user:pass@host:port; парсится в
# Playwright proxy={server,username,password}). Сильнейший рычаг против
# IP-бана: переиспользует тот же mobile-proxy паттерн, что tradein-стек
# (SCRAPER_PROXY_URL). Заводится ТОЛЬКО через окружение прод-контейнера.
# ENV: SCRAPE_KN_PROXY_URL.
scrape_kn_proxy_url: str | None = None
# Рычаг 3 (ГЛАВНЫЙ unblock #1945) — изоляция flats от extras.
# Эмпирически (prod, 2026-06-27): flats endpoint /portal-kn/api/sales/portal/table
# на concurrency=2 = 0 WAF-бан на 100+ объектах; extras /сервисы/api/object/{id}/*
# отдают 403 СРАЗУ (volume-independent, мертвы с 2026-06-03) И ТРАВЯТ cookies
# сессии → последующие flats на той же сессии тоже 403. flats_count (метрика
# #1945) рухнул 3670→9 именно из-за этого.
#
# True (дефолт, NEW): flats тянутся в ЧИСТОЙ flats-only сессии (extras на ней
# НЕ дёргаются НИКОГДА) → flats_count восстанавливается; extras идут отдельным
# best-effort проходом с recycle сессии на каждый WAF-403 (яд не накапливается
# и НЕ касается flats). False: старое поведение (flats+extras в одной сессии,
# poison-prone). ENV: SCRAPE_KN_EXTRAS_ISOLATED.
scrape_kn_extras_isolated: bool = True
# NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
# Settings оставлены deprecated на случай отката (можно удалить позже).
@ -164,6 +204,13 @@ class Settings(BaseSettings):
# Default rate-limit для nspd_lite fetcher (мс между запросами).
nspd_lite_rate_ms: int = 600
# Каталог хранения собранных PDF-отчётов ПТИЦА (эпик #2259 PR-D). Worker пишет
# PDF сюда, backend читает файл для /report/download. На проде — общий writable
# bind-mount `./reports:/app/reports` у сервисов backend+worker (docker-compose.prod.yml),
# иначе backend не увидит файл, записанный воркером. Для локальной разработки —
# относительный путь ОК (тест мокает render_full_report_pdf, файл не пишется).
reports_dir: str = "/app/reports"
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
# На проде монтируется bind-mount-ом docker-compose:
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
@ -306,5 +353,18 @@ class Settings(BaseSettings):
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
llm_max_retries: int = 2
# ── DaData /clean/address геокод (objective_backfill geo-pass, #2177) ──────
# Токен + секрет для DaData /clean/address (обогащение адреса → geo_lat/geo_lon).
# На gendesign-проде уже заведены в окружении контейнера (проверено 2026-07-03).
# Оба нужны для clean_address (в отличие от suggest, которому хватает токена).
# None/пусто → dadata_client.clean_address graceful-возвращает None (geo-pass
# тогда reject'ит всё «нет геокода», не падает). ENV: DADATA_API_TOKEN /
# DADATA_API_SECRET. Литерала ключа в коде/тестах нет.
dadata_api_token: str | None = None
dadata_api_secret: str | None = None
# Таймаут одного HTTP-вызова к DaData (сек). Воркер geo-pass не должен висеть
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
dadata_timeout_s: float = 8.0
settings = Settings()

View file

@ -0,0 +1,155 @@
# Russian Trusted CA bundle (Минцифры РФ) — публичные сертификаты с gu-st.ru (Госуслуги).
# Нужны для https-доменов с российским УЦ И/ИЛИ неполной серверной цепочкой (#2119).
# Скачаны 2026-07-02: russian_trusted_root_ca_pem.crt + russian_trusted_sub_ca_pem.crt.
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIHQjCCBSqgAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAyMTEyNTE5WhcNMjcwMzA2MTEyNTE5WjBvMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQgU3Vi
IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9YPqBKOk19NFymrE
wehzrhBEgT2atLezpduB24mQ7CiOa/HVpFCDRZzdxqlh8drku408/tTmWzlNH/br
HuQhZ/miWKOf35lpKzjyBd6TPM23uAfJvEOQ2/dnKGGJbsUo1/udKSvxQwVHpVv3
S80OlluKfhWPDEXQpgyFqIzPoxIQTLZ0deirZwMVHarZ5u8HqHetRuAtmO2ZDGQn
vVOJYAjls+Hiueq7Lj7Oce7CQsTwVZeP+XQx28PAaEZ3y6sQEt6rL06ddpSdoTMp
BnCqTbxW+eWMyjkIn6t9GBtUV45yB1EkHNnj2Ex4GwCiN9T84QQjKSr+8f0psGrZ
vPbCbQAwNFJjisLixnjlGPLKa5vOmNwIh/LAyUW5DjpkCx004LPDuqPpFsKXNKpa
L2Dm6uc0x4Jo5m+gUTVORB6hOSzWnWDj2GWfomLzzyjG81DRGFBpco/O93zecsIN
3SL2Ysjpq1zdoS01CMYxie//9zWvYwzI25/OZigtnpCIrcd2j1Y6dMUFQAzAtHE+
qsXflSL8HIS+IJEFIQobLlYhHkoE3avgNx5jlu+OLYe0dF0Ykx1PGNjbwqvTX37R
Cn32NMjlotW2QcGEZhDKj+3urZizp5xdTPZitA+aEjZM/Ni71VOdiOP0igbw6asZ
2fxdozZ1TnSSYNYvNATwthNmZysCAwEAAaOCAeUwggHhMBIGA1UdEwEB/wQIMAYB
Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTR4XENCy2BTm6KSo9MI7NM
XqtpCzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzCBxwYIKwYBBQUH
AQEEgbowgbcwOwYIKwYBBQUHMAKGL2h0dHA6Ly9yb3N0ZWxlY29tLnJ1L2NkcC9y
b290Y2Ffc3NsX3JzYTIwMjIuY3J0MDsGCCsGAQUFBzAChi9odHRwOi8vY29tcGFu
eS5ydC5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNydDA7BggrBgEFBQcwAoYv
aHR0cDovL3JlZXN0ci1wa2kucnUvY2RwL3Jvb3RjYV9zc2xfcnNhMjAyMi5jcnQw
gbAGA1UdHwSBqDCBpTA1oDOgMYYvaHR0cDovL3Jvc3RlbGVjb20ucnUvY2RwL3Jv
b3RjYV9zc2xfcnNhMjAyMi5jcmwwNaAzoDGGL2h0dHA6Ly9jb21wYW55LnJ0LnJ1
L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMDWgM6Axhi9odHRwOi8vcmVlc3Ry
LXBraS5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG9w0BAQsF
AAOCAgEARBVzZls79AdiSCpar15dA5Hr/rrT4WbrOfzlpI+xrLeRPrUG6eUWIW4v
Sui1yx3iqGLCjPcKb+HOTwoRMbI6ytP/ndp3TlYua2advYBEhSvjs+4vDZNwXr/D
anbwIWdurZmViQRBDFebpkvnIvru/RpWud/5r624Wp8voZMRtj/cm6aI9LtvBfT9
cfzhOaexI/99c14dyiuk1+6QhdwKaCRTc1mdfNQmnfWNRbfWhWBlK3h4GGE9JK33
Gk8ZS8DMrkdAh0xby4xAQ/mSWAfWrBmfzlOqGyoB1U47WTOeqNbWkkoAP2ys94+s
Jg4NTkiDVtXRF6nr6fYi0bSOvOFg0IQrMXO2Y8gyg9ARdPJwKtvWX8VPADCYMiWH
h4n8bZokIrImVKLDQKHY4jCsND2HHdJfnrdL2YJw1qFskNO4cSNmZydw0Wkgjv9k
F+KxqrDKlB8MZu2Hclph6v/CZ0fQ9YuE8/lsHZ0Qc2HyiSMnvjgK5fDc3TD4fa8F
E8gMNurM+kV8PT8LNIM+4Zs+LKEV8nqRWBaxkIVJGekkVKO8xDBOG/aN62AZKHOe
GcyIdu7yNMMRihGVZCYr8rYiJoKiOzDqOkPkLOPdhtVlgnhowzHDxMHND/E2WA5p
ZHuNM/m0TXt2wTTPL7JH2YC0gPz/BvvSzjksgzU5rLbRyUKQkgU=
-----END CERTIFICATE-----
# HARICA DV TLS Sub R1 (промежуточный, crt.harica.gr) — ekaterinburg-tr.gazprom.ru
# отдаёт ТОЛЬКО leaf без intermediate (misconfig сервера); Python AIA-chasing не делает.
# Добавлен 2026-07-02.
-----BEGIN CERTIFICATE-----
MIIGwTCCBKmgAwIBAgIQFVuBuF22xppLHnbzSLBwXDANBgkqhkiG9w0BAQsFADBs
MQswCQYDVQQGEwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJl
c2VhcmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0Eg
Um9vdCBDQSAyMDIxMB4XDTIxMDMxOTA5MjQwNFoXDTM2MDMxNTA5MjQwM1owYjEL
MAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl
YXJjaCBJbnN0aXR1dGlvbnMgQ0ExGjAYBgNVBAMMEUhBUklDQSBEViBUTFMgUlNB
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvmwtJxeWVmV1TqMSobWh
KsaKFvcBVNNfr2swkNI6l9PbH8e7uXVbpMCfG7VV3N22v65pJZR7Faxlfafs+hxt
qJNS6SunkXsc2368zlVD8ckRJl+ktVz2Ps+q/VsCvG+hcgUxShlM6BpqJ6aJqH3a
zLyP7b98/r+wnrmktQLLATJ9keWFC/tngso76KNctRR92br+cv0niYoacw2+cuWr
Y43J60DcE2fN07hrt4NA2V6I4d2GNWVJHteNSwMsC5GY/opmoN4bhObbAIEne3oZ
SKP/4pzC9aA9pIaD/JYA5jiD2R85neoYVXeR3PzWqHe+3I62JFbDdLgyjgzVu0lE
RkzmIg3UioLBgT7aRxkEQTiiIgdXYHi9CQ00Y9idgBKE0E5GmuJw0JegWk/kozdF
acjUPmTdq7AA1wbrTWOAKJr541B1ObOUe+FG9G1dDwcpSx6OIqNjq04ifoKlSPcN
RoFakaSW2lEeOg/1SCieLtDniftIOnzz0+kGIVU4TTTHrWwPGZs3a96u/JlZb87D
xZGbyxfRgIonWw/WVjBwas4GIGY8oECS43iJh4JaZaqdt4LdE+cwvpZWaKi8N+o1
qovjMLpcnAUZ/cWaH2t266z+ATCGMdEtorhf0fQxE/dUYgJEBmw2r9olJJRkXVDU
t/1shKYedGuayZucrhiCL6sCAwEAAaOCAWcwggFjMBIGA1UdEwEB/wQIMAYBAf8C
AQAwHwYDVR0jBBgwFoAUCkgjpmCkkgoz6pNbxVfqJU29Eu4wVAYIKwYBBQUHAQEE
SDBGMEQGCCsGAQUFBzAChjhodHRwOi8vcmVwby5oYXJpY2EuZ3IvY2VydHMvSEFS
SUNBLVRMUy1Sb290LTIwMjEtUlNBLmNlcjBEBgNVHSAEPTA7MDkGBFUdIAAwMTAv
BggrBgEFBQcCARYjaHR0cDovL3JlcG8uaGFyaWNhLmdyL2RvY3VtZW50cy9DUFMw
HQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMEIGA1UdHwQ7MDkwN6A1oDOG
MWh0dHA6Ly9jcmwuaGFyaWNhLmdyL0hBUklDQS1UTFMtUm9vdC0yMDIxLVJTQS5j
cmwwHQYDVR0OBBYEFAqIq7yL8PVK7GFEUEBEwYdm3t5RMA4GA1UdDwEB/wQEAwIB
hjANBgkqhkiG9w0BAQsFAAOCAgEAiFIdKz8OS9mWAcAsENHqY0S1qTG+KD8IX6yU
0Ycn8thjErylOClJtq/DAmPGUylA0b2ZctLrIPRWBDqXI0f6VMPDBk7kCBwX8F/T
l6JSBQ+sGpdAx5XQxIurJE3cKbduhetf5mpIGTsIDh2V094bJlxiufi0mmB1M/QE
sohaEXfmQn6nQQtiVZOmTtNr2dZ1lyTRsKHjE9McFIeczZNP/sEgYI+SWijNjp8K
7DAJOmw+8NfUjf7dq4HqLl2MbJSPEaLfNOENjWT+wygPzAKyKl2lcBgHl3FYR14u
qkqMUg25Euq2IbkDPpaiq8i1+3A2w+SPRH5sU+g13dlFyCal47yQIIFbeA+JXSPg
i1PK/ORDJuBAszOD3q7PNf64FPAjN1OKIZAGoCBjHaS0aGED5Jviqao81hSlo0A0
y5zb9JaJPoSV/wyIc3bgS+niWDhBuBCsbmfH1TfRBTfI8O9mXrCSxSxcJ10fhZVy
8SyJc+PeOJcU+VM4emO9mAhV6sv5ioKXdrhUlgLIhbsRJW30EyV5H7Z2MGAGGMSP
Rp1k43BrIngThpXwdQmGOsj6KS7PuqkKhMvIZVQ9lDcFnNI0ySk3BPsuTVO7yo92
03GT/P4nsjwqpCc6XoCpPb42EqyFRHH5GJ9ca1ePkKxs3lApHnW9E4rmIf5tSOnt
jISnqoU=
-----END CERTIFICATE-----
# GlobalSign GCC R6 AlphaSSL CA 2025 (промежуточный, AIA secure.globalsign.com) —
# ri.eias.ru / ri-loader.eias.ru не шлют intermediate. Добавлен 2026-07-02.
-----BEGIN CERTIFICATE-----
MIIFjTCCA3WgAwIBAgIRAIN9TriekS/nLK07x2kt3CAwDQYJKoZIhvcNAQELBQAw
TDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkds
b2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMjUwNTIxMDIzNjUyWhcN
MjcwNTIxMDAwMDAwWjBVMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2ln
biBudi1zYTErMCkGA1UEAxMiR2xvYmFsU2lnbiBHQ0MgUjYgQWxwaGFTU0wgQ0Eg
MjAyNTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ/oiu0Bviq52UUE
ADbFWmgu3rC7KDSMoorLN1Wd03McG3Z1aP71DlPCE33838r72Dfuj5M9LXfiQLJp
Au6MwNExmKOzothw4x0zGf5oBYyrCMGm3fBpLPafwYQ3MchBOWMTbf83rKUPLH48
KCJ0MnU8GUl8oA/J81wIvbbKPuNrFf6hvJDccjzc4NyxLz3A89zjV2g5whCg5O0u
9YX4Zxk9JHuc/LvllOJO4waAYLjbWBJkz3rV3ts1SmSYnJqmyRTIjXwQgRvhEYqt
DbRskt0W7M6cPwCze3GTBN2UHNpHkMs3YmVxku68I0aOQn5+uz//fDROP3z1Z/7I
APteRtECAwEAAaOCAV8wggFbMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr
BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU
xbSTj28r3B5Iv7cQMIXO0bK7SC0wHwYDVR0jBBgwFoAUrmwFo5MT4qLn4tcc1sfw
f8hnU6AwewYIKwYBBQUHAQEEbzBtMC4GCCsGAQUFBzABhiJodHRwOi8vb2NzcDIu
Z2xvYmFsc2lnbi5jb20vcm9vdHI2MDsGCCsGAQUFBzAChi9odHRwOi8vc2VjdXJl
Lmdsb2JhbHNpZ24uY29tL2NhY2VydC9yb290LXI2LmNydDA2BgNVHR8ELzAtMCug
KaAnhiVodHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL3Jvb3QtcjYuY3JsMCEGA1Ud
IAQaMBgwCAYGZ4EMAQIBMAwGCisGAQQBoDIKAQMwDQYJKoZIhvcNAQELBQADggIB
AB/uvBuZf4CiuSahwiXn4geF52roAH+6jxsEPTXTfb7bbeMDXsYgRRsOTNA70ruZ
Tnz5DfFMuBhNoFhIFb0qR1izdy6VkdKOqFPNF2dOFI1EcnY9l2ory9mrzHqVbrL4
vzUd17FLUVyjTVU7PAv4nxyhnO1GTeT83YlrdRF31NyR6bvZVTEERHmpbWSgeveJ
LRtaMzlGWiLZ8IwkH7o6GH3jp/KPtDW4Npu8w64HrRZdN2pqQhi7+YKwfHM7H+2U
dM1BGN0sjOWMVbMSB9MtCsleS2Mb7TRZEbOHxECJLLIluQypZr7Pol3+hAqrhyKI
k+6y+Da0NeDuWxW59Ku4NvClqW1UFX1SpfNGhzVfp/CH+vPM1tySomx2jE0EnYZu
GwVucXPBsp5nUWqUV9+143glVuS7GTg9hFPjNBInn17HbCoIIQIOzj5Vd9bK3A9U
GxXNpwenDHEalCsD/4eQYDHPhFE7sNe0D/OXu+FAM02VZkARx37Jp4bDdujvgL9P
vZPR3wThvDN1CTU8Bc3xea3yKFAraKcPZLkhReQUAm2VpR+HSJRPlUpYizlF9WkL
h3KcAVCBJWvnOkVwxyU5QJMcnwW95JlOtx+9100GL99jHE5rs3gXp7F4bg8H01QT
9jVOhBBmQ7nQoXuwI0tqal2QUqZz3eeu62CU7xBwtfYR
-----END CERTIFICATE-----

View file

@ -1,8 +1,72 @@
from typing import Any, Literal
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field
class BuildingProgramItem(BaseModel):
"""Stage 3a (#1965, эпик #1953) — один пункт программы застройки (типовой дом × N).
Пользователь набирает программу из ТИПОВЫХ домов каталога
(``app.services.generative.catalog.HOUSE_TYPES``) вместо max-FAR жадной раскладки:
«поставь ``count`` секций типа ``section_type`` этажностью ``floors``». Габариты
пятна секции берутся из каталога по ключу ``section_type`` (контракт несёт только
ключ, не геометрию единый справочник на бэке).
"""
# Ключ типа дома из каталога (``HouseType.section_type``). Валидируется на слое
# размещения по ``catalog.available_section_types`` — неизвестный ключ → 422.
section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)")
# Этажность этой группы секций; диапазон шире, чем у target_floors (до 40 — башни).
floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group")
# Сколько секций этого типа разместить.
count: int = Field(..., ge=1, le=50, description="Number of sections to place")
# ADDITIVE (Stage 3c): опциональное ручное пятно секции (ширина × глубина, м).
# None → габариты берутся из каталога по ``section_type`` (byte-for-byte
# backward-compat). Заданы → place_program строит footprint из них вместо
# каталожных (пользователь «вписал пятно»). Оба должны быть заданы вместе;
# частичное задание (только w / только d) трактуется как «не задано» на слое
# размещения. Границы [4, 120] м — вменяемый габарит секции/корпуса.
footprint_w_m: float | None = Field(
None, ge=4, le=120, description="Optional manual section footprint width, m"
)
footprint_d_m: float | None = Field(
None, ge=4, le=120, description="Optional manual section footprint depth, m"
)
class HouseTypeCatalogItem(BaseModel):
"""Stage 3b (#1965, эпик #1953) — один тип дома каталога, ОТДАВАЕМЫЙ фронту.
Read-only проекция ``app.services.generative.catalog.HouseType`` для пикера
Stage 3b: фронт показывает ``label_ru`` + габариты пятна + дефолтную этажность
+ класс, а в ``BuildingProgramItem.section_type`` кладёт ``section_type``. Это
делает каталог ЕДИНЫМ источником истины фронт ничего не хардкодит. Поля
зеркалят dataclass на бэке (см. ``GET /api/v1/concepts/house-types``).
"""
# Стабильный машинный ключ типа — значение для BuildingProgramItem.section_type.
section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)")
# Человекочитаемый русский лейбл для UI.
label_ru: str = Field(..., description="Human-readable Russian label")
# Габариты пятна секции, метры (ширина × глубина).
footprint_w_m: float = Field(..., description="Section footprint width, m")
footprint_d_m: float = Field(..., description="Section footprint depth, m")
# Площадь пятна секции, кв.м (helper для UI: ширина × глубина).
footprint_sqm: float = Field(..., description="Section footprint area, m² (w × d)")
# Дефолтная этажность типа — UI подставляет, пользователь меняет в [1, 40].
default_floors: int = Field(..., description="Default floors for this type")
# Подходящий класс жилья (econom / comfort / business).
housing_class: Literal["econom", "comfort", "business"] = Field(
..., description="Suitable housing class"
)
class HouseTypeCatalog(BaseModel):
"""Stage 3b (#1965) — каталог типовых домов, отдаваемый ``GET /concepts/house-types``."""
house_types: list[HouseTypeCatalogItem]
class ConceptInput(BaseModel):
"""Stage 1a — input contract. Frozen interface for frontend codegen."""
@ -15,6 +79,15 @@ class ConceptInput(BaseModel):
land_cost_rub: float | None = Field(
None, ge=0, description="Optional land cost for financial model"
)
# Stage 3a (#1965): ОПЦИОНАЛЬНАЯ программа застройки из типовых домов каталога.
# ADDITIVE поле (default None). None → существующая жадная раскладка (Stage 1b,
# byte-for-byte backward-compat). Задано → раскладка кладёт РОВНО эту программу
# (place_program): для каждого пункта ставит count секций каталожного пятна с его
# floors, вместо coverage-cap sweep. См. app.services.generative.placement.
building_program: list[BuildingProgramItem] | None = Field(
None,
description="Optional typed house program; None → greedy max-FAR placement",
)
class TEAP(BaseModel):
@ -147,7 +220,71 @@ class ConceptVariant(BaseModel):
buildings_geojson: dict[str, Any]
teap: TEAP
financial: FinancialModel
# Stage 3a (#1965) — честный сигнал частичного размещения для program-режима.
# Когда задан ``ConceptInput.building_program``: ``requested_count`` — сколько секций
# просили (Σ count по программе), ``placed_count`` — сколько реально влезло в участок.
# placed < requested → участок мал, разместилось N из M (фронт Stage 3b показывает
# «разместилось N из M», без hard-422). Оба None в greedy-режиме (программа не задана)
# → ADDITIVE, backward-compat: старый ответ концепции не меняется. Annotated+`= None`:
# рантайм-дефолт виден mypy (без pydantic-плагина), Field несёт только OpenAPI-описание.
placed_count: Annotated[
int | None, Field(description="Stage 3a: sections actually placed (program mode only)")
] = None
requested_count: Annotated[
int | None, Field(description="Stage 3a: sections requested by program (program mode only)")
] = None
class ConceptOutput(BaseModel):
variants: list[ConceptVariant]
class MassingProgram(BaseModel):
"""Stage 2a (#1965) — агрегированная массинг-программа для LIVE-пересчёта экономики.
Контракт POST ``/api/v1/concepts/recompute``: вход уже СВЁРНУТАЯ программа из
фронтового ``computeModel`` интерактивного 3D-массинга (суммарное пятно × этажность),
без покомпонентной геометрии секций. Эндпоинт синтезирует из неё :class:`TEAP` и
прогоняет готовый ``compute_financial`` пересчитанная экономика для дебаунс-слайдеров
Stage 2b.
Класс жилья / тип застройки те же Literal-множества, что у :class:`ConceptInput`
и ``compute_financial`` (single source of truth по допустимым значениям).
"""
total_footprint_sqm: float = Field(
..., ge=0, description="Суммарное пятно застройки всех секций, кв.м (скаляр)"
)
floors: int = Field(..., ge=1, le=40, description="Этажность программы")
sections: int = Field(1, ge=1, le=6, description="Число секций (метаданные программы)")
site_area_sqm: float = Field(..., ge=0, description="Площадь участка для плотности (FAR)")
housing_class: Literal["econom", "comfort", "business"] = "comfort"
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
land_cost_rub: float | None = Field(
None, ge=0, description="Стоимость участка для финмодели (опционально)"
)
# Предрезолвленная рыночная цена жилья, руб/кв.м — позволяет hot-эндпоинту ПРОПУСТИТЬ
# per-keystroke DB-lookup (фронт резолвит цену один раз и шлёт её в каждом запросе).
# None → эндпоинт сам сделает _lookup_market_price по центроиду (медленный путь).
market_price_per_sqm: float | None = Field(
None, ge=0, description="Предрезолвленная рыночная цена жилья, руб/кв.м (skip DB lookup)"
)
# ПОДЛИННЫЙ источник предрезолвленной цены, который фронт получил из financial_estimate
# (objective_geo_radius / objective_district_median / district_reference / class_norm).
# Honest-флаг для UI/PDF: НЕ подменяем его захардкоженным лейблом. Применяется ТОЛЬКО
# вместе с market_price_per_sqm; None → дефолтный лейбл (см. эндпоинт recompute_massing).
price_source: str | None = Field(
None, description="Подлинный источник предрезолвленной цены (forward из financial_estimate)"
)
# Центроид участка (WKT-точка WGS84) для DB-fallback цены, когда market_price_per_sqm
# не передан. None → пропускаем lookup и берём норматив класса (нет геопривязки).
parcel_centroid_wkt: str | None = Field(
None, description="WKT-точка центроида участка (WGS84) для DB-резолва цены при fallback"
)
class MassingRecomputeOutput(BaseModel):
"""Stage 2a (#1965) — результат LIVE-пересчёта: синтезированный ТЭП + финмодель."""
teap: TEAP
financial: FinancialModel

View file

@ -36,9 +36,16 @@ class EngineeringStructure(BaseModel):
name: str | None
type: str | None
cad_num: str | None
# Назначение сооружения из НСПД-дампа (params_purpose / object_type_value),
# напр. «1.1. Сооружения электроэнергетики». Заполняется ТОЛЬКО когда отличается
# от `type` (иначе дубль-шум). ADDITIVE (default None).
purpose: str | None = None
distance_to_boundary_m: float
geometry_geojson: dict[str, Any]
readable_address: str | None
# Доп. характеристики из НСПД-дампа (год постройки / длина / собственность),
# собранные в одну читаемую строку. None — данных нет. ADDITIVE (default None).
characteristics: str | None = None
raw_props: dict[str, Any]
source: str
@ -82,6 +89,10 @@ class UtilityInfrastructureFeature(BaseModel):
source_tag: str | None
distance_m: float
geometry_geojson: dict[str, Any]
# Гуманизированные характеристики сети из OSM tags (напряжение / оператор /
# диаметр / материал), собранные в одну строку. None — данных нет.
# ADDITIVE (default None) → не ломает существующих потребителей.
characteristics: str | None = None
class UtilityInfrastructureSummary(BaseModel):
@ -96,6 +107,187 @@ class UtilityInfrastructureResponse(BaseModel):
summary: UtilityInfrastructureSummary
# ── Connection-capacity schemas (Forgejo #2119 Phase A) ──────────────────────
# «Точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3: центры питания
# (power_supply_centers) с резервом + city-level резервы воды (water_supply_reserves).
class PowerConnectionPoint(BaseModel):
"""Центр питания (ЦП, ПС 35/110 кВ) вблизи участка со свободной мощностью."""
name: str
dzo_name: str | None
voltage_class: str | None # напр. '110/35/10'
load_index: str | None # 'open'|'limited'|'closed'|None
installed_capacity_mva: float | None
current_load_mva: float | None
reserve_mva: float | None # свободная мощность, МВА
reserve_asof: str | None # ISO-дата актуальности резерва
distance_m: float
lat: float | None
lon: float | None
# Пометка для ПС ЕЭСК без опубликованного резерва: свободная мощность у АО «ЕЭСК»
# (городская сеть ЕКБ) не раскрывается WFS/xlsx — берётся по запросу. #2119 B1.
reserve_note: str | None = None
# Единица reserve_mva/installed_capacity_mva: 'МВА' (Россети) или 'МВт'
# (ЕЭСК ПС 35-220). district — адм. район ЕКБ (из xlsx ЕЭСК; NULL у Россети).
# ADDITIVE (default None). #2119 Phase B2.
reserve_unit: str | None = None
district: str | None = None
class PowerCapacitySummary(BaseModel):
"""Сводка по электрике: ближайший ЦП с резервом + count по индексу загрузки."""
total_power_points: int
# Карта индекс загрузки ('open'|'limited'|'closed'|'unknown') → count.
by_load_index: dict[str, int]
# Ближайший ЦП с положительным резервом (или null, если резерв нигде не задан).
nearest_with_reserve: PowerConnectionPoint | None
class WaterCapacityRow(BaseModel):
"""Строка резерва ЦСВ/ЦСК Водоканала (city-level) за последний период."""
system_kind: str # 'water'|'sewerage'
system_name: str # ЦСВ/ЦСК, напр. «ЗФС, ГСВ»
reserve_thousand_m3_day: float | None # может быть отрицательным (дефицит)
note: str | None
period: str | None
class GasGrsRow(BaseModel):
"""Свободная мощность одной городской ГРС (агрегация по выходам). #2119 B1."""
grs_name: str
design_capacity_th_m3_h: float | None # проектная (сумма по выходам), тыс. м³/ч
free_capacity_th_m3_h: float | None # свободная (сумма по выходам), тыс. м³/ч
free_capacity_pct: float | None # средняя свободная, %
upgrade_due: str | None # срок расшивки (макс. по выходам, свободный текст)
outputs_count: int # число строк-выходов ГРС, вошедших в агрегацию
class GasCapacityBlock(BaseModel):
"""Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.
+ счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС 960 АО
«Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4.
"""
# Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если
# таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD).
city_grs: list[GasGrsRow]
# Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет).
total_free_th_m3_h: float | None
# Счётчики точек выхода последнего period_month (по ВСЕМ строкам среза, city-level —
# см. _query_gas_outlet_counts). ADDITIVE (default 0): 0, если таблица
# gas_grs_outlet_points ещё не наполнена (миграция 184 / monthly-sync). #2119 B2 PR-4.
# ВНИМАНИЕ: свободная мощность точек выхода — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!).
outlets_total: int = 0 # всего точек выхода в последнем срезе
outlets_deficit: int = 0 # из них с дефицитом (free_capacity_mln_m3 < 0)
outlets_needs_calc: int = 0 # из них «0.000*» — нужен гидравлический расчёт
class GasOutletPoint(BaseModel):
"""Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4."""
outlet_name: str # «Точка выхода» — юрлицо/объект/промплощадка (полный текст)
consumer_type: str | None # котельная / ТЭЦ / площадка …
# Свободная мощность точки выхода, млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Может быть
# <0 (дефицит со знаком). NULL, когда в источнике «0.000*» (needs_calc=True).
free_capacity_mln_m3: float | None
needs_calc: bool # «0.000*» — свободная мощность не определена, нужен гидрорасчёт
distance_m: float # расстояние от центроида участка, м
lat: float | None
lon: float | None
class HeatSystemRow(BaseModel):
"""Резерв тепловой мощности одной системы теплоснабжения. #2119 Phase B2."""
org: str # организация-держатель (ЕТК / Екатеринбургэнерго)
system_name: str # имя системы теплоснабжения (напр. «СТ №54»)
reserve_gcal_h: float | None # свободная мощность, Гкал/ч (может быть <0 — дефицит)
period: str | None # напр. '2026-Q2'
class HeatCapacityBlock(BaseModel):
"""Тепловой блок: резервы систем теплоснабжения за последний период. #2119 B2."""
# Строки heat_system_reserves за MAX(period) ПО КАЖДОЙ организации. Пусто, если
# таблица heat_system_reserves ещё не наполнена (weekly-sync, RUN-ON-PROD).
systems: list[HeatSystemRow]
# Суммарная свободная тепловая мощность, Гкал/ч (None если данных нет).
total_reserve_gcal_h: float | None
class NearbyNetworkZone(BaseModel):
"""Сетевая охранная зона cad_zouit рядом с участком — позитив-сигнал §3. #2119 C2/C3.
Охранная зона сети = коридор, где физически проходит сеть «теплотрасса в 24 м,
врезка дешёвая». Зона, ПЕРЕСЕКАЮЩАЯ участок, отдаётся с distance_m=0.
"""
# Вид сети: thermal/electricity/gas/water/communication/pipeline/general.
# general дочищен по name_by_doc (инж.коммуникации на проде часто реально тепло/газ).
net_kind: str
distance_m: int # расстояние от участка до охранной зоны, м (0 = пересекает участок)
name_by_doc: str | None # сырое детальное имя из cad_zouit (для тултипа/дебага)
# Гуманизированная подпись (C3): «Тепловая сеть (по охранной зоне), кад. № …» /
# «Ввод 10 кВ от ТП 3164 (по охранной зоне)» / «Инженерные коммуникации (вид не уточнён)».
human_label: str
class ConnectionCapacityResponse(BaseModel):
power_points: list[PowerConnectionPoint]
power_summary: PowerCapacitySummary
water: list[WaterCapacityRow]
# Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран.
gas: GasCapacityBlock | None = None
# Гео-точки выхода газосети в радиусе от участка (слой карты §3). ADDITIVE (Phase B2
# PR-4): [] если таблица gas_grs_outlet_points пуста / нет geom в радиусе.
gas_outlet_points: list[GasOutletPoint] = []
# Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран.
heat: HeatCapacityBlock | None = None
# Позитив-разрез (Phase C2/C3): сетевые охранные зоны cad_zouit в радиусе 300 м —
# до 2 ближайших на net_kind, гуманизированы. ADDITIVE (default []): [] если сеть
# рядом не найдена / нет geom участка.
nearby_network_zones: list[NearbyNetworkZone] = []
# ── Full PDF report (эпик #2259 PR-D) ────────────────────────────────────────
class ReportBuildResponse(BaseModel):
"""Ответ POST /{cad}/report — enqueue сборки полного PDF-отчёта (#2259 PR-D).
status="building" (202) таска поставлена в очередь, PDF собирается в фоне;
status="ready" (200) готовый кэш (тот же analyze+forecast ран) уже на диске,
качать сразу через /report/download.
"""
status: Literal["building", "ready"]
# Даты базовых ранов (ISO) — контекст, по каким данным собирается/собран отчёт.
analyze_run_at: str | None = None
forecast_run_at: str | None = None
class ReportStatusResponse(BaseModel):
"""Ответ GET /{cad}/report/status — состояние полного PDF-отчёта (#2259 PR-D).
status: "ready" (метадата-ран report-pdf-1.0 есть И файл на диске),
"building" (есть analyze-ран, но готового отчёта нет вероятно собирается / ещё не
запускался), "none" (нет даже analyze-рана отчётировать нечего).
"""
status: Literal["ready", "building", "none"]
# Даты базовых ранов (ISO) — по каким данным собран/будет собран отчёт.
analyze_run_at: str | None = None
forecast_run_at: str | None = None
# Дата генерации готового PDF (ISO) — только при status="ready".
report_generated_at: str | None = None
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
@ -400,6 +592,25 @@ class TopLayoutRow(BaseModel):
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)
class SupplyOnlyLayoutRow(BaseModel):
"""Строка блока «по предложению» (#2177 шаг 3).
Структура текущего предложения (комнатность × площадь × цена) для комплексов,
которые сматчены на objective project_name по ЯДРУ имени, но у которых НЕТ
сделок (velocity). Источник objective_lots (снапшот предложения). Это НЕ
velocity-ранжир: темпа продаж здесь нет, только структура остатков в продаже.
"""
project_name: str
room_bucket: str # {studio,1,2,3,4+} — вокабуляр velocity-стороны
area_bin: str
lots_count: int
median_price_m2: float | None # NULL если цены не прошли price-sanity
median_area_m2: float | None
# Маркер источника: всегда 'objective_lots' — честная семантика «без темпа продаж».
source: str = "objective_lots"
class LayoutTzMixRow(BaseModel):
"""Строка рекомендации unit-mix для ТЗ."""
@ -425,10 +636,28 @@ class LayoutTzRecommendation(BaseModel):
class LayoutDataQuality(BaseModel):
"""Метаданные качества данных (coverage)."""
"""Метаданные качества данных (coverage).
objects_with_velocity_data: int
objects_total_in_radius: int
Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
domrf_kn_objects дробит один ЖК на несколько obj_id (разные очереди /
дубли snapshot-строк / гео-кучки безымянных корпусов), из-за чего сырой
знаменатель завышал число «конкурентов» (прод-кейс: 12 obj_id = 5 реальных
комплексов «2 из 12» вместо честных «2 из 5»). Поля objects_* теперь
считают комплексы; raw_objects_total старый счёт obj_id для прозрачности.
"""
objects_with_velocity_data: int # число комплексов с velocity-данными
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
# прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в
# радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping.
groups_matched_by_core: int = 0
# Issue #2177 шаг 3: сколько комплексов попало в блок «по предложению» —
# сматчены по ядру на objective project_name, БЕЗ velocity, но с лотами в
# продаже (objective_lots). Аддитивно, не пересекается с velocity-покрытием.
groups_with_supply_only: int = 0
velocity_coverage_pct: float
confidence: Literal["high", "medium", "low"]
@ -439,6 +668,10 @@ class BestLayoutsResponse(BaseModel):
top_layouts: list[TopLayoutRow]
recommendation_for_tz: LayoutTzRecommendation
data_quality: LayoutDataQuality
# Issue #2177 шаг 3: fallback-блок «по предложению без темпа продаж» —
# структура текущего предложения для сматченных по ядру комплексов БЕЗ velocity.
# Аддитивно; НЕ смешивается с top_layouts (там velocity-ранжир). Пусто по умолчанию.
supply_only_layouts: list[SupplyOnlyLayoutRow] = Field(default_factory=list)
# ── Analyze endpoint market price (#33) ──────────────────────────────────────
@ -623,6 +856,11 @@ class AnalyzeResponse(BaseModel):
score_label: str | None = None
score_max_reference: float | None = None
score_explanation: str | None = None
# #1926: машиночитаемый advisory-флаг — score это POI-density-эвристика
# (0..score_max_reference), а не абсолютный инвест-рейтинг. Optional для
# обратной совместимости (старые моки/202-стаб → null).
score_is_advisory: bool | None = None
score_advisory: dict[str, Any] | None = None
score_breakdown: dict[str, Any] | None = None
score_breakdown_detailed: list[dict[str, Any]] | None = None
score_top_3_positives: list[dict[str, Any]] | None = None
@ -638,6 +876,9 @@ class AnalyzeResponse(BaseModel):
market_data_coverage_pct: float | None = None
pipeline_24mo: dict[str, Any] | None = None
velocity: dict[str, Any] | None = None
# market_trend: {status: ok|offer_only|source_stale|no_deals, ...}. #2178 добавил
# вложенный offer_trend (динамика цен предложения из objective_lots_history) и
# status='offer_only'. Схема loose (dict) — новые ключи проходят без правки модели.
market_trend: dict[str, Any] | None = None
market_price: dict[str, Any] | None = None

View file

@ -1814,8 +1814,7 @@ def _active_competitors_count(
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
if target_class:
n = _q(
"AND district_name = :dn"
" AND COALESCE(obj_class, obj_class_fallback) = :cls",
"AND district_name = :dn" " AND COALESCE(obj_class, obj_class_fallback) = :cls",
{"rc": region_code, "dn": district_name, "cls": target_class},
)
if n >= 2:
@ -2221,9 +2220,7 @@ def _competitors_two_dim(
return 0, n, float(n), scope
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
class_filter = (
"AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else ""
)
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else ""
params: dict[str, Any] = {
"rc": region_code,
"dn": district_name,
@ -2291,6 +2288,17 @@ def _bucket_success_ranking(
Возвращает список dict {bucket, success_score, n_deals, velocity_z,
price_z, area_z}, sorted DESC by success_score. Пустой список если
данных нет или district_name не передан.
target_class ДОЛЖЕН быть в словаре БД (русский) caller переводит через
_class_to_db_vocab. Default-класс (когда target_class is None) = 'Комфорт':
после миграции 172 view хранит только канонические русские классы
('Комфорт'/'Типовой'/'Бизнес'/'Элит'/'Премиум'/'не указан'), английский
'Comfort' больше не существует и матчил бы 0 строк (#1955). 'Комфорт' —
самый массовый класс ЕКБ (723 объекта) разумный baseline для
«класс не указан в запросе». NULL-class строки ('не указан', бывшие
obj_class IS NULL) в default-путь НЕ попадают намеренно: для рекомендации
квартирографии лучше показать реальный массовый класс, чем агрегат «не
указан»; явный запрос с target_class='не указан' их при этом достанет.
"""
if not district_name:
return []
@ -2301,7 +2309,7 @@ def _bucket_success_ranking(
SELECT bucket, success_score, n_deals, velocity_z, price_z, area_z
FROM v_bucket_success_score
WHERE district_name = :dn
AND obj_class = COALESCE(:cls, 'Comfort')
AND obj_class = COALESCE(:cls, 'Комфорт')
ORDER BY success_score DESC
"""
),
@ -2742,8 +2750,11 @@ def recommend_mix(
# #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены
noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"])
# #25 Success-driven ranking из v_bucket_success_score
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class)
# #25 Success-driven ranking из v_bucket_success_score.
# #1955: передаём target_class_db (русский словарь БД), НЕ сырой английский
# target_class — иначе obj_class = :cls молча даёт ноль строк ("Comfort" !=
# "Комфорт"), success-boost тихо деградирует в [].
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class_db)
# 5b-3) Per-bucket project velocity at price_factor=1.0:
# bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region.

View file

@ -0,0 +1,195 @@
"""Минимальный синхронный DaData-клиент — /clean/address → (lat, lon).
Нужен ТОЛЬКО для geo-pass в objective_backfill (#2177): адрес objective-проекта
координаты для haversine-сверки с domrf-точкой. Не тащим tradein-модуль
(`tradein-mvp/.../dadata.py`) импортом это другое приложение с другим settings.
Клиент СИНХРОННЫЙ (httpx.Client): geo-pass гоняется в CLI / sync-Session, без
event-loop. Авторизация /clean/address требует ОБА: token + secret (в отличие от
/suggest, которому хватает токена). Оба берутся из settings (env DADATA_API_TOKEN /
DADATA_API_SECRET).
Возвращает None (не raise) на любой сбой нет credentials, пустой/короткий адрес,
сетевая/HTTP-ошибка, 429 quota, невалидный JSON, адрес не распознан. Вызывающая
сторона (geo-pass) трактует None как «нет геокода» reject-кандидат, проход не
падает.
Прод-инцидент 2026-07-03: у токена может быть ОТКЛЮЧЕНА фича CLEAN («Feature
'CLEAN' disabled for token» стандартизация платная, баланс исчерпан), при этом
БЕСПЛАТНЫЙ suggestions API (10k/день, только Token без X-Secret) работает и тоже
отдаёт geo_lat/geo_lon. Поэтому: cleaner при 401/403 однократный фолбэк на
suggest/address (первая саджеста). Точность саджесты чуть ниже клинера для
наших целей (точка на карте + bbox-гейт) достаточно.
Docs: https://dadata.ru/api/clean/address/ · https://dadata.ru/api/suggest/address/
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address"
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
def _credentials() -> tuple[str, str] | None:
"""(token, secret) если ОБА заданы непусто, иначе None (graceful disable)."""
token = (settings.dadata_api_token or "").strip()
secret = (settings.dadata_api_secret or "").strip()
if not token or not secret:
return None
return token, secret
def _coerce_float(value: Any) -> float | None:
"""DaData отдаёт geo_lat/geo_lon строкой — coerce в float безопасно."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def clean_address(address: str) -> tuple[float, float] | None:
"""Геокодировать один адрес через DaData /clean/address → (lat, lon).
Args:
address: свободный текст адреса (objective_lots.address).
Returns:
(geo_lat, geo_lon) как float-пара, либо None если:
- credentials не заданы в ENV (graceful disable);
- адрес пустой / короче 3 символов;
- сетевая / HTTP-ошибка (timeout, 5xx, 429 quota, 401/403 auth, 4xx);
- невалидный JSON / пустой массив / не dict;
- DaData не вернул geo_lat/geo_lon (адрес не геокодирован).
"""
if not address or len(address.strip()) < 3:
return None
creds = _credentials()
if creds is None:
logger.debug("dadata_client: credentials не заданы — skip геокод")
return None
token, secret = creds
headers = {
"Authorization": f"Token {token}",
"X-Secret": secret,
"Content-Type": "application/json",
"Accept": "application/json",
}
body = [address.strip()]
try:
with httpx.Client(timeout=settings.dadata_timeout_s) as client:
response = client.post(DADATA_CLEAN_URL, headers=headers, json=body)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
logger.warning("dadata_client: network error для %r: %s", address[:60], exc)
return None
except Exception as exc:
# defensive: любой прочий httpx/клиент-сбой → None, geo-pass reject'ит,
# НЕ роняем весь проход. Логируем на warning (не глотаем молча).
logger.warning("dadata_client: unexpected client error для %r: %s", address[:60], exc)
return None
status = response.status_code
if status == 429:
logger.warning("dadata_client: HTTP 429 — quota exceeded")
return None
if status in (401, 403):
# Фича CLEAN у токена может быть отключена (баланс стандартизации) —
# фолбэк на бесплатный suggestions (тот же token, X-Secret не нужен).
logger.warning("dadata_client: cleaner HTTP %d — фолбэк на suggest/address", status)
return _suggest_geocode(address.strip(), token)
if status >= 400:
logger.warning("dadata_client: HTTP %d для %r", status, address[:60])
return None
try:
payload = response.json()
except ValueError as exc:
logger.warning("dadata_client: невалидный JSON: %s", exc)
return None
if not isinstance(payload, list) or not payload:
logger.info("dadata_client: пустой ответ для %r", address[:60])
return None
item = payload[0]
if not isinstance(item, dict):
logger.warning("dadata_client: первый элемент не dict (%s)", type(item).__name__)
return None
lat = _coerce_float(item.get("geo_lat"))
lon = _coerce_float(item.get("geo_lon"))
if lat is None or lon is None:
logger.info(
"dadata_client: нет координат (qc_geo=%s) для %r",
item.get("qc_geo"),
address[:60],
)
return None
return (lat, lon)
def _suggest_geocode(address: str, token: str) -> tuple[float, float] | None:
"""Фолбэк-геокод через БЕСПЛАТНЫЙ suggest/address (первая саджеста).
Возвращает (lat, lon) из suggestions[0].data.geo_lat/geo_lon либо None.
Та же graceful-семантика, что у clean_address.
"""
headers = {
"Authorization": f"Token {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
try:
with httpx.Client(timeout=settings.dadata_timeout_s) as client:
response = client.post(
DADATA_SUGGEST_URL,
headers=headers,
json={"query": address, "count": 1},
)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
logger.warning("dadata_client: suggest network error для %r: %s", address[:60], exc)
return None
except Exception as exc:
logger.warning("dadata_client: suggest unexpected error для %r: %s", address[:60], exc)
return None
if response.status_code != 200:
logger.warning("dadata_client: suggest HTTP %d для %r", response.status_code, address[:60])
return None
try:
payload = response.json()
except ValueError as exc:
logger.warning("dadata_client: suggest невалидный JSON: %s", exc)
return None
suggestions = payload.get("suggestions") if isinstance(payload, dict) else None
if not suggestions or not isinstance(suggestions[0], dict):
logger.info("dadata_client: suggest пусто для %r", address[:60])
return None
data = suggestions[0].get("data") or {}
lat = _coerce_float(data.get("geo_lat"))
lon = _coerce_float(data.get("geo_lon"))
if lat is None or lon is None:
logger.info(
"dadata_client: suggest без координат (qc_geo=%s) для %r",
data.get("qc_geo"),
address[:60],
)
return None
return (lat, lon)

View file

@ -0,0 +1,220 @@
"""Review-инструмент авто-маппингов objective_complex_mapping (feat/mapping-review-page).
Авто-проходы (auto_core_dev_v5 / auto_core_geo_v6 / fuzzy_* / ) пишут строки
с is_reviewed=false. Здесь чтение этих строк С ОБОГАЩЕНИЕМ для глаз ревьюера,
подтверждение (approve is_reviewed=true) и отклонение (reject DELETE строки,
чтобы отклонённый маппинг не травил mv_layout_velocity §4.2).
Обогащение:
domrf comm_name / dev_name LATEST snapshot по domrf_obj_id
(snapshot_date DESC как в objective_backfill._DOMRF_UNMAPPED_SQL).
objective developers агрегат objective_lots.developer, SCOPED по
(project_name = objective_complex_name). Урок 3.8с: агрегат по project_name,
НЕ глобальный иначе список застройщиков одного проекта протекает в чужой.
Чтения parametrized `text(...)` + CAST(:x AS type) (backend.md psycopg3).
DELETE (reject) только по PK с RETURNING для полного лога удаляемой строки.
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ── SQL: строки mapping + обогащение domrf latest snapshot + objective devs ────
# LEFT JOIN LATERAL к domrf_kn_objects: DISTINCT-ON-эквивалент через ORDER BY
# snapshot_date DESC LIMIT 1 (latest snapshot по obj_id). objective devs —
# коррелированный агрегат по project_name (scoped, урок 3.8с).
# Сорт: is_reviewed asc (unreviewed сверху), match_score asc NULLS FIRST
# (самые сомнительные / без скора сверху), id для стабильности.
_LIST_SQL = text("""
SELECT
m.id,
m.objective_complex_name,
m.objective_project_id,
m.objective_group,
m.domrf_obj_id,
m.match_method,
m.match_score,
m.is_reviewed,
m.note,
m.created_at,
d.comm_name AS domrf_comm_name,
d.dev_name AS domrf_dev_name,
odev.developers AS objective_developers
FROM objective_complex_mapping m
LEFT JOIN LATERAL (
SELECT o.comm_name, o.dev_name
FROM domrf_kn_objects o
WHERE o.obj_id = m.domrf_obj_id
ORDER BY o.snapshot_date DESC NULLS LAST
LIMIT 1
) d ON m.domrf_obj_id IS NOT NULL
LEFT JOIN LATERAL (
SELECT ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers
FROM objective_lots ol
WHERE ol.project_name = m.objective_complex_name
AND ol.developer IS NOT NULL
) odev ON TRUE
WHERE (CAST(:only_unreviewed AS boolean) = FALSE OR m.is_reviewed = FALSE)
ORDER BY m.is_reviewed ASC, m.match_score ASC NULLS FIRST, m.id ASC
LIMIT CAST(:limit AS int) OFFSET CAST(:offset AS int)
""")
_COUNT_SQL = text("""
SELECT COUNT(*) AS total
FROM objective_complex_mapping m
WHERE (CAST(:only_unreviewed AS boolean) = FALSE OR m.is_reviewed = FALSE)
""")
# ── SQL: approve — is_reviewed=true + append note. RETURNING для 404-детекта ───
# NULLIF/concat: пустой/NULL note → просто «approved <date>»; иначе append через
# перевод строки. Идемпотентно относительно is_reviewed (повторный approve OK).
_APPROVE_SQL = text("""
UPDATE objective_complex_mapping
SET is_reviewed = TRUE,
note = CASE
WHEN note IS NULL OR note = '' THEN CAST(:approve_note AS text)
ELSE note || E'\\n' || CAST(:approve_note AS text)
END,
updated_at = NOW()
WHERE id = CAST(:id AS bigint)
RETURNING id, objective_complex_name, domrf_obj_id,
match_method, match_score, is_reviewed, note
""")
# ── SQL: reject — DELETE по PK, RETURNING всей строки для лога (травит §4.2) ───
_DELETE_SQL = text("""
DELETE FROM objective_complex_mapping
WHERE id = CAST(:id AS bigint)
RETURNING id, objective_complex_name, objective_project_id, objective_group,
domrf_obj_id, match_method, match_score, is_reviewed, note,
created_at
""")
def _serialize_row(row: Any) -> dict[str, Any]:
"""Сериализовать строку list-запроса в loose dict для API (паттерн admin_etl)."""
score = row.match_score
created = row.created_at
return {
"id": row.id,
"objective_complex_name": row.objective_complex_name,
"objective_project_id": row.objective_project_id,
"objective_group": row.objective_group,
"domrf_obj_id": row.domrf_obj_id,
"match_method": row.match_method,
"match_score": float(score) if score is not None else None,
"is_reviewed": bool(row.is_reviewed),
"note": row.note,
"created_at": created.isoformat() if created else None,
# Обогащение для глаз ревьюера:
"domrf_comm_name": row.domrf_comm_name,
"domrf_dev_name": row.domrf_dev_name,
"objective_developers": list(row.objective_developers or []),
}
def list_mapping_review(
db: Session,
*,
only_unreviewed: bool = True,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
"""Список строк objective_complex_mapping + обогащение + total count.
Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху), id.
Args:
db: SQLAlchemy sync Session.
only_unreviewed: True (default) только is_reviewed=false.
limit: страница (строк).
offset: смещение страницы.
Returns:
dict: rows (list обогащённых строк), total (int, до limit/offset),
limit, offset, only_unreviewed.
"""
rows = db.execute(
_LIST_SQL,
{"only_unreviewed": only_unreviewed, "limit": limit, "offset": offset},
).all()
total = db.execute(_COUNT_SQL, {"only_unreviewed": only_unreviewed}).scalar_one()
return {
"rows": [_serialize_row(r) for r in rows],
"total": int(total),
"limit": limit,
"offset": offset,
"only_unreviewed": only_unreviewed,
}
def approve_mapping(db: Session, mapping_id: int) -> dict[str, Any] | None:
"""Подтвердить маппинг: is_reviewed=true + append «approved <date>» в note.
Returns:
dict обновлённой строки, либо None если id не найден ( 404 в роутере).
"""
approve_note = f"approved {date.today().isoformat()}"
row = db.execute(_APPROVE_SQL, {"id": mapping_id, "approve_note": approve_note}).first()
if row is None:
db.rollback()
return None
db.commit()
logger.info(
"mapping-review approve id=%s name=%r method=%s → is_reviewed=true",
row.id,
row.objective_complex_name,
row.match_method,
)
score = row.match_score
return {
"id": row.id,
"objective_complex_name": row.objective_complex_name,
"domrf_obj_id": row.domrf_obj_id,
"match_method": row.match_method,
"match_score": float(score) if score is not None else None,
"is_reviewed": bool(row.is_reviewed),
"note": row.note,
}
def reject_mapping(db: Session, mapping_id: int) -> dict[str, Any] | None:
"""Отклонить маппинг: DELETE строки (травит mv_layout_velocity §4.2 иначе).
Перед удалением полная строка логируется через logger.info (в note писать
некуда строки не будет). DELETE только по PK с RETURNING.
Returns:
dict удалённой строки (для эха в ответе), либо None если id не найден.
"""
row = db.execute(_DELETE_SQL, {"id": mapping_id}).first()
if row is None:
db.rollback()
return None
created = row.created_at
score = row.match_score
deleted: dict[str, Any] = {
"id": row.id,
"objective_complex_name": row.objective_complex_name,
"objective_project_id": row.objective_project_id,
"objective_group": row.objective_group,
"domrf_obj_id": row.domrf_obj_id,
"match_method": row.match_method,
"match_score": float(score) if score is not None else None,
"is_reviewed": bool(row.is_reviewed),
"note": row.note,
"created_at": created.isoformat() if created else None,
}
logger.info("mapping-review reject DELETE id=%s row=%s", row.id, deleted)
db.commit()
return deleted

View file

@ -1,9 +1,12 @@
"""Cross-load ETL: tradein.houses → gendesign.newbuilding_listings (#976 950-E5).
Читает houses + house_sources из tradein-БД (gendesign_reader role),
Читает market.v_houses + market.v_house_sources из tradein-БД (gendesign_reader role),
UPSERT-ит в gendesign newbuilding_listings по (source, ext_house_id).
Соединение к tradein через psycopg v3 (прямой коннект, НЕ SQLAlchemy).
Источник contract-view market.v_* (#2138), а не сырые public.*-таблицы: явный
column-контракт, устойчивый к scraper-driven ALTER TABLE на стороне tradein.
Запускается:
- ночью Celery beat (03:30 МСК, tasks/etl_newbuilding_crossload.py)
- вручную через POST /api/v1/admin/scrape/newbuilding-crossload
@ -54,8 +57,10 @@ SELECT
h.last_scraped_at,
-- yandex_jk_id из house_sources (LEFT JOIN может отсутствовать)
hs_y.ext_id AS yandex_jk_id
FROM houses h
LEFT JOIN house_sources hs_y
-- Читаем через contract-view market.v_* (#2138 step 2/3), НЕ сырые public.* —
-- стабильный явный column-контракт, не ломается на scraper-driven ALTER TABLE.
FROM market.v_houses h
LEFT JOIN market.v_house_sources hs_y
ON hs_y.house_id = h.id
AND hs_y.ext_source = 'yandex'
WHERE h.lat IS NOT NULL

View file

@ -12,20 +12,40 @@ Schema facts (confirmed via pg MCP):
колонки match_method + match_score + is_reviewed поддерживают audit trail.
match_method history:
'fuzzy' legacy Anton SQLite import (avg score 0.98, 127 rows)
'fuzzy_trgm' pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
'fuzzy_v2' pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
'manual' ручная корректура
'fuzzy' legacy Anton SQLite import (avg score 0.98, 127 rows)
'fuzzy_trgm' pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
'fuzzy_v2' pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
'auto_core_dev_v5' core-name normalize (#2198 normalize_complex_name) обеих сторон +
dev-confirm. trgm по СЫРЫМ именам давал ~300 маппингов, 1253 obj_id
остались (низкая similarity: «Жилой комплекс "Малевич" (ЖК "Малевич")»
vs «Малевич»). Ядро-нормализация схлопывает обёртки exact-core match;
dev-подтверждение (пересечение токенов / подстрока) отсеивает
одноимённые чужие проекты (Объектив = только ЕКБ). #2177 шаг 2.
'auto_core_geo_v6' гео-разрешение остатка core-pass (tier_b / ambiguous) через
DaData-геокод. Адрес objective-проекта (mode objective_lots.
address per project) clean_address (lat, lon); haversine
до domrf-точки (domrf_kn_objects.latitude/longitude, latest
snapshot). tier_b: дистанция порога confirm (гео вместо
dev). ambiguous: геокодим ВСЕХ кандидатов ядра, confirm только
если РОВНО ОДИН в радиусе. score 0.80, is_reviewed=false. #2177.
'manual' ручная корректура
"""
from __future__ import annotations
import argparse
import logging
from dataclasses import dataclass
import math
import re
import sys
from dataclasses import dataclass, field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.dadata_client import clean_address
from app.services.site_finder.best_layouts import normalize_complex_name
logger = logging.getLogger(__name__)
# Порог для auto-insert (высокая уверенность)
@ -234,3 +254,919 @@ def trigger_mv_refresh(db: Session) -> int:
from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity
return refresh_layout_velocity(db, concurrently=True)
# ════════════════════════════════════════════════════════════════════════════
# Core-name pass (#2177 шаг 2) — auto_core_dev_v5
# ════════════════════════════════════════════════════════════════════════════
# objective_group у всех существующих строк mapping = 'Екатеринбург'
# (Объектив покрывает только ЕКБ). Новые строки пишем с тем же значением.
OBJECTIVE_GROUP = "Екатеринбург"
CORE_MATCH_METHOD = "auto_core_dev_v5"
CORE_MATCH_SCORE = 0.90
CORE_MATCH_NOTE = "#2177 step2: exact-core + dev-confirm"
# Префиксы/обёртки орг-формы застройщика, срезаемые перед сравнением.
# Порядок применения не важен (все режутся из целого норм-имени).
_DEV_JUNK_RE = re.compile(
r"""
\b(?:
ооо | оао | пао | зао | ао | ип
| сз # СЗ = специализированный застройщик (аббрев.)
| специализированн\w* # «специализированный застройщик»
| застройщик\w*
| девелопмент\w*
| групп\w* # групп / группа
| компани\w*
| инвест\w* # инвест / инвестиции (частый шум)
| строительн\w*
)\b
""",
re.VERBOSE,
)
def _norm_dev(raw: str | None) -> str:
"""Нормализовать имя застройщика до сравниваемого ядра.
lower срез кавычек/скобок срез орг-формы (ООО/АО/ПАО/ЗАО/«СЗ»/
«специализированный застройщик»/«девелопмент»/«групп(а)») схлопывание
пробелов. Пустой / None ''.
Namespaced на _NAME_-уровне: возвращаем очищенную строку, дальнейшая
классификация (пересечение токенов / подстрока) в find_core_matches.
"""
if not raw:
return ""
name = raw.strip().lower()
# убрать кавычки-ёлочки/лапки/скобки — оставить только содержательные символы
name = re.sub(r"[«»\"'`()\[\]]", " ", name)
name = _DEV_JUNK_RE.sub(" ", name)
# мусорные разделители → пробел (точки в «Астон.» и т.п. не трогаем на dev-стороне:
# dev-имена обычно без них, но на всякий нормализуем распространённые)
name = re.sub(r"[.,;/\\]", " ", name)
name = re.sub(r"\s+", " ", name).strip()
return name
def _dev_confirms(domrf_dev: str, objective_devs: set[str]) -> bool:
"""True если domrf-застройщик подтверждает связь с objective-проектом.
У одного objective project_name бывает НЕСКОЛЬКО developers (SPV / очереди)
подтверждение по ЛЮБОМУ из них. Критерий (любой):
пересечение токенов норм-имён (общий содержательный токен), ИЛИ
одно норм-имя подстрока другого (в любую сторону), НО только когда
короткая сторона 4 символов: «дом»/«юг»/«ак» как подстрока чужого
длинного имени («Домострой», «Югстройинвест») ложное подтверждение,
а именно dev-confirm последняя защита от generic-core коллизий.
Пустые имена не подтверждают (нет сигнала).
"""
d = _norm_dev(domrf_dev)
if not d:
return False
d_tokens = {t for t in d.split() if len(t) >= 3}
for raw_odev in objective_devs:
o = _norm_dev(raw_odev)
if not o:
continue
if min(len(d), len(o)) >= 4 and (d in o or o in d):
return True
o_tokens = {t for t in o.split() if len(t) >= 3}
if d_tokens & o_tokens:
return True
return False
@dataclass
class CoreMatch:
"""Один core-match domrf-объекта к objective-проекту."""
domrf_obj_id: int
domrf_comm_name: str
domrf_dev_name: str | None
core: str
objective_project_name: str
objective_project_id: int | None
objective_developers: list[str]
dev_confirmed: bool
@dataclass
class CoreMatchReport:
"""Итог классификации core-pass.
tier_a ровно 1 objective-кандидат И dev-подтверждён пишем в БД.
tier_b ровно 1 кандидат, dev НЕ подтверждён кандидат на DaData-гео
проход, в БД НЕ пишем (только отчёт).
ambiguous >1 objective-кандидатов по core в отчёт, разрешение вручную/гео.
skipped_taken objective_complex_name уже занят в mapping (UNIQUE-констрейнт;
его domrf-группа уже покрыта дубли не нужны).
"""
tier_a: list[CoreMatch] = field(default_factory=list)
tier_b: list[CoreMatch] = field(default_factory=list)
ambiguous: list[CoreMatch] = field(default_factory=list)
skipped_taken: list[CoreMatch] = field(default_factory=list)
def counts(self) -> dict[str, int]:
return {
"tier_a": len(self.tier_a),
"tier_b": len(self.tier_b),
"ambiguous": len(self.ambiguous),
"skipped_taken": len(self.skipped_taken),
}
# ── SQL: несопоставленные domrf ЕКБ-объекты, latest snapshot per obj_id ───────
# ЛОВУШКА is_ekb (прод-проверка 2026-07-03): флаг проставляется per-snapshot и на
# свежих снапшотах ПУСТ (все latest-строки is_ekb=false — см. также комментарий
# в 155_objective_mapping_trgm_backfill.sql). `WHERE is_ekb = true` внутри
# DISTINCT ON отдал бы устаревшие имена со старых снапшотов (или 0 строк после
# DISTINCT). Поэтому: ЕКБ-принадлежность = «obj_id имел is_ekb=true хоть на одном
# снапшоте» (1285 живых объектов), а строку берём с LATEST снапшота без флага.
_DOMRF_UNMAPPED_SQL = text("""
SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.comm_name, o.dev_name
FROM domrf_kn_objects o
WHERE o.comm_name IS NOT NULL
AND o.obj_id IN (
SELECT DISTINCT e.obj_id FROM domrf_kn_objects e WHERE e.is_ekb = true
)
AND NOT EXISTS (
SELECT 1 FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id = o.obj_id
)
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
""")
# ── SQL: objective-проекты — distinct project_name + агрегаты developer/id ────
# objective_project_id: в objective_lots есть колонка objective_project_id
# (68_schema_objective.sql). Берём MAX (стабилен per project_name; NULL если нет).
_OBJECTIVE_PROJECTS_SQL = text("""
SELECT
ol.project_name,
ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers,
MAX(ol.objective_project_id) AS objective_project_id
FROM objective_lots ol
WHERE ol.project_name IS NOT NULL
AND ol.project_name <> ''
GROUP BY ol.project_name
""")
# ── SQL: уже занятые objective_complex_name в текущей группе ──────────────────
_TAKEN_NAMES_SQL = text("""
SELECT objective_complex_name
FROM objective_complex_mapping
WHERE objective_group = CAST(:group AS text)
""")
def find_core_matches(db: Session) -> CoreMatchReport:
"""Сопоставить несопоставленные domrf ЕКБ-объекты с objective-проектами по ядру.
Обе стороны нормализуются `normalize_complex_name` (#2198): обёртки вроде
«Жилой комплекс "Малевич" (ЖК "Малевич")» схлопываются до ядра «малевич»,
что даёт exact-core match там, где trgm по сырым именам не дотягивал.
Классификация (см. CoreMatchReport):
tier_a 1 objective-кандидат + dev-подтверждён (пишем);
tier_b 1 кандидат, dev НЕ подтверждён (DaData-гео проход, не пишем);
ambiguous >1 кандидата (ручное/гео разрешение);
skipped objective_complex_name уже в mapping (UNIQUE-констрейнт).
Реального обращения к БД в тестах нет db.execute мокается по паттерну файла.
"""
report = CoreMatchReport()
# objective-сторона: core → [(project_name, {devs}, project_id), ...]
objective_by_core: dict[str, list[tuple[str, set[str], int | None]]] = {}
for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all():
project_name = str(row[0])
developers = {str(d) for d in (row[1] or []) if d}
project_id = int(row[2]) if row[2] is not None else None
core = normalize_complex_name(project_name)
if not core:
continue
objective_by_core.setdefault(core, []).append((project_name, developers, project_id))
# уже занятые objective_complex_name (skip — их группа уже покрыта)
taken_names: set[str] = {
str(r[0]) for r in db.execute(_TAKEN_NAMES_SQL, {"group": OBJECTIVE_GROUP}).all()
}
# domrf-сторона: несопоставленные ЕКБ, latest snapshot per obj_id
for row in db.execute(_DOMRF_UNMAPPED_SQL).all():
obj_id = int(row[0])
comm_name = str(row[1])
dev_name = str(row[2]) if row[2] is not None else None
core = normalize_complex_name(comm_name)
if not core:
continue
candidates = objective_by_core.get(core)
if not candidates:
continue # нет objective-проекта с таким ядром — не наш случай
if len(candidates) > 1:
# >1 objective project_name делят одно ядро → ambiguous (не выбираем вслепую)
proj_name, devs, proj_id = candidates[0]
report.ambiguous.append(
_build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
)
continue
proj_name, devs, proj_id = candidates[0]
if proj_name in taken_names:
report.skipped_taken.append(
_build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
)
continue
match = _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
if match.dev_confirmed:
report.tier_a.append(match)
else:
report.tier_b.append(match)
logger.info("find_core_matches: %s", report.counts())
return report
def _build_core_match(
obj_id: int,
comm_name: str,
dev_name: str | None,
core: str,
proj_name: str,
proj_id: int | None,
devs: set[str],
) -> CoreMatch:
"""Сконструировать CoreMatch + посчитать dev_confirmed."""
return CoreMatch(
domrf_obj_id=obj_id,
domrf_comm_name=comm_name,
domrf_dev_name=dev_name,
core=core,
objective_project_name=proj_name,
objective_project_id=proj_id,
objective_developers=sorted(devs),
dev_confirmed=_dev_confirms(dev_name or "", devs),
)
def apply_core_matches(
db: Session,
report: CoreMatchReport,
*,
dry_run: bool = True,
) -> dict[str, int]:
"""Вставить ТОЛЬКО tier_a в objective_complex_mapping (auto_core_dev_v5).
tier_b / ambiguous / skipped НЕ пишутся (см. CoreMatchReport). Каждая вставка
в per-row SAVEPOINT (backend.md) с ON CONFLICT (objective_complex_name,
objective_group) DO NOTHING гонка/дубль не роняет весь проход.
Args:
db: SQLAlchemy sync Session.
report: результат find_core_matches().
dry_run: True (default) только логирует counts + первые 30 пар, БЕЗ insert.
Returns:
dict: inserted, conflict_skipped, error_skipped, tier_a_total.
"""
counts = report.counts()
logger.info(
"apply_core_matches (dry_run=%s): tier_a=%d tier_b=%d ambiguous=%d skipped_taken=%d",
dry_run,
counts["tier_a"],
counts["tier_b"],
counts["ambiguous"],
counts["skipped_taken"],
)
for m in report.tier_a[:30]:
logger.info(
" tier_a: obj_id=%s core=%r domrf=%r → objective=%r (dev %r ~ %r)",
m.domrf_obj_id,
m.core,
m.domrf_comm_name,
m.objective_project_name,
m.domrf_dev_name,
m.objective_developers,
)
if dry_run:
return {
"inserted": 0,
"conflict_skipped": 0,
"error_skipped": 0,
"tier_a_total": counts["tier_a"],
}
inserted = 0
conflict_skipped = 0
error_skipped = 0
for m in report.tier_a:
try:
with db.begin_nested():
result = db.execute(
text(
"""
INSERT INTO objective_complex_mapping
(objective_complex_name, objective_project_id, domrf_obj_id,
objective_group, match_method, match_score, is_reviewed, note)
VALUES (
CAST(:name AS text),
CAST(:project_id AS bigint),
CAST(:obj_id AS bigint),
CAST(:group AS text),
CAST(:method AS text),
CAST(:score AS numeric),
CAST(:reviewed AS boolean),
CAST(:note AS text)
)
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
"""
),
{
"name": m.objective_project_name,
"project_id": m.objective_project_id,
"obj_id": m.domrf_obj_id,
"group": OBJECTIVE_GROUP,
"method": CORE_MATCH_METHOD,
"score": CORE_MATCH_SCORE,
"reviewed": False,
"note": CORE_MATCH_NOTE,
},
)
if result.rowcount > 0:
inserted += 1
else:
conflict_skipped += 1
except Exception as e:
logger.warning(
"core insert failed для %s%s: %s",
m.domrf_comm_name,
m.objective_project_name,
e,
)
error_skipped += 1
db.commit()
logger.info(
"apply_core_matches done: inserted=%d conflict_skipped=%d error_skipped=%d",
inserted,
conflict_skipped,
error_skipped,
)
return {
"inserted": inserted,
"conflict_skipped": conflict_skipped,
"error_skipped": error_skipped,
"tier_a_total": counts["tier_a"],
}
def run_core_pass(*, apply: bool = False) -> dict[str, int]:
"""CLI-обёртка core-pass: открыть Session, найти матчи, (опц.) применить.
Дефолт dry-run. Реальный insert только при apply=True.
"""
from app.core.db import SessionLocal
db = SessionLocal()
try:
report = find_core_matches(db)
return apply_core_matches(db, report, dry_run=not apply)
finally:
db.close()
# ════════════════════════════════════════════════════════════════════════════
# Geo-pass (#2177) — auto_core_geo_v6
# ════════════════════════════════════════════════════════════════════════════
#
# Разрешает ОСТАТОК core-pass (tier_b + ambiguous) географией: адрес objective-
# проекта → DaData-геокод → haversine до domrf-точки. tier_b подтверждается
# близостью (гео вместо dev-confirm); ambiguous — только если РОВНО ОДИН из
# кандидатов ядра в радиусе (иначе остаётся ambiguous).
GEO_MATCH_METHOD = "auto_core_geo_v6"
GEO_MATCH_SCORE = 0.80
GEO_MAX_DISTANCE_M = 500.0
# Бережный потолок вызовов DaData за один прогон (МАКС-тариф, но не жжём квоту
# на прогон-цикле). Превышение → стоп с warning, недобранные кандидаты reject'ятся
# как «без геокода».
GEO_DADATA_CALL_LIMIT = 200
_EARTH_RADIUS_M = 6_371_000.0
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние (метры) между двумя точками по формуле гаверсинуса."""
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
return 2 * _EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a)))
@dataclass
class GeoMatch:
"""Один подтверждённый гео-матч domrf-объекта к objective-проекту.
origin 'tier_b' | 'ambiguous' (из какого класса core-pass пришёл).
distance_m гаверсинус адрес-проекта domrf-точки (метры).
"""
domrf_obj_id: int
domrf_comm_name: str
core: str
objective_project_name: str
objective_project_id: int | None
address: str
distance_m: float
origin: str
@dataclass
class GeoReject:
"""Отклонённый гео-кандидат (для отчёта). reason: 'no_address' |
'no_geocode' | 'too_far' | 'ambiguous_multi' | 'call_limit'.
distance_m None когда дистанцию посчитать не удалось (нет адреса/геокода/
координат domrf).
"""
domrf_obj_id: int
domrf_comm_name: str
objective_project_name: str
origin: str
reason: str
distance_m: float | None = None
@dataclass
class GeoMatchReport:
"""Итог geo-pass.
confirmed подтверждённые гео-матчи (пишутся в apply_geo_matches).
rejected кандидаты без адреса/геокода/слишком далёкие/оставшиеся ambiguous.
dadata_calls сколько раз реально дёрнули DaData (для контроля квоты).
call_limit_hit True если упёрлись в GEO_DADATA_CALL_LIMIT (часть кандидатов
не обработана см. rejected c reason='call_limit').
"""
confirmed: list[GeoMatch] = field(default_factory=list)
rejected: list[GeoReject] = field(default_factory=list)
dadata_calls: int = 0
call_limit_hit: bool = False
def counts(self) -> dict[str, int]:
return {
"confirmed": len(self.confirmed),
"rejected": len(self.rejected),
"dadata_calls": self.dadata_calls,
}
# ── SQL: mode (самый частый непустой) адрес objective_lots per project_name ────
# Адресов на проект много (по корпусам) — берём mode: GROUP BY (project_name,
# address) → COUNT DESC, DISTINCT ON (project_name) отбирает верхнюю строку.
# NULLIF(trim,'') отсекает пустые адреса до агрегации.
_OBJECTIVE_ADDRESS_SQL = text("""
SELECT DISTINCT ON (project_name)
project_name, address
FROM (
SELECT
ol.project_name AS project_name,
NULLIF(trim(ol.address), '') AS address,
COUNT(*) AS cnt
FROM objective_lots ol
WHERE ol.project_name IS NOT NULL
AND ol.project_name <> ''
AND NULLIF(trim(ol.address), '') IS NOT NULL
GROUP BY ol.project_name, NULLIF(trim(ol.address), '')
) agg
ORDER BY project_name, cnt DESC, address
""")
# ── SQL: latest-snapshot координаты domrf per obj_id (для набора obj_id) ───────
# DISTINCT ON (obj_id) + snapshot_date DESC — берём свежую точку. Фильтр по
# конкретным obj_id (кандидаты geo-pass), NULL-координаты отбрасываются.
_DOMRF_COORDS_SQL = text("""
SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.latitude, o.longitude
FROM domrf_kn_objects o
WHERE o.obj_id = ANY(:obj_ids)
AND o.latitude IS NOT NULL
AND o.longitude IS NOT NULL
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
""")
def _load_project_addresses(db: Session) -> dict[str, str]:
"""project_name → mode-адрес (самый частый непустой) из objective_lots."""
out: dict[str, str] = {}
for row in db.execute(_OBJECTIVE_ADDRESS_SQL).all():
name = str(row[0])
addr = row[1]
if addr:
out[name] = str(addr)
return out
def _load_domrf_coords(db: Session, obj_ids: list[int]) -> dict[int, tuple[float, float]]:
"""obj_id → (lat, lon) latest snapshot для заданного набора obj_id."""
if not obj_ids:
return {}
out: dict[int, tuple[float, float]] = {}
for row in db.execute(_DOMRF_COORDS_SQL, {"obj_ids": obj_ids}).all():
lat = float(row[1])
lon = float(row[2])
out[int(row[0])] = (lat, lon)
return out
def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M) -> GeoMatchReport:
"""Разрешить остаток core-pass (tier_b + ambiguous) через DaData-геокод.
Алгоритм:
1. find_core_matches берём report.tier_b и report.ambiguous.
2. Для каждого кандидата: адрес проекта (mode objective_lots.address)
clean_address (lat, lon); haversine до domrf-точки.
tier_b: дистанция max_distance_m confirm (гео вместо dev);
ambiguous: геокодим ВСЕХ кандидатов ядра; confirm только если РОВНО
ОДИН в радиусе (иначе остаётся ambiguous);
нет адреса / DaData null / дистанция больше reject c дистанцией.
3. Кэш геокода в памяти прогона (адреса повторяются). Лимит вызовов DaData
GEO_DADATA_CALL_LIMIT при превышении стоп с warning, остаток reject.
Args:
db: SQLAlchemy sync Session.
max_distance_m: радиус подтверждения (метры), default 500.
Returns:
GeoMatchReport (confirmed / rejected / dadata_calls / call_limit_hit).
Реального обращения к БД / DaData в тестах нет оба мокаются по паттерну файла.
"""
core_report = find_core_matches(db)
report = GeoMatchReport()
tier_b = core_report.tier_b
ambiguous = core_report.ambiguous
if not tier_b and not ambiguous:
logger.info("find_geo_matches: нет tier_b/ambiguous кандидатов — nothing to do")
return report
addresses = _load_project_addresses(db)
# ── ambiguous: нужны ВСЕ objective-кандидаты ядра, не только первый ────────
# core-report.ambiguous хранит по одному CoreMatch на domrf-obj_id (первый
# кандидат). Восстанавливаем полный список project_name'ов ядра из objective-
# стороны, чтобы геокодить всех и выбрать единственного в радиусе.
objective_by_core = _load_objective_by_core(db)
# obj_id'ы, для которых нужны координаты domrf (tier_b + ambiguous)
needed_obj_ids = [m.domrf_obj_id for m in tier_b] + [m.domrf_obj_id for m in ambiguous]
domrf_coords = _load_domrf_coords(db, needed_obj_ids)
# Кэш геокода в памяти прогона: address → (lat, lon) | None (None = «пробовали,
# DaData не дал»; кэшируем и его, чтобы не жечь квоту на повтор одного адреса).
geo_cache: dict[str, tuple[float, float] | None] = {}
def _geocode(addr: str) -> tuple[float, float] | None:
"""Геокод с кэшем + лимитом вызовов. None при отсутствии/лимите.
Адреса objective_lots идут БЕЗ города («ул. Гидрострой, д. 2а»)
DaData на таком отдаёт qc_geo=5 (нет координат): прод-прогон 2026-07-03
дал confirmed=0/45 именно из-за этого. Объектив покрывает только ЕКБ
безопасно префиксуем городом, если его нет в строке.
"""
if addr in geo_cache:
return geo_cache[addr]
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT:
report.call_limit_hit = True
return None
query = addr if "екатеринбург" in addr.lower() else f"г Екатеринбург, {addr}"
report.dadata_calls += 1
coords = clean_address(query)
geo_cache[addr] = coords
return coords
# ── tier_b: 1 кандидат, подтверждаем близостью ────────────────────────────
for m in tier_b:
domrf_pt = domrf_coords.get(m.domrf_obj_id)
addr = addresses.get(m.objective_project_name)
if not addr:
report.rejected.append(_geo_reject(m, "tier_b", "no_address"))
continue
if domrf_pt is None:
# у domrf нет координат — дистанцию не посчитать; не жжём квоту на геокод
report.rejected.append(_geo_reject(m, "tier_b", "no_geocode"))
continue
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache:
report.call_limit_hit = True
report.rejected.append(_geo_reject(m, "tier_b", "call_limit"))
continue
coords = _geocode(addr)
if coords is None:
report.rejected.append(_geo_reject(m, "tier_b", "no_geocode"))
continue
dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1])
if dist <= max_distance_m:
report.confirmed.append(_geo_confirm(m, addr, dist, "tier_b"))
else:
report.rejected.append(_geo_reject(m, "tier_b", "too_far", dist))
# ── ambiguous: геокодим всех кандидатов ядра, confirm если РОВНО ОДИН близко ─
for m in ambiguous:
domrf_pt = domrf_coords.get(m.domrf_obj_id)
if domrf_pt is None:
report.rejected.append(_geo_reject(m, "ambiguous", "no_geocode"))
continue
candidates = objective_by_core.get(m.core, [])
in_radius: list[tuple[str, int | None, str, float]] = []
any_geocoded = False
geocoded_count = 0
for proj_name, _devs, proj_id in candidates:
addr = addresses.get(proj_name)
if not addr:
continue
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache:
report.call_limit_hit = True
continue
coords = _geocode(addr)
if coords is None:
continue
any_geocoded = True
geocoded_count += 1
dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1])
if dist <= max_distance_m:
in_radius.append((proj_name, proj_id, addr, dist))
# Ревью 2026-07-03: «ровно один в радиусе» доверяем ТОЛЬКО когда
# геокодились ВСЕ кандидаты ядра. Иначе «один» — артефакт отсутствия
# данных (сосед без адреса/геокода мог быть ближе) → слепой confirm
# запрещён, остаётся честный reject partial_geocode.
if len(in_radius) == 1 and geocoded_count < len(candidates):
report.rejected.append(_geo_reject(m, "ambiguous", "partial_geocode", in_radius[0][3]))
elif len(in_radius) == 1:
proj_name, proj_id, addr, dist = in_radius[0]
report.confirmed.append(
GeoMatch(
domrf_obj_id=m.domrf_obj_id,
domrf_comm_name=m.domrf_comm_name,
core=m.core,
objective_project_name=proj_name,
objective_project_id=proj_id,
address=addr,
distance_m=dist,
origin="ambiguous",
)
)
elif not any_geocoded:
reason = "call_limit" if report.call_limit_hit else "no_geocode"
report.rejected.append(_geo_reject(m, "ambiguous", reason))
else:
# 0 в радиусе, или >1 в радиусе → остаётся ambiguous
nearest = min((d for *_, d in in_radius), default=None)
report.rejected.append(_geo_reject(m, "ambiguous", "ambiguous_multi", nearest))
logger.info(
"find_geo_matches: %s call_limit_hit=%s",
report.counts(),
report.call_limit_hit,
)
return report
def _load_objective_by_core(db: Session) -> dict[str, list[tuple[str, set[str], int | None]]]:
"""core → [(project_name, {devs}, project_id), ...] (как в find_core_matches)."""
out: dict[str, list[tuple[str, set[str], int | None]]] = {}
for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all():
project_name = str(row[0])
developers = {str(d) for d in (row[1] or []) if d}
project_id = int(row[2]) if row[2] is not None else None
core = normalize_complex_name(project_name)
if not core:
continue
out.setdefault(core, []).append((project_name, developers, project_id))
return out
def _geo_confirm(m: CoreMatch, address: str, distance_m: float, origin: str) -> GeoMatch:
return GeoMatch(
domrf_obj_id=m.domrf_obj_id,
domrf_comm_name=m.domrf_comm_name,
core=m.core,
objective_project_name=m.objective_project_name,
objective_project_id=m.objective_project_id,
address=address,
distance_m=distance_m,
origin=origin,
)
def _geo_reject(
m: CoreMatch, origin: str, reason: str, distance_m: float | None = None
) -> GeoReject:
return GeoReject(
domrf_obj_id=m.domrf_obj_id,
domrf_comm_name=m.domrf_comm_name,
objective_project_name=m.objective_project_name,
origin=origin,
reason=reason,
distance_m=distance_m,
)
def apply_geo_matches(
db: Session,
report: GeoMatchReport,
*,
dry_run: bool = True,
) -> dict[str, int]:
"""Вставить report.confirmed в objective_complex_mapping (auto_core_geo_v6).
Та же per-row SAVEPOINT + ON CONFLICT (objective_complex_name, objective_group)
DO NOTHING механика, что в apply_core_matches. Каждая строка:
match_method='auto_core_geo_v6', match_score=0.80, is_reviewed=false,
note с дистанцией в метрах и origin (tier_b/ambiguous).
Args:
db: SQLAlchemy sync Session.
report: результат find_geo_matches().
dry_run: True (default) только counts + первые 30 пар с дистанциями,
БЕЗ insert.
Returns:
dict: inserted, conflict_skipped, error_skipped, confirmed_total.
"""
counts = report.counts()
logger.info(
"apply_geo_matches (dry_run=%s): confirmed=%d rejected=%d dadata_calls=%d",
dry_run,
counts["confirmed"],
counts["rejected"],
counts["dadata_calls"],
)
for gm in report.confirmed[:30]:
logger.info(
" confirm[%s]: obj_id=%s core=%r domrf=%r → objective=%r (%.0f м, addr=%r)",
gm.origin,
gm.domrf_obj_id,
gm.core,
gm.domrf_comm_name,
gm.objective_project_name,
gm.distance_m,
gm.address[:60],
)
if dry_run:
return {
"inserted": 0,
"conflict_skipped": 0,
"error_skipped": 0,
"confirmed_total": counts["confirmed"],
}
inserted = 0
conflict_skipped = 0
error_skipped = 0
for gm in report.confirmed:
note = f"#2177 geo-pass ({gm.origin}): {gm.distance_m:.0f} м to domrf point"
try:
with db.begin_nested():
result = db.execute(
text(
"""
INSERT INTO objective_complex_mapping
(objective_complex_name, objective_project_id, domrf_obj_id,
objective_group, match_method, match_score, is_reviewed, note)
VALUES (
CAST(:name AS text),
CAST(:project_id AS bigint),
CAST(:obj_id AS bigint),
CAST(:group AS text),
CAST(:method AS text),
CAST(:score AS numeric),
CAST(:reviewed AS boolean),
CAST(:note AS text)
)
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
"""
),
{
"name": gm.objective_project_name,
"project_id": gm.objective_project_id,
"obj_id": gm.domrf_obj_id,
"group": OBJECTIVE_GROUP,
"method": GEO_MATCH_METHOD,
"score": GEO_MATCH_SCORE,
"reviewed": False,
"note": note,
},
)
if result.rowcount > 0:
inserted += 1
else:
conflict_skipped += 1
except Exception as e:
logger.warning(
"geo insert failed для %s%s: %s",
gm.domrf_comm_name,
gm.objective_project_name,
e,
)
error_skipped += 1
db.commit()
logger.info(
"apply_geo_matches done: inserted=%d conflict_skipped=%d error_skipped=%d",
inserted,
conflict_skipped,
error_skipped,
)
return {
"inserted": inserted,
"conflict_skipped": conflict_skipped,
"error_skipped": error_skipped,
"confirmed_total": counts["confirmed"],
}
def run_geo_pass(
*, apply: bool = False, max_distance_m: float = GEO_MAX_DISTANCE_M
) -> dict[str, int]:
"""CLI-обёртка geo-pass: открыть Session, найти гео-матчи, (опц.) применить.
Дефолт dry-run. Реальный insert только при apply=True.
"""
from app.core.db import SessionLocal
db = SessionLocal()
try:
report = find_geo_matches(db, max_distance_m=max_distance_m)
return apply_geo_matches(db, report, dry_run=not apply)
finally:
db.close()
def _main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="objective_backfill passes (#2177): core-pass (auto_core_dev_v5), "
"geo-pass (auto_core_geo_v6)."
)
parser.add_argument(
"--core-pass",
action="store_true",
help="Запустить core-name + dev-confirm проход (auto_core_dev_v5).",
)
parser.add_argument(
"--geo-pass",
action="store_true",
help="Запустить DaData-гео проход по tier_b/ambiguous остатку core-pass "
"(auto_core_geo_v6).",
)
parser.add_argument(
"--max-distance-m",
type=float,
default=GEO_MAX_DISTANCE_M,
help="Радиус подтверждения гео-матча в метрах (geo-pass; "
f"default {GEO_MAX_DISTANCE_M:.0f}).",
)
parser.add_argument(
"--apply",
action="store_true",
help="Реально писать матчи в БД (по умолчанию — dry-run).",
)
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if not args.core_pass and not args.geo_pass:
parser.print_help()
return 0
if args.core_pass:
result = run_core_pass(apply=args.apply)
logger.info("core-pass result: %s", result)
if args.geo_pass:
geo_result = run_geo_pass(apply=args.apply, max_distance_m=args.max_distance_m)
logger.info("geo-pass result: %s", geo_result)
return 0
if __name__ == "__main__":
sys.exit(_main())

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,325 @@
"""Оркестратор полного PDF-отчёта ПТИЦА (эпик #2259 PR-D).
Собирает единый PDF `/site-finder/analysis/{cad}` из уже-персистнутых ранов + дотянутых
на лету блоков (connection-capacity, концепция), рендерит карты (PR-C) и пишет файл на
volume `/app/reports/` с метадата-строкой в `analysis_runs` (schema `report-pdf-1.0`).
ПОТОК (:func:`build_full_report`):
1. analyze-ран (`latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)`) нет
ValueError (отчёт без базового анализа бессмысленен).
2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) нет Part B (§4§6)
деградирует «нет данных», отчёт всё равно валиден (передаём {} в part_b).
3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с
теми же id уже есть И файл на месте cache-hit, PDF не пере-рендерим.
4. connection-capacity (`get_connection_capacity`) best-effort, для §3-резервов.
5. концепция server-side генерация (как POST /concepts): `ConceptInput` из geom
участка (дефолт count=3 comfort) `geometry.generate`. Best-effort: упала/нет geom
отчёт без §7-концепции (§5 деградирует в рыночный контекст).
6. HTML (PR-A/B) + карты (PR-C: `render_parcel_map_png` / `render_concept_footprint_png`
`embed_map_png`, PNG max_px=1400) PDF (:func:`render_full_report_pdf`).
7. Запись файла + метадата-ран `report-pdf-1.0` (result = pdf_path/analyze_run_id/
forecast_run_id/generated_at/size_bytes).
WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native
ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`).
"""
from __future__ import annotations
import datetime as _dt
import logging
import os
import re
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.analysis_runs.repository import (
ANALYZE_SCHEMA_VERSION,
latest_run_for,
persist_analysis_run,
)
from app.services.exporters.full_report_html import (
MAP_CONCEPT_PLACEHOLDER,
MAP_PARCEL_PLACEHOLDER,
build_full_report_html,
build_full_report_html_part_a,
build_full_report_html_part_b,
)
from app.services.exporters.report_maps import (
embed_map_png,
render_concept_footprint_png,
render_parcel_map_png,
)
logger = logging.getLogger(__name__)
# schema_version метадата-строки отчёта в analysis_runs. Собственная схема (НЕ analyze /
# НЕ §22-форсайт "1.0") — по ней ищем существующий отчёт для cache-hit.
REPORT_SCHEMA_VERSION = "report-pdf-1.0"
# schema_version §22-форсайт-рана (зеркало `_FORECAST_SCHEMA_VERSION` в parcels.py — НЕ
# "forecast-1.0", а именно "1.0", это SiteFinderReport._SCHEMA_VERSION).
_FORECAST_SCHEMA_VERSION = "1.0"
# Верхняя граница длинной стороны карт-PNG (px) — печатный A4, 1400 достаточно для
# ~150 dpi на ширину колонки, но не раздувает PDF гигабайтными растрами.
_MAP_MAX_PX = 1400
# Радиус поиска ЦП для connection-capacity (м) — тот же дефолт, что у эндпоинта
# GET /{cad}/connection-capacity (parcels.py) и функции get_connection_capacity.
_CONNECTION_CAPACITY_RADIUS_M = 3000
def render_full_report_pdf(html: str) -> bytes:
"""Отрендерить готовый HTML полного отчёта в PDF-байты (WeasyPrint).
Тонкая обёртка: WeasyPrint импортируется ЛОКАЛЬНО (тяжёлый native не нужен при
импорте модуля, иначе ломает pytest-сбор на macOS без GTK/Pango). Образец
`layout_tz_pdf.render_layout_tz_pdf`.
Args:
html: полный HTML-документ (`build_full_report_html`).
Returns:
PDF-байты (готовы к записи файла / FileResponse).
"""
from weasyprint import HTML
pdf_bytes: bytes = HTML(string=html).write_pdf()
logger.info("render_full_report_pdf: %d bytes", len(pdf_bytes))
return pdf_bytes
def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str, Any] | None:
"""Сгенерировать концепцию server-side как это делает POST /concepts (best-effort).
Концепция НЕ персистится генерируем на лету из геометрии участка с ДЕФОЛТНОЙ
программой (count=3 comfort три стратегии жадной раскладки). Повторяем поток
`create_concept` (concepts.py): рыночная цена через `_lookup_market_price` по
центроиду `geometry.generate` сериализуем `ConceptOutput`. Любая ошибка (нет
geom / вырожденный участок / сбой БД) None: отчёт валиден без §7-концепции,
§5 деградирует в рыночный контекст.
Args:
db: SQLAlchemy session (для lookup рыночной цены).
analyze: persist-payload analyze-рана (несёт `geom_geojson` участка).
Returns:
Сериализованный `ConceptOutput` (dict `{"variants": [...]}`) или None.
"""
geom = analyze.get("geom_geojson")
if not isinstance(geom, dict):
logger.info("build_full_report: analyze-payload без geom_geojson → §7-концепция пропущена")
return None
try:
# Lazy import — тяжёлый generative-стек не нужен на module-load; concepts-хелперы
# цены живут в API-слое (он знает БД), переиспользуем ИМЕННО их (single source).
from app.api.v1.concepts import _lookup_market_price, _parcel_centroid_wkt
from app.schemas.concept import ConceptInput, ConceptOutput
from app.services.generative import geometry
payload = ConceptInput(parcel_geojson=geom) # дефолт: housing_class="comfort", 3 стратегии
market_price: float | None = None
price_source = "class_norm"
try:
wkt_point = _parcel_centroid_wkt(payload)
market_price, price_source = _lookup_market_price(db, wkt_point)
except Exception as exc:
# Рыночная цена best-effort — деградируем в норматив класса, не роняем концепцию.
logger.warning("build_full_report: market-price lookup упал, class_norm: %s", exc)
variants = geometry.generate(
payload,
market_price_per_sqm=market_price,
price_source=price_source,
)
result: dict[str, Any] = ConceptOutput(variants=variants).model_dump()
logger.info("build_full_report: концепция сгенерирована (%d вариантов)", len(variants))
return result
except Exception:
# Вырожденный участок / сбой generative-стека — отчёт валиден БЕЗ §7-концепции.
logger.exception("build_full_report: генерация концепции упала → §7 без концепции")
return None
def _get_connection_capacity(db: Session, cad: str) -> dict[str, Any] | None:
"""Дотянуть connection-capacity для §3-резервов (best-effort).
connection-capacity НЕ в persist-payload (ленивый эндпоинт) тянем `get_connection_capacity`
как это делает GET /{cad}/connection-capacity. Ошибка (участок без geom / сбой БД) None:
§3 деградирует к тому, что есть в analyze-payload (OSM-сети + НСПД-сооружения).
"""
try:
from app.services.site_finder.connection_capacity_lookup import get_connection_capacity
return get_connection_capacity(db, cad, _CONNECTION_CAPACITY_RADIUS_M)
except Exception:
logger.exception("build_full_report: connection-capacity lookup упал → §3 без резервов")
return None
def _find_cached_report(
db: Session,
cad: str,
analyze_run_id: int | None,
forecast_run_id: int | None,
) -> dict[str, Any] | None:
"""Найти готовый метадата-ран `report-pdf-1.0` с тем же кэш-ключом + существующим файлом.
Кэш-ключ = (analyze_run_id, forecast_run_id). Совпали ОБА id И файл `pdf_path` на
месте возвращаем result метадата-рана (cache-hit, не пере-рендерим). Иначе None.
"""
row = latest_run_for(db, cad, schema_version=REPORT_SCHEMA_VERSION)
if row is None:
return None
result = row.result if isinstance(row.result, dict) else {}
if (
result.get("analyze_run_id") == analyze_run_id
and result.get("forecast_run_id") == forecast_run_id
):
pdf_path = result.get("pdf_path")
if isinstance(pdf_path, str) and os.path.exists(pdf_path):
return result
return None
def _cad_safe(cad: str) -> str:
"""Кад.номер → безопасный компонент имени файла (whitelist).
Кад.номер приходит из URL-path жёстко ограничиваем алфавит `[0-9:]` (цифры +
разделители кад.номера РФ), ВСЁ прочее вырезаем, затем `:` `_`. Так в имя файла
физически не попадут `/`, `..`, пробелы или спецсимволы (path-traversal / инъекция
в путь), даже если path-param окажется мусорным.
"""
return re.sub(r"[^0-9:]", "", cad).replace(":", "_")
def build_full_report(db: Session, cad: str) -> dict[str, Any]:
"""Собрать (или вернуть из кэша) полный PDF-отчёт участка + метадата-ран. #2259 PR-D.
Оркестрация: analyze-ран (нет ValueError) forecast-ран (нет Part B «нет данных»)
cache-check по (analyze_run_id, forecast_run_id) connection-capacity (§3-резервы,
best-effort) концепция server-side (best-effort) HTML (PR-A/B) + карты (PR-C)
PDF (WeasyPrint) запись файла на volume + метадата-ран `report-pdf-1.0`.
Args:
db: SQLAlchemy session.
cad: кадастровый номер участка.
Returns:
Метадата отчёта: {pdf_path, analyze_run_id, forecast_run_id, generated_at,
size_bytes} (+ `cache_hit: True` если вернули существующий файл).
Raises:
ValueError: нет analyze-рана для участка (нечего отчётировать).
"""
analyze_row = latest_run_for(db, cad, schema_version=ANALYZE_SCHEMA_VERSION)
if analyze_row is None:
raise ValueError(f"нет analyze-рана для {cad!r} — сначала запустите /analyze")
analyze: dict[str, Any] = analyze_row.result or {}
analyze_run_id = int(analyze_row.id)
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {}
forecast_run_id = int(forecast_row.id) if forecast_row is not None else None
# Cache-hit: тот же (analyze, forecast) ран-ключ + файл на месте → не пере-рендерим.
cached = _find_cached_report(db, cad, analyze_run_id, forecast_run_id)
if cached is not None:
logger.info(
"build_full_report: cache-hit cad=%s analyze=%s forecast=%s path=%s",
cad,
analyze_run_id,
forecast_run_id,
cached.get("pdf_path"),
)
return {**cached, "cache_hit": True}
# connection-capacity (§3-резервы) + концепция (§7) — оба best-effort.
connection_capacity = _get_connection_capacity(db, cad)
concept = _generate_concept_result(db, analyze)
# Адрес участка для титула (egrn.address; None → строка адреса не рисуется).
egrn = analyze.get("egrn") if isinstance(analyze.get("egrn"), dict) else {}
address = egrn.get("address") if isinstance(egrn, dict) else None
generated_at = _dt.datetime.now(_dt.UTC)
generated_at_ru = generated_at.strftime("%d.%m.%Y")
# HTML (PR-A/B): §1§3 (+ connection-capacity в §3) и §4§7 (пустой forecast → «нет данных»).
part_a = build_full_report_html_part_a(
analyze, cad=cad, connection_capacity=connection_capacity
)
part_b = build_full_report_html_part_b(forecast, concept, cad=cad)
html = build_full_report_html(
part_a,
part_b,
cad=cad,
address=address if isinstance(address, str) else None,
generated_at=generated_at_ru,
)
# Карты (PR-C): участок (§1/§3) + концепция (§7, лучший вариант). Оба graceful:
# None → embed_map_png ставит плашку «карта недоступна».
parcel_png = render_parcel_map_png(analyze, max_px=_MAP_MAX_PX)
html = embed_map_png(html, MAP_PARCEL_PLACEHOLDER, parcel_png)
concept_png: bytes | None = None
if isinstance(concept, dict):
variants = concept.get("variants")
if isinstance(variants, list) and variants and isinstance(variants[0], dict):
concept_png = render_concept_footprint_png(
variants[0], analyze.get("geom_geojson"), max_px=_MAP_MAX_PX
)
html = embed_map_png(html, MAP_CONCEPT_PLACEHOLDER, concept_png)
pdf_bytes = render_full_report_pdf(html)
# Запись файла на volume + метадата-ран. Каталог создаём (parents, exist_ok).
reports_dir = Path(settings.reports_dir)
reports_dir.mkdir(parents=True, exist_ok=True)
file_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}.pdf"
pdf_path = reports_dir / file_name
# АТОМАРНАЯ запись: пишем в .tmp рядом и os.replace → финальный путь. Два конкурентных
# POST в один день целятся в ОДИН pdf_path (имя несёт только дату) — прямой write_bytes
# мог бы interleave-писать байты обоих рендеров в один файл (битый PDF). os.replace
# атомарен в пределах одной FS → download всегда видит целый файл (свой или чужой).
tmp_path = pdf_path.with_suffix(f".{os.getpid()}.tmp")
tmp_path.write_bytes(pdf_bytes)
os.replace(tmp_path, pdf_path)
size_bytes = len(pdf_bytes)
result: dict[str, Any] = {
"pdf_path": str(pdf_path),
"analyze_run_id": analyze_run_id,
"forecast_run_id": forecast_run_id,
"generated_at": generated_at.isoformat(),
"size_bytes": size_bytes,
}
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — PDF
# уже записан, просто следующий вызов не поймает cache-hit и пере-рендерит).
persist_analysis_run(
db,
cad_num=cad,
result=result,
params={"source": "report-pdf-task"},
district=analyze_row.district,
confidence=None,
status="done",
schema_version=REPORT_SCHEMA_VERSION,
created_by=None,
)
logger.info(
"build_full_report: cad=%s written path=%s size=%d analyze=%s forecast=%s",
cad,
pdf_path,
size_bytes,
analyze_run_id,
forecast_run_id,
)
return result

View file

@ -14,15 +14,20 @@ from app.schemas.parcel import BestLayoutsResponse
logger = logging.getLogger(__name__)
def render_layout_tz_pdf(
def build_layout_tz_html(
response: BestLayoutsResponse,
*,
cad_num: str,
parcel_address: str | None = None,
radius_km: float,
time_window: str,
) -> bytes:
"""Render ТЗ PDF от best-layouts response.
) -> str:
"""Собрать HTML ТЗ из best-layouts response (PURE — без WeasyPrint/БД/сети).
Вынесено из :func:`render_layout_tz_pdf`, чтобы HTML-разметку (наличие блоков,
RU-микрокопию, форматирование /м²) можно было проверять юнит-тестами БЕЗ
native GTK/Pango (WeasyPrint на macOS/Windows отсутствует). PDF-обёртка ниже
просто рендерит этот HTML.
Args:
response: BestLayoutsResponse от /best-layouts endpoint
@ -32,7 +37,7 @@ def render_layout_tz_pdf(
time_window: окно анализа (last_month/quarter/year)
Returns:
PDF bytes (готово для StreamingResponse)
HTML-строка документа (готова для WeasyPrint).
"""
today = dt.date.today().strftime("%d.%m.%Y")
safe_cad = _html.escape(cad_num)
@ -45,6 +50,18 @@ def render_layout_tz_pdf(
return "<td>—</td>"
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</td>"
def _price_m2_cell(val: float | None) -> str:
"""Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful)."""
if val is None:
return "<td>—</td>"
return f"<td>{val:,.0f}".replace(",", " ") + " ₽/м²</td>"
def _area_cell(val: float | None) -> str:
"""Ячейка площади, м² (1 знак). None → «—» (graceful)."""
if val is None:
return "<td>—</td>"
return f"<td>{val:.1f} м²</td>"
# Top layouts table rows
top_rows = "".join(
"<tr>"
@ -70,6 +87,42 @@ def render_layout_tz_pdf(
for m in response.recommendation_for_tz.mix
)
# §4.2 «По предложению (без темпа продаж)» (#2177 шаг 3). Аддитивный fallback-блок:
# структура текущего предложения (комплекс/тип/площадь/лотов/медиана ₽/м²) по
# комплексам, сматченным по ядру имени, но БЕЗ velocity. Graceful — на старых
# снапшотах поле отсутствует / пустой список (default []), тогда секцию не рисуем.
supply_only = getattr(response, "supply_only_layouts", None) or []
supply_only_rows = "".join(
"<tr>"
f"<td>{_html.escape(s.project_name)}</td>"
f"<td>{_html.escape(s.room_bucket)}</td>"
f"<td>{_html.escape(s.area_bin)}</td>"
f"{_area_cell(s.median_area_m2)}"
f"<td>{s.lots_count}</td>"
f"{_price_m2_cell(s.median_price_m2)}"
"</tr>"
for s in supply_only
)
supply_only_section = (
f"""
<h2>По предложению (без темпа продаж)</h2>
<p class="caveat">
Комплексы сматчены по ядру имени на объявления Объектива, но по ним НЕТ сделок
(темпа продаж) ниже только структура остатков в продаже (objective_lots),
БЕЗ ранжирования по скорости. Дополняет топ продаж выше, не заменяет его.
</p>
<table>
<thead><tr>
<th>Комплекс</th><th>Комнаты</th><th>Площадь</th>
<th>Ср. площадь</th><th>Лотов в продаже</th><th>Медиана, /м²</th>
</tr></thead>
<tbody>{supply_only_rows}</tbody>
</table>
"""
if supply_only
else ""
)
rec = response.recommendation_for_tz
safe_rationale = _html.escape(rec.rationale_text)
weighted_price = (
@ -95,6 +148,8 @@ def render_layout_tz_pdf(
th {{ background: #f5f5f5; font-weight: bold; }}
.rationale {{ background: #f8f8f8; padding: 10px; border-left: 3px solid #4a90e2;
margin: 1em 0; }}
.caveat {{ background: #fffbeb; padding: 8px 10px; border-left: 3px solid #c9a132;
margin: 0.6em 0; color: #7a5c12; font-size: 9.5pt; }}
.footer {{ margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd;
color: #888; font-size: 9pt; }}
.confidence-high {{ color: #2a8c2a; }}
@ -133,11 +188,11 @@ def render_layout_tz_pdf(
</tr></thead>
<tbody>{top_rows}</tbody>
</table>
{supply_only_section}
<h2>Качество данных</h2>
<p>
Покрытие: {dq.objects_with_velocity_data} из
{dq.objects_total_in_radius} ЖК с данными velocity
{dq.objects_total_in_radius} комплексов с данными velocity
({dq.velocity_coverage_pct:.1f}%)
</p>
<p>
@ -154,6 +209,40 @@ def render_layout_tz_pdf(
</body>
</html>"""
return html
def render_layout_tz_pdf(
response: BestLayoutsResponse,
*,
cad_num: str,
parcel_address: str | None = None,
radius_km: float,
time_window: str,
) -> bytes:
"""Render ТЗ PDF от best-layouts response.
Тонкая обёртка над :func:`build_layout_tz_html` собирает HTML (PURE) и
рендерит его WeasyPrint'ом в PDF-байты.
Args:
response: BestLayoutsResponse от /best-layouts endpoint
cad_num: кадастровый номер участка
parcel_address: optional human address (если known через geocoder)
radius_km: радиус анализа конкурентов
time_window: окно анализа (last_month/quarter/year)
Returns:
PDF bytes (готово для StreamingResponse)
"""
html = build_layout_tz_html(
response,
cad_num=cad_num,
parcel_address=parcel_address,
radius_km=radius_km,
time_window=time_window,
)
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
from weasyprint import HTML

View file

@ -0,0 +1,386 @@
"""Серверный статик-рендер карт для PDF-отчёта ПТИЦА (эпик #2259 PR-C).
Рендерит два вида PNG-карт БЕЗ браузера (headless matplotlib + geopandas + contextily):
:func:`render_parcel_map_png` карта участка (§1/§3): полигон участка (заливка +
контур), поверх риск-зоны НСПД / красные линии / ЗОУИТ-пересечения (если геометрии
присутствуют в persist-payload) + конкуренты точками. Подложка OSM-тайлы через
contextily; auto-bbox = участок + буфер.
:func:`render_concept_footprint_png` карта концепции (§7): контур участка + пятна
застройки (footprint'ы корпусов из ``ConceptVariant.buildings_geojson``) с подписью
этажности (``properties.floors``).
Плюс :func:`embed_map_png` подстановка ``<img src="data:image/png;base64,…">`` вместо
строки-плейсхолдера в готовом HTML (``png=None`` плашка «карта недоступна»).
CRS-контракт: все входные геометрии EPSG:4326 (GeoJSON ``geom_geojson`` /
``geometry_geojson``, WKT ``geom_wkt`` в 4326, конкуренты lat/lng). Рисуем в Web Mercator
(EPSG:3857), т.к. OSM-тайлы contextily идут в 3857 иначе подложка не совпадёт со слоями.
GRACEFUL: нет ``geom_geojson`` ``None`` (нечего рисовать). Тайлы OSM недоступны/таймаут
белый фон + слои, PNG всё равно возвращается (никогда не роняем экспорт из-за сети).
Битые отдельные геометрии слоёв пропускаются с ``logger.warning``, не валят всю карту.
HEADLESS: ``matplotlib.use("Agg")`` вызывается на уровне модуля ДО импорта pyplot импорт
``report_maps`` не требует X11/display (запускается в Celery-воркере/Docker без GUI).
"""
from __future__ import annotations
import base64
import io
import logging
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeoutError
from contextlib import contextmanager
from typing import Any
import matplotlib
# Headless-бэкенд ДО импорта pyplot — модуль импортируется в Celery-воркере без display.
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from shapely.geometry import Point, shape
from shapely.geometry.base import BaseGeometry
logger = logging.getLogger(__name__)
# ── Константы рендера ──────────────────────────────────────────────────────────
_WGS84 = "EPSG:4326"
_WEB_MERCATOR = "EPSG:3857"
# Буфер вокруг участка при auto-bbox (доля от большей стороны bbox). Даёт «воздух»
# по краям, чтобы контур участка не липнул к рамке и был виден контекст подложки.
_BBOX_PAD_FRAC: float = 0.25
# Таймаут ожидания OSM-тайлов (сек). Тайлы тянутся синхронно внутри contextily —
# оборачиваем в поток с bounded-таймаутом, чтобы медленный/недоступный tile-server
# не подвешивал воркер. Превышение → graceful fallback на белый фон.
_BASEMAP_TIMEOUT_S: float = 10.0
# Базовый физический размер фигуры (дюймы). Итоговый пиксель-размер = fig_inches * dpi,
# ограничивается сверху ``max_px`` пересчётом dpi (см. ``_figure_to_png``).
_FIG_INCHES: float = 6.4
# Палитра слоёв (edgecolor, facecolor, alpha) — консистентно с семантикой отчёта.
_PARCEL_STYLE = {"edgecolor": "#1d4ed8", "facecolor": "#3b82f6", "alpha": 0.28, "linewidth": 1.8}
_RISK_STYLE = {"edgecolor": "#b91c1c", "facecolor": "#ef4444", "alpha": 0.30, "linewidth": 1.0}
_REDLINE_STYLE = {"color": "#dc2626", "linewidth": 1.6, "linestyle": "--"}
_ZOUIT_STYLE = {"edgecolor": "#b45309", "facecolor": "#f59e0b", "alpha": 0.22, "linewidth": 1.0}
_OPPORTUNITY_STYLE = {
"edgecolor": "#15803d",
"facecolor": "#22c55e",
"alpha": 0.20,
"linewidth": 0.9,
}
_COMPETITOR_STYLE = {"color": "#7c3aed", "marker": "o", "markersize": 5.0, "linestyle": "none"}
_CONCEPT_FOOTPRINT_STYLE = {
"edgecolor": "#0f766e",
"facecolor": "#14b8a6",
"alpha": 0.55,
"linewidth": 1.2,
}
_MAP_UNAVAILABLE_HTML = (
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют '
"в отчёте</div>"
)
# ── Парсинг входных геометрий (EPSG:4326) ──────────────────────────────────────
def _geom_from_geojson(geojson: dict[str, Any] | None) -> BaseGeometry | None:
"""GeoJSON-geometry dict → shapely-geометрия (EPSG:4326). Битый вход → None+warning."""
if not isinstance(geojson, dict):
return None
try:
geom = shape(geojson)
except Exception as exc: # любой битый GeoJSON: пропустить слой, не валить всю карту
logger.warning("report_maps: не разобрал GeoJSON-геометрию: %s", exc)
return None
if geom.is_empty:
return None
return geom
def _geom_from_wkt(wkt: str | None) -> BaseGeometry | None:
"""WKT-строка (EPSG:4326) → shapely-геометрия. Битый вход → None+warning."""
if not wkt or not isinstance(wkt, str):
return None
try:
from shapely import wkt as shapely_wkt
geom = shapely_wkt.loads(wkt)
except Exception as exc: # битый WKT слоя: пропустить геометрию, не валить всю карту
logger.warning("report_maps: не разобрал WKT-геометрию: %s", exc)
return None
if geom.is_empty:
return None
return geom
def _to_mercator(geoms: list[BaseGeometry]) -> Any:
"""Список shapely-геометрий (EPSG:4326) → GeoSeries в Web Mercator (EPSG:3857).
Импорт geopandas локальный (тяжёлый), вызывается только когда есть что рисовать.
"""
import geopandas as gpd
series = gpd.GeoSeries(geoms, crs=_WGS84)
return series.to_crs(_WEB_MERCATOR)
# ── Basemap (OSM-тайлы) с bounded-таймаутом и graceful fallback ────────────────
def _add_basemap(ax: Any) -> bool:
"""Подложить OSM-тайлы под слои с таймаутом. True — легли, False — fallback (белый фон).
contextily тянет тайлы синхронно (сеть) оборачиваем в поток с таймаутом, чтобы
недоступный tile-сервер не подвешивал воркер. Любая ошибка/таймаут белый фон,
слои остаются видны, PNG всё равно рендерится.
"""
import contextily as cx
def _fetch() -> None:
cx.add_basemap(ax, crs=_WEB_MERCATOR, source=cx.providers.OpenStreetMap.Mapnik)
try:
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S)
return True
except FuturesTimeoutError:
logger.warning(
"report_maps: OSM basemap timeout (%.0fs) — fallback белый фон", _BASEMAP_TIMEOUT_S
)
return False
except Exception as exc: # тайлы недоступны: graceful fallback на белый фон, не валим экспорт
logger.warning("report_maps: OSM basemap недоступен (%s) — fallback белый фон", exc)
return False
# ── Общие хелперы фигуры ───────────────────────────────────────────────────────
@contextmanager
def _managed_figure() -> Iterator[tuple[Figure, Any]]:
"""Контекст-менеджер фигуры: создать axes, ГАРАНТИРОВАННО закрыть в finally.
pyplot держит созданные фигуры в глобальном реестре (``plt.get_fignums()``) до
явного ``plt.close``. В долгоживущем Celery-воркере любое исключение между
созданием фигуры и ``savefig`` без close оставляло бы residual-окно растущий
RSS (утечка на 1 фигуру за каждый упавший рендер). ``finally`` закрывает фигуру
на ЛЮБОМ пути выхода успех, per-geometry-ошибка, неожиданный raise.
"""
fig = plt.figure(figsize=(_FIG_INCHES, _FIG_INCHES))
try:
ax = fig.add_subplot(1, 1, 1)
ax.set_axis_off()
ax.set_aspect("equal")
yield fig, ax
finally:
plt.close(fig)
def _apply_bbox(ax: Any, parcel_merc: Any) -> None:
"""Выставить границы axes по bbox участка + буфер (``_BBOX_PAD_FRAC``)."""
minx, miny, maxx, maxy = parcel_merc.total_bounds
span = max(maxx - minx, maxy - miny) or 1.0
pad = span * _BBOX_PAD_FRAC
ax.set_xlim(minx - pad, maxx + pad)
ax.set_ylim(miny - pad, maxy + pad)
def _figure_to_png(fig: Figure, max_px: int) -> bytes:
"""Фигуру → PNG-bytes с dpi, подобранным так, чтобы длинная сторона ≤ ``max_px``.
Закрытие фигуры НЕ здесь: им владеет ``_managed_figure`` (finally покрывает и
путь, где ``savefig`` сам кинул исключение).
"""
dpi = max(72.0, min(300.0, float(max_px) / _FIG_INCHES))
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.05)
return buf.getvalue()
# ── Публичный API ──────────────────────────────────────────────────────────────
def render_parcel_map_png(analyze_result: dict[str, Any], *, max_px: int = 1600) -> bytes | None:
"""Карта участка + слои НСПД + конкуренты → PNG-bytes. Нет ``geom_geojson`` → None.
Args:
analyze_result: persist-payload ``analysis_runs.result`` (schema analyze-1.0).
Читаемые ключи с геометрией: ``geom_geojson`` (обязателен), ``nspd_risk_zones``,
``nspd_red_lines``, ``nspd_opportunity_parcels`` (WKT ``geom_wkt``),
``nspd_zouit_overlaps`` (``geometry_geojson``), ``competitors`` (``lat``/``lng``).
max_px: верхняя граница длинной стороны PNG в пикселях.
Returns:
PNG-bytes, либо None если геометрии участка нет (нечего рисовать).
"""
parcel = _geom_from_geojson(analyze_result.get("geom_geojson"))
if parcel is None:
return None
with _managed_figure() as (fig, ax):
# Участок — базовый слой, задаёт bbox.
parcel_merc = _to_mercator([parcel])
parcel_merc.plot(ax=ax, **_PARCEL_STYLE)
_apply_bbox(ax, parcel_merc)
# Риск-зоны (WKT).
_plot_wkt_layer(ax, analyze_result.get("nspd_risk_zones"), _RISK_STYLE)
# ЗОУИТ-пересечения (GeoJSON geometry_geojson).
_plot_geojson_layer(ax, analyze_result.get("nspd_zouit_overlaps"), _ZOUIT_STYLE)
# Opportunity-участки (WKT).
_plot_wkt_layer(ax, analyze_result.get("nspd_opportunity_parcels"), _OPPORTUNITY_STYLE)
# Красные линии (WKT, линии — без заливки: _REDLINE_STYLE несёт color, не facecolor).
_plot_wkt_layer(ax, analyze_result.get("nspd_red_lines"), _REDLINE_STYLE)
# Конкуренты — точки lat/lng.
_plot_competitors(ax, analyze_result.get("competitors"))
_add_basemap(ax) # graceful: False → просто белый фон, PNG всё равно вернётся
return _figure_to_png(fig, max_px)
def render_concept_footprint_png(
concept_variant: dict[str, Any],
parcel_geojson: dict[str, Any] | None,
*,
max_px: int = 1600,
) -> bytes | None:
"""Карта концепции: контур участка + пятна корпусов с подписью этажности → PNG-bytes.
Args:
concept_variant: dict-представление ``ConceptVariant`` с ключом ``buildings_geojson``
(FeatureCollection, ``properties.floors`` / ``footprint_sqm`` / ``section_id``).
parcel_geojson: GeoJSON-геометрия участка (контур-подложка). None рисуем только
корпуса (bbox по ним).
max_px: верхняя граница длинной стороны PNG в пикселях.
Returns:
PNG-bytes, либо None если нет ни участка, ни корпусов (нечего рисовать).
"""
fc = concept_variant.get("buildings_geojson") if isinstance(concept_variant, dict) else None
features = fc.get("features", []) if isinstance(fc, dict) else []
footprints: list[BaseGeometry] = []
floors_labels: list[int | None] = []
for feat in features:
if not isinstance(feat, dict):
continue
geom = _geom_from_geojson(feat.get("geometry"))
if geom is None:
continue
footprints.append(geom)
props = feat.get("properties") or {}
raw_floors = props.get("floors") if isinstance(props, dict) else None
floors_labels.append(int(raw_floors) if isinstance(raw_floors, int | float) else None)
parcel = _geom_from_geojson(parcel_geojson)
if parcel is None and not footprints:
return None
with _managed_figure() as (fig, ax):
# bbox: по участку если есть, иначе по совокупности footprint'ов.
bbox_source = _to_mercator([parcel]) if parcel is not None else _to_mercator(footprints)
if parcel is not None:
# Участок — только контур (без заливки), корпуса рисуются поверх.
_to_mercator([parcel]).plot(ax=ax, facecolor="none", edgecolor="#1d4ed8", linewidth=1.8)
if footprints:
fp_merc = _to_mercator(footprints)
fp_merc.plot(ax=ax, **_CONCEPT_FOOTPRINT_STYLE)
# Подпись этажности в центроиде каждого корпуса.
for geom_merc, floors in zip(fp_merc.geometry, floors_labels, strict=True):
if floors is None:
continue
c = geom_merc.centroid
ax.annotate(
f"{floors} эт.",
(c.x, c.y),
ha="center",
va="center",
fontsize=7.0,
color="#0f172a",
)
_apply_bbox(ax, bbox_source)
_add_basemap(ax)
return _figure_to_png(fig, max_px)
def embed_map_png(html: str, placeholder: str, png: bytes | None) -> str:
"""Заменить строку-``placeholder`` в HTML на ``<img>`` с data-URI PNG.
``png=None`` плашка «карта недоступна» (тот же класс ``.map-placeholder``, что и
в full_report_html стиль уже есть в шаблоне).
"""
if png is None:
return html.replace(placeholder, _MAP_UNAVAILABLE_HTML)
b64 = base64.b64encode(png).decode("ascii")
img = (
f'<img class="report-map" src="data:image/png;base64,{b64}" '
'style="width:100%;max-width:100%;height:auto;display:block;margin:8pt 0;'
'border:1px solid #e5e7eb;border-radius:6pt;" alt="Карта участка" />'
)
return html.replace(placeholder, img)
# ── Приватные слой-плоттеры ────────────────────────────────────────────────────
def _plot_wkt_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
"""Отрисовать слой из списка dict'ов с ключом ``geom_wkt`` (EPSG:4326).
Заливка/линия задаётся ``style`` (наличие ``facecolor`` полигон, ``color`` без
``facecolor`` линия) geopandas ``plot`` разбирает это сам по типу геометрии.
"""
geoms = _collect_wkt_geoms(items)
if not geoms:
return
_to_mercator(geoms).plot(ax=ax, **style)
def _plot_geojson_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
"""Отрисовать слой из списка dict'ов с ключом ``geometry_geojson`` (EPSG:4326)."""
if not isinstance(items, list):
return
geoms = [
g
for it in items
if isinstance(it, dict)
and (g := _geom_from_geojson(it.get("geometry_geojson"))) is not None
]
if not geoms:
return
_to_mercator(geoms).plot(ax=ax, **style)
def _collect_wkt_geoms(items: Any) -> list[BaseGeometry]:
"""Собрать shapely-геометрии из списка dict'ов по ключу ``geom_wkt``."""
if not isinstance(items, list):
return []
out: list[BaseGeometry] = []
for it in items:
if not isinstance(it, dict):
continue
geom = _geom_from_wkt(it.get("geom_wkt"))
if geom is not None:
out.append(geom)
return out
def _plot_competitors(ax: Any, competitors: Any) -> None:
"""Отрисовать конкурентов точками из ``lat``/``lng`` (EPSG:4326).
Внимание: у ``Competitor`` долгота в ключе ``lng`` (не ``lon``) см. schemas/parcel.py.
"""
if not isinstance(competitors, list):
return
pts: list[BaseGeometry] = []
for c in competitors:
if not isinstance(c, dict):
continue
lat, lng = c.get("lat"), c.get("lng")
if isinstance(lat, int | float) and isinstance(lng, int | float):
pts.append(Point(float(lng), float(lat)))
if not pts:
return
_to_mercator(pts).plot(ax=ax, **_COMPETITOR_STYLE)

View file

@ -259,13 +259,46 @@ def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
"""
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
if coverage is None:
note = "покрытие domrf↔objective неизвестно"
note = (
"Доля будущих проектов с известными планировками и площадями неизвестна — "
"оценка будущего предложения и конкуренции менее надёжна"
)
else:
pct = round(float(coverage) * 100.0, 1)
note = f"покрытие domrf↔objective {pct}% — {_QUALITY_WORD[level]}"
note = (
f"Известные планировки и площади есть у {pct}% будущих проектов "
f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза "
"будущего предложения и конкуренции"
)
return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)
def _history_factor(history_months: int | None) -> ConfidenceFactor:
"""Глубина ряда (мес) → ConfidenceFactor с «на что влияет» + связью с 6.2. PURE.
Короткий ряд не даёт оценить тренды и чувствительность к ставке (§9.6) а это
та же причина, по которой сценарии (6.2) схлопываются в один. Нота называет
реальное число месяцев И что от этого зависит. None low (нет сигнала). PURE.
"""
level = _level_from_value(
history_months, high_at=_HISTORY_MONTHS_HIGH, low_below=_HISTORY_MONTHS_LOW
)
if history_months is None:
note = (
"Длина истории продаж неизвестна — тренды и чувствительность к ставке "
"оценить нельзя (см. сценарии в 6.2)"
)
else:
note = (
f"{int(history_months)} мес истории ({_QUALITY_WORD[level]}) — на коротком "
"ряде тренды и чувствительность спроса к ставке оцениваются хуже "
"(поэтому в 6.2 может остаться один сценарий вместо трёх)"
)
return ConfidenceFactor(
name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note
)
def _confounded_factor(confounded: bool) -> ConfidenceFactor:
"""Шок-окно → ConfidenceFactor. PURE.
@ -483,15 +516,7 @@ def compute_report_confidence(
if domrf_coverage is not None:
factors.append(_coverage_factor(domrf_coverage))
if history_months is not None:
factors.append(
_factor_from_count(
_F_HISTORY_MONTHS,
history_months,
high_at=_HISTORY_MONTHS_HIGH,
low_below=_HISTORY_MONTHS_LOW,
unit="мес истории",
)
)
factors.append(_history_factor(history_months))
# Шок-окно учитываем ТОЛЬКО когда оно есть (True): чистое окно не должно
# искусственно тянуть к 'high', если других сигналов нет (см. graceful ниже).
if confounded:

View file

@ -73,7 +73,14 @@ from app.services.forecasting.demand_normalization import compute_demand_normali
from app.services.forecasting.macro_coefficient import compute_macro_coefficient
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
from app.services.forecasting.regression import compute_rate_regime_sensitivity
from app.services.forecasting.sales_series import SegmentSpec
from app.services.forecasting.sales_series import (
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
ROOM_AREA_BUCKET_STUDIO,
SegmentSpec,
)
from app.services.site_finder.competitors import get_competitors
from app.services.site_finder.future_supply import compute_future_supply_pressure
from app.services.site_finder.market_metrics import compute_market_metrics
@ -121,6 +128,51 @@ _CONFIDENCE_CAP: Confidence = "medium"
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
# #1959 VOCAB-мост (load-bearing): SegmentSpec.room_bucket приходит из what_to_build
# в Source-B room_area-вокабуляре ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/
# "3-к 60-80"/"80+ м²"). compute_market_metrics (после #1959) принимает ИМЕННО этот
# вокабуляр напрямую (его room CASE = зеркало sales_series.room_area_bucket_of). Эта
# white-list страхует от чужих/неизвестных меток: распознанные пробрасываем как есть,
# неизвестные (или None) → None = «без room-фильтра» (district-wide темп, но НЕ тихий
# 0-rows из-за опечатки в вокабуляре). Меняешь сетку room_buckets — обнови white-list.
_FORECAST_ROOM_BUCKETS: frozenset[str] = frozenset(
{
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
}
)
def _market_room_bucket(room_bucket: str | None) -> str | None:
"""Перевести SegmentSpec.room_bucket в room-фильтр compute_market_metrics. PURE.
Вокабуляр уже выровнен (#1959): market_metrics принимает Source-B room_area-метки
напрямую. Эта функция VALIDATING pass-through: распознанная Source-B метка
возвращается КАК ЕСТЬ; None / неизвестная метка None (= «без room-фильтра»,
district-wide темп). Без этой проверки опечатка/чужой вокабуляр дали бы тихий
0-rows фильтр метрики None ячейка молча выпадала бы из ранкинга. PURE.
Args:
room_bucket: room_bucket сегмента (ожидается Source-B вокабуляр) или None.
Returns:
Та же Source-B метка (если в белом списке) либо None.
"""
if room_bucket is None:
return None
if room_bucket in _FORECAST_ROOM_BUCKETS:
return room_bucket
logger.warning(
"demand_supply_forecast: unrecognized room_bucket %r — dropping room filter "
"(district-wide pace). Expected Source-B vocab %s.",
room_bucket,
sorted(_FORECAST_ROOM_BUCKETS),
)
return None
@dataclass(frozen=True)
class DemandSupplyForecast:
@ -555,6 +607,15 @@ def compute_demand_supply_forecast(
(она сама применяет β внутри); §9.6 compute_rate_sensitivity вызывается ТОЛЬКО
ради explainability-фразы её β/x_pct в арифметику спроса НЕ входят.
#1959 ПОСЕГМЕНТНЫЙ баланс (фикс «1.00 везде»): и СПРОС, и ПРЕДЛОЖЕНИЕ теперь
сужаются под spec (obj_class/room_bucket), а не берутся district-wide:
base_pace = compute_market_metrics(obj_class, room_bucket).unit_velocity
дедуплицированный темп ИМЕННО сегмента (раньше один темп на все ячейки
каждая what_to_build-ячейка делила одно и то же дефицит = 1.0 везде).
open-сток = market_metrics.n_available того же сегмента (дедуп). При заданном
сегменте L2/L3 (hidden/future) ИСКЛЮЧАЮТСЯ из баланса они класс/формат-
агностичны, иначе один объём двоился бы по всем ячейкам (снова 1.0).
Один раз на вызов:
macro = get_monthly_macro(db) для дефолтного rate-path (hold_last_rate).
base_pace = compute_market_metrics(...).unit_velocity (§9.2 ед./мес).
@ -565,9 +626,10 @@ def compute_demand_supply_forecast(
rate_future = rate_path[h] (или hold_last_rate).
§9.4 compute_demand_normalization(rate_future=rate_future) norm (β внутри).
projected_demand = base_pace × norm × §9.5 × h (линейно, без компаунда).
§9.3 compute_future_supply_pressure(horizon_months=h) open/hidden/future;
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
open + hidden_release + future поглощённое спросом (clamp 0).
§9.3 compute_future_supply_pressure(horizon_months=h) open/hidden/future
(для НЕзаданного сегмента); при заданном сегменте open = n_available,
hidden/future = 0. Чистое предложение = open + hidden_release + future
поглощённое спросом (clamp 0).
balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
(ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
§9.7 future_competitors = top-N get_competitors(horizon_months=h).
@ -600,7 +662,19 @@ def compute_demand_supply_forecast(
)
# ── Один раз: §9.2 наблюдаемый темп (base_pace) ────────────────────────────
metrics = compute_market_metrics(db, district=district, premise_kind=premise_kind)
# #1959: ПОСЕГМЕНТНЫЙ темп — фильтруем market_metrics по obj_class + room_bucket
# сегмента. Раньше брался district-wide unit_velocity (один темп на ВСЕ ячейки
# what_to_build → дефицит вырождался в 1.0 везде). room_bucket идёт из Source-B
# вокабуляра what_to_build ("Студии 15-30"/…); market_metrics теперь принимает
# его напрямую (room CASE = зеркало room_area_bucket_of) → переводчик-валидатор
# (см. _market_room_bucket) только страхует от неизвестных меток.
metrics = compute_market_metrics(
db,
district=district,
premise_kind=premise_kind,
obj_class=spec.obj_class,
room_bucket=_market_room_bucket(spec.room_bucket),
)
base_pace = metrics.unit_velocity
# ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
@ -616,6 +690,15 @@ def compute_demand_supply_forecast(
# значение).
confounded = _series_confounded(macro_coef, sensitivity)
# #1959: сегмент «задан», если сужена хотя бы одна предметная ось (класс/формат).
# Тогда ПРЕДЛОЖЕНИЕ берём ПОСЕГМЕНТНОЕ — open = market_metrics.n_available
# (дедуплицированный сток ИМЕННО этого сегмента), а L2/L3 (hidden/future)
# ИСКЛЮЧАЕМ из баланса: они класс/формат-агностичны (нет room-оси) → один и тот
# же объём прибавился бы КАЖДОЙ ячейке → двойной счёт и снова вырождение к 1.0.
# Незаданный сегмент (оба None) → прежний district-wide путь (open из §9.3 view +
# hidden + future), обратная совместимость для агрегатных вызовов.
segmented = spec.obj_class is not None or spec.room_bucket is not None
out: list[DemandSupplyForecast] = []
for h in horizon_list:
out.append(
@ -629,6 +712,8 @@ def compute_demand_supply_forecast(
rate_future=effective_rate_path.get(h),
base_pace=base_pace,
market_confidence=metrics.confidence,
segment_open_units=metrics.n_available,
segmented=segmented,
macro_coef=macro_coef,
sensitivity_phrase=sensitivity.phrase,
confounded=confounded,
@ -663,6 +748,8 @@ def _forecast_for_horizon(
rate_future: float | None,
base_pace: float | None,
market_confidence: Confidence,
segment_open_units: int,
segmented: bool,
macro_coef: Any,
sensitivity_phrase: str | None,
confounded: bool,
@ -680,16 +767,28 @@ def _forecast_for_horizon(
projected_demand = _project_demand(base_pace, norm_coefficient, macro_coefficient, horizon)
# ── ПРЕДЛОЖЕНИЕ: §9.3 слои → фазированный hidden → чистое ( поглощённое) ───
# fsp читаем ВСЕГДА (нужен fsp.confidence для итоговой уверенности; для незаданного
# сегмента — ещё и open/hidden/future как раньше).
fsp = compute_future_supply_pressure(
db, district=district, horizon_months=horizon, premise_kind=premise_kind
)
hidden_fraction = _hidden_release_fraction(horizon)
hidden_release = fsp.hidden_units * hidden_fraction
future_online = fsp.future_units_by_horizon
gross_supply = _gross_supply(fsp.open_units, hidden_release, future_online)
projected_supply = _project_supply(
fsp.open_units, hidden_release, future_online, projected_demand
)
if segmented:
# #1959: ПОСЕГМЕНТНОЕ предложение = открытый сток ИМЕННО этого сегмента
# (market_metrics.n_available, дедуплицированный). L2/L3 (hidden/future)
# класс/формат-агностичны (нет room-оси) → если прибавить их каждой ячейке,
# один и тот же объём двоится по всем сегментам → дефицит снова липнет к 1.0.
# Поэтому в посегментном балансе hidden/future = 0 (в dataclass их тоже не
# показываем, чтобы explain не вводил в заблуждение «этот сегмент имеет L2/L3»).
open_units = segment_open_units
hidden_release = 0.0
future_online = 0.0
else:
open_units = fsp.open_units
hidden_fraction = _hidden_release_fraction(horizon)
hidden_release = fsp.hidden_units * hidden_fraction
future_online = fsp.future_units_by_horizon
gross_supply = _gross_supply(open_units, hidden_release, future_online)
projected_supply = _project_supply(open_units, hidden_release, future_online, projected_demand)
# ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
@ -737,7 +836,7 @@ def _forecast_for_horizon(
demand_norm_coefficient=norm_coefficient,
macro_coefficient=macro_coefficient,
projected_demand_units=projected_demand,
open_units=fsp.open_units,
open_units=open_units,
hidden_release_units=hidden_release,
future_online_units=future_online,
projected_supply_units=projected_supply,

View file

@ -95,7 +95,8 @@ _CLASS_TO_FORECAST: dict[str, str] = {
"Comfort+": "комфорт",
"Business": "бизнес",
"Elite": "бизнес",
"Economy": "эконом",
# #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
"Economy": "стандарт",
}
# ── #983 named-константы (§10/§16) ─────────────────────────────────────────────
@ -145,8 +146,9 @@ def map_class(target_class: str | None) -> str | None:
"""Live target_class → forecast obj_class. PURE. None/неизвестное → None.
Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite в «бизнес»,
Economy в «эконом» (3 массовых класса §9.7). None None = «без фильтра
класса», вызывающий отдаёт движку дефолтную сетку классов.
Economy в «стандарт» (#1959: нижний массовый класс objective_lots —
«стандарт», эконома в данных нет). 3 массовых класса §9.7. None None =
«без фильтра класса», вызывающий отдаёт движку дефолтную сетку классов.
"""
if target_class is None:
return None

View file

@ -88,6 +88,14 @@ _STRONG_DEFICIT_THRESHOLD: float = 0.25
# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05.
_SIGNAL_BALANCE_EPS: float = 0.05
# #1959 honesty-guard: если ВСЕ ячейки сетки прижаты к ±1.0 (дефицит не различает
# сегменты — degenerate-fallback, как было до фикса посегментации), категоричные
# вердикты «строить»/«избегать» вводят в заблуждение (на самом деле сигнал не
# дискриминирует). Тогда вместо вердикта показываем честную плашку. |di| ≥ этого
# порога считаем «прижато к пределу шкалы».
_DEGENERATE_CLAMP_THRESHOLD: float = 0.999
_DEGENERATE_SEGMENT_NOTE: str = "недостаточно гранулярных данных для посегментного вывода"
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
_PRIMARY_HORIZON_MONTHS: int = 12
@ -144,17 +152,31 @@ def _as_dict_list(items: Sequence[Any] | None) -> list[dict[str, Any]]:
def _deal_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
"""Число сделок (продаж) за окно — для deal_count #990. PURE.
"""Число сделок (продаж) ЗА ОКНО — для deal_count #990. PURE.
Предпочитаем `market_metrics.n_sold` (§9.2 прямой счётчик проданных лотов выборки);
fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
сделок пошло бы в analog_count). Нет сигнала None (#990 → тянет в low).
#1964: НЕ берём `market_metrics.n_sold` — это КУМУЛЯТИВНЫЙ all-time проданный сток
(десятки-сотни тысяч), а #990/confidence_engine помечает фактор «за {window} мес»
(6) и гейтит порогами 6-мес окна (high50). n_sold 50 ВСЕГДА гейт бессмыслен и
подпись врёт. Берём оконное число = unit_velocity × window_months, где unit_velocity
(§9.2) = units_sold_window / window_months из _SALES_WINDOW_SQL (он
contract_date-windowed) произведение восстанавливает units_sold за окно. Так
подпись «за 6 мес» честная и порог 50/15 осмысленный (прод EKB: ~24 876 за 6 мес
vs ~380 921 кумулятивно).
unit_velocity отсутствует (None / нет market_metrics) None (#990 трактует как
«нет данных», тянет в low). window_months 0 / нет None (нечем масштабировать).
Fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
сделок пошло бы в analog_count).
"""
if market_metrics is not None:
n_sold = market_metrics.get("n_sold")
if isinstance(n_sold, int):
return n_sold
return None
if market_metrics is None:
return None
unit_velocity = market_metrics.get("unit_velocity")
window_months = market_metrics.get("window_months")
if not isinstance(unit_velocity, int | float):
return None
if not isinstance(window_months, int) or window_months <= 0:
return None
return round(float(unit_velocity) * window_months)
def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
@ -231,7 +253,8 @@ def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None:
"""Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE.
Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months`
именно за это окно считается n_sold. Нет None (#990 пропускает суффикс «за N мес»).
#1964: именно за это окно `_deal_count` считает сделки (unit_velocity ×
window_months). Нет None (#990 пропускает суффикс «за N мес»).
"""
if market_metrics is not None:
window = market_metrics.get("window_months")
@ -532,33 +555,71 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
"""
explicit = product_tz.get("mix")
if isinstance(explicit, list):
return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)]
entries = [m for m in explicit if isinstance(m, dict)]
degenerate = _all_cells_clamped([e.get("deficit_index") for e in entries])
return [_enrich_mix_entry(m, degenerate=degenerate) for m in entries]
ranked = product_tz.get("ranked_segments")
if isinstance(ranked, list):
segs = [seg for seg in ranked if isinstance(seg, dict)]
# #1959 honesty-guard: вся сетка прижата к ±1.0 → дефицит не дискриминирует
# сегменты (degenerate-fallback). Не эмитим категоричные «строить»/«избегать»
# — вместо них честная плашка `_DEGENERATE_SEGMENT_NOTE`.
degenerate = _all_cells_clamped([seg.get("deficit_index") for seg in segs])
return [
{
"bucket": seg.get("bucket"),
"obj_class": seg.get("obj_class"),
"deficit_index": seg.get("deficit_index"),
"projected_demand_units": seg.get("projected_demand_units"),
"signal": _build_signal(seg.get("deficit_index")),
"signal": (
_DEGENERATE_SEGMENT_NOTE
if degenerate
else _build_signal(seg.get("deficit_index"))
),
}
for seg in ranked
if isinstance(seg, dict)
for seg in segs
]
return []
def _enrich_mix_entry(entry: dict[str, Any]) -> dict[str, Any]:
def _all_cells_clamped(deficit_indices: Sequence[Any]) -> bool:
"""True если ВСЕ измеримые ячейки прижаты к ±1.0 (degenerate-fallback). PURE.
#1959 honesty-guard. «Измеримые» = числовые deficit_index (None-ячейки —
тонкие данные, в знаменатель не идут). Пусто / нет измеримых False (нечего
защищать обычный graceful-путь). Если хотя бы одна ячейка различается
(|di| < порога), сетка ДИСКРИМИНИРУЕТ вердикты честны False. PURE.
Args:
deficit_indices: deficit_index ячеек (числа / None / мусор).
Returns:
True только когда есть 1 измеримая ячейка и ВСЕ они |di| порога.
"""
measured = [
float(d) for d in deficit_indices if isinstance(d, int | float) and not isinstance(d, bool)
]
if not measured:
return False
return all(abs(d) >= _DEGENERATE_CLAMP_THRESHOLD for d in measured)
def _enrich_mix_entry(entry: dict[str, Any], *, degenerate: bool = False) -> dict[str, Any]:
"""Дополнить явную mix-ячейку деривированным `signal` (#1745). PURE.
Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет
`signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует
вход assembler-чистота).
#1959 honesty-guard: `degenerate=True` (вся сетка прижата к ±1.0) → вместо
категоричного сигнала ставим честную плашку `_DEGENERATE_SEGMENT_NOTE` (только
когда у ячейки не было своего явного `signal`).
"""
enriched = dict(entry)
if enriched.get("signal") is None:
enriched["signal"] = _build_signal(enriched.get("deficit_index"))
enriched["signal"] = (
_DEGENERATE_SEGMENT_NOTE if degenerate else _build_signal(enriched.get("deficit_index"))
)
return enriched

View file

@ -96,9 +96,10 @@ _MIN_RATE_PCT: float = 0.0
# читается как баг. Текст уходит в отчёт через ReportScenarios.scenarios_collapse_reason
# и далее в PDF/Excel-плашку вместо таблицы трёх одинаковых столбцов.
_COLLAPSE_REASON_LOW_BETA: Final[str] = (
"β rate-sensitivity не прошёл gate (n≥30 ∧ R²≥0.1 ∧ slope<0) на текущих "
"данных ЕКБ — сценарная дифференциация conservative/aggressive восстановится "
"после расширения окна по ключевой ставке."
"Чувствительность спроса к ключевой ставке не удалось оценить на коротком "
"ряде по Екатеринбургу, поэтому консервативный и агрессивный сценарии "
"совпали с базовым — показываем один базовый сценарий вместо трёх. Три "
"сценария вернутся, когда накопится более длинная история по ставке."
)
# Допуски сравнения metric'ов сценариев при детекции collapse (math.isclose). Берём

View file

@ -828,22 +828,62 @@ def _unavailable(key: str, *, reason: str) -> SpecialIndex:
# `market_metrics._SALES_WINDOW_SQL`: bind `:has_district` (boolean) переключает
# ветку, `:districts` (text[]) — список микро. «Продан» = is_sold IS TRUE ИЛИ
# contract_date заполнена. psycopg v3: CAST, НИКОГДА :x::type.
# #1964: считаем по ФИЗИЧЕСКИМ лотам (дедуп) — сырой objective_lots раздут ~2.91×
# (мульти lot_id на физлот); доля ипотеки — отношение, к дублям устойчива, но
# n_sold/n_mortgage теперь честные (по физлотам).
#
# Перф-батч #2 (#1953-followup): дедупим INLINE через DISTINCT ON (physflat-ключ,
# последний снапшот), НЕ через v_objective_lots_latest. Причина: view (DISTINCT ON ol.*)
# — оптимизационный барьер: WHERE снаружи НЕ проталкивается ниже DISTINCT ON →
# планировщик материализует ВСЮ таблицу (1.76M, Parallel Seq Scan + external merge sort
# 66-81 МБ/воркер) ПЕРЕД фильтром (~5.4-6.4 s district-scoped, прод-EXPLAIN). Inline:
# bitmap по objective_lots_district_class_idx + узкий sort (~3-5 МБ/воркер) ~0.37 s
# (≈15×).
#
# ⚠️ КОРРЕКТНОСТЬ дедупа (слои фильтров РАЗНЫЕ — не путать):
# • premise_kind — ПОЛНОСТЬЮ стабилен (0 mixed-групп на проде) → в WHERE ДО DISTINCT ON.
# • district — почти стабилен (4 transient-группы на 605k физлотов). Протолкнут ДО
# DISTINCT ON ОСОЗНАННО: это index-драйвер objective_lots_district_class_idx; увод
# фильтра ПОСЛЕ дедупа = full seq-scan, убивает перф-фикс. Принятый компромисс
# паттерна parcels.py/#1964 (≤4 возможных дрейфа при district-scoped запросе).
# • class — МЕНЯЮЩИЙСЯ (248 transient-групп на проде): класс физлота дрейфует между
# снапшотами. Фильтр lower(class)=… ДО DISTINCT ON ловил бы СТАРЫЙ снапшот под
# старым классом (прод-замер: бизнес 16637→16657 +20, стандарт 128721→128914 +193
# vs view). View фильтрует class на ПОСЛЕДНЕМ снапшоте → class-фильтр применяем
# ПОСЛЕ дедупа (в sold-CTE). Тогда эквивалентность view бит-в-бит по ВСЕМ классам.
# • sold-фильтр (is_sold / contract_date) — тоже МЕНЯЮЩИЙСЯ: ПОСЛЕ дедупа (на
# ПОСЛЕДНЕМ снапшоте), как делал view. Протолкнуть ДО DISTINCT ON СЛОМАЛО БЫ
# семантику (включил бы когда-либо-проданные + дедуп среди sold-снапшотов).
_ARTIFICIAL_DEMAND_SQL = text(
"""
WITH sold AS (
SELECT
ol.objective_lot_id,
(ol.encumbrance_type IS NOT NULL OR ol.bank_name IS NOT NULL) AS has_mortgage
FROM objective_lots ol
WHERE ol.premise_kind = CAST(:premise_kind AS text)
AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL)
WITH latest AS (
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
objective_lot_id,
is_sold,
contract_date,
class,
encumbrance_type,
bank_name
FROM objective_lots
WHERE premise_kind = CAST(:premise_kind AS text)
AND (
CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
OR district = ANY(CAST(:districts AS text[]))
)
ORDER BY project_name, corpus_name, section, floor,
lot_number, snapshot_date DESC, id DESC
),
sold AS (
SELECT
objective_lot_id,
(encumbrance_type IS NOT NULL OR bank_name IS NOT NULL) AS has_mortgage
FROM latest
WHERE (is_sold IS TRUE OR contract_date IS NOT NULL)
AND (
CAST(:obj_class AS text) IS NULL
OR lower(ol.class) = lower(CAST(:obj_class AS text))
OR lower(class) = lower(CAST(:obj_class AS text))
)
)
SELECT

View file

@ -64,7 +64,10 @@ Confidence = Literal["high", "medium", "low"]
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
# может добавить). Держим явной константой для детерминизма сетки.
_DEFAULT_CLASSES: tuple[str, ...] = ("эконом", "комфорт", "бизнес")
# #1959: нижний массовый класс objective_lots.class — «стандарт», НЕ «эконом»
# (verified на prod: стандарт=483k лотов, эконома НЕТ → раньше эта ячейка матчила
# 0 строк → молча выпадала из сетки; посегментный дефицит её не видел).
_DEFAULT_CLASSES: tuple[str, ...] = ("стандарт", "комфорт", "бизнес")
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это

View file

@ -0,0 +1,141 @@
"""Generative Design — Stage 3a (#1965): каталог типовых домов (house-type catalog).
Контракт Stage 3a: вместо max-FAR жадной раскладки (Stage 1b) пользователь выбирает
ТИПОВЫЕ дома и говорит, сколько секций каждого типа поставить. Этот модуль справочник
таких типов: для каждого ``section_type`` он несёт габариты пятна секции (ширина × глубина,
метры), дефолтную этажность и подходящий класс жилья.
ИСТОЧНИК: это РАЗУМНЫЙ ДЕФОЛТНЫЙ каталог (sane-default), а не выгрузка из БД. Габариты
типовые размеры секций массового жилья РФ (панель/монолит/башня/малоэтажка), округлённые
до реалистичных значений. Каталог намеренно захардкожен в коде на Stage 3a: миграции БД
сейчас нет (см. эпик #1953). Когда понадобится редактируемый застройщиком каталог — его
ПРОДВИГАЮТ в БД-таблицу с тем же контрактом (``section_type`` footprint/floors/class),
а этот модуль станет seed'ом/фолбэком. До тех пор — single source of truth по типам.
Детерминированно, без LLM / внешних API / БД.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Literal
logger = logging.getLogger(__name__)
# Класс жилья — то же Literal-множество, что у ConceptInput / compute_teap (single
# source of truth по допустимым значениям; рассинхрон тут = ошибка типов в mypy-strict).
HousingClass = Literal["econom", "comfort", "business"]
@dataclass(frozen=True)
class HouseType:
"""Один типовой дом каталога (тип секции МКД / малоэтажки).
section_type стабильный машинный КЛЮЧ типа (латиница, snake_case); это и есть
значение, которое фронт кладёт в ``BuildingProgramItem.section_type``.
label_ru человекочитаемый русский лейбл для UI (Stage 3b его показывает).
footprint_w_m ширина пятна секции, метры.
footprint_d_m глубина пятна секции, метры.
default_floors дефолтная этажность типа (UI подставляет, пользователь может менять
в пределах контракта BuildingProgramItem [1, 40]).
housing_class подходящий класс жилья (драйвит ТЭП/финмодель ниже по конвейеру).
"""
section_type: str
label_ru: str
footprint_w_m: float
footprint_d_m: float
default_floors: int
housing_class: HousingClass
@property
def footprint_sqm(self) -> float:
"""Площадь пятна секции, кв.м (ширина × глубина)."""
return self.footprint_w_m * self.footprint_d_m
# ── Каталог типовых домов (sane-default, см. модульный docstring про источник) ──────
# Покрываем основные форматы массового жилья РФ:
# * панель-эконом — длинная неглубокая секция, средняя этажность, эконом-класс;
# * монолит-комфорт— чуть шире/глубже, комфорт-класс, типовая «свечка» 14 этажей;
# * башня-бизнес — компактное квадратное пятно, высотная (точечная) застройка;
# * малоэтажка-комфорт — широкая невысокая секция (3 этажа), низкоплотная застройка;
# * таунхаус — узкое неглубокое пятно блокированной застройки, 3 этажа.
# Габариты — реалистичные типовые размеры; этажность — характерная для формата.
HOUSE_TYPES: tuple[HouseType, ...] = (
HouseType(
section_type="panel_econom",
label_ru="Панельная секция (эконом)",
footprint_w_m=24.0,
footprint_d_m=15.0,
default_floors=9,
housing_class="econom",
),
HouseType(
section_type="monolith_comfort",
label_ru="Монолитная секция (комфорт)",
footprint_w_m=21.0,
footprint_d_m=18.0,
default_floors=14,
housing_class="comfort",
),
HouseType(
section_type="tower_business",
label_ru="Башня (бизнес)",
footprint_w_m=18.0,
footprint_d_m=18.0,
default_floors=25,
housing_class="business",
),
HouseType(
section_type="lowrise_comfort",
label_ru="Малоэтажная секция (комфорт)",
footprint_w_m=30.0,
footprint_d_m=14.0,
default_floors=3,
housing_class="comfort",
),
HouseType(
section_type="townhouse",
label_ru="Таунхаус",
footprint_w_m=12.0,
footprint_d_m=10.0,
default_floors=3,
housing_class="comfort",
),
)
# Индекс по ключу для O(1)-лукапа. Построен один раз при импорте; ключи уникальны
# (assert ниже ловит дубликат типа на старте, а не молча затирает запись).
_BY_KEY: dict[str, HouseType] = {ht.section_type: ht for ht in HOUSE_TYPES}
assert len(_BY_KEY) == len(HOUSE_TYPES), "duplicate section_type key in HOUSE_TYPES"
def get_house_type(section_type: str) -> HouseType:
"""Найти тип дома по ключу ``section_type``. Бросает :class:`KeyError`, если нет.
Вызывающий слой (placement) обязан валидировать ключи программы заранее (см.
:func:`available_section_types`) неизвестный ключ здесь это программная ошибка,
а не пользовательский ввод, поэтому KeyError, а не тихий None.
"""
try:
return _BY_KEY[section_type]
except KeyError as exc:
raise KeyError(
f"unknown house type {section_type!r}; " f"available: {', '.join(sorted(_BY_KEY))}"
) from exc
def available_section_types() -> frozenset[str]:
"""Множество допустимых ключей ``section_type`` каталога (для валидации программы)."""
return frozenset(_BY_KEY)
__all__ = [
"HOUSE_TYPES",
"HouseType",
"HousingClass",
"available_section_types",
"get_house_type",
]

View file

@ -23,13 +23,19 @@ Deterministic, no LLM / no external API / no DB.
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from shapely.geometry import Polygon, box
from shapely.strtree import STRtree
from app.schemas.concept import ConceptInput, ConceptVariant
from app.services.generative import financial, teap
from app.schemas.concept import (
TEAP,
BuildingProgramItem,
ConceptInput,
ConceptVariant,
)
from app.services.generative import catalog, financial, teap
from app.services.generative.geometry import Parcel, ParcelGeometryError
logger = logging.getLogger(__name__)
@ -92,6 +98,53 @@ def _resolve_floors(target_floors: int, factor: float) -> int:
return max(_FLOORS_MIN, min(_FLOORS_MAX, floors))
class _Placer:
"""Аккумулятор размещённых секций + STRtree-индекс для проверки разрывов.
Извлечён из жадной раскладки, чтобы и :func:`_greedy_place` (coverage-cap sweep), и
:func:`place_program` (фиксированная программа типовых домов, Stage 3a) пользовались
ОДНОЙ И ТОЙ ЖЕ collision/setback-машиной, а не дублировали её. Состояние: принятые
footprints, их буферизованные на ``half_gap`` версии и перестраиваемый по ним STRtree.
"""
def __init__(self) -> None:
self.placed: list[Polygon] = []
self.built_area: float = 0.0
# Буферизованные footprints для проверки разрыва; индекс STRtree по ним.
self._buffered: list[Polygon] = []
self._tree: STRtree | None = None
def try_place(self, footprint: Polygon, buildable: Polygon, half_gap: float) -> bool:
"""Попытаться принять ``footprint``. True — принят, False — не лёг.
Принимается, если целиком внутри ``buildable`` (covers допускает касание границы)
И не нарушает разрыв ``half_gap*2`` с уже принятыми (буферим кандидата на
``half_gap`` и проверяем пересечение с буферизованными соседями через STRtree
две секции с зазором >= gap не пересекутся).
"""
if not buildable.covers(footprint):
return False
candidate_buf = footprint.buffer(half_gap, join_style="mitre")
if self._tree is not None:
for idx in self._tree.query(candidate_buf):
if candidate_buf.intersects(self._buffered[idx]):
return False
self.placed.append(footprint)
self.built_area += footprint.area
self._buffered.append(candidate_buf)
self._tree = STRtree(self._buffered)
return True
def _centered_footprint(cx: float, cy: float, width: float, depth: float) -> Polygon:
"""Прямоугольное пятно секции ``width × depth``, центрированное на (cx, cy), метры."""
half_w = width / 2.0
half_d = depth / 2.0
return box(cx - half_w, cy - half_d, cx + half_w, cy + half_d)
def _greedy_place(
parcel: Parcel,
spec: StrategySpec,
@ -103,63 +156,32 @@ def _greedy_place(
* кандидат-якоря центры ячеек сетки в фиксированном порядке;
* footprint строится центрированно на якоре;
* принимается, если целиком внутри buildable area И не нарушает разрыв ``gap_m``
с уже принятыми (проверка через STRtree по buffered-footprints);
с уже принятыми (проверка через STRtree, см. :class:`_Placer`);
* раскладка останавливается, когда пятно достигает ``coverage_cap`` от buildable
area (регулятор плотности по типу застройки) это также ограничивает число
размещений и держит O(n^2)-перестройку STRtree в бюджете.
"""
buildable = parcel.buildable_m
max_built = buildable.area * coverage_cap
placed: list[Polygon] = []
built_area = 0.0
# Буферизованные footprints для проверки разрыва; индекс STRtree по ним.
buffered: list[Polygon] = []
tree: STRtree | None = None
half_w = spec.section_w / 2.0
half_d = spec.section_d / 2.0
placer = _Placer()
half_gap = spec.gap_m / 2.0
for cell in parcel.grid:
if built_area >= max_built:
if placer.built_area >= max_built:
break
footprint = box(
cell.cx - half_w,
cell.cy - half_d,
cell.cx + half_w,
cell.cy + half_d,
)
# Целиком внутри пятна застройки (covers допускает касание границы).
if not buildable.covers(footprint):
continue
# Разрыв между секциями: буферим кандидата на half_gap и проверяем пересечение
# с буферизованными соседями — две секции с зазором >= gap_m не пересекутся.
candidate_buf = footprint.buffer(half_gap, join_style="mitre")
if tree is not None:
collision = False
for idx in tree.query(candidate_buf):
if candidate_buf.intersects(buffered[idx]):
collision = True
break
if collision:
continue
placed.append(footprint)
built_area += footprint.area
buffered.append(candidate_buf)
tree = STRtree(buffered)
footprint = _centered_footprint(cell.cx, cell.cy, spec.section_w, spec.section_d)
placer.try_place(footprint, buildable, half_gap)
logger.info(
"strategy=%s placed %d sections (%.0fx%.0f m, gap=%.0f m, coverage<=%.0f%%)",
spec.name,
len(placed),
len(placer.placed),
spec.section_w,
spec.section_d,
spec.gap_m,
coverage_cap * 100,
)
return placed
return placer.placed
def _footprints_to_geojson(
@ -187,6 +209,268 @@ def _footprints_to_geojson(
return {"type": "FeatureCollection", "features": features}
# ── Stage 3a (#1965): program-driven placement (типовые дома вместо max-FAR sweep) ──
# Разрыв между секциями в program-режиме (м). Пункт программы не несёт gap (контракт —
# только тип/этажность/количество), поэтому берём один нормативный противопожарный/
# инсоляционный зазор для всех секций программы — середина диапазона стратегий 1b
# (max_area gap=6 … max_insolation gap=15). Достаточно консервативно для реалистичной
# раскладки, не патологически разрежено.
_PROGRAM_GAP_M: float = 10.0
@dataclass(frozen=True)
class _PlacedSection:
"""Одна размещённая секция программы: пятно + этажность + тип каталога (для GeoJSON)."""
footprint: Polygon
floors: int
section_type: str
@dataclass(frozen=True)
class PlacedProgram:
"""Результат program-раскладки: что легло + честный счётчик «N из M».
``sections`` размещённые секции (пятна + этажность + тип) в порядке программы.
``requested_count`` сколько секций просили (Σ count по программе).
``placed_count`` сколько реально влезло (== len(sections)). placed < requested
участок мал, разместилось N из M (без hard-422 честный сигнал для Stage 3b).
"""
sections: tuple[_PlacedSection, ...]
requested_count: int
placed_count: int
def place_program(
parcel: Parcel,
program: list[BuildingProgramItem],
*,
gap_m: float = _PROGRAM_GAP_M,
) -> PlacedProgram:
"""Stage 3a: разложить РОВНО заданную программу типовых домов на участок.
Для каждого пункта программы (``section_type`` из каталога × ``count`` секций) кладём
до ``count`` секций каталожного пятна на сетку участка, переиспользуя ТУ ЖЕ
collision/STRtree/setback-машину, что и жадная раскладка (:class:`_Placer`) никакого
coverage-cap, стоп-критерий = достигнут ``count`` для пункта или кончились свободные
якоря. Пункты обрабатываются по порядку; накопленные секции участвуют в проверке
разрыва для последующих (общий :class:`_Placer`).
Если участок не вмещает все запрошенные секции, НЕ роняем 422 кладём сколько влезло
и возвращаем честный ``placed_count``/``requested_count`` (разместилось N из M).
Raises:
KeyError: ``section_type`` пункта нет в каталоге (валидируется на API-слое до
размещения; здесь это программная ошибка контракта, не пользовательский ввод).
"""
buildable = parcel.buildable_m
half_gap = gap_m / 2.0
placer = _Placer()
placed_sections: list[_PlacedSection] = []
requested = 0
for item in program:
house = catalog.get_house_type(item.section_type)
requested += item.count
# Ручное пятно (Stage 3c): если пользователь задал ОБА габарита — кладём
# его вместо каталожного («вписать пятно»). Частичное задание игнорируем
# (нужны и ширина, и глубина), падая обратно на каталог.
if item.footprint_w_m is not None and item.footprint_d_m is not None:
fp_w, fp_d = item.footprint_w_m, item.footprint_d_m
else:
fp_w, fp_d = house.footprint_w_m, house.footprint_d_m
placed_for_item = 0
for cell in parcel.grid:
if placed_for_item >= item.count:
break
footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
if placer.try_place(footprint, buildable, half_gap):
placed_sections.append(
_PlacedSection(
footprint=footprint,
floors=item.floors,
section_type=item.section_type,
)
)
placed_for_item += 1
if placed_for_item < item.count:
logger.warning(
"program: type=%s placed %d of %d sections (%.0fx%.0f m) — участок мал",
item.section_type,
placed_for_item,
item.count,
house.footprint_w_m,
house.footprint_d_m,
)
result = PlacedProgram(
sections=tuple(placed_sections),
requested_count=requested,
placed_count=len(placed_sections),
)
logger.info(
"program placed %d of %d sections across %d type(s)",
result.placed_count,
result.requested_count,
len(program),
)
return result
def _placed_program_to_geojson(
parcel: Parcel,
sections: tuple[_PlacedSection, ...],
) -> dict[str, object]:
"""Размещённые секции программы -> WGS84 FeatureCollection (контракт buildings_geojson).
Зеркалит :func:`_footprints_to_geojson`, но каждая секция несёт СВОИ floors и тип
каталога (program-режим смешивает типы/этажности), а ``strategy`` помечается
``"program"`` маркер, что вариант построен из программы, а не из 1b-стратегии.
"""
features: list[dict[str, object]] = []
for i, sec in enumerate(sections):
geom_wgs = parcel.metric_geom_to_wgs84(sec.footprint)
features.append(
{
"type": "Feature",
"geometry": geom_wgs,
"properties": {
"section_id": i + 1,
"floors": sec.floors,
"footprint_sqm": round(float(sec.footprint.area), 1),
"section_type": sec.section_type,
"strategy": "program",
},
}
)
return {"type": "FeatureCollection", "features": features}
def _aggregate_program_teap(
sections: tuple[_PlacedSection, ...],
*,
site_area_sqm: float,
housing_class: teap.HousingClass,
) -> TEAP:
"""Свести размещённую программу (СМЕШАННАЯ этажность) в один :class:`TEAP` — ТОЧНО.
``compute_teap`` берёт ОДНУ этажность на список пятен, поэтому при смешанной по типам
этажности нельзя просто скормить ему все пятна с одним числом (округлённая «средняя»
этажность даёт дрейф GFA ~1%). Вместо этого группируем секции по этажности, считаем
``compute_teap`` для каждой однородной группы и СУММИРУЕМ результаты:
* built / GFA / office / residential аддитивны сумма точна (GFA = Σ площадь_i×floors_i);
* apartments ``Σ floor(жилая_g / avg)`` по группам: физически корректнее, чем
``floor(Σжилая / avg)`` (нельзя «склеивать» дробные квартиры между корпусами);
* parking пересчитываем от ИТОГОВОГО числа квартир по той же норме класса (ceil от
суммы, а не сумма ceil иначе пер-группное округление вверх задвоит места);
* density (FAR) от суммарной GFA и площади участка (защита от деления на ноль).
Единый источник всех нормативных коэффициентов остаётся ``teap``-модуль (классовые
словари), новых магических чисел нет.
"""
if not sections:
return teap.compute_teap(
footprints=[], floors=0, site_area_sqm=site_area_sqm, housing_class=housing_class
)
# Группируем по этажности; внутри группы compute_teap корректен (одна этажность).
groups: dict[int, list[Polygon]] = {}
for sec in sections:
groups.setdefault(sec.floors, []).append(sec.footprint)
built = 0.0
gfa = 0.0
office = 0.0
residential = 0.0
apartments = 0
for floors, fps in groups.items():
# site_area_sqm здесь не важна для аддитивных полей — FAR пересчитаем в конце.
group = teap.compute_teap(
footprints=fps,
floors=floors,
site_area_sqm=site_area_sqm,
housing_class=housing_class,
)
built += group.built_area_sqm
gfa += group.total_floor_area_sqm
office += group.office_area_sqm
residential += group.residential_area_sqm
apartments += group.apartments_count
density = gfa / site_area_sqm if site_area_sqm > 0 else 0.0
parking_norm = teap._PARKING_PER_APARTMENT[housing_class]
parking_spaces = math.ceil(apartments * parking_norm)
return TEAP(
built_area_sqm=round(built, 1),
total_floor_area_sqm=round(gfa, 1),
office_area_sqm=round(office, 1),
residential_area_sqm=round(residential, 1),
apartments_count=apartments,
density=round(density, 3),
parking_spaces=parking_spaces,
)
def place_program_variant(
parcel: Parcel,
payload: ConceptInput,
*,
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
) -> ConceptVariant | None:
"""Stage 3a: построить ОДИН вариант из ``payload.building_program`` (типовые дома).
Раскладывает программу (:func:`place_program`), сводит размещённые пятна в ТЭП
(:func:`_aggregate_program_teap` точная GFA по группам этажности) и финмодель, и
наклеивает честный сигнал частичного размещения (``placed_count``/``requested_count``).
Возвращает ``None``, если ни одна секция не легла (участок не вмещает даже одну секцию
программы) вызывающий отбракует, как и в жадном пути.
``payload.building_program`` ДОЛЖЕН быть задан (вызывается только из program-ветки).
"""
program = payload.building_program
if not program: # защитный инвариант: эту ветку зовут только при заданной программе
raise ValueError("place_program_variant called without building_program")
placed = place_program(parcel, program)
if placed.placed_count == 0:
logger.warning(
"program placed 0 of %d sections — участок не вмещает программу, отбраковка",
placed.requested_count,
)
return None
teap_result = _aggregate_program_teap(
placed.sections,
site_area_sqm=parcel.site_area_sqm,
housing_class=payload.housing_class,
)
financial_result = financial.compute_financial(
teap=teap_result,
housing_class=payload.housing_class,
land_cost_rub=payload.land_cost_rub,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
development_type=payload.development_type,
)
buildings_geojson = _placed_program_to_geojson(parcel, placed.sections)
# Program-вариант репортуется под "balanced" (контракт strategy — фиксированный
# Literal трёх стратегий 1b; program-режим не вводит новую стратегию, маркер режима
# лежит в properties.strategy="program" каждой фичи GeoJSON). Один вариант на программу.
return ConceptVariant(
strategy="balanced",
buildings_geojson=buildings_geojson,
teap=teap_result,
financial=financial_result,
placed_count=placed.placed_count,
requested_count=placed.requested_count,
)
def place_strategy(
parcel: Parcel,
payload: ConceptInput,
@ -260,7 +544,31 @@ def place_all_strategies(
``market_price_per_sqm`` / ``price_source`` (рыночная калибровка цены жилья, PR-2)
прокидываются неизменными в каждую стратегию цена едина для участка.
Stage 3a (#1965): если задана ``payload.building_program`` — раскладываем РОВНО эту
программу типовых домов (:func:`place_program_variant`, один вариант), а НЕ три жадные
стратегии. ``building_program is None`` существующий жадный путь без изменений.
"""
if payload.building_program:
program_variant = place_program_variant(
parcel,
payload,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
)
if program_variant is None:
raise ParcelGeometryError(
"программа застройки не вместила ни одной секции — "
"участок слишком узкий/мелкий для выбранных типов домов"
)
logger.info(
"placed program variant: %d of %d sections, %dкв",
program_variant.placed_count,
program_variant.requested_count,
program_variant.teap.apartments_count,
)
return [program_variant]
variants = [
variant
for spec in _STRATEGIES
@ -288,7 +596,10 @@ def place_all_strategies(
__all__ = [
"FLOOR_HEIGHT_M",
"PlacedProgram",
"StrategySpec",
"place_all_strategies",
"place_program",
"place_program_variant",
"place_strategy",
]

View file

@ -136,4 +136,76 @@ def compute_teap(
return teap
__all__ = ["HousingClass", "compute_teap"]
def synthesize_teap_from_program(
*,
total_footprint_sqm: float,
floors: int,
site_area_sqm: float,
housing_class: HousingClass,
sections: int = 1,
) -> TEAP:
"""Свести АГРЕГИРОВАННУЮ массинг-программу (скалярное пятно × этажность) в :class:`TEAP`.
Stage 2a (#1965): LIVE-пересчёт экономики из интерактивного 3D-массинга. Фронтовый
``computeModel`` отдаёт уже СВЁРНУТУЮ программу суммарное пятно застройки (кв.м) и
этажность, без покомпонентной геометрии секций. Здесь мы строим из этого тот же
:class:`TEAP`, что и :func:`compute_teap`/``synthesize_teap_from_buildability``, чтобы
прогнать его через готовый :func:`compute_financial`.
Зеркалит :func:`synthesize_teap_from_buildability` ОДИН-В-ОДИН, только источник
площадей СКАЛЯРНОЕ пятно, а не ``max_far × area``:
* ``built`` (пятно) = ``total_footprint_sqm`` (как есть, сумма по секциям).
* ``GFA`` (total_floor) = ``total_footprint_sqm × floors``.
* ``office`` (нежилое) = ``GFA × _OFFICE_SHARE_OF_GFA[class]`` вырезается из GFA
до расчёта жилой (жилая ужимается ровно на эту площадь, total GFA не меняется).
* ``residential`` = ``(GFA office) × _EFFICIENCY_BY_CLASS[class]``.
* ``apartments`` = ``floor(residential / _AVG_APARTMENT_SQM[class])``.
* ``parking`` = ``ceil(apartments × _PARKING_PER_APARTMENT[class])``.
* ``density`` = ``GFA / site_area_sqm`` (FAR), защита от деления на ноль.
Чистая функция без БД / LLM / внешних API. Те же нормативные константы, что и
остальные синтезаторы (single source of truth, без новых магических чисел).
Args:
total_footprint_sqm: суммарное пятно застройки всех секций, кв.м (скаляр).
floors: этажность (общая для программы); вне диапазона клампится к >= 0.
site_area_sqm: площадь участка для плотности (FAR).
housing_class: класс жилья задаёт эффективность/средний лот/парковку/нежилое.
sections: число секций метаданные программы; на ТЭП НЕ влияет (площади уже
свёрнуты во ``total_footprint_sqm``). Принимается для симметрии с контрактом
фронта и будущей пер-секционной логики; здесь намеренно не используется.
"""
del sections # метаданные программы; площади уже агрегированы — на ТЭП не влияют
built_area = max(0.0, total_footprint_sqm)
total_floor_area = built_area * max(0, floors)
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA ДО расчёта жилой — точно как
# compute_teap / synthesize_teap_from_buildability (single source of truth по долям).
office_share = _OFFICE_SHARE_OF_GFA[housing_class]
office_area = total_floor_area * office_share
residential_gfa = total_floor_area - office_area
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
residential_area = residential_gfa * efficiency
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
density = total_floor_area / site_area_sqm if site_area_sqm > 0 else 0.0
parking_norm = _PARKING_PER_APARTMENT[housing_class]
parking_spaces = math.ceil(apartments_count * parking_norm)
return TEAP(
built_area_sqm=round(built_area, 1),
total_floor_area_sqm=round(total_floor_area, 1),
office_area_sqm=round(office_area, 1),
residential_area_sqm=round(residential_area, 1),
apartments_count=apartments_count,
density=round(density, 3),
parking_spaces=parking_spaces,
)
__all__ = ["HousingClass", "compute_teap", "synthesize_teap_from_program"]

View file

@ -33,7 +33,7 @@ from app.services.scrapers.documents import extract_documents, upsert_documents
# obj_checks import temporarily disabled — endpoint /checks returns 404 (run #19).
# Re-enable with _fetch_obj_checks_safe when endpoint is found (see TODO in Phase B/C).
# from app.services.scrapers.obj_checks import extract_obj_checks, upsert_obj_checks
from app.services.scrapers.stealth import BASE_URL, BrowserSession
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError
logger = logging.getLogger(__name__)
@ -1142,7 +1142,7 @@ async def fetch_obj_checks(sess: BrowserSession, obj_id: int) -> tuple[Any, str]
# ── _fetch_*_safe wrappers for asyncio.gather in Phase B/C ───────────────────
# Каждый wrapper возвращает (kind, full_url, result_or_exception).
# Exceptions НЕ raise — помещаются в возвращаемый tuple.
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) bounds concurrency per-request.
# BrowserSession._sem (size = per-instance concurrency, #1945 KN=2) bounds concurrency.
async def _fetch_flats_safe(
@ -1450,6 +1450,247 @@ async def download_photos(
return locals_out, thumbs_out
# ── result processing (shared by combined + isolated #1945 passes) ────────────
# #1945 isolated extras pass: после стольких подряд полностью-WAF объектов
# обрываем пас (extras заблокированы целиком — нет смысла жечь ~1500 recycle).
_EXTRAS_ABORT_AFTER_CONSEC_WAF = 5
# 5 doc-endpoint kinds, accumulated into a single per-object documents upsert.
_DOC_KINDS = frozenset(
(
"doc_rpd",
"doc_developer_report",
"doc_project_documentation",
"doc_documentation_other",
"doc_permits",
)
)
def _process_flats_result(
db: Session,
run_id: int,
obj_id: int,
full_url: str,
result: list[dict[str, Any]] | Exception,
snapshot_date: date,
region_code: int,
) -> int:
"""Upsert flats from a _fetch_flats_safe result. Returns flats inserted.
Errors kn_scrape_failures (via _classify_and_log), 0 returned. Вынесено из
Phase-B/C loop чтобы isolated-flats-pass (#1945) и combined-loop делили один код.
"""
if isinstance(result, Exception):
_classify_and_log(db, run_id, obj_id, "flats", full_url, result)
return 0
if result:
return upsert_flats(db, result, snapshot_date, region_code)
return 0
async def _process_extras_result(
sess: BrowserSession,
db: Session,
run_id: int,
obj_id: int,
kind_tag: str,
full_url: str,
result: Any,
snapshot_date: date,
extras_counts: dict[str, int],
all_docs: list[dict[str, Any]],
*,
download_photos_binary: bool,
pdir: Path,
) -> None:
"""Handle one extras endpoint result (sale_graph/sales_agg/infra/photos/docs).
Mutates extras_counts / all_docs in place. Errors kn_scrape_failures.
Doc-kinds накапливаются в all_docs (единый upsert делает вызывающий код).
Вынесено из loop чтобы combined- и isolated-extras-pass (#1945) делили код.
"""
if isinstance(result, Exception):
_classify_and_log(db, run_id, obj_id, kind_tag, full_url, result)
return
if kind_tag in ("sale_graph_apartments", "sale_graph_parking"):
sg_type = kind_tag.replace("sale_graph_", "")
rows_sg, _ = result
extras_counts["sale_graph_rows"] += upsert_sale_graph(
db, obj_id, sg_type, rows_sg, snapshot_date
)
elif kind_tag == "sales_agg":
agg_data, _ = result
extras_counts["sales_agg_rows"] += upsert_sales_agg(db, obj_id, agg_data, snapshot_date)
elif kind_tag == "infrastructure":
pois_data, _ = result
extras_counts["infra_rows"] += upsert_infrastructure(db, obj_id, pois_data, snapshot_date)
elif kind_tag == "photos":
photos_data, _ = result
local_paths: dict[str, str] = {}
thumb_paths: dict[str, str] = {}
if download_photos_binary and photos_data:
local_paths, thumb_paths = await download_photos(sess, obj_id, photos_data, pdir)
extras_counts["photos_downloaded"] += len(local_paths)
extras_counts["photos_rows"] += upsert_photos(
db, obj_id, photos_data, local_paths, thumb_paths
)
elif kind_tag in _DOC_KINDS:
doc_items, _ = result
all_docs.extend(extract_documents(doc_items or []))
def _extras_coros(sess: BrowserSession, obj_id: int) -> list[Any]:
"""The 10 extras-endpoint coroutines for one object (flats NOT included)."""
return [
_fetch_sale_graph_safe(sess, obj_id, "apartments"),
_fetch_sale_graph_safe(sess, obj_id, "parking"),
_fetch_sales_agg_safe(sess, obj_id),
_fetch_infrastructure_safe(sess, obj_id),
_fetch_photos_safe(sess, obj_id),
_fetch_doc_rpd_safe(sess, obj_id),
_fetch_doc_developer_report_safe(sess, obj_id),
_fetch_doc_project_documentation_safe(sess, obj_id),
_fetch_doc_documentation_other_safe(sess, obj_id),
_fetch_doc_permits_safe(sess, obj_id),
]
def _is_waf_poisoned(results: list[tuple[str, str, Any]]) -> bool:
"""Любой WafBlockedError среди результатов = сессия отравлена (cookies флагнуты).
После этого ВСЕ запросы на этой сессии (incl. следующего объекта) пойдут в 403,
поэтому вызывающий код должен пересоздать BrowserSession перед продолжением.
"""
return any(isinstance(r, WafBlockedError) for _kind, _url, r in results)
async def _run_extras_pass(
*,
db: Session,
run_id: int,
region_code: int,
all_objects: list[dict[str, Any]],
start_index: int,
snapshot_date: date,
extras_counts: dict[str, int],
pdir: Path,
download_photos_binary: bool,
load_state: str | None,
headed: bool,
browser_concurrency: int | None,
request_jitter_min_ms: int | None,
request_jitter_max_ms: int | None,
proxy_url: str | None,
) -> int:
"""#1945 isolated extras pass: extras в ОТДЕЛЬНОЙ BrowserSession от flats.
Ключ изоляции: на ЛЮБОЙ WafBlockedError (extras-403 травит cookies сессии)
закрываем сессию и открываем НОВУЮ (свежие cookies/контекст) перед следующим
объектом яд не накапливается и НИКОГДА не касается flats (flats-сессия уже
закрыта). Best-effort: каждый сбой kn_scrape_failures, run не падает.
Возвращает суммарный request_count всех extras-сессий (для метрики run'а).
"""
total = len(all_objects)
total_requests = 0
log_progress(
db, run_id, f"extras-пас (isolated): старт {total - start_index} объектов", stage="extras"
)
def _new_session() -> BrowserSession:
return BrowserSession(
region_code=region_code,
headed=headed,
load_state=load_state,
concurrency=browser_concurrency,
jitter_min_ms=request_jitter_min_ms,
jitter_max_ms=request_jitter_max_ms,
proxy_url=proxy_url,
)
# extras сейчас мертвы (403 с 2026-06-03). Recycle на КАЖДЫЙ объект (полный
# перезапуск браузера ~1500×) бессмыслен, если extras заблокированы целиком.
# Поэтому считаем подряд идущие полностью-WAF объекты и обрываем пас после
# порога — extras остаются best-effort opt-in, но не жгут час впустую.
consecutive_all_waf = 0
aborted = False
sess = _new_session()
await sess.__aenter__()
try:
await sess.warm_up()
for i in range(start_index, total):
obj = all_objects[i]
obj_id = obj.get("objId") or obj.get("obj_id")
if not obj_id:
continue
results = await asyncio.gather(*_extras_coros(sess, obj_id), return_exceptions=False)
all_docs: list[dict[str, Any]] = []
for kind_tag, full_url, result in results:
await _process_extras_result(
sess,
db,
run_id,
obj_id,
kind_tag,
full_url,
result,
snapshot_date,
extras_counts,
all_docs,
download_photos_binary=download_photos_binary,
pdir=pdir,
)
if all_docs:
ins, _skip = upsert_documents(db, obj_id, all_docs)
extras_counts["documents_rows"] += ins
poisoned = _is_waf_poisoned(results)
# Полностью-WAF объект = ни один extras не отдал JSON (все 403).
all_waf = all(isinstance(r, WafBlockedError) for _k, _u, r in results)
consecutive_all_waf = consecutive_all_waf + 1 if all_waf else 0
if consecutive_all_waf >= _EXTRAS_ABORT_AFTER_CONSEC_WAF:
aborted = True
log_progress(
db,
run_id,
f"extras-пас оборван: {consecutive_all_waf} объектов подряд"
" полностью WAF-403 (extras заблокированы целиком)",
level="warn",
stage="extras",
)
break
# RECYCLE: если сессия отравлена WAF-403 — пересоздаём перед next obj.
if poisoned:
total_requests += sess.request_count
await sess.__aexit__(None, None, None)
sess = _new_session()
await sess.__aenter__()
await sess.warm_up()
if (i + 1) % 10 == 0:
_checkpoint(db, run_id, i + 1)
finally:
total_requests += sess.request_count
await sess.__aexit__(None, None, None)
if aborted:
logger.info("extras isolated pass aborted early — extras WAF-blocked wholesale")
log_progress(
db,
run_id,
f"extras-пас done: sale_graph={extras_counts['sale_graph_rows']}"
f" agg={extras_counts['sales_agg_rows']} infra={extras_counts['infra_rows']}"
f" photos={extras_counts['photos_rows']} docs={extras_counts['documents_rows']}",
stage="extras",
)
return total_requests
# ── orchestrator ─────────────────────────────────────────────────────────────
@ -1660,9 +1901,25 @@ async def run_region_sweep(
photos_dir: Path | str | None = None,
place_override: str | None = None,
resume_from_run_id: int | None = None,
browser_concurrency: int | None = None,
request_jitter_min_ms: int | None = None,
request_jitter_max_ms: int | None = None,
proxy_url: str | None = None,
extras_isolated: bool = True,
) -> dict[str, Any]:
"""Full sweep for one region: bootstrap → fetch objects → per-object processing.
#1945 anti-ban: ``browser_concurrency`` / ``request_jitter_*_ms`` / ``proxy_url``
прокидываются в BrowserSession sweep'а, чтобы троттлить/проксировать ТОЛЬКО KN-
прогон (другие скраперы используют модульные дефолты stealth.py без изменений).
None у каждого = дефолт stealth.py (concurrency=8, jitter 6001500, без прокси).
``extras_isolated`` (#1945 ГЛАВНЫЙ unblock): True (дефолт) → flats тянутся в
ЧИСТОЙ flats-only сессии (extras на ней НЕ дёргаются НИКОГДА 403-яд extras не
травит cookies flats-сессии flats_count восстанавливается), а extras идут
ОТДЕЛЬНЫМ best-effort проходом с recycle сессии на WAF-403. False старое
поведение (flats+extras в одной сессии, poison-prone).
Resume-aware: если `resume_from_run_id` задан, читаем objects_snapshot и
progress_obj_index из исходного run и продолжаем с этого индекса. Новый
run_id создаётся (с resumed_from_run_id ссылкой), исходный остаётся
@ -1802,6 +2059,10 @@ async def run_region_sweep(
headed=headed,
load_state=load_state,
save_state=save_state,
concurrency=browser_concurrency,
jitter_min_ms=request_jitter_min_ms,
jitter_max_ms=request_jitter_max_ms,
proxy_url=proxy_url,
) as sess:
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies
# (___dmpkit___, domain_sid). Required since 2026-05-24 — without these
@ -1881,9 +2142,16 @@ async def run_region_sweep(
# ── Phase B/C — per-object processing (resumable, parallel per-object) ─
# Все endpoint'ы одного obj_id запускаются параллельно через asyncio.gather.
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) ограничивает запросы.
# BrowserSession._sem (size = browser_concurrency, #1945 троттлит KN до 2)
# ограничивает запросы.
# DB upserts выполняются последовательно после gather — один db Session
# не thread-safe для параллельной записи.
#
# #1945 ИЗОЛЯЦИЯ: при extras_isolated=True эта (warm) сессия делает
# ТОЛЬКО flats — extras на ней НЕ дёргаются (их 403-яд не травит cookies
# flats-сессии → flats_count восстанавливается). extras уходят отдельным
# best-effort проходом ниже (после закрытия flats-сессии) с recycle на
# WAF-403. При extras_isolated=False — старое combined-поведение.
pdir = Path(photos_dir) if photos_dir else PHOTOS_DIR_DEFAULT
total = len(all_objects)
for i in range(start_index, total):
@ -1898,17 +2166,12 @@ async def run_region_sweep(
if fetch_flats:
coros.append(_fetch_flats_safe(sess, obj_id))
if extras:
coros.append(_fetch_sale_graph_safe(sess, obj_id, "apartments"))
coros.append(_fetch_sale_graph_safe(sess, obj_id, "parking"))
coros.append(_fetch_sales_agg_safe(sess, obj_id))
coros.append(_fetch_infrastructure_safe(sess, obj_id))
coros.append(_fetch_photos_safe(sess, obj_id))
coros.append(_fetch_doc_rpd_safe(sess, obj_id))
coros.append(_fetch_doc_developer_report_safe(sess, obj_id))
coros.append(_fetch_doc_project_documentation_safe(sess, obj_id))
coros.append(_fetch_doc_documentation_other_safe(sess, obj_id))
coros.append(_fetch_doc_permits_safe(sess, obj_id))
# extras в combined-сессии ТОЛЬКО когда изоляция выключена —
# иначе они отравят flats (#1945). В isolated-режиме extras
# обрабатываются отдельным проходом ниже.
combined_extras = extras and not extras_isolated
if combined_extras:
coros.extend(_extras_coros(sess, obj_id))
# TODO: obj_checks endpoint not found at /api/object/{id}/checks (404).
# 6 чек-боксов "Проверено на наш.дом.рф" вероятно inline в kn/object payload.
# Re-enable после investigation структуры объекта (separate PR).
@ -1922,64 +2185,28 @@ async def run_region_sweep(
# Sequential upsert — DB session не thread-safe
all_docs: list[dict[str, Any]] = []
_doc_kinds = frozenset(
(
"doc_rpd",
"doc_developer_report",
"doc_project_documentation",
"doc_documentation_other",
"doc_permits",
)
)
for kind_tag, full_url, result in results:
if isinstance(result, Exception):
_classify_and_log(db, run_id, obj_id, kind_tag, full_url, result)
continue
if kind_tag == "flats":
flats_list: list[dict[str, Any]] = result # type: ignore[assignment]
if flats_list:
total_flats += upsert_flats(db, flats_list, snapshot_date, region_code)
elif kind_tag in ("sale_graph_apartments", "sale_graph_parking"):
sg_type = kind_tag.replace("sale_graph_", "")
rows_sg, _ = result # type: ignore[misc]
extras_counts["sale_graph_rows"] += upsert_sale_graph(
db, obj_id, sg_type, rows_sg, snapshot_date
total_flats += _process_flats_result(
db, run_id, obj_id, full_url, result, snapshot_date, region_code
)
else:
await _process_extras_result(
sess,
db,
run_id,
obj_id,
kind_tag,
full_url,
result,
snapshot_date,
extras_counts,
all_docs,
download_photos_binary=download_photos_binary,
pdir=pdir,
)
elif kind_tag == "sales_agg":
agg_data, _ = result # type: ignore[misc]
extras_counts["sales_agg_rows"] += upsert_sales_agg(
db, obj_id, agg_data, snapshot_date
)
elif kind_tag == "infrastructure":
pois_data, _ = result # type: ignore[misc]
extras_counts["infra_rows"] += upsert_infrastructure(
db, obj_id, pois_data, snapshot_date
)
elif kind_tag == "photos":
photos_data, _ = result # type: ignore[misc]
local_paths: dict[str, str] = {}
thumb_paths: dict[str, str] = {}
if download_photos_binary and photos_data:
local_paths, thumb_paths = await download_photos(
sess, obj_id, photos_data, pdir
)
extras_counts["photos_downloaded"] += len(local_paths)
extras_counts["photos_rows"] += upsert_photos(
db, obj_id, photos_data, local_paths, thumb_paths
)
elif kind_tag in _doc_kinds:
# Каждый из 5 doc-endpoint'ов отдаёт свой список документов.
# Накапливаем в all_docs — единый upsert после цикла.
doc_items, _ = result # type: ignore[misc]
all_docs.extend(extract_documents(doc_items or []))
# Единый upsert всех документов объекта после обработки 5 endpoint'ов.
# Единый upsert всех документов объекта после обработки doc-endpoint'ов.
if all_docs:
ins, _skip = upsert_documents(db, obj_id, all_docs)
extras_counts["documents_rows"] += ins
@ -1998,12 +2225,49 @@ async def run_region_sweep(
f" downloaded={extras_counts['photos_downloaded']}"
f" docs={extras_counts['documents_rows']}"
f" checks={extras_counts['checks_rows']}",
stage="extras" if extras else "fetch_flats",
stage="extras" if combined_extras else "fetch_flats",
)
_checkpoint(db, run_id, total)
request_count = sess.request_count
# ── Phase C (isolated) — extras best-effort пас в ОТДЕЛЬНОЙ сессии ──────
# flats-сессия закрыта. Теперь (только если extras=True И extras_isolated)
# тянем extras в disposable-сессиях: на каждый WAF-403 recycle всю
# BrowserSession (свежие cookies/контекст) перед следующим объектом, чтобы
# яд НЕ накапливался. extras сейчас мертвы (403 с 2026-06-03) — пас целиком
# best-effort: любые сбои уходят в kn_scrape_failures и НЕ роняют run.
if extras and extras_isolated:
try:
ex_req = await _run_extras_pass(
db=db,
run_id=run_id,
region_code=region_code,
all_objects=all_objects,
start_index=start_index,
snapshot_date=snapshot_date,
extras_counts=extras_counts,
pdir=pdir,
download_photos_binary=download_photos_binary,
load_state=load_state,
headed=headed,
browser_concurrency=browser_concurrency,
request_jitter_min_ms=request_jitter_min_ms,
request_jitter_max_ms=request_jitter_max_ms,
proxy_url=proxy_url,
)
request_count += ex_req
except Exception as e:
# best-effort: extras-пас НИКОГДА не должен ронять уже собранные flats.
logger.warning("extras isolated pass failed (best-effort): %s", e)
log_progress(
db,
run_id,
f"extras-пас (best-effort) упал: {type(e).__name__}: {str(e)[:200]}",
level="warn",
stage="extras",
)
# ── Phase D — finalize ─────────────────────────────────────────────
db.execute(
text(

View file

@ -17,7 +17,7 @@ import json
import logging
import random
from typing import Any
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
@ -33,8 +33,45 @@ 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.
_BROWSER_CONCURRENCY = 8
# Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через
# BrowserSession(jitter_min_ms=, jitter_max_ms=) против rate-бана (#1945).
_DEFAULT_JITTER_MIN_MS = 600
_DEFAULT_JITTER_MAX_MS = 1500
def parse_proxy_url(proxy_url: str | None) -> dict[str, str] | None:
"""Распарсить ``http://user:pass@host:port`` в Playwright proxy-dict.
Playwright ждёт ``{"server": "http://host:port", "username": ..., "password": ...}``
(creds отдельные ключи, НЕ в server-URL). Возвращает None для пустого/None
входа вызывающий код запускает Chromium без прокси (поведение по умолчанию).
Тот же URL-формат, что использует tradein-стек (SCRAPER_PROXY_URL).
"""
if not proxy_url:
return None
parts = urlsplit(proxy_url)
if not parts.hostname:
# НЕ эхо-им сырой proxy_url — он содержит пароль, а это исключение
# всплывает в kn_scrape_runs.error / log_progress / Sentry (#1945 sec-review).
raise ValueError(f"proxy URL без host (scheme={parts.scheme!r})")
scheme = parts.scheme or "http"
server = f"{scheme}://{parts.hostname}"
if parts.port:
server += f":{parts.port}"
out: dict[str, str] = {"server": server}
if parts.username:
out["username"] = parts.username
if parts.password:
out["password"] = parts.password
return out
# Маппинг region_code → URL-сегмент города для реалистичного Referer.
# Не обязан быть исчерпывающим — fallback на /новостройки/строящиеся/.
REGION_LANDING_PATH = {
@ -94,6 +131,10 @@ class BrowserSession:
auth: str | None = "Basic MTpxd2U=",
load_state: str | None = None,
save_state: str | None = None,
concurrency: int | None = None,
jitter_min_ms: int | None = None,
jitter_max_ms: int | None = None,
proxy_url: str | None = None,
) -> None:
self.region_code = region_code
self.headed = headed
@ -105,11 +146,20 @@ class BrowserSession:
# so it can be committed and reused on a server.
self.load_state = load_state
self.save_state = save_state
# Per-instance concurrency (#1945): KN-sweep throttles to 2 against the
# volume-ban; catalog/nspd callers pass nothing → keep the module default
# _BROWSER_CONCURRENCY=8 (их поведение НЕ меняется).
self._concurrency = concurrency if concurrency is not None else _BROWSER_CONCURRENCY
# Per-instance inter-request jitter window (#1945). None → module default.
self._jitter_min_ms = jitter_min_ms if jitter_min_ms is not None else _DEFAULT_JITTER_MIN_MS
self._jitter_max_ms = jitter_max_ms if jitter_max_ms is not None else _DEFAULT_JITTER_MAX_MS
# Optional rotating proxy (#1945). None → Chromium launches direct (default).
self._proxy = parse_proxy_url(proxy_url)
self._pw: Any = None
self._browser: Browser | None = None
self._context: BrowserContext | None = None
self._page: Page | None = None
self._sem = asyncio.Semaphore(_BROWSER_CONCURRENCY)
self._sem = asyncio.Semaphore(self._concurrency)
self._request_count = 0
self._warmed_up = False
@ -127,12 +177,19 @@ class BrowserSession:
landing_url = make_referer(self.region_code)
logger.info("bootstrap: opening %s", landing_url)
self._pw = await async_playwright().start()
self._browser = await self._pw.chromium.launch(headless=not self.headed)
launch_kwargs: dict[str, Any] = {"headless": not self.headed}
ctx_kwargs: dict[str, Any] = {
"user_agent": USER_AGENT,
"locale": "ru-RU",
"viewport": {"width": 1920, "height": 1080},
}
if self._proxy is not None:
# Прокси задаётся и на launch, и на context (Playwright требует на
# browser-level для Chromium; context-level дублирует для надёжности).
launch_kwargs["proxy"] = self._proxy
ctx_kwargs["proxy"] = self._proxy
logger.info("bootstrap: routing Chromium through proxy %s", self._proxy["server"])
self._browser = await self._pw.chromium.launch(**launch_kwargs)
if self.load_state:
ctx_kwargs["storage_state"] = self.load_state
logger.info("bootstrap: loading saved storage_state from %s", self.load_state)
@ -193,7 +250,7 @@ class BrowserSession:
last_err: Exception | None = None
for attempt in range(5):
async with self._sem:
await jitter_sleep()
await jitter_sleep(self._jitter_min_ms, self._jitter_max_ms)
try:
self._request_count += 1
result = await self._page.evaluate(_FETCH_JS, {"url": url, "auth": self.auth})

File diff suppressed because it is too large Load diff

View file

@ -491,28 +491,68 @@ _AVG_PRICE_SQL = text("""
# COUNT(DISTINCT ol.objective_lot_id) — дедуп на уровне лота, fan-out-safe.
# (_OBJECTIVE_PRICE_FALLBACK не прецедент: percentile/median dup-нечувствительны.)
#
# #1964: objective_lots — current-state UPSERT, но НЕ «ровно одна строка на лот».
# UNIQUE = objective_lot_id, а Объектив за пере-листинги присваивает ОДНОМУ
# физлоту (project,corpus,section,floor,lot) НЕСКОЛЬКО objective_lot_id → таблица
# раздута ~2.91× → flats_sold завышался. Поэтому ОБЯЗАТЕЛЕН physflat-дедуп до
# последнего снапшота на физлот (DISTINCT ON по physflat-ключу, snapshot_date DESC,
# id DESC).
#
# #1964→hunt #1953: РАНЬШЕ дедуп брался из v_objective_lots_latest (JOIN ON
# project_name). Регрессия: view материализует ВЕСЬ DISTINCT ON по 1.76M строк ДО
# того, как применится фильтр маппинга (cm.domrf_obj_id = ANY) — qual не
# проталкивается ниже DISTINCT ON view → Parallel Seq Scan + external sort всей
# таблицы (~213 MB temp, ~6896 ms на каждый cold /analyze), хотя нужны лоты лишь
# ~15 ЖК. FIX (зеркало #1964 inline-pushdown для analyze-консьюмеров +
# market_metrics._STOCK_SQL): сперва фильтруем objective_complex_mapping в CTE
# `mapped`, затем DISTINCT ON по СЫРОЙ objective_lots, JOIN ON project_name — так
# objective_lots_project_idx (project_name, snapshot_date DESC) обслуживает фильтр
# и дедуп материализуется ТОЛЬКО по лотам нужных ЖК. Прод-EXPLAIN: 6896 → ~437 ms
# (15.7×), вывод побайтово идентичен (hunt: 7082=7082, 0 mismatch).
#
# COUNT(DISTINCT objective_lot_id) сохраняем (fan-out-safety из #1964): маппинг 1:N
# по domrf_obj_id (UNIQUE = (objective_complex_name, objective_group), см. выше) при
# fan-out задвоил бы строки, COUNT(*) был бы не fan-out-safe. DISTINCT ON даёт
# physflat-дедуп, COUNT(DISTINCT) — fan-out-защиту по маппингу.
#
# ⚠ Читаем СЫРУЮ objective_lots inline, НЕ v_objective_lots_latest (см. выше).
#
# premise_kind='квартира' — как в _STOCK_SQL: без него проданные паркинги/
# кладовые/нежилое считались бы как проданные квартиры (over-count).
# objective_lots — current-state UPSERT (ON CONFLICT (objective_lot_id) DO
# UPDATE, ровно одна строка на лот) → snapshot-дедуп НЕ нужен (как в _STOCK_SQL).
#
# Дефолт premise_kind зеркалит market_metrics.compute_market_metrics
# (premise_kind="квартира") — оба считают продажи жилых лотов.
_SOLD_PREMISE_KIND = "квартира"
_SOLD_COUNT_SQL = text("""
WITH mapped AS (
SELECT cm.domrf_obj_id, cm.objective_complex_name
FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id = ANY(:obj_ids)
),
latest AS (
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
)
m.domrf_obj_id,
ol.objective_lot_id,
(
ol.is_sold IS TRUE
OR ol.contract_date IS NOT NULL
OR LOWER(COALESCE(ol.status, '')) = 'продан'
) AS sold_now
FROM mapped m
JOIN objective_lots ol
ON ol.project_name = m.objective_complex_name
WHERE ol.premise_kind = :premise_kind
ORDER BY
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
SELECT
cm.domrf_obj_id AS obj_id,
COUNT(DISTINCT ol.objective_lot_id) FILTER (
WHERE ol.is_sold IS TRUE
OR ol.contract_date IS NOT NULL
OR LOWER(COALESCE(ol.status, '')) = 'продан'
) AS flats_sold
FROM objective_complex_mapping cm
JOIN objective_lots ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND ol.premise_kind = :premise_kind
GROUP BY cm.domrf_obj_id
domrf_obj_id AS obj_id,
COUNT(DISTINCT objective_lot_id) FILTER (WHERE sold_now) AS flats_sold
FROM latest
GROUP BY domrf_obj_id
""")
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.

View file

@ -0,0 +1,624 @@
"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3 (#2119).
Собирает по участку:
- power_points: центры питания (power_supply_centers) в радиусе от центроида
участка (ST_DWithin geography) с классом напряжения / индексом загрузки /
резервом свободной мощности. Для ПС ЕЭСК без раскрытого резерва reserve_note
«резерв по запросу в АО «ЕЭСК» (город)».
- power_summary: ближайший ЦП с резервом + count по load_index.
- water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level).
- gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью
(gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1.
+ счётчики точек выхода (gas_grs_outlet_points, форма 6 ФАС 960 АО
«Екатеринбурггаз») последнего period_month: всего / дефицитных / «нужен
гидрорасчёт». #2119 B2 PR-4.
- gas_outlet_points: гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от
участка (ST_DWithin geom ~2000 м, latest period, LIMIT 40 по расстоянию) для слоя
на карте §3. Свободная мощность в млн.куб.м/МЕС (НЕ тыс.м³/ч как у ГРС!). #2119 B2.
- heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves)
за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2.
- nearby_network_zones: ПОЗИТИВ-разрез сетевые охранные зоны из cad_zouit в радиусе
300 м от участка (там физически ПРОХОДИТ сеть «теплотрасса в 24 м, врезка
дешёвая»). До 2 ближайших зон НА КАЖДЫЙ net_kind (thermal/electricity/gas/), с
гуманизированной подписью (кад.номер трубопровода / напряжение ВЛ + ТП). #2119 C2/C3.
Источник координат участка общий ``_get_parcel_wkt`` (cad_parcels_geom квартал
fallback). Радиус по умолчанию 3000 м (cap 10000).
"""
import logging
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.services.site_finder.network_zone_humanize import human_label, refine_net_kind
logger = logging.getLogger(__name__)
_DEFAULT_RADIUS_M = 3000
_MAX_RADIUS_M = 10000
# Радиус позитив-разреза сетевых охранных зон (cad_zouit): фиксированный 300 м — «сеть
# рядом с участком». Не зависит от radius_m ЦП (тот про магистральные ЦП за км).
# Degree-эквивалент для GIST-префильтра: 300 м / ~60.8 км/° долготы у ЕКБ (56.8° N) ≈
# 0.0050° — берём с запасом 0.006°, точный отсев делает geography ST_DWithin поверх. C2.
_NETWORK_ZONE_RADIUS_M = 300
_NETWORK_ZONE_RADIUS_DEG = 0.006
# До скольких ближайших зон отдаём НА КАЖДЫЙ net_kind (thermal/electricity/gas/…). C2.
_NETWORK_ZONE_PER_KIND = 2
# Предохранитель: запрос GIST-индексирован (~70 мс на плотном центре ЕКБ, EXPLAIN прод
# 2026-07-03), но эндпоинт синхронный — таймаут-guard как у offer_trend (parcels.py).
_NETWORK_ZONE_TIMEOUT_MS = 4000
# Радиус для гео-точек выхода газосети (gas_grs_outlet_points): фиксированный ~2000 м —
# точки привязаны к конкретным объектам (котельные/промплощадки), не к магистральным
# ЦП, поэтому свой узкий радиус независимо от radius_m ЦП. LIMIT — топ-N по расстоянию,
# чтобы слой на карте §3 не разбухал (у ~19% строк есть geom после геокода). #2119 B2.
_GAS_OUTLET_RADIUS_M = 2000
_GAS_OUTLET_LIMIT = 40
# Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная
# мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1.
_EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)"
def get_connection_capacity(
db: Session,
cad_num: str,
radius_m: int = _DEFAULT_RADIUS_M,
) -> dict:
"""Точки подключения со свободной мощностью вблизи участка.
Args:
db: SQLAlchemy session.
cad_num: кадастровый номер участка.
radius_m: радиус поиска ЦП, м (caller клампит 1..10000).
Returns:
{"power_points": [...], "power_summary": {...}, "water": [...],
"gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None,
"nearby_network_zones": [...]}
Raises:
ValueError: участок не найден в БД (нет geom).
"""
# Lazy import — избегаем циклического импорта на module-load.
from app.services.site_finder.quarter_dump_lookup import _get_parcel_wkt
radius_m = max(1, min(int(radius_m), _MAX_RADIUS_M))
parcel_wkt = _get_parcel_wkt(db, cad_num)
if parcel_wkt is None:
raise ValueError(f"Участок {cad_num!r} не найден в БД")
power_points = _query_power_points(db, parcel_wkt, radius_m)
power_summary = _build_power_summary(power_points)
water = _query_water_latest(db)
gas = _query_gas_city_grs(db)
gas_outlet_points = _query_gas_outlet_points(db, parcel_wkt)
heat = _query_heat_latest(db)
nearby_network_zones = _query_nearby_network_zones(db, parcel_wkt)
return {
"power_points": power_points,
"power_summary": power_summary,
"water": water,
"gas": gas,
"gas_outlet_points": gas_outlet_points,
"heat": heat,
"nearby_network_zones": nearby_network_zones,
}
def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]:
"""Центры питания в радиусе от центроида участка (ST_DWithin geography)."""
rows = (
db.execute(
text("""
SELECT sc_name, dzo_name, voltage_class, load_index,
installed_capacity_mva, current_load_mva, reserve_mva,
reserve_unit, district, reserve_asof,
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 power_supply_centers
WHERE geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
"""),
{"wkt": parcel_wkt, "radius_m": radius_m},
)
.mappings()
.all()
)
points: list[dict] = []
for r in rows:
reserve_mva = _num(r["reserve_mva"])
points.append(
{
"name": r["sc_name"],
"dzo_name": r["dzo_name"],
"voltage_class": r["voltage_class"],
"load_index": r["load_index"],
"installed_capacity_mva": _num(r["installed_capacity_mva"]),
"current_load_mva": _num(r["current_load_mva"]),
"reserve_mva": reserve_mva,
# Единица резерва: 'МВА' (Россети, default) или 'МВт' (ЕЭСК ПС 35-220).
# district — адм. район ЕКБ из xlsx ЕЭСК (NULL у Россети-строк). #2119 B2.
"reserve_unit": r["reserve_unit"],
"district": r["district"],
"reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None,
"reserve_note": _reserve_note(r["dzo_name"], reserve_mva),
"distance_m": round(float(r["distance_m"]), 1),
"lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
"lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
}
)
return points
def _reserve_note(dzo_name: str | None, reserve_mva: float | None) -> str | None:
"""Пометка резерва для ПС ЕЭСК без опубликованного резерва (#2119 B1).
Для ПС АО «ЕЭСК» (городская сеть ЕКБ) свободная мощность не раскрывается в
WFS/xlsx-источниках reserve_mva IS NULL. Показываем честную пометку «резерв по
запросу». Для ПС других ДЗО (или ЕЭСК с известным резервом) None.
"""
if reserve_mva is not None:
return None
if dzo_name and "еэск" in dzo_name.lower():
return _EESK_RESERVE_NOTE
return None
def _build_power_summary(points: list[dict]) -> dict:
"""Сводка: ближайший ЦП с положительным резервом + count по load_index."""
by_load_index: dict[str, int] = {}
for p in points:
li = p["load_index"] or "unknown"
by_load_index[li] = by_load_index.get(li, 0) + 1
# points уже отсортированы по distance ASC → первый с reserve_mva>0 = ближайший.
nearest_with_reserve = next(
(p for p in points if p["reserve_mva"] is not None and p["reserve_mva"] > 0),
None,
)
return {
"total_power_points": len(points),
"by_load_index": by_load_index,
"nearest_with_reserve": nearest_with_reserve,
}
def _query_water_latest(db: Session) -> list[dict]:
"""Все строки water_supply_reserves за ПОСЛЕДНИЙ period ПО КАЖДОМУ system_kind.
«Последний» max(period) лексикографически ('2026-Q1' < '2026-Q2' < ),
что для формата 'YYYY-Qn' совпадает с хронологией. period IS NULL исключаем.
MAX(period) берём per-system_kind (коррелированный подзапрос): Водоканал может
опубликовать водоотведение на квартал позже водоснабжения глобальный MAX
молча выкинул бы отстающий вид целиком.
"""
rows = (
db.execute(
text("""
SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day,
w.note, w.period
FROM water_supply_reserves w
WHERE w.period IS NOT NULL
AND w.period = (
SELECT MAX(w2.period) FROM water_supply_reserves w2
WHERE w2.period IS NOT NULL
AND w2.system_kind = w.system_kind
)
ORDER BY w.system_kind, w.system_name
""")
)
.mappings()
.all()
)
return [
{
"system_kind": r["system_kind"],
"system_name": r["system_name"],
"reserve_thousand_m3_day": _num(r["reserve_thousand_m3_day"]),
"note": r["note"],
"period": r["period"],
}
for r in rows
]
def _query_gas_city_grs(db: Session) -> dict:
"""Городские ГРС ЕКБ со свободной мощностью (gas_grs_capacity), агрегация по ГРС.
Фильтр «городских» ГРС по имени (ILIKE '%свердловск%' / '%екатеринбург%').
Агрегация по grs_name (у ГРС несколько строк-выходов): SUM(design), SUM(free),
AVG(pct), MAX(upgrade_due), COUNT(*) outputs_count. Пустая/несуществующая таблица
city_grs=[] (не падаем блок аддитивный, gas_grs_capacity может ещё не
существовать до применения миграции 181).
Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из
gas_grs_outlet_points (форма 6 ФАС 960 АО «Екатеринбурггаз») за последний
period_month outlets_total / outlets_deficit (free<0) / outlets_needs_calc.
См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС».
Returns: {"city_grs": [...], "total_free_th_m3_h": float|None,
"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
Размерности: свободная мощность ГРС тыс. м³/ЧАС; свободная мощность точек выхода
(в счётчиках/gas_outlet_points) млн. м³/МЕС. НЕ путать при агрегации.
"""
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
""")
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 181 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
# точек выхода (миграция 184, независимая) — оставляем, что удалось собрать.
logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e)
return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts}
city_grs: list[dict] = []
total_free = 0.0
has_free = False
for r in rows:
free = _num(r["free_sum"])
if free is not None:
total_free += free
has_free = True
city_grs.append(
{
"grs_name": r["grs_name"],
"design_capacity_th_m3_h": _num(r["design_sum"]),
"free_capacity_th_m3_h": free,
"free_capacity_pct": (
round(float(r["pct_avg"]), 1) if r["pct_avg"] is not None else None
),
"upgrade_due": r["upgrade_due"],
"outputs_count": int(r["outputs_count"]),
}
)
return {
"city_grs": city_grs,
"total_free_th_m3_h": round(total_free, 3) if has_free else None,
**outlet_counts,
}
def _query_gas_outlet_counts(db: Session) -> dict:
"""Счётчики точек выхода газосети (gas_grs_outlet_points) за последний period_month.
По ВСЕМ строкам последнего среза БЕЗ разбивки по ГРС (обоснование): grs_name_norm в
форме 6 АО «Екатеринбурггаз» это block-marker «Точка входа» (5 блоков ГТЕ вроде
«грс-1 свердловск»), НЕ строгий ключ городской связки gas_grs_capacity (ГТЕ, другой
источник). Матч outletГРС по grs_name_norm без FK ненадёжен на per-набор уровне
(имена ГРС в двух раскрытиях расходятся) честнее агрегировать счётчики ПО ГОРОДУ
целиком, чем молча терять строки на непопавшем join. Это city-level индикатор §3
«сколько точек выхода в дефиците / требуют гидрорасчёта», не per-ГРС.
Считаем за MAX(period_month) (свежайший помесячный срез); is_forecast не фильтруем
в проде факт/прогноз разведены, но для счётчика важен объём точек последнего среза.
Returns: {"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
- outlets_deficit free_capacity_mln_m3 < 0 (дефицит со знаком, млн.куб.м/МЕС);
- outlets_needs_calc free_capacity_needs_calc = TRUE («0.000*», нужен гидрорасчёт).
Таблицы может не быть (миграция 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
)
""")
)
.mappings()
.first()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
logger.warning("gas_grs_outlet_points недоступна (миграция 184 не применена?): %s", e)
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
if row is None:
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
return {
"outlets_total": int(row["outlets_total"] or 0),
"outlets_deficit": int(row["outlets_deficit"] or 0),
"outlets_needs_calc": int(row["outlets_needs_calc"] or 0),
}
def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
"""Гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка — слой §3.
ST_DWithin(geom::geography, центроид::geography, _GAS_OUTLET_RADIUS_M) за последний
period_month, ORDER BY distance ASC, LIMIT _GAS_OUTLET_LIMIT. geom есть лишь у ~19%
строк (геокод DaData, PR-3) в радиус попадает малое подмножество. Размерность
free_capacity_mln_m3 млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Таблицы может не быть
(миграция 184) [] (graceful, блок аддитивный).
План (EXPLAIN прод 2026-07-03): Seq Scan ~13 мс geography-каст НЕ использует
geometry-GIST; при 1037 строках это норм. Вырастет 10-50× функциональный GIST
по (CAST(geom AS geography)) или ST_DWithin по geometry с градусным радиусом.
Returns: [{outlet_name, consumer_type, free_capacity_mln_m3, needs_calc,
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,
},
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
logger.warning("gas_grs_outlet_points (радиус) недоступна (миграция 184?): %s", e)
return []
return [
{
"outlet_name": r["outlet_name"],
"consumer_type": r["consumer_type"],
# млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!); может быть <0 — дефицит со знаком.
"free_capacity_mln_m3": _num(r["free_capacity_mln_m3"]),
"needs_calc": bool(r["needs_calc"]),
"distance_m": round(float(r["distance_m"]), 1),
"lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
"lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
}
for r in rows
]
def _query_heat_latest(db: Session) -> dict:
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.
Строки heat_system_reserves за MAX(period) PER org (коррелированный подзапрос
как в _query_water_latest: одна организация может опубликовать свежий квартал
раньше другой, глобальный MAX молча выкинул бы отстающую). period IS NULL
исключаем; MAX(period) лексикографически совпадает с хронологией для 'YYYY-Qn'.
Сортировка org, system_name. Graceful при отсутствии таблицы (как gas-блок).
Returns: {"systems": [{org, system_name, reserve_gcal_h, period}],
"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
""")
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 182 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
logger.warning("heat_system_reserves недоступна (миграция 182 не применена?): %s", e)
return {"systems": [], "total_reserve_gcal_h": None}
systems: list[dict] = []
total_reserve = 0.0
has_reserve = False
for r in rows:
reserve = _num(r["reserve_gcal_h"])
if reserve is not None:
total_reserve += reserve
has_reserve = True
systems.append(
{
"org": r["org"],
"system_name": r["system_name"],
"reserve_gcal_h": reserve,
"period": r["period"],
}
)
return {
"systems": systems,
"total_reserve_gcal_h": round(total_reserve, 3) if has_reserve else None,
}
def _query_nearby_network_zones(db: Session, parcel_wkt: str) -> list[dict]:
"""Сетевые охранные зоны cad_zouit в радиусе 300 м от участка — позитив-развёртка §3.
Охранная зона сети = коридор, где физически ПРОХОДИТ сеть. «Теплотрасса в 24 м
врезка дешёвая» решающий сигнал для финдиректора (особенно heat, у которого в §3
нет своей гео-привязки). Зоны, ПЕРЕСЕКАЮЩИЕ участок, включаем с distance_m=0.
План (EXPLAIN прод 2026-07-03, cad_zouit_geom_gist существует): GIST-префильтр
``ST_DWithin(geom, parcel, 0.006°)`` + точный ``ST_DWithin(geom::geography,
parcel::geography, 300)`` Index Scan, ~70 мс на плотном центре ЕКБ. cad_zouit.geom
= geometry(MultiPolygon,4326); geography-каст приклеен к ``)``, не к bind-имени
(psycopg3-safe, как ``ARRAY[...]::int[]`` / power_points-запрос).
#1957: одна физическая зона хранится 2× (одинаковый reg_numb_border, разный
category_name) DISTINCT ON (reg_numb_border) схлопывает, оставляя ближайшую строку.
Гуманизация (C3, ``network_zone_humanize``): net_kind дочищается по name_by_doc
(general «инженерные коммуникации» на проде часто РЕАЛЬНО теплосеть/газопровод),
human_label вытаскивает кад.номер трубопровода / напряжение ВЛ + ТП. Итог до
_NETWORK_ZONE_PER_KIND ближайших зон НА КАЖДЫЙ net_kind (сорт по distance ASC).
Таймаут-guard в savepoint: запрос лёгкий, но эндпоинт синхронный при аномально
медленном скане (QueryCanceled) деградируем в [], не роняя весь эндпоинт. Блок
аддитивный (позитив-сигнал), отсутствие данных читается как «сеть рядом не найдена».
"""
try:
with db.begin_nested():
# SET не принимает bind-параметры (extended protocol → syntax error
# at or near "$1") — только литерал, как у offer_trend (parcels.py).
db.execute(text(f"SET LOCAL statement_timeout = '{_NETWORK_ZONE_TIMEOUT_MS:d}'"))
rows = (
db.execute(
text("""
SELECT DISTINCT ON (z.reg_numb_border)
z.type_zone,
z.name_by_doc,
z.reg_numb_border,
round(
ST_Distance(
z.geom::geography,
ST_GeomFromText(:wkt, 4326)::geography
)::numeric
) AS distance_m
FROM cad_zouit z
WHERE z.geom IS NOT NULL
AND z.type_zone IN (
'Охранная зона тепловых сетей',
'Охранная зона инженерных коммуникаций',
'Охранная зона объектов электроэнергетики'
' (объектов электросетевого хозяйства и объектов по'
' производству электрической энергии)',
'Охранная зона трубопроводов (газопроводов,'
' нефтепроводов и нефтепродуктопроводов, аммиакопроводов)',
'Охранная зона линий и сооружений связи'
)
AND ST_DWithin(
z.geom,
ST_GeomFromText(:wkt, 4326),
CAST(:radius_deg AS float)
)
AND ST_DWithin(
z.geom::geography,
ST_GeomFromText(:wkt, 4326)::geography,
CAST(:radius_m AS float)
)
ORDER BY z.reg_numb_border, distance_m ASC
"""),
{
"wkt": parcel_wkt,
"radius_deg": _NETWORK_ZONE_RADIUS_DEG,
"radius_m": _NETWORK_ZONE_RADIUS_M,
},
)
.mappings()
.all()
)
# SET LOCAL переживает RELEASE SAVEPOINT (transaction-scoped) —
# сбрасываем явно, как в offer_trend (parcels.py), чтобы таймаут
# не утёк на последующие запросы этой транзакции.
db.execute(text("SET LOCAL statement_timeout = 0"))
except SQLAlchemyError as e:
# QueryCanceled (таймаут) / отсутствие таблицы / прочие SQL-сбои — блок аддитивный
# (позитив-сигнал), деградируем в пустой, не роняя весь эндпоинт connection-capacity.
logger.warning("nearby_network_zones query failed: %s", e)
return []
return _shape_network_zones(rows)
def _shape_network_zones(rows: list) -> list[dict]:
"""DISTINCT-строки cad_zouit → гуманизированные зоны, до N ближайших per net_kind.
Классификацию net_kind (refine) и подпись (human_label) считаем в Python (regex по
name_by_doc), затем сортируем по distance ASC и берём _NETWORK_ZONE_PER_KIND на вид.
Возвращаемый список отсортирован по (distance_m, net_kind) для стабильного порядка.
"""
per_kind: dict[str, list[dict]] = {}
for r in rows:
type_zone = r["type_zone"] or ""
name_by_doc = r["name_by_doc"]
net_kind = refine_net_kind(type_zone, name_by_doc)
if net_kind is None:
# type_zone из белого списка, но не классифицировался — пропускаем (не должно).
continue
distance_m = int(r["distance_m"]) if r["distance_m"] is not None else 0
per_kind.setdefault(net_kind, []).append(
{
"net_kind": net_kind,
"distance_m": distance_m,
"name_by_doc": name_by_doc,
"human_label": human_label(net_kind, name_by_doc),
}
)
result: list[dict] = []
for kind_rows in per_kind.values():
kind_rows.sort(key=lambda z: z["distance_m"])
result.extend(kind_rows[:_NETWORK_ZONE_PER_KIND])
# Стабильный внешний порядок: ближайшее сверху, затем по виду сети.
result.sort(key=lambda z: (z["distance_m"], z["net_kind"]))
return result
def _num(value: object) -> float | None:
"""NUMERIC (Decimal) из БД → float | None."""
return float(value) if value is not None else None # type: ignore[arg-type]

View file

@ -0,0 +1,432 @@
"""Загрузчик резервов свободной мощности АО «ЕЭСК» из xlsx-раскрытия (#2119 B2).
Хаб eesk.ru «Наличие объёма свободной для технологического присоединения
мощности» статический HTML со ссылками на xlsx двух серий:
(а) ПС 35-220 кВ («резерв мощности по центрам питания», текст ссылки БЕЗ «ниже»
/ содержит «35-220») апдейт ``power_supply_centers`` (резерв в МВт!);
(б) ТП/РП «ниже 35» кВ (текст ссылки содержит «ниже 35»)
UPSERT ``power_tp_rp_reserves``.
Свежайший файл каждой серии выбирается по max(YYYY, N-квартал) из ТЕКСТА ссылки
(«N квартал(а) YYYY»). period='YYYY-QN', reserve_asof = последний день квартала.
Источник обычный SSL, БЕЗ гео-блока, но лоадер всё равно RUN-ON-PROD рядом с
rosseti/gas/vodokanal (единый weekly-воркер). httpx с явным таймаутом, per-row
SAVEPOINT при UPSERT (битая строка не валит батч).
Числовой парс/дата переиспользуют ``parse_reserve_number`` / ``parse_asof_date``
из ``rosseti_reserve_loader``; матч ЦП общий ``normalize_sc_name``.
"""
import io
import logging
import re
from datetime import date
import httpx
from openpyxl import load_workbook
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.site_finder.rosseti_reserve_loader import (
parse_asof_date, # noqa: F401 — переэкспорт для соседей/тестов симметрии
parse_reserve_number,
)
from app.services.site_finder.rosseti_wfs_loader import normalize_sc_name
logger = logging.getLogger(__name__)
# Хаб раскрытия (статический HTML). ЕЭСК — латиница, punycode не требуется.
HUB_URL = (
"https://www.eesk.ru/Raskritie_informacii/standarty_raskrytia_info/"
"Ob_osnovnih_potrebitelskih_harakteristik/"
"Informacija_o_tehnicheskom_sostojanii_se/"
"Nalichie_obema_svobodnoj_dlja_tehnologic"
)
_HTTP_TIMEOUT = 60
# Ссылки на xlsx-файлы в загрузках сайта: /upload/site1/document_file/<hash>.xls[x].
# Захватываем href + видимый ТЕКСТ ссылки (в тексте — серия и квартал/год).
_LINK_RE = re.compile(
r'<a\b[^>]*href=["\'](?P<href>[^"\']*/upload/site1/document_file/'
r'[^"\']+?\.xlsx?)["\'][^>]*>(?P<text>.*?)</a>',
re.IGNORECASE | re.DOTALL,
)
# «N квартал[а] YYYY» в тексте ссылки → (N, YYYY). Слово «квартал» в любой форме.
_QUARTER_RE = re.compile(r"(\d)\s*квартал\w*\s*(\d{4})", re.IGNORECASE)
_BASE = "https://www.eesk.ru"
# Последний день квартала (месяц, день) по номеру квартала.
_QUARTER_LAST_DAY: dict[int, tuple[int, int]] = {
1: (3, 31),
2: (6, 30),
3: (9, 30),
4: (12, 31),
}
def _strip_tags(html_fragment: str) -> str:
"""Убирает html-теги/entity-мусор из текста ссылки → плоская строка."""
text_only = re.sub(r"<[^>]+>", " ", html_fragment)
text_only = text_only.replace("&nbsp;", " ").replace("\xa0", " ")
return re.sub(r"\s+", " ", text_only).strip()
def _classify_series(link_text_low: str) -> str | None:
"""Классифицирует ссылку по её тексту: 'ps_35_220' | 'tp_rp_sub35' | None.
Различаем серии ТОЛЬКО по тексту ссылки:
(a) ПС 35-220 содержит «35-220» ИЛИ «резерв мощности по центрам питания»,
при этом НЕ содержит «ниже» (иначе это серия б);
(b) ТП/РП <35 содержит «ниже 35».
"""
has_below = "ниже 35" in link_text_low or "ниже35" in link_text_low
if has_below:
return "tp_rp_sub35"
if "35-220" in link_text_low or "по центрам питания" in link_text_low:
return "ps_35_220"
return None
def _quarter_from_text(link_text: str) -> tuple[int, int] | None:
"""Извлекает (year, quarter) из текста ссылки «…N квартал(а) YYYY…». Нет → None."""
m = _QUARTER_RE.search(link_text)
if not m:
return None
quarter = int(m.group(1))
year = int(m.group(2))
if quarter not in _QUARTER_LAST_DAY:
return None
return (year, quarter)
def quarter_period(year: int, quarter: int) -> str:
"""(2026, 2) → '2026-Q2'."""
return f"{year}-Q{quarter}"
def quarter_last_day(year: int, quarter: int) -> date:
"""Последний календарный день квартала → date (для reserve_asof)."""
month, day = _QUARTER_LAST_DAY[quarter]
return date(year, month, day)
def fetch_hub_links() -> dict[str, dict]:
"""Скрейпит хаб → СВЕЖАЙШИЙ xlsx каждой серии (ps_35_220 + tp_rp_sub35).
RUN-ON-PROD. GET с follow_redirects, regex по <a href uploadxlsx>ТЕКСТ</a>,
классификация серии + квартал по ТЕКСТУ ссылки, выбор max(year, quarter).
Returns: {series: {"url": str, "year": int, "quarter": int}} только найденные.
"""
resp = httpx.get(HUB_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True)
resp.raise_for_status()
return select_freshest_links(resp.text)
def select_freshest_links(html: str) -> dict[str, dict]:
"""Из HTML-хаба выбирает свежайшую ссылку каждой серии (чистая, тестируемая).
Свежайший = max((year, quarter)) среди ссылок одной серии. Ссылки без
распознанного квартала игнорируются (нельзя датировать).
"""
best: dict[str, dict] = {}
n_links = 0
for m in _LINK_RE.finditer(html):
n_links += 1
link_text = _strip_tags(m.group("text"))
series = _classify_series(link_text.lower())
if series is None:
continue
yq = _quarter_from_text(link_text)
if yq is None:
continue
year, quarter = yq
href = m.group("href")
url = href if href.startswith("http") else _BASE + href
prev = best.get(series)
if prev is None or (year, quarter) > (prev["year"], prev["quarter"]):
best[series] = {"url": url, "year": year, "quarter": quarter}
logger.info(
"eesk hub links: ps_35_220=%r tp_rp_sub35=%r (from %d xlsx links)",
best.get("ps_35_220"),
best.get("tp_rp_sub35"),
n_links,
)
return best
def _cell(row: tuple, idx: int) -> object:
"""Безопасно достаёт ячейку row по 0-based индексу (None если за границей)."""
return row[idx] if idx < len(row) else None
def _pct_share_to_percent(value: object) -> float | None:
"""Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем.
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах.
None/мусор None.
"""
num = parse_reserve_number(value)
if num is None:
return None
# Доля 0..1 → *100. Значения >1 считаем уже процентами (защита от смешанного ввода).
return round(num * 100.0, 2) if -1.0 <= num <= 1.0 else num
def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> dict[str, int]:
"""Апдейт power_supply_centers резервом ЕЭСК ПС 35-220 из xlsx.
ВЕРИФИЦИРОВАННАЯ структура (лист ``Лист1``, снята с живого файла): шапка строка
4 (1-based), данные строки 7-63 и 65-83, между секциями строки-разделители.
Скипаем строки где колонка C (имя ПС) пуста или D (мощность) не парсится числом.
Колонки 0-based: A=, B=адм. район, C=имя ПС, D=макс полная мощность МВА,
E=% загрузки ДОЛЯ (0.4141.0), F=степень МУ250 (в raw), G=свободная МВт.
Матч к power_supply_centers по normalize_sc_name(C) AND dzo_name ILIKE '%ЕЭСК%'.
reserve_mva=G, reserve_unit='МВт', installed_capacity_mva=D, district=B,
capacity_source='eesk_35_220', reserve_asof. Unmatched имена warning-лог.
Returns: счётчики rows/matched/unmatched.
"""
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
ws = wb["Лист1"] if "Лист1" in wb.sheetnames else wb.active
rows_seen = 0
matched = 0
unmatched = 0
unmatched_names: list[str] = []
try:
# min_row=7 (1-based) → данные с 7-й строки; секции 7-63 и 65-83 идут подряд,
# строки-разделители отсеиваются проверкой C пусто / D не число.
for row in ws.iter_rows(min_row=7, values_only=True):
sc_name = _cell(row, 2) # C
installed = parse_reserve_number(_cell(row, 3)) # D
# Строка-разделитель между секциями: нет имени ПС ИЛИ мощность не число.
if sc_name is None or not str(sc_name).strip() or installed is None:
continue
rows_seen += 1
district = _cell(row, 1) # B
load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %)
reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт)
name_norm = normalize_sc_name(str(sc_name))
params = {
"installed": installed,
"reserve": reserve,
"asof": reserve_asof,
"district": str(district).strip() if district else None,
"load_pct": load_pct,
"name_norm": name_norm,
}
try:
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
res = db.execute(
text("""
UPDATE power_supply_centers
SET reserve_mva = :reserve,
reserve_unit = 'МВт',
installed_capacity_mva = :installed,
district = :district,
load_index = COALESCE(
load_index,
CAST(:load_pct AS text)
),
capacity_source = 'eesk_35_220',
reserve_asof = :asof
WHERE sc_name_norm = :name_norm
AND dzo_name ILIKE '%ЕЭСК%'
"""),
params,
)
if res.rowcount and res.rowcount > 0:
matched += res.rowcount
else:
unmatched += 1
if len(unmatched_names) < 50:
unmatched_names.append(str(sc_name).strip())
except Exception as e:
logger.warning("eesk ps_35_220 update failed for %r: %s", sc_name, e)
unmatched += 1
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_ps_35_220: outer tx rolled back: %s", e)
raise
finally:
wb.close()
if unmatched_names:
logger.warning(
"eesk ps_35_220 unmatched (%d) sc_name список: %s",
unmatched,
unmatched_names,
)
logger.info(
"load_ps_35_220 done: rows=%d matched=%d unmatched=%d",
rows_seen,
matched,
unmatched,
)
return {"rows": rows_seen, "matched": matched, "unmatched": unmatched}
def load_tp_rp_sub35(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> dict[str, int]:
"""UPSERT резервов ЕЭСК ТП/РП «ниже 35» кВ в power_tp_rp_reserves из xlsx.
ВЕРИФИЦИРОВАННАЯ структура (лист ``Публикация``): строка 1 title, строка 3
шапка, ДАННЫЕ С СТРОКИ 4 (~2014 строк). Колонки 0-based: A=номер ТП/РП
(«10003»), B=предельно допустимая загрузка МВт, C=загрузка по 4 замерам МВт,
D=текущий резерв МВт.
UPSERT: name=«ТП/РП <A>», name_norm=str(A).strip().lower(),
municipality='Екатеринбург', voltage_kv=NULL, installed=B, current=C,
reserve=D, reserve_asof. ON CONFLICT (name_norm, municipality, voltage_kv) DO
UPDATE. БЕЗ кВА-санитайза. Per-row SAVEPOINT.
Returns: счётчики rows/inserted/updated/skipped.
"""
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
ws = wb["Публикация"] if "Публикация" in wb.sheetnames else wb.active
rows_seen = 0
inserted = 0
updated = 0
skipped = 0
try:
# min_row=4 (1-based) → данные с 4-й строки; 1 title, 3 шапка выше.
for row in ws.iter_rows(min_row=4, values_only=True):
num = _cell(row, 0) # A
if num is None or not str(num).strip():
continue
# Номер ТП/РП бывает float из Excel (10003.0) — нормализуем к целому виду.
num_raw = num
if isinstance(num, float) and num.is_integer():
num_raw = int(num)
num_str = str(num_raw).strip()
rows_seen += 1
installed = parse_reserve_number(_cell(row, 1)) # B
current = parse_reserve_number(_cell(row, 2)) # C
reserve = parse_reserve_number(_cell(row, 3)) # D
params = {
"name": f"ТП/РП {num_str}",
"name_norm": num_str.lower(),
"municipality": "Екатеринбург",
"installed": installed,
"current": current,
"reserve": reserve,
"asof": reserve_asof,
}
try:
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
result = db.execute(
# voltage_kv намеренно NULL: у ЕЭСК-серии «ниже 35» отдельного
# класса напряжения нет; settlement=municipality (одна колонка).
text("""
INSERT INTO power_tp_rp_reserves
(name, name_norm, municipality, settlement,
voltage_kv, installed_capacity_mva, current_load_mva,
reserve_mva, reserve_asof, fetched_at)
VALUES (
:name, :name_norm, :municipality, :municipality,
NULL, :installed, :current,
:reserve, :asof, NOW()
)
ON CONFLICT (name_norm, municipality, voltage_kv) DO UPDATE
SET name = EXCLUDED.name,
installed_capacity_mva = EXCLUDED.installed_capacity_mva,
current_load_mva = EXCLUDED.current_load_mva,
reserve_mva = EXCLUDED.reserve_mva,
reserve_asof = EXCLUDED.reserve_asof,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
"""),
params,
).scalar()
if result:
inserted += 1
else:
updated += 1
except Exception as e:
logger.warning("eesk tp_rp_sub35 upsert failed for %r: %s", num_str, e)
skipped += 1
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_tp_rp_sub35: outer tx rolled back: %s", e)
raise
finally:
wb.close()
result_dict = {
"rows": rows_seen,
"inserted": inserted,
"updated": updated,
"skipped": skipped,
}
logger.info("load_tp_rp_sub35 done: %s", result_dict)
return result_dict
def load_eesk_reserves(db: Session | None = None) -> dict[str, dict]:
"""Полный прогон ЕЭСК: discovery ссылок + скачивание + загрузка ПС и ТП/РП.
RUN-ON-PROD. Owns-session как соседи (rosseti/gas/vodokanal). Каждый шаг
graceful: сбой одного файла не валит другой.
Returns: {"ps": {...}, "tp_rp": {...}} со счётчиками (или {"error": ...}).
"""
owns_session = db is None
if db is None:
db = SessionLocal()
out: dict[str, dict] = {}
try:
links = fetch_hub_links()
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
ps = links.get("ps_35_220")
if ps:
try:
r = client.get(ps["url"])
r.raise_for_status()
asof = quarter_last_day(ps["year"], ps["quarter"])
out["ps"] = load_ps_35_220(db, r.content, asof)
out["ps"]["period"] = quarter_period(ps["year"], ps["quarter"])
except Exception as e:
logger.exception("load_eesk_reserves: ps file failed: %s", e)
out["ps"] = {"error": str(e)}
else:
out["ps"] = {"error": "no ps_35_220 link found"}
tp = links.get("tp_rp_sub35")
if tp:
try:
r = client.get(tp["url"])
r.raise_for_status()
asof = quarter_last_day(tp["year"], tp["quarter"])
out["tp_rp"] = load_tp_rp_sub35(db, r.content, asof)
out["tp_rp"]["period"] = quarter_period(tp["year"], tp["quarter"])
except Exception as e:
logger.exception("load_eesk_reserves: tp_rp file failed: %s", e)
out["tp_rp"] = {"error": str(e)}
else:
out["tp_rp"] = {"error": "no tp_rp_sub35 link found"}
finally:
if owns_session:
db.close()
logger.info("load_eesk_reserves done: %s", out)
return out

View file

@ -0,0 +1,529 @@
"""Загрузчик резервов тепловой мощности из ФГИС ФАС (ri.eias.ru) (#2119 Phase B2).
Источник реестр раскрытия информации ФАС по сфере теплоснабжения (ri.eias.ru,
reg=2644). Данные лежат за 3 GET-переходами:
1. Карточка организации (``PublicDisclosureInfoOrg.aspx``) HTML-грид (DevExpress)
со списком публикаций форм F_W_Q_14 / F_W_Q_4_6; из строк грида собираем ссылки
``TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId>`` + текст периода.
На проде ссылка живёт НЕ в href (там ``href="#"``), а в JS
``onclick="openTemplateDialog('https://ri-loader.eias.ru/TemplatePrinter.aspx?…', …)"``,
амперсанды бывают HTML-энкожены (``&amp;``) чанк строки перед парсом
прогоняем через ``html.unescape``. Период «N кв. YYYY года» (реже «N квартал
YYYY»). Берём ВСЕ публикации свежайшего квартала (max(year, quarter)) их может
быть несколько (по одной на центральную систему / ЦС).
2. TemplatePrinter (ri-loader.eias.ru) HTML с ``<div id="dataFile"
data-file="<base64>">``; base64 ZIP (in-memory) член ``tpl`` (XLSX).
3. Лист ``ТП`` формы 14 «Резерв мощности источников тепловой энергии»
(живой пример: СТ 54 3.26 Гкал/час).
Реальная сетка листа снята с прода (raw jsonb, все 4 публикации единообразны)
парсинг COLUMN-ALIGNED, а НЕ «правейшее число в строке»:
* Строка-заголовок систем: ``col4 == 'Централизованная система'``; ИМЕНА систем
лежат в колонках ``j 6`` (текст: «СТ 56», «СТ 54 » с trailing-space, «Локальные
зоны теплоснабжения МУП»). В хвосте той же строки бывают СЛУЖЕБНЫЕ ЧИСЛА (шаблон
ЕИАС) отсекаем их требованием «имя содержит хотя бы одну букву».
* Строка резерва (5): ``col3.strip() == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
мощности источников тепловой энергии» значения Гкал/ч стоят в ТЕХ ЖЕ колонках
``j``, что и имена систем. В хвосте длинный текст-примечание «Указывается резерв»
и служебные числа: они не в колонках систем и отсекаются сами.
* ЛОВУШКА ``Flag_Row_Size``: последняя КОЛОНКА листа (col25) служебные числа-высоты
строк шаблона ЕИАС (74, 52, 15 у рубрик анкеты). Старый «правейшее число строки»
хватал именно их и распознавал рубрики анкеты («Количество заявок», «Причины
отказа», кнопку «Добавить систему») как системы. Column-aligned парс их не видит.
Все ячейки листа целиком ``raw`` jsonb (будущие колонки не теряются). Нет строки
систем ИЛИ строки резерва warning-лог + parsed=0, НЕ падаем.
Источник ГЕО-БЛОКИРУЕТ non-RU IP + шлёт российский УЦ без intermediate загрузчик
РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec), SSL через общий
``_ru_ssl_context`` (бандл содержит GlobalSign-intermediate для eias.ru). httpx с
явным таймаутом, per-row SAVEPOINT при UPSERT.
Числовой парс переиспользует ``parse_reserve_number`` из rosseti_reserve_loader.
"""
import base64
import binascii
import io
import json
import logging
import re
import zipfile
from html import unescape
import httpx
from openpyxl import load_workbook
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.site_finder.gazprom_grs_loader import _ru_ssl_context
from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number
logger = logging.getLogger(__name__)
# Реестр ФАС по сфере «теплоснабжение», регион Свердловская область (reg=2644).
_REG = "2644"
_REG_TEMPLATE = "RU.5.66"
# Организации-держатели: (человекочитаемое имя, orgId в реестре ФАС).
# Расширение 2026-07-03 (разведка реестра ri.eias.ru, 500 орг области, 11 страниц
# грида): добавлены операторы ЕКБ со СВЕЖИМИ публикациями формы 14 (2026-Q1) и
# ненулевыми резервами. Регионгаз-инвест (16/16 ненулевых) + Т Плюс Свердловский
# (15/13) дают ×10 к базе ненулевых резервов; хвост — по +1-2 системы. Свежие-но-
# нулевые операторы (ТЭЦ ВИЗа, Солнечное тепло...) намеренно НЕ добавлены — только
# раздули бы нулевые строки. orgId верифицированы по ИНН через грид-ключи DevExpress.
ORGS: list[tuple[str, int]] = [
("ЕТК", 30814352),
("Екатеринбургэнерго", 26479450),
("Регионгаз-инвест", 26479474),
("Т Плюс (Свердловский)", 28951102),
("АкадемТеплоСети", 31516224),
("Химмаш Энерго", 28881099),
("Энергоснабжающая компания", 26322592),
("ЕТК (ООО)", 31889852),
]
# Карточка организации: грид с публикациями форм 14 / 4_6 сферы WARM, раздел QUARTER.
_CARD_URL = "https://ri.eias.ru/Discl/PublicDisclosureInfoOrg.aspx"
_CARD_FORMS = "F_W_Q_14;F_W_Q_4_6;"
# Выгрузка публикации: HTML с base64-ZIP в data-file. Хост loader-домена.
_TEMPLATE_URL = "https://ri-loader.eias.ru/TemplatePrinter.aspx"
_HTTP_TIMEOUT = 60
# Лист формы 14 с резервом мощности источников тепла.
_HEAT_SHEET = "ТП"
# Ссылка на публикацию в HTML грида карточки: захватываем guid + id (pubId).
# TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId> (порядок параметров может
# варьироваться — берём каждый независимым lookahead-регэкспом на href).
# URL живёт либо в старом href="/Discl/TemplatePrinter.aspx?...", либо (реальная
# разметка прода) в JS onclick="openTemplateDialog('https://ri-loader.eias.ru/
# TemplatePrinter.aspx?...', ...)" при href="#" — поэтому ищем URL где угодно в чанке,
# останавливаясь на кавычках / скобках / пробелах (границы JS-строки и href-атрибута).
_PUB_HREF_RE = re.compile(
r"(?P<href>(?:https?://[^\"'()\s]+)?/?[^\"'()\s]*TemplatePrinter\.aspx[^\"'()\s]*)",
re.IGNORECASE,
)
_GUID_RE = re.compile(r"[?&]guid=([^&\"'\s]+)", re.IGNORECASE)
_PUBID_RE = re.compile(r"[?&]id=(\d+)", re.IGNORECASE)
# Период в тексте строки грида → (year, quarter). Реальная разметка прода —
# «1 кв. 2026 года»; встречаются также «N квартал YYYY» / «N квартала YYYY» / «N кв YYYY».
_QUARTER_RE = re.compile(r"(\d)\s*кв(?:артал\w*|\.)?\s*(\d{4})", re.IGNORECASE)
# base64-payload файла: <div id="dataFile" ... data-file="<base64>">.
_DATAFILE_RE = re.compile(
r'<div\b[^>]*id=["\']dataFile["\'][^>]*data-file=["\'](?P<b64>[^"\']+)["\']',
re.IGNORECASE | re.DOTALL,
)
# Заголовок листа со строкой систем: в этой строке col4 == этому значению, а имена
# систем стоят в колонках j ≥ 6 (см. docstring модуля, п.3).
_SYSTEMS_HEADER = "Централизованная система"
# Строка резерва мощности (№5 формы 14): col3 == '5' И/ИЛИ col4 начинается с этого.
_RESERVE_ROW_NUM = "5"
_RESERVE_ROW_PREFIX = "Резерв мощности источников тепловой энергии"
# Первая колонка листа, где могут стоять имена систем (col0..col5 — служебные/№/ед.изм.).
_SYSTEM_COL_START = 6
# Имя системы обязано содержать хотя бы одну букву (кириллица/латиница) — иначе это
# служебное число из хвоста строки-заголовка (Flag_Row_Size шаблона ЕИАС).
_HAS_LETTER_RE = re.compile(r"[А-Яа-яЁёA-Za-z]")
# Разбивка HTML грида на строки (<tr>) — публикации живут построчно, период и
# ссылка в одной строке. Достаточно грубого split по <tr, полноценный DOM не нужен.
_TR_SPLIT_RE = re.compile(r"<tr\b", re.IGNORECASE)
def _quarter_from_text(row_text: str) -> tuple[int, int] | None:
"""«…N квартал YYYY…» → (year, quarter). Квартал вне 1..4 / нет совпадения → None."""
m = _QUARTER_RE.search(row_text)
if not m:
return None
quarter = int(m.group(1))
year = int(m.group(2))
if quarter not in (1, 2, 3, 4):
return None
return (year, quarter)
def build_card_url(org_id: int) -> str:
"""URL карточки организации в реестре ФАС (грид публикаций форм 14 / 4_6)."""
return (
f"{_CARD_URL}?reg={_REG}&orgId={org_id}" f"&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}"
)
def build_template_url(guid: str, pub_id: str) -> str:
"""URL выгрузки публикации (TemplatePrinter, base64-ZIP в data-file)."""
return f"{_TEMPLATE_URL}?reg={_REG_TEMPLATE}&guid={guid}&id={pub_id}"
def extract_publications(card_html: str) -> list[dict]:
"""Из HTML карточки → публикации свежайшего квартала: [{guid, id, year, quarter}].
По каждой <tr>-строке грида ищем ссылку на TemplatePrinter (guid+id) И период
«N квартал YYYY» в тексте той же строки. Собираем max((year, quarter)) и
возвращаем ВСЕ публикации именно этого квартала (их бывает несколько по ЦС).
Строки без ссылки ИЛИ без распознанного периода игнорируются. Одна публикация
встречается в нескольких строках грида (по видам деятельности: передача/сбыт)
дедупим по (guid, id), иначе один и тот же ZIP качается многократно.
"""
pubs: list[dict] = []
for raw_chunk in _TR_SPLIT_RE.split(card_html):
# Атрибуты грида могут содержать HTML-энтити (`&amp;guid=` вместо `&guid=`) —
# раскрываем до поиска, иначе `[?&]guid=` / `[?&]id=` не сматчатся.
chunk = unescape(raw_chunk)
href_m = _PUB_HREF_RE.search(chunk)
if not href_m:
continue
href = href_m.group("href")
guid_m = _GUID_RE.search(href)
pubid_m = _PUBID_RE.search(href)
if not guid_m or not pubid_m:
continue
yq = _quarter_from_text(chunk)
if yq is None:
continue
year, quarter = yq
pubs.append(
{"guid": guid_m.group(1), "id": pubid_m.group(1), "year": year, "quarter": quarter}
)
if not pubs:
logger.warning("eias_heat: публикации не найдены в карточке (0 строк с guid+период)")
return []
freshest = max((p["year"], p["quarter"]) for p in pubs)
latest: list[dict] = []
seen: set[tuple[str, str]] = set()
for p in pubs:
if (p["year"], p["quarter"]) != freshest or (p["guid"], p["id"]) in seen:
continue
seen.add((p["guid"], p["id"]))
latest.append(p)
logger.info(
"eias_heat: публикаций всего=%d, свежайший квартал=%d-Q%d%d публикаций",
len(pubs),
freshest[0],
freshest[1],
len(latest),
)
return latest
def extract_tpl_xlsx(template_html: str) -> bytes | None:
"""HTML выгрузки → байты XLSX (член ``tpl`` из base64-ZIP в data-file). None — нет.
``<div id="dataFile" data-file="<base64>">`` base64decode ZIP член ``tpl``.
Битый base64 / отсутствие члена ``tpl`` warning-лог + None (шаг graceful).
"""
m = _DATAFILE_RE.search(template_html)
if not m:
logger.warning("eias_heat: <div id=dataFile data-file> не найден в TemplatePrinter HTML")
return None
try:
zip_bytes = base64.b64decode(m.group("b64"), validate=False)
except (binascii.Error, ValueError) as e:
logger.warning("eias_heat: base64-декод data-file упал: %s", e)
return None
try:
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
names = zf.namelist()
member = next((n for n in names if n == "tpl" or n.rsplit("/", 1)[-1] == "tpl"), None)
if member is None:
logger.warning("eias_heat: член 'tpl' не найден в ZIP (члены: %s)", names[:20])
return None
return zf.read(member)
except zipfile.BadZipFile as e:
logger.warning("eias_heat: data-file не является валидным ZIP: %s", e)
return None
def _cell_str(value: object) -> str:
"""Ячейка → strip-строка (None → '')."""
return "" if value is None else str(value).strip()
def _is_systems_header(cells: list[object]) -> bool:
"""Строка-заголовок систем: col4 == 'Централизованная система'."""
return len(cells) > 4 and _cell_str(cells[4]) == _SYSTEMS_HEADER
def _is_reserve_row(cells: list[object]) -> bool:
"""Строка резерва (№5): col3.strip() == '5' И/ИЛИ col4 начинается с «Резерв мощности…»."""
if len(cells) <= 4:
return False
return _cell_str(cells[3]) == _RESERVE_ROW_NUM or _cell_str(cells[4]).startswith(
_RESERVE_ROW_PREFIX
)
def _system_columns(header_cells: list[object]) -> list[tuple[int, str]]:
"""Из строки-заголовка → пары (col_index, system_name) для j ≥ 6.
Имя обязано быть текстовым и содержать хотя бы одну букву иначе это служебное
число из хвоста строки (Flag_Row_Size шаблона ЕИАС), а не система.
"""
result: list[tuple[int, str]] = []
for j in range(_SYSTEM_COL_START, len(header_cells)):
value = header_cells[j]
if value is None:
continue
name = str(value).strip()
if name and _HAS_LETTER_RE.search(name):
result.append((j, name))
return result
def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]:
"""Парсит лист ``ТП`` формы 14 → (systems, all_cells).
COLUMN-ALIGNED по РЕАЛЬНОЙ сетке (не «правейшее число в строке»):
1. Строка-заголовок систем ``col4 == 'Централизованная система'``; имена систем
берём из колонок ``j 6`` (текст, содержит букву служебные числа хвоста
отсеиваются), имя .strip() (у части систем trailing-space).
2. Строка резерва (5) ``col3 == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
мощности источников тепловой энергии»; резерв каждой системы читаем из ТОЙ ЖЕ
колонки ``j``, что и её имя. Нулевой резерв (0.0) честный дефицит, СОХРАНЯЕМ;
непарсибельная ячейка (текст/пусто) систему пропускаем.
Нет строки систем ИЛИ строки резерва warning-лог + ([], all_cells) (graceful).
Служебная колонка 25 ``Flag_Row_Size`` (числа-высоты строк) в парс не попадает
именно она заставляла старый парсер хватать рубрики анкеты как «системы».
``all_cells`` ВСЕ ячейки листа (список строк-списков) для сохранения в raw.
"""
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
ws = wb[_HEAT_SHEET] if _HEAT_SHEET in wb.sheetnames else wb.active
all_cells: list[list[object]] = []
header_cells: list[object] | None = None
reserve_cells: list[object] | None = None
try:
for row in ws.iter_rows(values_only=True):
cells = list(row)
all_cells.append(cells)
if header_cells is None and _is_systems_header(cells):
header_cells = cells
elif reserve_cells is None and _is_reserve_row(cells):
reserve_cells = cells
finally:
wb.close()
if header_cells is None or reserve_cells is None:
logger.warning(
"eias_heat: сетка не распознана (header=%s, reserve=%s) — 0 систем",
header_cells is not None,
reserve_cells is not None,
)
return [], all_cells
systems: list[dict] = []
name_counts: dict[str, int] = {}
for j, name in _system_columns(header_cells):
reserve = parse_reserve_number(reserve_cells[j] if j < len(reserve_cells) else None)
if reserve is None:
continue # непарсибельная ячейка резерва (текст/пусто) → систему скипаем
# Разные системы могут носить БАЙТ-В-БАЙТ одинаковое имя в соседних колонках
# (живой пример ЕЭ: две «Локальные зоны … МУП "Екатеринбургэнерго"» с 62.198
# и 0). UPSERT по (org, sphere, system_name, period) схлопнул бы их с потерей
# данных — вторым и далее вхождениям добавляем порядковый суффикс « (N)».
name_counts[name] = name_counts.get(name, 0) + 1
if name_counts[name] > 1:
name = f"{name} ({name_counts[name]})"
systems.append({"system_name": name, "reserve_gcal_h": reserve})
return systems, all_cells
def _cells_to_jsonable(all_cells: list[list[object]]) -> list[list[object]]:
"""Ячейки листа → JSON-совместимый вид (datetime/прочее → str, числа/None as-is)."""
out: list[list[object]] = []
for row in all_cells:
json_row: list[object] = []
for value in row:
if value is None or isinstance(value, bool | int | float | str):
json_row.append(value)
else:
json_row.append(str(value))
out.append(json_row)
return out
def _upsert_heat_rows(
db: Session,
org: str,
period: str,
source_guid: str,
systems: list[dict],
all_cells: list[list[object]],
) -> dict[str, int]:
"""UPSERT распознанных систем публикации в heat_system_reserves. Per-row SAVEPOINT.
raw = {cells: <весь лист>} сохраняется у КАЖДОЙ строки публикации (одна выгрузка =
один лист). ON CONFLICT (org, sphere, system_name, period) DO UPDATE.
Returns: счётчики parsed/inserted/updated/skipped.
"""
raw_json = json.dumps({"cells": _cells_to_jsonable(all_cells)}, ensure_ascii=False)
inserted = 0
updated = 0
skipped = 0
for sysrec in systems:
params = {
"org": org,
"system_name": sysrec["system_name"],
"reserve": sysrec["reserve_gcal_h"],
"period": period,
"source_guid": source_guid,
"raw": raw_json,
}
try:
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
result = db.execute(
text("""
INSERT INTO heat_system_reserves
(org, sphere, system_name, reserve_gcal_h,
period, source_guid, raw, fetched_at)
VALUES (
:org, 'heat', :system_name, :reserve,
:period, :source_guid, CAST(:raw AS jsonb), NOW()
)
ON CONFLICT (org, sphere, system_name, period) DO UPDATE
SET reserve_gcal_h = EXCLUDED.reserve_gcal_h,
source_guid = EXCLUDED.source_guid,
raw = EXCLUDED.raw,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
"""),
params,
).scalar()
if result:
inserted += 1
else:
updated += 1
except Exception as e:
logger.warning("eias_heat upsert failed for %r/%r: %s", org, sysrec["system_name"], e)
skipped += 1
return {
"parsed": len(systems),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
}
def fetch_card_html(org_id: int) -> str:
"""Тянет HTML карточки организации. RUN-ON-PROD (гео-блок + российский УЦ)."""
resp = httpx.get(
build_card_url(org_id),
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
verify=_ru_ssl_context(),
)
resp.raise_for_status()
return resp.text
def fetch_template_html(client: httpx.Client, guid: str, pub_id: str) -> str:
"""Тянет HTML выгрузки публикации (base64-ZIP в data-file). RUN-ON-PROD."""
resp = client.get(build_template_url(guid, pub_id))
resp.raise_for_status()
return resp.text
def load_org_reserves(db: Session, org: str, org_id: int) -> dict[str, int]:
"""Полный прогон одной организации: карточка → публикации → выгрузки → UPSERT.
Аккумулирует счётчики по всем публикациям свежайшего квартала (по ЦС).
Ноль распознанных систем во всех публикациях warning-лог с первыми 20 строками
последнего листа, parsed=0, НЕ падаем.
"""
counts = {"parsed": 0, "inserted": 0, "updated": 0, "skipped": 0, "publications": 0}
card_html = fetch_card_html(org_id)
pubs = extract_publications(card_html)
if not pubs:
logger.warning("eias_heat[%s]: нет публикаций свежайшего квартала", org)
return counts
period = f"{pubs[0]['year']}-Q{pubs[0]['quarter']}"
last_cells: list[list[object]] = []
with httpx.Client(
timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
) as client:
for pub in pubs:
try:
template_html = fetch_template_html(client, pub["guid"], pub["id"])
xlsx_bytes = extract_tpl_xlsx(template_html)
if xlsx_bytes is None:
continue
systems, all_cells = parse_heat_sheet(xlsx_bytes)
last_cells = all_cells
counts["publications"] += 1
if not systems:
continue
sub = _upsert_heat_rows(db, org, period, pub["guid"], systems, all_cells)
counts["parsed"] += sub["parsed"]
counts["inserted"] += sub["inserted"]
counts["updated"] += sub["updated"]
counts["skipped"] += sub["skipped"]
except Exception as e:
logger.exception("eias_heat[%s]: публикация guid=%s упала: %s", org, pub["guid"], e)
if counts["parsed"] == 0:
logger.warning(
"eias_heat[%s]: 0 систем распознано (period=%s); первые 20 строк листа: %s",
org,
period,
last_cells[:20],
)
counts["period"] = period # type: ignore[assignment]
logger.info("eias_heat[%s] done: %s", org, counts)
return counts
def load_heat_reserves(db: Session | None = None) -> dict[str, dict]:
"""Полный прогон тепло-резервов ФАС по всем организациям (ЕТК, Екатеринбургэнерго).
RUN-ON-PROD. Owns-session как соседи (rosseti/gas/vodokanal/eesk). Каждая
организация graceful: падение одной не валит вторую. Коммит один в конце
(per-row SAVEPOINT уже изолирует битые строки).
Returns: {org_name: {parsed, inserted, updated, skipped, publications, period}}
или {org_name: {"error": ...}} при падении организации.
"""
owns_session = db is None
if db is None:
db = SessionLocal()
out: dict[str, dict] = {}
try:
for org, org_id in ORGS:
try:
out[org] = load_org_reserves(db, org, org_id)
except Exception as e:
logger.exception("load_heat_reserves: org %s failed: %s", org, e)
out[org] = {"error": str(e)}
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_heat_reserves: outer tx rolled back: %s", e)
raise
finally:
if owns_session:
db.close()
logger.info("load_heat_reserves done: %s", out)
return out

View file

@ -0,0 +1,804 @@
"""Загрузчик адресных точек выхода газораспределительной сети (#2119 Phase B2, PR-2).
Источник раскрытие информации АО «Екатеринбурггаз» (ГРО), форма 6 приложения 4
к Приказу ФАС России 960 от 08.12.2022 «О порядке раскрытия информации»: построчная
выгрузка «точек выхода» ГРС (~1037 строк/мес) объём заявок, удовлетворено, свободная
мощность точки выхода (млн. м³/МЕСЯЦ НЕ тыс. м³/ч как в gas_grs_capacity!). UPSERT-ит
в ``gas_grs_outlet_points``.
Источник ГЕО-БЛОКИРУЕТ non-RU IP загрузчик РАБОТАЕТ НА ПРОДЕ (Celery monthly / manual
docker exec) как gas_grs_capacity (B1, ГТЕ) и rosseti-/vodokanal-/eias-лоадеры. Код не
предполагает локальный сетевой доступ. ТОЛЬКО apex-домен ``ekgas.ru`` ``www.ekgas.ru``
даёт ``SSLV3_ALERT_HANDSHAKE_FAILURE``. SSL через общий ``_ru_ssl_context`` из
gazprom_grs_loader (Russian Trusted CA Минцифры), httpx с явным таймаутом.
Данные лежат за 2 GET-переходами:
1. Каталог раскрытия (``/raskrytie-informacii/raskrytie-informatsii-1``, ~800KB HTML,
585 ссылок на файлы). Целевой «Форма 6 приложения 4 к Приказу ФАС России 960
(на|за) <месяц> <год>г..xlsx», путь ``/uploads/default/<hex>/<имя>``. Hex меняется
с каждым файлом URL НЕ ЯКОРИМ: парсим каталог, вытаскиваем период «(за|на) <месяц>
<год>» + дату публикации (asof, DD.MM.YYYY рядом со ссылкой). Годовой «Форма 6
(Приложение 2) план на <год>» (.xls) СКИП (не адресный, xlrd в стеке нет).
Выбор свежайшего: приоритет ФАКТА над прогнозом. Берём максимальный (год, месяц)
среди «за» (is_forecast=False); если файлов «за» НЕТ вообще максимальный «на»
(is_forecast=True, прогноз). ВНИМАНИЕ: даже если «на» новее по (год, месяц), чем
любой «за», всё равно берём «за» (факт важнее прогноза). См. select_latest_file.
2. XLSX (~91КБ, 1 лист): подписи в row 10 (1-indexed), нумерация 1..7 в row 11, данные
с row 12 (~1037 строк, последняя сноска про «*», single-cell скип). Колонки
(1-indexed): 1=«Точка входа» (ГРС, block-marker заполнена ТОЛЬКО в первой строке
блока forward-fill; 5 блоков), 2=«Точка выхода» (свободный текст: юрлицо/объект,
иногда адрес внутри), 3=тип (котельная/ТЭЦ/), 4=группа потребления, 5=объём заявок,
6=удовлетворено, 7=свободная мощность float ИЛИ текст-маркер «0.000*» (НЕ ноль!
free_capacity_mln_m3=NULL, free_capacity_needs_calc=TRUE).
grs_name_norm нормализуется ТЕМ ЖЕ ``normalize_grs_name`` из gazprom_grs_loader (B1)
иначе JOIN «точка выхода свободная мощность ГРС» на gas_grs_capacity порвётся.
Числовой парс общий ``parse_reserve_number`` из rosseti_reserve_loader (запятая, «»
None, знак сохраняется: дефицит 0.084 хранится знаком). Вся строка целиком ``raw``
jsonb (будущие/неизвестные колонки не теряются).
"""
import argparse
import io
import json
import logging
import re
import sys
from datetime import date
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup
from openpyxl import load_workbook
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.dadata_client import clean_address
from app.services.site_finder.gazprom_grs_loader import _ru_ssl_context, normalize_grs_name
from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number
logger = logging.getLogger(__name__)
# ТОЛЬКО apex-домен — www.ekgas.ru отдаёт SSLV3_ALERT_HANDSHAKE_FAILURE (разведка
# 2026-07-03). Каталог раскрытия информации со ссылками на помесячные формы 6.
BASE_URL = "https://ekgas.ru"
CATALOG_URL = "https://ekgas.ru/raskrytie-informacii/raskrytie-informatsii-1"
_HTTP_TIMEOUT = 60
# ── Геокод address_extracted → geom (PR-3, #2119 B2) ──────────────────────────
# Cap вызовов DaData за один прогон (адресов на проде ~164 → влезает; страхует
# квоту от разбухшего файла). Тот же порядок, что GEO_DADATA_CALL_LIMIT в
# objective_backfill.
_GEO_DADATA_CAP = 200
# Префикс города: address_extracted — адресный ХВОСТ без города («ул. Стрелочников,
# д. 2»). DaData на строке без города отдаёт qc_geo=5 (нет координат) — прод-урок
# objective_backfill 2026-07-03. Точки выхода ЕКБ-газа привязаны к Екатеринбургу и
# окрестностям → безопасно префиксуем городом, если его в строке нет.
_EKB_PREFIX = "г Екатеринбург, "
# Sanity-bbox Екатеринбурга и ближних окрестностей (Арамиль/Реж/Берёзовский/Кольцово):
# DaData на кривом/усечённом адресе может «дорисовать» другой город — точку вне bbox
# НЕ пишем (честность: лучше NULL geom, чем ложная координата в Москве). Границы
# грубые с запасом: lat 56.5..57.2, lon 60.0..61.2.
_EKB_BBOX_LAT = (56.5, 57.2)
_EKB_BBOX_LON = (60.0, 61.2)
# Целевой лист XLSX (единственный) — берём активный, если имя не совпало.
# 1-indexed строки листа: подписи колонок = row 10, нумерация 1..7 = row 11, данные с 12.
# Сетка НЕ хардкодится по номерам строк/колонок: живой файл за май 2026 имеет
# header на row 11 (не 10 из разведки) и колонку «№ п/п» слева (данные в col 2-8,
# сдвиг +1) — первый прод-прогон распарсил 5 строк вместо 1037. Урок воды/тепла:
# опорные строки и колонки ищем ПО ПОДПИСЯМ (_find_layout), скан первых N строк.
_LAYOUT_SCAN_ROWS = 25
# Колонки формы 6 (0-indexed для списка ячеек строки).
# Подписи колонок header-строки → внутренние ключи (substring-матч, lower).
_COLUMN_LABELS: dict[str, str] = {
"точка входа": "entry",
"точка выхода": "outlet",
"наименование потребителя": "consumer_type",
"номер группы": "group",
"поступивш": "requested",
"удовлетво": "approved",
"свободная мощность": "free",
}
def _find_layout(rows: list[list[object]]) -> tuple[int, dict[str, int]] | None:
"""Ищет header-строку по подписям в первых _LAYOUT_SCAN_ROWS строках.
Returns (data_start_idx, {key: col_idx}) данные начинаются через строку
после header (между ними строка-нумерация «1..7»). None сетка не распознана
(graceful: 0 записей + warning, не мусор урок vodokanal).
"""
for i, row in enumerate(rows[:_LAYOUT_SCAN_ROWS]):
cols: dict[str, int] = {}
for j, cell in enumerate(row):
text = _clean_text(cell).lower()
if not text:
continue
for label, key in _COLUMN_LABELS.items():
if label in text and key not in cols:
cols[key] = j
if "entry" in cols and "outlet" in cols and "free" in cols:
return (i + 2, cols) # +1 строка-нумерация, +1 → первая строка данных
return None
# «0.000*» (и вариации с запятой/пробелами) в колонке свободной мощности = НЕ ноль, а
# «требуется гидравлический расчёт» → free_capacity_mln_m3=NULL, needs_calc=True.
# Маркер = число, за которым идёт «*» (звёздочка). Настоящий 0 без «*» парсится как 0.0.
_NEEDS_CALC_RE = re.compile(r"^\s*[\d.,\s\xa0]+\*\s*$")
# Русские месяцы → номер (родительный падеж как в «за январЯ»? нет — в именах файлов
# именительный: «за январь 2026»). Ключи в нижнем регистре.
_MONTHS: dict[str, int] = {
"январь": 1,
"февраль": 2,
"март": 3,
"апрель": 4,
"май": 5,
"июнь": 6,
"июль": 7,
"август": 8,
"сентябрь": 9,
"октябрь": 10,
"ноябрь": 11,
"декабрь": 12,
}
_MONTHS_ALT = "|".join(_MONTHS)
# Период в имени файла формы 6: «… (за|на) <месяц> <год>г..xlsx». «за» = факт
# (is_forecast=False), «на» = прогноз (is_forecast=True). Год — 4 цифры.
_PERIOD_RE = re.compile(rf"\b(за|на)\s+({_MONTHS_ALT})\s+(\d{{4}})", re.IGNORECASE)
# Целевой файл — форма 6 приложения №4 (адресная, помесячная, .xlsx). Годовой «Форма №6
# (Приложение №2) … план на <год>» (.xls) сюда НЕ подходит: другое приложение + .xls.
_FORM6_MARKER_RE = re.compile(r"форма\s*6.*приложени\w*\s*№?\s*4", re.IGNORECASE | re.DOTALL)
_XLSX_RE = re.compile(r"\.xlsx(?:$|\?)", re.IGNORECASE)
# Дата публикации рядом со ссылкой (DD.MM.YYYY) → asof.
_ASOF_RE = re.compile(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b")
# Адрес-экстрактор: улица-токен + номер дома. Город опционален. Консервативно —
# нет уверенного адреса → None (не выдумываем). Паттерны из реальных строк:
# «(г.Екатеринбург, Складской проезд,4а)» — в скобках;
# «, пер. Базовый, 17» — хвостом;
# «г.…, ул. К.Маркса,31».
# Улица-токен: ул|пер|пр|просп|проезд|б-р|ш|тракт|наб (с точкой или без, регистронезав.).
_STREET_TOKEN = r"(?:ул|пер|пр|просп|проспект|проезд|б-р|бульвар|ш|шоссе|тракт|наб)"
# Опциональный город впереди: «г.Екатеринбург,» / «г. Реж,».
_CITY_OPT = r"(?:г\.?\s*[А-ЯЁ][а-яё\-]+\s*,\s*)?"
# Название улицы: слова с кириллицей/точками/дефисами (напр. «К.Маркса», «Базовый»,
# «Складской проезд») — до номера дома. Номер: цифры + опц. буква/дробь («4а», «17»,
# «31/2»). Токен улицы может стоять ПЕРЕД названием («ул. Базовый») или ПОСЛЕ
# («Складской проезд»).
_ADDRESS_RE = re.compile(
rf"{_CITY_OPT}"
rf"(?:{_STREET_TOKEN}\.?\s*)?" # ведущий токен («ул. …», «пер. …») — опционален
r"[А-ЯЁ][А-Яа-яЁё.\- ]{1,40}?" # название улицы (капитализировано)
rf"(?:\s+{_STREET_TOKEN})?" # хвостовой токен («… проезд», «… тракт») — опционален
r"\s*,?\s*"
r"(?:д\.?\s*)?" # опц. «д.»
r"(\d+[а-яё]?(?:[/\-]\d+[а-яё]?)?)", # номер дома («4а», «17», «31/2»)
re.IGNORECASE,
)
# Чтобы отсечь ложные срабатывания на юрлицах («ИП Рошканяну Владимир Антонович») —
# требуем ЯВНЫЙ адресный сигнал: либо улица-токен, либо «г.<Город>» перед номером.
_HAS_STREET_TOKEN_RE = re.compile(rf"\b{_STREET_TOKEN}\b\.?", re.IGNORECASE)
_HAS_CITY_RE = re.compile(r"\bг\.?\s*[А-ЯЁ][а-яё\-]+", re.IGNORECASE)
_MULTISPACE_RE = re.compile(r"\s+")
def _clean_text(value: object) -> str:
"""Ячейка/текст → strip-строка со схлопнутыми пробелами (\\xa0→пробел). None → ''."""
if value is None:
return ""
return _MULTISPACE_RE.sub(" ", str(value).replace("\xa0", " ")).strip()
def _parse_period(name: str) -> tuple[int, int, bool] | None:
"""Имя файла → (year, month, is_forecast). «за»→факт(False), «на»→прогноз(True).
Нет распознанного «(за|на) <месяц> <год>» None. Месяц вне словаря None.
"""
m = _PERIOD_RE.search(name)
if not m:
return None
kind = m.group(1).lower()
month = _MONTHS.get(m.group(2).lower())
if month is None:
return None
year = int(m.group(3))
return (year, month, kind == "на")
def _is_form6_monthly(link_text: str, href: str) -> bool:
"""Ссылка — помесячная адресная форма 6 приложения №4 (.xlsx)?
Требуем: маркер «форма 6 приложение 4» (в тексте ссылки ИЛИ href), расширение
.xlsx и распознаваемый период. Годовой «Форма 6 (Приложение 2) план на <год>»
(.xls) сюда НЕ проходит (другое приложение + не .xlsx).
"""
haystack = f"{link_text} {href}"
if not _FORM6_MARKER_RE.search(haystack):
return False
if not _XLSX_RE.search(href) and not _XLSX_RE.search(link_text):
return False
return _parse_period(haystack) is not None
def extract_form6_links(catalog_html: str) -> list[dict]:
"""Из HTML каталога → список кандидатов формы 6: [{url, period_month, is_forecast, asof}].
По каждой <a>-ссылке, похожей на помесячную форму 6 приложения 4 (.xlsx),
вытаскиваем период «(за|на) <месяц> <год>» из текста/href period_month (первое
число месяца) + is_forecast, и дату публикации (DD.MM.YYYY рядом со ссылкой) asof.
Годовые/не-xlsx/без периода ссылки игнорируются. URL резолвится к apex-домену.
"""
soup = BeautifulSoup(catalog_html, "html.parser")
out: list[dict] = []
for a in soup.find_all("a", href=True):
href = a["href"].strip()
link_text = _clean_text(a.get_text(" "))
if not _is_form6_monthly(link_text, href):
continue
period = _parse_period(f"{link_text} {href}")
if period is None: # защита (уже проверено в _is_form6_monthly)
continue
year, month, is_forecast = period
# asof — дата публикации рядом со ссылкой (в тексте ссылки или соседних узлах).
asof = _extract_asof_near(a, link_text)
out.append(
{
"url": urljoin(BASE_URL, href),
"period_month": date(year, month, 1),
"is_forecast": is_forecast,
"asof": asof,
}
)
logger.info("ekgas: кандидатов формы 6 в каталоге: %d", len(out))
return out
def _extract_asof_near(anchor: object, link_text: str) -> date | None:
"""Дата публикации (DD.MM.YYYY) рядом со ссылкой → date. Нет — None.
Ищем сначала в тексте самой ссылки, затем в тексте родительского контейнера
(в каталоге дата обычно стоит в той же строке/ячейке, что и ссылка).
"""
candidates = [link_text]
parent = getattr(anchor, "parent", None)
if parent is not None:
candidates.append(_clean_text(parent.get_text(" ")))
for src in candidates:
m = _ASOF_RE.search(src)
if m:
day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
try:
return date(year, month, day)
except ValueError:
continue
return None
def select_latest_file(candidates: list[dict]) -> dict | None:
"""Выбирает свежайший файл: ПРИОРИТЕТ ФАКТА («за») над прогнозом («на»).
Правило (см. модульный docstring): если есть хоть один файл «за» (is_forecast=False),
берём максимальный (year, month) СРЕДИ «за» даже если какой-то «на» новее по
(year, month). Файлов «за» нет вообще максимальный «на» (is_forecast=True).
Пусто None.
"""
if not candidates:
return None
facts = [c for c in candidates if not c["is_forecast"]]
pool = facts if facts else candidates
return max(pool, key=lambda c: (c["period_month"].year, c["period_month"].month))
def _parse_free_capacity(value: object) -> tuple[float | None, bool]:
"""Ячейка свободной мощности → (free_capacity_mln_m3, needs_calc).
«0.000*» (число + звёздочка, любой вариант «0,000*»/«0.000 *») = НЕ ноль, а
«требуется гидравлический расчёт» (None, True). Иначе обычный числовой парс:
настоящий 0 (без «*») (0.0, False); дефицит 0.084 сохраняется знаком; «»/текст
(None, False).
"""
if isinstance(value, str) and _NEEDS_CALC_RE.match(value):
return None, True
return parse_reserve_number(value), False
def extract_address(outlet_name: str) -> str | None:
"""Извлекает адресный хвост из «Точки выхода» для геокода DaData (PR-3). Нет — None.
Консервативно: возвращаем адрес ТОЛЬКО при явном сигнале (улица-токен ул|пер|пр|
проезд|б-р|ш|тракт|наб ИЛИ «г.<Город>») + номер дома. Юрлицо без адреса
(«ИП Рошканяну Владимир Антонович») None (не выдумываем).
Examples:
«(г.Екатеринбург, Складской проезд,4а)» «г.Екатеринбург, Складской проезд,4а»
«, пер. Базовый, 17» «пер. Базовый, 17»
«ИП Рошканяну Владимир Антонович» None
"""
if not outlet_name:
return None
# Требуем явный адресный сигнал во всей строке — иначе номер в названии юрлица/объекта
# («ООО ТЭЦ-2», «цех №5») ложно распознался бы как дом.
if not (_HAS_STREET_TOKEN_RE.search(outlet_name) or _HAS_CITY_RE.search(outlet_name)):
return None
m = _ADDRESS_RE.search(outlet_name)
if not m:
return None
address = _MULTISPACE_RE.sub(" ", m.group(0)).strip().strip("(),; ")
return address or None
def _forward_fill_grs(rows: list[list[object]], cols: dict[str, int]) -> list[dict]:
"""Строки данных → записи с forward-fill «Точки входа» (block-marker ГРС).
«Точка входа» заполнена ТОЛЬКО в первой строке блока ГРС протягиваем
последнее непустое значение вниз (5 блоков). Индексы колонок из
_find_layout (label-детект). Строки до первого block-marker (нет ГРС) и
single-cell строки (сноска «*» в конце листа) пропускаются.
"""
def _cell(cells: list[object], key: str) -> object:
idx = cols.get(key, -1)
return cells[idx] if 0 <= idx < len(cells) else None
records: list[dict] = []
current_grs: str | None = None
for cells in rows:
# Single-cell строка (сноска про «*» / служебная) → скип.
non_empty = [c for c in cells if _clean_text(c)]
if len(non_empty) <= 1:
continue
entry = _clean_text(_cell(cells, "entry"))
if entry:
current_grs = entry
outlet = _clean_text(_cell(cells, "outlet"))
if not current_grs or not outlet:
continue # строка до первого block-marker ИЛИ без точки выхода → скип
free_raw = _cell(cells, "free")
free, needs_calc = _parse_free_capacity(free_raw)
records.append(
{
"grs_name": current_grs,
"outlet_name": outlet,
"consumer_type": _clean_text(_cell(cells, "consumer_type")) or None,
"consumption_group": _clean_text(_cell(cells, "group")) or None,
"volume_requested_mln_m3": parse_reserve_number(_cell(cells, "requested")),
"volume_approved_mln_m3": parse_reserve_number(_cell(cells, "approved")),
"free_capacity_mln_m3": free,
"free_capacity_needs_calc": needs_calc,
"address_extracted": extract_address(outlet),
"raw_cells": _cells_to_jsonable(cells),
}
)
return records
def _cells_to_jsonable(cells: list[object]) -> list[object]:
"""Ячейки строки → JSON-совместимый вид (datetime/прочее → str, числа/None as-is)."""
out: list[object] = []
for value in cells:
if value is None or isinstance(value, bool | int | float | str):
out.append(value)
else:
out.append(str(value))
return out
def _dedup_outlet_names(records: list[dict]) -> list[dict]:
"""Дедуп повторов outlet_name В ПРЕДЕЛАХ файла: 2-му и далее — суффикс « (N)».
Один и тот же outlet_name у одной ГРС может встречаться несколько раз (разные
группы потребления / объекты одного юрлица). UPSERT по (grs_name_norm, outlet_name,
period_month, is_forecast) схлопнул бы их с потерей данных (урок тепла/воды)
добавляем порядковый суффикс. Ключ дедупа (grs_name_norm, outlet_name).
"""
seen: dict[tuple[str, str], int] = {}
for rec in records:
key = (normalize_grs_name(rec["grs_name"]), rec["outlet_name"])
seen[key] = seen.get(key, 0) + 1
if seen[key] > 1:
rec["outlet_name"] = f"{rec['outlet_name']} ({seen[key]})"
return records
def parse_outlet_sheet(xlsx_bytes: bytes) -> list[dict]:
"""Парсит лист формы 6 → записи точек выхода (forward-fill ГРС + дедуп имён).
Сетка детектится ПО ПОДПИСЯМ (_find_layout): живой файл за май 2026 header
row 11, слева колонка « п/п», данные с row 13 (разведка видела r10/r12 без
« п/п» формат плавает). Свободная мощность: float ИЛИ «0.000*» needs_calc.
Single-cell сноска про «*» в конце листа скип. Сетка не распознана 0 записей
+ warning (не мусор урок vodokanal).
Каждая запись: {grs_name, outlet_name, consumer_type, consumption_group,
volume_requested_mln_m3, volume_approved_mln_m3, free_capacity_mln_m3,
free_capacity_needs_calc, address_extracted, raw_cells}. outlet_name дедуплен
суффиксом « (N)» в пределах файла.
"""
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
ws = wb.active
try:
all_rows: list[list[object]] = [list(row) for row in ws.iter_rows(values_only=True)]
finally:
wb.close()
layout = _find_layout(all_rows)
if layout is None:
logger.warning(
"ekgas: сетка листа не распознана (нет header с подписями) — 0 записей; "
"первые 5 строк: %s",
[[_clean_text(c) for c in r[:9]] for r in all_rows[:5]],
)
return []
data_start, cols = layout
records = _forward_fill_grs(all_rows[data_start:], cols)
records = _dedup_outlet_names(records)
logger.info("ekgas: распознано точек выхода=%d", len(records))
return records
def fetch_catalog_html() -> str:
"""Тянет HTML каталога раскрытия. RUN-ON-PROD (гео-блок). httpx с таймаутом, apex-SSL."""
resp = httpx.get(
CATALOG_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
)
resp.raise_for_status()
return resp.text
def fetch_xlsx(url: str) -> bytes:
"""Тянет XLSX формы 6 по URL. RUN-ON-PROD (гео-блок). httpx с таймаутом, apex-SSL."""
resp = httpx.get(url, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context())
resp.raise_for_status()
return resp.content
def _upsert_outlet_rows(db: Session, records: list[dict], meta: dict) -> dict[str, int]:
"""UPSERT точек выхода в gas_grs_outlet_points. Per-row SAVEPOINT (битая строка не валит батч).
meta = {period_month: date, is_forecast: bool, asof: date|None, source_url: str}.
ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast) DO UPDATE
обновляем объёмы/free/needs_calc/address/raw/fetched_at. Returns счётчики
inserted/updated/skipped/needs_calc/with_address.
"""
inserted = 0
updated = 0
skipped = 0
needs_calc = 0
with_address = 0
for rec in records:
grs_name = rec["grs_name"]
params = {
"grs_name": grs_name,
"grs_name_norm": normalize_grs_name(grs_name),
"outlet_name": rec["outlet_name"],
"consumer_type": rec["consumer_type"],
"consumption_group": rec["consumption_group"],
"requested": rec["volume_requested_mln_m3"],
"approved": rec["volume_approved_mln_m3"],
"free": rec["free_capacity_mln_m3"],
"needs_calc": rec["free_capacity_needs_calc"],
"address": rec["address_extracted"],
"period_month": meta["period_month"],
"is_forecast": meta["is_forecast"],
"asof": meta.get("asof"),
"source_url": meta.get("source_url"),
"raw": json.dumps({"cells": rec["raw_cells"]}, ensure_ascii=False),
}
try:
with db.begin_nested(): # SAVEPOINT — откат только этой строки
result = db.execute(
text("""
INSERT INTO gas_grs_outlet_points
(grs_name, grs_name_norm, outlet_name, consumer_type,
consumption_group, volume_requested_mln_m3,
volume_approved_mln_m3, free_capacity_mln_m3,
free_capacity_needs_calc, address_extracted,
period_month, is_forecast, asof, source_url, raw, fetched_at)
VALUES (
:grs_name, :grs_name_norm, :outlet_name, :consumer_type,
:consumption_group, :requested,
:approved, :free,
:needs_calc, :address,
:period_month, :is_forecast, :asof, :source_url,
CAST(:raw AS jsonb), NOW()
)
ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast)
DO UPDATE
-- geom НАМЕРЕННО НЕ в SET: geocode_gas_outlets (PR-3)
-- пишет его отдельным UPDATE. Помесячный рефреш INSERT-ит
-- без geom EXCLUDED.geom = NULL; включив его в SET, мы бы
-- ЗАТЁРЛИ уже геокоженную точку в NULL. Оставляем как есть.
SET grs_name = EXCLUDED.grs_name,
consumer_type = EXCLUDED.consumer_type,
consumption_group = EXCLUDED.consumption_group,
volume_requested_mln_m3 = EXCLUDED.volume_requested_mln_m3,
volume_approved_mln_m3 = EXCLUDED.volume_approved_mln_m3,
free_capacity_mln_m3 = EXCLUDED.free_capacity_mln_m3,
free_capacity_needs_calc = EXCLUDED.free_capacity_needs_calc,
address_extracted = EXCLUDED.address_extracted,
asof = EXCLUDED.asof,
source_url = EXCLUDED.source_url,
raw = EXCLUDED.raw,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
"""),
params,
).scalar()
if result:
inserted += 1
else:
updated += 1
if rec["free_capacity_needs_calc"]:
needs_calc += 1
if rec["address_extracted"]:
with_address += 1
except Exception as e:
logger.warning("ekgas upsert failed for %r/%r: %s", grs_name, rec["outlet_name"], e)
skipped += 1
return {
"rows": len(records),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"needs_calc": needs_calc,
"with_address": with_address,
}
def load_gas_outlet_points(db: Session | None = None) -> dict:
"""Тянет каталог ekgas → свежайшая форма 6 → парс XLSX → UPSERT точек выхода.
RUN-ON-PROD (гео-блок источника). Owns-session как соседи (gas/rosseti/eias): ``db``
None своя SessionLocal (fetch + запись sync). Выбор файла приоритет факта («за»)
над прогнозом («на»), см. select_latest_file.
Returns счётчики {rows, inserted, updated, skipped, needs_calc, with_address,
period, is_forecast} или {"error": ...} если подходящий файл в каталоге не найден.
"""
catalog_html = fetch_catalog_html()
candidates = extract_form6_links(catalog_html)
chosen = select_latest_file(candidates)
if chosen is None:
logger.warning("ekgas: подходящая форма 6 в каталоге не найдена (0 кандидатов)")
return {"error": "no form6 file found in catalog"}
meta = {
"period_month": chosen["period_month"],
"is_forecast": chosen["is_forecast"],
"asof": chosen["asof"],
"source_url": chosen["url"],
}
logger.info(
"ekgas: выбран файл period=%s is_forecast=%s asof=%s url=%s",
chosen["period_month"],
chosen["is_forecast"],
chosen["asof"],
chosen["url"],
)
xlsx_bytes = fetch_xlsx(chosen["url"])
records = parse_outlet_sheet(xlsx_bytes)
owns_session = db is None
if db is None:
db = SessionLocal()
try:
counts = _upsert_outlet_rows(db, records, meta)
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_gas_outlet_points: outer tx rolled back: %s", e)
raise
finally:
if owns_session:
db.close()
counts["period"] = chosen["period_month"].isoformat()
counts["is_forecast"] = chosen["is_forecast"]
logger.info("load_gas_outlet_points done: %s", counts)
return counts
# ── Геокод address_extracted → geom (PR-3) ────────────────────────────────────
# Кандидаты геокода: строки последнего period_month с адресом, но БЕЗ geom.
# MAX(period_month) в подзапросе — работаем только по свежайшему снапшоту (старые
# месяцы не трогаем). geom стабилен между месяцами (UPSERT его не затирает — см.
# DO UPDATE выше), так что уже геокоженные строки прошлых месяцев не пере-геокодим.
_GEOCODE_CANDIDATES_SQL = text("""
SELECT id, address_extracted
FROM gas_grs_outlet_points
WHERE address_extracted IS NOT NULL
AND geom IS NULL
AND period_month = (SELECT MAX(period_month) FROM gas_grs_outlet_points)
ORDER BY id
""")
# UPDATE geom = точка WGS84. CAST(:lon/:lat AS float) — psycopg3-канон (backend.md:
# НИКОГДА :lon::float). Пишем ТОЛЬКО если geom всё ещё NULL (idempotent при повторе).
_GEOCODE_UPDATE_SQL = text("""
UPDATE gas_grs_outlet_points
SET geom = ST_SetSRID(
ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)),
4326
)
WHERE id = CAST(:id AS bigint)
AND geom IS NULL
""")
def _ekb_prefixed(address: str) -> str:
"""address_extracted → строка для DaData с городом-префиксом при его отсутствии.
«ул. Стрелочников, д. 2» «г Екатеринбург, ул. Стрелочников, д. 2». Если город
уже есть в строке («г.Екатеринбург, » / «г. Реж, ») не дублируем.
"""
if "екатеринбург" in address.lower() or _HAS_CITY_RE.search(address):
return address
return f"{_EKB_PREFIX}{address}"
def _in_ekb_bbox(lat: float, lon: float) -> bool:
"""Точка в bbox Екатеринбурга и окрестностей? (sanity-гейт против чужого города)."""
return (
_EKB_BBOX_LAT[0] <= lat <= _EKB_BBOX_LAT[1] and _EKB_BBOX_LON[0] <= lon <= _EKB_BBOX_LON[1]
)
def geocode_gas_outlets(db: Session | None = None, *, apply: bool = False) -> dict:
"""Геокодит address_extracted точек выхода → geom (последний period_month).
Дефолт dry-run (apply=False): считает счётчики + печатает первые 20 пар
адрес(lat,lon), НО В БД НЕ ПИШЕТ. apply=True UPDATE geom.
Алгоритм:
1. SELECT строки последнего period_month c address_extracted IS NOT NULL AND
geom IS NULL (уже геокоженные не трогаем geom стабилен между месяцами).
2. Каждый адрес: префикс «г Екатеринбург, » при отсутствии города clean_address.
3. Sanity-bbox ЕКБ+окрестности: координата вне bbox failed (DaData «дорисовал»
чужой город на кривом адресе) geom НЕ пишем (честность).
4. Кэш в памяти (адреса повторяются) + cap _GEO_DADATA_CAP вызовов DaData.
Per-row SAVEPOINT (битая строка/UPDATE не валит батч, backend.md).
Returns счётчики {candidates, geocoded, failed, dadata_calls, cap_hit, applied}.
"""
owns_session = db is None
if db is None:
db = SessionLocal()
candidates: list[tuple[int, str]] = [
(int(row[0]), str(row[1])) for row in db.execute(_GEOCODE_CANDIDATES_SQL).all()
]
counts = {
"candidates": len(candidates),
"geocoded": 0,
"failed": 0,
"dadata_calls": 0,
"cap_hit": False,
"applied": apply,
}
# Кэш прогона: address → (lat, lon) | None (None = «пробовали, DaData не дал/вне
# bbox» — кэшируем и его, чтобы не жечь квоту на повтор одного адреса).
geo_cache: dict[str, tuple[float, float] | None] = {}
sample: list[tuple[str, tuple[float, float]]] = []
try:
for row_id, address in candidates:
if address in geo_cache:
coords = geo_cache[address]
elif counts["dadata_calls"] >= _GEO_DADATA_CAP:
counts["cap_hit"] = True
counts["failed"] += 1
continue
else:
counts["dadata_calls"] += 1
raw = clean_address(_ekb_prefixed(address))
# bbox-гейт ДО кэша: вне ЕКБ → трактуем как «нет координат» (None).
if raw is not None and not _in_ekb_bbox(raw[0], raw[1]):
logger.info(
"ekgas geocode: %r вне bbox ЕКБ (lat=%.4f lon=%.4f) → failed",
address[:60],
raw[0],
raw[1],
)
raw = None
coords = raw
geo_cache[address] = coords
if coords is None:
counts["failed"] += 1
continue
lat, lon = coords
if len(sample) < 20:
sample.append((address, (lat, lon)))
if not apply:
counts["geocoded"] += 1
continue
try:
with db.begin_nested(): # SAVEPOINT — откат только этой строки
db.execute(_GEOCODE_UPDATE_SQL, {"id": row_id, "lat": lat, "lon": lon})
counts["geocoded"] += 1
except Exception as e:
logger.warning("ekgas geocode UPDATE failed for id=%s %r: %s", row_id, address, e)
counts["failed"] += 1
if apply:
db.commit()
except Exception as e:
if apply:
db.rollback()
logger.exception("geocode_gas_outlets: rolled back: %s", e)
raise
finally:
if owns_session:
db.close()
logger.info("geocode_gas_outlets done (apply=%s): %s", apply, counts)
if not apply:
for addr, (lat, lon) in sample:
logger.info(" sample: %r → (%.6f, %.6f)", addr[:80], lat, lon)
return counts
def _main(argv: list[str] | None = None) -> int:
"""CLI: помесячная загрузка формы 6 (--load) и геокод адресов → geom (--geocode-outlets).
RUN-ON-PROD (гео-блок источника + DaData-ключи на проде). Пример:
python -m app.services.site_finder.ekgas_outlet_loader --geocode-outlets
python -m app.services.site_finder.ekgas_outlet_loader --geocode-outlets --apply
"""
parser = argparse.ArgumentParser(
description="Загрузчик точек выхода ГРС «Екатеринбурггаз» (#2119 B2): "
"load формы 6 + геокод address_extracted → geom."
)
parser.add_argument(
"--load",
action="store_true",
help="Загрузить свежайшую форму 6 из каталога ekgas → UPSERT точек выхода.",
)
parser.add_argument(
"--geocode-outlets",
action="store_true",
help="Геокодить address_extracted (последний period_month, geom IS NULL) → geom.",
)
parser.add_argument(
"--apply",
action="store_true",
help="Реально писать в БД (для --geocode-outlets). Без флага — dry-run.",
)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
if not args.load and not args.geocode_outlets:
parser.error("нужен --load и/или --geocode-outlets")
if args.load:
logger.info("load result: %s", load_gas_outlet_points())
if args.geocode_outlets:
logger.info("geocode result: %s", geocode_gas_outlets(apply=args.apply))
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(_main())

View file

@ -0,0 +1,458 @@
"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1).
No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН
HTML-таблицу «Информация о наличии (отсутствии) технической возможности»
(~350 строк) со свободной мощностью газораспределительных станций (ГРС)
проектная / текущая загрузка / свободная мощность (тыс. м³/ч) + уведомление о
расшивке. UPSERT-ит в ``gas_grs_capacity``.
Источник ГЕО-БЛОКИРУЕТ non-RU IP загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly /
manual docker exec) как rosseti-/vodokanal-лоадеры. Код не предполагает
локальный сетевой доступ.
Парсинг под РЕАЛЬНУЮ структуру таблицы ГТЕ (снята с прода 2026-07-02):
* caption-строка (1 ячейка) скип;
* «по состоянию на DD.MM.YYYY» в первых строках asof;
* строка-заголовок = ячейки содержат «наименование» И «проектн» (НЕ caption);
* ниже идут суб-заголовки + строка нумерации «0..7» скипаются как не-данные;
* single-cell строка = регион-разделитель («СВЕРДЛОВСКАЯ ОБЛАСТЬ») задаёт
``current_region``; данные пишутся ТОЛЬКО пока текущий регион Свердловский;
* FULL-строка (len>=10): idx 2=имя, 3=проектная, 4=загрузка, 5=объём по ТУ,
6=свободная, 7=свободная %, 8+=уведомление (основание/срок/параметры);
* CONTINUATION-строка (5<=len<=9, доп. выход): сдвиг на 1 idx 0=имя (с выходом),
1=проектная, 2=загрузка, 3=объём ТУ, 4=свободная, 5=%, 6=уведомление;
* имя с суффиксом «(выход N)» output_name отдельно; суффикс-число «(5)»
НЕ выход, остаётся в имени.
Числа через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая,
«» None). Объём по ТУ хранится ТОЛЬКО в ``raw`` (без миграции колонки).
Полная строка целиком ``raw`` jsonb (неизвестные/будущие колонки не теряются).
"""
import json
import logging
import re
import ssl
from datetime import date
from functools import lru_cache
from pathlib import Path
import httpx
from bs4 import BeautifulSoup
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.site_finder.rosseti_reserve_loader import (
parse_asof_date,
parse_reserve_number,
)
logger = logging.getLogger(__name__)
# Страница расклытия «Газпром трансгаз Екатеринбург» с инлайн HTML-таблицей.
# Punycode не требуется — латинский хост.
DISCLOSURE_URL = "https://ekaterinburg-tr.gazprom.ru/about/informatsiya-o-tekhnicheskoj-vozmo/"
_HTTP_TIMEOUT = 60
# Домен ГТЕ использует сертификат российского УЦ (Russian Trusted CA, Минцифры) —
# его нет в стандартном CA-bundle → CERTIFICATE_VERIFY_FAILED (прод-инцидент
# 2026-07-02). Подключаем публичный бандл (Госуслуги) ТОЛЬКО для этого лоадера,
# системные CA остаются в контексте (create_default_context + load_verify_locations).
_RU_CA_BUNDLE = (
Path(__file__).resolve().parents[2] / "resources" / "certs" / "russian_trusted_ca_bundle.pem"
)
@lru_cache(maxsize=1)
def _ru_ssl_context() -> ssl.SSLContext:
"""SSL-контекст: системные CA + Russian Trusted Root/Sub CA (Минцифры)."""
ctx = ssl.create_default_context()
ctx.load_verify_locations(cafile=str(_RU_CA_BUNDLE))
return ctx
_MULTISPACE_RE = re.compile(r"\s+")
# «н/д» / «-» / прочерк / пусто в текстовой ячейке (напр. срок расшивки) → None.
_NODATA_TEXT_TOKENS = frozenset(
{"", "н/д", "нд", "n/a", "na", "-", "", "", "нет данных", "х", "x"}
)
# Строка-заголовок таблицы ГТЕ: ячейки содержат «наименование» И «проектн».
# Отличает РЕАЛЬНЫЙ header от caption-строки («Информация о наличии…») и от
# суб-заголовков / строки нумерации.
_HEADER_MARKERS = ("наименование", "проектн")
# Свердловская область — единственный регион, который грузим (ГТЕ отдаёт и
# сопредельные: Курганская, Челябинская и т.д. — они отсекаются region-фильтром).
_SVERDLOVSK_TOKEN = "свердлов"
# Фрагмент «(выход №N)» в имени ГРС → отдельный output_name. «(5)» (только цифры) —
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
# Группы: 1=«выход», 2=номер (для канонизации «выход №N» без разнобоя пробелов вокруг №,
# иначе «выход № 1» и «выход №1» породили бы РАЗНЫЕ output_name → дубли по
# ON CONFLICT (grs_name_norm, output_name)).
_OUTPUT_SUFFIX_RE = re.compile(r"\(\s*(выход)\s*№?\s*(\d+)\s*\)", re.IGNORECASE)
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
_FULL_IDX = {
"grs_name": 2,
"design": 3,
"load": 4,
"volume_tu": 5,
"free": 6,
"pct": 7,
"notice": (8, 9, 10),
}
# CONTINUATION-строка (доп. выход, сдвиг на -1): имя=0, проектная=1, загрузка=2,
# объём ТУ=3, свободная=4, %=5, уведомление=6+.
_CONT_IDX = {
"grs_name": 0,
"design": 1,
"load": 2,
"volume_tu": 3,
"free": 4,
"pct": 5,
"notice": (6, 7, 8),
}
# Границы длины строки: full = данные с полным набором столбцов; continuation =
# усечённый доп. выход. Иначе строка не-данные (шапка/суб/нумерация) → скип.
_FULL_MIN_LEN = 10
_CONT_MIN_LEN = 5
_CONT_MAX_LEN = 9
def normalize_grs_name(name: str | None) -> str:
"""Нормализует имя ГРС для матча/дедупа.
Правила: lower, ёе, схлопывание пробелов и дефисов (все виды тире одиночный
дефис без пробелов вокруг), удаление кавычек. Префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ
они различают станции. Пусто ''.
Examples:
«ГРС Кольцово» «грс кольцово»
«ГРС Берёзовский» «грс березовский»
«ГРС Северная 2» «грс северная-2»
"""
if not name:
return ""
s = name.strip().lower().replace("ё", "е")
s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "")
# Все виды тире (—, , -) в одиночный дефис, пробелы вокруг убираем.
s = re.sub(r"\s*[—–-]\s*", "-", s)
s = _MULTISPACE_RE.sub(" ", s).strip()
return s
def _parse_pct(value: str | None) -> float | None:
"""Процент свободной мощности «(96 %)» → 96.0. «(—)»/пусто → None.
Ячейка приходит в скобках и с неразрывным пробелом перед «%». Снимаем скобки,
«%», пробелы и парсим числом. Отрицательные (дефицит) сохраняются знаком.
"""
if value is None:
return None
stripped = value.strip("()%\xa0 ").strip()
if not stripped or stripped in _NODATA_TEXT_TOKENS:
return None
return parse_reserve_number(stripped)
def _split_grs_name(raw_name: str) -> tuple[str, str | None]:
"""Имя ячейки → (grs_name без выхода, output_name|None).
«ГРС Арти (выход 2)» («ГРС Арти», «выход 2»); фрагмент «(выход N)» бывает
и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход 1) (5)» («ГРС-1 Свердловск (5)»,
«выход 1»)) вырезаем его, остаток склеиваем и нормализуем двойные пробелы.
output_name канонизируется в «выход N» (нижний регистр, ровно один пробел после
«выход», сразу «N» без пробела) разнобой пробелов вокруг «» иначе дал бы
разные output_name и дубли по ON CONFLICT (grs_name_norm, output_name).
«ГРС Арамиль (5)» без изменений (цифра в скобках НЕ выход, часть имени).
"""
m = _OUTPUT_SUFFIX_RE.search(raw_name)
if not m:
return raw_name, None
# Канонический output_name: «выход №N» (число из группы 2, «№» приклеено к цифре).
output = f"{m.group(1).lower()}{m.group(2)}"
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
# двойные пробелы, оставшиеся на месте выреза в середине).
grs_name = raw_name[: m.start()] + raw_name[m.end() :]
grs_name = _MULTISPACE_RE.sub(" ", grs_name).strip()
return grs_name, output
def _is_header_row(texts: list[str]) -> bool:
"""Строка — РЕАЛЬНЫЙ заголовок (ячейки содержат «наименование» И «проектн»)?
Отличает header от caption («Информация о наличии») и суб-заголовков.
"""
joined = " ".join(texts).lower()
return all(marker in joined for marker in _HEADER_MARKERS)
def _extract_table(html: str) -> object | None:
"""Находит наиболее «содержательную» <table> (с макс. числом строк). None — нет."""
soup = BeautifulSoup(html, "html.parser")
tables = soup.find_all("table")
if not tables:
return None
# Основная таблица — с наибольшим числом строк (шапки/навигация мелкие).
return max(tables, key=lambda t: len(t.find_all("tr")))
def _row_cells(tr: object) -> list[object]:
"""Ячейки строки (<th> и <td> в порядке появления)."""
return tr.find_all(["th", "td"]) # type: ignore[union-attr]
def _row_texts(cells: list[object]) -> list[str]:
"""Ячейки строки → список очищенных (collapse пробелов, \\xa0→пробел) текстов."""
out: list[str] = []
for cell in cells:
raw = cell.get_text(" ").replace("\xa0", " ") # type: ignore[union-attr]
out.append(_MULTISPACE_RE.sub(" ", raw).strip())
return out
def _cell_at(texts: list[str], idx: int) -> str | None:
"""Текст ячейки по индексу → None если пусто / «—»-токен / за границей."""
if idx >= len(texts):
return None
s = texts[idx].strip()
if not s or s.lower() in _NODATA_TEXT_TOKENS:
return None
return s
def _build_upgrade_due(texts: list[str], notice_idxs: tuple[int, ...]) -> str | None:
"""Ячейки уведомления (Основание/Срок/Параметры) → « · »-join непустых.
Все «»/пусто None (нет уведомления о расшивке).
"""
parts = [t for i in notice_idxs if (t := _cell_at(texts, i)) is not None]
return " · ".join(parts) if parts else None
def _build_record(
texts: list[str], idx: dict, current_region: str, asof: date | None
) -> dict | None:
"""Строит запись ГРС из строки данных по карте индексов (FULL или CONTINUATION).
Имя обязательно иначе None (не-данные). Объём по ТУ уходит ТОЛЬКО в raw.
"""
raw_name = _cell_at(texts, idx["grs_name"])
if not raw_name:
return None
grs_name, output_name = _split_grs_name(raw_name)
return {
"grs_name": grs_name,
"output_name": output_name,
"region": current_region,
"design_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["design"])),
"current_load_th_m3_h": parse_reserve_number(_cell_at(texts, idx["load"])),
"free_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["free"])),
"free_capacity_pct": _parse_pct(_cell_at(texts, idx["pct"])),
"upgrade_due": _build_upgrade_due(texts, idx["notice"]),
"asof": asof,
# Объём газа по ТУ — БЕЗ отдельной колонки в схеме → сохраняем в raw.
"raw": {"cells": texts, "volume_by_tu": _cell_at(texts, idx["volume_tu"])},
}
def parse_grs_table(html: str) -> list[dict]:
"""Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам).
Под РЕАЛЬНУЮ структуру таблицы (см. модульный docstring):
- основная <table> = с макс. числом строк;
- asof («по состоянию на DD.MM.YYYY») ищется в первых 4 строках;
- строка-заголовок детектится по маркерам «наименование» + «проектн»; всё до
неё (caption / asof) и суб-заголовки/нумерация сразу после скипаются;
- single-cell строка = регион-разделитель current_region (title-case);
- данные пишутся ТОЛЬКО когда current_region содержит «свердлов»;
- FULL-строка (len>=10) и CONTINUATION-строка (5<=len<=9) фиксированные
индексы; иначе строка не-данные скип с warning-логом.
Каждая запись:
{grs_name, output_name, region, design_capacity_th_m3_h, current_load_th_m3_h,
free_capacity_th_m3_h, free_capacity_pct, upgrade_due, asof, raw}.
"""
table = _extract_table(html)
if table is None:
logger.warning("gazprom_grs: <table> не найдена в HTML")
return []
rows = table.find_all("tr") # type: ignore[union-attr]
if not rows:
return []
row_texts = [_row_texts(_row_cells(tr)) for tr in rows]
# asof: «по состоянию на DD.MM.YYYY» в первых 4 строках (caption/asof-строка).
asof = parse_asof_date(*(" ".join(t) for t in row_texts[:4]))
# Находим строку-заголовок (маркеры «наименование» + «проектн»). Данные — ниже.
header_idx: int | None = None
for i, texts in enumerate(row_texts):
if _is_header_row(texts):
header_idx = i
break
if header_idx is None:
logger.warning("gazprom_grs: строка-заголовок не найдена (маркеры %s)", _HEADER_MARKERS)
return []
records: list[dict] = []
current_region: str | None = None
in_sverdlovsk = False
for texts in row_texts[header_idx + 1 :]:
n = len(texts)
if n == 0 or not any(t for t in texts):
continue # пустая строка → скип
if n == 1:
# Single-cell → регион-разделитель. title-case, обновляем контекст.
region = _cell_at(texts, 0)
if region:
current_region = region.title()
in_sverdlovsk = _SVERDLOVSK_TOKEN in region.lower()
continue
if not in_sverdlovsk:
continue # регион не Свердловский → строку не пишем
if n >= _FULL_MIN_LEN:
idx_map = _FULL_IDX
elif _CONT_MIN_LEN <= n <= _CONT_MAX_LEN:
idx_map = _CONT_IDX
else:
logger.warning("gazprom_grs: строка неожиданной длины n=%d → скип: %s", n, texts)
continue
rec = _build_record(texts, idx_map, current_region or "", asof)
if rec is not None:
records.append(rec)
logger.info("gazprom_grs: распознано записей ГРС=%d (asof=%s)", len(records), asof)
return records
def _passes_region_filter(records: list[dict]) -> list[dict]:
"""Фильтр Свердловской области по полю region записи.
Основной регион-фильтр УЖЕ применён в parse_grs_table (по current_region-
разделителям). Эта функция второй барьер на случай записей с чужим/пустым
region (совместимость с прежним пайплайном load_grs_capacity). Если region есть
хоть у одной оставляем только «свердлов»; иначе всё.
"""
if any(r.get("region") for r in records):
return [r for r in records if _SVERDLOVSK_TOKEN in (r.get("region") or "").lower()]
return records
def fetch_grs_html() -> str:
"""Тянет HTML страницы раскрытия ГТЕ. RUN-ON-PROD (гео-блок). httpx с таймаутом."""
resp = httpx.get(
DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
)
resp.raise_for_status()
return resp.text
def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]:
"""UPSERT записей ГРС в gas_grs_capacity. Per-row SAVEPOINT (битая строка не валит батч).
ON CONFLICT (grs_name_norm, output_name) NULLS NOT DISTINCT в схеме (output_name
nullable). Returns: счётчики rows/inserted/updated/skipped.
"""
inserted = 0
updated = 0
skipped = 0
for rec in records:
grs_name = rec["grs_name"]
params = {
"grs_name": grs_name,
"grs_name_norm": normalize_grs_name(grs_name),
"output_name": rec["output_name"],
"region": rec["region"],
"design": rec["design_capacity_th_m3_h"],
"load": rec["current_load_th_m3_h"],
"free": rec["free_capacity_th_m3_h"],
"pct": rec["free_capacity_pct"],
"upgrade_due": rec["upgrade_due"],
"asof": rec.get("asof"),
"raw": json.dumps(rec["raw"], ensure_ascii=False),
}
try:
with db.begin_nested(): # SAVEPOINT — откат только этой строки
result = db.execute(
text("""
INSERT INTO gas_grs_capacity
(grs_name, grs_name_norm, output_name, region,
design_capacity_th_m3_h, current_load_th_m3_h,
free_capacity_th_m3_h, free_capacity_pct,
upgrade_due, asof, raw, fetched_at)
VALUES (
:grs_name, :grs_name_norm, :output_name, :region,
:design, :load, :free, :pct,
:upgrade_due, :asof, CAST(:raw AS jsonb), NOW()
)
ON CONFLICT (grs_name_norm, output_name) DO UPDATE
SET grs_name = EXCLUDED.grs_name,
region = EXCLUDED.region,
design_capacity_th_m3_h = EXCLUDED.design_capacity_th_m3_h,
current_load_th_m3_h = EXCLUDED.current_load_th_m3_h,
free_capacity_th_m3_h = EXCLUDED.free_capacity_th_m3_h,
free_capacity_pct = EXCLUDED.free_capacity_pct,
upgrade_due = EXCLUDED.upgrade_due,
asof = EXCLUDED.asof,
raw = EXCLUDED.raw,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
"""),
params,
).scalar()
if result:
inserted += 1
else:
updated += 1
except Exception as e:
logger.warning("gas_grs upsert failed for %r/%r: %s", grs_name, rec["output_name"], e)
skipped += 1
return {"rows": len(records), "inserted": inserted, "updated": updated, "skipped": skipped}
def load_grs_capacity(db: Session | None = None) -> dict[str, int]:
"""Тянет страницу ГТЕ + парсит HTML-таблицу + UPSERT свободной мощности ГРС.
RUN-ON-PROD (гео-блок источника). ``db`` для совместимости сигнатуры; None
своя SessionLocal (fetch + запись sync, как rosseti_wfs_loader).
Returns: счётчики {rows, inserted, updated, skipped}.
"""
html = fetch_grs_html()
records = _passes_region_filter(parse_grs_table(html))
owns_session = db is None
if db is None:
db = SessionLocal()
try:
counts = _upsert_grs_rows(db, records)
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_grs_capacity: outer tx rolled back: %s", e)
raise
finally:
if owns_session:
db.close()
logger.info("load_grs_capacity done: %s", counts)
return counts

View file

@ -51,18 +51,23 @@ def _market_metrics_key(
obj_ids: Sequence[int] | None = None,
window_months: int = 6,
premise_kind: str = "квартира",
obj_class: str | None = None,
room_bucket: str | None = None,
) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для compute_market_metrics (#1129).
Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для
hashability), окно, premise_kind. В форсайт-пути obj_ids всегда None tuple()
устойчив. `db` не в ключе (одна сессия на отчёт).
hashability), окно, premise_kind, + сегмент-оси obj_class/room_bucket (#1959 —
посегментный дефицит). В форсайт-пути obj_ids всегда None tuple() устойчив.
`db` не в ключе (одна сессия на отчёт).
"""
return (
district,
tuple(obj_ids) if obj_ids is not None else None,
window_months,
premise_kind,
obj_class,
room_bucket,
)
@ -269,15 +274,45 @@ def _room_bucket(rooms_int: int | None) -> str:
# SQL aggregation
# ──────────────────────────────────────────────────────────────────────────────
# Текущий сток per-flat. Считаем по objective_lots (последний UPSERT-снапшот).
# Текущий сток per-flat. ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959):
# objective_lots — append-per-snapshot (~2.9× инфляция строк), без дедупа сток
# раздут и симметрично раздувает спрос И предложение → деградирует посегментный
# дефицит. Ключ физлота = (project_name, corpus_name, section, floor, lot_number);
# берём строку с MAX(snapshot_date), tie-break MAX(id). Поддерживается индексом
# objective_lots_physflat_latest_idx (создаётся в миграции 173) — он совпадает с
# ключом и направлениями этого DISTINCT ON → Index Only Scan + Unique без Sort.
# project_name NOT NULL; corpus/section/floor/lot_number могут быть NULL (~2.5%
# лотов) — для таких лотов DISTINCT ON схлопывает NULL=NULL в одну группу
# (неидентифицируемые физлоты, в дефицит-расчёте безопасно), для остального —
# точный per-flat дедуп.
#
# ⚠️ ДЕДУП БЕЗУСЛОВНЫЙ — применяется ко ВСЕМ вызовам compute_market_metrics, не
# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных
# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated),
# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп
# здесь касается ТОЛЬКО выборки внутри метрик; #1964 ввёл общий view
# v_objective_lots_latest для ОСТАЛЬНЫХ current-state консьюмеров — он использует
# ТОТ ЖЕ physflat-ключ/направления (project,corpus,section,floor,lot_number;
# snapshot_date DESC, id DESC), что и этот inline DISTINCT ON (инвариант общий).
# Этот блок НЕ рефакторим на view (zero behavior change, лишний риск).
#
# Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим
# LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр
# ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²"), ТОЧНОЕ зеркало
# sales_series.room_area_bucket_of (и _SOURCE_B_SQL): площадь ≥ :large_area → LARGE,
# иначе по rooms_int; так what_to_build (Source-B сетка) фильтрует БЕЗ перевода
# вокабуляра. Применяются ДО DISTINCT ON: сегментация объективна по физлоту
# (класс/комнатность/площадь не меняются между снапшотами).
#
# is_sold распознаём И через флаг is_sold, И через наличие contract_date / статус
# 'продан' — Объектив заполняет их неконсистентно. n_long_unsold: непродан и
# в продаже > N мес (sales_start_date — самый надёжный «когда вышел на рынок»).
_STOCK_SQL = text(
"""
WITH lots AS (
SELECT
ol.objective_lot_id,
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
)
ol.project_name,
ol.rooms_int,
ol.area_pd,
@ -297,6 +332,26 @@ _STOCK_SQL = text(
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
)
AND (
CAST(:cls AS text) IS NULL
OR LOWER(ol.class) = LOWER(CAST(:cls AS text))
)
AND (
CAST(:room_bucket AS text) IS NULL
OR (CASE
WHEN ol.area_pd >= CAST(:large_area AS numeric)
THEN CAST(:b_large AS text)
WHEN ol.rooms_int IS NULL THEN CAST(:b_unknown AS text)
WHEN ol.rooms_int <= 0 THEN CAST(:b_studio AS text)
WHEN ol.rooms_int = 1 THEN CAST(:b_1k AS text)
WHEN ol.rooms_int = 2 THEN CAST(:b_2k AS text)
WHEN ol.rooms_int = 3 THEN CAST(:b_3k AS text)
ELSE CAST(:b_large AS text)
END) = CAST(:room_bucket AS text)
)
ORDER BY
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
SELECT
COUNT(*) AS n_lots,
@ -320,9 +375,45 @@ _STOCK_SQL = text(
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
# Bug #949: Автовокзал 6mo давал ~33 245 ед. вместо реальных ~2 308.)
# area_pd берём из самого objective_lots (текущий per-flat area).
#
# ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959, симметрично _STOCK_SQL):
# без дедупа один проданный физлот, присутствующий в N снапшотах, считается N раз
# → velocity/absorption раздуты ~2.9× (раздувают спрос симметрично раздутому
# предложению → дефицит вырождается в 1.0). Дедупим в CTE `latest`, окно по
# contract_date и сегмент-фильтры применяем уже к дедуплицированному набору.
# Ключ/направления дедупа — зеркало _STOCK_SQL (оба поддержаны индексом
# objective_lots_physflat_latest_idx из миграции 173). contract_date / rooms_int /
# area_pd берём из последнего снапшота физлота.
# Сегмент-фильтры (#1959): obj_class (lowercase, LOWER=LOWER) + room_bucket
# (Source-B room_area-вокабуляр "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/
# "80+ м²" — ТОЧНОЕ зеркало sales_series.room_area_bucket_of, как в _STOCK_SQL) —
# применяются ПОСЛЕ дедупа (объективны по физлоту: класс/комнатность/площадь не
# меняются между снапшотами). ROLLUP остаётся: при room_bucket-фильтре отдаёт
# {bucket, total}.
_SALES_WINDOW_SQL = text(
"""
WITH latest AS (
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
)
ol.rooms_int,
ol.area_pd,
ol.contract_date,
ol.class
FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind
AND (
CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
)
AND (
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
)
ORDER BY
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
SELECT
COUNT(*) AS units_sold_window,
COALESCE(SUM(area_pd), 0) AS area_sold_window,
@ -334,18 +425,26 @@ _SALES_WINDOW_SQL = text(
-- эмитит итог ПЕРВЫМ NULL-группа затирает units_total частичным
-- счётом unit_velocity/absorption занижены, MoS завышен.
GROUPING(rooms_int) AS is_total
FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind
FROM latest
WHERE contract_date IS NOT NULL
AND contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
AND (
CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
CAST(:cls AS text) IS NULL
OR LOWER(class) = LOWER(CAST(:cls AS text))
)
AND (
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
CAST(:room_bucket AS text) IS NULL
OR (CASE
WHEN area_pd >= CAST(:large_area AS numeric)
THEN CAST(:b_large AS text)
WHEN rooms_int IS NULL THEN CAST(:b_unknown AS text)
WHEN rooms_int <= 0 THEN CAST(:b_studio AS text)
WHEN rooms_int = 1 THEN CAST(:b_1k AS text)
WHEN rooms_int = 2 THEN CAST(:b_2k AS text)
WHEN rooms_int = 3 THEN CAST(:b_3k AS text)
ELSE CAST(:b_large AS text)
END) = CAST(:room_bucket AS text)
)
AND ol.contract_date IS NOT NULL
AND ol.contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
GROUP BY ROLLUP (rooms_int)
"""
)
@ -359,6 +458,8 @@ def compute_market_metrics(
obj_ids: Sequence[int] | None = None,
window_months: int = 6,
premise_kind: str = "квартира",
obj_class: str | None = None,
room_bucket: str | None = None,
) -> MarketMetrics:
"""Вычислить рыночные метрики ТЗ §9.2 для локации.
@ -366,9 +467,34 @@ def compute_market_metrics(
заданы). Если ни один не задан считается по всей выборке premise_kind
(имеет смысл для ЕКБ-wide baseline).
#1959 (посегментный дефицит): `obj_class` (lowercase в objective_lots,
матчим регистронезависимо) и `room_bucket` (Source-B room_area-вокабуляр
"Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²", зеркало
`sales_series.room_area_bucket_of` what_to_build фильтрует БЕЗ перевода)
сужают выборку до одного сегмента. Так base_pace (unit_velocity) и доступный
сток становятся ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки
what_to_build делят один темп дефицит вырождается в 1.0). None по обеим осям
прежнее district-wide поведение. ВАЖНО: SQL дедуплицирует objective_lots до
последнего снапшота на физлот counts (а значит и confidence) считаются на
дедуплицированном наборе.
Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики =
None, confidence='low'. Никогда не бросает на отсутствии данных.
"""
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of /
# _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без перевода.
# Локальный (lazy) импорт: модульный импорт sales_series тянет forecasting/__init__
# → demand_supply_forecast → future_supply → market_metrics (циклический импорт).
from app.services.forecasting.sales_series import (
_LARGE_AREA_THRESHOLD_M2,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_UNKNOWN,
)
obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
has_obj_ids = bool(obj_id_list)
# Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
@ -386,6 +512,19 @@ def compute_market_metrics(
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
"obj_ids": obj_id_list,
"overstock_interval": f"{_OVERSTOCK_MONTHS_THRESHOLD} months",
# #1959 сегмент-фильтры (None → ось не сужается).
"cls": obj_class,
"room_bucket": room_bucket,
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of
# / _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без
# перевода. Меняешь bucketing — правь в ОБОИХ местах (тут + sales_series).
"large_area": _LARGE_AREA_THRESHOLD_M2,
"b_studio": ROOM_AREA_BUCKET_STUDIO,
"b_1k": ROOM_AREA_BUCKET_1K,
"b_2k": ROOM_AREA_BUCKET_2K,
"b_3k": ROOM_AREA_BUCKET_3K,
"b_large": ROOM_AREA_BUCKET_LARGE,
"b_unknown": ROOM_AREA_BUCKET_UNKNOWN,
}
# ── Текущий сток ──────────────────────────────────────────────────────────
@ -433,10 +572,12 @@ def compute_market_metrics(
confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)
logger.info(
"market_metrics: district=%s micros=%s obj_ids=%d n_lots=%d n_sold=%d "
"n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
"market_metrics: district=%s micros=%s class=%s room=%s obj_ids=%d n_lots=%d "
"n_sold=%d n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
district,
micros,
obj_class,
room_bucket,
len(obj_id_list),
n_lots,
n_sold_total,
@ -575,3 +716,218 @@ def _price_sensitivity(
)
return None, None
return float(elast["elasticity"]), str(elast["source"])
# ──────────────────────────────────────────────────────────────────────────────
# Offer-price trend (#2178) — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history.
#
# Мотивация: §4.3 «Тренд рынка» строится на rosreestr_deals, чей поток встал
# (max дата 2026-01-01) → recent-окно пусто → status='source_stale' и пустая
# карточка. objective_lots_history даёт weekly-снапшоты цен предложения активных
# лотов — это ВТОРОЙ источник, живой (снимки до 2026-06-30), которым мы заполняем
# карточку, когда сделки устарели.
#
# СВЯЗКА С ЖК РАДИУСА — тот же путь, что best_layouts/competitors (маппинг, НЕ
# ненадёжный complex_id-FK, который джойнит лишь ~28% и мультигородской, см.
# 131_district_vocab_map.sql):
# radius (ST_DWithin по domrf_kn_objects)
# → domrf_obj_id
# → objective_complex_mapping.domrf_obj_id → objective_complex_name
# → objective_lots.project_name → objective_lot_id-набор
# → objective_lots_history (per-snapshot цены этих лотов).
# objective_lots_history НЕ содержит project_name — поэтому набор objective_lot_id
# фиксируем из objective_lots (текущее состояние), а историю берём по нему.
#
# ДЕДУП задвоенного снапшота (КРИТИЧНО, #2178): 2026-06-22 был просвипан дважды
# (~621k строк = два свипа в один день). На (objective_lot_id, snapshot_date) в
# истории стоит UNIQUE-констрейнт → физически дубля быть не должно, НО на проде
# он есть (два ETL-пути до починки триггера). Безусловно дедупим до одной строки
# на (objective_lot_id, snapshot_date): DISTINCT ON (...) ORDER BY fetched_at DESC
# — берём последний свип дня. Медиану считаем УЖЕ по дедуплицированному набору,
# иначе задвоенный день весит ×2 и смещает медиану.
#
# ФИЛЬТР «в продаже» (честный, по реальным status в objective_lots_history —
# "резерв"|"свободна"|"продан"|"бронь", см. 68_schema_objective.sql): лот активен
# = is_sold IS NOT TRUE И LOWER(status) не равен 'продан'. Бронь/резерв считаем
# «в продаже» (цена ещё котируется рынку). contract_date-признак здесь не
# используем: в истории он часто NULL у активных лотов, а нам важна котировка.
#
# ФИЛЬТР ВМЕНЯЕМОСТИ ЦЕНЫ: price_per_m2_rub BETWEEN 10000 AND 1000000 — отсекает
# ETL-мусор (0 / гросс-суммы, ошибочно попавшие в per-m2). Границы шире, чем у
# сделок-тренда (30k500k), т.к. предложение включает премиум/апартаменты.
_OFFER_TREND_SQL = text(
"""
WITH radius_obj AS (
SELECT DISTINCT ON (o.obj_id) o.obj_id
FROM domrf_kn_objects o
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(
ST_MakePoint(
CAST(:center_lon AS float), CAST(:center_lat AS float)
),
4326
)::geography,
CAST(:radius_m AS float)
)
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
),
radius_projects AS (
SELECT DISTINCT cm.objective_complex_name AS project_name
FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM radius_obj)
),
radius_lots AS (
SELECT DISTINCT ol.objective_lot_id
FROM objective_lots ol
JOIN radius_projects rp ON rp.project_name = ol.project_name
WHERE ol.premise_kind = CAST(:premise_kind AS text)
),
hist AS (
-- Дедуп задвоенного снапшота: одна строка на (лот, день), последний свип.
SELECT DISTINCT ON (h.objective_lot_id, h.snapshot_date)
h.snapshot_date,
h.price_per_m2_rub
FROM objective_lots_history h
JOIN radius_lots rl ON rl.objective_lot_id = h.objective_lot_id
WHERE h.price_per_m2_rub BETWEEN CAST(:price_min AS numeric)
AND CAST(:price_max AS numeric)
AND (h.is_sold IS NOT TRUE)
AND LOWER(COALESCE(h.status, '')) <> 'продан'
ORDER BY h.objective_lot_id, h.snapshot_date, h.fetched_at DESC
)
SELECT
snapshot_date AS snapshot_date,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_price_m2,
COUNT(*) AS lots_count,
(SELECT COUNT(*) FROM radius_projects) AS complexes_covered
FROM hist
GROUP BY snapshot_date
ORDER BY snapshot_date
"""
)
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k500k):
# предложение включает премиум/апартаменты; отсекаем только явный ETL-мусор.
_OFFER_PRICE_MIN: int = 10000
_OFFER_PRICE_MAX: int = 1000000
# Радиус ЖК-подбора — зеркало market_trend-хендлера parcels.py (3 км от центроида).
_OFFER_TREND_RADIUS_M: int = 3000
@dataclass(frozen=True)
class OfferPriceTrend:
"""Динамика медианной цены предложения (руб/м²) по снапшотам Объектива (#2178).
Источник objective_lots_history (weekly-снапшоты активных лотов ЖК в радиусе).
Всегда конструируется через ``from_points``; пустой набор точек None-поля,
``points=[]`` (никогда не бросает на отсутствии данных).
"""
points: list[dict[str, Any]] # [{date: 'YYYY-MM-DD', median_price_m2, lots_count}]
delta_pct: float | None # (последняя медиана первая) / первая × 100
lots_latest: int | None # lots_count последнего снапшота
complexes_covered: int # сколько ЖК (project_name) попало в радиус-выборку
def as_dict(self) -> dict[str, Any]:
return {
"points": self.points,
"delta_pct": _round_or_none(self.delta_pct, 1),
"lots_latest": self.lots_latest,
"complexes_covered": self.complexes_covered,
}
@property
def has_data(self) -> bool:
"""True если есть ≥1 снапшот с медианой — тогда карточку можно показать."""
return len(self.points) > 0
def compute_offer_price_trend(
db: Session,
*,
center_lat: float,
center_lon: float,
radius_m: int = _OFFER_TREND_RADIUS_M,
premise_kind: str = "квартира",
) -> OfferPriceTrend:
"""Медианная цена предложения по снапшотам Объектива для ЖК в радиусе (#2178).
ЖК определяются тем же путём, что market_trend-хендлер / best_layouts: ЖК в
радиусе ``radius_m`` от (``center_lat``, ``center_lon``) по domrf_kn_objects
objective_complex_mapping objective_lots. По их лотам считается per-snapshot
медиана price_per_m2_rub активных лотов (дедуп задвоенного дня, фильтр
вменяемости цены).
Возвращает OfferPriceTrend ВСЕГДА (даже на пустых данных: points=[], поля None,
complexes_covered=0). Никогда не бросает на отсутствии данных на ошибке SQL
логирует и возвращает пустой результат (graceful, зеркало _query_stock).
"""
params: dict[str, Any] = {
"center_lat": float(center_lat),
"center_lon": float(center_lon),
"radius_m": float(radius_m),
"premise_kind": premise_kind,
"price_min": _OFFER_PRICE_MIN,
"price_max": _OFFER_PRICE_MAX,
}
try:
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
except Exception:
logger.exception(
"market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s)",
center_lat,
center_lon,
radius_m,
)
rows = []
points: list[dict[str, Any]] = []
complexes_covered = 0
for r in rows:
# complexes_covered — константа по всем строкам (scalar-subquery), берём
# из первой встреченной строки. При пустом hist rows=[] → остаётся 0.
complexes_covered = int(r["complexes_covered"] or 0)
median = r["median_price_m2"]
if median is None:
continue
snap = r["snapshot_date"]
# snapshot_date — DATE; нормализуем (isoformat) вне зависимости от того,
# date или datetime отдал драйвер.
date_str = snap.isoformat() if hasattr(snap, "isoformat") else str(snap)
points.append(
{
"date": date_str,
"median_price_m2": round(float(median)),
"lots_count": int(r["lots_count"] or 0),
}
)
delta_pct: float | None = None
lots_latest: int | None = None
if points:
first_median = points[0]["median_price_m2"]
last_median = points[-1]["median_price_m2"]
lots_latest = points[-1]["lots_count"]
if first_median and first_median > 0:
delta_pct = (last_median - first_median) / first_median * 100.0
logger.info(
"offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d "
"lots_latest=%s delta_pct=%s",
center_lat,
center_lon,
radius_m,
len(points),
lots_latest,
_round_or_none(delta_pct, 1),
)
return OfferPriceTrend(
points=points,
delta_pct=delta_pct,
lots_latest=lots_latest,
complexes_covered=complexes_covered,
)

View file

@ -0,0 +1,168 @@
"""Гуманизация «сетевых охранных зон» ЗОУИТ для §3 ПТИЦА (#2119 C3).
Позитив-разрез охранных зон инженерных сетей: рядом с участком физически ПРОХОДИТ
сеть (охранная зона = коридор сети) «теплотрасса в 24 м врезка дешёвая». Pure /
без БД: на вход ``type_zone`` + ``name_by_doc`` (детальные имена из cad_zouit), на
выход уточнённый ``net_kind`` + человекочитаемый ``human_label`` для финдиректора.
Зачем отдельно от ``network_obremenenie.classify_network_zone``:
- ``classify_network_zone`` смотрит ТОЛЬКО на ``type_zone`` (грубый вид: thermal /
general / ). На проде (2026-07-03) большинство «Охранная зона инженерных
коммуникаций» (``general``) по ``name_by_doc`` РЕАЛЬНО теплосети/газопроводы
(«Трубопровод (тепловые сети) с кадастровым номером 66:41:», «Газопровод
низкого давления»). Оставить их ``general`` = потерять решающий тепло-сигнал.
- Поэтому здесь ``refine_net_kind`` дочищает ``general`` по ``name_by_doc``, а
``human_label`` вытаскивает кад.номер трубопровода / напряжение ВЛ / номер ТП
regex-ами. Классификатор type_zone НЕ дублируем переиспользуем как базу.
"""
from __future__ import annotations
import re
from app.services.site_finder.network_obremenenie import NetworkKind, classify_network_zone
# Кад.номер РФ: 2:2:до7цифр:до-длинная последняя секция (напр. 66:41:0108079:76).
# Последняя секция бывает с ведущими нулями («0036») — берём как есть, не нормализуем.
_CAD_NUM_RE = re.compile(r"\b\d{2}:\d{2}:\d{6,7}:\d+\b")
# «ВЛ 0,4 кВ» / «КЛ 10кВ» / «ВЛИ-0,4 кВ» — тип линии + напряжение. Запятая-десятичный
# разделитель (0,4) и точка (0.4) оба валидны; пробел перед «кВ» опционален.
_VL_KV_RE = re.compile(
r"\b(ВЛИ|ВЛЗ|ВЛ|КЛ|КВЛ)\b[\s-]*?(\d+(?:[.,]\d+)?)\s*кВ",
re.IGNORECASE,
)
# Номер ТП/РП/КТП: «ТП 3164», «ТП 32505», «ТП 3736-3747», «КТП №589». Берём первый
# «якорный» ТП (источник питания ввода). Номер — цифры, опционально с дефисом-диапазоном.
_TP_RE = re.compile(
r"\b(ТП|РП|КТП|КТПН|БКТП|РТП)\b\s*(?:п/?№|№|N)?\s*(\d+(?:-\d+)?)",
re.IGNORECASE,
)
# Маркеры «на самом деле тепло» в name_by_doc для general-зон (инженерные коммуникации).
_THERMAL_NEEDLES: tuple[str, ...] = (
"теплоснабж",
"тепловая сеть",
"тепловые сети",
"тепловых сетей",
"теплосет",
"теплотрасс",
)
# Маркеры «на самом деле газ» в name_by_doc для general-зон.
_GAS_NEEDLES: tuple[str, ...] = (
"газопров",
"газораспредел",
"газоснаб",
"догазифик",
)
# Маркеры электро (ВЛ/КЛ/электросет/подстанция) в name_by_doc для general-зон.
# «трансформаторная подстанция» / «распределительный пункт» — объект электросети без
# явного «ВЛ N кВ» (прод-кейс 66:41:0404019:333). ТП здесь = трансформаторная (электро),
# не путать с тепловой ТК (тепловая камера) — та ловится _THERMAL_NEEDLES раньше.
_ELECTRICITY_NEEDLES: tuple[str, ...] = (
"электросет",
"электроснабж",
"электросетевого",
"кабельн",
"трансформаторн",
"распределительн",
)
def refine_net_kind(type_zone: str | None, name_by_doc: str | None) -> NetworkKind | None:
"""Уточнить вид сети: база по ``type_zone``, дочистка ``general`` по ``name_by_doc``.
Возвращает ``None``, если зона вообще не сетевая (как ``classify_network_zone``).
Для ``general`` («инженерные коммуникации») пытается распознать реальный вид по
детальному имени: тепло thermal, газ gas, электро electricity. Если вид не
распознан остаётся ``general`` («вид не уточнён»).
Специфичные type_zone (thermal / electricity / gas / ) НЕ переопределяем именем
для них type_zone уже точный, а name_by_doc лишь обогащает подпись.
"""
base = classify_network_zone(type_zone)
if base != "general":
return base
name = (name_by_doc or "").lower()
if any(n in name for n in _THERMAL_NEEDLES):
return "thermal"
if any(n in name for n in _GAS_NEEDLES):
return "gas"
# ВЛ/КЛ N кВ в имени → электро (regex поймает даже без слова «электросеть»).
if any(n in name for n in _ELECTRICITY_NEEDLES) or _VL_KV_RE.search(name_by_doc or ""):
return "electricity"
return "general"
def _fmt_voltage(raw: str) -> str:
"""«0,4» / «0.4» / «10» → нормализованная строка напряжения («0,4», «10»)."""
return raw.replace(".", ",")
def _is_last_mile_voltage(raw: str) -> bool:
"""Напряжение ≤1 кВ (0,4 кВ и т.п.) — last-mile ввод, не магистраль."""
try:
return float(raw.replace(",", ".")) <= 1.0
except ValueError:
return False
def human_label(net_kind: NetworkKind | None, name_by_doc: str | None) -> str:
"""Человекочитаемая подпись сетевой зоны для §3 (финдиректор-facing).
thermal «Тепловая сеть (по охранной зоне)» + кад.номер трубопровода, если есть.
electricity «Ввод N кВ от ТП M (по охранной зоне)» (regex ВЛ/КЛ + ТП);
напряжение 1 кВ подаём как «(ввод 0,4 кВ, last-mile)» второстепенно.
gas/pipeline «Трубопровод (газо-/нефтепровод) по охранной зоне».
general «Инженерные коммуникации (вид не уточнён)».
None «Охранная зона» (fallback, не должно доходить для сетевых).
"""
name = name_by_doc or ""
if net_kind == "thermal":
cad = _CAD_NUM_RE.search(name)
base = "Тепловая сеть (по охранной зоне)"
if cad:
return f"{base}, кад. № {cad.group(0)}"
return base
if net_kind == "electricity":
vl = _VL_KV_RE.search(name)
tp = _TP_RE.search(name)
if vl:
voltage = _fmt_voltage(vl.group(2))
last_mile = _is_last_mile_voltage(vl.group(2))
if tp:
tp_num = tp.group(2)
if last_mile:
# ≤1 кВ — last-mile, подаём как второстепенный факт (уточнение в скобках).
return f"Ввод от ТП {tp_num} (по охранной зоне, {voltage} кВ — last-mile)"
return f"Ввод {voltage} кВ от ТП {tp_num} (по охранной зоне)"
if last_mile:
return f"Электроввод {voltage} кВ — last-mile (по охранной зоне)"
return f"Ввод {voltage} кВ (по охранной зоне)"
if tp:
return f"Электросеть от ТП {tp.group(2)} (по охранной зоне)"
return "Объект электросетевого хозяйства (по охранной зоне)"
if net_kind in ("gas", "pipeline"):
cad = _CAD_NUM_RE.search(name)
base = "Трубопровод (газо-/нефтепровод) по охранной зоне"
if cad:
return f"{base}, кад. № {cad.group(0)}"
return base
if net_kind == "water":
return "Сеть водоснабжения/водоотведения (по охранной зоне)"
if net_kind == "communication":
return "Линия связи (по охранной зоне)"
if net_kind == "general":
return "Инженерные коммуникации (вид не уточнён)"
return "Охранная зона"
__all__ = ["human_label", "refine_net_kind"]

View file

@ -0,0 +1,319 @@
"""OSM-геокод городских ТП/РП ЕКБ → geom для power_tp_rp_reserves (#2119 B2, финал).
Хвост §3 «свободная мощность»: у ~2012 ЕКБ-строк power_tp_rp_reserves (ЕЭСК,
«ниже 35» кВ) name_norm = чистый номер («10003»), но geom NULL. OSM в bbox ЕКБ
несёт ~821 substation; часть подписана номером ТП/РП в теге ``ref`` или ``name``
(«РП-101», «ТП-180», «ТП 1591») по нему матчим наши строки и заливаем координату.
Разведка 2026-07-03: матч по номеру даёт ~119 наших ТП, 0 неоднозначностей в срезе.
Консервативно: берём число только если это похоже на номер ТП/РП (ТП/РП/RP/TP-токен
рядом ИЛИ голый ref-число) «110 кВ» из voltage мимо. Дубль-номера (>1 OSM-фичи на
один номер) честно скипаем в ambiguous.
RUN-ON-PROD-friendly, но источник (Overpass) без гео-блока можно и локально.
httpx с явным таймаутом, per-row SAVEPOINT (backend.md), idempotent UPDATE
(geom IS NULL + geocode_status='osm'). Dry-run по умолчанию; CLI ``--apply``.
Overpass endpoint: overpass.kumi.systems (overpass-api.de даёт 406 на наш POST;
area-запрос роняет соединение используем ТОЛЬКО bbox).
"""
from __future__ import annotations
import argparse
import logging
import re
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
# Overpass-инстанс: overpass-api.de отдаёт 406 на наш POST, kumi стабилен (разведка).
OVERPASS_URL = "https://overpass.kumi.systems/api/interpreter"
# bbox ЕКБ (south, west, north, east) для Overpass. area-запрос роняет соединение —
# ТОЛЬКО bbox. Тот же прямоугольник используем и для sanity-гейта координаты.
_EKB_BBOX = (56.70, 60.40, 56.97, 60.80)
_HTTP_TIMEOUT = 60
# Overpass QL: node/way/relation с power=substation в bbox, вывод с центроидом (center)
# и тегами. `out center tags;` — для way/relation отдаёт center-точку, для node — саму.
_OVERPASS_QL = (
"[out:json][timeout:{timeout}];"
"("
'node["power"="substation"]({s},{w},{n},{e});'
'way["power"="substation"]({s},{w},{n},{e});'
'relation["power"="substation"]({s},{w},{n},{e});'
");"
"out center tags;"
)
# Токен «ТП/РП» (кириллица и латиница-двойники) + число 2-5 цифр после него.
# Примеры: «РП-101» → 101, «ТП-180» → 180, «ТП 1591» → 1591, «TP-42» → 42.
# Разделитель между токеном и числом — дефис/пробел/точка/двоеточие (или отсутствует).
_TP_TOKEN_NUM_RE = re.compile(
r"(?:тп|рп|tp|rp)\s*[-–—.:№]?\s*(\d{2,5})\b",
re.IGNORECASE,
)
# Голый ref-номер целиком: только цифры 2-5 знаков (напр. ref='101' без ТП-токена).
_BARE_NUM_RE = re.compile(r"^\s*(\d{2,5})\s*$")
def fetch_osm_substations() -> list[dict]:
"""Тянет OSM-подстанции ЕКБ (power=substation) в bbox через Overpass → element-list.
POST data= (GET area роняет соединение). Скип ``substation=traction`` (тяговые
ж/д подстанции не городские ТП/РП). httpx с явным таймаутом. Возвращает
сырые Overpass-элементы ``{"type","id","lat"/"center","tags"}``.
"""
ql = _OVERPASS_QL.format(
timeout=_HTTP_TIMEOUT,
s=_EKB_BBOX[0],
w=_EKB_BBOX[1],
n=_EKB_BBOX[2],
e=_EKB_BBOX[3],
)
resp = httpx.post(OVERPASS_URL, data={"data": ql}, timeout=_HTTP_TIMEOUT)
resp.raise_for_status()
data = resp.json()
elements: list[dict] = (data or {}).get("elements") or []
kept: list[dict] = []
for el in elements:
tags = el.get("tags") or {}
# Тяговые ж/д подстанции — не городские ТП/РП, выкидываем.
if tags.get("substation") == "traction":
continue
kept.append(el)
logger.info(
"osm_tp_geocoder: Overpass отдал %d substation, после traction-скипа %d",
len(elements),
len(kept),
)
return kept
def extract_tp_number(tags: dict) -> str | None:
"""Извлекает номер ТП/РП из тегов OSM-фичи (``ref`` приоритетнее ``name``).
Консервативно только когда число выглядит номером ТП/РП, не воль­тажем:
- ``ref`` = голое число 2-5 цифр («101») берём;
- в ``ref``/``name`` есть ТП/РП/TP/RP-токен + число («РП-101», «ТП 1591») берём число;
- «110 кВ» / «110 kV» БЕЗ ТП-токена None (это класс напряжения, не номер).
Приоритет источника: ref, затем name. Возвращает строку-номер или None.
"""
ref = (tags.get("ref") or "").strip()
name = (tags.get("name") or "").strip()
# 1. Голый ref-номер (наиболее надёжный сигнал: оператор проставил ref явно).
if ref:
m_bare = _BARE_NUM_RE.match(ref)
if m_bare:
return m_bare.group(1)
# 2. ТП/РП-токен + число — сперва в ref, потом в name.
for source in (ref, name):
if not source:
continue
m = _TP_TOKEN_NUM_RE.search(source)
if m:
return m.group(1)
# «110 кВ» и прочий вольтаж без ТП-токена и без голого ref — намеренно None.
return None
def _center_coords(el: dict) -> tuple[float, float] | None:
"""(lat, lon) из Overpass-элемента: node → lat/lon, way/relation → center. None — нет."""
if el.get("lat") is not None and el.get("lon") is not None:
return float(el["lat"]), float(el["lon"])
center = el.get("center") or {}
if center.get("lat") is not None and center.get("lon") is not None:
return float(center["lat"]), float(center["lon"])
return None
def _in_ekb_bbox(lat: float, lon: float) -> bool:
"""Координата в bbox ЕКБ? Overpass и так ограничен bbox'ом — проверка дёшева/страховка."""
s, w, n, e = _EKB_BBOX
return s <= lat <= n and w <= lon <= e
def _index_osm_by_number(elements: list[dict]) -> tuple[dict[str, tuple[float, float]], set[str]]:
"""Строит {номер → (lat,lon)} по OSM-фичам; номера с >1 фичей → ambiguous-set.
Номер извлекается ``extract_tp_number``; берётся только фича с валидной координатой
в bbox ЕКБ. Если один номер несут 2 разных фичи (разные координаты) он попадает
в ambiguous и НЕ матчится (честность: не угадываем, какая из ТП «та самая»).
"""
by_number: dict[str, tuple[float, float]] = {}
seen_coords: dict[str, set[tuple[float, float]]] = {}
ambiguous: set[str] = set()
for el in elements:
tags = el.get("tags") or {}
number = extract_tp_number(tags)
if number is None:
continue
coords = _center_coords(el)
if coords is None:
continue
lat, lon = coords
if not _in_ekb_bbox(lat, lon):
logger.info(
"osm_tp_geocoder: номер %s вне bbox ЕКБ (lat=%.4f lon=%.4f) → скип",
number,
lat,
lon,
)
continue
prev = seen_coords.setdefault(number, set())
# Округляем до ~11 м, чтобы дубли-теги одной ТП (way+node) не считались коллизией.
coord_key = (round(lat, 4), round(lon, 4))
prev.add(coord_key)
if len(prev) > 1:
ambiguous.add(number)
by_number.pop(number, None)
continue
if number not in ambiguous:
by_number[number] = (lat, lon)
return by_number, ambiguous
# UPDATE geom + geocode_status='osm'. CAST(:x AS ...) — psycopg3-канон (backend.md,
# НИКОГДА :x::float). Пишем ТОЛЬКО ЕКБ + geom всё ещё NULL → idempotent при повторе.
_UPDATE_SQL = text("""
UPDATE power_tp_rp_reserves
SET geom = ST_SetSRID(
ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)),
4326
),
geocode_status = 'osm'
WHERE name_norm = CAST(:name_norm AS text)
AND municipality = 'Екатеринбург'
AND geom IS NULL
""")
# Кандидаты на геокод: ЕКБ-строки без координаты. name_norm = чистый номер.
_CANDIDATES_SQL = text("""
SELECT name_norm
FROM power_tp_rp_reserves
WHERE municipality = 'Екатеринбург'
AND geom IS NULL
AND name_norm IS NOT NULL
ORDER BY name_norm
""")
def geocode_tp_from_osm(db: Session | None = None, *, apply: bool = False) -> dict:
"""Матч name_norm ЕКБ-ТП/РП ↔ OSM-номер → UPDATE geom + geocode_status='osm'.
Дефолт dry-run (apply=False): считает счётчики + логирует первые 20 пар
номер(lat,lon), НО В БД НЕ ПИШЕТ. apply=True реально пишет.
Алгоритм:
1. fetch OSM-substation ЕКБ (traction уже отсеян).
2. индекс {номер координата}; дубль-номер ambiguous (не матчим).
3. SELECT ЕКБ-строк с geom IS NULL; matched, если номер есть в индексе.
4. UPDATE geom WHERE name_norm=... AND municipality='Екатеринбург' AND geom IS NULL
(idempotent). Per-row SAVEPOINT битый UPDATE не валит батч.
Returns счётчики {osm_features, numbered, matched, updated, ambiguous, applied}.
"""
owns_session = db is None
if db is None:
db = SessionLocal()
counts = {
"osm_features": 0,
"numbered": 0,
"matched": 0,
"updated": 0,
"ambiguous": 0,
"applied": apply,
}
sample: list[tuple[str, tuple[float, float]]] = []
try:
elements = fetch_osm_substations()
counts["osm_features"] = len(elements)
by_number, ambiguous = _index_osm_by_number(elements)
counts["numbered"] = len(by_number)
counts["ambiguous"] = len(ambiguous)
candidates: list[str] = [str(row[0]) for row in db.execute(_CANDIDATES_SQL).all()]
for name_norm in candidates:
coords = by_number.get(name_norm)
if coords is None:
continue
counts["matched"] += 1
lat, lon = coords
if len(sample) < 20:
sample.append((name_norm, (lat, lon)))
if not apply:
continue
try:
with db.begin_nested(): # SAVEPOINT — откат только этой строки
res = db.execute(_UPDATE_SQL, {"name_norm": name_norm, "lat": lat, "lon": lon})
if res.rowcount and res.rowcount > 0:
counts["updated"] += res.rowcount
except Exception as e:
logger.warning("osm_tp_geocoder UPDATE failed for %r: %s", name_norm, e)
if apply:
db.commit()
except Exception as e:
if apply:
db.rollback()
logger.exception("geocode_tp_from_osm: rolled back: %s", e)
raise
finally:
if owns_session:
db.close()
logger.info("geocode_tp_from_osm done (apply=%s): %s", apply, counts)
if not apply:
for number, (lat, lon) in sample:
logger.info(" sample: ТП/РП %s → (%.6f, %.6f)", number, lat, lon)
return counts
def _main(argv: list[str] | None = None) -> int:
"""CLI: OSM-геокод городских ТП/РП ЕКБ → geom. Dry-run по умолчанию.
Примеры:
python -m app.services.site_finder.osm_tp_geocoder # dry-run
python -m app.services.site_finder.osm_tp_geocoder --apply # пишет в БД
"""
parser = argparse.ArgumentParser(
description="OSM-геокод городских ТП/РП ЕКБ (#2119 B2): матч name_norm ↔ "
"OSM substation ref/name → geom + geocode_status='osm'."
)
parser.add_argument(
"--apply",
action="store_true",
help="Реально писать в БД. Без флага — dry-run (только счётчики + сэмпл).",
)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logger.info("osm_tp_geocoder result: %s", geocode_tp_from_osm(apply=args.apply))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(_main())

View file

@ -87,6 +87,37 @@ def _infer_development_type(max_floors: int | None) -> str:
return "high_rise"
def select_calibrated_price(
*,
market_price: dict[str, Any] | None,
geo_radius_price_median: float | None,
district_price_median: float | None,
) -> tuple[float | None, str]:
"""Выбрать откалиброванную цену продажи жилья + её источник (single source of truth).
Приоритет от точного рынка к нормативу (совпадает с исходной логикой
:func:`synthesize_parcel_financial`, вынесен сюда для переиспользования оптимизатором
программы, чтобы выбор цены не разошёлся):
1. ``market_price.median`` квартальная MV перепродаж ``objective_district_median``.
2. ``geo_radius_price_median`` медиана новостроек Objective в гео-радиусе
``objective_geo_radius``.
3. ``district_price_median`` name-match медиана админ-района ``district_reference``.
4. ничего из рынка ``(None, "class_norm")`` caller использует норматив класса.
Returns:
``(price, price_source)`` ``price`` может быть ``None`` (тогда source
``"class_norm"``), что сигналит caller-у про fallback на норматив класса.
"""
if market_price and _as_float(market_price.get("median")) is not None:
return _as_float(market_price.get("median")), "objective_district_median"
if _as_float(geo_radius_price_median) is not None:
return _as_float(geo_radius_price_median), "objective_geo_radius"
if district_price_median is not None:
return float(district_price_median), "district_reference"
return None, "class_norm"
def synthesize_teap_from_buildability(
*,
area_m2: float | None,
@ -238,18 +269,13 @@ def synthesize_parcel_financial(
# ── Цена продажи: quarter-MV → гео-радиус → district median (рыночный прокси) ─
# Приоритет от точного рынка к нормативу. Гео-радиус вставлен МЕЖДУ квартальной MV
# и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ.
price: float | None = None
price_source = "class_norm"
if market_price and _as_float(market_price.get("median")) is not None:
price = _as_float(market_price.get("median"))
price_source = "objective_district_median"
elif _as_float(geo_radius_price_median) is not None:
price = _as_float(geo_radius_price_median)
price_source = "objective_geo_radius"
elif district_price_median is not None:
price = float(district_price_median)
price_source = "district_reference"
# и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ. Логика
# вынесена в общий helper (single-source-of-truth с program_optimizer).
price, price_source = select_calibrated_price(
market_price=market_price,
geo_radius_price_median=geo_radius_price_median,
district_price_median=district_price_median,
)
housing_class = _infer_housing_class(price)
development_type = _infer_development_type(max_floors)
@ -331,6 +357,7 @@ def _as_int(value: Any) -> int | None:
__all__ = [
"select_calibrated_price",
"synthesize_parcel_financial",
"synthesize_teap_from_buildability",
]

View file

@ -0,0 +1,229 @@
"""Оптимизатор программы застройки — топ-N вариантов (класс × этажность) по NPV (#2181).
Когда финмодель участка (``financial_estimate`` в ``/analyze``) даёт ОТРИЦАТЕЛЬНЫЙ
вердикт (NPV < 0 или ROI < 0), девелоперу мало «не выгодно» важно честно показать,
существует ли ВООБЩЕ прибыльная раскладка на этом участке при других параметрах
(класс жилья + этажность), и если нет какие варианты «наименее убыточные».
Этот модуль перебирает небольшую сетку (класс × этажность), прогоняет каждый комбо
через ту же :func:`compute_financial`, что и основная финмодель, и возвращает топ-N по
NPV. Всё детерминированно и **быстро** (15 мс на 12 комбо считается СИНХРОННО в теле
запроса, без Celery), и **чисто** без БД / LLM / внешних API. Данные участка
(площадь, предельные параметры зоны, откалиброванная цена, стоимость земли, абсорбция)
прокидывает caller ровно те же входы, что использованы для основного
``financial_estimate`` (см. ``synthesize_parcel_financial``).
КРИТИЧНО ЕДИНАЯ ЦЕНА ВО ВСЕХ КОМБО
------------------------------------
Когда рыночная цена известна, ОДНА И ТА ЖЕ откалиброванная ``market_price_per_sqm`` идёт
во все комбо (класс двигает ТОЛЬКО себестоимость/эффективность/паркинг/ср.лот, но НЕ
цену продажи). Иначе «business» ложно выиграл бы за счёт норматива продажи 210k против
110k у эконома сравнивали бы цены, а не экономику застройки. Норматив класса
(``_SALE_PRICE_PER_SQM``) применяется ТОЛЬКО когда рынка нет то же поведение, что в
:func:`synthesize_parcel_financial`.
HONEST CAVEAT
-------------
Как и основная финмодель это оценка по МАКСИМАЛЬНОЙ застройке зоны (синтез ТЭП из
предельных параметров градрегламента), а НЕ по реальной проектной раскладке. Caveat
(``_FINANCIAL_CAVEAT``) кладётся в результат и обязан показываться в UI/PDF.
"""
from __future__ import annotations
import logging
from typing import Any, get_args
from app.services.generative.financial import HousingClass, compute_financial
from app.services.site_finder.parcel_financial import (
_FINANCIAL_CAVEAT,
_infer_development_type,
synthesize_teap_from_buildability,
)
logger = logging.getLogger(__name__)
# Классы жилья для перебора — весь набор (econom / comfort / business). Берём из
# Literal-типа HousingClass, чтобы сетка не разошлась с финмоделью при добавлении класса.
_CLASSES_GRID: tuple[HousingClass, ...] = get_args(HousingClass)
# Опорная сетка этажности (кламп по max_floors_allowed зоны). Значения — типовые
# высотные пороги РФ (5 — среднеэтажка, 9 — «хрущёвочный» потолок, 14 — высотка,
# 25 — башня); двигают GFA и development_type (порог spot/mid_rise/high_rise в
# _infer_development_type → длительность СМР в DCF-графике).
_FLOORS_GRID: tuple[int, ...] = (5, 9, 14, 25)
_DEFAULT_TOP_N: int = 3
def _build_floors_grid(max_floors_allowed: int | None) -> list[int]:
"""Сетка этажности, клампнутая по предельной этажности зоны.
* ``max_floors_allowed`` не задан (``None``) весь опорный набор (зона неизвестна,
перебираем всё).
* задан оставляем только этажи ``<= max_floors_allowed``.
* после клампа пусто (предел ниже минимального опорного, напр. 3) берём сам
``max_floors_allowed`` (единственная физически допустимая этажность).
"""
if max_floors_allowed is None:
return list(_FLOORS_GRID)
clamped = [f for f in _FLOORS_GRID if f <= max_floors_allowed]
if not clamped:
# Предел ниже минимального опорного (напр. 3 этажа) — считаем по самому пределу.
return [max_floors_allowed]
return clamped
def optimize_program(
*,
area_m2: float | None,
max_far: float | None,
max_building_pct: float | None,
max_floors_allowed: int | None,
land_cost_rub: float | None,
market_price_per_sqm: float | None,
price_source: str,
velocity_sqm_per_month: float | None,
top_n: int = _DEFAULT_TOP_N,
) -> dict[str, Any] | None:
"""Перебрать сетку (класс × этажность) → топ-N вариантов программы по NPV.
Прогоняет каждый комбо через :func:`compute_financial` (та же финмодель, что и
основной ``financial_estimate``) и ранжирует по NPV убыв. (тай-брейк ROI убыв.).
Args:
area_m2: площадь участка, кв.м. ``None``/<=0 ``None`` (нечего считать).
max_far: КСИТ/max_far зоны (руб/кв.м GFA). Задаёт GFA при синтезе ТЭП. Если
``None``, GFA выводится из ``max_building_pct × floors`` (по каждому комбо
своя этажность из сетки).
max_building_pct: % застройки зоны. Нужен вместе с этажностью, если нет ``max_far``.
max_floors_allowed: предельная этажность зоны кламп сетки этажности. ``None``
весь опорный набор.
land_cost_rub: стоимость участка (обычно кадастровая) land_cost. ``None`` 0.
market_price_per_sqm: ЕДИНАЯ откалиброванная рыночная цена продажи жилья во ВСЕ
комбо. ``None`` каждый комбо использует норматив своего класса
(``_SALE_PRICE_PER_SQM``) то же поведение, что в основной финмодели.
price_source: honest-метка источника цены (из ``select_calibrated_price``).
Игнорируется финмоделью, если ``market_price_per_sqm is None`` (форсится
``class_norm``).
velocity_sqm_per_month: темп абсорбции района (м²/мес) окно продаж DCF. ``None``
дефолт-30-мес график (``schedule_is_default``).
top_n: сколько лучших вариантов вернуть (по умолчанию 3).
Returns:
``dict`` с ключами ``variants`` (список топ-N), ``any_viable`` (хоть один вариант
с NPV > 0), ``grid_size`` (сколько комбо реально посчиталось), ``caveat``. Либо
``None``, если считать нельзя (нет площади / ни один комбо не дал ТЭП).
"""
if area_m2 is None or area_m2 <= 0:
return None
floors_grid = _build_floors_grid(max_floors_allowed)
variants: list[dict[str, Any]] = []
for housing_class in _CLASSES_GRID:
for floors in floors_grid:
variant = _evaluate_combo(
housing_class=housing_class,
floors=floors,
area_m2=area_m2,
max_far=max_far,
max_building_pct=max_building_pct,
land_cost_rub=land_cost_rub,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
velocity_sqm_per_month=velocity_sqm_per_month,
)
if variant is not None:
variants.append(variant)
if not variants:
# Ни один комбо не дал ТЭП (нет ни far, ни pct+floors) — считать нечего.
return None
# Ранжир по NPV убыв.; тай-брейк — ROI убыв. (при равном NPV предпочитаем
# эффективность капитала). При all-negative топ-N — «наименее убыточные».
variants.sort(key=lambda v: (v["npv_rub"], v["roi"]), reverse=True)
any_viable = any(v["npv_rub"] > 0 for v in variants)
return {
"variants": variants[:top_n],
"any_viable": any_viable,
"grid_size": len(variants),
# HEAVY caveat — тот же, что у основной финмодели (оценка по МАКС. застройке
# зоны, НЕ по реальной раскладке). Обязан показываться в UI/PDF.
"caveat": _FINANCIAL_CAVEAT,
}
def _evaluate_combo(
*,
housing_class: HousingClass,
floors: int,
area_m2: float,
max_far: float | None,
max_building_pct: float | None,
land_cost_rub: float | None,
market_price_per_sqm: float | None,
price_source: str,
velocity_sqm_per_month: float | None,
) -> dict[str, Any] | None:
"""Посчитать один комбо (класс × этажность) → компактный вариант программы.
Сбой одного комбо (например, ТЭП не вывелся) НЕ валит весь грид логируется и
возвращается ``None`` (caller скипает). Это тот же «пояс безопасности», что у
``financial_estimate`` в analyze-эндпоинте.
"""
try:
# development_type по ЭТАЖНОСТИ комбо (не по зоне) — этаж двигает график СМР в DCF.
development_type = _infer_development_type(floors)
teap = synthesize_teap_from_buildability(
area_m2=area_m2,
max_far=max_far,
max_building_pct=max_building_pct,
max_floors=floors,
housing_class=housing_class,
)
if teap is None:
return None
fin = compute_financial(
teap=teap,
housing_class=housing_class,
land_cost_rub=land_cost_rub,
# ЕДИНАЯ цена во все комбо (когда рынок есть) — класс НЕ двигает цену продажи.
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
development_type=development_type,
market_velocity_sqm_per_month=velocity_sqm_per_month,
)
return {
"housing_class": housing_class,
"floors": floors,
"development_type": development_type,
"npv_rub": fin.npv_rub,
"irr": fin.irr,
"roi": fin.roi,
"net_profit_rub": fin.net_profit_rub,
"residential_sqm": teap.residential_area_sqm,
"apartments_count": teap.apartments_count,
# honest-поля источника/графика цены — те же флаги, что в основной финмодели.
"price_per_sqm_used": fin.price_per_sqm_used,
"price_source": fin.price_source,
"schedule_is_default": fin.schedule_is_default,
}
except Exception:
# Один сбойный комбо не валит грид — логируем и скипаем (не глотаем молча).
logger.exception(
"program_optimizer combo failed: class=%s floors=%d area=%.0f",
housing_class,
floors,
area_m2,
)
return None
__all__ = ["optimize_program"]

View file

@ -1300,14 +1300,51 @@ def _get_engineering_structures_by_boundary(
except (ValueError, json.JSONDecodeError):
geom_dict = {}
# Реальные атрибуты НСПД-дампа (cat 36328) лежат в props["options"]:
# params_name — имя с типом/напряжением («КЛ 10кВ ТП 1077-ТП 5419»)
# params_purpose — назначение («1.1. Сооружения электроэнергетики»)
# object_type_value — «Сооружение»
# address_readable_address, cad_number, год/протяжённость/собственность
# Top-level props их НЕ содержит (только опаковые category/subcategory),
# поэтому раньше попап показывал generic «Объект». Читаем options.* с
# fallback на top-level/legacy ключи.
opts: dict[str, Any] = props["options"] if isinstance(props.get("options"), dict) else {}
name = (
opts.get("params_name")
or props.get("name")
or props.get("object_name")
or opts.get("object_type_value")
)
ptype = (
opts.get("params_purpose")
or opts.get("object_type_value")
or props.get("purpose")
or props.get("object_type")
or props.get("type_zone")
)
cad = opts.get("cad_number") or props.get("cad_num") or props.get("cadastral_number")
address = (
opts.get("address_readable_address")
or props.get("readable_address")
or props.get("address")
)
clean_type = _clean_struct_str(ptype)
# purpose («1.1. Сооружения электроэнергетики») — первый непустой из
# params_purpose / object_type_value. Показываем ТОЛЬКО когда отличается от
# уже вычисленного type (иначе дубль-шум в попапе → оставляем None).
purpose_raw = opts.get("params_purpose") or opts.get("object_type_value")
clean_purpose = _clean_struct_str(purpose_raw)
purpose = clean_purpose if clean_purpose and clean_purpose != clean_type else None
result.append(
{
"name": props.get("name") or props.get("object_name"),
"type": props.get("purpose") or props.get("object_type") or props.get("type_zone"),
"cad_num": props.get("cad_num") or props.get("cadastral_number"),
"name": _clean_struct_str(name),
"type": clean_type,
"cad_num": _clean_struct_str(cad),
"purpose": purpose,
"distance_to_boundary_m": round(distance_m, 1),
"geometry_geojson": geom_dict,
"readable_address": props.get("readable_address") or props.get("address"),
"readable_address": _clean_struct_str(address),
"characteristics": _build_structure_characteristics(opts),
"raw_props": props,
"source": "nspd_36328",
}
@ -1315,6 +1352,44 @@ def _get_engineering_structures_by_boundary(
return result
def _clean_struct_str(value: Any) -> str | None:
"""Нормализовать строковый атрибут НСПД: strip, None для пустого / «-»."""
if value is None:
return None
s = str(value).strip()
return s if s and s != "-" else None
def _build_structure_characteristics(opts: dict[str, Any]) -> str | None:
"""Собрать читаемую строку характеристик из options НСПД-дампа (cat 36328).
Год постройки · протяжённость · форма собственности то, что реально есть в
дампе. Чисто презентационная агрегация; None, если ничего нет.
"""
if not isinstance(opts, dict):
return None
parts: list[str] = []
year = _clean_struct_str(opts.get("params_year_built"))
if year:
parts.append(f"{year} г.")
ext = opts.get("params_extension")
try:
ext_val = float(ext) if ext not in (None, "") else 0.0
except (TypeError, ValueError):
ext_val = 0.0
if ext_val > 0:
parts.append(f"протяжённость {round(ext_val)} м")
ownership = _clean_struct_str(opts.get("ownership_type"))
if ownership:
low = ownership.lower()
parts.append(
f"{low} собственность"
if low in ("частная", "государственная", "муниципальная", "смешанная")
else ownership
)
return " · ".join(parts) if parts else None
def _get_zouit_engineering_overlaps(
db: Session,
quarter: str,

View file

@ -0,0 +1,408 @@
"""Загрузчик резервов свободной мощности Россети Урал из xlsx-раскрытия (#2119).
Два xlsx-файла раскрытия информации ФАС (rosseti-ural.ru):
(а) «свободная мощность по ЦП» апдейт power_supply_centers
(б) «свободная мощность для ТП по ПС и РП <35» UPSERT power_tp_rp_reserves
Источник ГЕО-БЛОКИРУЕТ non-RU IP РАБОТАЕТ НА ПРОДЕ (Celery / docker exec).
Структуры xlsx ВЕРИФИЦИРОВАНЫ (см. docstring-и функций): фиксированные offset'ы
строк заголовков/нумерации/данных. openpyxl read_only для памяти на 10k+ строках.
Матч ЦП-резерва к power_supply_centers по ОБЩЕМУ нормализатору normalize_sc_name.
"""
import io
import logging
import re
from datetime import date
from urllib.parse import unquote
import httpx
from openpyxl import load_workbook
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.site_finder.rosseti_wfs_loader import normalize_sc_name
logger = logging.getLogger(__name__)
# Punycode-host не требуется — rosseti-ural.ru латиница.
DISCLOSURE_URL = "https://rosseti-ural.ru/company/disclosure/monopoly/characteristic/bandwidth/"
_HTTP_TIMEOUT = 60
# «н/д» / «-» / пусто → None (нет данных).
_NODATA_TOKENS = frozenset({"", "н/д", "нд", "n/a", "na", "-", "", "", "нет данных"})
# Дата актуальности «на 30.06.2026» из имени файла или заголовка листа.
_ASOF_RE = re.compile(r"на\s+(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})", re.IGNORECASE)
# Дата в имени файла для выбора «свежайшего» (DD.MM.YYYY / DD-MM-YYYY / YYYY-MM-DD).
_FILE_DATE_RE = re.compile(
r"(\d{1,2})[.\-](\d{1,2})[.\-](\d{4})|(\d{4})[.\-](\d{1,2})[.\-](\d{1,2})"
)
# Порог кВА-санитайза: у ТП установленная мощность в МВА физически <2.5;
# значение выше — почти наверняка забито в кВА (÷1000). РП не трогаем.
_TP_KVA_THRESHOLD_MVA = 2.5
def parse_reserve_number(value: object) -> float | None:
"""Парсит числовую ячейку резерва/мощности xlsx → float.
«н/д»/пусто None. Запятая-десятичный точка. Сохраняет знак
(резервы бывают отрицательными = дефицит). Нечисловой мусор None.
"""
if value is None:
return None
if isinstance(value, int | float):
return float(value)
s = str(value).strip().lower()
if s in _NODATA_TOKENS:
return None
# Убираем пробелы-разделители тысяч (в т.ч. неразрывные) и нормализуем запятую.
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
# Оставляем ведущий минус + цифры + точку.
m = re.match(r"^-?\d+(?:\.\d+)?$", s)
if not m:
return None
try:
return float(s)
except ValueError:
return None
def parse_asof_date(*sources: str | None) -> date | None:
"""Ищет дату «на DD.MM.YYYY» в переданных строках (имя файла / заголовок листа).
Возвращает первую найденную date. None если нигде нет.
"""
for src in sources:
if not src:
continue
m = _ASOF_RE.search(src)
if m:
day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
try:
return date(year, month, day)
except ValueError:
continue
return None
def _file_date_key(filename: str) -> tuple[int, int, int]:
"""Ключ сортировки «свежести» по дате в имени файла. Нет даты → (0,0,0)."""
m = _FILE_DATE_RE.search(filename)
if not m:
return (0, 0, 0)
if m.group(1): # DD-MM-YYYY
return (int(m.group(3)), int(m.group(2)), int(m.group(1)))
return (int(m.group(4)), int(m.group(5)), int(m.group(6))) # YYYY-MM-DD
def sanitize_tp_capacity_mva(installed: float | None, is_rp: bool) -> tuple[float | None, bool]:
"""кВА-санитайз установленной мощности ТП.
Часть строк ошибочно забивает кВА в колонку МВА. Если это ТП (не РП) и
installed > порога (2.5 МВА физически невозможно для ТП) считаем что это
кВА и делим на 1000. Возвращает (значение_МВА, был_ли_санитайз).
РП не трогаем (у РП мощность легитимно бывает высокой).
"""
if installed is None or is_rp:
return installed, False
if installed > _TP_KVA_THRESHOLD_MVA:
return installed / 1000.0, True
return installed, False
def fetch_disclosure_links() -> dict[str, str]:
"""Скрейпит страницу раскрытия → ссылки на СВЕЖАЙШИЕ xlsx (ЦП + ТП/РП).
RUN-ON-PROD (гео-блок). GET с follow_redirects, regex по href .xlsx, фильтр
Свердловск-related, выбор LATEST по дате-в-имени для двух типов файлов.
Returns: {"cp": url|'', "tp_rp": url|''} пустые если не нашлось.
"""
resp = httpx.get(DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True)
resp.raise_for_status()
html = resp.text
# href на .xlsx (относительные и абсолютные).
hrefs = re.findall(r'href=["\']([^"\']+?\.xlsx)["\']', html, re.IGNORECASE)
base = "https://rosseti-ural.ru"
# ВАЖНО (прод-инцидент 2026-07-02): href'ы URL-энкоженные (кириллица как
# %D0%A1...) — фильтр по СЫРОЙ строке никогда не матчился → 'no cp link
# found'. Фильтруем/классифицируем/датируем по ДЕКОДИРОВАННОМУ имени, а
# скачиваем по сырому href (энкоженный URL корректен для httpx).
cp_candidates: list[tuple[str, str]] = [] # (url, decoded_low)
tp_candidates: list[tuple[str, str]] = []
for href in hrefs:
decoded = unquote(href).lower()
# Свердловск-фильтр: «Свердловской области» (ЦП) / «Свердловэнерго» (ТП/РП).
if "свердлов" not in decoded and "sverdl" not in decoded:
continue
url = href if href.startswith("http") else base + href
if "по цп" in decoded:
cp_candidates.append((url, decoded))
elif "для тп" in decoded and ("рп" in decoded or "35" in decoded):
tp_candidates.append((url, decoded))
def _latest(cands: list[tuple[str, str]]) -> str:
# Дата «на DD.MM.YYYY» — в декодированном имени файла.
return max(cands, key=lambda c: _file_date_key(c[1]))[0] if cands else ""
result = {"cp": _latest(cp_candidates), "tp_rp": _latest(tp_candidates)}
logger.info(
"rosseti disclosure links: cp=%r tp_rp=%r (from %d hrefs)",
result["cp"],
result["tp_rp"],
len(hrefs),
)
return result
def _cell(row: tuple, idx: int) -> object:
"""Безопасно достаёт ячейку row по 0-based индексу (None если за границей)."""
return row[idx] if idx < len(row) else None
def load_cp_reserves(db: Session, xlsx_bytes: bytes, filename: str = "") -> dict[str, int]:
"""Апдейт power_supply_centers резервами по ЦП из xlsx-раскрытия.
ВЕРИФИЦИРОВАННАЯ структура ЦП-файла: заголовки строки 5-6, строка 7 =
нумерация столбцов, ДАННЫЕ С СТРОКИ 8. Колонки (1-based Excel 0-based idx):
B(1)=ЦП name, C(2)=ПО (баланс), E(4)=municipality, F(5)=voltage class,
G(6)=installed МВА, H(7)=current load МВА, I(8)=reserve МВА.
Матч к power_supply_centers по sc_name_norm (тот же нормализатор!)
installed/current/reserve/capacity_source='cp_35_110'. Unmatched лог count.
Returns: счётчики rows/matched/unmatched.
"""
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
ws = wb.active
asof = parse_asof_date(filename, ws.title if ws is not None else None)
rows_seen = 0
matched = 0
unmatched = 0
unmatched_names: list[str] = []
try:
# min_row=8 → данные с 8-й строки (1-based). iter_rows отдаёт кортежи values.
for row in ws.iter_rows(min_row=8, values_only=True):
cp_name = _cell(row, 1) # B
if cp_name is None or not str(cp_name).strip():
continue
rows_seen += 1
name_norm = normalize_sc_name(str(cp_name))
installed = parse_reserve_number(_cell(row, 6)) # G
current = parse_reserve_number(_cell(row, 7)) # H
reserve = parse_reserve_number(_cell(row, 8)) # I
municipality = _cell(row, 4) # E
try:
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
res = db.execute(
text("""
UPDATE power_supply_centers
SET installed_capacity_mva = :installed,
current_load_mva = :current,
reserve_mva = :reserve,
reserve_asof = :asof,
capacity_source = 'cp_35_110',
municipality = COALESCE(municipality, :municipality)
WHERE sc_name_norm = :name_norm
"""),
{
"installed": installed,
"current": current,
"reserve": reserve,
"asof": asof,
"municipality": (str(municipality).strip() if municipality else None),
"name_norm": name_norm,
},
)
if res.rowcount and res.rowcount > 0:
matched += res.rowcount
else:
unmatched += 1
if len(unmatched_names) < 20:
unmatched_names.append(str(cp_name).strip())
except Exception as e:
logger.warning("cp_reserve update failed for %r: %s", cp_name, e)
unmatched += 1
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_cp_reserves: outer tx rolled back: %s", e)
raise
finally:
wb.close()
logger.info(
"load_cp_reserves done: rows=%d matched=%d unmatched=%d (sample unmatched=%s)",
rows_seen,
matched,
unmatched,
unmatched_names[:10],
)
return {"rows": rows_seen, "matched": matched, "unmatched": unmatched}
def _is_rp(name: str) -> bool:
"""Строка про РП (распределительный пункт), а не ТП? Влияет на кВА-санитайз."""
return bool(re.search(r"\bрп\b|рп[\s-]?\d", name.lower()))
def load_tp_rp_reserves(db: Session, xlsx_bytes: bytes, filename: str = "") -> dict[str, int]:
"""UPSERT резервов ТП/РП <35 кВ в power_tp_rp_reserves из xlsx-раскрытия.
ВЕРИФИЦИРОВАННАЯ структура: заголовки строки 6-7, нумерация строка 8,
ДАННЫЕ С СТРОКИ 9 (~10 741 строк). Колонки (0-based idx):
0=name (ТП-721), 1=РЭС, 2=region, 3=municipality/settlement,
4=voltage kV, 5=installed МВА, 6=load МВА, 7=reserve МВА.
кВА-санитайз: у ТП (не РП) installed > 2.5 делим на 1000 (лог count).
«н/д» NULL. UPSERT по UNIQUE(name_norm, municipality, voltage_kv).
Per-row SAVEPOINT. Returns: счётчики.
"""
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
ws = wb.active
asof = parse_asof_date(filename, ws.title if ws is not None else None)
rows_seen = 0
inserted = 0
updated = 0
skipped = 0
kva_sanitized = 0
try:
for row in ws.iter_rows(min_row=9, values_only=True):
name = _cell(row, 0)
if name is None or not str(name).strip():
continue
name_str = str(name).strip()
rows_seen += 1
res_unit = _cell(row, 1)
municipality = _cell(row, 3)
voltage_kv = _cell(row, 4)
installed = parse_reserve_number(_cell(row, 5))
current = parse_reserve_number(_cell(row, 6))
reserve = parse_reserve_number(_cell(row, 7))
installed, sanitized = sanitize_tp_capacity_mva(installed, _is_rp(name_str))
if sanitized:
kva_sanitized += 1
params = {
"name": name_str,
"name_norm": normalize_sc_name(name_str),
"res_unit": str(res_unit).strip() if res_unit else None,
"municipality": str(municipality).strip() if municipality else None,
"voltage_kv": str(voltage_kv).strip() if voltage_kv else None,
"installed": installed,
"current": current,
"reserve": reserve,
"asof": asof,
}
try:
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
result = db.execute(
# settlement намеренно = municipality: в xlsx Россетей это ОДНА
# колонка «МО/населённый пункт»; отдельного settlement источник
# не даёт (колонка в схеме — задел под Фазу B/геокод).
text("""
INSERT INTO power_tp_rp_reserves
(name, name_norm, res_unit, municipality, settlement,
voltage_kv, installed_capacity_mva, current_load_mva,
reserve_mva, reserve_asof, fetched_at)
VALUES (
:name, :name_norm, :res_unit, :municipality, :municipality,
:voltage_kv, :installed, :current,
:reserve, :asof, NOW()
)
ON CONFLICT (name_norm, municipality, voltage_kv) DO UPDATE
SET name = EXCLUDED.name,
res_unit = EXCLUDED.res_unit,
installed_capacity_mva = EXCLUDED.installed_capacity_mva,
current_load_mva = EXCLUDED.current_load_mva,
reserve_mva = EXCLUDED.reserve_mva,
reserve_asof = EXCLUDED.reserve_asof,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
"""),
params,
).scalar()
if result:
inserted += 1
else:
updated += 1
except Exception as e:
logger.warning("tp_rp_reserve upsert failed for %r: %s", name_str, e)
skipped += 1
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_tp_rp_reserves: outer tx rolled back: %s", e)
raise
finally:
wb.close()
result_dict = {
"rows": rows_seen,
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"kva_sanitized": kva_sanitized,
}
logger.info("load_tp_rp_reserves done: %s", result_dict)
return result_dict
def load_all_reserves(db: Session | None = None) -> dict[str, dict]:
"""Полный прогон: discovery ссылок + скачивание + загрузка ЦП и ТП/РП резервов.
RUN-ON-PROD. Каждый шаг graceful: сбой одного файла не валит другой.
Returns: {"cp": {...}, "tp_rp": {...}} со счётчиками (или {"error": ...}).
"""
owns_session = db is None
if db is None:
db = SessionLocal()
out: dict[str, dict] = {}
try:
links = fetch_disclosure_links()
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
if links.get("cp"):
try:
r = client.get(links["cp"])
r.raise_for_status()
# unquote: дата «на DD.MM.YYYY» ищется в ДЕКОДИРОВАННОМ имени.
out["cp"] = load_cp_reserves(db, r.content, unquote(links["cp"]))
except Exception as e:
logger.exception("load_all_reserves: cp file failed: %s", e)
out["cp"] = {"error": str(e)}
else:
out["cp"] = {"error": "no cp link found"}
if links.get("tp_rp"):
try:
r = client.get(links["tp_rp"])
r.raise_for_status()
out["tp_rp"] = load_tp_rp_reserves(db, r.content, unquote(links["tp_rp"]))
except Exception as e:
logger.exception("load_all_reserves: tp_rp file failed: %s", e)
out["tp_rp"] = {"error": str(e)}
else:
out["tp_rp"] = {"error": "no tp_rp link found"}
finally:
if owns_session:
db.close()
logger.info("load_all_reserves done: %s", out)
return out

View file

@ -0,0 +1,269 @@
"""Загрузчик центров питания (ЦП) Россети из WFS — свободная мощность §3 (#2119).
No-B2B верифицированный источник: WFS геосервера портала-тп.рф отдаёт ~488
центров питания (ПС 35/110 кВ) по Свердловской области с координатами (WGS84,
округлены ~100 м), классом напряжения и индексом загрузки (открыт/ограничен/
закрыт для техприсоединения). UPSERT-ит в ``power_supply_centers``.
Источник ГЕО-БЛОКИРУЕТ non-RU IP загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly /
manual docker exec). Punycode-host ОБЯЗАТЕЛЕН кириллический алиас портал-тп.рф
не резолвится корректно из curl/httpx.
Структура зеркалит utility_infrastructure_loader: httpx с явным таймаутом,
per-row SAVEPOINT при UPSERT (битая фича не валит weekly-sync).
Также экспортирует ``normalize_sc_name`` общий нормализатор имён ЦП, которым
пользуется и ``rosseti_reserve_loader`` (матч xlsx-резерва по имени), и endpoint.
"""
import hashlib
import json
import logging
import re
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
# Punycode-host ОБЯЗАТЕЛЕН (кириллический алиас портал-тп.рф не для curl/httpx).
# xn----7sb7akeedqd.xn--p1ai == портал-тп.рф.
WFS_BASE_URL = "https://xn----7sb7akeedqd.xn--p1ai/geoserver/wfs"
# Слой центров питания (полный вид с резервами/напряжением).
_WFS_TYPE_NAME = "gisosslabs:sc_points_fullview"
# CQL-фильтр по региону — только Свердловская область.
_WFS_CQL_FILTER = "rg_code='SverdlovskOblast'"
# Таймаут WFS-запроса (сек). ~488 фич — с запасом.
_WFS_TIMEOUT = 60
# sc_indexload_id (справочник Россети) → индекс загрузки ЦП. Неизвестный id →
# None (сырое значение остаётся в raw). Верифицированные id из справочника:
_INDEXLOAD_MAP: dict[int, str] = {
256184: "open", # открыт для техприсоединения (есть резерв)
256186: "limited", # ограниченно
256188: "closed", # закрыт (резерва нет)
}
# «ПС»/«подстанция» + класс напряжения-префикс — срезаем при нормализации имени.
# Оба компонента опциональны, но хотя бы один должен присутствовать (иначе матч
# пустой и sub ничего не делает). Примеры:
# «ПС 110/35/10 Нижне-Исетская» → «нижне-исетская»
# «ПС Уктус» → «уктус» (только «ПС», без напряжения)
# «110/10 Уктус» → «уктус» (только напряжение)
_VOLTAGE_PREFIX_RE = re.compile(
r"^\s*(?:(?:пс|подстанци\w*)\s*)?(?:[\d/.,]+\s*(?:кв)?\s*)?",
re.IGNORECASE,
)
# Класс напряжения внутри имени ЦП, напр. «110/35/10» или «110/10».
_VOLTAGE_CLASS_RE = re.compile(r"\b(\d{1,3}(?:[/.]\d{1,3}){1,3})\b")
_MULTISPACE_RE = re.compile(r"\s+")
def normalize_sc_name(name: str | None) -> str:
"""Нормализует имя ЦП для матча xlsx-резерва с WFS-фичей.
Правила: lower, ёе, срез «ПС»/подстанция + класс напряжения-префикса,
удаление кавычек, схлопывание тире/пробелов. Пусто ''.
Examples:
«ПС 110/35/10 Нижне-Исетская» «нижне-исетская»
«Нижне Исетская» «нижне-исетская»
«ПС "Уктус"» «уктус»
"""
if not name:
return ""
s = name.strip().lower().replace("ё", "е")
s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "")
# Срез voltage/«ПС»-префикса в начале имени (не трогаем цифры внутри имени).
s = _VOLTAGE_PREFIX_RE.sub("", s, count=1)
# Схлопываем все виды тире (—, , -) в одиночный дефис, пробелы вокруг него убираем.
s = re.sub(r"\s*[—–-]\s*", "-", s)
s = _MULTISPACE_RE.sub(" ", s).strip()
return s
def parse_voltage_class(name: str | None) -> str | None:
"""Извлекает класс напряжения из текста имени ЦП («ПС 110/35/10 …» → '110/35/10').
Возвращает первую последовательность вида «NNN/NNN[/NNN]». None если нет.
"""
if not name:
return None
m = _VOLTAGE_CLASS_RE.search(name)
if not m:
return None
return m.group(1).replace(".", "/")
def _stable_external_id(feature: dict, props: dict) -> str:
"""Стабильный external_id фичи: feature['id'] или хэш ключевых полей.
WFS обычно отдаёт стабильный ``feature['id']``; если его нет детерминированный
sha1 по (sc_name, координаты) чтобы UPSERT оставался идемпотентным.
"""
fid = feature.get("id")
if fid:
return str(fid)
geom = feature.get("geometry") or {}
coords = geom.get("coordinates")
seed = f"{props.get('sc_name', '')}|{coords}"
# sha1 здесь — стабильный дедуп-id фичи, не криптография.
return "h:" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
def _map_load_index(props: dict) -> str | None:
"""sc_indexload_id → 'open'|'limited'|'closed'|None (неизвестный → None)."""
raw = props.get("sc_indexload_id")
if raw is None:
return None
try:
return _INDEXLOAD_MAP.get(int(raw))
except (TypeError, ValueError):
return None
def fetch_power_supply_centers() -> list[dict]:
"""Тянет WFS-фичи центров питания Свердловской области (GeoJSON Feature-list).
RUN-ON-PROD: источник гео-блокирует non-RU IP. httpx с явным таймаутом,
verify по умолчанию (валидный TLS у геосервера). Возвращает list feature-dict'ов
``{"id", "geometry": {...}, "properties": {...}}``.
"""
params = {
"service": "WFS",
"version": "1.1.0",
"request": "GetFeature",
"typeName": _WFS_TYPE_NAME,
"outputFormat": "json",
"CQL_FILTER": _WFS_CQL_FILTER,
}
resp = httpx.get(WFS_BASE_URL, params=params, timeout=_WFS_TIMEOUT)
resp.raise_for_status()
data = resp.json()
features: list[dict] = (data or {}).get("features") or []
logger.info("rosseti_wfs: загружено центров питания=%d", len(features))
return features
def _point_geom_sql(feature: dict) -> tuple[str, dict] | None:
"""SQL-выражение + params для geom из GeoJSON Point. None — если не Point/нет коорд."""
geom = feature.get("geometry") or {}
if geom.get("type") != "Point":
return None
coords = geom.get("coordinates")
if not coords or len(coords) < 2:
return None
lon, lat = coords[0], coords[1]
if lon is None or lat is None:
return None
return "ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)", {"lon": lon, "lat": lat}
def load_power_supply_centers(db: Session | None = None) -> dict[str, int]:
"""Тянет WFS + UPSERT-ит центры питания в power_supply_centers.
UPSERT по UNIQUE(source, external_id). Per-row SAVEPOINT битая фича не валит
weekly-sync. ``db`` принимается для совместимости сигнатуры; если None своя
SessionLocal (WFS-fetch + запись в БД sync, как utility_infrastructure_loader).
Returns: счётчики fetched/inserted/updated/skipped.
"""
features = fetch_power_supply_centers()
owns_session = db is None
if db is None:
db = SessionLocal()
inserted = 0
updated = 0
skipped = 0
fetched = len(features)
try:
for feature in features:
props: dict = feature.get("properties") or {}
sc_name = props.get("sc_name")
if not sc_name:
skipped += 1
continue
geom_pair = _point_geom_sql(feature)
geom_sql = geom_pair[0] if geom_pair else "NULL"
geom_params = geom_pair[1] if geom_pair else {}
params: dict = {
"source": "rosseti_wfs",
"external_id": _stable_external_id(feature, props),
"sc_name": sc_name,
"sc_name_norm": normalize_sc_name(sc_name),
"dzo_name": props.get("dzo_name"),
"org_name": props.get("org_name"),
"branch_name": props.get("br_name"),
"voltage_class": parse_voltage_class(sc_name),
"load_index": _map_load_index(props),
"municipality": props.get("municipality"),
"place_city": props.get("sc_placecity"),
"raw": json.dumps(feature, ensure_ascii=False),
**geom_params,
}
try:
with db.begin_nested(): # SAVEPOINT — откат только этой фичи
result = db.execute(
text(f"""
INSERT INTO power_supply_centers
(source, external_id, sc_name, sc_name_norm, dzo_name,
org_name, branch_name, voltage_class, load_index,
municipality, place_city, geom, raw, fetched_at)
VALUES (
:source, :external_id, :sc_name, :sc_name_norm, :dzo_name,
:org_name, :branch_name, :voltage_class, :load_index,
:municipality, :place_city, {geom_sql},
CAST(:raw AS jsonb), NOW()
)
ON CONFLICT (source, external_id) DO UPDATE
SET sc_name = EXCLUDED.sc_name,
sc_name_norm = EXCLUDED.sc_name_norm,
dzo_name = EXCLUDED.dzo_name,
org_name = EXCLUDED.org_name,
branch_name = EXCLUDED.branch_name,
voltage_class = EXCLUDED.voltage_class,
load_index = EXCLUDED.load_index,
municipality = EXCLUDED.municipality,
place_city = EXCLUDED.place_city,
geom = EXCLUDED.geom,
raw = EXCLUDED.raw,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
"""),
params,
).scalar()
if result:
inserted += 1
else:
updated += 1
except Exception as e:
logger.warning("rosseti_wfs upsert failed for %r: %s", sc_name, e)
skipped += 1
db.commit()
except Exception as e:
db.rollback()
logger.exception("rosseti_wfs: unexpected error, outer tx rolled back: %s", e)
raise
finally:
if owns_session:
db.close()
result_dict = {
"fetched": fetched,
"inserted": inserted,
"updated": updated,
"skipped": skipped,
}
logger.info("rosseti_wfs load done: %s", result_dict)
return result_dict

View file

@ -184,24 +184,47 @@ def _l2_confidence(n_objects_total: int, n_objects_with_free_flats: int) -> Conf
# Доступно = выставлено и НЕ продано. is_sold распознаём И флагом, И статусом 'продан'
# / наличием contract_date (Объектив заполняет неконсистентно — зеркало _STOCK_SQL).
# Группируем по (district, class). class — lowercase в lots (м.68 коммент).
#
# #1964: дедуп до ФИЗИЧЕСКОГО лота. Сырая objective_lots раздута ~2.91× (мульти
# objective_lot_id на один физлот через пере-листинги) → units_available по району
# был завышен ~3-4.6× (прод: Юго-Западный комфорт 58 606 → 12 620) → дефицит-форсайт
# переоценивал открытое предложение L1.
#
# Перф-батч #2 (#1953-followup): дедупим INLINE через DISTINCT ON (physflat-ключ,
# последний снапшот), НЕ через v_objective_lots_latest. Причина: view селектит ol.* и
# его DISTINCT ON — оптимизационный барьер: WHERE снаружи (premise_kind/district) НЕ
# проталкивается ниже DISTINCT ON → планировщик материализует ВСЮ таблицу (1.76M,
# Parallel Seq Scan width≈891 + external merge sort 76-101 МБ/воркер) ПЕРЕД фильтром
# (~6.0 s, прод-EXPLAIN). Inline протолкивает СТАБИЛЬНЫЕ фильтры (premise_kind, district
# — НЕ меняются между снапшотами физлота: премайз-кайнд 0 mixed-групп на проде, район ~4
# transient на 605k) В WHERE ДО DISTINCT ON → bitmap по objective_lots_district_class_idx
# + узкий sort (~5 МБ/воркер) ~0.47 s district-scoped (≈13× быстрее; EKB-wide остаётся
# seq-scan, но width≈99 вместо 891 → ~4.6 s). Эквивалентность view-vs-inline доказана
# бит-в-бит на проде (все district×class пары, 0 расхождений) — зеркало parcels.py.
# sold_now (is_sold/contract_date/status='продан') — МЕНЯЮЩИЕСЯ атрибуты → считаем
# ПОСЛЕ дедупа (на последнем снапшоте), как делал view.
_L1_OPEN_SQL = text(
"""
WITH lots AS (
SELECT
ol.district,
ol.class AS obj_class,
ol.area_pd,
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
district,
class AS obj_class,
area_pd,
(
ol.is_sold IS TRUE
OR ol.contract_date IS NOT NULL
OR LOWER(COALESCE(ol.status, '')) = 'продан'
is_sold IS TRUE
OR contract_date IS NOT NULL
OR LOWER(COALESCE(status, '')) = 'продан'
) AS sold_now
FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind
FROM objective_lots
WHERE premise_kind = CAST(:premise_kind AS text)
AND (
CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
OR district = ANY(CAST(:districts AS text[]))
)
ORDER BY project_name, corpus_name, section, floor,
lot_number, snapshot_date DESC, id DESC
)
SELECT
district,

View file

@ -1,7 +1,7 @@
"""Загрузчик OSM инженерных сетей («инженерные сети») из Overpass API (#1746).
No-B2B open-data путь: тянет ЛЭП / подстанции / трубопроводы (газ/вода) / ЦТП /
вышки связи через Overpass для bbox ЕКБ и UPSERT-ит в
No-B2B open-data путь: тянет ЛЭП / подстанции / трубопроводы (газ/вода/теплотрассы) /
ЦТП / вышки связи через Overpass для bbox ЕКБ и UPSERT-ит в
``osm_utility_infrastructure_ekb``. Живой тест дал 3081+ utility-элементов ЕКБ.
Структура зеркалит ``noise_loader.py``: httpx-клиент с явным UA + таймаутом,
@ -22,6 +22,7 @@ import asyncio
import json
import logging
import random
import re
import httpx
from sqlalchemy import text
@ -72,8 +73,8 @@ _HEADERS = {
# Описание запросов: (overpass_key, overpass_value, el_type, infrastructure_kind).
# el_type: 'way' (out geom) | 'node' (out body) | 'nwr' (out geom, node+way).
# substance: для pipeline-веток газ/вода дополнительно фильтруем по substance,
# чтобы не смешивать водопровод с газопроводом — см. _SUBSTANCE_FILTER.
# substance: generic man_made=pipeline фильтруем+классифицируем по substance —
# газопровод vs теплотрасса, см. _SUBSTANCE_FILTER / _PIPELINE_SUBSTANCE_KIND.
# Виды сети (infrastructure_kind):
# power | water | gas | heat | communication | sewage
_UTILITY_QUERIES: list[tuple[str, str, str, str]] = [
@ -88,8 +89,13 @@ _UTILITY_QUERIES: list[tuple[str, str, str, str]] = [
("pipeline", "water", "way", "water"),
# gas — магистральные газопроводы (way), pipeline substance=gas
("pipeline", "gas", "way", "gas"),
("man_made", "pipeline", "way", "gas"), # substance=gas фильтруется ниже
# heat — ЦТП / теплопункты (sparse — это нормально)
# generic man_made=pipeline тянет ВСЕ трубы; substance разводит их на
# gas / heat (см. _PIPELINE_SUBSTANCE_KIND). kind ниже — дефолтный для строки
# (gas), реальный вид переопределяется по substance в _upsert_elements.
("man_made", "pipeline", "way", "gas"),
# heat — теплотрассы (man_made=pipeline substance∈{heat,hot_water,steam},
# разводятся из строки выше) + ЦТП / теплопункты (man_made=heat_substation,
# в ЕКБ sparse/0 — это нормально; линии-теплотрассы = единственный гео-источник §3).
("man_made", "heat_substation", "nwr", "heat"),
# communication — вышки связи (точка/площадка) tower:type=communication
("man_made", "tower", "nwr", "communication"),
@ -98,13 +104,46 @@ _UTILITY_QUERIES: list[tuple[str, str, str, str]] = [
("man_made", "wastewater_plant", "nwr", "sewage"),
]
# Для generic man_made=pipeline нужно различать вещество по тегу `substance`
# (или legacy `type`): иначе под газ попадут все трубы. Ключ — (key, value),
# значение — ожидаемый substance. Элементы без совпадения substance пропускаем.
_SUBSTANCE_FILTER: dict[tuple[str, str], frozenset[str]] = {
("man_made", "pipeline"): frozenset({"gas", "natural_gas", "cng"}),
# Для generic man_made=pipeline вещество (тег `substance`, или legacy `type`)
# определяет ВИД сети: газопровод (gas) vs теплотрасса (heat). Раньше брали только
# газ, остальное скипали — теплотрассы (единственный гео-источник §3, ~135 way в
# ЕКБ) терялись. Теперь substance → kind. Труба без известного substance
# по-прежнему пропускается (не под газ), чтобы нефтепроводы/химию не примешивать.
# gas — газоснабжение: gas / natural_gas / cng
# heat — теплоснабжение: heat / hot_water / steam
_PIPELINE_SUBSTANCE_KIND: dict[str, str] = {
"gas": "gas",
"natural_gas": "gas",
"cng": "gas",
"heat": "heat",
"hot_water": "heat",
"steam": "heat",
}
# Substance-фильтр для generic man_made=pipeline: пропускаем трубу только если её
# substance ведёт в известный вид сети (_PIPELINE_SUBSTANCE_KIND). Ключ — (key,
# value), значение — допустимые substance. Собирается из маппинга выше, чтобы
# фильтр и классификация не разошлись.
_SUBSTANCE_FILTER: dict[tuple[str, str], frozenset[str]] = {
("man_made", "pipeline"): frozenset(_PIPELINE_SUBSTANCE_KIND),
}
def _substance_of(tags: dict) -> str:
"""Вещество трубы: тег `substance` (или legacy `type`), lower-case, «» если нет."""
return (tags.get("substance") or tags.get("type") or "").lower()
def _pipeline_kind_from_substance(tags: dict, default_kind: str) -> str:
"""Вид сети для generic man_made=pipeline по substance.
substance{gas,natural_gas,cng} gas; {heat,hot_water,steam} heat.
Неизвестный/пустой substance ``default_kind`` (труба уже прошла
_passes_tag_filters, до сюда без совпадения не доходит).
"""
return _PIPELINE_SUBSTANCE_KIND.get(_substance_of(tags), default_kind)
# tower:type=communication — для man_made=tower оставляем только вышки связи
# (иначе под communication попадут смотровые/водонапорные башни).
_TOWER_TYPE_REQUIRED: dict[tuple[str, str], frozenset[str]] = {
@ -121,23 +160,23 @@ def _build_overpass_query(key: str, value: str, el_type: str) -> str:
south, west, north, east = EKB_BBOX
bbox = f"({south},{west},{north},{east})"
if el_type == "nwr":
return f"[out:json][timeout:30];" f'nwr["{key}"="{value}"]{bbox};' f"out geom;"
return f'[out:json][timeout:30];nwr["{key}"="{value}"]{bbox};out geom;'
if el_type == "way":
return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;"
return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;"
return f'[out:json][timeout:30];way["{key}"="{value}"]{bbox};out geom;'
return f'[out:json][timeout:30];node["{key}"="{value}"]{bbox};out body;'
def _passes_tag_filters(key: str, value: str, tags: dict) -> bool:
"""Доп-фильтр по тегам элемента (substance / tower:type).
Для generic ``man_made=pipeline`` оставляем только газовые трубы (substance),
для ``man_made=tower`` только вышки связи (tower:type). Остальные ключи
проходят без фильтра.
Для generic ``man_made=pipeline`` оставляем только трубы с известным веществом
(газ или теплоноситель см. _PIPELINE_SUBSTANCE_KIND); нефтепроводы/химию и
трубы без substance пропускаем. Для ``man_made=tower`` только вышки связи
(tower:type). Остальные ключи проходят без фильтра.
"""
substance_ok = _SUBSTANCE_FILTER.get((key, value))
if substance_ok is not None:
substance = (tags.get("substance") or tags.get("type") or "").lower()
if substance not in substance_ok:
if _substance_of(tags) not in substance_ok:
return False
tower_ok = _TOWER_TYPE_REQUIRED.get((key, value))
if tower_ok is not None:
@ -178,9 +217,7 @@ def _backoff_delay(attempt_idx: int, retry_after: float | None) -> float:
return base + random.uniform(0.0, _RETRY_JITTER_SECONDS)
async def _fetch_one_query(
client: httpx.AsyncClient, query: str, label: str
) -> list[dict] | None:
async def _fetch_one_query(client: httpx.AsyncClient, query: str, label: str) -> list[dict] | None:
"""Один Overpass-запрос с retry-backoff и фолбэком на зеркала (#1746).
На каждом endpoint из ``OVERPASS_ENDPOINTS`` делаем до
@ -332,6 +369,10 @@ def _upsert_elements(elements: list[dict]) -> dict[str, int]:
osm_id: int = el["id"]
osm_type: str = el["type"] # 'way' | 'node'
kind: str = el.get("_infrastructure_kind", "power")
# generic man_made=pipeline: реальный вид (gas vs heat) по substance —
# одна overpass-строка тянет и газопроводы, и теплотрассы (#2119).
if (key, value) == ("man_made", "pipeline"):
kind = _pipeline_kind_from_substance(tags, kind)
source_tag: str | None = el.get("_source_tag")
name: str | None = tags.get("name")
@ -443,6 +484,146 @@ async def load_utility_infrastructure(db: Session | None = None) -> dict[str, in
return _upsert_elements(elements)
# Junk-значения OSM tags, которые не несут смысла для попапа.
_JUNK_TAG_VALUES = frozenset({"", "yes", "no", "fixme", "unknown", "-"})
def _clean_utility_tag(value: object) -> str | None:
"""Нормализовать значение OSM tag: strip, None для пустого / junk."""
if value is None:
return None
s = str(value).strip()
return s if s and s.lower() not in _JUNK_TAG_VALUES else None
# OSM material (en) → RU («человеческим языком», правило ui-microcopy). Неизвестное
# значение остаётся как есть (лучше сырое, чем потерять).
_MATERIAL_RU: dict[str, str] = {
"metal": "металл",
"steel": "сталь",
"iron": "железо",
"cast_iron": "чугун",
"ductile_iron": "высокопрочный чугун",
"aluminium": "алюминий",
"aluminum": "алюминий",
"copper": "медь",
"concrete": "бетон",
"reinforced_concrete": "железобетон",
"wood": "дерево",
"wooden": "дерево",
"plastic": "пластик",
"pvc": "ПВХ",
"polyethylene": "полиэтилен",
"pe": "полиэтилен",
"asbestos_cement": "асбестоцемент",
"ceramic": "керамика",
}
def _format_voltage(raw: str) -> str | None:
"""OSM voltage (вольты, возможно multi «110000;10000») → «110 кВ/10 кВ».
1000 В «<kV> кВ», иначе «<V> В». Multi-токены через «;» join'ятся «/».
Нечисловые токены пропускаются. None если ни одного валидного.
"""
parts: list[str] = []
for token in raw.split(";"):
tok = token.strip()
if not tok:
continue
try:
volts = int(float(tok))
except (TypeError, ValueError):
continue # нечисловой мусор — пропускаем
if volts >= 1000:
kv = volts / 1000
kv_str = f"{kv:g}" # 110.0 → «110», 0.4 → «0.4»
parts.append(f"{kv_str} кВ")
else:
parts.append(f"{volts} В")
return "/".join(parts) if parts else None
def _format_diameter(raw: str) -> str | None:
"""Трубопровод diameter → «⌀ <n> мм». Берём ПЕРВЫЙ числовой run (отбрасываем
суффиксы «mm»/«мм» и не склеиваем мульти-размеры «500x300» «500»). None если
цифр нет.
"""
m = re.search(r"\d+", raw)
return f"{m.group()} мм" if m else None
# Теплоноситель теплотрассы (OSM substance) → RU для попапа. «heat» — обобщённая
# тепловая сеть (без уточнения носителя) — подписи не даём (kind=heat уже читается
# фронтом как «Теплотрасса»), чтобы не дублировать. hot_water / steam — уточняют.
_HEAT_SUBSTANCE_RU: dict[str, str] = {
"hot_water": "горячая вода",
"steam": "пар",
}
def _heat_substance_label(tags: dict) -> str | None:
"""Теплоноситель теплотрассы (hot_water→«горячая вода», steam→«пар»).
None для substance=heat (обобщённо не уточняем) или неизвестного попап и
так знает, что это теплотрасса (kind=heat «Теплотрасса» на фронте).
"""
return _HEAT_SUBSTANCE_RU.get(_substance_of(tags))
def _utility_characteristics(kind: str, tags: dict | None) -> str | None:
"""Одна гуманизированная RU-строка ключевых характеристик OSM-сети.
Стиль совпадает с connection-point ``_build_structure_characteristics``
(quarter_dump_lookup): « · »-join непустых частей. Включает (при наличии):
теплоноситель (для heat), напряжение (multi «/»), оператор, диаметр ( мм),
материал, кол-во цепей. None если ничего пригодного.
Examples:
power voltage=110000;10000 operator=«МРСК Урала»
«110 кВ/10 кВ · МРСК Урала»
gas diameter=500mm material=steel « 500 мм · сталь»
heat substance=hot_water diameter=200 «горячая вода · 200 мм»
voltage=400 «400 В»; voltage=yes None
"""
if not isinstance(tags, dict):
return None
parts: list[str] = []
# Теплоноситель — только для теплотрасс (kind=heat), первым: «горячая вода» / «пар».
if kind == "heat":
heat_label = _heat_substance_label(tags)
if heat_label:
parts.append(heat_label)
voltage = _clean_utility_tag(tags.get("voltage"))
if voltage:
vstr = _format_voltage(voltage)
if vstr:
parts.append(vstr)
operator = _clean_utility_tag(tags.get("operator"))
if operator:
parts.append(operator)
diameter = _clean_utility_tag(tags.get("diameter"))
if diameter:
dstr = _format_diameter(diameter)
if dstr:
parts.append(dstr)
material = _clean_utility_tag(tags.get("material"))
if material:
parts.append(_MATERIAL_RU.get(material.lower(), material))
# Кол-во цепей/кабелей ЛЭП — только если значение чисто числовое.
circuits = _clean_utility_tag(tags.get("circuits")) or _clean_utility_tag(tags.get("cables"))
if circuits and circuits.isdigit():
parts.append(f"{circuits} цеп.")
return " · ".join(parts) if parts else None
def get_nearby_utility_infrastructure(
db: Session,
cad_num: str,
@ -480,6 +661,7 @@ def get_nearby_utility_infrastructure(
db.execute(
text("""
SELECT osm_id, osm_type, infrastructure_kind, name, source_tag,
u.tags AS tags,
ST_Distance(
u.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
@ -519,6 +701,7 @@ def get_nearby_utility_infrastructure(
"name": r["name"],
"source_tag": r["source_tag"],
"distance_m": dist,
"characteristics": _utility_characteristics(kind, r["tags"]),
"geometry_geojson": json.loads(r["geojson"]) if r["geojson"] else {},
}
)

View file

@ -0,0 +1,523 @@
"""Загрузчик резервов ЦСВ/ЦСК Водоканала ЕКБ из docx-раскрытия (#2119, фикс #2126).
Две страницы водоканалекб.рф (водоснабжение / водоотведение) публикуют docx с
таблицами свободной мощности ЦСВ (центр. система водоснабжения) / ЦСК (центр.
система водоотведения). UPSERT-ит в ``water_supply_reserves``.
Источник ГЕО-БЛОКИРУЕТ non-RU IP РАБОТАЕТ НА ПРОДЕ (Celery / docker exec).
Punycode-host обязателен (водоканалекб.рф xn--80aadbki6adhshb.xn--p1ai).
Парсим таблицы через stdlib (zipfile + ElementTree по word/document.xml) это
надёжнее python-docx для vMerge-детекта и тривиально тестируется на синтетическом
xml-фрагменте. Резерв бывает ОТРИЦАТЕЛЬНЫМ (дефицит, напр. 168,5) знак сохраняем,
запятая-десятичный float. period инферится из имени файла (1kv.-2026 '2026-Q1').
РЕАЛЬНАЯ СЕТКА (снята с прода 2026-07-03, обе страницы 1 таблица, но РАЗНЫЙ layout).
Парсинг COLUMN-ALIGNED по ПОДПИСЯМ ШАПКИ и метке показателя (НЕ по индексам вслепую):
* Страница «водоснабжение» (system_kind='water') 6 колонок, «длинный» layout:
каждый ЦСВ занимает 4 СТРОКИ (по строке на показатель). Шапка: col2=='Наименование
показателя'. col0=' ЦСВ' (номер п/п: 1.1, 2.1…2.26, ПТВ), col1='Название ЦСВ'
(имя системы, повторяется в каждой строке блока), col2=показатель, col3=ед.изм,
col4=значение периода, col5=примечание. РЕЗЕРВ = строка, где col2 начинается с
«Резерв мощности централизованной системы» И col3 «тысм3/сут» число из col4.
Заявки/отказы строки «Количество поданных» / «отказе в подключении».
Живые резервы: ЗФС,ГСВ = 168,5; Изоплит = 0,0; Шабровский = 1,03; ПТВ = 73,34.
* Страница «водоотведение» (system_kind='sewerage') 5 колонок, «блочный» layout:
имя системы ОТДЕЛЬНАЯ строка-заголовок (только col0, напр. «Централизованная
система водоотведения "Южный бассейн канализования"»), под ней строка-шапка
(col0='№ п/п') и 4 строки показателей (col0 = 1..4 ИЛИ пусто). РЕЗЕРВ = строка,
где col1 начинается с «Резерв мощности централизованной системы» И col2
«тысм3/сут» число из col3. Имя системы forward-fill-ится из последней
строки-заголовка. Живые резервы: Южный бассейн = 296,85; Северный = 2,02.
ЛОВУШКА (прод-баг #2126, был мусор 28+6 строк): старый парсер брал «первое число
строки» как резерв и «первую текстовую ячейку» как имя. На проде это давало п/п
(1.1, 2.18) в поле резерва и рубрики анкеты («Количество поданных», «Итого:»,
служебные шапки « ЦСВ»/« п/п») как «системы». Column-aligned парс по метке
показателя их не видит: строки-рубрики, «Итого:», шапки и легенда отфильтрованы;
резерв читается ТОЛЬКО из строки «Резерв мощности». НУЛИ («Отсутствие резерва
мощности», 0,0) сохраняем честно (0.0 + note), как в eias_heat_loader.
"""
import io
import logging
import re
import zipfile
from xml.etree import ElementTree as ET
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
# WordprocessingML namespace.
_W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
_NS = {"w": _W}
# Punycode-host обязателен (водоканалекб.рф). xn--80aadbki6adhshb.xn--p1ai.
_BASE = "https://xn--80aadbki6adhshb.xn--p1ai"
PAGES: dict[str, str] = {
"water": f"{_BASE}/info/vodosnabzhenie/",
"sewerage": f"{_BASE}/info/vodotvedenie/",
}
_HTTP_TIMEOUT = 60
# docx-ссылка «для сайта»: dlya-sajta-*.docx.
_DOCX_HREF_RE = re.compile(r'href=["\']([^"\']*dlya-sajta-[^"\']*\.docx)["\']', re.IGNORECASE)
# Дата в имени файла для выбора свежайшего.
_FILE_DATE_RE = re.compile(r"(\d{4})|(\d{1,2})[.\-](\d{4})")
# Период вида «1kv.-2026» / «1кв-2026» / «1 квартал 2026».
_PERIOD_RE = re.compile(r"(\d)\s*(?:kv|кв)\w*[.\s-]*(\d{4})", re.IGNORECASE)
_NODATA_TOKENS = frozenset({"", "н/д", "нд", "n/a", "-", "", "", "нет данных", "х", "x"})
# Метки показателей формы раскрытия (нормализуем casefold + схлопнутые пробелы перед
# сравнением: в docx встречаются \xa0 и двойные пробелы). Резерв — по префиксу, т.к.
# хвост различается («…холодного водоснабжения» / «…водоотведения»).
_RESERVE_LABEL_PREFIX = "резерв мощности централизованной системы"
_REQUESTED_LABEL = "количество поданных и зарегистрированных заявок на подключение"
_REFUSED_LABEL_PREFIX = "количество заявок на подключение, по которым принято решение об отказе"
# Ед.изм. резерва: «тыс. м3/сут» / «тыс.м3/сут» (пробелы схлопнуты) — защита от того,
# чтобы «Резерв…» из чужой формы с иной размерностью не попал в тыс.м³/сут.
# Оба токена обязательны: одного «тыс» мало — «тыс. м3/час» прошёл бы с чужой
# временной базой (ревью 2026-07-03).
_RESERVE_UNIT_MARKERS = ("тыс", "сут")
# Шапки таблицы и служебные строки — не данные.
_HEADER_MARKERS = frozenset({"№ п/п", "№ цсв", "наименование показателя", "название цсв"})
_TOTAL_MARKER = "итого"
# Строка-заголовок системы в «блочном» layout водоотведения: только col0 заполнен и
# начинается с одного из этих префиксов (сама система, а не показатель/шапка).
_SYSTEM_TITLE_PREFIXES = (
"централизованная система",
"транспортировка сточных вод",
)
# Легенда в конце водоснабжения: «ЦСВ централизованная система…» / «ПТВ …».
_LEGEND_MARKER = " "
def parse_water_number(value: str | None) -> float | None:
"""Числовая ячейка резерва тыс. м³/сут → float. Сохраняет знак (дефицит <0).
Запятая-десятичный точка, минусы всех видов (//-) нормализуются.
«н/д»/пусто None.
"""
if value is None:
return None
s = str(value).strip().lower()
if s in _NODATA_TOKENS:
return None
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
# Нормализуем юникод-минусы к ASCII '-'.
s = s.replace("", "-").replace("", "-").replace("", "-")
m = re.search(r"-?\d+(?:\.\d+)?", s)
if not m:
return None
try:
return float(m.group())
except ValueError:
return None
def infer_period(filename: str) -> str | None:
"""Инферит период отчёта из имени файла: «1kv.-2026» → '2026-Q1'. None — нет."""
m = _PERIOD_RE.search(filename)
if not m:
return None
quarter, year = m.group(1), m.group(2)
return f"{year}-Q{quarter}"
def _cell_text(tc: ET.Element) -> str:
"""Весь текст ячейки таблицы (<w:tc>) — конкатенация всех <w:t>."""
parts = [t.text or "" for t in tc.iter(f"{{{_W}}}t")]
return "".join(parts).strip()
def _vmerge_state(tc: ET.Element) -> str:
"""Состояние вертикального merge ячейки: 'restart' | 'continue' | 'none'.
<w:vMerge w:val="restart"/> начало блока (значение здесь).
<w:vMerge/> (без val или val='continue') продолжение (forward-fill сверху).
Нет <w:vMerge> обычная ячейка.
"""
tc_pr = tc.find(f"{{{_W}}}tcPr")
if tc_pr is None:
return "none"
vmerge = tc_pr.find(f"{{{_W}}}vMerge")
if vmerge is None:
return "none"
val = vmerge.get(f"{{{_W}}}val")
return "restart" if val == "restart" else "continue"
def parse_docx_table_rows(document_xml: bytes) -> list[list[str]]:
"""Парсит ПЕРВУЮ таблицу document.xml → list строк (list ячеек-строк).
Forward-fill по вертикальному merge: ячейка в состоянии 'continue' наследует
текст ячейки той же колонки из предыдущей строки (system-name + note мержатся).
Возвращает матрицу текстов [row][col]. Пусто [].
"""
root = ET.fromstring(document_xml)
tables = root.iter(f"{{{_W}}}tbl")
tbl = next(tables, None)
if tbl is None:
return []
matrix: list[list[str]] = []
prev_row: list[str] = []
for tr in tbl.findall(f"{{{_W}}}tr"):
cells = tr.findall(f"{{{_W}}}tc")
row: list[str] = []
for col_idx, tc in enumerate(cells):
state = _vmerge_state(tc)
if state == "continue":
# forward-fill из той же колонки предыдущей строки.
filled = prev_row[col_idx] if col_idx < len(prev_row) else ""
row.append(filled)
else:
row.append(_cell_text(tc))
matrix.append(row)
prev_row = row
return matrix
def _norm(s: str | None) -> str:
"""Ячейку → casefold + схлопнутые пробелы (\xa0/двойные) для сравнения с метками."""
if not s:
return ""
return re.sub(r"\s+", " ", s.replace("\xa0", " ")).strip().casefold()
def _cell(row: list[str], idx: int) -> str:
"""Ячейка row[idx] со .strip() или '' если колонки нет."""
return row[idx].strip() if idx < len(row) and row[idx] else ""
def _is_header_row(row: list[str]) -> bool:
"""Строка-шапка таблицы (№ п/п / Наименование показателя / …)?"""
return any(_norm(c) in _HEADER_MARKERS for c in row)
def _is_total_row(row: list[str]) -> bool:
"""Строка «Итого:» (агрегат по всем системам — не резерв конкретной ЦСВ)."""
return _norm(_cell(row, 0)).startswith(_TOTAL_MARKER)
def _row_metric(row: list[str], label_col: int) -> str:
"""Метка показателя строки (нормализованная) из колонки label_col."""
return _norm(_cell(row, label_col))
def _is_reserve_metric(metric: str) -> bool:
return metric.startswith(_RESERVE_LABEL_PREFIX)
def _is_requested_metric(metric: str) -> bool:
return metric == _REQUESTED_LABEL
def _is_refused_metric(metric: str) -> bool:
return metric.startswith(_REFUSED_LABEL_PREFIX)
def _system_title(row: list[str]) -> str | None:
"""Строка-заголовок системы «блочного» layout (водоотведение): имя или None.
Заполнена ТОЛЬКО col0, текст начинается с «Централизованная система» /
«Транспортировка сточных вод». Возвращает имя системы (col0.strip()).
"""
if any(_cell(row, i) for i in range(1, len(row))):
return None # не одиночный заголовок — заполнены и другие колонки
text0 = _cell(row, 0)
norm0 = _norm(text0)
if any(norm0.startswith(p) for p in _SYSTEM_TITLE_PREFIXES):
return text0
return None
def _detect_layout(matrix: list[list[str]]) -> tuple[str, int, int, int]:
"""По шапке определяет layout → (kind, name_col, label_col, value_col).
kind='water' : шапка с col2=='Наименование показателя', имя ЦСВ в col1,
показатель col2, значение col4.
kind='block' : шапка с col1=='Наименование показателя' (водоотведение),
имя системы отдельная строка-заголовок, значение col3.
Не нашли шапку ('', -1, -1, -1) (graceful).
"""
for row in matrix:
if _norm(_cell(row, 2)) == "наименование показателя":
return ("water", 1, 2, 4)
if _norm(_cell(row, 1)) == "наименование показателя":
return ("block", -1, 1, 3)
return ("", -1, -1, -1)
def _flush_group(
name: str,
reserve_cell: str | None,
note_cell: str,
requested: int | None,
refused: int | None,
records: list[dict],
) -> None:
"""Собирает запись ЦСВ/ЦСК из накопленной группы строк, если есть имя + резерв."""
if not name or reserve_cell is None:
return
reserve = parse_water_number(reserve_cell)
# note: явное примечание строки резерва (напр. «Отсутствие резерва мощности»)
# честно фиксирует ноль-резерв; иначе None.
note = note_cell.strip() or None
records.append(
{
"system_name": name,
"reserve_thousand_m3_day": reserve,
"connections_requested": requested,
"connections_refused": refused,
"note": note,
}
)
def extract_water_rows(matrix: list[list[str]]) -> list[dict]:
"""Из матрицы docx → записи ЦСВ/ЦСК, COLUMN-ALIGNED по метке показателя.
Определяет layout по шапке (см. ``_detect_layout``): «water» имя ЦСВ в col1,
«block» (водоотведение) имя в отдельной строке-заголовке. Затем группирует
строки показателей по системе и берёт резерв ТОЛЬКО из строки «Резерв мощности
централизованной системы» (col value_col), заявки/отказы из строк
«Количество поданных» / «отказе». Ноль-резерв (0,0 + «Отсутствие резерва
мощности») сохраняется честно. Шапки, «Итого:», легенда отфильтрованы.
Не распознан layout [] (graceful, лог наверху).
"""
kind, name_col, label_col, value_col = _detect_layout(matrix)
if not kind:
return []
records: list[dict] = []
# Накопитель текущей группы (одна система = один блок строк показателей).
cur_name = ""
cur_reserve: str | None = None
cur_note = ""
cur_requested: int | None = None
cur_refused: int | None = None
def flush() -> None:
nonlocal cur_name, cur_reserve, cur_note, cur_requested, cur_refused
_flush_group(cur_name, cur_reserve, cur_note, cur_requested, cur_refused, records)
cur_name, cur_reserve, cur_note = "", None, ""
cur_requested, cur_refused = None, None
for row in matrix:
if not any(c.strip() for c in row):
continue
if _is_header_row(row):
continue
if _is_total_row(row):
flush() # «Итого:» закрывает предыдущую систему, само не данные
continue
# Легенда в конце водоснабжения («ЦСВ централизованная система…»).
if _norm(_cell(row, 0)).startswith(("цсв ", "птв ")) and _LEGEND_MARKER in _cell(row, 0):
flush()
continue
if kind == "block":
title = _system_title(row)
if title is not None:
flush() # новая система-заголовок
cur_name = title
continue
metric = _row_metric(row, label_col)
if kind == "water":
# Имя ЦСВ повторяется в каждой строке блока (col1). Смена имени → новая
# система: сбрасываем накопитель.
row_name = _cell(row, name_col)
if row_name and row_name != cur_name:
flush()
cur_name = row_name
value = _cell(row, value_col)
if _is_reserve_metric(metric):
# Защита размерности: у резерва ед.изм. — «тыс…м3/сут» (col label+1).
unit = _norm(_cell(row, label_col + 1))
if all(m in unit for m in _RESERVE_UNIT_MARKERS):
cur_reserve = value
cur_note = _cell(row, value_col + 1) # Примечание строки резерва
elif _is_requested_metric(metric):
n = parse_water_number(value)
if n is not None and float(n).is_integer():
cur_requested = int(n)
elif _is_refused_metric(metric):
n = parse_water_number(value)
if n is not None and float(n).is_integer():
cur_refused = int(n)
flush() # последняя система
return records
def fetch_docx_link(page_url: str) -> str:
"""Скрейпит страницу → ссылка на СВЕЖАЙШИЙ dlya-sajta-*.docx. '' если нет.
RUN-ON-PROD (гео-блок). GET follow_redirects, regex по href.
"""
resp = httpx.get(page_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
resp.raise_for_status()
hrefs = _DOCX_HREF_RE.findall(resp.text)
if not hrefs:
return ""
def _key(h: str) -> tuple:
years = [int(y) for grp in _FILE_DATE_RE.findall(h) for y in grp if y]
return (max(years) if years else 0, h)
best = max(hrefs, key=_key)
return best if best.startswith("http") else _BASE + best
def _dedupe_names(records: list[dict]) -> list[dict]:
"""Разводит БАЙТ-В-БАЙТ одинаковые system_name суффиксом « (N)».
UNIQUE (system_kind, system_name, period) схлопнул бы две разные системы с
одинаковым именем в одну (с потерей резерва). Имена ЦСВ на проде обычно уникальны
(well-суффиксы), но защищаемся как в eias_heat_loader.
"""
seen: dict[str, int] = {}
for rec in records:
name = rec["system_name"]
seen[name] = seen.get(name, 0) + 1
if seen[name] > 1:
rec["system_name"] = f"{name} ({seen[name]})"
return records
def _upsert_water_rows(
db: Session,
system_kind: str,
records: list[dict],
period: str | None,
source_url: str,
) -> dict[str, int]:
"""UPSERT записей ЦСВ/ЦСК в water_supply_reserves. Per-row SAVEPOINT."""
inserted = 0
updated = 0
skipped = 0
for rec in records:
params = {
"system_kind": system_kind,
"system_name": rec["system_name"],
"reserve": rec["reserve_thousand_m3_day"],
"requested": rec["connections_requested"],
"refused": rec["connections_refused"],
"note": rec["note"],
"period": period,
"source_url": source_url,
}
try:
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
result = db.execute(
text("""
INSERT INTO water_supply_reserves
(system_kind, system_name, reserve_thousand_m3_day,
connections_requested, connections_refused, note,
period, source_url, fetched_at)
VALUES (
:system_kind, :system_name, :reserve,
:requested, :refused, :note,
:period, :source_url, NOW()
)
ON CONFLICT (system_kind, system_name, period) DO UPDATE
SET reserve_thousand_m3_day = EXCLUDED.reserve_thousand_m3_day,
connections_requested = EXCLUDED.connections_requested,
connections_refused = EXCLUDED.connections_refused,
note = EXCLUDED.note,
source_url = EXCLUDED.source_url,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
"""),
params,
).scalar()
if result:
inserted += 1
else:
updated += 1
except Exception as e:
logger.warning("water_reserve upsert failed for %r: %s", rec["system_name"], e)
skipped += 1
return {"inserted": inserted, "updated": updated, "skipped": skipped}
def load_water_reserves_from_docx(
db: Session,
system_kind: str,
docx_bytes: bytes,
source_url: str = "",
) -> dict[str, int]:
"""Парсит docx-байты → UPSERT ЦСВ/ЦСК в water_supply_reserves.
Выделено из load_water_reserves для юнит-теста на синтетическом docx.
Читает word/document.xml из zip, forward-fill vMerge, извлечение записей.
"""
with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf:
document_xml = zf.read("word/document.xml")
matrix = parse_docx_table_rows(document_xml)
records = _dedupe_names(extract_water_rows(matrix))
period = infer_period(source_url)
if not records:
logger.warning(
"water_reserves[%s]: 0 записей распознано (layout не найден или пустая "
"таблица); первые 6 строк матрицы: %s",
system_kind,
matrix[:6],
)
try:
counts = _upsert_water_rows(db, system_kind, records, period, source_url)
db.commit()
except Exception as e:
db.rollback()
logger.exception("load_water_reserves_from_docx: outer tx rolled back: %s", e)
raise
result = {"records": len(records), **counts, "period": period} # type: ignore[dict-item]
logger.info("water_reserves[%s] done: %s", system_kind, result)
return {k: v for k, v in result.items() if isinstance(v, int)}
def load_water_reserves(db: Session | None = None) -> dict[str, dict]:
"""Полный прогон: обе страницы (водоснабжение/водоотведение) → UPSERT.
RUN-ON-PROD. Каждая страница graceful: сбой одной не валит другую.
Returns: {"water": {...}, "sewerage": {...}}.
"""
owns_session = db is None
if db is None:
db = SessionLocal()
out: dict[str, dict] = {}
try:
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
for kind, page_url in PAGES.items():
try:
link = fetch_docx_link(page_url)
if not link:
out[kind] = {"error": "no docx link found"}
continue
r = client.get(link)
r.raise_for_status()
out[kind] = load_water_reserves_from_docx(db, kind, r.content, link)
except Exception as e:
logger.exception("load_water_reserves: %s failed: %s", kind, e)
out[kind] = {"error": str(e)}
finally:
if owns_session:
db.close()
logger.info("load_water_reserves done: %s", out)
return out

View file

@ -283,6 +283,76 @@ def build_beat_schedule() -> dict:
"options": {"queue": "celery"},
}
# Точки подключения со свободной мощностью (Forgejo #2119 Phase A):
# электро — Россети WFS (~488 ЦП) + xlsx-резервы (ЦП + 10.7k ТП/РП);
# вода — Водоканал ЕКБ docx (ЦСВ/ЦСК, city-level).
# Источники ГЕО-БЛОКИРУЮТ non-RU IP → RUN-ON-PROD (Celery worker на VPS).
# Меняются медленно (раскрытие ФАС квартальное) — еженедельно с запасом.
# Вторник 04:00 МСКНАМЕРЕННО вне monday-кластера тяжёлых Overpass/site_finder
# рефрешей (не конкурируем за БД/сеть). Электро (WFS+xlsx) и вода (docx) —
# разные хосты, ставим с интервалом 30 мин, чтобы не бить оба одновременно.
schedule["power-capacity-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_power_supply_centers",
"schedule": _parse_cron("0 4 * * tue"), # 04:00 МСК, вторник
"options": {"queue": "celery"},
}
schedule["water-capacity-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_water_reserves",
"schedule": _parse_cron("30 4 * * tue"), # 04:30 МСК, вторник
"options": {"queue": "celery"},
}
# Газовая вертикаль (Forgejo #2119 Фаза B1): свободная мощность ГРС «Газпром
# трансгаз Екатеринбург» из инлайн HTML-таблицы раскрытия. Источник ГЕО-БЛОКИРУЕТ
# non-RU IP → RUN-ON-PROD. 05:00 МСК вторник — через 30 мин после воды (разные
# хосты, не бьём одновременно), в том же вторник-кластере вне monday-нагрузки.
schedule["gas-grs-capacity-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_gas_grs_capacity",
"schedule": _parse_cron("0 5 * * tue"), # 05:00 МСК, вторник
"options": {"queue": "celery"},
}
# Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960 → gas_grs_outlet_points +
# догеокод адресов → geom). Forgejo #2119 Фаза B2, PR-4. Источник ГЕО-БЛОКИРУЕТ
# non-RU IP + DaData-ключи на проде → RUN-ON-PROD. Источник публикуется ~5-го числа
# месяца, НО beat не поддерживает «раз в месяц по факту публикации» надёжно (jitter
# даты) → еженедельно вторник, идемпотентно: помесячный UPSERT + geom сохраняется,
# не-новый период просто пере-UPSERT-ится без эффекта. 05:15 МСК — в газовом суб-
# кластере сразу после gas-grs (05:00, тот же apex-родственник ekgas/ГТЕ), до ЕЭСК
# (05:30); намеренно вне monday-нагрузки. Целимся ~05:30-окно спеки, но 05:30 занят
# ЕЭСК → 05:15 (не бьём оба газовых источника ekgas одновременно).
schedule["gas-outlet-points-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_gas_outlet_points",
"schedule": _parse_cron("15 5 * * tue"), # 05:15 МСК, вторник
"options": {"queue": "celery"},
}
# ЕЭСК-резервы (Forgejo #2119 Фаза B2): ПС 35-220 (МВт) + ТП/РП «ниже 35» из
# xlsx-раскрытия eesk.ru. Обычный SSL БЕЗ гео-блока, но держим в том же вторник-
# кластере рядом с rosseti/gas/vodokanal. 05:30 МСК — через 30 мин после газа
# (разные хосты, не бьём одновременно), вне monday-нагрузки.
schedule["eesk-reserves-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_eesk_reserves",
"schedule": _parse_cron("30 5 * * tue"), # 05:30 МСК, вторник
"options": {"queue": "celery"},
}
# OSM-геокод городских ТП/РП ЕКБ → geom (Forgejo #2119 Фаза B2, финал). Матч
# name_norm (чистый номер) ↔ OSM substation ref/name через Overpass →
# power_tp_rp_reserves.geom. Источник (Overpass) БЕЗ гео-блока, но держим в том же
# вторник-кластере рядом с ЕЭСК. 05:45 МСК — после газа/ЕЭСК (05:30), до тепла
# (06:00); идемпотентно (geom IS NULL-гейт), вне monday-Overpass-нагрузки.
schedule["osm-tp-geocode-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_osm_tp_geocode",
"schedule": _parse_cron("45 5 * * tue"), # 05:45 МСК, вторник
"options": {"queue": "celery"},
}
# Тепло-резервы (Forgejo #2119 Фаза B2): резерв мощности источников тепла из
# реестра раскрытия ФАС (ri.eias.ru, ЕТК + Екатеринбургэнерго). Источник ГЕО-
# БЛОКИРУЕТ non-RU IP + российский УЦ → RUN-ON-PROD. 06:00 МСК — через 30 мин
# после ЕЭСК (разные хосты), в том же вторник-кластере вне monday-нагрузки.
schedule["heat-reserves-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_heat_reserves",
"schedule": _parse_cron("0 6 * * tue"), # 06:00 МСК, вторник
"options": {"queue": "celery"},
}
# #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК.
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
schedule["ekburg-permits-monthly"] = {

View file

@ -68,6 +68,7 @@ celery_app = Celery(
"app.workers.tasks.supply_layers_refresh",
"app.workers.tasks.location_refresh",
"app.workers.tasks.forecast",
"app.workers.tasks.full_report",
"app.workers.tasks.ird_harvest",
"app.workers.tasks.ekb_krt_sync",
"app.workers.tasks.gknspecial_harvest",
@ -87,6 +88,7 @@ celery_app = Celery(
"app.workers.tasks.riasurt_sverdl_harvest",
"app.workers.tasks.mv_sales_tracker_refresh",
"app.workers.tasks.scrape_freshness_check",
"app.workers.tasks.connection_capacity_sync",
],
)
celery_app.conf.timezone = "Europe/Moscow"

View file

@ -0,0 +1,242 @@
"""Celery tasks: еженедельная синхронизация точек подключения со свободной
мощностью электро (Россети WFS + xlsx-резервы) и вода (Водоканал docx). #2119.
Источники ГЕО-БЛОКИРУЮТ non-RU IP таски выполняются НА ПРОДЕ (Celery worker
на VPS). Persistent breadcrumbs в nspd_geo_log (как utility/poi/noise sync).
Import-light: тяжёлые загрузчики импортируются лениво внутри тасков.
"""
import logging
from sqlalchemy import text
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
def _log_breadcrumb(stage: str, level: str, message: str) -> None:
"""Записать строку в nspd_geo_log (job_id=NULL) — диагностика без SSH."""
try:
from app.core.db import SessionLocal
db = SessionLocal()
try:
db.execute(
text(
"INSERT INTO nspd_geo_log (job_id, level, stage, message) "
"VALUES (NULL, :lvl, :stage, :msg)"
),
{"lvl": level, "stage": stage, "msg": message[:500]},
)
db.commit()
finally:
db.close()
except Exception as e:
logger.warning("connection_capacity breadcrumb failed: %s", e)
@celery_app.task(
name="tasks.connection_capacity_sync.sync_power_supply_centers",
queue="celery",
)
def sync_power_supply_centers() -> dict:
"""WFS центры питания + xlsx-резервы (ЦП + ТП/РП) → power_supply_centers / ТП-РП.
RUN-ON-PROD (гео-блок источников). Сначала WFS (координаты + напряжение +
индекс загрузки), затем резервы из xlsx-раскрытия. Каждый шаг graceful
сбой резервов не откатывает загруженные WFS-точки.
"""
_log_breadcrumb("power_capacity_sync", "info", "task started")
result: dict = {}
try:
from app.services.site_finder.rosseti_wfs_loader import load_power_supply_centers
result["wfs"] = load_power_supply_centers()
except Exception as e:
logger.exception("sync_power_supply_centers: WFS failed: %s", e)
_log_breadcrumb("power_capacity_sync", "error", f"wfs {type(e).__name__}: {e}")
result["wfs"] = {"error": str(e)}
try:
from app.services.site_finder.rosseti_reserve_loader import load_all_reserves
result["reserves"] = load_all_reserves()
except Exception as e:
logger.exception("sync_power_supply_centers: reserves failed: %s", e)
_log_breadcrumb("power_capacity_sync", "error", f"reserves {type(e).__name__}: {e}")
result["reserves"] = {"error": str(e)}
logger.info("sync_power_supply_centers done: %s", result)
_log_breadcrumb("power_capacity_sync", "info", f"done: {result}")
return result
@celery_app.task(
name="tasks.connection_capacity_sync.sync_water_reserves",
queue="celery",
)
def sync_water_reserves() -> dict:
"""Резервы ЦСВ/ЦСК Водоканала ЕКБ (docx) → water_supply_reserves.
RUN-ON-PROD (гео-блок). Обе страницы (водоснабжение/водоотведение) graceful.
"""
_log_breadcrumb("water_capacity_sync", "info", "task started")
try:
from app.services.site_finder.vodokanal_reserve_loader import load_water_reserves
result = load_water_reserves()
logger.info("sync_water_reserves done: %s", result)
_log_breadcrumb("water_capacity_sync", "info", f"done: {result}")
return result
except Exception as e:
logger.exception("sync_water_reserves failed: %s", e)
_log_breadcrumb("water_capacity_sync", "error", f"{type(e).__name__}: {e}")
raise
@celery_app.task(
name="tasks.connection_capacity_sync.sync_gas_grs_capacity",
queue="celery",
)
def sync_gas_grs_capacity() -> dict:
"""Свободная мощность ГРС «Газпром трансгаз Екатеринбург» (HTML) → gas_grs_capacity.
RUN-ON-PROD (гео-блок источника). Инлайн HTML-таблица раскрытия ГТЕ UPSERT
свободной мощности ГРС (по выходам). #2119 Фаза B1.
"""
_log_breadcrumb("gas_grs_sync", "info", "task started")
try:
from app.services.site_finder.gazprom_grs_loader import load_grs_capacity
result = load_grs_capacity()
logger.info("sync_gas_grs_capacity done: %s", result)
_log_breadcrumb("gas_grs_sync", "info", f"done: {result}")
return result
except Exception as e:
logger.exception("sync_gas_grs_capacity failed: %s", e)
_log_breadcrumb("gas_grs_sync", "error", f"{type(e).__name__}: {e}")
raise
@celery_app.task(
name="tasks.connection_capacity_sync.sync_eesk_reserves",
queue="celery",
)
def sync_eesk_reserves() -> dict:
"""Резервы АО «ЕЭСК» (xlsx-раскрытие) → power_supply_centers (ПС 35-220, МВт) +
power_tp_rp_reserves (ТП/РП «ниже 35»). #2119 Фаза B2.
RUN-ON-PROD рядом с rosseti/gas/vodokanal (единый weekly-кластер). discovery
свежайшего xlsx + загрузка ПС и ТП/РП каждый файл graceful (сбой одного не
валит другой; лоадер сам изолирует per-row SAVEPOINT-ами).
"""
_log_breadcrumb("eesk_reserves_sync", "info", "task started")
try:
from app.services.site_finder.eesk_reserve_loader import load_eesk_reserves
result = load_eesk_reserves()
logger.info("sync_eesk_reserves done: %s", result)
_log_breadcrumb("eesk_reserves_sync", "info", f"done: {result}")
return result
except Exception as e:
logger.exception("sync_eesk_reserves failed: %s", e)
_log_breadcrumb("eesk_reserves_sync", "error", f"{type(e).__name__}: {e}")
raise
@celery_app.task(
name="tasks.connection_capacity_sync.sync_osm_tp_geocode",
queue="celery",
)
def sync_osm_tp_geocode() -> dict:
"""OSM-геокод городских ТП/РП ЕКБ → geom для power_tp_rp_reserves. #2119 B2 (финал).
RUN-ON-PROD рядом с ЕЭСК (тот же вторник-кластер). Матч name_norm (чистый номер)
OSM substation ref/name UPDATE geom + geocode_status='osm'. Идемпотентно
(geom IS NULL-гейт): не-новые точки просто пере-скипаются без эффекта. apply=True.
Источник (Overpass) без гео-блока, но держим на проде в едином weekly-кластере.
"""
_log_breadcrumb("osm_tp_geocode_sync", "info", "task started")
try:
from app.services.site_finder.osm_tp_geocoder import geocode_tp_from_osm
result = geocode_tp_from_osm(apply=True)
logger.info("sync_osm_tp_geocode done: %s", result)
_log_breadcrumb("osm_tp_geocode_sync", "info", f"done: {result}")
return result
except Exception as e:
logger.exception("sync_osm_tp_geocode failed: %s", e)
_log_breadcrumb("osm_tp_geocode_sync", "error", f"{type(e).__name__}: {e}")
raise
@celery_app.task(
name="tasks.connection_capacity_sync.sync_gas_outlet_points",
queue="celery",
)
def sync_gas_outlet_points() -> dict:
"""Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960) → gas_grs_outlet_points,
затем догеокод новых адресных строк geom. #2119 Фаза B2, PR-4.
RUN-ON-PROD (гео-блок источника ekgas.ru + DaData-ключи на проде). Два шага:
1. load_gas_outlet_points() свежайшая форма 6 из каталога UPSERT (идемпотентно:
помесячный UPSERT по (grs_name_norm, outlet_name, period_month, is_forecast),
geom НЕ затирается в DO UPDATE);
2. geocode_gas_outlets(apply=True) новые адресные строки последнего period_month
с geom IS NULL догеокодятся через DaData (in-memory кэш + cap внутри лоадера;
уже геокоженные строки прошлых месяцев не трогаются).
Источник обновляется ~5-го числа месяца, но расписание еженедельное (вторник)
не-новый период просто пере-UPSERT-ится без эффекта (geom сохраняется). Геокод
graceful: сбой шага 2 не откатывает загруженные строки (шаг 1 уже закоммичен своей
сессией). Каждый шаг логируется breadcrumb-ом в nspd_geo_log.
"""
_log_breadcrumb("gas_outlet_sync", "info", "task started")
result: dict = {}
try:
from app.services.site_finder.ekgas_outlet_loader import load_gas_outlet_points
result["load"] = load_gas_outlet_points()
except Exception as e:
logger.exception("sync_gas_outlet_points: load failed: %s", e)
_log_breadcrumb("gas_outlet_sync", "error", f"load {type(e).__name__}: {e}")
result["load"] = {"error": str(e)}
try:
from app.services.site_finder.ekgas_outlet_loader import geocode_gas_outlets
result["geocode"] = geocode_gas_outlets(apply=True)
except Exception as e:
logger.exception("sync_gas_outlet_points: geocode failed: %s", e)
_log_breadcrumb("gas_outlet_sync", "error", f"geocode {type(e).__name__}: {e}")
result["geocode"] = {"error": str(e)}
logger.info("sync_gas_outlet_points done: %s", result)
_log_breadcrumb("gas_outlet_sync", "info", f"done: {result}")
return result
@celery_app.task(
name="tasks.connection_capacity_sync.sync_heat_reserves",
queue="celery",
)
def sync_heat_reserves() -> dict:
"""Резервы тепловой мощности ФАС (ri.eias.ru) → heat_system_reserves. #2119 B2.
RUN-ON-PROD (гео-блок non-RU + российский УЦ). По всем организациям-держателям
(ЕТК, Екатеринбургэнерго): карточка ФАС публикации свежайшего квартала
выгрузки xlsx UPSERT. Каждая организация graceful (падение одной не валит вторую).
"""
_log_breadcrumb("heat_reserves_sync", "info", "task started")
try:
from app.services.site_finder.eias_heat_loader import load_heat_reserves
result = load_heat_reserves()
logger.info("sync_heat_reserves done: %s", result)
_log_breadcrumb("heat_reserves_sync", "info", f"done: {result}")
return result
except Exception as e:
logger.exception("sync_heat_reserves failed: %s", e)
_log_breadcrumb("heat_reserves_sync", "error", f"{type(e).__name__}: {e}")
raise

View file

@ -0,0 +1,80 @@
"""Celery task: собрать полный PDF-отчёт ПТИЦА в фоне (эпик #2259 PR-D).
Сборка полного PDF `/site-finder/analysis/{cad}` (§1§7 + карты) занимает секунды-десятки
секунд (WeasyPrint-рендер + OSM-тайлы карт + server-side концепция), поэтому НЕ крутится
inline на синхронном эндпоинте здесь фоновая on-demand таска, оркестрирующая
:func:`build_full_report` (full_report_pdf.py) и пишущая PDF-файл + метадата-ран.
ПОТОК: `SessionLocal()` `build_full_report(db, cad)` метадата-dict (pdf_path/)
close. Best-effort: нет analyze-рана ValueError внутри оркестратора ловим, warning,
return None (таска не валит воркер отчётировать нечего). Прочий сбой logger.exception
+ None (карты/концепция/форсайт сами best-effort внутри оркестратора).
Mirror conventions (forecast.py): `@celery_app.task`, `SessionLocal()` + try/finally close,
`logger` (не print), синхронный `def`. Очередь дефолтная `celery` (как forecast-таска: у
неё нет `queue=` в celery_app routing default; воркер слушает `--queues=celery,scrape_kn,geo`).
TIME LIMIT: WeasyPrint-рендер + карты могут занять до ~минуты на тяжёлом участке
щедрый per-task лимит (time_limit=600 / soft_time_limit=540), как у forecast-таски.
REGISTRATION: добавлено в `include=[...]` celery_app.py (зеркало forecast). Beat НЕТ
таска enqueue-on-demand (триггер из POST /{cad}/report).
"""
from __future__ import annotations
import logging
from typing import Any
from app.core.db import SessionLocal
from app.services.exporters.full_report_pdf import build_full_report
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(
bind=True,
name="tasks.full_report.build_full_report",
time_limit=600,
soft_time_limit=540,
)
def build_full_report_task(self: Any, cad: str) -> dict[str, Any] | None:
"""Собрать полный PDF-отчёт участка в фоне и записать файл + метадата-ран.
Оркестрация целиком в :func:`build_full_report` (full_report_pdf.py): analyze-ран
(нет ValueError None), forecast-ран (нет Part B «нет данных»), connection-capacity
и концепция best-effort, карты + WeasyPrint-рендер файл на volume `/app/reports/` +
метадата-ран `report-pdf-1.0`. Кэш по (analyze_run_id, forecast_run_id): тот же ключ +
файл на месте cache-hit, не пере-рендерит.
Args:
cad: кадастровый номер участка.
Returns:
Метадата отчёта {pdf_path, analyze_run_id, forecast_run_id, generated_at,
size_bytes} (+ `cache_hit` при кэш-хите), либо None если нет analyze-рана /
неожиданный сбой (таска failed gracefully, воркер не падает).
"""
db = SessionLocal()
try:
logger.info("build_full_report_task: старт cad=%s", cad)
result = build_full_report(db, cad)
logger.info(
"build_full_report_task: готово cad=%s path=%s cache_hit=%s",
cad,
result.get("pdf_path"),
result.get("cache_hit", False),
)
return result
except ValueError as exc:
# Нет analyze-рана — отчётировать нечего (graceful, не crash воркера).
logger.warning("build_full_report_task: нечего собирать cad=%s: %s", cad, exc)
return None
except Exception:
# Неожиданный сбой рендера/записи: traceback + None (карты/концепция/форсайт
# уже best-effort внутри оркестратора — сюда всплывает только фатальное).
logger.exception("build_full_report_task failed for cad=%s (returning None)", cad)
return None
finally:
db.close()

View file

@ -210,6 +210,12 @@ def scrape_kn_region(
fetch_flats=fetch_flats,
extras=extras,
download_photos_binary=download_photos,
# #1945 anti-ban: throttle + optional proxy + flats/extras isolation.
browser_concurrency=settings.scrape_kn_browser_concurrency,
request_jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
request_jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
proxy_url=settings.scrape_kn_proxy_url,
extras_isolated=settings.scrape_kn_extras_isolated,
)
)
@ -289,5 +295,11 @@ def resume_kn_run(self: Any, run_id: int) -> dict[str, Any]:
developers=developers,
load_state=state_path,
resume_from_run_id=run_id,
# #1945 anti-ban: throttle + optional proxy + flats/extras isolation.
browser_concurrency=settings.scrape_kn_browser_concurrency,
request_jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
request_jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
proxy_url=settings.scrape_kn_proxy_url,
extras_isolated=settings.scrape_kn_extras_isolated,
)
)

View file

@ -38,6 +38,8 @@ dependencies = [
"beautifulsoup4>=4.12.0", # HTML-парсер реестра КРТ-площадок ЕКБ (#1060)
"pymupdf>=1.24.0", # растеризация PDF→image (без poppler) для OCR (#1062)
"pytesseract>=0.3.13", # OCR сканов через Tesseract для изъятия ЕКБ (#1062)
"contextily>=1.7.0", # OSM basemap-тайлы для серверного рендера карт отчёта (#2259 PR-C)
"matplotlib>=3.11.0", # headless (Agg) рендер PNG-карт участка/концепции (#2259 PR-C)
]
[dependency-groups]
@ -91,11 +93,16 @@ strict = true
# Геометрия/экспорт-библиотеки без type stubs (shapely/ezdxf/weasyprint не несут
# py.typed) — игнорируем missing-imports, чтобы strict-модули generative проходили.
# geopandas/matplotlib/contextily добавлены для report_maps (#2259 PR-C, серверный
# рендер карт отчёта) — те же py.typed-less библиотеки.
[[tool.mypy.overrides]]
module = [
"shapely.*",
"ezdxf.*",
"weasyprint.*",
"geopandas.*",
"matplotlib.*",
"contextily.*",
]
ignore_missing_imports = true

View file

@ -174,12 +174,76 @@ class TestObjPricingPushdown:
"совпадал с выводимыми конкурентами"
)
def test_aggregation_unchanged(self) -> None:
"""Агрегатные выражения (AVG/COUNT FILTER) не тронуты — менялся только scope."""
def test_aggregation_per_domrf_obj_id(self) -> None:
"""Агрегаты (AVG/COUNT FILTER) считаются per domrf_obj_id (логика цен #1332).
#1964: источник агрегатов сменился с сырого objective_lots (alias ol) на
physflat-дедуп CTE obj_lots_latest (alias oll) см. test_obj_pricing_*_physflat
ниже. Сами агрегатные выражения и группировка per-obj_id неизменны.
"""
sql = self._competitor_sql()
# Логика цен/fallback (#1332) неизменна: те же агрегаты per domrf_obj_id.
assert "ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "COUNT(*) FILTER (WHERE ol.price_per_m2_rub IS NOT NULL )" in sql.replace(
assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "lots_with_price" in sql
assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
assert "GROUP BY np.domrf_obj_id" in sql
def test_obj_pricing_dedups_physflat_inline(self) -> None:
"""#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.
objective_lots раздут ~2.91× (мульти objective_lot_id на физлот через
пере-листинги) units_sold/units_available карточки конкурента были завышены.
Дедупим INLINE (DISTINCT ON physflat-ключ, последний снапшот) со scope по
project_name ближних ЖК. НЕ через v_objective_lots_latest: view материализует
ВСЮ таблицу 1.76M (join не проходит ниже DISTINCT ON seq-scan+sort ~5.7 s на
request-path analyze_parcel; inline scoped ~0.4 s, прод-EXPLAIN #1964).
"""
sql = self._competitor_sql()
assert "obj_lots_latest AS (" in sql, "должен быть physflat-дедуп CTE (#1964)"
assert "DISTINCT ON ( ol.project_name, ol.corpus_name, ol.section," in sql.replace(
"\n", " "
) or "lots_with_price" in sql
assert "GROUP BY cm.domrf_obj_id" in sql
), "obj_lots_latest должен дедупить по physflat-ключу"
assert "snapshot_date DESC, ol.id DESC" in sql, "берём последний снапшот физлота"
assert (
"v_objective_lots_latest" not in sql
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
class TestCompetitorAvgAreaPd:
"""#2111: средняя площадь квартиры конкурента (avg_area_pd) доходит до попапа.
obj_pricing CTE считает ROUND(AVG(oll.area_pd),1) AS avg_area_pd, финальный
SELECT выбирает p.avg_area_pd, а _competitor_with_coords должен протолкнуть
ключ в loose-dict competitors[] (parcel.py:651). Mock-based, без БД.
"""
def test_avg_area_pd_passes_through(self) -> None:
"""_competitor_with_coords сохраняет avg_area_pd из входной строки."""
row = {
"obj_id": 1,
"comm_name": "ЖК Тест",
"dev_name": "TestDev",
"avg_price_per_m2_rub": 150000,
"avg_area_pd": 47.3,
"units_sold": 10,
"units_available": 5,
"lat": 56.838011,
"lon": 60.597474,
}
out = parcels_module._competitor_with_coords(row)
assert out["avg_area_pd"] == 47.3
def test_avg_area_pd_none_when_absent(self) -> None:
"""Отсутствие avg_area_pd → ключ есть, значение None (graceful, ЖК без маппинга)."""
row = {"obj_id": 2, "comm_name": "ЖК Без Цен", "lat": None, "lon": None}
out = parcels_module._competitor_with_coords(row)
assert out["avg_area_pd"] is None
def test_sql_computes_and_selects_avg_area_pd(self) -> None:
"""Competitor-SQL вычисляет AVG(area_pd) и выбирает avg_area_pd в финале."""
src = inspect.getsource(parcels_module.analyze_parcel)
marker = "WITH latest_obj AS ("
start = src.index(marker)
block = re.sub(r"\s+", " ", src[start : src.index('"""', start)])
assert "ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd" in block
assert "p.avg_area_pd" in block

View file

@ -70,7 +70,9 @@ def _make_db_for_analyze(
_make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
@ -91,7 +93,7 @@ def _make_db_for_analyze(
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row

View file

@ -53,11 +53,19 @@ def _make_db_for_analyze(
geom_found: bool = True,
district_found: bool = True,
market_price_row: dict[str, Any] | None = None,
district_row_override: dict[str, Any] | None = None,
success_rows: list[dict[str, Any]] | None = None,
geo_radius_row: dict[str, Any] | None = None,
district_price_row: dict[str, Any] | None = None,
) -> MagicMock:
"""Сконструировать mock DB Session для analyze_parcel.
market_price_row=None имитирует "нет данных в MV" (mp_row is None source='no_data').
market_price_row={...} имитирует найденную строку в MV.
district_row_override переопределяет raw-district mapping (#1960 basis chain).
success_rows строки v_bucket_success_score (#1955 obj_class passthrough).
geo_radius_row строка geo_radius_price (objective lots в радиусе, #1960 basis #2).
district_price_row строка district_price_block (objective по имени, #1960 basis #1).
"""
db = MagicMock()
@ -67,20 +75,20 @@ def _make_db_for_analyze(
else None
)
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
district_row = (
_make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"dist_to_center": 1500.0,
}
)
if district_found
else None
)
_district_data = district_row_override or {
"district_name": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
district_row = _make_mapping(_district_data) if district_found else None
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
mp_mock = _make_mapping(market_price_row) if market_price_row is not None else None
success_mocks = [_make_mapping(r) for r in success_rows] if success_rows is not None else []
geo_mock = _make_mapping(geo_radius_row) if geo_radius_row is not None else None
dp_mock = _make_mapping(district_price_row) if district_price_row is not None else None
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
# Нормализуем SQL для сигнатурного матчинга (collapse whitespace).
@ -94,13 +102,22 @@ def _make_db_for_analyze(
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row
# market-price (#33) — уникальная сигнатура трёх скользящих медиан.
elif "median_6m" in sql and "median_12m" in sql and "median_24m" in sql:
first_val = mp_mock
# #1955: success-ranking из v_bucket_success_score.
elif "v_bucket_success_score" in sql:
all_val = success_mocks
# #1960 basis #1: district_price_block (Objective по имени района).
elif "AS sample_size" in sql and "objective_lots" in sql:
first_val = dp_mock
# #1960 basis #2: geo_radius_price (Objective в радиусе).
elif "AS n_complexes" in sql and "objective_lots" in sql:
first_val = geo_mock
r = MagicMock()
r.mappings.return_value.first.return_value = first_val
@ -254,3 +271,201 @@ def test_market_price_invalid_cad_returns_404() -> None:
finally:
app.dependency_overrides.clear()
_stop_patches()
# ── #1960: «Медиана рынка» = newbuild-consistent basis ──────────────────────────
def test_district_median_uses_geo_radius_not_quarter_rosreestr() -> None:
"""#1960: при тонкой росреестровской квартальной медиане (deals<5) карточка
district.median_price_per_m2 берёт newbuild geo_radius медиану, НЕ 64k.
Репродукция бага для 66:41:0205010:287: quarter_median_12m=63694 (1 ДКП),
geo_radius медиана=132690. Objective-по-имени отсутствует (name-match gap)
basis должен упасть на geo_radius_price.
"""
from app.core.db import get_db
db = _make_db_for_analyze(
district_row_override={
"district_name": "Железнодорожный",
"ekb_reference_median": 69687,
"quarter_median_12m": 63694, # загрязнённая росреестровская квартальная
"quarter_deals_count": 6, # 24-мес окно ≥5, но 12-мес — тонкое
"dist_to_center": 0.0,
},
# Objective по имени района отсутствует (5/9 районов ЕКБ без name-match).
district_price_row=None,
# geo_radius — newbuild basis (что и остальная страница).
geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
district = body["district"]
assert district["median_price_basis"] == "objective_geo_radius"
assert district["median_price_per_m2"] == pytest.approx(132690.0)
# КЛЮЧЕВОЕ: загрязнённая 64k росреестровская медиана НЕ выигрывает.
assert district["median_price_per_m2"] != pytest.approx(63694.0)
# И значение в newbuild-диапазоне (~130-200k).
assert 130000 <= district["median_price_per_m2"] <= 200000
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_district_median_prefers_objective_district_when_available() -> None:
"""#1960: если есть Objective-медиана по имени района — она имеет приоритет
над geo_radius и квартальной росреестровской."""
from app.core.db import get_db
db = _make_db_for_analyze(
district_row_override={
"district_name": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": 64000,
"quarter_deals_count": 6,
"dist_to_center": 1500.0,
},
district_price_row={
"price_min": 90000,
"price_max": 250000,
"price_median": 165000,
"sample_size": 42,
},
geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
district = resp.json()["district"]
assert district["median_price_basis"] == "objective_district"
assert district["median_price_per_m2"] == pytest.approx(165000.0)
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_district_median_quarter_rosreestr_gated_by_min_deals() -> None:
"""#1960: квартальная росреестровская медиана может стать basis ТОЛЬКО как
последний fallback и ТОЛЬКО при deals_count 5. Тонкий квартал (deals<5)
median None (не показываем загрязнённое значение)."""
from app.core.db import get_db
# Нет Objective-данных вообще, нет ekb_reference, тонкий квартал (1 сделка).
db = _make_db_for_analyze(
district_row_override={
"district_name": "Глухой",
"ekb_reference_median": None,
"quarter_median_12m": 64000,
"quarter_deals_count": 1, # < 5 → не проходит guard
"dist_to_center": 3000.0,
},
district_price_row=None,
geo_radius_row=None,
)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
district = resp.json()["district"]
assert district["median_price_basis"] == "none"
assert district["median_price_per_m2"] is None
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_district_median_quarter_rosreestr_selected_as_last_resort() -> None:
"""#1960: если ВСЕ newbuild-fallback'и None (нет Objective-по-имени, нет
geo_radius, нет ekb_reference), но квартальная росреестровская медиана имеет
deals_count 5 она выбирается как basis (4-й fallback) с явным источником."""
from app.core.db import get_db
db = _make_db_for_analyze(
district_row_override={
"district_name": "Глухой",
"ekb_reference_median": None, # нет справочной DDU-медианы
"quarter_median_12m": 88000,
"quarter_deals_count": 7, # ≥ 5 → проходит guard
"dist_to_center": 3000.0,
},
district_price_row=None, # нет Objective по имени
geo_radius_row=None, # нет geo_radius
)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
district = resp.json()["district"]
assert district["median_price_basis"] == "quarter_rosreestr"
assert district["median_price_per_m2"] == pytest.approx(88000.0)
finally:
app.dependency_overrides.clear()
_stop_patches()
# ── #1955: obj_class passthrough в success_recommendation ────────────────────────
def test_success_recommendation_carries_obj_class() -> None:
"""#1955: ranking-строки несут obj_class — UI может различать строки с
одинаковым area-label (Комфорт vs Типовой vs «не указан»)."""
from app.core.db import get_db
success = [
{
"bucket": "Студии 15-30",
"obj_class": "Комфорт",
"success_score": 1.2,
"n_deals": 4857,
"avg_price_per_m2": 194000,
"avg_area_m2": 25.0,
"velocity_z": 0.5,
"price_z": 0.3,
"area_z": -0.2,
},
{
"bucket": "Студии 15-30",
"obj_class": "не указан",
"success_score": 0.4,
"n_deals": 165,
"avg_price_per_m2": 142000,
"avg_area_m2": 26.0,
"velocity_z": 0.1,
"price_z": -0.1,
"area_z": 0.0,
},
]
db = _make_db_for_analyze(success_rows=success)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
rec = resp.json()["success_recommendation"]
assert rec is not None
ranking = rec["ranking"]
assert len(ranking) == 2
assert ranking[0]["obj_class"] == "Комфорт"
assert ranking[1]["obj_class"] == "не указан"
# Один area-label, но разные классы → больше не визуально-дубль.
assert ranking[0]["bucket"] == ranking[1]["bucket"]
assert ranking[0]["obj_class"] != ranking[1]["obj_class"]
# И никакого английского фантома 'Comfort'.
assert all(r["obj_class"] != "Comfort" for r in ranking)
finally:
app.dependency_overrides.clear()
_stop_patches()

View file

@ -40,14 +40,14 @@ def _make_mapping(data: dict[str, Any]) -> MagicMock:
def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
db = MagicMock()
geom_row = _make_mapping(
{"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}
)
geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
wkt_row = _make_mapping({"wkt": _WKT})
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
@ -63,7 +63,7 @@ def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
# И centroid_row для блока 6, и OSRM-origin SELECT (тот же shape).
@ -170,9 +170,7 @@ def test_flag_on_osrm_replaces_distance(monkeypatch) -> None:
monkeypatch.setattr(settings, "use_osrm_distances", True)
_start_patches()
try:
with patch(
"app.api.v1.parcels.get_road_distances_m", return_value=[700.0]
) as mock_osrm:
with patch("app.api.v1.parcels.get_road_distances_m", return_value=[700.0]) as mock_osrm:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text

View file

@ -65,7 +65,9 @@ def _make_db_for_analyze(
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)

View file

@ -64,7 +64,9 @@ def _make_db_for_analyze(
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
@ -83,7 +85,7 @@ def _make_db_for_analyze(
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row

View file

@ -110,7 +110,9 @@ def _make_db_for_analyze() -> MagicMock:
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)

View file

@ -0,0 +1,162 @@
"""Stage 2a (#1965, эпик #1953) — POST /api/v1/concepts/recompute endpoint tests.
LIVE-пересчёт экономики из агрегированной массинг-программы (пятно × этажность) для
дебаунс-слайдеров Stage 2b. Проверяем: 200 + связный вывод (ТЭП-математика, revenue>0,
NPV посчитан), FAST path с предрезолвленной ценой (БЕЗ обращения к БД) и DB-fallback по
центроиду (через dependency override get_db мок-сессией). compute_financial остаётся
чистой её арифметика покрыта в tests/services/generative; тут контракт эндпоинта.
"""
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from app.core.db import get_db
from app.main import app
def _program(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"total_footprint_sqm": 3000.0,
"floors": 12,
"sections": 2,
"site_area_sqm": 10_000.0,
"housing_class": "comfort",
"development_type": "mid_rise",
}
base.update(overrides)
return base
def test_recompute_returns_200_and_coherent_output() -> None:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(market_price_per_sqm=150_000.0),
)
assert resp.status_code == 200, resp.text
body = resp.json()
teap = body["teap"]
fin = body["financial"]
# ТЭП-математика: GFA = пятно × этажность; пятно = total_footprint_sqm.
assert teap["built_area_sqm"] == 3000.0
assert teap["total_floor_area_sqm"] == 3000.0 * 12
# Финмодель связна: выручка/затраты > 0, NPV посчитан (поле присутствует).
assert fin["revenue_rub"] > 0
assert fin["cost_rub"] > 0
assert isinstance(fin["npv_rub"], float)
def test_recompute_market_price_passthrough_skips_db() -> None:
# Предрезолвленная цена в теле → используется как есть, БЕЗ обращения к БД.
# get_db переопределён сессией, которая ВЗРЫВАЕТСЯ при любом execute — если бы
# эндпоинт пошёл в БД, тест бы упал. Зелёный тест доказывает skip DB lookup.
class _ExplodingSession:
def execute(self, *_a: object, **_k: object) -> None:
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
try:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(market_price_per_sqm=180_000.0),
)
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
# Цена прокинута в выручку жилья → price_per_sqm_used == переданная цена.
assert fin["price_per_sqm_used"] == 180_000.0
assert fin["price_is_calibrated"] is True
# Без body price_source → дефолтный лейбл (фронт не форвардил подлинный source).
assert fin["price_source"] == "objective_district_median"
finally:
app.dependency_overrides.pop(get_db, None)
def test_recompute_body_price_source_echoed_through() -> None:
# Фронт форвардит ПОДЛИННЫЙ source цены (из financial_estimate, напр. geo_radius) —
# эндпоинт НЕ подменяет его захардкоженным дефолтом; honest-флаг доходит до UI/PDF.
# get_db взрывается → доказывает, что и source-passthrough идёт по FAST path без БД.
class _ExplodingSession:
def execute(self, *_a: object, **_k: object) -> None:
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
try:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(market_price_per_sqm=160_000.0, price_source="objective_geo_radius"),
)
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
assert fin["price_per_sqm_used"] == 160_000.0
assert fin["price_is_calibrated"] is True
# Подлинный source эхнут наружу, НЕ перетёрт дефолтом "objective_district_median".
assert fin["price_source"] == "objective_geo_radius"
finally:
app.dependency_overrides.pop(get_db, None)
def test_recompute_db_fallback_when_no_price_but_centroid() -> None:
# Нет market_price_per_sqm, но передан centroid → эндпоинт делает _lookup_market_price.
# Мок-сессия отдаёт район + Objective-медиану (n>=10) → цена калибруется из БД.
class _Result:
def __init__(self, row: dict[str, Any] | None) -> None:
self._row = row
def mappings(self) -> _Result:
return self
def first(self) -> dict[str, Any] | None:
return self._row
class _FakeSession:
def __init__(self, rows: list[dict[str, Any] | None]) -> None:
self._rows = rows
self.calls = 0
def execute(self, *_a: object, **_k: object) -> _Result:
row = self._rows[self.calls] if self.calls < len(self._rows) else None
self.calls += 1
return _Result(row)
rows = [
{"district_name": "Кировский", "median_price_per_m2": 130_000},
{"median_ppm2": 175_000.0, "sample_size": 42},
]
app.dependency_overrides[get_db] = lambda: _FakeSession(rows)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(parcel_centroid_wkt="POINT (60.6 56.83)"),
)
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
# Цена пришла из Objective-медианы района (DB-fallback).
assert fin["price_per_sqm_used"] == 175_000.0
assert fin["price_source"] == "objective_district_median"
assert fin["price_is_calibrated"] is True
finally:
app.dependency_overrides.pop(get_db, None)
def test_recompute_no_price_no_centroid_uses_class_norm() -> None:
# Ни цены, ни центроида → норматив класса comfort (145_000), не калибровано.
client = TestClient(app)
resp = client.post("/api/v1/concepts/recompute", json=_program())
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
assert fin["price_per_sqm_used"] == 145_000.0
assert fin["price_is_calibrated"] is False
assert fin["price_source"] == "class_norm"
def test_recompute_validates_floors_bounds() -> None:
# floors вне [1, 40] → 422 (Pydantic-валидация контракта).
client = TestClient(app)
resp = client.post("/api/v1/concepts/recompute", json=_program(floors=99))
assert resp.status_code == 422

View file

@ -259,7 +259,9 @@ def _make_db_for_analyze() -> MagicMock:
district_row = _make_mapping_analyze(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)

View file

@ -4,12 +4,14 @@ Mock-based — не требуют живой БД.
Паттерн mock DB: аналогично test_parcel_competitors.py dependency_overrides[get_db].
Порядок вызовов в get_best_layouts (Fix SF-01 inline velocity):
db.scalar() MAX(snapshot_date) (только когда vel_rows non-empty)
db.execute() calls:
1. _PARCEL_CENTROID_SQL .mappings().first()
2. _COMPETITORS_IN_RADIUS_SQL .mappings().all()
3. _INLINE_VELOCITY_SQL .mappings().all()
4. _SUPPLY_BATCH_SQL .mappings().all() (пропускается если latest_snap is None)
4. _SUPPLY_BATCH_SQL .mappings().all()
Fix #1956: supply берёт per-object последний snapshot внутри _SUPPLY_BATCH_SQL
(flats_latest CTE) отдельного db.scalar(MAX(snapshot_date)) больше нет.
"""
from __future__ import annotations
@ -21,6 +23,14 @@ import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services.site_finder.best_layouts import (
_COMPETITORS_IN_RADIUS_SQL,
_INLINE_VELOCITY_SQL,
_OBJECTIVE_DEVELOPERS_SQL,
_OBJECTIVE_PROJECT_NAMES_SQL,
_PARCEL_CENTROID_SQL,
_SUPPLY_BATCH_SQL,
)
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
@ -35,11 +45,21 @@ def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
return r
def _obj_id_row(obj_id: int) -> MagicMock:
"""Строка obj_id из _COMPETITORS_IN_RADIUS_SQL."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
return r
def _obj_id_row(obj_id: int) -> dict[str, object]:
"""Строка из _COMPETITORS_IN_RADIUS_SQL.
Issue #2177: SQL возвращает comm_name/dev_name/гео для группировки obj_id в
комплексы, а get_best_layouts делает dict(r). Возвращаем реальный dict (как
SQLAlchemy RowMapping). Уникальное comm_name на obj_id каждый = своя группа,
поэтому существующие coverage-инварианты этих API-тестов не меняются.
"""
return {
"obj_id": obj_id,
"comm_name": f"obj-{obj_id}",
"dev_name": f"dev-{obj_id}",
"latitude": None,
"longitude": None,
}
def _vel_row(
@ -64,6 +84,8 @@ def _vel_row(
"avg_price_per_m2_rub": avg_price_rub,
"competitor_obj_ids": oids,
"competitor_count": len(oids),
# Issue #2177 шаг 2b: project_name'ы с velocity в bucket'е (mapping core-match).
"matched_project_names": [],
"window_start": ws,
"window_end": we,
}[k]
@ -85,45 +107,56 @@ def _make_db(
id_rows: list[MagicMock] | None = None,
vel_rows: list[MagicMock] | None = None,
supply_rows: list[MagicMock] | None = None,
latest_snap: dt.date | None = None,
project_names: list[str] | None = None,
developers_by_project: dict[str, list[str]] | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
"""Сконструировать mock Session с диспатчем по SQL-стейтменту.
db.scalar() возвращает latest_snap (MAX snapshot_date) вызывается перед supply.
Порядок db.execute():
1. centroid .mappings().first()
2. competitors-in-radius .mappings().all()
3. velocity .mappings().all()
4. supply .mappings().all() (только если latest_snap is not None)
Fix #1956: per-object последний снимок считается внутри _SUPPLY_BATCH_SQL.
Issue #2177 шаг 2b добавил objective-projects/developers (core-attribution) перед
velocity роутим по идентичности SQL-констант, не позиционно. db.execute():
_PARCEL_CENTROID_SQL .mappings().first()
_COMPETITORS_IN_RADIUS_SQL .mappings().all()
_OBJECTIVE_PROJECT_NAMES_SQL .all()
_OBJECTIVE_DEVELOPERS_SQL .all()
_INLINE_VELOCITY_SQL .mappings().all()
_SUPPLY_BATCH_SQL .mappings().all()
"""
db = MagicMock()
# db.scalar — pre-computed MAX(snapshot_date) для supply query
db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY
r_centroid = MagicMock()
r_centroid.mappings.return_value.first.return_value = coord
results: list[MagicMock] = []
r_comp = MagicMock()
r_comp.mappings.return_value.all.return_value = id_rows or []
# 1: centroid
r0 = MagicMock()
r0.mappings.return_value.first.return_value = coord
results.append(r0)
r_proj = MagicMock()
r_proj.all.return_value = [(n,) for n in (project_names or [])]
# 2: competitors-in-radius
r1 = MagicMock()
r1.mappings.return_value.all.return_value = id_rows or []
results.append(r1)
r_dev = MagicMock()
r_dev.all.return_value = [
(pname, devs) for pname, devs in (developers_by_project or {}).items()
]
# 3: velocity (only queried if id_rows non-empty)
r2 = MagicMock()
r2.mappings.return_value.all.return_value = vel_rows or []
results.append(r2)
r_vel = MagicMock()
r_vel.mappings.return_value.all.return_value = vel_rows or []
# 4: supply
r3 = MagicMock()
r3.mappings.return_value.all.return_value = supply_rows or []
results.append(r3)
r_supply = MagicMock()
r_supply.mappings.return_value.all.return_value = supply_rows or []
db.execute.side_effect = results
dispatch = {
_PARCEL_CENTROID_SQL: r_centroid,
_COMPETITORS_IN_RADIUS_SQL: r_comp,
_OBJECTIVE_PROJECT_NAMES_SQL: r_proj,
_OBJECTIVE_DEVELOPERS_SQL: r_dev,
_INLINE_VELOCITY_SQL: r_vel,
_SUPPLY_BATCH_SQL: r_supply,
}
def _execute(stmt, *_args, **_kwargs):
return dispatch[stmt]
db.execute.side_effect = _execute
return db
@ -378,7 +411,6 @@ def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None:
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
latest_snap=dt.date.today(),
)
from app.core.db import get_db
@ -407,7 +439,6 @@ def test_sold_pct_below_100_is_not_oversold() -> None:
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
latest_snap=dt.date.today(),
)
from app.core.db import get_db

View file

@ -449,9 +449,9 @@ def test_competitors_avg_price_populated() -> None:
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
)
assert comp["avg_price_per_m2"] == pytest.approx(
150_000.0
), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
# OBJ-3 #307: domrf-hit → price_source='domrf'.
assert comp["price_source"] == "domrf"
finally:
@ -471,9 +471,7 @@ def test_competitors_objective_price_fallback_fills_gap() -> None:
rows = [_obj_row(obj_id=1)]
# domrf price пуст → fallback на objective median.
obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
db = _make_db(
coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows
)
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows)
from app.core.db import get_db
@ -597,14 +595,41 @@ def test_sold_count_sql_is_fanout_safe() -> None:
НЕ domrf_obj_id один obj_id может иметь несколько mapping-строк (то же имя,
разный objective_group) JOIN по project_name задвоил бы лоты. DISTINCT по
objective_lot_id дедуплицирует на уровне лота. Регресс к COUNT(*) fail.
#1964: DISTINCT СОХРАНЯЕТСЯ и в inline-варианте (hunt #1953): будущий fan-out
маппинга задвоил бы строки, и COUNT(*) перестал бы быть fan-out-safe. В новом
SQL final-SELECT агрегирует по CTE `latest` (без ol-алиаса) COUNT(DISTINCT
objective_lot_id).
"""
sql = _sold_sql_text()
assert "COUNT(DISTINCT ol.objective_lot_id)" in sql, (
"fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
)
assert (
"COUNT(DISTINCT objective_lot_id)" in sql
), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
# COUNT(*) допустим внутри как агрегат? нет — sold-count агрегирует только distinct lot.
assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге"
def test_sold_count_sql_reads_raw_objective_lots_inline() -> None:
"""hunt #1953 (регрессия #1964): sold-count читает СЫРУЮ objective_lots inline с
DISTINCT ON по physflat-ключу, НЕ v_objective_lots_latest.
Через view DISTINCT ON материализует ВСЮ 1.76M-таблицу ДО фильтра маппинга (qual
не проталкивается ниже view-DISTINCT ON) Parallel Seq Scan + external sort
(~6896 ms, ~213 MB temp на каждый cold /analyze). Inline-pushdown: фильтр
маппинга в CTE DISTINCT ON по сырой objective_lots с JOIN ON project_name
(objective_lots_project_idx) ~437 ms, вывод побайтово идентичен. Регресс к
`v_objective_lots_latest` fail.
"""
sql = _sold_sql_text()
assert "v_objective_lots_latest" not in sql, "не должен читать view (#1953 regression)"
assert "JOIN objective_lots" in sql, "должен джойнить СЫРУЮ objective_lots inline"
# physflat-ключ inline DISTINCT ON.
assert "DISTINCT ON (" in sql
for col in ("project_name", "corpus_name", "section", "floor", "lot_number"):
assert col in sql, f"physflat-ключ должен включать {col}"
assert "snapshot_date DESC" in sql and "id DESC" in sql
def test_sold_count_sql_filters_premise_kind() -> None:
"""premise_kind-фильтр (как в MarketMetrics._STOCK_SQL) — без него проданные
паркинги/кладовые/нежилое считались бы проданными квартирами (over-count)."""

View file

@ -0,0 +1,393 @@
"""Тесты для GET /{cad_num}/connection-capacity (Forgejo #2119 Phase A).
FastAPI TestClient + mock сервиса get_connection_capacity без Postgres.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
_VALID_CAD = "66:41:0204016:10"
def _power_point(
name: str = "ПС 110/10 Уктус",
reserve: float | None = 15.0,
load_index: str | None = "open",
distance_m: float = 850.0,
reserve_unit: str | None = "МВА",
district: str | None = None,
reserve_note: str | None = None,
) -> dict[str, Any]:
return {
"name": name,
"dzo_name": "Россети Урал",
"voltage_class": "110/10",
"load_index": load_index,
"installed_capacity_mva": 40.0,
"current_load_mva": 25.0,
"reserve_mva": reserve,
"reserve_unit": reserve_unit,
"district": district,
"reserve_asof": "2026-06-30",
"reserve_note": reserve_note,
"distance_m": distance_m,
"lat": 56.78,
"lon": 60.61,
}
def _heat_block(
systems: list[dict[str, Any]] | None = None,
total: float | None = 3.26,
) -> dict[str, Any]:
if systems is None:
systems = [
{
"org": "ЕТК",
"system_name": "СТ №54",
"reserve_gcal_h": 3.26,
"period": "2026-Q2",
}
]
return {"systems": systems, "total_reserve_gcal_h": total}
def _gas_block(
outlets_total: int = 1037,
outlets_deficit: int = 218,
outlets_needs_calc: int = 144,
) -> dict[str, Any]:
return {
"city_grs": [
{
"grs_name": "ГРС Свердловская",
"design_capacity_th_m3_h": 210.0,
"free_capacity_th_m3_h": 40.5,
"free_capacity_pct": 20.2,
"upgrade_due": "2028",
"outputs_count": 3,
}
],
"total_free_th_m3_h": 40.5,
"outlets_total": outlets_total,
"outlets_deficit": outlets_deficit,
"outlets_needs_calc": outlets_needs_calc,
}
def _gas_outlet_point(
outlet_name: str = "Котельная (Складской проезд,4а)",
free: float | None = 0.512,
needs_calc: bool = False,
distance_m: float = 145.3,
) -> dict[str, Any]:
return {
"outlet_name": outlet_name,
"consumer_type": "котельная",
"free_capacity_mln_m3": free,
"needs_calc": needs_calc,
"distance_m": distance_m,
"lat": 56.812345,
"lon": 60.611111,
}
def _make_response(
power_points: list[dict[str, Any]] | None = None,
water: list[dict[str, Any]] | None = None,
heat: dict[str, Any] | None = None,
gas: dict[str, Any] | None = None,
gas_outlet_points: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
if power_points is None:
power_points = [_power_point()]
if water is None:
water = [
{
"system_kind": "water",
"system_name": "ЗФС, ГСВ",
"reserve_thousand_m3_day": -168.5,
"note": "дефицит",
"period": "2026-Q1",
}
]
by_load_index: dict[str, int] = {}
for p in power_points:
li = p["load_index"] or "unknown"
by_load_index[li] = by_load_index.get(li, 0) + 1
nearest = next((p for p in power_points if p["reserve_mva"] and p["reserve_mva"] > 0), None)
return {
"power_points": power_points,
"power_summary": {
"total_power_points": len(power_points),
"by_load_index": by_load_index,
"nearest_with_reserve": nearest,
},
"water": water,
"gas": gas if gas is not None else _gas_block(),
"gas_outlet_points": (
gas_outlet_points if gas_outlet_points is not None else [_gas_outlet_point()]
),
"heat": heat if heat is not None else _heat_block(),
}
def test_connection_capacity_returns_power_and_water() -> None:
full = _make_response()
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=full,
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
body = r.json()
assert len(body["power_points"]) == 1
assert body["power_points"][0]["voltage_class"] == "110/10"
assert body["power_summary"]["total_power_points"] == 1
assert body["power_summary"]["by_load_index"]["open"] == 1
assert body["power_summary"]["nearest_with_reserve"]["reserve_mva"] == pytest.approx(15.0)
# water: отрицательный резерв (дефицит) сохраняет знак.
assert body["water"][0]["reserve_thousand_m3_day"] == pytest.approx(-168.5)
assert body["water"][0]["system_name"] == "ЗФС, ГСВ"
def test_connection_capacity_empty() -> None:
empty = _make_response(power_points=[], water=[])
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=empty,
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
body = r.json()
assert body["power_points"] == []
assert body["power_summary"]["total_power_points"] == 0
assert body["power_summary"]["nearest_with_reserve"] is None
assert body["water"] == []
def test_connection_capacity_no_reserve_nearest_null() -> None:
"""Все ЦП без положительного резерва → nearest_with_reserve=null."""
pts = [_power_point(reserve=None, load_index="closed")]
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=_make_response(power_points=pts),
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
body = r.json()
assert body["power_summary"]["nearest_with_reserve"] is None
assert body["power_summary"]["by_load_index"]["closed"] == 1
def test_connection_capacity_parcel_not_found_404() -> None:
with patch(
"app.api.v1.parcels.get_connection_capacity",
side_effect=ValueError("Участок '66:41:9999999:1' не найден в БД"),
):
client = TestClient(app)
r = client.get("/api/v1/parcels/66:41:9999999:1/connection-capacity")
assert r.status_code == 404
assert "не найден" in r.json()["detail"]
def test_connection_capacity_passes_radius() -> None:
with patch("app.api.v1.parcels.get_connection_capacity") as mock_svc:
mock_svc.return_value = _make_response()
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=5000")
assert r.status_code == 200
call = mock_svc.call_args
assert call[0][2] == 5000 or call[1].get("radius_m") == 5000
def test_connection_capacity_radius_too_small_422() -> None:
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=50")
assert r.status_code == 422
def test_connection_capacity_radius_too_large_422() -> None:
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=20000")
assert r.status_code == 422
# ── Phase B2: heat-блок + reserve_unit/district + reserve_note ────────────────
def test_connection_capacity_heat_block_per_org() -> None:
"""Heat-блок: строки за последний период per-org + суммарный резерв. #2119 B2."""
heat = _heat_block(
systems=[
{"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2"},
# Другая организация может публиковать свежий квартал раньше/позже —
# per-org MAX(period) сервиса это учитывает (тут просто передаём готовое).
{
"org": "Екатеринбургэнерго",
"system_name": "ТЭЦ Зона-1",
"reserve_gcal_h": -5.0, # дефицит — знак сохраняется
"period": "2026-Q1",
},
],
total=-1.74,
)
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=_make_response(heat=heat),
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
body = r.json()
assert len(body["heat"]["systems"]) == 2
assert body["heat"]["systems"][0]["org"] == "ЕТК"
assert body["heat"]["systems"][0]["reserve_gcal_h"] == pytest.approx(3.26)
# дефицит (отрицательный резерв) сохраняет знак.
assert body["heat"]["systems"][1]["reserve_gcal_h"] == pytest.approx(-5.0)
assert body["heat"]["systems"][1]["period"] == "2026-Q1"
assert body["heat"]["total_reserve_gcal_h"] == pytest.approx(-1.74)
def test_connection_capacity_heat_graceful_absent() -> None:
"""Ключ 'heat' отсутствует (таблица не наполнена / миграция не применена) → null."""
resp = _make_response()
del resp["heat"]
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=resp,
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
assert r.json()["heat"] is None
def test_connection_capacity_heat_empty_systems() -> None:
"""Таблица есть, но систем нет → пустой блок, total=null (graceful degrade)."""
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=_make_response(heat=_heat_block(systems=[], total=None)),
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
body = r.json()
assert body["heat"]["systems"] == []
assert body["heat"]["total_reserve_gcal_h"] is None
def test_connection_capacity_power_reserve_unit_and_district() -> None:
"""power_points пробрасывает reserve_unit ('МВт' у ЕЭСК) + district. #2119 B2."""
pt = _power_point(
name="ПС 110/10 Свердловск",
reserve_unit="МВт",
district="Октябрьский",
)
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=_make_response(power_points=[pt]),
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
p0 = r.json()["power_points"][0]
assert p0["reserve_unit"] == "МВт"
assert p0["district"] == "Октябрьский"
def test_connection_capacity_reserve_note_null_when_reserve_filled() -> None:
"""ЕЭСК теперь с числами → reserve_note NULL когда reserve_mva заполнен. #2119 B2."""
# Заполненный резерв → сервис отдаёт reserve_note=None (даже для ЕЭСК-строк).
pt = _power_point(reserve=12.5, reserve_note=None)
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=_make_response(power_points=[pt]),
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
assert r.json()["power_points"][0]["reserve_note"] is None
# ── Phase B2 PR-4: газовые точки выхода (счётчики в gas + радиус-слой) ─────────
def test_connection_capacity_gas_outlet_counters() -> None:
"""gas-блок несёт счётчики точек выхода последнего среза (city-level). #2119 B2 PR-4."""
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=_make_response(),
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
gas = r.json()["gas"]
assert gas["outlets_total"] == 1037
assert gas["outlets_deficit"] == 218
assert gas["outlets_needs_calc"] == 144
# ГРС-часть B1 не сломана.
assert gas["total_free_th_m3_h"] == pytest.approx(40.5)
def test_connection_capacity_gas_outlet_points_layer() -> None:
"""gas_outlet_points — гео-слой точек выхода: дистанция/needs_calc/дефицит. #2119 B2 PR-4."""
pts = [
_gas_outlet_point(distance_m=145.3, free=0.512, needs_calc=False),
# дефицит со знаком.
_gas_outlet_point(
outlet_name="Промплощадка №2", distance_m=980.4, free=-0.084, needs_calc=False
),
# «0.000*» → NULL мощность + needs_calc=True.
_gas_outlet_point(outlet_name="ТЭЦ узел", distance_m=1500.0, free=None, needs_calc=True),
]
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=_make_response(gas_outlet_points=pts),
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
outlets = r.json()["gas_outlet_points"]
assert len(outlets) == 3
assert outlets[0]["distance_m"] == pytest.approx(145.3)
assert outlets[0]["free_capacity_mln_m3"] == pytest.approx(0.512)
assert outlets[1]["free_capacity_mln_m3"] == pytest.approx(-0.084) # дефицит-знак
assert outlets[2]["free_capacity_mln_m3"] is None
assert outlets[2]["needs_calc"] is True
def test_connection_capacity_gas_outlet_points_empty_default() -> None:
"""gas_outlet_points пуст (нет geom в радиусе) → []; ключ может отсутствовать → []."""
resp = _make_response(gas_outlet_points=[])
del resp["gas_outlet_points"] # сервис может не собрать ключ (миграция 184 не применена)
with patch(
"app.api.v1.parcels.get_connection_capacity",
return_value=resp,
):
client = TestClient(app)
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
assert r.status_code == 200, r.text
assert r.json()["gas_outlet_points"] == []

View file

@ -0,0 +1,244 @@
"""Тесты GET /{cad_num}/neighbor-buildings (Forgejo #2180).
Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены §7 «Размещение
застройки»: GeoJSON FeatureCollection упрощённых полигонов cad_buildings вокруг
участка.
Механика моков: get_db переопределяем через app.dependency_overrides (FastAPI
Depends держит оригинальную ссылку unittest.mock.patch неэффективен). Хендлер
делает один основной `db.execute(...).mappings().all()` (соседи), и ТОЛЬКО при
пустом результате второй `db.execute(...).first()` (EXISTS-пробник участка,
404 vs пустой 200). Мок покрывает обе формы. Очистку dependency_overrides делает
autouse-фикстура в tests/conftest.py. RBAC-гейт в test-mode (settings.testing)
байпасится auth-заголовок не нужен.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.core.db import get_db
from app.main import app
_VALID_CAD = "66:41:0204016:10"
# Валидный GeoJSON-полигон footprint'а — как отдаёт ST_AsGeoJSON (строка).
_POLYGON_GEOJSON = json.dumps(
{
"type": "Polygon",
"coordinates": [
[
[60.640, 56.830],
[60.641, 56.830],
[60.641, 56.831],
[60.640, 56.831],
[60.640, 56.830],
]
],
}
)
def _make_building_row(
cad_num: str = "66:41:0204016:100",
floors: int | None = 9,
building_name: str | None = "Жилой дом",
year_built: int | None = 1998,
distance_m: float = 42.0,
geojson: str | None = _POLYGON_GEOJSON,
) -> dict[str, Any]:
"""Одна строка результата _NEIGHBOR_BUILDINGS_SQL (mappings())."""
return {
"cad_num": cad_num,
"floors": floors,
"building_name": building_name,
"year_built": year_built,
"geojson": geojson,
"distance_m": distance_m,
}
def _build_mock_db(
rows: list[dict[str, Any]],
*,
parcel_exists: bool = True,
) -> MagicMock:
"""Mock Session.
Основной запрос соседей .mappings().all() = rows.
Fallback EXISTS-пробник (вызывается лишь при пустом rows) .first() =
(1,) если parcel_exists иначе None.
"""
mock_db = MagicMock()
mappings_mock = MagicMock()
mappings_mock.all.return_value = rows
execute_result = MagicMock()
execute_result.mappings.return_value = mappings_mock
# .first() дёргается только на EXISTS-пробнике (тот же execute_result — ок,
# т.к. пробник запускается только когда rows пуст).
execute_result.first.return_value = (1,) if parcel_exists else None
mock_db.execute.return_value = execute_result
return mock_db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
@pytest.fixture()
def client() -> TestClient:
return TestClient(app)
# ── happy path: фичи + floors → height_m ──────────────────────────────────
def test_neighbor_buildings_happy_path(client: TestClient) -> None:
rows = [
_make_building_row("66:41:0204016:100", floors=9, distance_m=30.0),
_make_building_row("66:41:0204016:101", floors=16, distance_m=85.0),
_make_building_row(
"66:41:0204016:102",
floors=None, # floors неизвестны → height_m null
building_name="", # пустое имя → null
year_built=None,
distance_m=120.0,
),
]
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
assert r.status_code == 200, r.text
body = r.json()
assert body["type"] == "FeatureCollection"
assert body["count"] == 3
assert body["radius_m"] == 300
assert body["truncated"] is False
assert len(body["features"]) == 3
f0 = body["features"][0]
assert f0["type"] == "Feature"
assert f0["geometry"]["type"] == "Polygon" # распарсен из строки, не escaped
props0 = f0["properties"]
assert props0["cad_num"] == "66:41:0204016:100"
assert props0["floors"] == 9
assert props0["height_m"] == pytest.approx(27.0) # 9 × 3.0
assert props0["building_name"] == "Жилой дом"
assert props0["year_built"] == 1998
assert props0["is_neighbor"] is True
# floors=16 → height_m 48.0
assert body["features"][1]["properties"]["height_m"] == pytest.approx(48.0)
# floors=None → height_m null, building_name пустой → null, year_built null
props2 = body["features"][2]["properties"]
assert props2["floors"] is None
assert props2["height_m"] is None
assert props2["building_name"] is None
assert props2["year_built"] is None
assert props2["is_neighbor"] is True
# ── пустой результат (участок есть, соседей нет) → 200 empty ───────────────
def test_neighbor_buildings_empty_ok(client: TestClient) -> None:
"""Участок в БД есть (EXISTS=true), но соседей в радиусе нет → 200 пустой."""
app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=True))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
assert r.status_code == 200, r.text
body = r.json()
assert body["type"] == "FeatureCollection"
assert body["features"] == []
assert body["count"] == 0
assert body["truncated"] is False
# ── участок не найден (нет geom) → 404 ─────────────────────────────────────
def test_neighbor_buildings_parcel_not_found_404(client: TestClient) -> None:
"""Пустой результат + EXISTS-пробник вернул None (нет geom) → 404."""
app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=False))
r = client.get("/api/v1/parcels/66:41:9999999:1/neighbor-buildings")
assert r.status_code == 404
assert "не найдена" in r.json()["detail"]
# ── truncated: >300 соседей → флаг + обрезка до 300 ────────────────────────
def test_neighbor_buildings_truncated(client: TestClient) -> None:
"""SQL LIMIT 301 → если пришёл 301, отдаём 300 + truncated=true."""
rows = [
_make_building_row(f"66:41:0204016:{i}", floors=(i % 20) + 1, distance_m=float(i))
for i in range(301)
]
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
assert r.status_code == 200, r.text
body = r.json()
assert body["count"] == 300
assert len(body["features"]) == 300
assert body["truncated"] is True
# ── radius валидация → 422 ─────────────────────────────────────────────────
def test_neighbor_buildings_radius_too_small_422(client: TestClient) -> None:
"""radius_m=10 (< min 50) → 422."""
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=10")
assert r.status_code == 422
def test_neighbor_buildings_radius_too_large_422(client: TestClient) -> None:
"""radius_m=2000 (> max 1000) → 422."""
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=2000")
assert r.status_code == 422
def test_neighbor_buildings_radius_passthrough(client: TestClient) -> None:
"""radius_m из query отражается в ответе (и внутри — в bind SQL)."""
app.dependency_overrides[get_db] = _override_db(_build_mock_db([_make_building_row()]))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=500")
assert r.status_code == 200, r.text
assert r.json()["radius_m"] == 500
# ── битая геометрия (ST_AsGeoJSON NULL) пропускается, не роняет сцену ───────
def test_neighbor_buildings_skips_null_geometry(client: TestClient) -> None:
rows = [
_make_building_row("66:41:0204016:100", geojson=None), # битый polygon
_make_building_row("66:41:0204016:101", geojson=_POLYGON_GEOJSON),
]
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
assert r.status_code == 200, r.text
body = r.json()
assert body["count"] == 1
assert body["features"][0]["properties"]["cad_num"] == "66:41:0204016:101"

View file

@ -0,0 +1,275 @@
"""Тесты 3 эндпоинтов полного PDF-отчёта в parcels API (эпик #2259 PR-D).
Покрывает:
POST /{cad}/report: нет analyze-рана 404; enqueue 202 {status:"building"};
готовый кэш (файл на диске) 200 {status:"ready"} без повторного enqueue;
провал брокера 503.
GET /{cad}/report/status: none (нет analyze) / building (analyze есть, файла нет) /
ready (метадата-ран + файл на диске).
GET /{cad}/report/download: файл на диске 200 application/pdf + attachment;
отчёт не готов 404.
Стратегия mock (зеркало test_parcels_forecast.py): DB через dependency_overrides,
`latest_run_for` патчим, Celery `.delay` на реальной таске (lazy import в хендлере
резолвит атрибут в момент вызова). RBAC обходится settings.testing=True (conftest).
"""
from __future__ import annotations
import datetime as dt
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
from app.services.exporters.full_report_pdf import REPORT_SCHEMA_VERSION
_CAD = "66:41:0204016:10"
_ANALYZE_SV = "analyze-1.0"
_FORECAST_SV = "1.0"
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
def _run(run_id: int, result: dict[str, Any] | None = None) -> MagicMock:
r = MagicMock()
r.id = run_id
r.result = result or {}
r.district = "Кировский"
r.created_at = dt.datetime(2026, 7, 3, 10, 0, 0)
return r
def _lrf_router(rows: dict[str, MagicMock | None]):
"""Фабрика side_effect для latest_run_for: row по kwargs['schema_version']."""
def _side(db: Any, cad: str, *, schema_version: str | None = None) -> MagicMock | None:
return rows.get(schema_version)
return _side
# ── POST /{cad}/report ────────────────────────────────────────────────────────
def test_post_report_no_analyze_returns_404() -> None:
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch(
"app.api.v1.parcels.latest_run_for", side_effect=_lrf_router({_ANALYZE_SV: None})
):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
assert resp.status_code == 404, resp.text
assert "анализа" in resp.json()["detail"]
finally:
app.dependency_overrides.clear()
def test_post_report_enqueues_and_returns_202() -> None:
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
delay_mock = MagicMock()
try:
with (
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
):
# _cached_report_result: report-row None → кэша нет, delay вызывается.
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
assert resp.status_code == 202, resp.text
body = resp.json()
assert body["status"] == "building"
assert body["analyze_run_at"] is not None
delay_mock.assert_called_once_with(_CAD)
finally:
app.dependency_overrides.clear()
def test_post_report_cache_hit_returns_200_ready(tmp_path: Path) -> None:
from app.core.db import get_db
pdf = tmp_path / "report.pdf"
pdf.write_bytes(b"%PDF-cached")
report_row = _run(
500,
{
"pdf_path": str(pdf),
"analyze_run_id": 101,
"forecast_run_id": 202,
"generated_at": "2026-07-03T09:00:00+00:00",
"size_bytes": 10,
},
)
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: report_row}
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
delay_mock = MagicMock()
try:
with (
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "ready"
delay_mock.assert_not_called() # кэш есть → не enqueue'им
finally:
app.dependency_overrides.clear()
def test_post_report_broker_down_returns_503() -> None:
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
try:
with (
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
assert resp.status_code == 503, resp.text
finally:
app.dependency_overrides.clear()
# ── GET /{cad}/report/status ──────────────────────────────────────────────────
def test_status_none_when_no_analyze() -> None:
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
rows = {_ANALYZE_SV: None, _FORECAST_SV: None}
try:
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "none"
finally:
app.dependency_overrides.clear()
def test_status_building_when_analyze_but_no_file() -> None:
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
try:
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "building"
assert body["analyze_run_at"] is not None
finally:
app.dependency_overrides.clear()
def test_status_ready_when_file_on_disk(tmp_path: Path) -> None:
from app.core.db import get_db
pdf = tmp_path / "report.pdf"
pdf.write_bytes(b"%PDF")
report_row = _run(
500,
{
"pdf_path": str(pdf),
"analyze_run_id": 101,
"forecast_run_id": 202,
"generated_at": "2026-07-03T09:00:00+00:00",
"size_bytes": 4,
},
)
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: report_row}
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "ready"
assert body["report_generated_at"] == "2026-07-03T09:00:00+00:00"
finally:
app.dependency_overrides.clear()
# ── GET /{cad}/report/download ────────────────────────────────────────────────
def test_download_returns_pdf_when_ready(tmp_path: Path) -> None:
from app.core.db import get_db
pdf = tmp_path / "report.pdf"
pdf.write_bytes(b"%PDF-1.7 payload")
# generated_at — ВЧЕРАШНЯЯ дата (cache-hit со вчера): имя файла должно взять ЕЁ,
# НЕ today (иначе имя врёт про дату сборки — ревью PR-D п.3).
report_row = _run(
500,
{
"pdf_path": str(pdf),
"analyze_run_id": 101,
"forecast_run_id": 202,
"generated_at": "2026-07-01T09:00:00+00:00",
"size_bytes": 16,
},
)
rows = {REPORT_SCHEMA_VERSION: report_row}
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "application/pdf"
cd = resp.headers["content-disposition"]
assert "attachment;" in cd
assert cd.endswith('.pdf"')
assert f"gendesign_report_{_CAD.replace(':', '_')}_" in cd
# Дата в имени — из метадаты (2026-07-01), НЕ today.
assert "2026-07-01" in cd
assert resp.content == b"%PDF-1.7 payload"
finally:
app.dependency_overrides.clear()
def test_download_404_when_not_ready() -> None:
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
rows = {REPORT_SCHEMA_VERSION: None}
try:
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download")
assert resp.status_code == 404, resp.text
assert "готов" in resp.json()["detail"]
finally:
app.dependency_overrides.clear()

View file

@ -77,7 +77,13 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
)
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
district_row = _make_mapping(
{"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0}
{
"district_name": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
@ -88,7 +94,7 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row
@ -157,9 +163,7 @@ def test_horizon_default_12_and_enqueued() -> None:
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
@ -186,9 +190,7 @@ def test_horizon_valid_values_accepted() -> None:
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
assert resp.status_code == 200, f"horizon={h}: {resp.text}"
@ -213,9 +215,7 @@ def test_horizon_invalid_returns_422() -> None:
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
assert resp.status_code == 422, f"horizon={h}: {resp.text}"
@ -234,9 +234,7 @@ def test_enqueue_passes_created_by_header() -> None:
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
@ -261,9 +259,7 @@ def test_enqueue_failure_returns_200_unavailable() -> None:
_start_patches()
delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
@ -314,9 +310,7 @@ def test_get_forecast_ready_when_run_present() -> None:
try:
# 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
# здесь None (gate не применяется).
with patch(
"app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]
) as lrf:
with patch("app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]) as lrf:
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
assert resp.status_code == 200, resp.text
@ -354,9 +348,7 @@ def test_get_forecast_db_error_returns_pending_not_500() -> None:
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch(
"app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")
):
with patch("app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
assert resp.status_code == 202, resp.text

View file

@ -0,0 +1,69 @@
"""Тесты `_poi_detail_from_tags` / `_clean_tag` — обогащение POI-точек §1 карты
«максимумом информации» из OSM tags jsonb (адрес, оператор, часы, сайт, телефон).
Покрывает:
(a) полный набор тегов все поля заполнены, адрес склеен «улица, дом»;
(b) приоритет contact:* над «голым» website/phone, brand как fallback оператора;
(c) фиксированный набор ключей (всегда 5) с None при отсутствии тега;
(d) мусорные значения («yes», пустые строки, не-строки) отбрасываются.
"""
from __future__ import annotations
from app.api.v1.parcels import _clean_tag, _poi_detail_from_tags
_KEYS = {"address", "operator", "opening_hours", "website", "phone"}
def test_full_tags_all_fields_populated() -> None:
out = _poi_detail_from_tags(
{
"addr:street": "улица Маяковского",
"addr:housenumber": "12",
"operator": 'ООО "Аптека+"',
"opening_hours": "Mo-Su 08:00-22:00",
"contact:website": "https://apteka.example",
"contact:phone": "+7 343 000-00-00",
}
)
assert set(out) == _KEYS
assert out["address"] == "улица Маяковского, 12"
assert out["operator"] == 'ООО "Аптека+"'
assert out["opening_hours"] == "Mo-Su 08:00-22:00"
assert out["website"] == "https://apteka.example"
assert out["phone"] == "+7 343 000-00-00"
def test_contact_priority_and_brand_fallback() -> None:
out = _poi_detail_from_tags(
{
"brand": "Пятёрочка", # operator отсутствует → берём brand
"website": "http://bare.example",
"contact:website": "https://contact.example", # приоритетнее
"phone": "+7 000",
"contact:phone": "+7 111", # приоритетнее
}
)
assert out["operator"] == "Пятёрочка"
assert out["website"] == "https://contact.example"
assert out["phone"] == "+7 111"
assert out["address"] is None
def test_fixed_keyset_with_none_when_absent() -> None:
for tags in (None, {}, {"amenity": "pharmacy"}):
out = _poi_detail_from_tags(tags)
assert set(out) == _KEYS
assert all(v is None for v in out.values())
def test_house_only_address() -> None:
assert _poi_detail_from_tags({"addr:housenumber": "5А"})["address"] == "5А"
def test_clean_tag_drops_junk() -> None:
assert _clean_tag("yes") is None
assert _clean_tag(" ") is None
assert _clean_tag(None) is None
assert _clean_tag(123) is None
assert _clean_tag(" Реальное значение ") == "Реальное значение"

View file

@ -54,7 +54,13 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
)
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
district_row = _make_mapping(
{"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0}
{
"district_name": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
@ -65,7 +71,7 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row

View file

@ -0,0 +1,430 @@
"""Unit-тесты PURE HTML-агрегатора §1§3 + альтернативы (эпик #2259 PR-A).
Чистые тесты БЕЗ native libs (WeasyPrint не импортируется) и БЕЗ БД/сети: агрегатор
только ПОТРЕБЛЯЕТ уже-персистнутый `analysis_runs.result` (loose dict `analyze-1.0`).
Фикстур СИНТЕТИЧЕСКИЙ (структура зеркалит прод-payload, но без реальных строк).
Покрываем:
реалистичный полный payload все секции §1§3 + якоря + факты;
отсутствующие секции / None-поля «нет данных», без падения (graceful);
пустой / не-dict payload валидный HTML с «нет данных»;
HTML-escape динамических строк payload (XSS-вектор через внешние источники);
плейсхолдер карты `{{MAP_PARCEL}}` присутствует;
блок альтернатив рисуется только при наличии `program_alternatives`;
каркас `build_full_report_html`: титул / оглавление / опциональный Part B.
DATABASE_URL выставляем до импорта app-модулей (зеркало test_report_md.py) на случай
side-effect'ов импорта пакета app.
"""
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.exporters.full_report_html import (
_ANCHOR_ALT,
_ANCHOR_S1,
_ANCHOR_S2,
_ANCHOR_S3,
_NO_DATA,
MAP_PARCEL_PLACEHOLDER,
build_full_report_html,
build_full_report_html_part_a,
)
# ── Синтетический фикстур (структура прод-payload, без реальных строк) ──────────
def _synthetic_result() -> dict[str, Any]:
"""Реалистичный полный analyze-payload: §1 участок + §2 окружение + §3 сети."""
return {
"cad_num": "00:00:0000000:0000",
# §1 — кадастр / регламент / ЗОУИТ / gate
"egrn": {
"address": "Тестовый регион, тестовый город, улица Примерная, 1",
"area_m2": 12345.0,
"land_category": "Категория земель тестовая",
"permitted_use_text": "тестовое разрешённое использование",
"parcel_status": "Учтенный",
"ownership_type": "Частная",
"right_type": "Собственность",
"cadastral_value_rub": 9876543.0,
},
"parcel_meta": {
"land_subtype": "Землепользование",
"land_category": "Категория земель тестовая",
"permitted_use": "тестовое разрешённое использование",
"cad_cost": 9876543.0,
},
"geometry_suitability": {"area_m2": 12345, "label": "подходящий"},
"nspd_zoning": {
"zone_code": "ТЗ-1",
"zone_name": None,
"max_far": 2.0,
"max_floors": 12,
"max_height_m": 40,
"max_building_pct": 60,
"min_parcel_area_m2": None,
"regulation_source": "test-geoportal",
"regulation_zone_index": "ТЗ-1",
"main_vri": ["2.6 Тестовая многоэтажная застройка", "3.1 Тестовое обслуживание"],
},
"encumbrance": {
"has_zouit": True,
"zouit_count": 2,
"zouit_types": ["Тестовая охранная зона", "Тестовая санитарная зона"],
},
"nspd_zouit_overlaps": [
{
"type_zone": "Тестовая охранная зона сетей",
"name": "Тестовая охранная зона сетей",
"reg_numb_border": "00:00-0.0000",
"coverage_pct": 0.5,
}
],
"gate_verdict": {
"source": "nspd_dump",
"can_build_mkd": False,
"verdict_label": "Нельзя",
"blockers": [{"code": "TEST_BLOCK", "detail": "Тестовый блокер застройки"}],
"warnings": [{"code": "TEST_WARN", "detail": "Тестовое предупреждение"}],
},
# §2 — окружение
"location": {"distance_to_center_km": 7.5},
"metro": {"nearest_top3": [{"name": "Тестовая станция", "distance_m": 4200}]},
"noise": {"level": "тихо", "estimated_db": 43.2},
"air_quality": {"no2": 1.6, "pm10": 4.9, "pm2_5": 4.1, "source": "test-source"},
"geotech_risk": {
"seismic_label": "минимальная сейсмика",
"seismic_intensity_balls": 5,
"permafrost": False,
"industrial_within_500m": 3,
},
"hydrology": {
"flood_risk_flag": False,
"nearest": [{"name": "Тестовая река", "subtype": "river", "distance_m": 500}],
},
"neighbors_summary": {
"neighbors": [
{
"building_name": "Тестовое здание",
"cad_num": "00:00:0000000:0001",
"purpose": "Нежилое",
"floors_parsed": 2,
"distance_m": 0,
}
]
},
# §3 — сети
"utilities": {
"nearest_substation_m": 120,
"nearest_water_main_m": None,
"nearest_gas_m": None,
"nearest_heat_m": None,
"power_line_охранная_зона_flag": False,
"summary": [
{"subtype": "substation", "nearest_m": 120, "count_within_2km": 2},
{"subtype": "pipeline", "nearest_m": 175, "count_within_2km": 4},
],
},
"nspd_engineering_nearby": [
{
"name": None,
"type": None,
"distance_m": 50,
"raw_props": {
"params_name": "Тестовый трубопровод",
"params_purpose": "тестовое коммунальное сооружение",
"cad_number": "00:00:0000000:0002",
},
}
],
}
def _synthetic_alternatives() -> dict[str, Any]:
"""Блок `program_alternatives` (отрицательный фин-вердикт → топ вариантов)."""
return {
"variants": [
{
"housing_class": "comfort",
"floors": 12,
"development_type": "high_rise",
"npv_rub": -216_000_000.0,
"irr": -0.05,
"roi": -0.12,
"residential_sqm": 20000.0,
"apartments_count": 350,
"price_per_sqm_used": 142500.0,
"price_source": "objective_district_median",
"schedule_is_default": True,
},
{
"housing_class": "econom",
"floors": 9,
"development_type": "mid_rise",
"npv_rub": -300_000_000.0,
"irr": -0.09,
"roi": -0.2,
"residential_sqm": 15000.0,
"apartments_count": 280,
"price_per_sqm_used": 110000.0,
"price_source": "class_norm",
"schedule_is_default": False,
},
],
"any_viable": False,
"grid_size": 9,
"caveat": "Оценка по максимальной застройке зоны, не инвест-рекомендация.",
}
# ── 1. Реалистичный полный payload → все секции + якоря ─────────────────────────
def test_full_payload_renders_all_sections_and_anchors() -> None:
html = build_full_report_html_part_a(_synthetic_result(), cad="00:00:0000000:0000")
for anchor in (_ANCHOR_S1, _ANCHOR_S2, _ANCHOR_S3):
assert f'id="{anchor}"' in html
# Ключевые факты §1
assert "улица Примерная, 1" in html
assert "ТЗ-1" in html # зона ПЗЗ
assert "12 345" in html # площадь с пробелом-разрядом
# §2 окружение
assert "Тестовая станция" in html
assert "минимальная сейсмика" in html
# §3 сети
assert "substation" in html
assert "Тестовый трубопровод" in html
# ── 2. Плейсхолдер карты присутствует ───────────────────────────────────────────
def test_map_placeholder_present() -> None:
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
assert MAP_PARCEL_PLACEHOLDER in html
assert "{{MAP_PARCEL}}" in html
# ── 3. gate-вердикт: «Нельзя» + блокеры/предупреждения ──────────────────────────
def test_gate_verdict_renders_blockers_and_warnings() -> None:
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
assert "Нельзя" in html
assert "Тестовый блокер застройки" in html
assert "Тестовое предупреждение" in html
# ── 4. Отсутствующие секции → «нет данных», без падения ─────────────────────────
def test_missing_sections_render_no_data() -> None:
html = build_full_report_html_part_a({}, cad="X")
# Секции и якоря всё равно на месте (каркас граничный).
for anchor in (_ANCHOR_S1, _ANCHOR_S2, _ANCHOR_S3):
assert f'id="{anchor}"' in html
assert _NO_DATA in html
# Плейсхолдер карты рисуется даже на пустом payload.
assert MAP_PARCEL_PLACEHOLDER in html
# ── 5. Не-dict / None payload → валидный HTML с «нет данных» ─────────────────────
def test_non_dict_payload_is_graceful() -> None:
for junk in (None, [], "строка", 42):
html = build_full_report_html_part_a(junk, cad="X") # type: ignore[arg-type]
assert f'id="{_ANCHOR_S1}"' in html
assert _NO_DATA in html
# ── 6. None-поля внутри существующих секций не ломают рендер ─────────────────────
def test_none_fields_within_sections() -> None:
result: dict[str, Any] = {
"egrn": {"address": None, "area_m2": None, "land_category": None},
"nspd_zoning": {"zone_code": None, "max_far": None, "main_vri": None},
"utilities": {"summary": None, "nearest_substation_m": None},
"metro": {"nearest_top3": None},
"hydrology": {"nearest": None},
"neighbors_summary": {"neighbors": None},
}
html = build_full_report_html_part_a(result, cad="X")
# Не должно быть исключений; секции присутствуют, «нет данных» где пусто.
assert f'id="{_ANCHOR_S2}"' in html
assert _NO_DATA in html
# ── 7. HTML-escape динамических строк payload (XSS-вектор) ───────────────────────
def test_dynamic_strings_are_escaped() -> None:
result: dict[str, Any] = {
"egrn": {"address": "<script>alert('xss')</script>"},
"nspd_zoning": {
"zone_code": "<b>zone</b>",
"main_vri": ["<img src=x onerror=alert(1)>"],
},
"gate_verdict": {
"can_build_mkd": True,
"verdict_label": "<i>ok</i>",
"blockers": [{"code": "<code>", "detail": "<detail>"}],
},
}
html = build_full_report_html_part_a(result, cad="X")
# Сырые теги payload не должны попасть в разметку неэкранированными.
assert "<script>alert" not in html
assert "&lt;script&gt;alert" in html
assert "<img src=x onerror" not in html
assert "&lt;img src=x onerror" in html
assert "&lt;b&gt;zone&lt;/b&gt;" in html
# ── 8. Блок альтернатив: рисуется только при наличии program_alternatives ────────
def test_alternatives_block_present_when_data() -> None:
result = _synthetic_result()
result["program_alternatives"] = _synthetic_alternatives()
html = build_full_report_html_part_a(result, cad="X")
assert f'id="{_ANCHOR_ALT}"' in html
assert "Комфорт" in html # RU-метка housing_class
assert "высотная" in html # RU-метка development_type
assert "216 млн ₽" in html # NPV округлён до млн
assert "наименее убыточные" in html # any_viable=False → эта формулировка
assert "не инвест-рекомендация" in html # caveat
def test_alternatives_block_absent_when_missing() -> None:
# Нет ключа вовсе.
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
assert f'id="{_ANCHOR_ALT}"' not in html
# None / пустые variants → тоже пропуск.
for empty in (None, {}, {"variants": []}):
result = _synthetic_result()
result["program_alternatives"] = empty
html = build_full_report_html_part_a(result, cad="X")
assert f'id="{_ANCHOR_ALT}"' not in html
def test_alternatives_any_viable_true_subtitle() -> None:
alt = _synthetic_alternatives()
alt["any_viable"] = True
alt["variants"][0]["npv_rub"] = 42_000_000.0 # положительный → «+42 млн ₽»
result = _synthetic_result()
result["program_alternatives"] = alt
html = build_full_report_html_part_a(result, cad="X")
assert "сходится при другой программе" in html
assert "+42 млн ₽" in html
# ── 8b. connection-capacity в §3 (эпик #2259 PR-D — ресурсные резервы) ──────────
def _synthetic_connection_capacity() -> dict[str, Any]:
"""Синтетический connection-capacity (структура ConnectionCapacityResponse)."""
return {
"power_summary": {
"total_power_points": 3,
"by_load_index": {"open": 2, "closed": 1},
"nearest_with_reserve": {
"reserve_mva": 12.5,
"reserve_unit": "МВА",
"voltage_class": "110/10",
"distance_m": 640.0,
},
},
"power_points": [{"sc_name": "ПС Тестовая"}],
"water": [{"system_name": "ЗФС, ГСВ", "reserve_thousand_m3_day": 5.4, "period": "2026-Q2"}],
"gas": {
"city_grs": [
{"grs_name": "ГРС-1", "free_capacity_th_m3_h": 18.0, "free_capacity_pct": 22.3}
]
},
"heat": {
"systems": [
{"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.1, "period": "2026-Q2"}
]
},
"nearby_network_zones": [
{
"net_kind": "thermal",
"human_label": "Тепловая сеть (по охранной зоне)",
"distance_m": 24,
}
],
}
def test_connection_capacity_block_rendered_in_section_3() -> None:
html = build_full_report_html_part_a(
_synthetic_result(), cad="X", connection_capacity=_synthetic_connection_capacity()
)
assert "Электроснабжение — свободная мощность" in html
assert "Водоснабжение/водоотведение — резервы" in html
assert "Газоснабжение — свободная мощность ГРС" in html
assert "Теплоснабжение — резервы систем" in html
assert "Сети рядом (охранные зоны)" in html
# Значения/подписи присутствуют.
assert "110/10" in html # класс напряжения ближайшего ЦП
assert "22.3%" in html # free_capacity_pct — уже проценты, НЕ домножены на 100
assert "Тепловая сеть (по охранной зоне)" in html
def test_connection_capacity_absent_when_none() -> None:
"""connection_capacity=None (default) → блок резервов не рисуется (backward-compat)."""
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
assert "Электроснабжение — свободная мощность" not in html
# Пустой dict → тоже пропуск.
html2 = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity={})
assert "Газоснабжение — свободная мощность ГРС" not in html2
# ── 9. Каркас документа: титул / оглавление / опциональный Part B ────────────────
def test_document_frame_with_part_a_only() -> None:
part_a = build_full_report_html_part_a(_synthetic_result(), cad="00:00:0000000:0000")
doc = build_full_report_html(
part_a,
None,
cad="00:00:0000000:0000",
address="улица Примерная, 1",
generated_at="03.07.2026",
)
assert doc.startswith("<!DOCTYPE html>")
assert "00:00:0000000:0000" in doc
assert "улица Примерная, 1" in doc
assert "03.07.2026" in doc
# Оглавление §1§3 есть, пункт Part B (§4§7) скрыт.
assert f'href="#{_ANCHOR_S1}"' in doc
assert "section-4" not in doc
# Part A встроен.
assert f'id="{_ANCHOR_S1}"' in doc
def test_document_frame_with_part_b_adds_toc_entry() -> None:
part_a = build_full_report_html_part_a(_synthetic_result(), cad="X")
part_b = '<div class="section" id="section-4"><h2>§4 test</h2></div>'
doc = build_full_report_html(part_a, part_b, cad="X", address=None, generated_at="03.07.2026")
assert "section-4" in doc
assert part_b in doc
def test_document_frame_escapes_cad_and_address() -> None:
doc = build_full_report_html(
"<div></div>",
None,
cad="<script>x</script>",
address="<b>addr</b>",
generated_at="03.07.2026",
)
assert "<script>x</script>" not in doc
assert "&lt;script&gt;x&lt;/script&gt;" in doc
assert "&lt;b&gt;addr&lt;/b&gt;" in doc

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