Compare commits
No commits in common. "main" and "fix/tradein-no-numberless-house-insert" have entirely different histories.
main
...
fix/tradei
660 changed files with 50576 additions and 68004 deletions
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- ✅ Что сделано хорошо
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` (новый анкор).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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'))) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={() => {}} />
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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), тепловая
|
||||
* окраска столбцов green→red повторяет цвет маркеров карты. Фильтры: город,
|
||||
* цена, год постройки, тип дома, мин. объявлений, сортировка. */
|
||||
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={() => {}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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
|
||||
|
|
@ -103,10 +103,6 @@ jobs:
|
|||
filters: |
|
||||
backend:
|
||||
- 'tradein-mvp/backend/**'
|
||||
# scraper-kit вкомпилирован в backend-образ (build context tradein-mvp/,
|
||||
# scheduler_main импортирует пакет) — kit-only изменение обязано
|
||||
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
browser:
|
||||
|
|
@ -121,19 +117,11 @@ jobs:
|
|||
- 'tradein-mvp/backend/app/services/scheduler.py'
|
||||
- 'tradein-mvp/backend/app/scheduler_main.py'
|
||||
- 'tradein-mvp/backend/app/tasks/**'
|
||||
# #2188: scheduler исполняет matching/dedup при каждом scrape-тике —
|
||||
# без этих путей scraper-контейнер оставался на старом коде
|
||||
# (2026-07-02: fias-dedup доехал до tradein-backend, но не до
|
||||
# tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть
|
||||
# scheduler — его правки тоже обязаны пересоздавать контейнер.
|
||||
- 'tradein-mvp/backend/app/services/matching/**'
|
||||
- 'tradein-mvp/backend/app/services/house_dedup_merge.py'
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
|
||||
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
|
||||
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
|
||||
# Tests use mocks + a stub DATABASE_URL — no real Postgres/Redis needed.
|
||||
# 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 +146,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 +219,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 +323,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 +414,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 +432,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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
6
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
24
Caddyfile
24
Caddyfile
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (не в репе).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
@ -77,28 +72,16 @@ from app.services.site_finder.custom_pois import (
|
|||
from app.services.site_finder.developer_attribution import get_developer_attribution
|
||||
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
||||
from app.services.site_finder.ird_analyze import build_ird_analyze_block
|
||||
from app.services.site_finder.market_metrics import compute_offer_price_trend
|
||||
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 +205,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-3→I, 4-6→II, 7-9→III, 10-12→IV (римские).
|
||||
"""
|
||||
return f"{_ROMAN_QUARTERS[(d.month - 1) // 3]} квартал {d.year}"
|
||||
|
||||
|
||||
# Человеко-читаемые имена категорий для verbal breakdown (X1).
|
||||
_POI_CATEGORY_RU: dict[str, str] = {
|
||||
"school": "Школа",
|
||||
|
|
@ -857,20 +827,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 +854,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 +871,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 +885,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 +929,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 +1127,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 +1532,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 +2011,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_* выше.
|
||||
|
|
@ -3156,46 +2786,6 @@ def analyze_parcel(
|
|||
# Порог устаревания источника: latest deal старше ~3 мес (90 дней) от сегодня.
|
||||
stale_cutoff = _dt.date.today() - _dt.timedelta(days=90)
|
||||
|
||||
# #2178: ВТОРОЙ источник — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history
|
||||
# (ЖК в том же радиусе 3 км). Живёт, когда rosreestr встал (сделки stale).
|
||||
# Считаем всегда; ниже решаем, основной он или дополнительный к сделкам.
|
||||
# Своя savepoint — offer-запрос не должен ронять уже посчитанный сделки-тренд.
|
||||
offer_block: dict[str, Any] | None = None
|
||||
try:
|
||||
with db.begin_nested():
|
||||
# Предохранитель: на плотном центре ЕКБ холодный прогон доходит
|
||||
# до ~5.5 с (EXPLAIN 2026-07-02) — эндпоинт синхронный, дольше
|
||||
# 8 с ждать нельзя. Таймаут → QueryCanceled → откат savepoint →
|
||||
# offer_block=None (прежнее поведение). После запроса возвращаем
|
||||
# серверный дефолт (0, проверено на проде), т.к. RELEASE SAVEPOINT
|
||||
# сохранил бы SET LOCAL до конца внешней транзакции.
|
||||
db.execute(text("SET LOCAL statement_timeout = '8000'"))
|
||||
offer_trend = compute_offer_price_trend(
|
||||
db, center_lat=centroid_lat, center_lon=centroid_lon
|
||||
)
|
||||
db.execute(text("SET LOCAL statement_timeout = 0"))
|
||||
if offer_trend.has_data:
|
||||
_offer_delta = offer_trend.delta_pct
|
||||
if _offer_delta is None:
|
||||
_offer_label = "Динамика цен предложения (Объектив)"
|
||||
elif _offer_delta > 8:
|
||||
_offer_label = "Цены предложения заметно растут"
|
||||
elif _offer_delta > 0:
|
||||
_offer_label = "Цены предложения умеренно растут"
|
||||
elif _offer_delta > -5:
|
||||
_offer_label = "Цены предложения стабильны"
|
||||
else:
|
||||
_offer_label = "Цены предложения снижаются"
|
||||
offer_block = {
|
||||
"source": "Объектив, цены предложения",
|
||||
"label": _offer_label,
|
||||
"radius_km": 3,
|
||||
**offer_trend.as_dict(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("offer_price_trend query failed for %s: %s", cad_num, e)
|
||||
offer_block = None
|
||||
|
||||
if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
|
||||
# Happy path — есть данные в обоих окнах.
|
||||
recent_p = float(trend_row["recent_avg"])
|
||||
|
|
@ -3220,51 +2810,12 @@ def analyze_parcel(
|
|||
"label": perspective_label,
|
||||
"radius_km": 3,
|
||||
}
|
||||
# #2178: Росреестр свежий → показываем ОБА блока (сделки + предложение).
|
||||
if offer_block is not None:
|
||||
market_trend["offer_trend"] = offer_block
|
||||
elif offer_block is not None:
|
||||
# #2178: сделки устарели/отсутствуют, НО есть живая динамика предложения —
|
||||
# она становится ОСНОВНЫМ содержимым карточки (status='offer_only').
|
||||
# as_of_date отдаём как дату последнего снапшота предложения, чтобы фронт
|
||||
# честно показал «по состоянию на …». Сохраняем максимум контекста сделок.
|
||||
_offer_as_of = offer_block["points"][-1]["date"] if offer_block["points"] else None
|
||||
_deals_stale = (
|
||||
recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
|
||||
)
|
||||
# Честная микрокопия. Росреестр отдаёт сделки ПОКВАРТАЛЬНО (max(deal_date)
|
||||
# = начало квартала поставки) — это НЕ «источник устарел». Для stale-ветки
|
||||
# называем квартал поставки; иначе просто нет сделок рядом. Обе показывают
|
||||
# предложение как основной контент.
|
||||
_offer_only_label = (
|
||||
(
|
||||
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
|
||||
"(поставка поквартальная); показываем динамику цен предложения"
|
||||
)
|
||||
if _deals_stale and max_period is not None
|
||||
else "Нет сделок рядом — показываем динамику цен предложения"
|
||||
)
|
||||
market_trend = {
|
||||
"status": "offer_only",
|
||||
"label": _offer_only_label,
|
||||
"deals_stale": _deals_stale,
|
||||
"recent_deals_count": recent_n_val,
|
||||
"deals_as_of_date": (max_period.isoformat() if max_period is not None else None),
|
||||
"as_of_date": _offer_as_of,
|
||||
"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 мес → источник устарел (не «нет рядом»).
|
||||
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 +3180,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 +3337,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 +3461,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 +3588,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 +3620,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 +3859,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: радиус поиска ЦП, 100–10000 м (default 3000).
|
||||
|
|
@ -4343,160 +3875,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: радиус поиска, 50–1000 м (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"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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. Шире дефолта
|
||||
# (600–1500), чтобы размазать запросы во времени и не триггерить rate-ban.
|
||||
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
|
||||
# ширим до 1200–3000. 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()
|
||||
|
|
|
|||
|
|
@ -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) ──────────────────────────────────────
|
||||
|
|
@ -888,9 +753,6 @@ class AnalyzeResponse(BaseModel):
|
|||
market_data_coverage_pct: float | None = None
|
||||
pipeline_24mo: dict[str, Any] | None = None
|
||||
velocity: dict[str, Any] | None = None
|
||||
# market_trend: {status: ok|offer_only|source_stale|no_deals, ...}. #2178 добавил
|
||||
# вложенный offer_trend (динамика цен предложения из objective_lots_history) и
|
||||
# status='offer_only'. Схема loose (dict) — новые ключи проходят без правки модели.
|
||||
market_trend: dict[str, Any] | None = None
|
||||
market_price: dict[str, Any] | None = None
|
||||
|
||||
|
|
@ -913,7 +775,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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-таски форсайта,
|
||||
~20–30с) и перечитываем; всё ещё нет (сбой/тонкие данные) → 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 (~20–30с) и персистим тем же
|
||||
контрактом, чтобы последующий `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'е) и перечитываем — самодостаточность
|
||||
# отчёта важнее +20–30с (как §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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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: частичный/
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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+ м²) и долю в % если она есть. "
|
||||
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
||||
|
|
|
|||
|
|
@ -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 200–500 мс), отдельная
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 200–500 мс встроен в 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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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). Приведение Polygon→MultiPolygon
|
||||
делается в 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",
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -329,18 +317,10 @@ def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]
|
|||
return [], all_cells
|
||||
|
||||
systems: list[dict] = []
|
||||
name_counts: dict[str, int] = {}
|
||||
for j, name in _system_columns(header_cells):
|
||||
reserve = parse_reserve_number(reserve_cells[j] if j < len(reserve_cells) else None)
|
||||
if reserve is None:
|
||||
continue # непарсибельная ячейка резерва (текст/пусто) → систему скипаем
|
||||
# Разные системы могут носить БАЙТ-В-БАЙТ одинаковое имя в соседних колонках
|
||||
# (живой пример ЕЭ: две «Локальные зоны … МУП "Екатеринбургэнерго"» с 62.198
|
||||
# и 0). UPSERT по (org, sphere, system_name, period) схлопнул бы их с потерей
|
||||
# данных — вторым и далее вхождениям добавляем порядковый суффикс « (N)».
|
||||
name_counts[name] = name_counts.get(name, 0) + 1
|
||||
if name_counts[name] > 1:
|
||||
name = f"{name} ({name_counts[name]})"
|
||||
systems.append({"system_name": name, "reserve_gcal_h": reserve})
|
||||
|
||||
return systems, all_cells
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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() :]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
@ -732,230 +716,3 @@ def _price_sensitivity(
|
|||
)
|
||||
return None, None
|
||||
return float(elast["elasticity"]), str(elast["source"])
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Offer-price trend (#2178) — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history.
|
||||
#
|
||||
# Мотивация: §4.3 «Тренд рынка» строится на rosreestr_deals, чей поток встал
|
||||
# (max дата 2026-01-01) → recent-окно пусто → status='source_stale' и пустая
|
||||
# карточка. objective_lots_history даёт weekly-снапшоты цен предложения активных
|
||||
# лотов — это ВТОРОЙ источник, живой (снимки до 2026-06-30), которым мы заполняем
|
||||
# карточку, когда сделки устарели.
|
||||
#
|
||||
# СВЯЗКА С ЖК РАДИУСА — тот же путь, что best_layouts/competitors (маппинг, НЕ
|
||||
# ненадёжный complex_id-FK, который джойнит лишь ~28% и мультигородской, см.
|
||||
# 131_district_vocab_map.sql):
|
||||
# radius (ST_DWithin по domrf_kn_objects)
|
||||
# → domrf_obj_id
|
||||
# → objective_complex_mapping.domrf_obj_id → objective_complex_name
|
||||
# → objective_lots.project_name → objective_lot_id-набор
|
||||
# → objective_lots_history (per-snapshot цены этих лотов).
|
||||
# objective_lots_history НЕ содержит project_name — поэтому набор objective_lot_id
|
||||
# фиксируем из objective_lots (текущее состояние), а историю берём по нему.
|
||||
#
|
||||
# ДЕДУП задвоенного снапшота (КРИТИЧНО, #2178): 2026-06-22 был просвипан дважды
|
||||
# (~621k строк = два свипа в один день). На (objective_lot_id, snapshot_date) в
|
||||
# истории стоит UNIQUE-констрейнт → физически дубля быть не должно, НО на проде
|
||||
# он есть (два ETL-пути до починки триггера). Безусловно дедупим до одной строки
|
||||
# на (objective_lot_id, snapshot_date): DISTINCT ON (...) ORDER BY fetched_at DESC
|
||||
# — берём последний свип дня. Медиану считаем УЖЕ по дедуплицированному набору,
|
||||
# иначе задвоенный день весит ×2 и смещает медиану.
|
||||
#
|
||||
# ФИЛЬТР «в продаже» (честный, по реальным status в objective_lots_history —
|
||||
# "резерв"|"свободна"|"продан"|"бронь", см. 68_schema_objective.sql): лот активен
|
||||
# = is_sold IS NOT TRUE И LOWER(status) не равен 'продан'. Бронь/резерв считаем
|
||||
# «в продаже» (цена ещё котируется рынку). contract_date-признак здесь не
|
||||
# используем: в истории он часто NULL у активных лотов, а нам важна котировка.
|
||||
#
|
||||
# ФИЛЬТР ВМЕНЯЕМОСТИ ЦЕНЫ: price_per_m2_rub BETWEEN 10000 AND 1000000 — отсекает
|
||||
# ETL-мусор (0 / гросс-суммы, ошибочно попавшие в per-m2). Границы шире, чем у
|
||||
# сделок-тренда (30k–500k), т.к. предложение включает премиум/апартаменты.
|
||||
_OFFER_TREND_SQL = text(
|
||||
"""
|
||||
WITH radius_obj AS (
|
||||
SELECT DISTINCT ON (o.obj_id) o.obj_id
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:center_lon AS float), CAST(:center_lat AS float)
|
||||
),
|
||||
4326
|
||||
)::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
|
||||
),
|
||||
radius_projects AS (
|
||||
SELECT DISTINCT cm.objective_complex_name AS project_name
|
||||
FROM objective_complex_mapping cm
|
||||
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM radius_obj)
|
||||
),
|
||||
radius_lots AS (
|
||||
SELECT DISTINCT ol.objective_lot_id
|
||||
FROM objective_lots ol
|
||||
JOIN radius_projects rp ON rp.project_name = ol.project_name
|
||||
WHERE ol.premise_kind = CAST(:premise_kind AS text)
|
||||
),
|
||||
hist AS (
|
||||
-- Дедуп задвоенного снапшота: одна строка на (лот, день), последний свип.
|
||||
SELECT DISTINCT ON (h.objective_lot_id, h.snapshot_date)
|
||||
h.snapshot_date,
|
||||
h.price_per_m2_rub
|
||||
FROM objective_lots_history h
|
||||
JOIN radius_lots rl ON rl.objective_lot_id = h.objective_lot_id
|
||||
WHERE h.price_per_m2_rub BETWEEN CAST(:price_min AS numeric)
|
||||
AND CAST(:price_max AS numeric)
|
||||
AND (h.is_sold IS NOT TRUE)
|
||||
AND LOWER(COALESCE(h.status, '')) <> 'продан'
|
||||
ORDER BY h.objective_lot_id, h.snapshot_date, h.fetched_at DESC
|
||||
)
|
||||
SELECT
|
||||
snapshot_date AS snapshot_date,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_price_m2,
|
||||
COUNT(*) AS lots_count,
|
||||
(SELECT COUNT(*) FROM radius_projects) AS complexes_covered
|
||||
FROM hist
|
||||
GROUP BY snapshot_date
|
||||
ORDER BY snapshot_date
|
||||
"""
|
||||
)
|
||||
|
||||
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k–500k):
|
||||
# предложение включает премиум/апартаменты; отсекаем только явный 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
|
||||
|
||||
# Радиус ЖК-подбора — зеркало market_trend-хендлера parcels.py (3 км от центроида).
|
||||
_OFFER_TREND_RADIUS_M: int = 3000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OfferPriceTrend:
|
||||
"""Динамика медианной цены предложения (руб/м²) по снапшотам Объектива (#2178).
|
||||
|
||||
Источник — objective_lots_history (weekly-снапшоты активных лотов ЖК в радиусе).
|
||||
Всегда конструируется через ``from_points``; пустой набор точек → None-поля,
|
||||
``points=[]`` (никогда не бросает на отсутствии данных).
|
||||
"""
|
||||
|
||||
points: list[dict[str, Any]] # [{date: 'YYYY-MM-DD', median_price_m2, lots_count}]
|
||||
delta_pct: float | None # (последняя медиана − первая) / первая × 100
|
||||
lots_latest: int | None # lots_count последнего снапшота
|
||||
complexes_covered: int # сколько ЖК (project_name) попало в радиус-выборку
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"points": self.points,
|
||||
"delta_pct": _round_or_none(self.delta_pct, 1),
|
||||
"lots_latest": self.lots_latest,
|
||||
"complexes_covered": self.complexes_covered,
|
||||
}
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""True если есть ≥1 снапшот с медианой — тогда карточку можно показать."""
|
||||
return len(self.points) > 0
|
||||
|
||||
|
||||
def compute_offer_price_trend(
|
||||
db: Session,
|
||||
*,
|
||||
center_lat: float,
|
||||
center_lon: float,
|
||||
radius_m: int = _OFFER_TREND_RADIUS_M,
|
||||
premise_kind: str = "квартира",
|
||||
) -> OfferPriceTrend:
|
||||
"""Медианная цена предложения по снапшотам Объектива для ЖК в радиусе (#2178).
|
||||
|
||||
ЖК определяются тем же путём, что market_trend-хендлер / best_layouts: ЖК в
|
||||
радиусе ``radius_m`` от (``center_lat``, ``center_lon``) по domrf_kn_objects →
|
||||
objective_complex_mapping → objective_lots. По их лотам считается per-snapshot
|
||||
медиана price_per_m2_rub активных лотов (дедуп задвоенного дня, фильтр
|
||||
вменяемости цены).
|
||||
|
||||
Возвращает OfferPriceTrend ВСЕГДА (даже на пустых данных: points=[], поля None,
|
||||
complexes_covered=0). Никогда не бросает на отсутствии данных — на ошибке SQL
|
||||
логирует и возвращает пустой результат (graceful, зеркало _query_stock).
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"center_lat": float(center_lat),
|
||||
"center_lon": float(center_lon),
|
||||
"radius_m": float(radius_m),
|
||||
"premise_kind": premise_kind,
|
||||
"price_min": _OFFER_PRICE_MIN,
|
||||
"price_max": _OFFER_PRICE_MAX,
|
||||
}
|
||||
try:
|
||||
# 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",
|
||||
center_lat,
|
||||
center_lon,
|
||||
radius_m,
|
||||
exc,
|
||||
)
|
||||
rows = []
|
||||
|
||||
points: list[dict[str, Any]] = []
|
||||
complexes_covered = 0
|
||||
for r in rows:
|
||||
# complexes_covered — константа по всем строкам (scalar-subquery), берём
|
||||
# из первой встреченной строки. При пустом hist rows=[] → остаётся 0.
|
||||
complexes_covered = int(r["complexes_covered"] or 0)
|
||||
median = r["median_price_m2"]
|
||||
if median is None:
|
||||
continue
|
||||
snap = r["snapshot_date"]
|
||||
# snapshot_date — DATE; нормализуем (isoformat) вне зависимости от того,
|
||||
# date или datetime отдал драйвер.
|
||||
date_str = snap.isoformat() if hasattr(snap, "isoformat") else str(snap)
|
||||
points.append(
|
||||
{
|
||||
"date": date_str,
|
||||
"median_price_m2": round(float(median)),
|
||||
"lots_count": int(r["lots_count"] or 0),
|
||||
}
|
||||
)
|
||||
|
||||
delta_pct: float | None = None
|
||||
lots_latest: int | None = None
|
||||
if points:
|
||||
first_median = points[0]["median_price_m2"]
|
||||
last_median = points[-1]["median_price_m2"]
|
||||
lots_latest = points[-1]["lots_count"]
|
||||
if first_median and first_median > 0:
|
||||
delta_pct = (last_median - first_median) / first_median * 100.0
|
||||
|
||||
logger.info(
|
||||
"offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d "
|
||||
"lots_latest=%s delta_pct=%s",
|
||||
center_lat,
|
||||
center_lon,
|
||||
radius_m,
|
||||
len(points),
|
||||
lots_latest,
|
||||
_round_or_none(delta_pct, 1),
|
||||
)
|
||||
|
||||
return OfferPriceTrend(
|
||||
points=points,
|
||||
delta_pct=delta_pct,
|
||||
lots_latest=lots_latest,
|
||||
complexes_covered=complexes_covered,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue