Compare commits

..

No commits in common. "main" and "fix/tradein-deploy-scraper-paths-filter" have entirely different histories.

657 changed files with 50461 additions and 65897 deletions

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: sonnet
model: opus
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
---

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: sonnet
model: opus
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
---

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: sonnet
model: opus
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
---
@ -78,7 +78,6 @@ Staff+ code reviewer в autonomous-merge режиме. Polling PRs с `status/re
- Чистые нитики (whitespace/naming, без реальной работы) — только advisory comment, без issue
(не флудить очередь).
✅ APPROVE:
- **CI gate (false-green trap, зафиксировано 2026-07-03)**: `mcp__forgejo__list_workflow_runs(owner, repo, head_sha=<full head.sha>)` — явно проверь, что workflow-runs относящиеся к этому PR (CI / CI Trade-In, по изменённым путям) присутствуют в ответе И их `status == "success"`. Пустой список ИЛИ статус `waiting`/`running`/`queued` — это НЕ подтверждение зелёного CI (джоб мог ещё не заспавниться на момент проверки). Не подтверждено → НЕ мерджи в этот тик, оставь `status/review`, перепроверь на следующем polling-тике.
- `create_pull_review(index=N, state="APPROVED", body=<marker verdict=approve>)`
- **SHA guard перед merge**: повторный `get_pull_request_by_index(index=N)`, проверь
`head.sha[:7] == sha7` из marker — иначе устаревший verdict до fixup-push, abort merge
@ -109,8 +108,6 @@ Staff+ code reviewer в autonomous-merge режиме. Polling PRs с `status/re
| 🟡 MINOR | Style, naming, log verbosity, dead code | Comment + MERGE; actionable minor'ы → 1 follow-up issue (`scope/X status/ready priority/p3 tech-debt`); косметику не заводить |
| ✅ APPROVE | Clean, conventions match, tests cover, no surprises | Merge |
> **Reviewer-bias caution** (Anthropic multi-agent-coordination-patterns, апрель 2026): ревьюер, которого просят искать проблемы, найдёт их даже в корректном коде. 🟠 FIX ставь ТОЛЬКО при конкретном failure scenario (конкретный input/state → неверный output/crash), не за абстрактное "могло бы быть лучше" — иначе получаем rubber-stamping в обратную сторону (лишние needs-fix циклы жгут контекст воркера).
## Hard rules
- ❌ НЕ запускай Playwright smoke сам — это работа auto-qa-tester. Передача через status/qa.

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: sonnet
model: opus
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents
---

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: sonnet
model: opus
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
---

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: sonnet
model: opus
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: sonnet
model: opus
color: blue
---

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: sonnet
model: opus
memory: project
color: red
---
@ -108,10 +108,10 @@ mcp__obsidian__obsidian_simple_search "<keyword>"
- Time spent: ~3 min
### Critical issues (BLOCK push)
- [ ] `file.py:42`конкретный failure scenario (input/state → неверный output/crash) + fix suggestion. Без repro-сценария — не критикал, переквалифицируй в Minor или Positive observation.
- [ ] `file.py:42`описание проблемы + fix suggestion
### Minor issues (можно fix потом)
- [ ] `file.py:84` — улучшение (не rubber-stamp: если это не влияет на поведение — Positive observations или пропусти)
- [ ] `file.py:84` — улучшение
### Positive observations
- ✅ Что сделано хорошо

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: sonnet
model: opus
color: purple
---

View file

@ -2,7 +2,7 @@
name: devops-engineer
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: sonnet
model: opus
color: orange
---

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: sonnet
model: opus
color: green
---

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: sonnet
model: opus
memory: project
color: cyan
---

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: sonnet
model: opus
color: yellow
---

View file

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

View file

@ -32,40 +32,3 @@ Target: `tradein-mvp/frontend` (NOT the repo-root `frontend/`, which is the Site
- Synth scan re-includes Next route/layout/error files on every build → `componentSrcMap` nulls (13 entries) must persist. New app-router pages would need adding.
- `.d.ts` props are weak (`[key:string]:unknown`) — synth mode has no built types. Real prop contracts live in each component's source `interface Props`. A real `tsup`/`tsc` lib build would fix this (recommend if the agent needs strong API contracts).
- Grades clear on any `cfg.provider`/preview-affecting config change (expected) — re-grade from fresh sheets.
## Re-sync 2026-07-02 — v2 dashboard sync (main was 223 commits ahead; harness authored on June components)
**Two source-kit.mjs fork fixes were REQUIRED for the evolved v2 codebase (both committed in the override, declared in cfg.libOverrides):**
1. **Exclude `next/font` importers from the synth-entry.** `src/app/v2/layout.tsx` calls `Manrope()`/`IBM_Plex_Mono()` from `next/font/google` at MODULE TOP-LEVEL. esbuild can't resolve the Next-only loader → stubs it `(void 0)``undefined()` aborts the whole browser IIFE → `window.TradeInUI` empty → ALL 58 components vanish (`[RENDER] root empty` everywhere, `[BUNDLE_EXPORT]`). Fix: `comps` filter drops any file whose content matches `/from\s+['"]next\/font/`. If a NEW file top-level-calls a Next build-time loader, same class of crash — extend the filter.
2. **Re-export default-exported components.** `export * from <path>` does NOT re-export a module's `default`. The v2 views/nav/overlay/panel are authored `export default function <Name>` (AnalyticsView, HeroBar, HistoryView, ParamsPanel, SectionOverlay, SourcesView, TopNav) → absent from `window.TradeInUI``[BUNDLE_EXPORT] not a component`. Fix: entry now also emits `export { default as <Name> } from <path>` for each `export default function/class <Name>`. Named-export components (`export function X`) were always fine.
**componentSrcMap:** added `TradeInV2Layout / TradeInV2Page / SaleShareLayout / SaleSharePage` = null (Next route files, never DS components — same as RootLayout et al.).
**overrides (grid):** MapPicker `{cardMode:single, primaryStory:Default}` (portal/fixed), StreetDealsCard + Topbar `{cardMode:column}` (wider than a grid cell).
**Floor-card components (6, authorable on any future re-sync):** SaleShareControls, SaleShareList, SaleShareMap, SectionOverlay, LocationDrawer, BuildingListingsDrawer — overlays/drawers whose props don't seed rich data via PreviewProvider, so they show the honest typographic floor. All the OTHER v2 components (views/nav/hero/panel) render richly because the provider seeds their data.
**Known render warn:** ResultPanel — `variants identical` (Default vs Error cells render near-identically; not broken, the Error cell just doesn't diverge visually enough). Recorded here so re-syncs don't read it as new.
**Font note (re-sync risk):** the v2 HUD's real typefaces are **Manrope + IBM_Plex_Mono** (loaded by the excluded `v2/layout.tsx` via next/font → CSS vars `--font-manrope`/`--font-plex-mono`). `cfg.extraFonts` ships **Inter + JetBrains Mono** (June brand fonts). v2 previews therefore render Manrope-slot text in the shipped fallback. If brand-exact v2 rendering is wanted, add Manrope + IBM Plex Mono woff2 to `.design-sync/fonts/` + `cfg.extraFonts` and map the `--font-manrope`/`--font-plex-mono` vars.
**ResultPanel NAME COLLISION (fixed 2026-07-02):** two components named `ResultPanel` in src — `app/scrapers/_components/ScraperPage.tsx` (`export function ResultPanel`, scraper mut-panel) and `components/trade-in/v2/ResultPanel.tsx` (`export default function ResultPanel`, the v2 estimate result / honest hero). The default-re-export fix makes the v2 one win `window.TradeInUI.ResultPanel` (explicit `export {default as ResultPanel}` beats the star export). But the June authored preview `previews/ResultPanel.tsx` was the SCRAPER one (`mut` props) → the v2 component rendered fine (reads data from context) but its prompt.md documented the wrong (scraper) API. FIX: (1) `cfg.componentSrcMap.ResultPanel = "src/components/trade-in/v2/ResultPanel.tsx"` pins enrichment to v2; (2) re-authored `previews/ResultPanel.tsx``<ResultPanel onNavigate={()=>{}} />` (v2 API; `data` defaults to the component's built-in RESULT_FIXTURE = honest-hero). If a re-sync ever shows ResultPanel with scraper markup, the pin/preview regressed. Any NEW duplicate PascalCase component name will hit the same class of issue — the default-re-export makes the default-export win; pin + author the intended one.
**Upload gotcha (this machine):** after a follow-up single-component rebuild, `resync-verdict.json upload.deletePaths` came back with ALL other components (318 paths) — a stale-anchor diff artifact, NOT real deletions (ResultPanel + audit/uploads were absent from it). Do NOT feed that deletePaths to delete_files (would nuke the project). For a focused single-component re-upload: write just that component's `components/<group>/<Name>/*` + `_preview/<Name>.js` + `_ds_sync.json` (sentinel-fenced), deletes=[].
- **CORRECTION (2026-07-03):** the 318 deletePaths were NOT a diff artifact — they were REAL local deletions caused by a source-kit fork bug (see below): the July-02 `componentSrcMap.ResultPanel` pin collapsed the synth build to 1 component, so the local ds-bundle genuinely lost the other 53. The "don't feed deletePaths blindly" advice stands (it saved the project), but the root cause is fixed now.
## Re-sync 2026-07-03 — 6 floor cards authored + v2 brand fonts (#2267)
**source-kit.mjs fork fix #3 (REQUIRED): componentSrcMap pin must AUGMENT synth discovery, not replace it.** In synth mode there is no shipped `.d.ts`, so `exportedNames()` is empty and `names` contains ONLY the non-null `componentSrcMap` pins. The old guard `if (!components.length && synthEntry) components = deriveComponentsFromSrc(...)` therefore never ran once a single pin existed (ResultPanel, added 2026-07-02) → the whole bundle collapsed to 1 component (`(stale preview: X — component no longer exported)` for everything else; verdict shows all others as `removed` + bogus deletePaths). Fix in the fork: when `synthEntry`, always union `deriveComponentsFromSrc(srcFiles)` (minus `null`-excluded) with the pinned names. Any future pin would have re-triggered this. NB: the fork edit re-keys EVERY component's sourceKey → full re-grade pass (done: 47/54 renderHashes byte-identical to the 2026-07-02 anchor → carry-forward grades; the rest eyeballed).
**6 floor cards → authored (all graded good):**
- `SaleShareControls` — inline `SaleShareSummary` (histogram 7 корзин, coverage 92.1%); порог 8% приглушает нижнюю корзину; все фильтры.
- `SaleShareList` — 6 инлайн-домов ЕКБ (heat-бейджи, «аварийный», over_100 «возможно, несколько корпусов», selected-row) + Empty cell. `cardMode: column`.
- `SaleShareMap` — 8 heat-маркеров + открытый popup выбранного дома; OSM-тайлы офлайн не красятся (известно, как MapCard).
- `SectionOverlay` (v2) — рендерится contained в relative-«артборде» (absolute-позиционирование против ближайшего positioned ancestor; wrapper height 680 + v2-градиент). 2 cells: HistoryView (04) / AnalyticsView (06) на их встроенных fixtures (data-props не переданы). `cardMode: column`.
- `LocationDrawer` (v2) — open=true в таком же contained-артборде (height 640). `cardMode: column`.
- `BuildingListingsDrawer``createPortal` + `.ss-drawer-overlay` = position:fixed → `cardMode: single` (прецедент MapPicker). Шапка богатая (props), тело fetch-coupled (`useBuildingListings`, data-prop нет) → офлайн честный error-state «Не удалось загрузить объявления дома». Сидировать можно было бы ключом `["sale-share","listings",<house_id>]` в PreviewProvider — сознательно НЕ сделано (кэш-ключ завязан на house_id фикстуры превью; хрупко).
**v2 brand fonts shipped (Manrope + IBM Plex Mono).** woff2 (latin+cyrillic; Manrope variable 200-800, Plex Mono static 300/400/500 — веса из `app/v2/layout.tsx`) скачаны с Google Fonts → `.design-sync/fonts/`, @font-face добавлены в `brand-fonts.css`. **ГОЧА: extraFonts-пайплайн (`css.mjs extractFonts`) извлекает ТОЛЬКО `@font-face`-блоки — `:root{}` из brand-fonts.css молча выбрасывается.** Маппинг `--font-manrope`/`--font-plex-mono` поэтому живёт в `preview-provider.tsx` (`<style>` в провайдере — капчер-путь) + задокументирован для design-консюмеров в `conventions.md` (сниппет `:root{...}`). После фикса v2-цифры реально в Plex Mono (проверено по sheets ResultPanel/SectionOverlay).
**Upload 2026-07-03: НЕ выполнен из worker-сессии** — DesignSync MCP-тулов в ней нет (ToolSearch пуст). Verdict готов и чист: ok=true, pendingGrade=0, deletePaths=0 (легитимно — remote-анкор полный, local снова 54 компонента), upload.any=true, components=54 (все re-key'нуты форк-фиксом), bundle+styling+aux=true. Main-сессия: залить ds-bundle по verdict'у (deletePaths пуст — ничего не удалять) и после успеха скопировать свежий `ds-bundle/_ds_sync.json``.design-sync/.cache/remote-sync.json` (новый анкор).

View file

@ -24,21 +24,7 @@
"ScraperPage": null,
"ScrapersUnifiedPage": null,
"TradeInPage": null,
"YandexScraperPage": null,
"TradeInV2Layout": null,
"TradeInV2Page": null,
"SaleShareLayout": null,
"SaleSharePage": null,
"ResultPanel": "src/components/trade-in/v2/ResultPanel.tsx"
},
"overrides": {
"MapPicker": { "cardMode": "single", "primaryStory": "Default" },
"StreetDealsCard": { "cardMode": "column" },
"Topbar": { "cardMode": "column" },
"SaleShareList": { "cardMode": "column" },
"SectionOverlay": { "cardMode": "column" },
"LocationDrawer": { "cardMode": "column" },
"BuildingListingsDrawer": { "cardMode": "single", "primaryStory": "Default" }
"YandexScraperPage": null
},
"provider": {
"component": "PreviewProvider"
@ -46,8 +32,5 @@
"extraEntries": [
"../../.design-sync/preview-provider.tsx"
],
"readmeHeader": ".design-sync/conventions.md",
"extraFonts": [
"../../.design-sync/fonts/brand-fonts.css"
]
"readmeHeader": ".design-sync/conventions.md"
}

View file

@ -35,12 +35,6 @@ This is **not Tailwind and not CSS-in-JS**. Styling is a **global stylesheet** o
| Border | `--border-soft` `--border-card` `--border-strong` |
| Shape/type | `--radius` `--radius-sm` `--radius-lg` `--shadow-md` `--font-sans` (Manrope) `--font-mono` `--container` |
**v2 (МЕРА HUD) fonts:** the `v2/*` components read `var(--font-manrope)` / `var(--font-plex-mono)` (in the app these come from `next/font`). The woff2 for both families ships in `fonts/fonts.css`; define the vars once at your design root:
```css
:root { --font-manrope: 'Manrope'; --font-plex-mono: 'IBM Plex Mono'; }
```
```tsx
<div style={{ background: "var(--bg-card)", border: "1px solid var(--border-card)",
borderRadius: "var(--radius)", padding: 16, color: "var(--fg-primary)",

Binary file not shown.

View file

@ -1,112 +0,0 @@
/* Brand fonts shipped with the trade-in DS bundle.
* June set: Inter + JetBrains Mono (variable woff2).
* v2 HUD set (2026-07-03): Manrope + IBM Plex Mono the МЕРА v2 typefaces,
* loaded in the app via next/font in app/v2/layout.tsx (CSS vars
* --font-manrope / --font-plex-mono). next/font is excluded from the synth
* bundle, so we ship the woff2 here and map the vars in :root below.
* All fonts: latin + cyrillic subsets (ЕКБ addresses are Cyrillic). */
:root {
--font-manrope: 'Manrope';
--font-plex-mono: 'IBM Plex Mono';
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('./Manrope-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('./Manrope-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('./IBMPlexMono-300-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('./IBMPlexMono-300-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./IBMPlexMono-400-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./IBMPlexMono-400-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./IBMPlexMono-500-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./IBMPlexMono-500-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('./Inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('./Inter-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('./JetBrainsMono-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('./JetBrainsMono-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

View file

@ -10,7 +10,7 @@
// knob for component inclusion: non-null value = add/pin src path, null =
// exclude a .d.ts-exported internal.
import { existsSync, writeFileSync, readFileSync } from 'node:fs';
import { existsSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { Project, Node, ts } from 'ts-morph';
// forked from design-sync lib/source-kit.mjs — synth-entry process shim (Next.js app reads process.env.NEXT_PUBLIC_*)
@ -70,18 +70,7 @@ export async function resolvePackage(ctx) {
console.error(`[NO_DIST] ${PKG} has no built entry and no src/ to synthesize from — run its build.`);
process.exit(1);
}
// Next route files (app/**/layout.tsx, page.tsx) that call `next/font`
// loaders (Manrope(), IBM_Plex_Mono()) at module top-level crash the browser
// IIFE: esbuild can't resolve the Next-only loader → stubs it to `undefined`
// → `undefined()` aborts the whole bundle → window.<global> stays empty and
// every component vanishes. These files are never DS components anyway, so
// drop any `next/font`-importing module from the synth-entry set.
const comps = srcFiles.filter(
(p) =>
SRC_IMPL_RX.test(p) &&
!NON_IMPL_RX.test(p) &&
!/from\s+['"]next\/font/.test(readFileSync(p, 'utf8')),
);
const comps = srcFiles.filter((p) => SRC_IMPL_RX.test(p) && !NON_IMPL_RX.test(p));
// Next.js app code reads process.env.NEXT_PUBLIC_* at module top-level; in
// the browser IIFE `process` is undefined → every component throws. Emit a
// shim module and import it FIRST (ESM evaluates the first import's body
@ -100,25 +89,10 @@ export async function resolvePackage(ctx) {
"__e.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ??= 'trade-in@example.com';\n",
);
entry = join(OUT, '.pkg-entry.mjs');
// `export *` does NOT re-export a module's default. Components authored as
// `export default function <Name>` (the v2 views/nav/overlay/panel) would be
// absent from window.<global> → [BUNDLE_EXPORT] "not a component". Re-export
// each named default under its declared name so default-exported components
// reach the global alongside the named ones.
const defaultReexports = comps
.map((p) => {
const m = readFileSync(p, 'utf8').match(
/export\s+default\s+(?:async\s+)?(?:function|class)\s+([A-Z][A-Za-z0-9]*)/,
);
return m ? `export { default as ${m[1]} } from ${JSON.stringify(p)};` : null;
})
.filter(Boolean);
writeFileSync(
entry,
`import ${JSON.stringify(slash(shim))};\n` +
comps.map((p) => `export * from ${JSON.stringify(p)};`).join('\n') +
'\n' +
defaultReexports.join('\n') +
'\n',
);
synthEntry = true;
@ -143,17 +117,8 @@ export async function resolvePackage(ctx) {
names.add(k);
}
let components = [...names].sort().map((name) => ({ name, group: 'general' }));
if (synthEntry) {
// Synth mode has no shipped .d.ts → `names` holds only componentSrcMap
// pins. A non-null pin must AUGMENT src discovery (it exists to pin
// enrichment to a specific file), not REPLACE it — with the old
// `!components.length` guard a single pin (ResultPanel, 2026-07-02)
// collapsed the whole synth bundle to just the pinned component.
const derived = deriveComponentsFromSrc(srcFiles).filter((c) => srcMap[c.name] !== null);
const have = new Set(components.map((c) => c.name));
components = components
.concat(derived.filter((c) => !have.has(c.name)))
.sort((a, b) => a.name.localeCompare(b.name));
if (!components.length && synthEntry) {
components = deriveComponentsFromSrc(srcFiles).filter((c) => srcMap[c.name] !== null);
}
if (!components.length) {
if (cfg.cssEntry || existsSync(join(PKG_DIR, 'styles.css'))) {

View file

@ -57,17 +57,5 @@ export function PreviewProvider({ children }: { children: React.ReactNode }) {
);
return qc;
});
return (
<QueryClientProvider client={client}>
{/* v2 HUD font vars. The app defines --font-manrope/--font-plex-mono via
next/font in app/v2/layout.tsx, which is excluded from the synth
bundle (next/font loader crash) so the vars never exist in capture
and tokens.font.* fell back. The @font-face for both families ships
in .design-sync/fonts/brand-fonts.css (cfg.extraFonts), but that
pipeline extracts @font-face blocks ONLY (a :root{} there is
dropped) map the vars here instead. */}
<style>{":root{--font-manrope:'Manrope';--font-plex-mono:'IBM Plex Mono'}"}</style>
{children}
</QueryClientProvider>
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View file

@ -1,36 +0,0 @@
import { BuildingListingsDrawer } from 'tradein-mvp-frontend';
// Правый drawer с активными объявлениями выбранного дома (/trade-in/sale-share):
// createPortal в document.body, .ss-drawer-overlay = position:fixed по всему
// вьюпорту (как MapPicker) → cardMode:single. Шапка — адрес + тепловой бейдж
// доли + «N из M квартир» (из props). Тело FETCH-COUPLED: useBuildingListings
// (GET /buildings/{id}/listings) — data-prop нет, глобальный query-cache пуст →
// в офлайн-capture честный pending/error-state («Загрузка объявлений…» /
// «Не удалось загрузить объявления дома»). На реальной странице — список
// объявлений с ценой, ₽/м², этажом и ссылкой на оригинал.
const building = {
house_id: 3101,
address: 'ул. Викулова, 46',
lat: 56.8412,
lon: 60.5556,
sale_share_pct: 34.6,
sale_share_pct_45d: 41.2,
listings_45d: 33,
over_100: false,
active_secondary: 27,
flat_count_effective: 78,
gar_match_method: 'cadastre',
median_price_rub: 4_950_000,
median_price_per_m2: 158_500,
avg_days_on_market: 74,
year_built: 1972,
house_type: 'panel',
total_floors: 9,
series_name: '1-468',
is_emergency: false,
};
/** Открытый drawer дома «ул. Викулова, 46» (34.6% в продаже, 27 из 78 квартир). */
export const Default = () => (
<BuildingListingsDrawer building={building} onClose={() => {}} />
);

View file

@ -1,25 +0,0 @@
import { LocationDrawer } from 'tradein-mvp-frontend';
// «ПОЯСНЕНИЕ К РАСЧЁТУ» — правый drawer HUD «МЕРА Оценка» (/trade-in/v2),
// открывается с «?» у «КОЭФ. ЛОКАЦИИ» в HeroBar. Честная методика: как
// агрегируются источники (Циан/Я.Недвижимость/Авито/Домклик + Росреестр) и
// явная плашка «коэффициент локации в разработке». Контент статичный —
// данных не принимает, только open/onClose. Drawer absolute-позиционирован
// (width 452 + scrim) — рендерим contained внутри relative-обёртки.
/** Открытое состояние: scrim + выдвинутая панель с методикой и info-плашкой
* о локации. */
export const Default = () => (
<div
style={{
position: 'relative',
height: 640,
borderRadius: 10,
overflow: 'hidden',
background:
'radial-gradient(1100px 520px at 30% -10%, #f7fbff 0%, #eef4fa 55%, #e6eef7 100%)',
}}
>
<LocationDrawer open={true} onClose={() => {}} />
</div>
);

View file

@ -1,10 +1,36 @@
import { ResultPanel } from 'tradein-mvp-frontend';
/**
* ResultPanel (v2) центральная панель результата оценки на `/trade-in/v2`:
* одна цифра-герой (ожидаемая цена продажи / «оценка»), тиры median + ДКП,
* доверительные диапазоны, источники и мини-гистограмма распределения.
* Данные по умолчанию берутся из встроенной `RESULT_FIXTURE` компонента,
* поэтому карточке достаточно передать `onNavigate` (навигация по секциям).
*/
export const Default = () => <ResultPanel onNavigate={() => {}} />;
/** Переиспользуемая панель результата мутации скрапера. Успех JSON-дамп
* ответа в `.scraper-result`. */
export const Default = () => (
<ResultPanel
mut={{
isSuccess: true,
isPending: false,
error: null,
data: {
run_id: 4821,
source: 'avito',
status: 'completed',
anchors_seen: 64,
lots_scraped: 312,
houses_matched: 47,
detail_fetched: 298,
errors: 0,
started_at: '2026-05-30T09:12:00Z',
finished_at: '2026-05-30T09:48:21Z',
},
}}
/>
);
/** Ветка ошибки — красная панель `.scraper-result--error` с текстом сбоя. */
export const Error_ = () => (
<ResultPanel
mut={{
isSuccess: false,
isPending: false,
error: new Error('502 Bad Gateway: upstream avito proxy timeout'),
}}
/>
);

View file

@ -1,54 +0,0 @@
import { SaleShareControls } from 'tradein-mvp-frontend';
// Панель «Порог и фильтры» страницы /trade-in/sale-share. Controlled-компонент:
// состояние живёт в page.tsx и приходит через props. Сводка (гистограмма
// распределения sale_share_pct) — реалистичные значения по покрытию ЕКБ
// (view v_building_sale_share ≈ 6.7k домов вторички, знаменатель ГАР у ~92%).
const summary = {
total_secondary_buildings: 6667,
buildings_with_denominator: 6143,
coverage_pct: 92.1,
max_pct: 42.0,
p95_pct: 11.3,
histogram: [
{ bucket: '0-5', count: 4980 },
{ bucket: '5-10', count: 642 },
{ bucket: '10-20', count: 298 },
{ bucket: '20-30', count: 121 },
{ bucket: '30-50', count: 57 },
{ bucket: '50-100', count: 18 },
{ bucket: '100+', count: 6 },
],
};
const HOUSE_TYPES = ['panel', 'brick', 'monolith', 'monolith_brick', 'block', 'stalin'];
/** Порог 8% на слайдере: корзины ниже порога приглушены (opacity), тепловая
* окраска столбцов greenred повторяет цвет маркеров карты. Фильтры: город,
* цена, год постройки, тип дома, мин. объявлений, сортировка. */
export const Default = () => (
<SaleShareControls
summary={summary}
minPct={8}
onMinPct={() => {}}
city="Екатеринбург"
onCity={() => {}}
priceMin=""
onPriceMin={() => {}}
priceMax=""
onPriceMax={() => {}}
yearMin=""
onYearMin={() => {}}
yearMax=""
onYearMax={() => {}}
houseType=""
onHouseType={() => {}}
houseTypeOptions={HOUSE_TYPES}
sort="share_desc"
onSort={() => {}}
window="now"
onWindow={() => {}}
minCount={3}
onMinCount={() => {}}
/>
);

View file

@ -1,50 +0,0 @@
import { SaleShareList } from 'tradein-mvp-frontend';
// Список домов /trade-in/sale-share, отсортирован по доле в продаже (server-side
// share_desc). Реалистичные дома ЕКБ (view v_building_sale_share): адрес,
// тепловой бейдж доли, «N из M квартир», медиана + ₽/м², экспозиция,
// год/тип/этажность/серия. Включены оба edge-бейджа: «аварийный» и
// «возможно, несколько корпусов» (over_100 — ГАР-коллизия адреса).
const buildings = [
{ house_id: 4212, address: 'ул. Космонавтов, 52', lat: 56.8890, lon: 60.6132, sale_share_pct: 128.0, sale_share_pct_45d: 131.5, listings_45d: 46, over_100: true, active_secondary: 32, flat_count_effective: 25, gar_match_method: 'address', median_price_rub: 3_650_000, median_price_per_m2: 121_700, avg_days_on_market: 96, year_built: 1961, house_type: 'brick', total_floors: 5, series_name: null, is_emergency: false },
{ house_id: 3387, address: 'пер. Сапёров, 5', lat: 56.8271, lon: 60.6198, sale_share_pct: 48.9, sale_share_pct_45d: 52.4, listings_45d: 24, over_100: false, active_secondary: 22, flat_count_effective: 45, gar_match_method: 'cadastre', median_price_rub: 2_990_000, median_price_per_m2: 98_400, avg_days_on_market: 148, year_built: 1957, house_type: 'brick', total_floors: 3, series_name: null, is_emergency: true },
{ house_id: 3101, address: 'ул. Викулова, 46', lat: 56.8412, lon: 60.5556, sale_share_pct: 34.6, sale_share_pct_45d: 41.2, listings_45d: 33, over_100: false, active_secondary: 27, flat_count_effective: 78, gar_match_method: 'cadastre', median_price_rub: 4_950_000, median_price_per_m2: 158_500, avg_days_on_market: 74, year_built: 1972, house_type: 'panel', total_floors: 9, series_name: '1-468', is_emergency: false },
{ house_id: 2874, address: 'ул. Бебеля, 138', lat: 56.8664, lon: 60.5721, sale_share_pct: 22.4, sale_share_pct_45d: 25.0, listings_45d: 20, over_100: false, active_secondary: 17, flat_count_effective: 76, gar_match_method: 'cadastre', median_price_rub: 5_400_000, median_price_per_m2: 149_200, avg_days_on_market: 61, year_built: 1978, house_type: 'panel', total_floors: 9, series_name: '141', is_emergency: false },
{ house_id: 5530, address: 'ул. Малышева, 84', lat: 56.8380, lon: 60.6203, sale_share_pct: 12.1, sale_share_pct_45d: 13.8, listings_45d: 16, over_100: false, active_secondary: 14, flat_count_effective: 116, gar_match_method: 'cadastre', median_price_rub: 7_850_000, median_price_per_m2: 172_300, avg_days_on_market: 47, year_built: 1954, house_type: 'stalin', total_floors: 6, series_name: null, is_emergency: false },
{ house_id: 6119, address: 'ул. Щербакова, 20', lat: 56.7791, lon: 60.6120, sale_share_pct: 6.8, sale_share_pct_45d: 8.1, listings_45d: 22, over_100: false, active_secondary: 18, flat_count_effective: 264, gar_match_method: 'cadastre', median_price_rub: 8_900_000, median_price_per_m2: 164_800, avg_days_on_market: 38, year_built: 2016, house_type: 'monolith', total_floors: 25, series_name: null, is_emergency: false },
];
/** Полная таблица (6 домов, выбран «ул. Викулова, 46» строка is-selected),
* сортировка по доле, кнопка «Объявления » ведёт в drawer. */
export const Default = () => (
<SaleShareList
buildings={buildings}
sort="share_desc"
onSort={() => {}}
selectedHouseId={3101}
hoveredHouseId={null}
onSelect={() => {}}
onHover={() => {}}
isLoading={false}
isError={false}
minPct={5}
window="now"
/>
);
/** Пустой результат — фильтры отсекли все дома (честный empty-state). */
export const Empty = () => (
<SaleShareList
buildings={[]}
sort="share_desc"
onSort={() => {}}
selectedHouseId={null}
hoveredHouseId={null}
onSelect={() => {}}
onHover={() => {}}
isLoading={false}
isError={false}
minPct={25}
window="now"
/>
);

View file

@ -1,30 +0,0 @@
import { SaleShareMap } from 'tradein-mvp-frontend';
// Тепловая карта домов /trade-in/sale-share (Leaflet + OSM с CDN, как
// MapCard/MapPicker). Цвет и радиус circleMarker растут с sale_share_pct
// (green → red, over_100 → тёмно-красный). 8 домов ЕКБ с координатами;
// выбранный дом (selectedHouseId) получает открытый popup. OSM-тайлы —
// внешний fetch: в офлайн-capture подложка может не прогрузиться,
// маркеры/popup/контролы рендерятся всегда.
const buildings = [
{ house_id: 4212, address: 'ул. Космонавтов, 52', lat: 56.8890, lon: 60.6132, sale_share_pct: 128.0, sale_share_pct_45d: 131.5, listings_45d: 46, over_100: true, active_secondary: 32, flat_count_effective: 25, gar_match_method: 'address', median_price_rub: 3_650_000, median_price_per_m2: 121_700, avg_days_on_market: 96, year_built: 1961, house_type: 'brick', total_floors: 5, series_name: null, is_emergency: false },
{ house_id: 3387, address: 'пер. Сапёров, 5', lat: 56.8271, lon: 60.6198, sale_share_pct: 48.9, sale_share_pct_45d: 52.4, listings_45d: 24, over_100: false, active_secondary: 22, flat_count_effective: 45, gar_match_method: 'cadastre', median_price_rub: 2_990_000, median_price_per_m2: 98_400, avg_days_on_market: 148, year_built: 1957, house_type: 'brick', total_floors: 3, series_name: null, is_emergency: true },
{ house_id: 3101, address: 'ул. Викулова, 46', lat: 56.8412, lon: 60.5556, sale_share_pct: 34.6, sale_share_pct_45d: 41.2, listings_45d: 33, over_100: false, active_secondary: 27, flat_count_effective: 78, gar_match_method: 'cadastre', median_price_rub: 4_950_000, median_price_per_m2: 158_500, avg_days_on_market: 74, year_built: 1972, house_type: 'panel', total_floors: 9, series_name: '1-468', is_emergency: false },
{ house_id: 2874, address: 'ул. Бебеля, 138', lat: 56.8664, lon: 60.5721, sale_share_pct: 22.4, sale_share_pct_45d: 25.0, listings_45d: 20, over_100: false, active_secondary: 17, flat_count_effective: 76, gar_match_method: 'cadastre', median_price_rub: 5_400_000, median_price_per_m2: 149_200, avg_days_on_market: 61, year_built: 1978, house_type: 'panel', total_floors: 9, series_name: '141', is_emergency: false },
{ house_id: 5530, address: 'ул. Малышева, 84', lat: 56.8380, lon: 60.6203, sale_share_pct: 12.1, sale_share_pct_45d: 13.8, listings_45d: 16, over_100: false, active_secondary: 14, flat_count_effective: 116, gar_match_method: 'cadastre', median_price_rub: 7_850_000, median_price_per_m2: 172_300, avg_days_on_market: 47, year_built: 1954, house_type: 'stalin', total_floors: 6, series_name: null, is_emergency: false },
{ house_id: 6119, address: 'ул. Щербакова, 20', lat: 56.7791, lon: 60.6120, sale_share_pct: 6.8, sale_share_pct_45d: 8.1, listings_45d: 22, over_100: false, active_secondary: 18, flat_count_effective: 264, gar_match_method: 'cadastre', median_price_rub: 8_900_000, median_price_per_m2: 164_800, avg_days_on_market: 38, year_built: 2016, house_type: 'monolith', total_floors: 25, series_name: null, is_emergency: false },
{ house_id: 5871, address: 'ул. Крауля, 44', lat: 56.8443, lon: 60.5610, sale_share_pct: 17.9, sale_share_pct_45d: 19.6, listings_45d: 14, over_100: false, active_secondary: 12, flat_count_effective: 67, gar_match_method: 'cadastre', median_price_rub: 5_150_000, median_price_per_m2: 152_900, avg_days_on_market: 58, year_built: 1980, house_type: 'panel', total_floors: 9, series_name: '141', is_emergency: false },
{ house_id: 6402, address: 'ул. 8 Марта, 190', lat: 56.8043, lon: 60.6094, sale_share_pct: 3.4, sale_share_pct_45d: 4.0, listings_45d: 11, over_100: false, active_secondary: 9, flat_count_effective: 262, gar_match_method: 'cadastre', median_price_rub: 9_300_000, median_price_per_m2: 176_400, avg_days_on_market: 33, year_built: 2019, house_type: 'monolith', total_floors: 26, series_name: null, is_emergency: false },
];
/** Карта ЕКБ: 8 тепловых маркеров, выбран «ул. Викулова, 46» открыт popup
* (адрес, % в продаже, N из M квартир). */
export const Default = () => (
<SaleShareMap
buildings={buildings}
selectedHouseId={3101}
hoveredHouseId={null}
onSelect={() => {}}
onHover={() => {}}
/>
);

View file

@ -1,39 +0,0 @@
import { SectionOverlay } from 'tradein-mvp-frontend';
// Glass/blur оверлей секций HUD «МЕРА Оценка» (/trade-in/v2): скобки-уголки,
// шапка «номер секции + заголовок + ← К ОЦЕНКЕ», скроллируемое тело со
// сменной view. Позиционируется absolute относительно артборда — в preview
// рендерим contained внутри relative-обёртки с фоном v2-страницы.
// Данные не передаём: каждая view падает на свой встроенный fixture-набор
// (HISTORY_FIXTURE / ANALYTICS_FIXTURE …) — как storybook/unwired usage.
const Artboard = ({ children }: { children?: unknown }) => (
<div
style={{
position: 'relative',
height: 680,
borderRadius: 10,
overflow: 'hidden',
background:
'radial-gradient(1100px 520px at 30% -10%, #f7fbff 0%, #eef4fa 55%, #e6eef7 100%)',
}}
>
{children as React.ReactNode}
</div>
);
/** Секция 04 «ПРОДАЖИ В ДОМЕ» (active=1 HistoryView на fixture-данных):
* таблица ДКП-продаж дома с ценами и датами. */
export const HistorySection = () => (
<Artboard>
<SectionOverlay active={1} onClose={() => {}} onNavigate={() => {}} />
</Artboard>
);
/** Секция 06 «АНАЛИТИКА ДОМА» (active=3 AnalyticsView): KPI дома,
* динамика цены, недавние продажи. */
export const AnalyticsSection = () => (
<Artboard>
<SectionOverlay active={3} onClose={() => {}} onNavigate={() => {}} />
</Artboard>
);

View file

@ -1,135 +0,0 @@
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

@ -133,7 +133,7 @@ jobs:
# 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.
# 1 pre-existing order-dependent test is deselected (see DESELECT note below).
# 2 pre-existing order-dependent tests are deselected (see DESELECT note below).
test:
runs-on: ubuntu-latest
needs: changes
@ -158,21 +158,20 @@ jobs:
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- 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). Актуализировано в #2208.
run: uv sync --frozen
# 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
- 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) убран — проходит в полном
# прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
# совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
# 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.
run: |
uv run pytest -q \
--deselect "tests/test_search_api.py::test_search_cache_hit"
--deselect "tests/test_search_api.py::test_search_cache_hit" \
--deselect "tests/test_cian_valuation.py::test_cache_hit_returns_cached"
build-backend:
runs-on: ubuntu-latest
@ -232,13 +231,10 @@ 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: |
@ -339,44 +335,24 @@ jobs:
export IMAGE_TAG="$IMAGE_TAG"
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
# нового кода → старые контейнеры продолжают работать на СТАРОМ коде +
# СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый
# код на старой схеме». Откат = просто ничего не поднимали.
# (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
# Селективный 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
echo "→ postgres ready."
# (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app.
# Применяем SQL миграции (если есть backend/data/sql/*.sql)
# 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.
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
# #2216) glob'ом не подхватывается.
sleep 5
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
# первый deploy после внедрения tracking на уже-наполненной prod-БД
@ -450,113 +426,16 @@ jobs:
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
fi
# (4) Теперь — новый app-код: схема уже актуальна.
#
# #1951: раньше scraper пересоздавался ВТОРОЙ отдельной командой `up -d`,
# уже ПОСЛЕ browser/backend/frontend. Если это происходило посреди
# in-flight sweep'а (avito/cian/rosreestr full-load), процесс убивался на
# лету, а осиротевшая scrape_runs-строка сидела status='running' с
# замёрзшим heartbeat до периодического 6h zombie-reaper'а — false "hang"
# investigation вместо честного deploy-артефакта (см. cian_full_load #404).
# Три меры ниже — все devops-only (shell в deploy-скрипте), БЕЗ правок
# Python в scraper-стартап-пути (scheduler_main.py / scraper-kit /
# app/services/scrapers — намеренно не тронуты, см. PR-описание):
#
# 1) Атомарный recreate — browser/backend/frontend[+scraper] поднимаются
# ОДНИМ `docker compose up -d` инвокейшном (список сервисов собирается
# заранее в $SERVICES), а не двумя последовательными командами.
# 2) Graceful drain — если scraper будет пересоздан, ждём (до 5 мин, poll
# каждые 10s) пока scrape_runs.status='running' не станет 0, ПРЕЖДЕ чем
# инициировать recreate. Таймаут не блокирует деплой навсегда —
# SIGTERM-drain (#1182 Phase 2/3a) + stop_grace_period 120s остаются
# финальной страховкой для того, что не успело дойти до checkpoint'а.
# 3) Startup-reap — сразу после recreate помечаем 'cancelled' любые
# 'running'-строки, чей heartbeat не обновлялся с МОМЕНТА (по часам
# самой БД — SELECT NOW(), без risk clock-skew раннера), взятого
# непосредственно перед stop. Такие строки заведомо осиротели ЭТИМ
# recreate'ом (старый контейнер физически не может писать heartbeat
# после своей остановки) — не ждём 6h периодического reap_zombies().
# 'cancelled' (не 'zombie') — честно отличает «убит деплоем» от
# «непонятно завис» (последнее по-прежнему ловит только 6h-reaper).
# Порог — по метке времени конкретного recreate, а не по фиксированному
# интервалу: не зависит от heartbeat-каденса разных источников и не
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
# admin-triggered scrape внутри backend, если backend в этом деплое
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
SERVICES="browser backend frontend"
SCRAPER_STOP_TS=""
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
drained=""
for i in $(seq 1 30); do
# NB: не сливать "psql не ответил" с "0 running" — иначе неудачный
# прогон психgl молча читается как «слито», и graceful drain
# становится no-op именно в момент проблем с БД во время деплоя.
running_count=""
psql_out="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT COUNT(*) FROM scrape_runs WHERE status='running';" 2>/dev/null)" \
&& running_count="$(printf '%s' "$psql_out" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ -n "$running_count" ] && [ "$running_count" = "0" ]; then
drained="yes"; break
fi
if [ -z "$running_count" ]; then
echo " ...не удалось прочитать running_count (psql failed) — считаем как «ещё активен», жду 10s (попытка $i/30)"
else
echo " ...${running_count} активных run(ов) ещё бегут, жду 10s (попытка $i/30)"
fi
sleep 10
done
if [ -n "$drained" ]; then
echo "→ Активных run'ов нет — recreate scraper безопасен."
else
echo "WARNING: активные scrape_runs остались после 5 мин ожидания — recreate продолжится."
echo " SIGTERM-drain (#1182) + stop_grace_period 120s постараются сохранить checkpoint;"
echo " startup-reap ниже подчистит то, что не успеет."
fi
# Checkpoint по часам БД (не раннера) прямо перед recreate.
# NB: tr -d '[:space:]' сломан для timestamptz-литерала — убирает и
# внутренний пробел между датой и временем ("2026-07-04 06:43" →
# "2026-07-0406:43"), CAST(...AS timestamptz) на такое падает молча
# (см. WARNING-фолбэк ниже). sed убирает только leading/trailing.
SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS=""
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
SERVICES="$SERVICES scraper"
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
# — поэтому подставляем bash-значением напрямую. SCRAPER_STOP_TS сгенерирован
# самим Postgres (SELECT NOW()), не внешний ввод → безопасно.
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c "
UPDATE scrape_runs
SET status = 'cancelled',
finished_at = NOW(),
error = 'deploy #1951: tradein-scraper recreated mid-run (startup-reap, checkpoint ${SCRAPER_STOP_TS})'
WHERE status = 'running'
AND heartbeat_at < CAST('${SCRAPER_STOP_TS}' AS timestamptz);
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
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 существует) уже с первого старта. Рестарт удалён.
# 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
# Caddy reload — основной Caddyfile содержит inline tradein routes
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
@ -565,28 +444,12 @@ jobs:
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile || true
# 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=""
# Health check
for i in $(seq 1 30); do
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
exec -T backend curl -fsS http://localhost:8000/health && break
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,14 +199,6 @@ 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

View file

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

6
.gitignore vendored
View file

@ -1,4 +1,4 @@
# Memory graph (hardlink to C:\mcp\.aim\memory-gendesign.jsonl)
# Memory graph (hardlink to C:\mcp\.aim\memory-gendesign.jsonl)
*.jsonl
!memory/memory-gendesign.jsonl
@ -87,10 +87,6 @@ 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

@ -16,13 +16,15 @@
# (basic_auth runs before handle), making /health and /preview/* exclusions
# ineffective. With route { }, handlers are matched top-to-bottom as written.
# #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.
{
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
}
}
gendsgn.ru {
encode zstd gzip
@ -86,11 +88,6 @@ 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}
}
}
@ -111,9 +108,6 @@ 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

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

View file

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

View file

@ -9,13 +9,6 @@ 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.
"""
@ -23,24 +16,17 @@ header removed 2026-05-23 — двойная auth избыточна для pilo
from __future__ import annotations
import logging
from typing import Annotated, Any
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, 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,
)
@ -118,37 +104,6 @@ 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[
@ -169,68 +124,3 @@ 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

@ -497,21 +497,6 @@ def trigger_poi_sync() -> dict[str, Any]:
return {"task_id": result.id, "queued_at": "now"}
@router.post("/gisogd-permits-sync")
def trigger_gisogd_permits_sync() -> dict[str, Any]:
"""Manual trigger инкрементальной загрузки РНС/РВЭ ГИСОГД-СО → gisogd_permits (#2367).
Обычно запускается еженедельно через beat (вторник 06:30 МСК). Этот endpoint
для ad-hoc запуска (например после деплоя миграции 187_gisogd_permits.sql или для
внеочередного обновления). Инкрементально: карточки тянутся только для новых/
изменившихся документов, повторный запуск дёшев.
"""
from app.workers.tasks.gisogd_permits_sync import sync_gisogd_permits
result = sync_gisogd_permits.apply_async()
return {"task_id": result.id, "queued_at": "now"}
class TriggerObjectiveEtlRequest(BaseModel):
sqlite_path: str | None = Field(
default=None,
@ -1152,7 +1137,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
)
from app.workers.tasks.etl_newbuilding_crossload import etl_newbuilding_crossload
result = etl_newbuilding_crossload.apply_async(kwargs={"triggered_by": "manual"})
result = etl_newbuilding_crossload.apply_async()
return {"task_id": result.id, "queued_at": "now"}
@ -1419,12 +1404,8 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
table="nspd_geo_jobs",
work_col="targets_done",
attempt_fallback_col="created_at",
# On-demand источник БЕЗ cron (admin UI / CLI / lazy из analyze) — штучные
# фетчи по активности пользователя. fresh_days=7 флагал каждую неделю
# тишины ложным stale-алертом (расследование 2026-07-04); пороги — под
# реальную каденцию, critical=False и так не трогает overall.
fresh_days=30.0,
stale_days=90.0,
fresh_days=7.0,
stale_days=30.0,
),
FreshnessSource(
source="cadastre",
@ -1435,20 +1416,6 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
fresh_days=14.0,
stale_days=45.0,
),
FreshnessSource(
# #2367: реестр РНС/РВЭ ГИСОГД-СО. Data-table режим (плоская таблица без run-
# ledger): свежесть = MAX(fetched_at), upd_24h/_7d = COUNT(*) по окну. Источник
# обновляется ежедневно, тянем еженедельно (beat вторник) → fresh<14d, stale<45d
# (широкий запас на пропуск одного-двух вторников, как cadastre). critical=False.
source="gisogd_permits",
label="ГИСОГД-СО РНС/РВЭ (реестр разрешений)",
table="gisogd_permits",
timestamp_col="fetched_at",
# В timestamp-режиме work_col не используется — валидное имя колонки.
work_col="id",
fresh_days=14.0,
stale_days=45.0,
),
]
@ -1789,20 +1756,6 @@ def trigger_ekburg_permits(
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
_WAF_COOLDOWN_GUARD_MSG = (
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
"передай i_understand_waf_risk=true в теле запроса."
)
class TriggerKnCatalogObjectsRequest(BaseModel):
region_code: int = Field(default=66, ge=1, le=99)
max_objects: int | None = Field(default=None, ge=1, le=2000)
@ -1814,14 +1767,6 @@ class TriggerKnCatalogObjectsRequest(BaseModel):
"что уже скраплено сегодня."
),
)
i_understand_waf_risk: bool = Field(
default=False,
description=(
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper бьёт по "
"тому же /сервисы/* BrowserSession path family, что вызвал бан."
),
)
@router.post("/kn-catalog-objects")
@ -1838,14 +1783,7 @@ def trigger_kn_catalog_objects(
- max_objects=None дефолтный лимит таска (300).
- max_objects=3 smoke-тест.
- force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
re-trigger без явного подтверждения оператора запрещён.
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
kwargs: dict[str, Any] = {
@ -1863,65 +1801,3 @@ def trigger_kn_catalog_objects(
"force": payload.force,
"queued_at": "now",
}
class TriggerKnCatalogFlatsRequest(BaseModel):
region_code: int = Field(default=66, ge=1, le=99)
max_flats: int | None = Field(default=None, ge=1, le=5000)
force: bool = Field(
default=False,
description=(
"True — игнорировать фильтр свежести ('catalog_updated_at свежий') и "
"грузить ВСЕ квартиры последнего snapshot с непустым catalog_url_hash "
"('Загрузить все'). По умолчанию пропускает то, что скраплено < 30 дней назад."
),
)
i_understand_waf_risk: bool = Field(
default=False,
description=(
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper ездит по "
"тому же /сервисы/* BrowserSession path family, что и catalog-objects."
),
)
@router.post("/kn-catalog-flats")
def trigger_kn_catalog_flats(
payload: TriggerKnCatalogFlatsRequest,
) -> dict[str, Any]:
"""Manual trigger для catalog-FLAT scraper (#2442): цена/статус/отделка/потолки/
дата обновления + plan-изображения квартир из SSR-страницы каталога.
Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL. До тех пор пока
#2442 Task 1 (elemId → catalog_url_hash) не задеплоен и свежий kn-sweep не
наполнил hash вернёт 0 обработанных строк (ожидаемо, не баг).
- max_flats=None дефолтный лимит таска (300).
- max_flats=3 smoke-тест.
- force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
того же WAF-бана.
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
kwargs: dict[str, Any] = {
"region_code": payload.region_code,
"force": payload.force,
}
if payload.max_flats is not None:
kwargs["max_flats"] = payload.max_flats
result = scrape_kn_catalog_flats.apply_async(kwargs=kwargs)
return {
"task_id": result.id,
"region_code": payload.region_code,
"max_flats": payload.max_flats,
"force": payload.force,
"queued_at": "now",
}

View file

@ -35,11 +35,7 @@ from app.core.db import get_db
from app.schemas.chat import ChatAskRequest, ChatAskResponse, ChatIntent, GroundedIn
from app.services.chat.intents import render_answer, route_intent
from app.services.chat.orchestrator import orchestrate_chat
from app.services.chat.retrieval import (
_FORECAST_SCHEMA_VERSION,
get_parcel_context_for_chat,
get_report_for_chat,
)
from app.services.chat.retrieval import _FORECAST_SCHEMA_VERSION, get_report_for_chat
logger = logging.getLogger(__name__)
@ -96,46 +92,11 @@ async def ask(
report_status="pending",
)
# Курируемый паспорт участка + градрегламент (§1 analyze-рана) — отдельный read-only
# seam. §22-отчёт (форсайт) НЕ несёт тер.зону/ЗОУИТ/ЕГРН, поэтому дотягиваем их из
# analyze-1.0 и вливаем в КОПИЮ report_dict под ключ "parcel_context" (tool
# get_parcel_info режет именно его). Analyze-рана нет/сбой чтения → работаем как
# раньше (только форсайт); НЕ меняем pending-поведение (оно завязано на §22-ран выше).
report = await _with_parcel_context(db, payload.cad_num, report)
if settings.llm_enabled:
return await _answer_via_llm(db, payload, report, run_id)
return _answer_deterministic(payload, report, run_id)
async def _with_parcel_context(
db: Session,
cad_num: str,
report: dict[str, Any],
) -> dict[str, Any]:
"""Дотянуть курируемый паспорт участка и влить его в КОПИЮ report_dict.
Read-only: sync-чтение analyze-рана мостим через run_in_threadpool (как §22-отчёт).
None (рана нет) возвращаем report без изменений. Сбой БД глотаем в pending-стиле
эндпоинта: паспорт участка обогащение, его отсутствие не должно ронять чат.
"""
try:
parcel_context = await run_in_threadpool(get_parcel_context_for_chat, db, cad_num)
except Exception:
logger.warning(
"chat: parcel context read failed for cad=%s — continuing without it",
cad_num,
exc_info=True,
)
return report
if not parcel_context:
return report
# Копия: не мутируем report_dict, пришедший из get_report_for_chat.
merged = dict(report)
merged["parcel_context"] = parcel_context
return merged
def _answer_deterministic(
payload: ChatAskRequest,
report: dict[str, Any],

View file

@ -3,13 +3,11 @@ import json
import logging
import math
import time
from pathlib import Path
from typing import Annotated, Any, Literal
import httpx
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from shapely import wkt as _shp_wkt
from shapely.geometry import Polygon
from sqlalchemy import text
@ -37,8 +35,6 @@ from app.schemas.parcel import (
ParcelSearchRequest,
ParcelSearchResponse,
RedLine,
ReportBuildResponse,
ReportStatusResponse,
RiskZone,
UtilityInfrastructureResponse,
)
@ -50,7 +46,6 @@ from app.services.analysis_runs.repository import (
list_runs_for,
persist_analysis_run,
)
from app.services.exporters.full_report_pdf import REPORT_SCHEMA_VERSION
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
from app.services.exporters.report_docx import render_report_docx
from app.services.exporters.report_md import (
@ -82,23 +77,12 @@ from app.services.site_finder.osrm_client_local import (
OsrmLocalUnavailableError,
get_road_distances_m,
)
from app.services.site_finder.parcel_financial import (
_as_float as _fin_as_float,
)
from app.services.site_finder.parcel_financial import (
_as_int as _fin_as_int,
)
from app.services.site_finder.parcel_financial import (
select_calibrated_price,
synthesize_parcel_financial,
)
from app.services.site_finder.permits_nearby import get_permits_nearby
from app.services.site_finder.parcel_financial import synthesize_parcel_financial
from app.services.site_finder.poi_score import (
PoiScoreResponse,
compute_poi_routing_decay,
compute_poi_weighted_top7,
)
from app.services.site_finder.program_optimizer import optimize_program
from app.services.site_finder.quarter_dump_lookup import (
get_connection_points,
get_quarter_dump_data,
@ -222,19 +206,6 @@ def _confidence_label(c: float) -> str:
return "low"
_ROMAN_QUARTERS = ("I", "II", "III", "IV")
def _deals_quarter_label(d: _dt.date) -> str:
"""Дата сделок Росреестра → «I квартал 2026».
Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date) = начало
квартала поставки (2026-01-01 = I квартал 2026), а НЕ «источник устарел».
Месяц 1-3I, 4-6II, 7-9III, 10-12IV (римские).
"""
return f"{_ROMAN_QUARTERS[(d.month - 1) // 3]} квартал {d.year}"
# Человеко-читаемые имена категорий для verbal breakdown (X1).
_POI_CATEGORY_RU: dict[str, str] = {
"school": "Школа",
@ -857,20 +828,6 @@ _NEIGHBORS_SUMMARY_SQL = text("""
ORDER BY distance_m ASC
LIMIT 30
),
neighbors_total AS (
-- #2464 cluster B: честный COUNT(*) по ВСЕЙ 100м-выборке — БЕЗ LIMIT 30
-- (тот же WHERE, что у `neighbors` выше). count_buildings_100m раньше
-- считался как len(neighbors), тихо капаясь на 30 даже когда соседей
-- в радиусе больше.
SELECT COUNT(*) AS n
FROM cad_buildings b
WHERE ST_DWithin(
b.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
100
)
AND b.cad_num != CAST(:our_cad AS text)
),
overlap_rows AS (
SELECT cad_num,
building_name,
@ -898,8 +855,7 @@ _NEIGHBORS_SUMMARY_SQL = text("""
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
FROM overlap_rows o),
'[]'::json
) AS overlap_rows,
(SELECT n FROM neighbors_total) AS neighbors_total_count
) AS overlap_rows
""")
@ -916,11 +872,6 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
один сетевой round-trip (~47ms) на каждый analyze сами вычисления не
меняются. Формат возвращаемого dict идентичен прежнему.
#2464 cluster B: `count_buildings_100m` — честный COUNT(*) по всей 100м-выборке
(CTE `neighbors_total`, БЕЗ LIMIT), а не len(neighbors) (капалось на LIMIT 30).
`neighbors_truncated` True если в радиусе больше соседей, чем показано в
списке `neighbors`.
SQL module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
"""
@ -935,9 +886,6 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
)
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
# #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) —
# НЕ len(neighbor_rows), которое капалось на 30 даже когда соседей больше.
neighbors_total_count = int(row["neighbors_total_count"]) if row else 0
except Exception as e:
logger.warning("neighbors query failed: %s", e)
return {"data_available": False, "note": f"neighbors query failed: {e}"}
@ -982,46 +930,36 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
]
has_existing = len(overlap_buildings) > 0
# neighbor_rows — до 30 (SQL CTE `neighbors` LIMIT), список ниже дополнительно
# режется до 20 для payload. `neighbors_truncated` сравнивает честный total с
# тем, что РЕАЛЬНО показано (len(neighbors_list)) — а не с промежуточным
# neighbor_rows (до 30) — иначе флаг мог соврать False на 21..30 соседях,
# где список уже обрезан до 20, а total ещё не превысил len(neighbor_rows).
neighbors_list = [
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
]
return {
"data_available": True,
"radius_m": 100,
# #2464 cluster B: честный total (neighbors_total CTE, без LIMIT) — НЕ
# len(neighbor_rows) (капалось на LIMIT 30 у CTE `neighbors`).
"count_buildings_100m": neighbors_total_count,
"neighbors_truncated": neighbors_total_count > len(neighbors_list),
"count_buildings_100m": len(neighbor_rows),
"avg_floors_100m": avg_floors,
"max_floors_100m": max_floors,
"median_cost_per_m2_100m": median_cost,
"neighbors": neighbors_list,
"neighbors": [
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
],
"has_existing_buildings": has_existing,
"overlap_buildings": overlap_buildings,
"note": (
@ -1190,70 +1128,6 @@ def _compute_confidence(
}
def _build_market_pulse(
competitor_rows: list[dict[str, Any]],
competitors_total: int,
velocity_data: dict[str, Any] | None,
) -> dict[str, Any]:
"""OBJ-3 aggregate: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
в `competitor_rows` (детальный список для карты/карточек), но исключаются
из расчётов рыночных метрик (market_avg_price_per_m2, top_sellers).
#2464 cluster B: `competitors_total` — честный total (передаётся вызывающим
кодом из отдельного COUNT(*) БЕЗ LIMIT, см. `analyze_parcel` 5a), НЕ
len(competitor_rows) (которое капалось на LIMIT 20 запроса конкурентов, и
заодно раздувало `coverage_pct` competitors_priced/20 вместо
competitors_priced/true_total). PURE вызывающий код передаёт честный total
отдельно, эта функция его не пересчитывает.
"""
competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
competitors_priced = len(competitors_with_price)
if competitors_with_price:
prices = [float(c["avg_price_per_m2_rub"]) for c in competitors_with_price]
market_avg_price = round(sum(prices) / len(prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
with_sales = [
c
for c in competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
top_sellers = sorted(
with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in top_sellers
]
else:
market_avg_price = None
top_sellers_list = []
coverage_pct = (
round(competitors_priced * 100.0 / competitors_total, 1) if competitors_total > 0 else 0.0
)
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
return {
"avg_velocity_m2": avg_velocity,
"market_avg_price_per_m2": market_avg_price,
"competitors_total": competitors_total,
"competitors_with_price": competitors_priced,
"coverage_pct": coverage_pct,
"top_sellers": top_sellers_list,
}
# #93 — on-demand cadastre fetch tuning constants.
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
#
@ -1659,221 +1533,6 @@ def export_parcel_forecast(
)
# ── Полный PDF-отчёт ПТИЦА (эпик #2259 PR-D) ─────────────────────────────────
# §1§7 + карты, собирается фоновой Celery-таской и хранится файлом на volume
# `/app/reports/` + метадата-строкой в analysis_runs (schema `report-pdf-1.0`).
# 3 эндпоинта — enqueue / status / download; пути ≥2-сегментные (рядом с
# /forecast/export), не конфликтуют с 1-сегментным /{parcel_id}.
def _iso_or_none(value: Any) -> str | None:
"""`datetime` → ISO-строка, иначе None (даты ранов для report-status ответов)."""
return value.isoformat() if isinstance(value, _dt.datetime) else None
def _cached_report_result(db: Session, cad_num: str) -> dict[str, Any] | None:
"""Метадата готового отчёта (`report-pdf-1.0`), если файл реально на диске.
Читает последний метадата-ран схемы `report-pdf-1.0`; возвращает его `result`
только если `pdf_path` существует на диске (файл могли не смонтировать / удалить).
Иначе None (отчёт не готов). Read-only, без исключений наружу (сбой None).
"""
try:
row = latest_run_for(db, cad_num, schema_version=REPORT_SCHEMA_VERSION)
except Exception:
logger.exception("report status: чтение report-pdf-1.0 рана упало для %s", cad_num)
return None
if row is None:
return None
result = row.result if isinstance(row.result, dict) else {}
pdf_path = result.get("pdf_path")
if isinstance(pdf_path, str) and Path(pdf_path).exists():
return result
return None
@router.post(
"/{cad_num}/report",
response_model=ReportBuildResponse,
summary="Собрать полный PDF-отчёт участка (§1§7 + карты) — enqueue (#2259 PR-D)",
)
def build_parcel_report(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
response: Response,
) -> ReportBuildResponse:
"""Поставить в очередь сборку полного PDF-отчёта участка (или вернуть готовый кэш).
Готовый кэш (метадата-ран `report-pdf-1.0` для последних analyze+forecast ранов +
файл на диске) 200 {status:"ready"} (качать сразу через /report/download). Иначе
enqueue фоновой Celery-таски `build_full_report_task(cad)` 202 {status:"building"}.
Best-effort enqueue: провал Celery/Redis НЕ роняет ответ (503 честнее 500, но и он
крайне редок /analyze уже enqueue'ит форсайт тем же брокером).
В ответе даты базовых ранов (по каким данным собирается/собран отчёт).
Args:
cad_num: кадастровый номер участка.
"""
analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
if analyze_run is None:
raise HTTPException(
status_code=404, detail="нет анализа участка — сначала запустите /analyze"
)
forecast_run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
analyze_at = _iso_or_none(getattr(analyze_run, "created_at", None))
forecast_at = _iso_or_none(getattr(forecast_run, "created_at", None))
# Готовый кэш → 200 ready (не enqueue'им повторно). Отдаём и дату генерации PDF из
# метадаты рана — чтобы fast-path кнопка показала дату без отдельного GET /status.
cached = _cached_report_result(db, cad_num)
if cached is not None:
return ReportBuildResponse(
status="ready",
analyze_run_at=analyze_at,
forecast_run_at=forecast_at,
report_generated_at=cached.get("generated_at"),
)
# Enqueue фоновой сборки. Lazy import таски (как forecast в analyze_parcel) — атрибут
# резолвится в момент вызова, патчибельно в тестах. Провал брокера → 503 (не 500).
try:
from app.workers.tasks.full_report import build_full_report_task
build_full_report_task.delay(cad_num)
except Exception as exc:
logger.exception("report enqueue failed for %s", cad_num)
raise HTTPException(
status_code=503, detail="очередь сборки отчёта недоступна, повторите позже"
) from exc
response.status_code = 202
return ReportBuildResponse(
status="building", analyze_run_at=analyze_at, forecast_run_at=forecast_at
)
@router.get(
"/{cad_num}/report/status",
response_model=ReportStatusResponse,
summary="Статус полного PDF-отчёта участка (#2259 PR-D)",
)
def parcel_report_status(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
) -> ReportStatusResponse:
"""Состояние полного PDF-отчёта: ready / building / none.
ready метадата-ран `report-pdf-1.0` есть И файл на диске (качать через
/report/download); building analyze-ран есть, но готового PDF нет (собирается или
ещё не запускался POST /report); none нет даже analyze-рана (отчётировать нечего).
Read-only поллинг: сбой чтения building (не 500), клиент повторит.
Args:
cad_num: кадастровый номер участка.
"""
try:
analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
forecast_run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
except Exception:
logger.exception("report status: чтение ранов упало для %s", cad_num)
return ReportStatusResponse(status="building")
if analyze_run is None:
return ReportStatusResponse(status="none")
analyze_at = _iso_or_none(getattr(analyze_run, "created_at", None))
forecast_at = _iso_or_none(getattr(forecast_run, "created_at", None))
cached = _cached_report_result(db, cad_num)
if cached is not None:
return ReportStatusResponse(
status="ready",
analyze_run_at=analyze_at,
forecast_run_at=forecast_at,
report_generated_at=cached.get("generated_at"),
)
return ReportStatusResponse(
status="building", analyze_run_at=analyze_at, forecast_run_at=forecast_at
)
# Медиа-типы + расширения по формату скачивания полного отчёта (PR-D pdf + PR-F docx).
_REPORT_DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
# HEAD нужен фронтовому preflight'у (#2338): FastAPI НЕ добавляет HEAD к @router.get
# автоматически (прод отдавал 405 → UI не скачивал DOCX вовсе). FileResponse при
# HEAD нативно отдаёт только заголовки (Starlette send_header_only).
@router.head("/{cad_num}/report/download", include_in_schema=False)
@router.get(
"/{cad_num}/report/download",
summary="Скачать готовый полный отчёт участка — PDF или DOCX (#2259 PR-D/PR-F)",
)
def download_parcel_report(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
format: Annotated[
Literal["pdf", "docx"],
Query(description="Формат файла: pdf (default) | docx (Word-документ, #2259 PR-F)"),
] = "pdf",
) -> FileResponse:
"""Отдать готовый файл полного отчёта в выбранном формате (PDF / DOCX).
Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) FileResponse с
Content-Disposition attachment (`gendesign_report_<cad>_<date>.<ext>`). Отчёт не готов /
файл не найден 404 (сначала POST /report, дождаться status=ready).
`format=docx`: старые раны (собранные до PR-F) не несут `docx_path` в метадате
честный 404 с подсказкой «пересоберите отчёт» (POST /report пере-соберёт с DOCX, т.к.
ключ кэша не изменился, но файла docx нет пере-рендер запишет оба).
Args:
cad_num: кадастровый номер участка.
format: "pdf" (default) | "docx".
"""
cached = _cached_report_result(db, cad_num)
if cached is None:
raise HTTPException(
status_code=404, detail="отчёт ещё не готов — запустите POST /report и дождитесь ready"
)
if format == "docx":
file_path = cached.get("docx_path")
media_type = _REPORT_DOCX_MEDIA_TYPE
ext = "docx"
# Старый ран без docx_path (собран до PR-F) → пере-соберите POST /report.
if not isinstance(file_path, str) or not Path(file_path).exists():
raise HTTPException(
status_code=404,
detail=(
"DOCX-версия недоступна для этого отчёта — пересоберите отчёт (POST /report)"
),
)
else:
file_path = cached["pdf_path"]
media_type = "application/pdf"
ext = "pdf"
cad_safe = cad_num.replace(":", "_")
# Дата в имени файла — из МЕТАДАТЫ отчёта (когда файл реально собран), НЕ today: на
# cache-hit со вчерашнего отчёта today соврал бы. generated_at — ISO-строка из
# build_full_report; битую/отсутствующую парсим best-effort → fallback на today.
date_str = _dt.date.today().strftime("%Y-%m-%d")
generated_at = cached.get("generated_at")
if isinstance(generated_at, str):
try:
date_str = _dt.datetime.fromisoformat(generated_at).strftime("%Y-%m-%d")
except ValueError:
logger.warning("report download: битый generated_at %r для %s", generated_at, cad_num)
filename = f"gendesign_report_{cad_safe}_{date_str}.{ext}"
return FileResponse(
file_path,
media_type=media_type,
filename=filename,
)
@router.post("/{cad_num}/analyze", response_model=AnalyzeResponse)
def analyze_parcel(
cad_num: str,
@ -2353,34 +2012,6 @@ def analyze_parcel(
.all()
)
# 5a) #2464 cluster B: честный COUNT(*) конкурентов в радиусе 3км — БЕЗ LIMIT.
# competitor_rows выше капнут `LIMIT 20` (топ-20 ближайших/строящихся для карты и
# детального списка) — market_pulse.competitors_total ниже раньше = len(competitor_rows),
# тихо капаясь на 20 даже когда в радиусе ЖК больше. Лёгкий COUNT переиспользует
# ТОТ ЖЕ latest_obj + ST_DWithin фильтр, но БЕЗ obj_lots_latest/obj_pricing джойнов
# (нужны только для цен детального списка, не для total) — на порядок дешевле
# полного запроса конкурентов (см. #1964 EXPLAIN про стоимость lots-дедупа).
_competitors_total_true = (
db.execute(
text("""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT COUNT(*) FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
"""),
{"wkt": geom_wkt},
).scalar()
or 0
)
# 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес
# в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
# + по кварталам сдачи. Константы — см. PIPELINE_* выше.
@ -3232,16 +2863,12 @@ def analyze_parcel(
_deals_stale = (
recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
)
# Честная микрокопия. Росреестр отдаёт сделки ПОКВАРТАЛЬНО (max(deal_date)
# = начало квартала поставки) — это НЕ «источник устарел». Для stale-ветки
# называем квартал поставки; иначе просто нет сделок рядом. Обе показывают
# Разные причины отсутствия сделок → честная микрокопия: устаревание
# источника vs просто нет сделок рядом (источник свеж). Обе показывают
# предложение как основной контент.
_offer_only_label = (
(
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
"(поставка поквартальная); показываем динамику цен предложения"
)
if _deals_stale and max_period is not None
"Сделки устарели — показываем динамику цен предложения"
if _deals_stale
else "Нет сделок рядом — показываем динамику цен предложения"
)
market_trend = {
@ -3254,17 +2881,13 @@ def analyze_parcel(
"offer_trend": offer_block,
}
elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
# Recent-окно пусто И latest deal старше 3 мес. Росреестр отдаёт сделки
# ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — честно
# называем квартал поставки, НЕ «источник устарел». #2178: сюда попадаем
# ТОЛЬКО когда и offer-данных нет (offer_block is None).
# Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»).
# #2178: сюда попадаем ТОЛЬКО когда и offer-данных нет (offer_block is None) —
# прежний source_stale остаётся честным «нечего показать».
market_trend = {
"status": "source_stale",
"as_of_date": max_period.isoformat(),
"label": (
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
"(поставка поквартальная); тренд по сделкам не показываем"
),
"label": (f"Источник сделок устарел (данные до {max_period.strftime('%m.%Y')})"),
"recent_deals_count": 0,
}
else:
@ -3629,25 +3252,6 @@ def analyze_parcel(
except Exception as e:
logger.warning("ekburg permits query failed for %s: %s", cad_num, e)
# 10d-pre2b) #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (gisogd_permits,
# ST_DWithin 500м). НОВЫЙ ключ payload — заменяет TODO-прокси quarter-prefix выше,
# НЕ трогая старый ekburg-путь (recent_permits_in_quarter / permits_summary остаются).
# Non-fatal: сбой → honest-ноль вместо падения всего analyze.
permits_nearby_data: dict[str, Any] = {
"radius_m": 500,
"total_count": 0,
"rs_count": 0,
"rv_count": 0,
"nearest_distance_m": None,
"items": [],
"items_truncated": False,
"source": "gisogd66",
}
try:
permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500)
except Exception as e:
logger.warning("gisogd permits_nearby query failed for %s: %s", cad_num, e)
# 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS
karpinsky_url = (
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
@ -3805,12 +3409,56 @@ def analyze_parcel(
except Exception as _ve:
logger.warning("velocity compute failed for %s: %s", cad_num, _ve)
# #2464 cluster B: competitors_total — честный total из отдельного COUNT(*)
# (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса
# конкурентов. См. _build_market_pulse docstring для полной логики агрегата.
market_pulse: dict[str, Any] = _build_market_pulse(
competitor_rows, int(_competitors_total_true), velocity_data
# OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
# Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
# в competitor list для карты, но исключаются из расчётов рыночных метрик.
_competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
_competitors_total = len(competitor_rows)
_competitors_priced = len(_competitors_with_price)
if _competitors_with_price:
_prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price]
_market_avg_price = round(sum(_prices) / len(_prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
_with_sales = [
c
for c in _competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
_top_sellers = sorted(
_with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
_top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in _top_sellers
]
else:
_market_avg_price = None
_top_sellers_list = []
_coverage_pct = (
round(_competitors_priced * 100.0 / _competitors_total, 1)
if _competitors_total > 0
else 0.0
)
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
_avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
market_pulse: dict[str, Any] = {
"avg_velocity_m2": _avg_velocity,
"market_avg_price_per_m2": _market_avg_price,
"competitors_total": _competitors_total,
"competitors_with_price": _competitors_priced,
"coverage_pct": _coverage_pct,
"top_sellers": _top_sellers_list,
}
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается
@ -3885,40 +3533,6 @@ def analyze_parcel(
logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e)
financial_estimate = None
# #2181: оптимизатор программы — при ОТРИЦАТЕЛЬНОМ фин-вердикте (NPV<0 или ROI<0)
# честно перебираем сетку (класс × этажность) той же compute_financial и отдаём топ-3
# варианта + флаг any_viable (есть ли ВООБЩЕ плюс на этом участке). Позитивные отчёты
# не засоряем (program_alternatives=None). Быстро (≈15 мс/12 комбо) → синхронно, без
# Celery. Тот же «пояс безопасности», что у financial_estimate: любой сбой → None,
# analyze НЕ падает. Единая откалиброванная цена во все комбо (DRY через
# select_calibrated_price) — иначе business ложно выиграет за счёт норматива 210k.
program_alternatives: dict[str, Any] | None = None
try:
_fin_negative = financial_estimate is not None and (
(_fin_as_float(financial_estimate.get("npv_rub")) or 0.0) < 0
or (_fin_as_float(financial_estimate.get("roi")) or 0.0) < 0
)
if _fin_negative:
_opt_price, _opt_price_source = select_calibrated_price(
market_price=market_price,
geo_radius_price_median=geo_radius_price.get("median"),
district_price_median=district_price_block["district_price_per_m2_median"],
)
_opt_zoning = nspd_dump_data["nspd_zoning"] or {}
program_alternatives = optimize_program(
area_m2=_fin_area_m2,
max_far=_fin_as_float(_opt_zoning.get("max_far")),
max_building_pct=_fin_as_float(_opt_zoning.get("max_building_pct")),
max_floors_allowed=_fin_as_int(_opt_zoning.get("max_floors")),
land_cost_rub=_fin_cad_value,
market_price_per_sqm=_opt_price,
price_source=_opt_price_source,
velocity_sqm_per_month=_velocity_absorption,
)
except Exception:
logger.exception("program_optimizer failed for %s", cad_num)
program_alternatives = None
# #1960: карточка «Медиана рынка» (district.median_price_per_m2) должна быть
# КОНСИСТЕНТНА с остальной страницей, построенной на NEWBUILD Objective/DOM.РФ
# basis (geo_radius_price.median, market_avg ≈197k), а не на загрязнённой
@ -4046,8 +3660,6 @@ def analyze_parcel(
# #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m)
"recent_permits_in_quarter": recent_permits,
"permits_summary": permits_summary,
# #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (ГИСОГД-66, ST_DWithin 500м)
"permits_nearby": permits_nearby_data,
"zoning": zoning,
"success_recommendation": success_recommendation,
"isochrones_available": bool(settings.openrouteservice_api_key),
@ -4080,10 +3692,6 @@ def analyze_parcel(
# застройке зоны (синтез ТЭП из buildability). dict|None (None когда gate
# против / нет чисел зоны / нет площади). caveat внутри. См. parcel_financial.
"financial_estimate": financial_estimate,
# #2181: при ОТРИЦАТЕЛЬНОМ фин-вердикте — топ-3 варианта программы (класс ×
# этажность) по NPV + флаг any_viable (существует ли ВООБЩЕ плюс). dict|None
# (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
"program_alternatives": program_alternatives,
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
"weights_profile": {
"source": _weights_source,
@ -4323,10 +3931,6 @@ def get_parcel_connection_capacity(
power_points ЦП в радиусе от центроида участка (ST_DWithin geography).
water все строки за ПОСЛЕДНИЙ период (город-уровень, показывается целиком).
nearby_network_zones позитив-разрез (#2119 C2/C3): сетевые охранные зоны cad_zouit
в радиусе 300 м (где физически проходит сеть) до 2 ближайших на net_kind,
гуманизированы; пересекающие участок отдаются с distance_m=0. Радиус 300 м
независимый от radius_m ЦП.
Query params:
radius_m: радиус поиска ЦП, 10010000 м (default 3000).
@ -4343,160 +3947,10 @@ def get_parcel_connection_capacity(
power_summary=data["power_summary"],
water=data["water"],
gas=data.get("gas"),
gas_outlet_points=data.get("gas_outlet_points") or [],
heat=data.get("heat"),
nearby_network_zones=data.get("nearby_network_zones") or [],
)
# ── neighbor-buildings (Forgejo #2180) ──────────────────────────────────────
# Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены «Размещение
# застройки» (§7 отчёта ПТИЦА). Отдаёт GeoJSON FeatureCollection упрощённых
# полигонов cad_buildings вокруг участка. Кэш не нужен — один GIST-запрос.
#
# Модуль-level `text(...)` (а не inline в хендлере) чтобы integration-EXPLAIN-
# gate мог проверить SQL против прод-PG (psycopg v3 `:x::type`-антипаттерн,
# несуществующие PostGIS-функции). ::geography приклеен к ЗАКРЫВАЮЩЕЙ скобке —
# это разрешённая форма (не `:bind::type`), bind-числа идут через CAST(:x AS float).
_NEIGHBOR_BUILDINGS_SQL = text("""
WITH parcel AS (
SELECT geom
FROM cad_parcels_geom
WHERE cad_num = CAST(:cad AS text)
AND geom IS NOT NULL
LIMIT 1
)
SELECT
cb.cad_num,
cb.floors,
cb.building_name,
cb.year_built,
ST_AsGeoJSON(ST_SimplifyPreserveTopology(cb.geom, 0.000005)) AS geojson,
ST_Distance(cb.geom::geography, (SELECT geom FROM parcel)::geography) AS distance_m
FROM cad_buildings cb, parcel
WHERE ST_DWithin(
cb.geom::geography,
parcel.geom::geography,
CAST(:radius AS float)
)
AND NOT ST_Intersects(cb.geom, parcel.geom)
ORDER BY distance_m ASC
LIMIT 301
""")
# Высота этажа для грубой 3D-экструзии footprint'а (§7). Типовой МКД ≈ 3 м/этаж —
# достаточная точность для превью-сцены «Размещение застройки».
_NEIGHBOR_FLOOR_HEIGHT_M = 3.0
# Отдаём максимум 300 соседей (LIMIT 301 в SQL — «взяли на один больше» чтобы
# без COUNT(*) понять, обрезали ли выборку → truncated).
_NEIGHBOR_MAX_FEATURES = 300
@router.get(
"/{cad_num}/neighbor-buildings",
summary="Соседние здания (footprint'ы) для 3D-сцены §7 (Forgejo #2180)",
)
def get_parcel_neighbor_buildings(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
radius_m: Annotated[int, Query(ge=50, le=1000)] = 300,
) -> dict[str, Any]:
"""GeoJSON FeatureCollection footprint'ов соседних зданий вокруг участка.
Для 3D-сцены «Размещение застройки» (§7 отчёта ПТИЦА): рисуем существующую
застройку контекста, чтобы концепция вписывалась в окружение. Здания берём из
`cad_buildings` в радиусе `radius_m` (ST_DWithin по geography). Здания,
ПЕРЕСЕКАЮЩИЕ сам участок, исключаем на его месте строим мы (их footprint'ы
только мешали бы сцене).
Геометрия упрощается (ST_SimplifyPreserveTopology, ~0.5 м) фронту не нужна
кадастровая точность контура для превью. properties каждой фичи:
cad_num, floors (int|null), height_m (floors×3 если floors>0, иначе null),
building_name (null если пусто), year_built (int|null), is_neighbor=true.
Отдаём максимум 300 ближайших (ORDER BY расстоянию); `truncated=true` если в
радиусе их больше. Участок не найден (нет geom) 404.
Query params:
radius_m: радиус поиска, 501000 м (default 300).
"""
rows = (
db.execute(
_NEIGHBOR_BUILDINGS_SQL,
{"cad": cad_num, "radius": float(radius_m)},
)
.mappings()
.all()
)
# Пустой результат может значить И «участка нет в БД», И «участок есть, но
# соседей в радиусе нет» — надо различать (404 vs пустой 200). Отдельный
# быстрый EXISTS-пробник участка: без него не отличить «нет geom» от «нет
# соседей». Только когда rows пуст (в норме — 1 дешёвый запрос сверху).
if not rows:
parcel_exists = db.execute(
text(
"SELECT 1 FROM cad_parcels_geom "
"WHERE cad_num = CAST(:cad AS text) AND geom IS NOT NULL LIMIT 1"
),
{"cad": cad_num},
).first()
if parcel_exists is None:
raise HTTPException(
status_code=404,
detail=(
f"Геометрия участка {cad_num} не найдена в БД. "
"Запустите POST /analyze для дозагрузки."
),
)
truncated = len(rows) > _NEIGHBOR_MAX_FEATURES
kept = rows[:_NEIGHBOR_MAX_FEATURES]
features: list[dict[str, Any]] = []
for r in kept:
# geojson — строка из ST_AsGeoJSON; парсим в dict для валидного вложения
# в FeatureCollection (иначе фронт получит geometry как escaped-строку).
geom_raw = r.get("geojson")
if not geom_raw:
# Топологически битый polygon → ST_AsGeoJSON(NULL). Пропускаем: одна
# плохая геометрия не должна ронять всю сцену.
continue
geometry = json.loads(geom_raw)
floors = r.get("floors")
floors_int = int(floors) if floors is not None and int(floors) > 0 else None
year_built = r.get("year_built")
building_name = (r.get("building_name") or "").strip() or None
features.append(
{
"type": "Feature",
"geometry": geometry,
"properties": {
"cad_num": r.get("cad_num"),
"floors": floors_int,
"height_m": (
round(floors_int * _NEIGHBOR_FLOOR_HEIGHT_M, 1)
if floors_int is not None
else None
),
"building_name": building_name,
"year_built": int(year_built) if year_built is not None else None,
"is_neighbor": True,
},
}
)
return {
"type": "FeatureCollection",
"features": features,
"count": len(features),
"radius_m": radius_m,
"truncated": truncated,
}
_ORS_BASE = "https://api.openrouteservice.org/v2/isochrones"
_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"})

View file

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

@ -65,11 +65,6 @@ class ConnectionPointsSummary(BaseModel):
in_protection_zone: bool
protection_zones_intersecting: int
total_structures_in_radius: int
# Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без
# LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL).
# ADDITIVE (default False) — старые клиенты не ломаются.
protection_zones_truncated: bool = False
structures_truncated: bool = False
class ConnectionPointsResponse(BaseModel):
@ -105,10 +100,6 @@ class UtilityInfrastructureSummary(BaseModel):
nearest_distance_m: float | None
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
nearest_by_kind: dict[str, float | None]
# Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же
# ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200).
# ADDITIVE (default False) — старые клиенты не ломаются.
features_truncated: bool = False
class UtilityInfrastructureResponse(BaseModel):
@ -177,38 +168,13 @@ class GasGrsRow(BaseModel):
class GasCapacityBlock(BaseModel):
"""Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.
+ счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС 960 АО
«Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4.
"""
"""Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1."""
# Городские ГРС (Свердловск/Екатеринбург) — агрегация по 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):
@ -230,74 +196,14 @@ class HeatCapacityBlock(BaseModel):
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
# Дата генерации готового PDF (ISO) — только при status="ready" (кэш-хит); на
# building-ветке None (файла ещё нет). Даёт fast-path кнопке дату без GET /status.
report_generated_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) ───────────────────────────────
@ -604,25 +510,6 @@ 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 для ТЗ."""
@ -648,28 +535,10 @@ class LayoutTzRecommendation(BaseModel):
class LayoutDataQuality(BaseModel):
"""Метаданные качества данных (coverage).
"""Метаданные качества данных (coverage)."""
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
objects_with_velocity_data: int
objects_total_in_radius: int
velocity_coverage_pct: float
confidence: Literal["high", "medium", "low"]
@ -680,10 +549,6 @@ 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) ──────────────────────────────────────
@ -913,7 +778,6 @@ class AnalyzeResponse(BaseModel):
parcel_meta: dict[str, Any] | None = None
recent_permits_in_quarter: list[dict[str, Any]] | None = None
permits_summary: dict[str, Any] | None = None
permits_nearby: dict[str, Any] | None = None
zoning: dict[str, Any] | None = None
success_recommendation: dict[str, Any] | None = None
isochrones_available: bool | None = None

View file

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

View file

@ -19,7 +19,7 @@ from typing import Any
from sqlalchemy.orm import Session
from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION, latest_run_for
from app.services.analysis_runs.repository import latest_run_for
logger = logging.getLogger(__name__)
@ -28,10 +28,6 @@ logger = logging.getLogger(__name__)
# схеме явный и устойчивый.
_FORECAST_SCHEMA_VERSION = "1.0"
# Максимум названий ЗОУИТ-зон в курируемом срезе (§19: ограничиваем объём, чтобы наружу
# не ушёл длинный «хвост» — сводки хватает списка из первых нескольких зон).
_MAX_ZOUIT_NAMES = 15
def get_report_for_chat(
db: Session,
@ -61,116 +57,3 @@ def get_report_for_chat(
return None, None
return result, run.id
def _as_dict(value: Any) -> dict[str, Any]:
"""Секция payload как dict или {} (graceful — как _as_dict в full_report_html)."""
return value if isinstance(value, dict) else {}
def _put(target: dict[str, Any], key: str, value: Any) -> None:
"""Положить ТОЛЬКО скаляр в курируемый срез (§19 data-residency).
Гейт двойной: и по КЛЮЧУ (вызывающий передаёт фиксированный whitelist), и по ТИПУ
значения. Пропускаем лишь str|int|float|bool dict/list/любая вложенная структура
ОТБРАСЫВАЕТСЯ (иначе сырой под-словарь ЕГРН/зонирования с PII мог бы утечь наружу;
redaction-слой ловит regex-PII в строках, но НЕ структуры). Пустую строку опускаем.
UP038: union-форма isinstance (не кортеж) pre-commit пинит старый ruff.
"""
if not isinstance(value, str | int | float | bool):
return
if value == "":
return
target[key] = value
def _zouit_names(overlaps: list[Any]) -> list[str]:
"""Собрать dedup-список названий ЗОУИТ-зон (type_zone|name), обрезанный до лимита.
§19: ТОЛЬКО строки-названия никакой геометрии/reg_numb/coverage сырых пересечений.
Не-строковые значения (dict/list/число) ОТБРАСЫВАЕМ не приводим str()'ом, иначе
вложенная структура утекла бы как её repr. dict.fromkeys держит порядок и dedup
(зеркало _build_zouit в §1-рендере).
"""
# Фильтруем до str ДО dict.fromkeys: не-str (dict/list) не только небезопасны для
# §19, но и unhashable → уронили бы dict.fromkeys TypeError'ом.
raw = (ov.get("type_zone") or ov.get("name") for ov in overlaps if isinstance(ov, dict))
names = list(dict.fromkeys(n for n in raw if isinstance(n, str) and n))
return names[:_MAX_ZOUIT_NAMES]
def get_parcel_context_for_chat(db: Session, cad_num: str) -> dict[str, Any] | None:
"""Курируемый паспорт участка + градрегламент для чата (§19 data-residency).
READ-ONLY: последний analyze-ран (schema_version='analyze-1.0', НЕ §22-форсайт)
ПЛОСКИЙ whitelist-дикт из ФИКСИРОВАННОГО набора публичных градо-полей. Сырой
analyze-blob во внешний LLM ЗАПРЕЩЁН (см. safe_payload.py) поэтому собираем только
скаляры и список строк по явным ключам, НИКОГДА не протаскивая под-словари/геометрию.
Ключи payload те же, что читает §1-рендер отчёта (full_report_html:_build_parcel_facts
/ _build_zoning / _build_zouit): egrn, nspd_zoning, encumbrance, nspd_zouit_overlaps.
None если analyze-рана/результата нет (graceful, как get_report_for_chat) вызывающий
работает как раньше (только §22-отчёт, без паспорта участка).
"""
run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
if run is None:
return None
result = run.result
if not isinstance(result, dict):
logger.warning(
"chat: analyze run %s for cad=%s has non-dict result (%s) — no parcel context",
run.id,
cad_num,
type(result).__name__,
)
return None
egrn = _as_dict(result.get("egrn"))
nspd_zoning = _as_dict(result.get("nspd_zoning"))
encumbrance = _as_dict(result.get("encumbrance"))
overlaps = [ov for ov in (result.get("nspd_zouit_overlaps") or []) if isinstance(ov, dict)]
context: dict[str, Any] = {}
# ── Кадастровые факты (ЕГРН) — скаляры, без геометрии ────────────────────────
_put(context, "address", egrn.get("address"))
_put(context, "area_m2", egrn.get("area_m2"))
_put(context, "land_category", egrn.get("land_category"))
_put(context, "permitted_use_text", egrn.get("permitted_use_text"))
_put(context, "cadastral_value_rub", egrn.get("cadastral_value_rub"))
_put(context, "parcel_status", egrn.get("parcel_status"))
_put(context, "ownership_type", egrn.get("ownership_type"))
# ── Территориальная зона ПЗЗ + лимиты регламента (те же ключи, что §1-рендер) ─
_put(
context,
"zone_code",
nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index"),
)
_put(context, "zone_name", nspd_zoning.get("zone_name"))
_put(context, "max_far", nspd_zoning.get("max_far"))
_put(context, "max_floors", nspd_zoning.get("max_floors"))
_put(context, "max_height_m", nspd_zoning.get("max_height_m"))
_put(context, "max_building_pct", nspd_zoning.get("max_building_pct"))
_put(context, "min_parcel_area_m2", nspd_zoning.get("min_parcel_area_m2"))
_put(context, "regulation_source", nspd_zoning.get("regulation_source"))
# ── ЗОУИТ-обременения: сводка + список названий (dedup, capped) ───────────────
has_zouit = encumbrance.get("has_zouit")
zouit_count = encumbrance.get("zouit_count")
zouit_names = _zouit_names(overlaps)
# Противоречие «encumbrance=нет, но пересечения ЕСТЬ» — доверяем фактам НСПД (тот же
# приём, что _build_zouit): свежий геослой перекрывает устаревшую сводку.
if not has_zouit and zouit_names:
has_zouit = True
if zouit_count in (None, 0):
zouit_count = len(overlaps)
_put(context, "has_zouit", has_zouit)
_put(context, "zouit_count", zouit_count)
if zouit_names:
context["zouit_zone_names"] = zouit_names
return context or None

View file

@ -14,14 +14,9 @@ tool'ами (см. tools.py). ЭТО ЕДИНСТВЕННОЕ место, чер
РАЗРЕШЕНО в ``section_data`` / ``fields``:
срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
scoring / confidence / scenarios) это посчитанные advisory-агрегаты.
``parcel_context`` КУРИРУЕМЫЙ срез analyze-рана (retrieval.get_parcel_context_for_chat):
публичные градоданные участка (адрес, площадь, категория, ВРИ, тер.зона ПЗЗ + лимиты
застройки, ЗОУИТ-сводка + названия зон). Строится по фиксированному whitelist'у из
скаляров и списка строк БЕЗ геометрии/координат, без сырых под-словарей, без PII.
ЗАПРЕЩЕНО (НИКОГДА не должно сюда дойти):
сырой ``analyze``-blob целиком / сырые строки БД (rosreestr_deals, parcels, );
геометрия/координаты участка или ЗОУИТ-пересечений;
сырой ``analyze``-blob / сырые строки БД (rosreestr_deals, parcels, );
свободный insight-текст / лиды / любой контент с PII;
любой источник, помеченный confidential.

View file

@ -3,11 +3,10 @@
LLM в tool-loop'е (см. orchestrator.py) просит секции отчёта через function-calling.
Здесь две вещи и НИЧЕГО больше:
1. OpenAI tool-спеки (JSON-schema) шести read-only секционных tool'ов
1. OpenAI tool-спеки (JSON-schema) пяти read-only секционных tool'ов
(`get_exec_summary` / `get_product_recommendation` / `get_forecast` /
`get_risks` / `get_scenarios` / `get_parcel_info`). Без параметров каждый отдаёт
фиксированную секцию(и) отчёта (модель не управляет вычислениями, только запрашивает
данные).
`get_risks` / `get_scenarios`). Без параметров каждый отдаёт фиксированную
секцию(и) отчёта (модель не управляет вычислениями, только запрашивает данные).
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
НИКАКОЙ движковой математики только срез готового dict'а.
@ -74,16 +73,6 @@ def get_scenarios(report: dict[str, Any]) -> dict[str, Any]:
return _section(report, "scenarios")
def get_parcel_info(report: dict[str, Any]) -> dict[str, Any]:
"""§1 parcel_context — паспорт участка + градрегламент ПЗЗ + ЗОУИТ.
Курируемый срез analyze-рана (тер.зона ПЗЗ, ВРИ, лимиты застройки, ЗОУИТ), влитый
эндпоинтом под ключ "parcel_context" (retrieval.get_parcel_context_for_chat). Секции
в отчёте нет (analyze-рана не было) маркер «недоступно», как у остальных tool'ов.
"""
return _section(report, "parcel_context")
# ── Реестр имя→executor + имя→секции отчёта (для provenance grounded_in.sections) ─
# ЕДИНЫЙ источник истины: и спеки, и orchestrator берут имена/маппинг отсюда.
@ -95,7 +84,6 @@ _TOOLS: dict[str, tuple[Callable[[dict[str, Any]], dict[str, Any]], tuple[str, .
"get_forecast": (get_forecast, ("future_market",)),
"get_risks": (get_risks, ("scoring", "confidence")),
"get_scenarios": (get_scenarios, ("scenarios",)),
"get_parcel_info": (get_parcel_info, ("parcel_context",)),
}
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
@ -115,11 +103,8 @@ _TOOL_DESCRIPTIONS: dict[str, str] = {
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
"и уровень/факторы уверенности отчёта."
),
"get_scenarios": ("Сценарии развития: разброс по консервативному / базовому / агрессивному."),
"get_parcel_info": (
"Паспорт участка и градостроительный регламент: адрес, площадь, категория земель, "
"ВРИ, территориальная зона ПЗЗ (код и название), лимиты застройки, "
"ЗОУИТ-обременения."
"get_scenarios": (
"Сценарии развития: разброс по консервативному / базовому / агрессивному."
),
}
@ -143,7 +128,7 @@ def _spec(name: str) -> dict[str, Any]:
def tool_specs() -> list[dict[str, Any]]:
"""Все 6 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
"""Все 5 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
return [_spec(name) for name in _TOOLS]
@ -176,7 +161,6 @@ __all__ = [
"execute_tool",
"get_exec_summary",
"get_forecast",
"get_parcel_info",
"get_product_recommendation",
"get_risks",
"get_scenarios",

View file

@ -1,195 +0,0 @@
"""Минимальный синхронный 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

@ -1,220 +0,0 @@
"""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

@ -12,40 +12,20 @@ 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)
'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' ручная корректура
'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' ручная корректура
"""
from __future__ import annotations
import argparse
import logging
import math
import re
import sys
from dataclasses import dataclass, field
from dataclasses import dataclass
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 (высокая уверенность)
@ -254,919 +234,3 @@ 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

File diff suppressed because it is too large Load diff

View file

@ -1,494 +0,0 @@
"""Оркестратор полного 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")`) нет best-effort
СИНХРОННО считаем его тут же (`_ensure_forecast_run`, зеркало Celery-таски форсайта,
~2030с) и перечитываем; всё ещё нет (сбой/тонкие данные) 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`).
6b. DOCX-вариант (PR-F, `build_full_report_docx`) из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ
карт-PNG (НЕ рендерим карты дважды) рядом `.docx`-файл.
7. Запись файлов (PDF + DOCX, атомарно tmp+os.replace) + метадата-ран `report-pdf-1.0`
(result = pdf_path/docx_path/analyze_run_id/forecast_run_id/generated_at/size_bytes/
docx_size_bytes). Старые раны без docx_path download?format=docx отдаёт 404.
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_docx import build_full_report_docx
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"
# Горизонты best-effort синхронного форсайта (мес). Зеркало Celery-таски
# `forecast_site_finder_report(horizon=12)`: `_horizons_for(12)` = sorted({6,12,18,24}|{12})
# = [6,12,18,24] (forecast.py) = orchestrator._DEFAULT_HORIZONS. Держим тот же набор,
# чтобы «холодный» участок получил §4§6 идентичные ленивому GET /forecast-пути.
_FORECAST_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
# Верхняя граница длинной стороны карт-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 _largest_polygon_geojson(geom: dict[str, Any]) -> dict[str, Any]:
"""MultiPolygon → GeoJSON крупнейшего полигона-контура; Polygon и прочее — как есть.
Многоконтурный участок приходит как MultiPolygon, а concept-стек (`parse_parcel` +
`_parcel_centroid_wkt`) принимает только Polygon `ParcelGeometryError`. Берём
контур с максимальной площадью (тот же приём, что generative-геометрия применяет к
buildable-мультиполигону после буфера, geometry.py:263-265). Любой сбой парсинга /
не-MultiPolygon возвращаем geom без изменений (best-effort, не роняем концепцию).
Args:
geom: GeoJSON-геометрия участка (Polygon / MultiPolygon / Feature-обёртка).
Returns:
GeoJSON Polygon крупнейшего контура (если вход был MultiPolygon), иначе `geom`.
"""
geom_dict = geom.get("geometry") if geom.get("type") == "Feature" else geom
if not isinstance(geom_dict, dict) or geom_dict.get("type") != "MultiPolygon":
return geom
try:
from shapely.geometry import mapping, shape
multi = shape(geom_dict)
if multi.is_empty or not hasattr(multi, "geoms"):
return geom
largest = max(multi.geoms, key=lambda g: g.area)
logger.info(
"build_full_report: multi-contour участок — взят крупнейший контур из %d",
len(list(multi.geoms)),
)
return dict(mapping(largest))
except Exception:
# Вырожденная/битая геометрия — отдаём как есть, concept-стек сам решит (best-effort).
logger.exception("build_full_report: не удалось выделить крупнейший контур MultiPolygon")
return geom
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
# Многоконтурный участок → geom = MultiPolygon, а concept-стек (parse_parcel +
# _parcel_centroid_wkt через _parse_polygon) принимает ТОЛЬКО Polygon и роняет
# ParcelGeometryError("expected Polygon, got MultiPolygon"). Берём крупнейший контур —
# ровно как generative-геометрия после буфера (geometry.py:263-265). Иначе §7 и
# market-price молча деградируют на любом мультиконтуре.
geom = _largest_polygon_geojson(geom)
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 _ensure_forecast_run(
db: Session,
cad: str,
analyze_row: Any,
analyze: dict[str, Any],
) -> None:
"""Best-effort синхронно посчитать §22-форсайт для «холодного» участка (#2259 gap).
§22-форсайт-ран ("1.0") существует ТОЛЬКО если пользователь открывал страницу участка
(§22 строится лениво GET /forecast-поллингом Celery-таской `forecast_site_finder_report`).
На холодном участке полный отчёт выходил БЕЗ §4§6. Здесь тот же compute+persist, что
делает Celery-таска, но СИНХРОННО и ПРЯМЫМ вызовом: мы УЖЕ внутри worker'а
(build_full_report_task), поэтому НЕ .delay считаем inline (~2030с) и персистим тем же
контрактом, чтобы последующий `latest_run_for("1.0")` в build_full_report поймал свежий ран
(его новый id корректно войдёт в кэш-ключ отчёта cache-miss §4§6 попадут в PDF).
Зеркало `forecast_site_finder_report(horizon=12)` (workers/tasks/forecast.py):
horizons = `_FORECAST_HORIZONS` (= `_horizons_for(12)` = [6,12,18,24]);
district = денорм-колонка рана fallback analyze["district"]["district_name"];
`build_site_finder_report(...)` `report.as_dict()` `persist_analysis_run(...,
schema_version=d["schema_version"], status="done", ...)`.
Best-effort: ЛЮБОЙ сбой/долгий compute logger.warning + return (НЕ exception форсайт
может честно не собраться на тонких данных, GlitchTip-шум не нужен). Тогда отчёт, как и
раньше, выйдет без §4§6 (part_b деградирует «нет данных»).
Args:
db: SQLAlchemy session (та же, что у build_full_report свою НЕ открываем).
cad: кадастровый номер участка.
analyze_row: Row analyze-рана (несёт денорм `district`).
analyze: persist-payload analyze-рана (district-fallback + competitors для сегмента).
"""
try:
# Lazy import — тяжёлый forecasting-стек не нужен на module-load (как concept-стек).
from app.services.forecasting.orchestrator import build_site_finder_report
district = analyze_row.district or (analyze.get("district") or {}).get("district_name")
logger.info(
"build_full_report: холодный участок cad=%s — best-effort синхронный §22-форсайт "
"(district=%s horizons=%s)",
cad,
district,
_FORECAST_HORIZONS,
)
report = build_site_finder_report(
db,
analyze=analyze,
cad_num=cad,
district=district,
horizons=_FORECAST_HORIZONS,
)
d = report.as_dict()
new_id = persist_analysis_run(
db,
cad_num=cad,
result=d,
params={"horizon": 12, "source": "full-report-inline-forecast"},
district=district,
confidence=(d.get("confidence") or {}).get("level"),
status="done",
schema_version=d["schema_version"], # "1.0" (SiteFinderReport._SCHEMA_VERSION)
created_by=None,
segment=(d.get("meta") or {}).get("segment"),
)
logger.info("build_full_report: §22-форсайт посчитан inline cad=%s run_id=%s", cad, new_id)
except Exception:
# Форсайт может честно не собраться (тонкие данные / сбой §9.x-шва) или занять
# слишком долго — деградируем в отчёт без §4§6 (warning, НЕ exception: держим
# GlitchTip-шум в узде, ведёт себя как ленивая Celery-таска, которая тоже best-effort).
logger.warning(
"build_full_report: inline §22-форсайт не собрался cad=%s → отчёт без §4§6",
cad,
exc_info=True,
)
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 _atomic_write(path: Path, data: bytes) -> None:
"""Атомарно записать байты в `path`: `.<pid>.tmp` рядом → `os.replace`.
Два конкурентных POST в один день целятся в ОДИН путь (имя несёт только дату)
прямой `write_bytes` мог бы interleave-писать байты обоих рендеров в один файл
(битый вывод). `os.replace` атомарен в пределах одной FS download всегда видит
целый файл (свой или чужой). Общий для PDF и DOCX (тот же приём).
"""
tmp_path = path.with_suffix(f".{os.getpid()}.tmp")
tmp_path.write_bytes(data)
os.replace(tmp_path, path)
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)
if forecast_row is None:
# Холодный участок: §22-форсайт-ран ("1.0") строится лениво GET /forecast-поллингом
# и на не-открытом участке отсутствует → отчёт выходил без §4§6. Best-effort
# считаем его СИНХРОННО прямо тут (мы в worker'е) и перечитываем — самодостаточность
# отчёта важнее +2030с (как §7-концепция генерится в оркестраторе). Сбой/долго →
# forecast_row остаётся None, part_b деградирует «нет данных» (как раньше).
_ensure_forecast_run(db, cad, analyze_row, analyze)
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, analyze_result=analyze)
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)
# DOCX-вариант (PR-F): из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ карт-PNG (parcel_png /
# concept_png уже отрендерены выше — НЕ рендерим карты дважды). Зеркалит §1§7 PDF.
docx_bytes = build_full_report_docx(
analyze,
forecast,
concept,
connection_capacity,
cad=cad,
address=address if isinstance(address, str) else None,
generated_at=generated_at_ru,
parcel_map_png=parcel_png,
concept_map_png=concept_png,
)
# Запись файлов на volume + метадата-ран. Каталог создаём (parents, exist_ok).
reports_dir = Path(settings.reports_dir)
reports_dir.mkdir(parents=True, exist_ok=True)
base_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}"
pdf_path = reports_dir / f"{base_name}.pdf"
docx_path = reports_dir / f"{base_name}.docx"
# Атомарная запись обоих файлов (tmp+os.replace — см. `_atomic_write`).
_atomic_write(pdf_path, pdf_bytes)
_atomic_write(docx_path, docx_bytes)
size_bytes = len(pdf_bytes)
docx_size_bytes = len(docx_bytes)
result: dict[str, Any] = {
"pdf_path": str(pdf_path),
"docx_path": str(docx_path),
"analyze_run_id": analyze_run_id,
"forecast_run_id": forecast_run_id,
"generated_at": generated_at.isoformat(),
"size_bytes": size_bytes,
"docx_size_bytes": docx_size_bytes,
}
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — файлы
# уже записаны, просто следующий вызов не поймает 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 pdf=%s (%d B) docx=%s (%d B) analyze=%s forecast=%s",
cad,
pdf_path,
size_bytes,
docx_path,
docx_size_bytes,
analyze_run_id,
forecast_run_id,
)
return result

View file

@ -14,20 +14,15 @@ from app.schemas.parcel import BestLayoutsResponse
logger = logging.getLogger(__name__)
def build_layout_tz_html(
def render_layout_tz_pdf(
response: BestLayoutsResponse,
*,
cad_num: str,
parcel_address: str | None = None,
radius_km: float,
time_window: str,
) -> str:
"""Собрать HTML ТЗ из best-layouts response (PURE — без WeasyPrint/БД/сети).
Вынесено из :func:`render_layout_tz_pdf`, чтобы HTML-разметку (наличие блоков,
RU-микрокопию, форматирование /м²) можно было проверять юнит-тестами БЕЗ
native GTK/Pango (WeasyPrint на macOS/Windows отсутствует). PDF-обёртка ниже
просто рендерит этот HTML.
) -> bytes:
"""Render ТЗ PDF от best-layouts response.
Args:
response: BestLayoutsResponse от /best-layouts endpoint
@ -37,7 +32,7 @@ def build_layout_tz_html(
time_window: окно анализа (last_month/quarter/year)
Returns:
HTML-строка документа (готова для WeasyPrint).
PDF bytes (готово для StreamingResponse)
"""
today = dt.date.today().strftime("%d.%m.%Y")
safe_cad = _html.escape(cad_num)
@ -50,18 +45,6 @@ def build_layout_tz_html(
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>"
@ -87,42 +70,6 @@ def build_layout_tz_html(
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 = (
@ -148,8 +95,6 @@ def build_layout_tz_html(
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; }}
@ -188,11 +133,11 @@ def build_layout_tz_html(
</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>
@ -209,40 +154,6 @@ def build_layout_tz_html(
</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

@ -1,386 +0,0 @@
"""Серверный статик-рендер карт для 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,11 +24,6 @@ import logging
# импорт из модуля удовлетворяет strict no-implicit-reexport.
from ezdxf.enums import TextEntityAlignment
from ezdxf.filemanagement import new as ezdxf_new
# Modelspace определён в ezdxf.layouts.layout и не в __all__ пакета ezdxf.layouts;
# импорт из модуля-определителя удовлетворяет strict no-implicit-reexport (как ezdxf_new).
from ezdxf.layouts.layout import Modelspace
from shapely.coords import CoordinateSequence
from shapely.geometry import Polygon
from app.schemas.concept import ConceptVariant
@ -49,7 +44,7 @@ _LAYER_BUILDINGS = "BUILDINGS"
_LABEL_HEIGHT_M = 2.0
def _ring_points(coords: CoordinateSequence) -> list[tuple[float, float]]:
def _ring_points(coords: object) -> list[tuple[float, float]]:
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
pts = list(coords)
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
@ -58,7 +53,7 @@ def _ring_points(coords: CoordinateSequence) -> list[tuple[float, float]]:
return [(float(x), float(y)) for x, y in pts]
def _add_polygon(msp: Modelspace, poly: Polygon, layer: str) -> None:
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
@ -156,15 +151,15 @@ def _feature_to_metric_polygon(parcel: Parcel, feature: object) -> Polygon | Non
return None
coords = geometry.get("coordinates")
# Shapely mapping() emits nested tuples; accept both tuple and list.
if not isinstance(coords, list | tuple) or not coords:
if not isinstance(coords, (list, tuple)) or not coords:
return None
ring = coords[0]
if not isinstance(ring, list | tuple) or len(ring) < 4:
if not isinstance(ring, (list, tuple)) or len(ring) < 4:
return None
metric_pts: list[tuple[float, float]] = []
for pt in ring:
if not isinstance(pt, list | tuple) or len(pt) < 2:
if not isinstance(pt, (list, tuple)) or len(pt) < 2:
return None
lon, lat = float(pt[0]), float(pt[1])
x, y = parcel.wgs84_to_metric(lon, lat)

View file

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

View file

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

View file

@ -48,24 +48,22 @@ _SYSTEM_BASE = PromptTemplate(
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
_CHAT_SYSTEM = PromptTemplate(
name="chat_system",
version=3,
version=2,
template=(
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
"ЖЁСТКИЕ ПРАВИЛА:\n"
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
"Чтобы получить нужные числа, вызови подходящий инструмент. Для вопросов про сам "
"участок — адрес, площадь, категорию земель, ВРИ, территориальную зону ПЗЗ и её "
"код/название, лимиты застройки, ЗОУИТ-обременения — вызови get_parcel_info.\n"
"Чтобы получить нужные числа, вызови подходящий инструмент.\n"
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
"отчёте нет. Не подставляй правдоподобные значения.\n"
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
"инвестиционного решения. Не давай гарантий доходности.\n"
"5. Вопросы, выходящие за рамки данных отчёта и паспорта участка (сравнение с другими "
"участками, юридические заключения, получение разрешений/согласований) — вежливо "
"скажи, что это вне области отчёта, и предложи вопросы по участку и его форсайту.\n"
"5. Вопросы вне отчёта по участку (градостроительная документация / ПЗЗ-разрешения, "
"сравнение с другими участками, юридические заключения) — вежливо скажи, что это вне "
"области отчёта, и предложи вопросы по самому форсайту.\n"
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "

View file

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

View file

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

View file

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

View file

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

View file

@ -1,501 +0,0 @@
"""Загрузчик реестра РНС/РВЭ Свердловской области из открытого API ГИСОГД-СО (#2367).
Источник: ``gisogd66.midural.ru`` региональная ГИС органов гос. власти Свердловской
области. Открытый JSON-API раздела 13 («Сведения о документах в деле о земельном
участке»), БЕЗ авторизации (bare curl 200, разведка 2026-07-04).
Три эндпоинта (все GET JSON, base ``https://gisogd66.midural.ru/api/v1/{schema}``):
* ``/gisogddocgroups/razdel13`` группы документов раздела (DocRS/DocRV/);
* ``/gisogddocgroupdocs/razdel13/{group}`` весь список группы одним ответом (без
пагинации): key, name, docNum, dateDoc, numReg, dateReg;
* ``/gisogddocs/razdel13/{key}`` карточка документа: spatialUnit (кад.,
может быть несколько через запятую / отсутствовать), infosetGeoJson (GeoJSON Polygon
WGS84 lon/lat, может отсутствовать hasGeometry=false), approvedOrganization, docNum,
dateDoc, dateReg.
Две схемы (тянем обе, sverdregion первым): ``agate_sverdregion`` (регион-агрегат,
стабильные карточки, покрывает и ЕКБ) и ``agate_ekbgo`` (ЕКБ, свежее на ~неделю, но
часть старых ключей битая на портале). Один документ может встретиться в обеих
бизнес-ключ ``(doc_group, doc_num)``; при конфликте предпочитаем запись с более
поздним ``date_reg``.
Инкрементальность: список группы сверяется с БД по (source_schema, source_key, date_reg);
карточки (второй запрос дорогой) тянутся ТОЛЬКО для новых / изменившихся документов.
Politeness: единый httpx-клиент с limits(max_connections=5), timeout=20с, 2 ретрая на
сетевых ошибках. Карточки (5xx-битые записи портала) НЕ ретраятся deterministically
broken, не транзиент. Circuit breaker: 50 карточек подряд с ошибкой прерываем группу
(битый сегмент). UPSERT per-row SAVEPOINT (битая строка не валит батч). Периодический
db.commit() (каждые 200 записей + после каждой schema/group), чтобы партиал пережил
soft_time_limit-kill.
"""
from __future__ import annotations
import json
import logging
import re
import time
from datetime import date, datetime
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
# ── Константы источника ───────────────────────────────────────────────────────
GISOGD_BASE = "https://gisogd66.midural.ru/api/v1"
_SECTION = "razdel13"
# Схемы источника — обе тянем. Порядок ВАЖЕН: sverdregion первым.
# sverdregion (регион-агрегат) отдаёт карточки стабильно и покрывает в т.ч. ЕКБ-доки
# через агрегацию, поэтому он — базовый источник. ekbgo вторым как freshness-энхансер
# (свежее на ~неделю), НО у него большой сегмент старых ключей (префикс ~100001007xxx)
# детерминированно отдаёт 500 на карточках (сломано на стороне портала, 2026-07-04) —
# гоняем его после того, как регион уже покрыл основную массу (см. circuit breaker ниже).
SCHEMAS: tuple[str, ...] = ("agate_sverdregion", "agate_ekbgo")
# Группа источника (doc group key) → наш doc_group-код в БД (CHECK IN ('RS','RV')).
GROUP_CODE: dict[str, str] = {"DocRS": "RS", "DocRV": "RV"}
_HTTP_TIMEOUT = 20.0
_MAX_CONNECTIONS = 5
_MAX_RETRIES = 2 # ретраи (helper делает суммарно до 1 + _MAX_RETRIES попыток)
_RETRY_BACKOFF_S = 1.5
_CARD_SLEEP_S = 0.05 # вежливая пауза между карточками (второй, «дорогой» запрос)
_COMMIT_EVERY = 200 # периодический commit внутри группы (партиал переживает time-limit)
# Circuit breaker: столько карточек ПОДРЯД с ошибкой fetch → прерываем группу (портал
# отдаёт битый сегмент старых ключей детерминированным 5xx; остаток догоним будущими ранами).
_CARD_FAIL_BREAKER = 50
USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) Site Finder gisogd permits loader"
# Валидация кадастрового номера: NN:NN:NNNNNN(N):N (6-7 знаков в третьем блоке).
_CAD_RE = re.compile(r"^\d{2}:\d{2}:\d{6,7}:\d+$")
# ── HTTP helpers (module-level — мокаются в тестах) ───────────────────────────
def _make_client() -> httpx.Client:
"""httpx.Client с пулом (max_connections=5), таймаутом и UA. Закрывать вызывающим."""
return httpx.Client(
base_url=GISOGD_BASE,
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
limits=httpx.Limits(max_connections=_MAX_CONNECTIONS),
)
def _get_json(client: httpx.Client, path: str, *, retries_5xx: int = _MAX_RETRIES) -> Any:
"""GET path → JSON с ретраями на 5xx / сетевых ошибках.
Сетевые Timeout/TransportError ретраятся всегда (до 1 + _MAX_RETRIES попыток)
это транзиент. 5xx ретраятся ``retries_5xx`` раз: списки группы с дефолтом
(транзиентная нагрузка портала), карточки с ``retries_5xx=0`` (5xx карточки
детерминированно битые записи сегмента портала, не транзиент). 4xx сразу raise.
"""
last_exc: Exception | None = None
for attempt in range(_MAX_RETRIES + 1):
try:
resp = client.get(path)
if resp.status_code >= 500:
last_exc = httpx.HTTPStatusError(
f"{resp.status_code} on {path}", request=resp.request, response=resp
)
# 5xx: ретраим только пока attempt в пределах retries_5xx (0 → сразу raise).
if attempt >= retries_5xx:
raise last_exc
logger.warning(
"gisogd66: %s (попытка %d) на %s", resp.status_code, attempt + 1, path
)
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
continue
resp.raise_for_status()
return resp.json()
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc
logger.warning(
"gisogd66: сетевая ошибка (попытка %d) на %s: %s", attempt + 1, path, exc
)
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
assert last_exc is not None
raise last_exc
def fetch_group_list(client: httpx.Client, schema: str, group: str) -> list[dict[str, Any]]:
"""Весь список документов группы (без пагинации) → list of dict.
Поля элемента: key, name, docNum, dateDoc, numReg, dateReg. Пустой список при
неожиданном (не-list) ответе.
"""
data = _get_json(client, f"/{schema}/gisogddocgroupdocs/{_SECTION}/{group}")
if not isinstance(data, list):
logger.warning("gisogd66: %s/%s список не-list (%s)", schema, group, type(data).__name__)
return []
logger.info("gisogd66: %s/%s список документов=%d", schema, group, len(data))
return data
def fetch_doc_card(client: httpx.Client, schema: str, key: str | int) -> dict[str, Any] | None:
"""Карточка документа → dict (или None при не-dict / ошибке карточки).
Поля: spatialUnit, infosetGeoJson, hasGeometry, approvedOrganization, docNum,
dateDoc, dateReg, name.
5xx карточки НЕ ретраятся (retries_5xx=0): битый сегмент старых ключей портала
отдаёт детерминированный 500 3 попытки с backoff = ~12с потерянного времени/карточку.
"""
data = _get_json(client, f"/{schema}/gisogddocs/{_SECTION}/{key}", retries_5xx=0)
if not isinstance(data, dict):
logger.warning("gisogd66: карточка %s/%s не-dict (%s)", schema, key, type(data).__name__)
return None
return data
# ── Парсинг полей ─────────────────────────────────────────────────────────────
def _parse_date(value: Any) -> date | None:
"""ISO-datetime / date-строка источника → date. None если пусто / не парсится.
Источник отдаёт «2026-06-02T00:00:00» (dateDoc) и «2026-06-19T15:55:11» (dateReg).
"""
if not value:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
s = str(value).strip()
if not s:
return None
# Обрезаем таймзону-суффикс 'Z' если вдруг придёт, парсим ISO.
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).date()
except ValueError:
pass
for fmt in ("%Y-%m-%d", "%d.%m.%Y"):
try:
return datetime.strptime(s[:10], fmt).date()
except ValueError:
continue
logger.debug("gisogd66: не распарсил дату %r", value)
return None
def parse_cad_nums(spatial_unit: Any) -> list[str]:
"""spatialUnit → список валидных кадастровых номеров (может быть несколько через запятую).
Split по запятой, strip, валидация по _CAD_RE. Невалидные (мусор) отбрасываются с
warning-логом. Отсутствие / пусто []. Дубликаты схлопываются (сохраняя порядок).
"""
if not spatial_unit:
return []
out: list[str] = []
seen: set[str] = set()
for part in str(spatial_unit).split(","):
cad = part.strip()
if not cad:
continue
if not _CAD_RE.match(cad):
logger.warning(
"gisogd66: невалидный кадастровый номер %r в spatialUnit — отброшен", cad
)
continue
if cad not in seen:
seen.add(cad)
out.append(cad)
return out
def extract_geojson(card: dict[str, Any]) -> str | None:
"""infosetGeoJson карточки → GeoJSON-строка (для ST_GeomFromGeoJSON) или None.
Источник кладёт GeoJSON КАК СТРОКУ в поле infosetGeoJson. Пусто / hasGeometry=false
/ невалидный JSON None (лоадер запишет geom=NULL). Приведение PolygonMultiPolygon
делается в SQL через ST_Multi здесь возвращаем сырой GeoJSON как есть.
"""
raw = card.get("infosetGeoJson")
if not raw:
return None
if isinstance(raw, dict):
# На случай если источник когда-нибудь отдаст объект, а не строку.
return json.dumps(raw)
s = str(raw).strip()
if not s:
return None
try:
json.loads(s) # sanity-парс: битую строку не отправляем в PostGIS
except (ValueError, TypeError) as exc:
logger.warning("gisogd66: битый infosetGeoJson (%s) — geom=NULL", exc)
return None
return s
# ── Инкрементальность ─────────────────────────────────────────────────────────
def _existing_reg_dates(db: Session, schema: str) -> dict[str, date | None]:
"""{source_key: date_reg} для уже загруженных из этой схемы записей.
Используется для инкрементальной сверки: карточку тянем только если key новый ИЛИ
date_reg в списке отличается от сохранённого.
"""
rows = db.execute(
text("SELECT source_key, date_reg FROM gisogd_permits WHERE source_schema = :schema"),
{"schema": schema},
).all()
return {str(r[0]): r[1] for r in rows}
# ── UPSERT ────────────────────────────────────────────────────────────────────
def _upsert_permit(db: Session, rec: dict[str, Any]) -> str:
"""UPSERT одной записи в gisogd_permits по (doc_group, doc_num). Per-row SAVEPOINT.
Конфликт-резолв: при коллизии бизнес-ключа обновляем ТОЛЬКО если у новой записи
date_reg НЕ старше сохранённой (EXCLUDED.date_reg >= existing) предпочитаем
более позднюю регистрацию (или запись без даты не затирает датированную).
Returns: 'inserted' | 'updated' | 'skipped_unchanged'.
"""
result = db.execute(
text("""
INSERT INTO gisogd_permits
(doc_group, doc_num, doc_name, date_doc, date_reg,
approved_organization, source_schema, source_key,
cad_nums, geom, fetched_at, updated_at)
VALUES (
:doc_group, :doc_num, :doc_name, :date_doc, :date_reg,
:approved_org, :source_schema, :source_key,
CAST(:cad_nums AS text[]),
-- CAST обязателен: без него ни одно вхождение :geojson не даёт Postgres
-- тип параметра (ST_GeomFromGeoJSON перегружена text/json/jsonb)
-- psycopg.errors.AmbiguousParameter на КАЖДОЙ строке (прод-прогон #2367).
CASE WHEN CAST(:geojson AS text) IS NULL THEN NULL
ELSE ST_Multi(ST_SetSRID(
ST_GeomFromGeoJSON(CAST(:geojson AS text)), 4326)) END,
NOW(), NOW()
)
ON CONFLICT (doc_group, doc_num) DO UPDATE
SET doc_name = EXCLUDED.doc_name,
date_doc = EXCLUDED.date_doc,
date_reg = EXCLUDED.date_reg,
approved_organization = EXCLUDED.approved_organization,
source_schema = EXCLUDED.source_schema,
source_key = EXCLUDED.source_key,
cad_nums = EXCLUDED.cad_nums,
geom = EXCLUDED.geom,
updated_at = NOW()
WHERE EXCLUDED.date_reg IS NOT DISTINCT FROM gisogd_permits.date_reg
OR EXCLUDED.date_reg > gisogd_permits.date_reg
OR gisogd_permits.date_reg IS NULL
RETURNING (xmax = 0) AS is_insert
"""),
{
"doc_group": rec["doc_group"],
"doc_num": rec["doc_num"],
"doc_name": rec["doc_name"],
"date_doc": rec["date_doc"],
"date_reg": rec["date_reg"],
"approved_org": rec["approved_organization"],
"source_schema": rec["source_schema"],
"source_key": rec["source_key"],
"cad_nums": rec["cad_nums"],
"geojson": rec["geojson"],
},
).scalar()
if result is None:
# WHERE-гейт не пропустил апдейт (у нас более старый date_reg) → без изменений.
return "skipped_unchanged"
return "inserted" if result else "updated"
# ── Оркестрация ───────────────────────────────────────────────────────────────
def load_gisogd_permits(db: Session | None = None) -> dict[str, int]:
"""Инкрементально загрузить РНС/РВЭ ГИСОГД-СО (обе схемы, обе группы) в gisogd_permits.
Для каждой (schema, group): список сверка с БД по (source_key, date_reg)
карточки только для новых/изменённых UPSERT по (doc_group, doc_num) с
предпочтением позднего date_reg. ``db`` для совместимости сигнатуры; None своя
SessionLocal.
Returns: счётчики {listed, fetched_details, inserted, updated, skipped_unchanged,
no_cad, no_geom, card_failed, group_aborted}.
"""
owns_session = db is None
if db is None:
db = SessionLocal()
counts = {
"listed": 0,
"fetched_details": 0,
"inserted": 0,
"updated": 0,
"skipped_unchanged": 0,
"no_cad": 0,
"no_geom": 0,
"card_failed": 0, # каждая упавшая карточка (5xx / сеть / не-dict)
"group_aborted": 0, # групп прервано circuit breaker'ом (битый сегмент портала)
}
client = _make_client()
try:
for schema in SCHEMAS:
existing = _existing_reg_dates(db, schema)
for group_key, doc_group in GROUP_CODE.items():
try:
items = fetch_group_list(client, schema, group_key)
except Exception as exc:
logger.exception("gisogd66: список %s/%s упал: %s", schema, group_key, exc)
continue
counts["listed"] += len(items)
_sync_group(db, client, schema, doc_group, items, existing, counts)
# Commit после КАЖДОЙ (schema, group), а не один раз в конце: партиал
# должен пережить soft_time_limit-kill (иначе теряем весь прогон).
db.commit()
except Exception as exc:
db.rollback()
logger.exception("gisogd66: outer tx rolled back: %s", exc)
raise
finally:
client.close()
if owns_session:
db.close()
logger.info("load_gisogd_permits done: %s", counts)
return counts
def _sync_group(
db: Session,
client: httpx.Client,
schema: str,
doc_group: str,
items: list[dict[str, Any]],
existing: dict[str, date | None],
counts: dict[str, int],
) -> None:
"""Обработать одну (schema, group): инкрементальная сверка + карточки + UPSERT.
Периодический commit каждые ``_COMMIT_EVERY`` обработанных записей (партиал переживёт
time-limit kill). Circuit breaker: ``_CARD_FAIL_BREAKER`` карточек ПОДРЯД с ошибкой
fetch прерываем группу (битый сегмент портала остаток догоним будущими ранами).
Любая успешная карточка сбрасывает счётчик подряд-фейлов.
"""
processed = 0 # успешно обработанных (upsert/skip) с последнего commit
consecutive_fail = 0 # карточек подряд с ошибкой fetch (для circuit breaker)
for item in items:
key = item.get("key")
if key is None:
continue
source_key = str(key)
list_reg = _parse_date(item.get("dateReg"))
# Инкрементальность: пропускаем карточку, если key уже есть И date_reg совпал.
if source_key in existing and existing[source_key] == list_reg:
counts["skipped_unchanged"] += 1
consecutive_fail = 0 # skip — не фейл карточки, сбрасываем breaker
processed += 1
if processed % _COMMIT_EVERY == 0:
db.commit()
continue
card: dict[str, Any] | None
try:
card = fetch_doc_card(client, schema, source_key)
except Exception as exc:
logger.warning("gisogd66: карточка %s/%s упала: %s", schema, source_key, exc)
card = None
finally:
time.sleep(_CARD_SLEEP_S)
if card is None:
# Неуспех fetch (5xx / сеть / не-dict): считаем и двигаем circuit breaker.
counts["card_failed"] += 1
consecutive_fail += 1
if consecutive_fail >= _CARD_FAIL_BREAKER:
logger.error(
"gisogd66: %s/%s: %d карточек подряд 5xx — прерываю группу "
"(битый сегмент портала)",
schema,
doc_group,
_CARD_FAIL_BREAKER,
)
counts["group_aborted"] += 1
return
continue
consecutive_fail = 0 # успешная карточка сбрасывает breaker
counts["fetched_details"] += 1
rec = _build_record(item, card, schema, doc_group, source_key, list_reg)
if rec is None:
continue
if not rec["cad_nums"]:
counts["no_cad"] += 1
if rec["geojson"] is None:
counts["no_geom"] += 1
try:
with db.begin_nested(): # SAVEPOINT — откат только этой строки
outcome = _upsert_permit(db, rec)
except Exception as exc:
logger.warning("gisogd66: upsert %s %r упал: %s", doc_group, rec["doc_num"], exc)
continue
counts[outcome] += 1
processed += 1
if processed % _COMMIT_EVERY == 0:
db.commit()
def _build_record(
item: dict[str, Any],
card: dict[str, Any],
schema: str,
doc_group: str,
source_key: str,
list_reg: date | None,
) -> dict[str, Any] | None:
"""Список-элемент + карточка → нормализованная запись для UPSERT. None если нет doc_num.
doc_num берём из карточки, fallback на список; strip обязателен (в списке ~25%
номеров с ведущими/хвостовыми пробелами). Даты предпочитаем из карточки, fallback
на список. cad_nums из spatialUnit карточки, geojson из infosetGeoJson.
"""
raw_num = card.get("docNum") or item.get("docNum")
doc_num = str(raw_num).strip() if raw_num else ""
if not doc_num:
logger.warning("gisogd66: карточка %s/%s без docNum — пропуск", schema, source_key)
return None
return {
"doc_group": doc_group,
"doc_num": doc_num,
"doc_name": card.get("name") or item.get("name"),
"date_doc": _parse_date(card.get("dateDoc")) or _parse_date(item.get("dateDoc")),
"date_reg": _parse_date(card.get("dateReg")) or list_reg,
"approved_organization": card.get("approvedOrganization"),
"source_schema": schema,
"source_key": source_key,
"cad_nums": parse_cad_nums(card.get("spatialUnit")),
"geojson": extract_geojson(card),
}
__all__ = [
"GROUP_CODE",
"SCHEMAS",
"extract_geojson",
"fetch_doc_card",
"fetch_group_list",
"load_gisogd_permits",
"parse_cad_nums",
]

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -9,18 +9,8 @@
- 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).
@ -29,35 +19,13 @@ 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 = "Резерв — по запросу в АО «ЕЭСК» (город)"
@ -77,8 +45,7 @@ def get_connection_capacity(
Returns:
{"power_points": [...], "power_summary": {...}, "water": [...],
"gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None,
"nearby_network_zones": [...]}
"gas": {...}|None, "heat": {...}|None}
Raises:
ValueError: участок не найден в БД (нет geom).
@ -96,18 +63,14 @@ def get_connection_capacity(
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,
}
@ -248,47 +211,33 @@ def _query_gas_city_grs(db: Session) -> dict:
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) млн. м³/МЕС. НЕ путать при агрегации.
Returns: {"city_grs": [...], "total_free_th_m3_h": float|None}.
"""
outlet_counts = _query_gas_outlet_counts(db)
try:
# SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)).
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, outer tx остаётся usable.
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT grs_name,
SUM(design_capacity_th_m3_h) AS design_sum,
SUM(free_capacity_th_m3_h) AS free_sum,
AVG(free_capacity_pct) AS pct_avg,
MAX(upgrade_due) AS upgrade_due,
COUNT(*) AS outputs_count
FROM gas_grs_capacity
WHERE grs_name ILIKE '%свердловск%'
OR grs_name ILIKE '%екатеринбург%'
GROUP BY grs_name
ORDER BY grs_name
""")
)
.mappings()
.all()
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}
return {"city_grs": [], "total_free_th_m3_h": None}
city_grs: list[dict] = []
total_free = 0.0
@ -313,139 +262,9 @@ def _query_gas_city_grs(db: Session) -> dict:
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:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
row = (
db.execute(
text("""
SELECT COUNT(*) AS outlets_total,
COUNT(*) FILTER (
WHERE free_capacity_mln_m3 < 0
) AS outlets_deficit,
COUNT(*) FILTER (
WHERE free_capacity_needs_calc
) AS outlets_needs_calc
FROM gas_grs_outlet_points
WHERE period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
""")
)
.mappings()
.first()
)
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:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
free_capacity_needs_calc AS needs_calc,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM gas_grs_outlet_points
WHERE geom IS NOT NULL
AND period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT CAST(:limit AS int)
"""),
{
"wkt": parcel_wkt,
"radius_m": _GAS_OUTLET_RADIUS_M,
"limit": _GAS_OUTLET_LIMIT,
},
)
.mappings()
.all()
)
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:
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.
@ -459,25 +278,23 @@ def _query_heat_latest(db: Session) -> dict:
"total_reserve_gcal_h": float|None}.
"""
try:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
FROM heat_system_reserves h
WHERE h.period IS NOT NULL
AND h.period = (
SELECT MAX(h2.period) FROM heat_system_reserves h2
WHERE h2.period IS NOT NULL
AND h2.org = h.org
)
ORDER BY h.org, h.system_name
""")
)
.mappings()
.all()
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).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
@ -506,129 +323,6 @@ def _query_heat_latest(db: Session) -> dict:
}
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

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

View file

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

View file

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

View file

@ -69,21 +69,9 @@ _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.

View file

@ -1,804 +0,0 @@
"""Загрузчик адресных точек выхода газораспределительной сети (#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

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

View file

@ -94,10 +94,7 @@ _SVERDLOVSK_TOKEN = "свердлов"
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-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)
_OUTPUT_SUFFIX_RE = re.compile(r"\((выход\s*№?\s*\d+)\)", re.IGNORECASE)
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
@ -171,16 +168,12 @@ def _split_grs_name(raw_name: str) -> tuple[str, str | 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)}"
output = _MULTISPACE_RE.sub(" ", m.group(1)).strip()
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
# двойные пробелы, оставшиеся на месте выреза в середине).
grs_name = raw_name[: m.start()] + raw_name[m.end() :]

View file

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

View file

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

View file

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

View file

@ -613,13 +613,7 @@ def compute_market_metrics(
def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
"""Текущий сток. На ошибке/пустых данных → все счётчики 0 (graceful)."""
try:
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
# / analyze). Голый db.rollback() orphan'ит outer SessionTransaction (см.
# velocity.py:170) — begin_nested откатывает только эту savepoint, outer tx
# остаётся usable для последующих db.execute (_query_sales_window/_price_sensitivity)
# на этой же сессии.
with db.begin_nested():
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
except Exception:
logger.exception(
"market_metrics: stock query failed (districts=%s)", params.get("districts")
@ -656,12 +650,7 @@ def _query_sales_window(
затирать прежние счётчики. На ошибке/пусто (0, 0.0, {}).
"""
try:
# SAVEPOINT — db shared с caller (см. _query_stock выше). CRITICAL: без него
# сбой здесь оставляет транзакцию aborted, и следующий db.execute в этой же
# цепочке (_price_sensitivity → _elasticity_coef) падает "current transaction
# is aborted" вместо честного graceful-fallback.
with db.begin_nested():
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
except Exception:
logger.exception(
"market_metrics: sales-window query failed (districts=%s)", params.get("districts")
@ -711,19 +700,14 @@ def _price_sensitivity(
micros = resolve_objective_districts(db, district)
elasticity_window = max(window_months, 24)
try:
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
# / analyze). _elasticity_coef делает свой db.execute внутри без try/except —
# оборачиваем ВЕСЬ вызов, чтобы сбой откатывал только эту savepoint (не outer
# SessionTransaction, см. velocity.py:170), и db оставалась usable дальше.
with db.begin_nested():
elast = _elasticity_coef(
db,
region_code=_EKB_REGION_CODE,
district_name=district,
target_class=None,
elasticity_window_months=elasticity_window,
districts=micros if micros is not None else [],
)
elast = _elasticity_coef(
db,
region_code=_EKB_REGION_CODE,
district_name=district,
target_class=None,
elasticity_window_months=elasticity_window,
districts=micros if micros is not None else [],
)
except Exception:
logger.exception(
"market_metrics: elasticity reuse failed (district=%s micros=%s)",
@ -826,9 +810,6 @@ _OFFER_TREND_SQL = text(
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k500k):
# предложение включает премиум/апартаменты; отсекаем только явный ETL-мусор.
# ⚠ Значения зеркалированы ЛИТЕРАЛАМИ в partial-предикате покрывающего индекса
# data/sql/186_objective_lots_history_offer_trend_covering_idx.sql — при изменении
# констант индекс надо пересоздать, иначе offer-trend молча теряет Index-Only Scan.
_OFFER_PRICE_MIN: int = 10000
_OFFER_PRICE_MAX: int = 1000000
@ -893,22 +874,13 @@ def compute_offer_price_trend(
"price_max": _OFFER_PRICE_MAX,
}
try:
# SAVEPOINT — db shared с caller (POST .../analyze / §22-отчёт, Depends(get_db)).
# 8с-гвард (parcels.py) может отменить этот запрос (QueryCanceled) → без savepoint
# транзакция остаётся aborted и следующий db.execute падает "current transaction
# is aborted". begin_nested откатывает только этот savepoint, db остаётся usable.
with db.begin_nested():
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
except Exception as exc:
# Ожидаемый путь: 8с-гвард (parcels.py) отменяет запрос на холодном кэше
# (~2/сутки, профилирование 2026-07-04) — блок аддитивный, analyze успешен.
# WARNING, не exception: ERROR-уровень поднимал ложные GlitchTip-события.
logger.warning(
"market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s): %s",
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,
exc,
)
rows = []

View file

@ -1,168 +0,0 @@
"""Гуманизация «сетевых охранных зон» ЗОУИТ для §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

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

View file

@ -1,319 +0,0 @@
"""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())

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