Compare commits
326 commits
fix/tradei
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 698576e77b | |||
| 67b50bf713 | |||
| c61ed5a96e | |||
| e61c2debe5 | |||
| c904fbf94e | |||
| 9fd6396fbc | |||
| 8b7af92ee3 | |||
| 2c5b30f8c9 | |||
| d530602fbd | |||
| 32037af6de | |||
| 5647c2b40e | |||
| 749c96cd0c | |||
| 987dce11bc | |||
| b5477362f0 | |||
| 60c21ae2a5 | |||
| 3bd0873230 | |||
| 5e73b35206 | |||
| 69ca746673 | |||
| 2cd24ecfbc | |||
| 92371e7d9e | |||
| 7fa52bb3ad | |||
| 348be445e3 | |||
|
|
8fdcd8df62 | ||
| c98a879a79 | |||
|
|
2ef3e42da1 | ||
|
|
720cee2c8e | ||
| 73f932d60a | |||
| e7c388dde7 | |||
|
|
07275c3c97 | ||
|
|
d92ff5c3d3 | ||
|
|
d31b1e01ee | ||
| 51473fc841 | |||
| c3d0f86f37 | |||
|
|
219cdfdbcc | ||
|
|
bf4ce1ba36 | ||
| 519b482cdd | |||
| e4bb2fc2e3 | |||
|
|
85321d1d1a | ||
| b1ac551451 | |||
| 032fd95a7e | |||
|
|
0583fb0efd | ||
| a3dfa2a99f | |||
|
|
20bdc7dec8 | ||
|
|
6487813ec5 | ||
|
|
1c9f0e1161 | ||
| 0f4ed2f6e9 | |||
|
|
27d1b09c72 | ||
|
|
c1a01fdbf9 | ||
| fb123bd97e | |||
|
|
f9ae6f0c53 | ||
| 3293df8e14 | |||
| 4c56c5189a | |||
|
|
25a865f27b | ||
|
|
69f8b50934 | ||
|
|
7801ac6cc1 | ||
| 5d7c3e1c30 | |||
| 6936558f6f | |||
|
|
f631af4de3 | ||
|
|
4a579e74e0 | ||
|
|
b93d26990b | ||
| 90a6e4bfe9 | |||
|
|
cf91ac6b24 | ||
| 74017b39c4 | |||
|
|
e250869c9d | ||
| b0b2c94ece | |||
|
|
e2502a0134 | ||
| 51952ebd70 | |||
|
|
c0cbdc2f99 | ||
| fcc37c46ab | |||
| 95f0b0aa64 | |||
|
|
752cb1044b | ||
| 338ccbb78f | |||
| a88e755403 | |||
| 4f205bd7da | |||
| 8dd35c01d8 | |||
| ed504cbbd9 | |||
| cc231946c2 | |||
| d22e0e00ae | |||
| 346767ba90 | |||
|
|
f4c26d474a | ||
| 3fe1216317 | |||
|
|
3c4a33ca32 | ||
| f987e819b2 | |||
| 9a6b56016b | |||
| d3f3370bed | |||
| 01360e9c3d | |||
| 22f3c44dc2 | |||
| 82fdabccba | |||
| 39dd63333e | |||
| 1fa99812af | |||
| 53b160ff0a | |||
| 464519c69b | |||
| 7315d04396 | |||
| d1fa450f48 | |||
| be4b2ee1fa | |||
| f3988add0a | |||
| c0c666cccc | |||
| de5da6ec35 | |||
| 1bbd4c99e2 | |||
| 82e0b8b0a3 | |||
| b40995780b | |||
| c8c96a87ee | |||
| 9b6aba42dc | |||
| 5b3799a23a | |||
| eea9c99e73 | |||
| 9c46689da1 | |||
| 994eba0a16 | |||
| 7c1d3693e2 | |||
| f93ef5374d | |||
| b2d42a631a | |||
| ced616d150 | |||
| acc1e05357 | |||
| 102bdbda47 | |||
| 752c3656a3 | |||
| cfed042712 | |||
| 5cbf3485a0 | |||
| b102c334d8 | |||
|
|
b238d3ef50 | ||
| 3a2b4ba825 | |||
| 27868a4d2e | |||
| 9c6afcc876 | |||
| 921aba04b8 | |||
| 0658399f01 | |||
|
|
27bbd579c3 | ||
| a0c609caf4 | |||
| 8030f8ecef | |||
| 00a4dca5c0 | |||
| f0a9763e48 | |||
| b28aefec16 | |||
| c8343c8f3d | |||
| 2a3a93b138 | |||
|
|
5015109f31 | ||
|
|
6ace4ea4ce | ||
|
|
b6b7af6b42 | ||
|
|
f0c7ac4972 | ||
|
|
36c451e155 | ||
| eafa305ac8 | |||
|
|
575c233e51 | ||
| 84716e61de | |||
|
|
79a77200d7 | ||
| 2e345acb82 | |||
| ab250a132b | |||
| 828d691c0d | |||
|
|
c30581ae8d | ||
|
|
85059aeb1b | ||
| b3a59b7cf1 | |||
| 5d51eae705 | |||
|
|
d0b011d388 | ||
|
|
42b1ca6d7f | ||
| 27ba08b821 | |||
|
|
8ca529ece0 | ||
| 155d5ec3a9 | |||
| 480d4c2128 | |||
| a7e1391814 | |||
| 98df57827e | |||
| dd222a4eb3 | |||
|
|
a9b87aa311 | ||
| a5097c3a97 | |||
| 2538fc02e2 | |||
| 293184ebc6 | |||
| 4f0cae5c18 | |||
| 9850bbde14 | |||
| 2580158d8c | |||
| 9b92b595db | |||
|
|
7800f6014a | ||
| 758a4b06d4 | |||
| 53947015e9 | |||
|
|
b445c34863 | ||
|
|
96dd27c4ae | ||
| 53e7834f19 | |||
|
|
debe55b8a0 | ||
| ae3278f92f | |||
| 282c92747e | |||
| ae814706a2 | |||
| 57d156973e | |||
| 38f703c8ec | |||
| d31fabd102 | |||
|
|
51e2e545b7 | ||
|
|
9b4cdf8ec6 | ||
| 91c8365d91 | |||
| 6468bd6b37 | |||
| 1e5fc13635 | |||
| 758d7c6bf8 | |||
|
|
ccf716119c | ||
|
|
b89e0c3daf | ||
| c4201f7d2a | |||
| c778e4c85d | |||
|
|
ecec9ff6d7 | ||
|
|
fea054d24f | ||
| 7ebeddc2bc | |||
| 36fe33750f | |||
|
|
4a6820b8f7 | ||
| 5fa505b809 | |||
| 9289c3d69d | |||
| f872f5636b | |||
| aa2b33f264 | |||
| 17cf7344b3 | |||
|
|
53287da886 | ||
|
|
7546868535 | ||
|
|
7049717782 | ||
| 1d9e25ce07 | |||
| e7f97f1355 | |||
| e3d26f0aae | |||
| 49304a78bc | |||
| f41ca530c5 | |||
| 30e033d653 | |||
| 9feadc8458 | |||
|
|
116cd10046 | ||
|
|
eb564cfc01 | ||
| 793234cff1 | |||
| 308eeac214 | |||
| 01f2c5290d | |||
| 7fd80adfa7 | |||
| 86af5b78bf | |||
| 7a113273f4 | |||
| 24b773efe3 | |||
| 279e7de778 | |||
|
|
88b93435fc | ||
|
|
ea24289f47 | ||
|
|
db3afa3176 | ||
| a7677b0fba | |||
| 27e21f37d3 | |||
|
|
8c8289cdad | ||
| bc9549782b | |||
| 5db6a2bec3 | |||
|
|
d95c9e6e55 | ||
|
|
57963e3a06 | ||
|
|
90731da537 | ||
| a8b504c3de | |||
| 4fedfc5970 | |||
| 0e2b4998c8 | |||
| c129515ab8 | |||
| 3945060f0c | |||
| 64d3a925ef | |||
| 71b125cc13 | |||
|
|
a7253639cd | ||
| 698c1be4f3 | |||
|
|
6f170504ba | ||
| b902edd44c | |||
|
|
dafd12d9fa | ||
|
|
404d88ee34 | ||
|
|
c672e0322d | ||
| e9e3cc7dcf | |||
| d15ff3d99d | |||
| b22edc6d6f | |||
|
|
25186a78ac | ||
| 666962185c | |||
| d6d4a1c94a | |||
| 2f127c4316 | |||
| 034d9f9bca | |||
| 0b3ec7f91b | |||
| e7881cf45c | |||
| c0c18f4ecc | |||
| 85435fbf1b | |||
| 8a42fba836 | |||
| 7c12812f81 | |||
| 99ee09e230 | |||
| dfc1ee8be0 | |||
| 039954580e | |||
|
|
6f81ee73d7 | ||
| 703f6f60c0 | |||
| 4a9ddb2a9a | |||
|
|
a3cab9023f | ||
|
|
9cdc7cef21 | ||
| fc5c4b1e40 | |||
|
|
9835876ced | ||
|
|
375d59c48d | ||
|
|
9a9f13d513 | ||
|
|
a08e263c60 | ||
|
|
ca0a51c876 | ||
| 68429967ab | |||
|
|
dd050af16d | ||
|
|
8a10d943d7 | ||
| f891f8f00f | |||
| bbe596747d | |||
|
|
dd8b5769e5 | ||
| c44bb49809 | |||
|
|
33e3503f97 | ||
| fc6542416d | |||
| e1c5f2184c | |||
|
|
c2096249d5 | ||
| 21608eced1 | |||
| 5f6db8dbb7 | |||
|
|
291717fa2f | ||
|
|
efa0bafa18 | ||
|
|
c65a73ca9a | ||
| 910bfed5f1 | |||
| e440209d91 | |||
|
|
edbdd28286 | ||
| 14d76fb136 | |||
| 29a3cf9483 | |||
|
|
bbd47ae536 | ||
| 6f5bdffbe4 | |||
| 15757685c9 | |||
| 55b13e2b13 | |||
| cc9516ab3d | |||
| c438c9a9e8 | |||
| af70d50f0d | |||
| 7c73bbfbeb | |||
| 830d9f429a | |||
| 031bafde86 | |||
| 5888b0b9d3 | |||
| 4b6d1cbc06 | |||
| 1bba3db7eb | |||
| 9cfefc0ca4 | |||
| fc55c2c0b7 | |||
| 4c23c222f6 | |||
| e9c8d05e1d | |||
| 8ea52b9b7a | |||
|
|
9de867631b | ||
| 9f28092356 | |||
| 5fb5675871 | |||
| 0793571c4a | |||
| 19ecd941f3 | |||
| ae483468c9 | |||
| ac19c4f7b3 | |||
| 4ce0f51bb8 | |||
| 47f45fc3d2 | |||
| 72d26b35fa | |||
| f5b0076e6f | |||
| e6000cb029 | |||
| b028377584 | |||
| a7ad1764c1 | |||
| ca473a0012 | |||
| d8ea4f2fd7 | |||
| e2bf285980 |
657 changed files with 65897 additions and 50461 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: opus
|
||||
model: sonnet
|
||||
tools: Task, Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_get_recent_changes, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-backend
|
|||
description: "[DRAFT — autonomous loop only] Backend engineer в режиме /loop dynamic. Polling Forgejo issues scope/backend, claim+work+push+PR. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-code-reviewer
|
|||
description: "[DRAFT — autonomous loop only] Code reviewer + merge authority в режиме /loop 2m. Читает PR diff, выносит verdict, мерджит APPROVE. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes
|
||||
---
|
||||
|
||||
|
|
@ -78,6 +78,7 @@ 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
|
||||
|
|
@ -108,6 +109,8 @@ 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: opus
|
||||
model: sonnet
|
||||
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: opus
|
||||
model: sonnet
|
||||
tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__playwright__browser_navigate, mcp__playwright__browser_click, mcp__playwright__browser_type, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_evaluate, mcp__playwright__browser_wait_for, mcp__playwright__browser_close
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-resolver
|
|||
description: "[DRAFT — autonomous loop only] Human-proxy resolver в режиме /loop 15m. Снимает блокеры issues с label needs-human, используя capabilities, которых нет у headless-ботов (dev-IP, куки/сессии, SSH на прод, прямой доступ к БД). НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window НА МАШИНЕ ПОЛЬЗОВАТЕЛЯ."
|
||||
status: draft
|
||||
created_at: 2026-05-30
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details, mcp__playwright__browser_navigate, mcp__playwright__browser_snapshot, mcp__playwright__browser_evaluate, mcp__playwright__browser_click, mcp__playwright__browser_type, mcp__playwright__browser_close
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: backend-engineer
|
||||
description: Backend Python engineer for GenDesign — FastAPI, SQLAlchemy 2.0, Celery, psycopg v3, scrapers, services. Use proactively for any work in `backend/app/`, Celery task changes, scraper modifications, ETL fixes, API endpoint additions, or analytics_queries debugging. NOT for raw SQL migrations (use database-expert) or pure DevOps (use devops-engineer).
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__obsidian__obsidian_patch_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details, mcp__postgres-tradein__explain_query
|
||||
model: opus
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: code-reviewer
|
||||
description: "Code reviewer для GenDesign — проверяет staged/recent changes на безопасность, корректность, производительность, conformance с project conventions. Read-only — НЕ пишет код, НЕ коммитит, НЕ пушит. Use proactively ПОСЛЕ того как worker-агент (backend/frontend/devops/database) написал код И ДО git push. Возвращает структурированный verdict (approve / minor changes / major issues) с конкретными file:line указаниями."
|
||||
tools: Read, Glob, Grep, Bash, WebFetch, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes
|
||||
model: opus
|
||||
model: sonnet
|
||||
memory: project
|
||||
color: red
|
||||
---
|
||||
|
|
@ -108,10 +108,10 @@ mcp__obsidian__obsidian_simple_search "<keyword>"
|
|||
- Time spent: ~3 min
|
||||
|
||||
### Critical issues (BLOCK push)
|
||||
- [ ] `file.py:42` — описание проблемы + fix suggestion
|
||||
- [ ] `file.py:42` — конкретный failure scenario (input/state → неверный output/crash) + fix suggestion. Без repro-сценария — не критикал, переквалифицируй в Minor или Positive observation.
|
||||
|
||||
### Minor issues (можно fix потом)
|
||||
- [ ] `file.py:84` — улучшение
|
||||
- [ ] `file.py:84` — улучшение (не rubber-stamp: если это не влияет на поведение — Positive observations или пропусти)
|
||||
|
||||
### 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: opus
|
||||
model: sonnet
|
||||
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: opus
|
||||
model: sonnet
|
||||
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: opus
|
||||
model: sonnet
|
||||
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: opus
|
||||
model: sonnet
|
||||
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: opus
|
||||
model: sonnet
|
||||
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` → `.github/workflows/deploy-obsidian.yml`
|
||||
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md` → `.forgejo/workflows/deploy-obsidian.yml`
|
||||
- `docs/**` alone → НЕ триггерит деплой
|
||||
|
||||
## После изменения .env на VPS
|
||||
|
|
|
|||
|
|
@ -32,3 +32,40 @@ 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,7 +24,21 @@
|
|||
"ScraperPage": null,
|
||||
"ScrapersUnifiedPage": null,
|
||||
"TradeInPage": null,
|
||||
"YandexScraperPage": 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" }
|
||||
},
|
||||
"provider": {
|
||||
"component": "PreviewProvider"
|
||||
|
|
@ -32,5 +46,8 @@
|
|||
"extraEntries": [
|
||||
"../../.design-sync/preview-provider.tsx"
|
||||
],
|
||||
"readmeHeader": ".design-sync/conventions.md"
|
||||
"readmeHeader": ".design-sync/conventions.md",
|
||||
"extraFonts": [
|
||||
"../../.design-sync/fonts/brand-fonts.css"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ 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)",
|
||||
|
|
|
|||
BIN
.design-sync/fonts/IBMPlexMono-300-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-300-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-300-latin.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-300-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-400-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-400-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-400-latin.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-400-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-500-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-500-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-500-latin.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-500-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/Inter-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/Inter-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/Inter-latin.woff2
Normal file
BIN
.design-sync/fonts/Inter-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/JetBrainsMono-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/JetBrainsMono-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/JetBrainsMono-latin.woff2
Normal file
BIN
.design-sync/fonts/JetBrainsMono-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/Manrope-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/Manrope-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/Manrope-latin.woff2
Normal file
BIN
.design-sync/fonts/Manrope-latin.woff2
Normal file
Binary file not shown.
112
.design-sync/fonts/brand-fonts.css
Normal file
112
.design-sync/fonts/brand-fonts.css
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/* 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 } from 'node:fs';
|
||||
import { existsSync, writeFileSync, readFileSync } 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,7 +70,18 @@ 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);
|
||||
}
|
||||
const comps = srcFiles.filter((p) => SRC_IMPL_RX.test(p) && !NON_IMPL_RX.test(p));
|
||||
// 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')),
|
||||
);
|
||||
// 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
|
||||
|
|
@ -89,10 +100,25 @@ 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;
|
||||
|
|
@ -117,8 +143,17 @@ export async function resolvePackage(ctx) {
|
|||
names.add(k);
|
||||
}
|
||||
let components = [...names].sort().map((name) => ({ name, group: 'general' }));
|
||||
if (!components.length && synthEntry) {
|
||||
components = deriveComponentsFromSrc(srcFiles).filter((c) => srcMap[c.name] !== null);
|
||||
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) {
|
||||
if (cfg.cssEntry || existsSync(join(PKG_DIR, 'styles.css'))) {
|
||||
|
|
|
|||
|
|
@ -57,5 +57,17 @@ export function PreviewProvider({ children }: { children: React.ReactNode }) {
|
|||
);
|
||||
return qc;
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
36
.design-sync/previews/BuildingListingsDrawer.tsx
Normal file
36
.design-sync/previews/BuildingListingsDrawer.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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={() => {}} />
|
||||
);
|
||||
25
.design-sync/previews/LocationDrawer.tsx
Normal file
25
.design-sync/previews/LocationDrawer.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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,36 +1,10 @@
|
|||
import { ResultPanel } from 'tradein-mvp-frontend';
|
||||
|
||||
/** Переиспользуемая панель результата мутации скрапера. Успех → 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'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
/**
|
||||
* ResultPanel (v2) — центральная панель результата оценки на `/trade-in/v2`:
|
||||
* одна цифра-герой (ожидаемая цена продажи / «оценка»), тиры median + ДКП,
|
||||
* доверительные диапазоны, источники и мини-гистограмма распределения.
|
||||
* Данные по умолчанию берутся из встроенной `RESULT_FIXTURE` компонента,
|
||||
* поэтому карточке достаточно передать `onNavigate` (навигация по секциям).
|
||||
*/
|
||||
export const Default = () => <ResultPanel onNavigate={() => {}} />;
|
||||
|
|
|
|||
54
.design-sync/previews/SaleShareControls.tsx
Normal file
54
.design-sync/previews/SaleShareControls.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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={() => {}}
|
||||
/>
|
||||
);
|
||||
50
.design-sync/previews/SaleShareList.tsx
Normal file
50
.design-sync/previews/SaleShareList.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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"
|
||||
/>
|
||||
);
|
||||
30
.design-sync/previews/SaleShareMap.tsx
Normal file
30
.design-sync/previews/SaleShareMap.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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={() => {}}
|
||||
/>
|
||||
);
|
||||
39
.design-sync/previews/SectionOverlay.tsx
Normal file
39
.design-sync/previews/SectionOverlay.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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>
|
||||
);
|
||||
135
.forgejo/workflows/ci-tradein.yml
Normal file
135
.forgejo/workflows/ci-tradein.yml
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
name: CI Trade-In
|
||||
|
||||
# Forgejo Actions pre-merge gate for the SUBPROJECT tradein-mvp/.
|
||||
# WHY THIS FILE EXISTS (#2208): основной .forgejo/workflows/ci.yml гейтит
|
||||
# ТОЛЬКО backend/** + frontend/** главного стека — tradein-PR проходили на
|
||||
# пусто-зелёных чеках (paths-filter no-op), а pytest tradein жил лишь в
|
||||
# post-merge deploy-tradein.yml. Итог: сломанный tradein-код мержился в main
|
||||
# и обнаруживался только на деплое. Этот workflow добавляет РЕАЛЬНЫЙ pre-merge
|
||||
# gate: tradein-backend pytest + tradein-frontend type-check/lint ДО мержа.
|
||||
on:
|
||||
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709,
|
||||
# см. подробное обоснование в ci.yml). Кратко: раньше push+pull_request на один
|
||||
# SHA давали разный github.ref → разные concurrency-группы → 2× прогон на
|
||||
# дефицитных раннерах. В bot-пайплайне каждый коммит идёт через PR, так что
|
||||
# pull_request гейтит его полностью; push-прогон был чистым дублем.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
# github.ref стабилен на весь PR (refs/pull/<N>/merge) → новый push в ветку PR
|
||||
# отменяет предыдущий незавершённый прогон ЭТОГО PR вместо накопления.
|
||||
group: ci-tradein-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Paths-filter: гейт бежит ТОЛЬКО когда поменялся tradein-код.
|
||||
# PR не трогающий tradein-mvp/ → оба job'а no-op'ятся → дёшево.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'tradein-mvp/backend/**'
|
||||
- 'tradein-mvp/packages/**'
|
||||
# workspace root: lock-only bump (uv lock --upgrade) или правка
|
||||
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
|
||||
- 'tradein-mvp/uv.lock'
|
||||
- 'tradein-mvp/pyproject.toml'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./tradein-mvp/backend
|
||||
env:
|
||||
# psycopg v3 требует parseable URL на импорте; реального коннекта нет —
|
||||
# DB-тесты мокаются (mirror deploy-tradein.yml test-job).
|
||||
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
# Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается
|
||||
# на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI).
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Cache uv packages
|
||||
# Кросс-прогонный кэш скачанных/собранных wheel'ов (~/.cache/uv).
|
||||
# continue-on-error чтобы сбой cache-бэкенда раннера НИКОГДА не ронял gate.
|
||||
# Ключ по workspace-локу tradein-mvp/uv.lock (tracked с воркспейса #2137).
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-tradein-${{ runner.os }}-${{ hashFiles('tradein-mvp/uv.lock') }}
|
||||
restore-keys: |
|
||||
uv-tradein-${{ runner.os }}-
|
||||
|
||||
- name: Sync deps (incl. dev group — pytest)
|
||||
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
|
||||
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
|
||||
# Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root
|
||||
# вверх от cwd.
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
||||
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
||||
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
||||
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в
|
||||
# полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан
|
||||
# совпадать с test-job в deploy-tradein.yml.
|
||||
run: |
|
||||
uv run pytest -q \
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit"
|
||||
|
||||
frontend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./tradein-mvp/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
# Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine).
|
||||
# npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет
|
||||
# package-lock.json (Dockerfile ставит через npm install), а cache=npm
|
||||
# без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint.
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install deps (npm install, no lockfile)
|
||||
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage):
|
||||
# --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches;
|
||||
# --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`):
|
||||
# в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но
|
||||
# Dockerfile ставит именно npm install) → `npm ci` упал бы.
|
||||
run: npm install --legacy-peer-deps --no-audit --no-fund
|
||||
|
||||
- name: Type-check (tsc --noEmit)
|
||||
# Blocking: любая TS-ошибка → job RED.
|
||||
run: npm run type-check
|
||||
|
||||
- name: Lint (next lint)
|
||||
# Blocking: любая ESLint-ошибка → job RED.
|
||||
run: npm run lint
|
||||
|
|
@ -9,6 +9,14 @@ name: Deploy Obsidian
|
|||
#
|
||||
# Не пересобирает никаких Docker-образов (CouchDB официальный с DockerHub).
|
||||
# Не трогает main-стек (backend / frontend / postgres / worker / beat / caddy).
|
||||
#
|
||||
# ИСТОРИЯ (2026-07-05): жил в .github/workflows/ с момента миграции с GitHub
|
||||
# (16.05.2026), помечен в README как «остался на GitHub» — но живого зеркала
|
||||
# на github.com с настроенными секретами не оказалось: 0 запусков за всю
|
||||
# историю Forgejo Actions (12000+ прогонов остальных workflow), контейнер
|
||||
# не пересоздавался с 17.05 до ручного SSH-фикса 04.07. Перенесён сюда —
|
||||
# единственная директория, которую реально исполняет этот инстанс.
|
||||
# См. issue #2416.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -17,7 +25,7 @@ on:
|
|||
- "docker-compose.obsidian.yml"
|
||||
- "scripts/setup-couchdb.sh"
|
||||
- "docs/obsidian-livesync.md"
|
||||
- ".github/workflows/deploy-obsidian.yml"
|
||||
- ".forgejo/workflows/deploy-obsidian.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -133,7 +133,7 @@ jobs:
|
|||
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
|
||||
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
|
||||
# Tests use mocks + a stub DATABASE_URL — no real Postgres/Redis needed.
|
||||
# 2 pre-existing order-dependent tests are deselected (see DESELECT note below).
|
||||
# 1 pre-existing order-dependent test is deselected (see DESELECT note below).
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
@ -158,20 +158,21 @@ jobs:
|
|||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Sync deps (incl. dev group — pytest)
|
||||
# NB: tradein-mvp/backend/uv.lock is gitignored (see tradein-mvp/.gitignore),
|
||||
# so --frozen can't work in CI (no lockfile in a fresh checkout). Resolve
|
||||
# from pyproject.toml like the Dockerfile does (uv sync --no-dev there).
|
||||
run: uv sync
|
||||
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
|
||||
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
|
||||
# Dockerfile (uv sync --frozen --no-dev). Актуализировано в #2208.
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# DESELECT (2026-05): two pre-existing tests fail only in whole-suite
|
||||
# ordering (pass in isolation) due to global state leak from other test
|
||||
# modules — not introduced here. Excluded so the gate is reliably green;
|
||||
# tracked separately. Everything else (1087 tests) must pass.
|
||||
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
||||
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
||||
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
||||
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном
|
||||
# прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
|
||||
# совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
|
||||
run: |
|
||||
uv run pytest -q \
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit" \
|
||||
--deselect "tests/test_cian_valuation.py::test_cache_hit_returns_cached"
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit"
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -231,10 +232,13 @@ jobs:
|
|||
context: ./tradein-mvp/frontend
|
||||
push: true
|
||||
# basePath=/trade-in baked-in во время build (Next.js)
|
||||
# NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут
|
||||
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
|
||||
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
|
||||
# уходит в notFound (404), не индексируется и не краулится.
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||
NEXT_PUBLIC_ENABLE_PREVIEW=1
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
|
|
@ -335,24 +339,44 @@ jobs:
|
|||
export IMAGE_TAG="$IMAGE_TAG"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
|
||||
|
||||
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
|
||||
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
|
||||
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — recreating tradein-scraper"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
|
||||
else
|
||||
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
|
||||
fi
|
||||
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
|
||||
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
|
||||
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
|
||||
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
|
||||
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
|
||||
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
|
||||
# нового кода → старые контейнеры продолжают работать на СТАРОМ коде +
|
||||
# СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый
|
||||
# код на старой схеме». Откат = просто ничего не поднимали.
|
||||
|
||||
# Применяем SQL миграции (если есть backend/data/sql/*.sql)
|
||||
# (1) Только БД — чтобы прогнать миграции до нового app-кода.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres
|
||||
|
||||
# (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep).
|
||||
echo "→ Ожидание готовности postgres..."
|
||||
pg_ready=""
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
pg_isready -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
|
||||
pg_ready="yes"; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$pg_ready" ]; then
|
||||
echo "ERROR: postgres не стал ready за отведённое время — прерываю деплой."
|
||||
echo " Новый app-код НЕ поднят; старые контейнеры не тронуты."
|
||||
exit 1
|
||||
fi
|
||||
echo "→ postgres ready."
|
||||
|
||||
# (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app.
|
||||
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
|
||||
# старте volume. Здесь — для повторных миграций после первого запуска.
|
||||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||
sleep 5
|
||||
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||
# #2216) glob'ом не подхватывается.
|
||||
|
||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||
|
|
@ -426,16 +450,113 @@ jobs:
|
|||
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
|
||||
fi
|
||||
|
||||
# Retry backend lifespan hook AFTER migrations applied.
|
||||
# tradein-backend startup runs ensure_fdw_user_mapping which needs
|
||||
# FOREIGN SERVER gendesign_remote (created by 060_postgres_fdw_extension.sql).
|
||||
# Without restart, the first compose-up's startup hook failed with
|
||||
# "server gendesign_remote does not exist" because migrations hadn't run yet.
|
||||
# See PR #493 deploy/1156 for the incident details.
|
||||
echo "→ Restarting tradein-backend so lifespan hook retries USER MAPPING setup"
|
||||
docker restart tradein-backend
|
||||
# Give backend time to come up before Caddy reload + health check below
|
||||
sleep 5
|
||||
# (4) Теперь — новый app-код: схема уже актуальна.
|
||||
#
|
||||
# #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 существует) уже с первого старта. Рестарт удалён.
|
||||
|
||||
# Caddy reload — основной Caddyfile содержит inline tradein routes
|
||||
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
|
||||
|
|
@ -444,12 +565,28 @@ jobs:
|
|||
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
||||
caddy reload --config /etc/caddy/Caddyfile || true
|
||||
|
||||
# Health check
|
||||
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (#2214).
|
||||
# Раньше цикл после 30 неуспешных попыток молча продолжал скрипт и
|
||||
# доходил до записи success-маркера → мёртвый backend помечался
|
||||
# «задеплоено». Теперь: флаг healthy выставляется ТОЛЬКО при HTTP 200
|
||||
# на /health; после цикла — hard exit 1, если флаг пуст. exit 1
|
||||
# происходит ДО записи .tradein-deployed-sha (маркер пишется последним,
|
||||
# ниже) → следующий прогон changes-job возьмёт корректную базу.
|
||||
# NB set -e: curl стоит в условии `if` (exempt из errexit) — неуспешная
|
||||
# попытка НЕ фатальна, а лишь провоцирует следующую итерацию цикла.
|
||||
healthy=""
|
||||
for i in $(seq 1 30); do
|
||||
docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
||||
exec -T backend curl -fsS http://localhost:8000/health && break
|
||||
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
||||
exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
|
||||
healthy="yes"; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$healthy" ]; then
|
||||
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ backend healthy на /health."
|
||||
|
||||
# Cleanup старых образов
|
||||
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
|
||||
|
|
|
|||
|
|
@ -199,6 +199,14 @@ jobs:
|
|||
# but other callers may invoke the raw path).
|
||||
chmod +x ops/*.sh 2>/dev/null || true
|
||||
|
||||
# Full PDF report bind-source (#2259 PR-D). docker создаёт отсутствующий
|
||||
# bind-source как root:root — worker пишет PDF под uid 1000 → PermissionError
|
||||
# → вечный «building». Создаём каталог заранее + chown под контейнерный uid.
|
||||
# chown под non-root deploy-юзером требует sudo → fallback (|| true — если и
|
||||
# sudo нет, каталог уже наш и chown не нужен).
|
||||
mkdir -p reports
|
||||
chown 1000:1000 reports 2>/dev/null || sudo chown 1000:1000 reports 2>/dev/null || true
|
||||
|
||||
# Sentry release tracking
|
||||
mkdir -p backend
|
||||
touch backend/.env.runtime
|
||||
|
|
|
|||
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,6 +87,10 @@ sf_anton_snapshot.db-*
|
|||
data/osrm/*
|
||||
!data/osrm/.gitkeep
|
||||
|
||||
# Full PDF report bind-source (#2259 PR-D) — worker пишет PDF в ./reports:/app/reports.
|
||||
# Артефакты рантайма, не коммитим (untracked и так выживает при deploy git reset --hard).
|
||||
/reports/
|
||||
|
||||
# Log cruft at repo root
|
||||
debug.log
|
||||
.ds-sync/
|
||||
|
|
|
|||
24
Caddyfile
24
Caddyfile
|
|
@ -16,15 +16,13 @@
|
|||
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
||||
# ineffective. With route { }, handlers are matched top-to-bottom as written.
|
||||
|
||||
{
|
||||
servers {
|
||||
# ВКЛЮЧАЕТ raw Authorization/Cookie headers в access log
|
||||
# (по default Caddy 2.x редактирует их как "REDACTED").
|
||||
# Plain password будет в gendsgn.ru.log + auth_audit.log — оба root-only на VPS.
|
||||
# См. forwarder.py _extract_attempted_username для использования.
|
||||
log_credentials
|
||||
}
|
||||
}
|
||||
# #2213: global `log_credentials` УБРАН. Он заставлял Caddy писать raw
|
||||
# Authorization header (Base64 `user:password`, тривиально обратимый) в ОБА лог-файла
|
||||
# (gendsgn.ru.log + auth_audit.log). Даже при root-only доступе и коротком retention
|
||||
# это plaintext-пароли пилотов на диске — недопустимый риск против скромной выгоды.
|
||||
# Trade-off: glitchtip-auth-forwarder больше не извлекает `attempted_username` из 401
|
||||
# (тег деградирует в "(none)" — forwarder это уже обрабатывает gracefully, не падает).
|
||||
# Событие basic_auth 401 (remote_ip / uri / method) по-прежнему уходит в GlitchTip.
|
||||
|
||||
gendsgn.ru {
|
||||
encode zstd gzip
|
||||
|
|
@ -88,6 +86,11 @@ 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}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +111,9 @@ gendsgn.ru {
|
|||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
|
||||
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
|
||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru
|
|||
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
|
||||
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
|
||||
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
|
||||
- [`.github/workflows/deploy-obsidian.yml`](.github/workflows/deploy-obsidian.yml) — obsidian (**остался на GitHub**): триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты).
|
||||
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*
|
||||
|
||||
**Forgejo Secrets / Variables:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе).
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ users:
|
|||
admin: admin
|
||||
kopylov: pilot
|
||||
user1: pilot
|
||||
user2: pilot
|
||||
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
|
||||
user3: pilot
|
||||
user4: pilot
|
||||
user5: pilot
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ POST /api/v1/admin/etl/objective-backfill
|
|||
POST /api/v1/admin/etl/nspd-denorm-backfill
|
||||
Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps.
|
||||
|
||||
GET /api/v1/admin/etl/mapping-review
|
||||
Список авто-маппингов objective_complex_mapping + обогащение для ревью.
|
||||
POST /api/v1/admin/etl/mapping-review/{id}/approve
|
||||
Подтвердить маппинг (is_reviewed=true + note append).
|
||||
POST /api/v1/admin/etl/mapping-review/{id}/reject
|
||||
Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2).
|
||||
|
||||
Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
|
||||
header removed 2026-05-23 — двойная auth избыточна для pilot.
|
||||
"""
|
||||
|
|
@ -16,17 +23,24 @@ header removed 2026-05-23 — двойная auth избыточна для pilo
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.services.etl.mapping_review import (
|
||||
approve_mapping,
|
||||
list_mapping_review,
|
||||
reject_mapping,
|
||||
)
|
||||
from app.services.etl.objective_backfill import (
|
||||
AUTO_ACCEPT_THRESHOLD,
|
||||
AUTO_ACCEPT_THRESHOLD_V2,
|
||||
REVIEW_THRESHOLD,
|
||||
apply_core_matches,
|
||||
auto_apply_matches,
|
||||
find_core_matches,
|
||||
find_match_candidates,
|
||||
trigger_mv_refresh,
|
||||
)
|
||||
|
|
@ -104,6 +118,37 @@ def run_objective_backfill(
|
|||
return result
|
||||
|
||||
|
||||
@router.post("/objective-core-backfill")
|
||||
def run_objective_core_backfill(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
dry_run: Annotated[bool, Query(description="Preview без insertions (default True)")] = True,
|
||||
refresh_mv: Annotated[bool, Query(description="REFRESH mv_layout_velocity после apply")] = True,
|
||||
) -> dict[str, object]:
|
||||
"""Core-name + dev-confirm проход (#2177 шаг 2, match_method='auto_core_dev_v5').
|
||||
|
||||
Нормализует ЯДРО имени обеих сторон (normalize_complex_name, #2198) и мапит
|
||||
несопоставленные domrf ЕКБ-объекты на objective-проекты. Пишутся ТОЛЬКО tier_a
|
||||
(ровно 1 кандидат + подтверждение застройщика). tier_b/ambiguous — в counts.
|
||||
|
||||
Дефолт dry_run=True (безопасно). Реальный insert — ?dry_run=false.
|
||||
|
||||
Returns dict:
|
||||
inserted / conflict_skipped / error_skipped / tier_a_total — из apply;
|
||||
tier_b / ambiguous / skipped_taken — счётчики отчёта;
|
||||
mv_rows_after_refresh — строк в MV после REFRESH (0 если dry-run / нет вставок).
|
||||
"""
|
||||
report = find_core_matches(db)
|
||||
result: dict[str, object] = dict(apply_core_matches(db, report, dry_run=dry_run))
|
||||
result.update(report.counts())
|
||||
|
||||
mv_rows = 0
|
||||
if refresh_mv and not dry_run and result.get("inserted", 0):
|
||||
mv_rows = trigger_mv_refresh(db)
|
||||
logger.info("mv_layout_velocity refreshed after core-backfill: %d rows", mv_rows)
|
||||
result["mv_rows_after_refresh"] = mv_rows
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/nspd-denorm-backfill")
|
||||
def run_nspd_denorm_backfill(
|
||||
limit: Annotated[
|
||||
|
|
@ -124,3 +169,68 @@ def run_nspd_denorm_backfill(
|
|||
task = backfill_all_dumps.apply_async(kwargs={"limit": limit})
|
||||
logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit)
|
||||
return {"task_id": task.id, "status": "enqueued", "limit": limit}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Mapping review — глазами подтвердить/отклонить авто-маппинги (feat/…-review)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/mapping-review")
|
||||
def get_mapping_review(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
only_unreviewed: Annotated[
|
||||
bool, Query(description="Только is_reviewed=false (авто-матчи без ревью)")
|
||||
] = True,
|
||||
limit: Annotated[int, Query(ge=1, le=500, description="Строк на странице")] = 100,
|
||||
offset: Annotated[int, Query(ge=0, description="Смещение страницы")] = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Список строк objective_complex_mapping + обогащение для ревьюера.
|
||||
|
||||
Обогащение каждой строки:
|
||||
- domrf_comm_name / domrf_dev_name — latest snapshot по domrf_obj_id;
|
||||
- objective_developers — застройщики objective-проекта (агрегат
|
||||
objective_lots.developer, scoped по project_name).
|
||||
|
||||
Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху).
|
||||
|
||||
Returns dict:
|
||||
rows: list обогащённых строк (id, objective_complex_name,
|
||||
objective_project_id, objective_group, domrf_obj_id, match_method,
|
||||
match_score, is_reviewed, note, created_at, domrf_comm_name,
|
||||
domrf_dev_name, objective_developers);
|
||||
total: всего строк (с учётом only_unreviewed, БЕЗ limit/offset);
|
||||
limit / offset / only_unreviewed: эхо параметров.
|
||||
"""
|
||||
return list_mapping_review(db, only_unreviewed=only_unreviewed, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/mapping-review/{mapping_id}/approve")
|
||||
def approve_mapping_review(
|
||||
mapping_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
"""Подтвердить маппинг: is_reviewed=true + append «approved <date>» в note.
|
||||
|
||||
404 если строки с таким id нет.
|
||||
"""
|
||||
result = approve_mapping(db, mapping_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/mapping-review/{mapping_id}/reject")
|
||||
def reject_mapping_review(
|
||||
mapping_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
"""Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2).
|
||||
|
||||
Удаляемая строка целиком логируется (logger.info) перед commit — в note
|
||||
писать некуда, строки не будет. 404 если id не найден.
|
||||
"""
|
||||
deleted = reject_mapping(db, mapping_id)
|
||||
if deleted is None:
|
||||
raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден")
|
||||
return {"status": "deleted", "deleted": deleted}
|
||||
|
|
|
|||
|
|
@ -497,6 +497,21 @@ 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,
|
||||
|
|
@ -1137,7 +1152,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
|
|||
)
|
||||
from app.workers.tasks.etl_newbuilding_crossload import etl_newbuilding_crossload
|
||||
|
||||
result = etl_newbuilding_crossload.apply_async()
|
||||
result = etl_newbuilding_crossload.apply_async(kwargs={"triggered_by": "manual"})
|
||||
return {"task_id": result.id, "queued_at": "now"}
|
||||
|
||||
|
||||
|
|
@ -1404,8 +1419,12 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
|
|||
table="nspd_geo_jobs",
|
||||
work_col="targets_done",
|
||||
attempt_fallback_col="created_at",
|
||||
fresh_days=7.0,
|
||||
stale_days=30.0,
|
||||
# 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,
|
||||
),
|
||||
FreshnessSource(
|
||||
source="cadastre",
|
||||
|
|
@ -1416,6 +1435,20 @@ _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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -1756,6 +1789,20 @@ def trigger_ekburg_permits(
|
|||
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
||||
|
||||
|
||||
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
|
||||
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
|
||||
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
|
||||
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
|
||||
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
|
||||
_WAF_COOLDOWN_GUARD_MSG = (
|
||||
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
|
||||
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
|
||||
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
|
||||
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
|
||||
"передай i_understand_waf_risk=true в теле запроса."
|
||||
)
|
||||
|
||||
|
||||
class TriggerKnCatalogObjectsRequest(BaseModel):
|
||||
region_code: int = Field(default=66, ge=1, le=99)
|
||||
max_objects: int | None = Field(default=None, ge=1, le=2000)
|
||||
|
|
@ -1767,6 +1814,14 @@ class TriggerKnCatalogObjectsRequest(BaseModel):
|
|||
"что уже скраплено сегодня."
|
||||
),
|
||||
)
|
||||
i_understand_waf_risk: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
|
||||
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper бьёт по "
|
||||
"тому же /сервисы/* BrowserSession path family, что вызвал бан."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/kn-catalog-objects")
|
||||
|
|
@ -1783,7 +1838,14 @@ def trigger_kn_catalog_objects(
|
|||
- max_objects=None → дефолтный лимит таска (300).
|
||||
- max_objects=3 → smoke-тест.
|
||||
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||
|
||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
||||
re-trigger без явного подтверждения оператора запрещён.
|
||||
"""
|
||||
if not payload.i_understand_waf_risk:
|
||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||
|
||||
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
|
|
@ -1801,3 +1863,65 @@ def trigger_kn_catalog_objects(
|
|||
"force": payload.force,
|
||||
"queued_at": "now",
|
||||
}
|
||||
|
||||
|
||||
class TriggerKnCatalogFlatsRequest(BaseModel):
|
||||
region_code: int = Field(default=66, ge=1, le=99)
|
||||
max_flats: int | None = Field(default=None, ge=1, le=5000)
|
||||
force: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"True — игнорировать фильтр свежести ('catalog_updated_at свежий') и "
|
||||
"грузить ВСЕ квартиры последнего snapshot с непустым catalog_url_hash "
|
||||
"('Загрузить все'). По умолчанию пропускает то, что скраплено < 30 дней назад."
|
||||
),
|
||||
)
|
||||
i_understand_waf_risk: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
|
||||
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper ездит по "
|
||||
"тому же /сервисы/* BrowserSession path family, что и catalog-objects."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/kn-catalog-flats")
|
||||
def trigger_kn_catalog_flats(
|
||||
payload: TriggerKnCatalogFlatsRequest,
|
||||
) -> dict[str, Any]:
|
||||
"""Manual trigger для catalog-FLAT scraper (#2442): цена/статус/отделка/потолки/
|
||||
дата обновления + plan-изображения квартир из SSR-страницы каталога.
|
||||
|
||||
Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL. До тех пор пока
|
||||
#2442 Task 1 (elemId → catalog_url_hash) не задеплоен и свежий kn-sweep не
|
||||
наполнил hash — вернёт 0 обработанных строк (ожидаемо, не баг).
|
||||
|
||||
- max_flats=None → дефолтный лимит таска (300).
|
||||
- max_flats=3 → smoke-тест.
|
||||
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||
|
||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
||||
того же WAF-бана.
|
||||
"""
|
||||
if not payload.i_understand_waf_risk:
|
||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||
|
||||
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"region_code": payload.region_code,
|
||||
"force": payload.force,
|
||||
}
|
||||
if payload.max_flats is not None:
|
||||
kwargs["max_flats"] = payload.max_flats
|
||||
result = scrape_kn_catalog_flats.apply_async(kwargs=kwargs)
|
||||
|
||||
return {
|
||||
"task_id": result.id,
|
||||
"region_code": payload.region_code,
|
||||
"max_flats": payload.max_flats,
|
||||
"force": payload.force,
|
||||
"queued_at": "now",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ 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_report_for_chat
|
||||
from app.services.chat.retrieval import (
|
||||
_FORECAST_SCHEMA_VERSION,
|
||||
get_parcel_context_for_chat,
|
||||
get_report_for_chat,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -92,11 +96,46 @@ 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,11 +3,13 @@ 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
|
||||
|
|
@ -35,6 +37,8 @@ from app.schemas.parcel import (
|
|||
ParcelSearchRequest,
|
||||
ParcelSearchResponse,
|
||||
RedLine,
|
||||
ReportBuildResponse,
|
||||
ReportStatusResponse,
|
||||
RiskZone,
|
||||
UtilityInfrastructureResponse,
|
||||
)
|
||||
|
|
@ -46,6 +50,7 @@ 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,12 +82,23 @@ from app.services.site_finder.osrm_client_local import (
|
|||
OsrmLocalUnavailableError,
|
||||
get_road_distances_m,
|
||||
)
|
||||
from app.services.site_finder.parcel_financial import synthesize_parcel_financial
|
||||
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.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,
|
||||
|
|
@ -206,6 +222,19 @@ 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": "Школа",
|
||||
|
|
@ -828,6 +857,20 @@ _NEIGHBORS_SUMMARY_SQL = text("""
|
|||
ORDER BY distance_m ASC
|
||||
LIMIT 30
|
||||
),
|
||||
neighbors_total AS (
|
||||
-- #2464 cluster B: честный COUNT(*) по ВСЕЙ 100м-выборке — БЕЗ LIMIT 30
|
||||
-- (тот же WHERE, что у `neighbors` выше). count_buildings_100m раньше
|
||||
-- считался как len(neighbors), тихо капаясь на 30 даже когда соседей
|
||||
-- в радиусе больше.
|
||||
SELECT COUNT(*) AS n
|
||||
FROM cad_buildings b
|
||||
WHERE ST_DWithin(
|
||||
b.geom::geography,
|
||||
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
|
||||
100
|
||||
)
|
||||
AND b.cad_num != CAST(:our_cad AS text)
|
||||
),
|
||||
overlap_rows AS (
|
||||
SELECT cad_num,
|
||||
building_name,
|
||||
|
|
@ -855,7 +898,8 @@ _NEIGHBORS_SUMMARY_SQL = text("""
|
|||
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
|
||||
FROM overlap_rows o),
|
||||
'[]'::json
|
||||
) AS overlap_rows
|
||||
) AS overlap_rows,
|
||||
(SELECT n FROM neighbors_total) AS neighbors_total_count
|
||||
""")
|
||||
|
||||
|
||||
|
|
@ -872,6 +916,11 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
один сетевой round-trip (~47ms) на каждый analyze — сами вычисления не
|
||||
меняются. Формат возвращаемого dict идентичен прежнему.
|
||||
|
||||
#2464 cluster B: `count_buildings_100m` — честный COUNT(*) по всей 100м-выборке
|
||||
(CTE `neighbors_total`, БЕЗ LIMIT), а не len(neighbors) (капалось на LIMIT 30).
|
||||
`neighbors_truncated` — True если в радиусе больше соседей, чем показано в
|
||||
списке `neighbors`.
|
||||
|
||||
SQL — module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
|
||||
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
|
||||
"""
|
||||
|
|
@ -886,6 +935,9 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
)
|
||||
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
|
||||
overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
|
||||
# #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) —
|
||||
# НЕ len(neighbor_rows), которое капалось на 30 даже когда соседей больше.
|
||||
neighbors_total_count = int(row["neighbors_total_count"]) if row else 0
|
||||
except Exception as e:
|
||||
logger.warning("neighbors query failed: %s", e)
|
||||
return {"data_available": False, "note": f"neighbors query failed: {e}"}
|
||||
|
|
@ -930,36 +982,46 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
]
|
||||
has_existing = len(overlap_buildings) > 0
|
||||
|
||||
# neighbor_rows — до 30 (SQL CTE `neighbors` LIMIT), список ниже дополнительно
|
||||
# режется до 20 для payload. `neighbors_truncated` сравнивает честный total с
|
||||
# тем, что РЕАЛЬНО показано (len(neighbors_list)) — а не с промежуточным
|
||||
# neighbor_rows (до 30) — иначе флаг мог соврать False на 21..30 соседях,
|
||||
# где список уже обрезан до 20, а total ещё не превысил len(neighbor_rows).
|
||||
neighbors_list = [
|
||||
{
|
||||
"cad_num": r["cad_num"],
|
||||
"building_name": r.get("building_name"),
|
||||
"floors": r.get("floors"),
|
||||
"floors_parsed": _parse_floors(r.get("floors")),
|
||||
"year_built": r.get("year_built"),
|
||||
"area_m2": round(float(r["area"])) if r.get("area") else None,
|
||||
"cost_per_m2": (
|
||||
round(float(r["cost_value"]) / float(r["area"]))
|
||||
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
|
||||
else None
|
||||
),
|
||||
"distance_m": round(float(r["distance_m"])),
|
||||
"readable_address": r.get("readable_address"),
|
||||
# #2111 — функциональное назначение + статус здания (cad_buildings,
|
||||
# populated bulk_harvest.py). purpose: TEXT категория, status:
|
||||
# commissioned/under-construction и т.п.
|
||||
"purpose": r.get("purpose"),
|
||||
"status": r.get("status"),
|
||||
}
|
||||
for r in neighbor_rows[:20]
|
||||
]
|
||||
|
||||
return {
|
||||
"data_available": True,
|
||||
"radius_m": 100,
|
||||
"count_buildings_100m": len(neighbor_rows),
|
||||
# #2464 cluster B: честный total (neighbors_total CTE, без LIMIT) — НЕ
|
||||
# len(neighbor_rows) (капалось на LIMIT 30 у CTE `neighbors`).
|
||||
"count_buildings_100m": neighbors_total_count,
|
||||
"neighbors_truncated": neighbors_total_count > len(neighbors_list),
|
||||
"avg_floors_100m": avg_floors,
|
||||
"max_floors_100m": max_floors,
|
||||
"median_cost_per_m2_100m": median_cost,
|
||||
"neighbors": [
|
||||
{
|
||||
"cad_num": r["cad_num"],
|
||||
"building_name": r.get("building_name"),
|
||||
"floors": r.get("floors"),
|
||||
"floors_parsed": _parse_floors(r.get("floors")),
|
||||
"year_built": r.get("year_built"),
|
||||
"area_m2": round(float(r["area"])) if r.get("area") else None,
|
||||
"cost_per_m2": (
|
||||
round(float(r["cost_value"]) / float(r["area"]))
|
||||
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
|
||||
else None
|
||||
),
|
||||
"distance_m": round(float(r["distance_m"])),
|
||||
"readable_address": r.get("readable_address"),
|
||||
# #2111 — функциональное назначение + статус здания (cad_buildings,
|
||||
# populated bulk_harvest.py). purpose: TEXT категория, status:
|
||||
# commissioned/under-construction и т.п.
|
||||
"purpose": r.get("purpose"),
|
||||
"status": r.get("status"),
|
||||
}
|
||||
for r in neighbor_rows[:20]
|
||||
],
|
||||
"neighbors": neighbors_list,
|
||||
"has_existing_buildings": has_existing,
|
||||
"overlap_buildings": overlap_buildings,
|
||||
"note": (
|
||||
|
|
@ -1128,6 +1190,70 @@ def _compute_confidence(
|
|||
}
|
||||
|
||||
|
||||
def _build_market_pulse(
|
||||
competitor_rows: list[dict[str, Any]],
|
||||
competitors_total: int,
|
||||
velocity_data: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""OBJ-3 aggregate: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
|
||||
|
||||
Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
|
||||
в `competitor_rows` (детальный список для карты/карточек), но исключаются
|
||||
из расчётов рыночных метрик (market_avg_price_per_m2, top_sellers).
|
||||
|
||||
#2464 cluster B: `competitors_total` — честный total (передаётся вызывающим
|
||||
кодом из отдельного COUNT(*) БЕЗ LIMIT, см. `analyze_parcel` 5a), НЕ
|
||||
len(competitor_rows) (которое капалось на LIMIT 20 запроса конкурентов, и
|
||||
заодно раздувало `coverage_pct` — competitors_priced/20 вместо
|
||||
competitors_priced/true_total). PURE — вызывающий код передаёт честный total
|
||||
отдельно, эта функция его не пересчитывает.
|
||||
"""
|
||||
competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
|
||||
competitors_priced = len(competitors_with_price)
|
||||
if competitors_with_price:
|
||||
prices = [float(c["avg_price_per_m2_rub"]) for c in competitors_with_price]
|
||||
market_avg_price = round(sum(prices) / len(prices))
|
||||
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
|
||||
with_sales = [
|
||||
c
|
||||
for c in competitors_with_price
|
||||
if c["units_sold"] is not None and int(c["units_sold"]) > 0
|
||||
]
|
||||
top_sellers = sorted(
|
||||
with_sales,
|
||||
key=lambda c: int(c["units_sold"]),
|
||||
reverse=True,
|
||||
)[:5]
|
||||
top_sellers_list = [
|
||||
{
|
||||
"obj_id": c["obj_id"],
|
||||
"comm_name": c["comm_name"],
|
||||
"dev_name": c["dev_name"],
|
||||
"units_sold": int(c["units_sold"]),
|
||||
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
|
||||
}
|
||||
for c in top_sellers
|
||||
]
|
||||
else:
|
||||
market_avg_price = None
|
||||
top_sellers_list = []
|
||||
|
||||
coverage_pct = (
|
||||
round(competitors_priced * 100.0 / competitors_total, 1) if competitors_total > 0 else 0.0
|
||||
)
|
||||
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
|
||||
# ЖК с objective_corpus_room_month данными (non-null by construction).
|
||||
avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
|
||||
return {
|
||||
"avg_velocity_m2": avg_velocity,
|
||||
"market_avg_price_per_m2": market_avg_price,
|
||||
"competitors_total": competitors_total,
|
||||
"competitors_with_price": competitors_priced,
|
||||
"coverage_pct": coverage_pct,
|
||||
"top_sellers": top_sellers_list,
|
||||
}
|
||||
|
||||
|
||||
# #93 — on-demand cadastre fetch tuning constants.
|
||||
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
|
||||
#
|
||||
|
|
@ -1533,6 +1659,221 @@ 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,
|
||||
|
|
@ -2012,6 +2353,34 @@ def analyze_parcel(
|
|||
.all()
|
||||
)
|
||||
|
||||
# 5a) #2464 cluster B: честный COUNT(*) конкурентов в радиусе 3км — БЕЗ LIMIT.
|
||||
# competitor_rows выше капнут `LIMIT 20` (топ-20 ближайших/строящихся для карты и
|
||||
# детального списка) — market_pulse.competitors_total ниже раньше = len(competitor_rows),
|
||||
# тихо капаясь на 20 даже когда в радиусе ЖК больше. Лёгкий COUNT переиспользует
|
||||
# ТОТ ЖЕ latest_obj + ST_DWithin фильтр, но БЕЗ obj_lots_latest/obj_pricing джойнов
|
||||
# (нужны только для цен детального списка, не для total) — на порядок дешевле
|
||||
# полного запроса конкурентов (см. #1964 EXPLAIN про стоимость lots-дедупа).
|
||||
_competitors_total_true = (
|
||||
db.execute(
|
||||
text("""
|
||||
WITH latest_obj AS (
|
||||
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude
|
||||
FROM domrf_kn_objects
|
||||
WHERE latitude IS NOT NULL
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
)
|
||||
SELECT COUNT(*) FROM latest_obj o
|
||||
WHERE ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
3000
|
||||
)
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес
|
||||
# в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
|
||||
# + по кварталам сдачи. Константы — см. PIPELINE_* выше.
|
||||
|
|
@ -2863,12 +3232,16 @@ def analyze_parcel(
|
|||
_deals_stale = (
|
||||
recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
|
||||
)
|
||||
# Разные причины отсутствия сделок → честная микрокопия: устаревание
|
||||
# источника vs просто нет сделок рядом (источник свеж). Обе показывают
|
||||
# Честная микрокопия. Росреестр отдаёт сделки ПОКВАРТАЛЬНО (max(deal_date)
|
||||
# = начало квартала поставки) — это НЕ «источник устарел». Для stale-ветки
|
||||
# называем квартал поставки; иначе просто нет сделок рядом. Обе показывают
|
||||
# предложение как основной контент.
|
||||
_offer_only_label = (
|
||||
"Сделки устарели — показываем динамику цен предложения"
|
||||
if _deals_stale
|
||||
(
|
||||
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
|
||||
"(поставка поквартальная); показываем динамику цен предложения"
|
||||
)
|
||||
if _deals_stale and max_period is not None
|
||||
else "Нет сделок рядом — показываем динамику цен предложения"
|
||||
)
|
||||
market_trend = {
|
||||
|
|
@ -2881,13 +3254,17 @@ def analyze_parcel(
|
|||
"offer_trend": offer_block,
|
||||
}
|
||||
elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
|
||||
# Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»).
|
||||
# #2178: сюда попадаем ТОЛЬКО когда и offer-данных нет (offer_block is None) —
|
||||
# прежний source_stale остаётся честным «нечего показать».
|
||||
# Recent-окно пусто И latest deal старше 3 мес. Росреестр отдаёт сделки
|
||||
# ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — честно
|
||||
# называем квартал поставки, НЕ «источник устарел». #2178: сюда попадаем
|
||||
# ТОЛЬКО когда и offer-данных нет (offer_block is None).
|
||||
market_trend = {
|
||||
"status": "source_stale",
|
||||
"as_of_date": max_period.isoformat(),
|
||||
"label": (f"Источник сделок устарел (данные до {max_period.strftime('%m.%Y')})"),
|
||||
"label": (
|
||||
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
|
||||
"(поставка поквартальная); тренд по сделкам не показываем"
|
||||
),
|
||||
"recent_deals_count": 0,
|
||||
}
|
||||
else:
|
||||
|
|
@ -3252,6 +3629,25 @@ 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/"
|
||||
|
|
@ -3409,56 +3805,12 @@ def analyze_parcel(
|
|||
except Exception as _ve:
|
||||
logger.warning("velocity compute failed for %s: %s", cad_num, _ve)
|
||||
|
||||
# OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
|
||||
# Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
|
||||
# в competitor list для карты, но исключаются из расчётов рыночных метрик.
|
||||
_competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
|
||||
_competitors_total = len(competitor_rows)
|
||||
_competitors_priced = len(_competitors_with_price)
|
||||
if _competitors_with_price:
|
||||
_prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price]
|
||||
_market_avg_price = round(sum(_prices) / len(_prices))
|
||||
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
|
||||
_with_sales = [
|
||||
c
|
||||
for c in _competitors_with_price
|
||||
if c["units_sold"] is not None and int(c["units_sold"]) > 0
|
||||
]
|
||||
_top_sellers = sorted(
|
||||
_with_sales,
|
||||
key=lambda c: int(c["units_sold"]),
|
||||
reverse=True,
|
||||
)[:5]
|
||||
_top_sellers_list = [
|
||||
{
|
||||
"obj_id": c["obj_id"],
|
||||
"comm_name": c["comm_name"],
|
||||
"dev_name": c["dev_name"],
|
||||
"units_sold": int(c["units_sold"]),
|
||||
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
|
||||
}
|
||||
for c in _top_sellers
|
||||
]
|
||||
else:
|
||||
_market_avg_price = None
|
||||
_top_sellers_list = []
|
||||
|
||||
_coverage_pct = (
|
||||
round(_competitors_priced * 100.0 / _competitors_total, 1)
|
||||
if _competitors_total > 0
|
||||
else 0.0
|
||||
# #2464 cluster B: competitors_total — честный total из отдельного COUNT(*)
|
||||
# (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса
|
||||
# конкурентов. См. _build_market_pulse docstring для полной логики агрегата.
|
||||
market_pulse: dict[str, Any] = _build_market_pulse(
|
||||
competitor_rows, int(_competitors_total_true), velocity_data
|
||||
)
|
||||
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
|
||||
# ЖК с objective_corpus_room_month данными (non-null by construction).
|
||||
_avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
|
||||
market_pulse: dict[str, Any] = {
|
||||
"avg_velocity_m2": _avg_velocity,
|
||||
"market_avg_price_per_m2": _market_avg_price,
|
||||
"competitors_total": _competitors_total,
|
||||
"competitors_with_price": _competitors_priced,
|
||||
"coverage_pct": _coverage_pct,
|
||||
"top_sellers": _top_sellers_list,
|
||||
}
|
||||
|
||||
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
|
||||
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается
|
||||
|
|
@ -3533,6 +3885,40 @@ 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), а не на загрязнённой
|
||||
|
|
@ -3660,6 +4046,8 @@ 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),
|
||||
|
|
@ -3692,6 +4080,10 @@ 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,
|
||||
|
|
@ -3931,6 +4323,10 @@ 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).
|
||||
|
|
@ -3947,10 +4343,160 @@ 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,13 +120,18 @@ class Settings(BaseSettings):
|
|||
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
|
||||
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
|
||||
#
|
||||
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch()
|
||||
# ТОЛЬКО для KN-sweep BrowserSession (другие скраперы — nspd/catalog —
|
||||
# продолжают использовать модульный дефолт _BROWSER_CONCURRENCY=8 без
|
||||
# изменений). 2 — эмпирически безопасный потолок против volume-бана.
|
||||
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch().
|
||||
# Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2
|
||||
# (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat /
|
||||
# catalog-object scrapers) тоже переиспользуют эти же значения — они бьют
|
||||
# по тому же /сервисы/* path family, что и вызвало WAF hard-ban 2026-05-24
|
||||
# (#2443). nspd и прочие скраперы вне этого path family продолжают
|
||||
# использовать модульный дефолт _BROWSER_CONCURRENCY=8 без изменений.
|
||||
# 2 — эмпирически безопасный потолок против volume-бана.
|
||||
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
|
||||
scrape_kn_browser_concurrency: int = 2
|
||||
# Окно случайной паузы (мс) между запросами KN-sweep. Шире дефолта
|
||||
# Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog-
|
||||
# flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта
|
||||
# (600–1500), чтобы размазать запросы во времени и не триггерить rate-ban.
|
||||
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
|
||||
# ширим до 1200–3000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
|
||||
|
|
@ -204,6 +209,13 @@ class Settings(BaseSettings):
|
|||
# Default rate-limit для nspd_lite fetcher (мс между запросами).
|
||||
nspd_lite_rate_ms: int = 600
|
||||
|
||||
# Каталог хранения собранных PDF-отчётов ПТИЦА (эпик #2259 PR-D). Worker пишет
|
||||
# PDF сюда, backend читает файл для /report/download. На проде — общий writable
|
||||
# bind-mount `./reports:/app/reports` у сервисов backend+worker (docker-compose.prod.yml),
|
||||
# иначе backend не увидит файл, записанный воркером. Для локальной разработки —
|
||||
# относительный путь ОК (тест мокает render_full_report_pdf, файл не пишется).
|
||||
reports_dir: str = "/app/reports"
|
||||
|
||||
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
|
||||
# На проде монтируется bind-mount-ом docker-compose:
|
||||
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
||||
|
|
@ -346,5 +358,18 @@ class Settings(BaseSettings):
|
|||
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
|
||||
llm_max_retries: int = 2
|
||||
|
||||
# ── DaData /clean/address геокод (objective_backfill geo-pass, #2177) ──────
|
||||
# Токен + секрет для DaData /clean/address (обогащение адреса → geo_lat/geo_lon).
|
||||
# На gendesign-проде уже заведены в окружении контейнера (проверено 2026-07-03).
|
||||
# Оба нужны для clean_address (в отличие от suggest, которому хватает токена).
|
||||
# None/пусто → dadata_client.clean_address graceful-возвращает None (geo-pass
|
||||
# тогда reject'ит всё «нет геокода», не падает). ENV: DADATA_API_TOKEN /
|
||||
# DADATA_API_SECRET. Литерала ключа в коде/тестах нет.
|
||||
dadata_api_token: str | None = None
|
||||
dadata_api_secret: str | None = None
|
||||
# Таймаут одного HTTP-вызова к DaData (сек). Воркер geo-pass не должен висеть
|
||||
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
|
||||
dadata_timeout_s: float = 8.0
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ class ConnectionPointsSummary(BaseModel):
|
|||
in_protection_zone: bool
|
||||
protection_zones_intersecting: int
|
||||
total_structures_in_radius: int
|
||||
# Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без
|
||||
# LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL).
|
||||
# ADDITIVE (default False) — старые клиенты не ломаются.
|
||||
protection_zones_truncated: bool = False
|
||||
structures_truncated: bool = False
|
||||
|
||||
|
||||
class ConnectionPointsResponse(BaseModel):
|
||||
|
|
@ -100,6 +105,10 @@ class UtilityInfrastructureSummary(BaseModel):
|
|||
nearest_distance_m: float | None
|
||||
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
|
||||
nearest_by_kind: dict[str, float | None]
|
||||
# Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же
|
||||
# ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200).
|
||||
# ADDITIVE (default False) — старые клиенты не ломаются.
|
||||
features_truncated: bool = False
|
||||
|
||||
|
||||
class UtilityInfrastructureResponse(BaseModel):
|
||||
|
|
@ -168,13 +177,38 @@ class GasGrsRow(BaseModel):
|
|||
|
||||
|
||||
class GasCapacityBlock(BaseModel):
|
||||
"""Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1."""
|
||||
"""Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.
|
||||
|
||||
+ счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС №960 АО
|
||||
«Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4.
|
||||
"""
|
||||
|
||||
# Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если
|
||||
# таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD).
|
||||
city_grs: list[GasGrsRow]
|
||||
# Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет).
|
||||
total_free_th_m3_h: float | None
|
||||
# Счётчики точек выхода последнего period_month (по ВСЕМ строкам среза, city-level —
|
||||
# см. _query_gas_outlet_counts). ADDITIVE (default 0): 0, если таблица
|
||||
# gas_grs_outlet_points ещё не наполнена (миграция 184 / monthly-sync). #2119 B2 PR-4.
|
||||
# ВНИМАНИЕ: свободная мощность точек выхода — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!).
|
||||
outlets_total: int = 0 # всего точек выхода в последнем срезе
|
||||
outlets_deficit: int = 0 # из них с дефицитом (free_capacity_mln_m3 < 0)
|
||||
outlets_needs_calc: int = 0 # из них «0.000*» — нужен гидравлический расчёт
|
||||
|
||||
|
||||
class GasOutletPoint(BaseModel):
|
||||
"""Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4."""
|
||||
|
||||
outlet_name: str # «Точка выхода» — юрлицо/объект/промплощадка (полный текст)
|
||||
consumer_type: str | None # котельная / ТЭЦ / площадка …
|
||||
# Свободная мощность точки выхода, млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Может быть
|
||||
# <0 (дефицит со знаком). NULL, когда в источнике «0.000*» (needs_calc=True).
|
||||
free_capacity_mln_m3: float | None
|
||||
needs_calc: bool # «0.000*» — свободная мощность не определена, нужен гидрорасчёт
|
||||
distance_m: float # расстояние от центроида участка, м
|
||||
lat: float | None
|
||||
lon: float | None
|
||||
|
||||
|
||||
class HeatSystemRow(BaseModel):
|
||||
|
|
@ -196,14 +230,74 @@ 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) ───────────────────────────────
|
||||
|
|
@ -510,6 +604,25 @@ class TopLayoutRow(BaseModel):
|
|||
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)
|
||||
|
||||
|
||||
class SupplyOnlyLayoutRow(BaseModel):
|
||||
"""Строка блока «по предложению» (#2177 шаг 3).
|
||||
|
||||
Структура текущего предложения (комнатность × площадь × цена) для комплексов,
|
||||
которые сматчены на objective project_name по ЯДРУ имени, но у которых НЕТ
|
||||
сделок (velocity). Источник — objective_lots (снапшот предложения). Это НЕ
|
||||
velocity-ранжир: темпа продаж здесь нет, только структура остатков в продаже.
|
||||
"""
|
||||
|
||||
project_name: str
|
||||
room_bucket: str # {studio,1,2,3,4+} — вокабуляр velocity-стороны
|
||||
area_bin: str
|
||||
lots_count: int
|
||||
median_price_m2: float | None # NULL если цены не прошли price-sanity
|
||||
median_area_m2: float | None
|
||||
# Маркер источника: всегда 'objective_lots' — честная семантика «без темпа продаж».
|
||||
source: str = "objective_lots"
|
||||
|
||||
|
||||
class LayoutTzMixRow(BaseModel):
|
||||
"""Строка рекомендации unit-mix для ТЗ."""
|
||||
|
||||
|
|
@ -535,10 +648,28 @@ class LayoutTzRecommendation(BaseModel):
|
|||
|
||||
|
||||
class LayoutDataQuality(BaseModel):
|
||||
"""Метаданные качества данных (coverage)."""
|
||||
"""Метаданные качества данных (coverage).
|
||||
|
||||
objects_with_velocity_data: int
|
||||
objects_total_in_radius: int
|
||||
Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
|
||||
domrf_kn_objects дробит один ЖК на несколько obj_id (разные очереди /
|
||||
дубли snapshot-строк / гео-кучки безымянных корпусов), из-за чего сырой
|
||||
знаменатель завышал число «конкурентов» (прод-кейс: 12 obj_id = 5 реальных
|
||||
комплексов → «2 из 12» вместо честных «2 из 5»). Поля objects_* теперь
|
||||
считают комплексы; raw_objects_total — старый счёт obj_id для прозрачности.
|
||||
"""
|
||||
|
||||
objects_with_velocity_data: int # число комплексов с velocity-данными
|
||||
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
|
||||
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
|
||||
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
||||
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
||||
# прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в
|
||||
# радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping.
|
||||
groups_matched_by_core: int = 0
|
||||
# Issue #2177 шаг 3: сколько комплексов попало в блок «по предложению» —
|
||||
# сматчены по ядру на objective project_name, БЕЗ velocity, но с лотами в
|
||||
# продаже (objective_lots). Аддитивно, не пересекается с velocity-покрытием.
|
||||
groups_with_supply_only: int = 0
|
||||
velocity_coverage_pct: float
|
||||
confidence: Literal["high", "medium", "low"]
|
||||
|
||||
|
|
@ -549,6 +680,10 @@ class BestLayoutsResponse(BaseModel):
|
|||
top_layouts: list[TopLayoutRow]
|
||||
recommendation_for_tz: LayoutTzRecommendation
|
||||
data_quality: LayoutDataQuality
|
||||
# Issue #2177 шаг 3: fallback-блок «по предложению без темпа продаж» —
|
||||
# структура текущего предложения для сматченных по ядру комплексов БЕЗ velocity.
|
||||
# Аддитивно; НЕ смешивается с top_layouts (там velocity-ранжир). Пусто по умолчанию.
|
||||
supply_only_layouts: list[SupplyOnlyLayoutRow] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Analyze endpoint market price (#33) ──────────────────────────────────────
|
||||
|
|
@ -778,6 +913,7 @@ 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,6 +504,15 @@ def developer_history(
|
|||
|
||||
|
||||
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
|
||||
# #2464 cluster E: без дедупа каждый ЖК возвращался ×N раз (одна строка на
|
||||
# snapshot_date, ретенции нет) — портфель девелопера раздувался в разы (прод-замер
|
||||
# ~7.6-9.0× per-developer). DISTINCT ON (obj_id) + snapshot_date DESC NULLS LAST —
|
||||
# latest-snapshot-per-obj_id (тот же паттерн, что cmp_rows/latest_obj CTE в
|
||||
# recommend_mix ниже в этом файле). dev_id — стабильный идентификатор
|
||||
# (не меняется между снапшотами одного ЖК) → фильтруется ДО DISTINCT ON, как
|
||||
# region_cd/district_name в сиблингах. Итоговый ORDER BY ready_dt вынесен во внешний
|
||||
# SELECT: DISTINCT ON требует, чтобы его собственный ORDER BY начинался с ключа
|
||||
# дедупа (obj_id, snapshot_date), не с ready_dt.
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -511,8 +520,15 @@ def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
|
|||
SELECT obj_id, comm_name, addr, region_cd, flat_count,
|
||||
square_living, ready_dt, obj_class, escrow,
|
||||
problem_flag, latitude, longitude, is_ekb
|
||||
FROM domrf_kn_objects
|
||||
WHERE dev_id = :dev
|
||||
FROM (
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, comm_name, addr, region_cd, flat_count,
|
||||
square_living, ready_dt, obj_class, escrow,
|
||||
problem_flag, latitude, longitude, is_ekb
|
||||
FROM domrf_kn_objects
|
||||
WHERE dev_id = :dev
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
) latest
|
||||
ORDER BY ready_dt DESC NULLS LAST
|
||||
"""
|
||||
),
|
||||
|
|
@ -1797,12 +1813,38 @@ def _active_competitors_count(
|
|||
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
|
||||
|
||||
def _q(where_extras: str, params: dict[str, Any]) -> int:
|
||||
# #2464 cluster E: domrf_kn_objects хранит МНОЖЕСТВО snapshot_date на obj_id
|
||||
# (UNIQUE(obj_id, snapshot_date), ретенции нет) — прод-замер 4090 строк / 482
|
||||
# distinct obj_id для site_status='Строящиеся' (8.49× инфляция). Наивный
|
||||
# COUNT(*) считал каждый исторический снапшот отдельно. Fix: DISTINCT ON
|
||||
# (obj_id) + snapshot_date DESC NULLS LAST даёт latest-snapshot-per-obj_id
|
||||
# (сиблинг-паттерн: _L3_FUTURE_SQL #1212 в site_finder/supply_layers.py).
|
||||
#
|
||||
# ВСЕ волатильные предикаты применяются ПОСЛЕ DISTINCT ON (во внешнем WHERE),
|
||||
# а не внутри CTE — иначе DISTINCT ON вернул бы «последний снапшот, ПРОШЕДШИЙ
|
||||
# фильтр», а не истинно последний снапшот объекта. Прод-замер per-obj_id
|
||||
# variance across snapshots (authoritative, coordinator 2026-07-08):
|
||||
# dev_id=0, region_cd=0 (СТАБИЛЬНЫ) · district_name=1, obj_class=29,
|
||||
# site_status=11 (ВОЛАТИЛЬНЫ).
|
||||
# → В CTE остаётся ТОЛЬКО region_cd (стабилен + ограничивает стоимость дедупа
|
||||
# как partition-scope). {where_extras} (district_name/obj_class) и
|
||||
# site_status='Строящиеся' — во внешнем WHERE, на истинно-последней строке.
|
||||
# Пример бага, который это чинит: ЖК сменил класс Комфорт→Бизнес; при
|
||||
# фильтре obj_class внутри CTE взяли бы старый Комфорт-снапшот и посчитали
|
||||
# его активным Комфортом, хотя его актуальный класс уже Бизнес.
|
||||
n = db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM domrf_kn_objects
|
||||
WHERE region_cd = :rc
|
||||
AND site_status = 'Строящиеся'
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, site_status, district_name,
|
||||
obj_class, obj_class_fallback
|
||||
FROM domrf_kn_objects
|
||||
WHERE region_cd = :rc
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
)
|
||||
SELECT COUNT(*) FROM latest
|
||||
WHERE site_status = 'Строящиеся'
|
||||
{where_extras}
|
||||
"""
|
||||
),
|
||||
|
|
@ -2233,14 +2275,28 @@ def _competitors_two_dim(
|
|||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
WITH active AS (
|
||||
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name
|
||||
WITH latest AS (
|
||||
-- #2464 cluster E: сначала истинно-последний снапшот на obj_id,
|
||||
-- ТОЛЬКО по стабильному region_cd (variance=0). Волатильные
|
||||
-- предикаты (site_status=11, district_name=1, obj_class=29 per
|
||||
-- прод-замер) — НЕ внутри DISTINCT ON, иначе взяли бы «последний
|
||||
-- снапшот, прошедший фильтр», а не истинно последний (ЖК,
|
||||
-- сменивший класс/район/статус, засчитался бы по устаревшему
|
||||
-- снапшоту). Зеркалит _q() в _active_competitors_count выше.
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, latitude, longitude, district_name,
|
||||
site_status, obj_class, obj_class_fallback
|
||||
FROM domrf_kn_objects
|
||||
WHERE region_cd = :rc
|
||||
AND site_status = 'Строящиеся'
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
),
|
||||
active AS (
|
||||
-- Волатильные фильтры на истинно-последней строке.
|
||||
SELECT obj_id, latitude, longitude, district_name
|
||||
FROM latest
|
||||
WHERE site_status = 'Строящиеся'
|
||||
AND district_name = :dn
|
||||
{class_filter}
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
),
|
||||
centroid AS (
|
||||
SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.analysis_runs.repository import latest_run_for
|
||||
from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION, latest_run_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
|
|||
# схеме явный и устойчивый.
|
||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||
|
||||
# Максимум названий ЗОУИТ-зон в курируемом срезе (§19: ограничиваем объём, чтобы наружу
|
||||
# не ушёл длинный «хвост» — сводки хватает списка из первых нескольких зон).
|
||||
_MAX_ZOUIT_NAMES = 15
|
||||
|
||||
|
||||
def get_report_for_chat(
|
||||
db: Session,
|
||||
|
|
@ -57,3 +61,116 @@ 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,9 +14,14 @@ 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,10 +3,11 @@
|
|||
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_risks` / `get_scenarios` / `get_parcel_info`). Без параметров — каждый отдаёт
|
||||
фиксированную секцию(и) отчёта (модель не управляет вычислениями, только запрашивает
|
||||
данные).
|
||||
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
|
||||
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
|
||||
НИКАКОЙ движковой математики — только срез готового dict'а.
|
||||
|
|
@ -73,6 +74,16 @@ 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 берут имена/маппинг отсюда.
|
||||
|
||||
|
|
@ -84,6 +95,7 @@ _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).
|
||||
|
|
@ -103,8 +115,11 @@ _TOOL_DESCRIPTIONS: dict[str, str] = {
|
|||
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
|
||||
"и уровень/факторы уверенности отчёта."
|
||||
),
|
||||
"get_scenarios": (
|
||||
"Сценарии развития: разброс по консервативному / базовому / агрессивному."
|
||||
"get_scenarios": ("Сценарии развития: разброс по консервативному / базовому / агрессивному."),
|
||||
"get_parcel_info": (
|
||||
"Паспорт участка и градостроительный регламент: адрес, площадь, категория земель, "
|
||||
"ВРИ, территориальная зона ПЗЗ (код и название), лимиты застройки, "
|
||||
"ЗОУИТ-обременения."
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +143,7 @@ def _spec(name: str) -> dict[str, Any]:
|
|||
|
||||
|
||||
def tool_specs() -> list[dict[str, Any]]:
|
||||
"""Все 5 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
|
||||
"""Все 6 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
|
||||
return [_spec(name) for name in _TOOLS]
|
||||
|
||||
|
||||
|
|
@ -161,6 +176,7 @@ __all__ = [
|
|||
"execute_tool",
|
||||
"get_exec_summary",
|
||||
"get_forecast",
|
||||
"get_parcel_info",
|
||||
"get_product_recommendation",
|
||||
"get_risks",
|
||||
"get_scenarios",
|
||||
|
|
|
|||
195
backend/app/services/dadata_client.py
Normal file
195
backend/app/services/dadata_client.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Минимальный синхронный DaData-клиент — /clean/address → (lat, lon).
|
||||
|
||||
Нужен ТОЛЬКО для geo-pass в objective_backfill (#2177): адрес objective-проекта
|
||||
→ координаты для haversine-сверки с domrf-точкой. Не тащим tradein-модуль
|
||||
(`tradein-mvp/.../dadata.py`) импортом — это другое приложение с другим settings.
|
||||
|
||||
Клиент СИНХРОННЫЙ (httpx.Client): geo-pass гоняется в CLI / sync-Session, без
|
||||
event-loop. Авторизация /clean/address требует ОБА: token + secret (в отличие от
|
||||
/suggest, которому хватает токена). Оба берутся из settings (env DADATA_API_TOKEN /
|
||||
DADATA_API_SECRET).
|
||||
|
||||
Возвращает None (не raise) на любой сбой — нет credentials, пустой/короткий адрес,
|
||||
сетевая/HTTP-ошибка, 429 quota, невалидный JSON, адрес не распознан. Вызывающая
|
||||
сторона (geo-pass) трактует None как «нет геокода» → reject-кандидат, проход не
|
||||
падает.
|
||||
|
||||
Прод-инцидент 2026-07-03: у токена может быть ОТКЛЮЧЕНА фича CLEAN («Feature
|
||||
'CLEAN' disabled for token» — стандартизация платная, баланс исчерпан), при этом
|
||||
БЕСПЛАТНЫЙ suggestions API (10k/день, только Token без X-Secret) работает и тоже
|
||||
отдаёт geo_lat/geo_lon. Поэтому: cleaner → при 401/403 однократный фолбэк на
|
||||
suggest/address (первая саджеста). Точность саджесты чуть ниже клинера — для
|
||||
наших целей (точка на карте + bbox-гейт) достаточно.
|
||||
|
||||
Docs: https://dadata.ru/api/clean/address/ · https://dadata.ru/api/suggest/address/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address"
|
||||
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||
|
||||
|
||||
def _credentials() -> tuple[str, str] | None:
|
||||
"""(token, secret) если ОБА заданы непусто, иначе None (graceful disable)."""
|
||||
token = (settings.dadata_api_token or "").strip()
|
||||
secret = (settings.dadata_api_secret or "").strip()
|
||||
if not token or not secret:
|
||||
return None
|
||||
return token, secret
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> float | None:
|
||||
"""DaData отдаёт geo_lat/geo_lon строкой — coerce в float безопасно."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def clean_address(address: str) -> tuple[float, float] | None:
|
||||
"""Геокодировать один адрес через DaData /clean/address → (lat, lon).
|
||||
|
||||
Args:
|
||||
address: свободный текст адреса (objective_lots.address).
|
||||
|
||||
Returns:
|
||||
(geo_lat, geo_lon) как float-пара, либо None если:
|
||||
- credentials не заданы в ENV (graceful disable);
|
||||
- адрес пустой / короче 3 символов;
|
||||
- сетевая / HTTP-ошибка (timeout, 5xx, 429 quota, 401/403 auth, 4xx);
|
||||
- невалидный JSON / пустой массив / не dict;
|
||||
- DaData не вернул geo_lat/geo_lon (адрес не геокодирован).
|
||||
"""
|
||||
if not address or len(address.strip()) < 3:
|
||||
return None
|
||||
|
||||
creds = _credentials()
|
||||
if creds is None:
|
||||
logger.debug("dadata_client: credentials не заданы — skip геокод")
|
||||
return None
|
||||
|
||||
token, secret = creds
|
||||
headers = {
|
||||
"Authorization": f"Token {token}",
|
||||
"X-Secret": secret,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
body = [address.strip()]
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=settings.dadata_timeout_s) as client:
|
||||
response = client.post(DADATA_CLEAN_URL, headers=headers, json=body)
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
logger.warning("dadata_client: network error для %r: %s", address[:60], exc)
|
||||
return None
|
||||
except Exception as exc:
|
||||
# defensive: любой прочий httpx/клиент-сбой → None, geo-pass reject'ит,
|
||||
# НЕ роняем весь проход. Логируем на warning (не глотаем молча).
|
||||
logger.warning("dadata_client: unexpected client error для %r: %s", address[:60], exc)
|
||||
return None
|
||||
|
||||
status = response.status_code
|
||||
if status == 429:
|
||||
logger.warning("dadata_client: HTTP 429 — quota exceeded")
|
||||
return None
|
||||
if status in (401, 403):
|
||||
# Фича CLEAN у токена может быть отключена (баланс стандартизации) —
|
||||
# фолбэк на бесплатный suggestions (тот же token, X-Secret не нужен).
|
||||
logger.warning("dadata_client: cleaner HTTP %d — фолбэк на suggest/address", status)
|
||||
return _suggest_geocode(address.strip(), token)
|
||||
if status >= 400:
|
||||
logger.warning("dadata_client: HTTP %d для %r", status, address[:60])
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.warning("dadata_client: невалидный JSON: %s", exc)
|
||||
return None
|
||||
|
||||
if not isinstance(payload, list) or not payload:
|
||||
logger.info("dadata_client: пустой ответ для %r", address[:60])
|
||||
return None
|
||||
|
||||
item = payload[0]
|
||||
if not isinstance(item, dict):
|
||||
logger.warning("dadata_client: первый элемент не dict (%s)", type(item).__name__)
|
||||
return None
|
||||
|
||||
lat = _coerce_float(item.get("geo_lat"))
|
||||
lon = _coerce_float(item.get("geo_lon"))
|
||||
if lat is None or lon is None:
|
||||
logger.info(
|
||||
"dadata_client: нет координат (qc_geo=%s) для %r",
|
||||
item.get("qc_geo"),
|
||||
address[:60],
|
||||
)
|
||||
return None
|
||||
|
||||
return (lat, lon)
|
||||
|
||||
|
||||
def _suggest_geocode(address: str, token: str) -> tuple[float, float] | None:
|
||||
"""Фолбэк-геокод через БЕСПЛАТНЫЙ suggest/address (первая саджеста).
|
||||
|
||||
Возвращает (lat, lon) из suggestions[0].data.geo_lat/geo_lon либо None.
|
||||
Та же graceful-семантика, что у clean_address.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Token {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
try:
|
||||
with httpx.Client(timeout=settings.dadata_timeout_s) as client:
|
||||
response = client.post(
|
||||
DADATA_SUGGEST_URL,
|
||||
headers=headers,
|
||||
json={"query": address, "count": 1},
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
logger.warning("dadata_client: suggest network error для %r: %s", address[:60], exc)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("dadata_client: suggest unexpected error для %r: %s", address[:60], exc)
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("dadata_client: suggest HTTP %d для %r", response.status_code, address[:60])
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.warning("dadata_client: suggest невалидный JSON: %s", exc)
|
||||
return None
|
||||
|
||||
suggestions = payload.get("suggestions") if isinstance(payload, dict) else None
|
||||
if not suggestions or not isinstance(suggestions[0], dict):
|
||||
logger.info("dadata_client: suggest пусто для %r", address[:60])
|
||||
return None
|
||||
|
||||
data = suggestions[0].get("data") or {}
|
||||
lat = _coerce_float(data.get("geo_lat"))
|
||||
lon = _coerce_float(data.get("geo_lon"))
|
||||
if lat is None or lon is None:
|
||||
logger.info(
|
||||
"dadata_client: suggest без координат (qc_geo=%s) для %r",
|
||||
data.get("qc_geo"),
|
||||
address[:60],
|
||||
)
|
||||
return None
|
||||
return (lat, lon)
|
||||
220
backend/app/services/etl/mapping_review.py
Normal file
220
backend/app/services/etl/mapping_review.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Review-инструмент авто-маппингов objective_complex_mapping (feat/mapping-review-page).
|
||||
|
||||
Авто-проходы (auto_core_dev_v5 / auto_core_geo_v6 / fuzzy_* / …) пишут строки
|
||||
с is_reviewed=false. Здесь — чтение этих строк С ОБОГАЩЕНИЕМ для глаз ревьюера,
|
||||
подтверждение (approve → is_reviewed=true) и отклонение (reject → DELETE строки,
|
||||
чтобы отклонённый маппинг не травил mv_layout_velocity §4.2).
|
||||
|
||||
Обогащение:
|
||||
• domrf comm_name / dev_name — LATEST snapshot по domrf_obj_id
|
||||
(snapshot_date DESC — как в objective_backfill._DOMRF_UNMAPPED_SQL).
|
||||
• objective developers — агрегат objective_lots.developer, SCOPED по
|
||||
(project_name = objective_complex_name). Урок 3.8с: агрегат по project_name,
|
||||
НЕ глобальный — иначе список застройщиков одного проекта протекает в чужой.
|
||||
|
||||
Чтения — parametrized `text(...)` + CAST(:x AS type) (backend.md psycopg3).
|
||||
DELETE (reject) — только по PK с RETURNING для полного лога удаляемой строки.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── SQL: строки mapping + обогащение domrf latest snapshot + objective devs ────
|
||||
# LEFT JOIN LATERAL к domrf_kn_objects: DISTINCT-ON-эквивалент через ORDER BY
|
||||
# snapshot_date DESC LIMIT 1 (latest snapshot по obj_id). objective devs —
|
||||
# коррелированный агрегат по project_name (scoped, урок 3.8с).
|
||||
# Сорт: is_reviewed asc (unreviewed сверху), match_score asc NULLS FIRST
|
||||
# (самые сомнительные / без скора сверху), id для стабильности.
|
||||
_LIST_SQL = text("""
|
||||
SELECT
|
||||
m.id,
|
||||
m.objective_complex_name,
|
||||
m.objective_project_id,
|
||||
m.objective_group,
|
||||
m.domrf_obj_id,
|
||||
m.match_method,
|
||||
m.match_score,
|
||||
m.is_reviewed,
|
||||
m.note,
|
||||
m.created_at,
|
||||
d.comm_name AS domrf_comm_name,
|
||||
d.dev_name AS domrf_dev_name,
|
||||
odev.developers AS objective_developers
|
||||
FROM objective_complex_mapping m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT o.comm_name, o.dev_name
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.obj_id = m.domrf_obj_id
|
||||
ORDER BY o.snapshot_date DESC NULLS LAST
|
||||
LIMIT 1
|
||||
) d ON m.domrf_obj_id IS NOT NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers
|
||||
FROM objective_lots ol
|
||||
WHERE ol.project_name = m.objective_complex_name
|
||||
AND ol.developer IS NOT NULL
|
||||
) odev ON TRUE
|
||||
WHERE (CAST(:only_unreviewed AS boolean) = FALSE OR m.is_reviewed = FALSE)
|
||||
ORDER BY m.is_reviewed ASC, m.match_score ASC NULLS FIRST, m.id ASC
|
||||
LIMIT CAST(:limit AS int) OFFSET CAST(:offset AS int)
|
||||
""")
|
||||
|
||||
_COUNT_SQL = text("""
|
||||
SELECT COUNT(*) AS total
|
||||
FROM objective_complex_mapping m
|
||||
WHERE (CAST(:only_unreviewed AS boolean) = FALSE OR m.is_reviewed = FALSE)
|
||||
""")
|
||||
|
||||
# ── SQL: approve — is_reviewed=true + append note. RETURNING для 404-детекта ───
|
||||
# NULLIF/concat: пустой/NULL note → просто «approved <date>»; иначе append через
|
||||
# перевод строки. Идемпотентно относительно is_reviewed (повторный approve OK).
|
||||
_APPROVE_SQL = text("""
|
||||
UPDATE objective_complex_mapping
|
||||
SET is_reviewed = TRUE,
|
||||
note = CASE
|
||||
WHEN note IS NULL OR note = '' THEN CAST(:approve_note AS text)
|
||||
ELSE note || E'\\n' || CAST(:approve_note AS text)
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
RETURNING id, objective_complex_name, domrf_obj_id,
|
||||
match_method, match_score, is_reviewed, note
|
||||
""")
|
||||
|
||||
# ── SQL: reject — DELETE по PK, RETURNING всей строки для лога (травит §4.2) ───
|
||||
_DELETE_SQL = text("""
|
||||
DELETE FROM objective_complex_mapping
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
RETURNING id, objective_complex_name, objective_project_id, objective_group,
|
||||
domrf_obj_id, match_method, match_score, is_reviewed, note,
|
||||
created_at
|
||||
""")
|
||||
|
||||
|
||||
def _serialize_row(row: Any) -> dict[str, Any]:
|
||||
"""Сериализовать строку list-запроса в loose dict для API (паттерн admin_etl)."""
|
||||
score = row.match_score
|
||||
created = row.created_at
|
||||
return {
|
||||
"id": row.id,
|
||||
"objective_complex_name": row.objective_complex_name,
|
||||
"objective_project_id": row.objective_project_id,
|
||||
"objective_group": row.objective_group,
|
||||
"domrf_obj_id": row.domrf_obj_id,
|
||||
"match_method": row.match_method,
|
||||
"match_score": float(score) if score is not None else None,
|
||||
"is_reviewed": bool(row.is_reviewed),
|
||||
"note": row.note,
|
||||
"created_at": created.isoformat() if created else None,
|
||||
# Обогащение для глаз ревьюера:
|
||||
"domrf_comm_name": row.domrf_comm_name,
|
||||
"domrf_dev_name": row.domrf_dev_name,
|
||||
"objective_developers": list(row.objective_developers or []),
|
||||
}
|
||||
|
||||
|
||||
def list_mapping_review(
|
||||
db: Session,
|
||||
*,
|
||||
only_unreviewed: bool = True,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Список строк objective_complex_mapping + обогащение + total count.
|
||||
|
||||
Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху), id.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
only_unreviewed: True (default) — только is_reviewed=false.
|
||||
limit: страница (строк).
|
||||
offset: смещение страницы.
|
||||
|
||||
Returns:
|
||||
dict: rows (list обогащённых строк), total (int, до limit/offset),
|
||||
limit, offset, only_unreviewed.
|
||||
"""
|
||||
rows = db.execute(
|
||||
_LIST_SQL,
|
||||
{"only_unreviewed": only_unreviewed, "limit": limit, "offset": offset},
|
||||
).all()
|
||||
total = db.execute(_COUNT_SQL, {"only_unreviewed": only_unreviewed}).scalar_one()
|
||||
|
||||
return {
|
||||
"rows": [_serialize_row(r) for r in rows],
|
||||
"total": int(total),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"only_unreviewed": only_unreviewed,
|
||||
}
|
||||
|
||||
|
||||
def approve_mapping(db: Session, mapping_id: int) -> dict[str, Any] | None:
|
||||
"""Подтвердить маппинг: is_reviewed=true + append «approved <date>» в note.
|
||||
|
||||
Returns:
|
||||
dict обновлённой строки, либо None если id не найден (→ 404 в роутере).
|
||||
"""
|
||||
approve_note = f"approved {date.today().isoformat()}"
|
||||
row = db.execute(_APPROVE_SQL, {"id": mapping_id, "approve_note": approve_note}).first()
|
||||
if row is None:
|
||||
db.rollback()
|
||||
return None
|
||||
db.commit()
|
||||
logger.info(
|
||||
"mapping-review approve id=%s name=%r method=%s → is_reviewed=true",
|
||||
row.id,
|
||||
row.objective_complex_name,
|
||||
row.match_method,
|
||||
)
|
||||
score = row.match_score
|
||||
return {
|
||||
"id": row.id,
|
||||
"objective_complex_name": row.objective_complex_name,
|
||||
"domrf_obj_id": row.domrf_obj_id,
|
||||
"match_method": row.match_method,
|
||||
"match_score": float(score) if score is not None else None,
|
||||
"is_reviewed": bool(row.is_reviewed),
|
||||
"note": row.note,
|
||||
}
|
||||
|
||||
|
||||
def reject_mapping(db: Session, mapping_id: int) -> dict[str, Any] | None:
|
||||
"""Отклонить маппинг: DELETE строки (травит mv_layout_velocity §4.2 иначе).
|
||||
|
||||
Перед удалением полная строка логируется через logger.info (в note писать
|
||||
некуда — строки не будет). DELETE только по PK с RETURNING.
|
||||
|
||||
Returns:
|
||||
dict удалённой строки (для эха в ответе), либо None если id не найден.
|
||||
"""
|
||||
row = db.execute(_DELETE_SQL, {"id": mapping_id}).first()
|
||||
if row is None:
|
||||
db.rollback()
|
||||
return None
|
||||
|
||||
created = row.created_at
|
||||
score = row.match_score
|
||||
deleted: dict[str, Any] = {
|
||||
"id": row.id,
|
||||
"objective_complex_name": row.objective_complex_name,
|
||||
"objective_project_id": row.objective_project_id,
|
||||
"objective_group": row.objective_group,
|
||||
"domrf_obj_id": row.domrf_obj_id,
|
||||
"match_method": row.match_method,
|
||||
"match_score": float(score) if score is not None else None,
|
||||
"is_reviewed": bool(row.is_reviewed),
|
||||
"note": row.note,
|
||||
"created_at": created.isoformat() if created else None,
|
||||
}
|
||||
logger.info("mapping-review reject DELETE id=%s row=%s", row.id, deleted)
|
||||
db.commit()
|
||||
return deleted
|
||||
|
|
@ -12,20 +12,40 @@ Schema facts (confirmed via pg MCP):
|
|||
колонки match_method + match_score + is_reviewed поддерживают audit trail.
|
||||
|
||||
match_method history:
|
||||
'fuzzy' — legacy Anton SQLite import (avg score 0.98, 127 rows)
|
||||
'fuzzy_trgm' — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
|
||||
'fuzzy_v2' — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
|
||||
'manual' — ручная корректура
|
||||
'fuzzy' — legacy Anton SQLite import (avg score 0.98, 127 rows)
|
||||
'fuzzy_trgm' — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
|
||||
'fuzzy_v2' — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
|
||||
'auto_core_dev_v5' — core-name normalize (#2198 normalize_complex_name) обеих сторон +
|
||||
dev-confirm. trgm по СЫРЫМ именам давал ~300 маппингов, 1253 obj_id
|
||||
остались (низкая similarity: «Жилой комплекс "Малевич" (ЖК "Малевич")»
|
||||
vs «Малевич»). Ядро-нормализация схлопывает обёртки → exact-core match;
|
||||
dev-подтверждение (пересечение токенов / подстрока) отсеивает
|
||||
одноимённые чужие проекты (Объектив = только ЕКБ). #2177 шаг 2.
|
||||
'auto_core_geo_v6' — гео-разрешение остатка core-pass (tier_b / ambiguous) через
|
||||
DaData-геокод. Адрес objective-проекта (mode objective_lots.
|
||||
address per project) → clean_address → (lat, lon); haversine
|
||||
до domrf-точки (domrf_kn_objects.latitude/longitude, latest
|
||||
snapshot). tier_b: дистанция ≤ порога → confirm (гео вместо
|
||||
dev). ambiguous: геокодим ВСЕХ кандидатов ядра, confirm только
|
||||
если РОВНО ОДИН в радиусе. score 0.80, is_reviewed=false. #2177.
|
||||
'manual' — ручная корректура
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.dadata_client import clean_address
|
||||
from app.services.site_finder.best_layouts import normalize_complex_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Порог для auto-insert (высокая уверенность)
|
||||
|
|
@ -234,3 +254,919 @@ def trigger_mv_refresh(db: Session) -> int:
|
|||
from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity
|
||||
|
||||
return refresh_layout_velocity(db, concurrently=True)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Core-name pass (#2177 шаг 2) — auto_core_dev_v5
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# objective_group у всех существующих строк mapping = 'Екатеринбург'
|
||||
# (Объектив покрывает только ЕКБ). Новые строки пишем с тем же значением.
|
||||
OBJECTIVE_GROUP = "Екатеринбург"
|
||||
|
||||
CORE_MATCH_METHOD = "auto_core_dev_v5"
|
||||
CORE_MATCH_SCORE = 0.90
|
||||
CORE_MATCH_NOTE = "#2177 step2: exact-core + dev-confirm"
|
||||
|
||||
# Префиксы/обёртки орг-формы застройщика, срезаемые перед сравнением.
|
||||
# Порядок применения не важен (все режутся из целого норм-имени).
|
||||
_DEV_JUNK_RE = re.compile(
|
||||
r"""
|
||||
\b(?:
|
||||
ооо | оао | пао | зао | ао | ип
|
||||
| сз # СЗ = специализированный застройщик (аббрев.)
|
||||
| специализированн\w* # «специализированный застройщик»
|
||||
| застройщик\w*
|
||||
| девелопмент\w*
|
||||
| групп\w* # групп / группа
|
||||
| компани\w*
|
||||
| инвест\w* # инвест / инвестиции (частый шум)
|
||||
| строительн\w*
|
||||
)\b
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _norm_dev(raw: str | None) -> str:
|
||||
"""Нормализовать имя застройщика до сравниваемого ядра.
|
||||
|
||||
lower → срез кавычек/скобок → срез орг-формы (ООО/АО/ПАО/ЗАО/«СЗ»/
|
||||
«специализированный застройщик»/«девелопмент»/«групп(а)») → схлопывание
|
||||
пробелов. Пустой / None → ''.
|
||||
|
||||
Namespaced на _NAME_-уровне: возвращаем очищенную строку, дальнейшая
|
||||
классификация (пересечение токенов / подстрока) — в find_core_matches.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
name = raw.strip().lower()
|
||||
# убрать кавычки-ёлочки/лапки/скобки — оставить только содержательные символы
|
||||
name = re.sub(r"[«»\"'`()\[\]]", " ", name)
|
||||
name = _DEV_JUNK_RE.sub(" ", name)
|
||||
# мусорные разделители → пробел (точки в «Астон.» и т.п. не трогаем на dev-стороне:
|
||||
# dev-имена обычно без них, но на всякий нормализуем распространённые)
|
||||
name = re.sub(r"[.,;/\\]", " ", name)
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
return name
|
||||
|
||||
|
||||
def _dev_confirms(domrf_dev: str, objective_devs: set[str]) -> bool:
|
||||
"""True если domrf-застройщик подтверждает связь с objective-проектом.
|
||||
|
||||
У одного objective project_name бывает НЕСКОЛЬКО developers (SPV / очереди) —
|
||||
подтверждение по ЛЮБОМУ из них. Критерий (любой):
|
||||
• пересечение токенов норм-имён (общий содержательный токен), ИЛИ
|
||||
• одно норм-имя — подстрока другого (в любую сторону), НО только когда
|
||||
короткая сторона ≥ 4 символов: «дом»/«юг»/«ак» как подстрока чужого
|
||||
длинного имени («Домострой», «Югстройинвест») — ложное подтверждение,
|
||||
а именно dev-confirm — последняя защита от generic-core коллизий.
|
||||
|
||||
Пустые имена не подтверждают (нет сигнала).
|
||||
"""
|
||||
d = _norm_dev(domrf_dev)
|
||||
if not d:
|
||||
return False
|
||||
d_tokens = {t for t in d.split() if len(t) >= 3}
|
||||
for raw_odev in objective_devs:
|
||||
o = _norm_dev(raw_odev)
|
||||
if not o:
|
||||
continue
|
||||
if min(len(d), len(o)) >= 4 and (d in o or o in d):
|
||||
return True
|
||||
o_tokens = {t for t in o.split() if len(t) >= 3}
|
||||
if d_tokens & o_tokens:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoreMatch:
|
||||
"""Один core-match domrf-объекта к objective-проекту."""
|
||||
|
||||
domrf_obj_id: int
|
||||
domrf_comm_name: str
|
||||
domrf_dev_name: str | None
|
||||
core: str
|
||||
objective_project_name: str
|
||||
objective_project_id: int | None
|
||||
objective_developers: list[str]
|
||||
dev_confirmed: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoreMatchReport:
|
||||
"""Итог классификации core-pass.
|
||||
|
||||
tier_a — ровно 1 objective-кандидат И dev-подтверждён → пишем в БД.
|
||||
tier_b — ровно 1 кандидат, dev НЕ подтверждён → кандидат на DaData-гео
|
||||
проход, в БД НЕ пишем (только отчёт).
|
||||
ambiguous — >1 objective-кандидатов по core → в отчёт, разрешение вручную/гео.
|
||||
skipped_taken — objective_complex_name уже занят в mapping (UNIQUE-констрейнт;
|
||||
его domrf-группа уже покрыта — дубли не нужны).
|
||||
"""
|
||||
|
||||
tier_a: list[CoreMatch] = field(default_factory=list)
|
||||
tier_b: list[CoreMatch] = field(default_factory=list)
|
||||
ambiguous: list[CoreMatch] = field(default_factory=list)
|
||||
skipped_taken: list[CoreMatch] = field(default_factory=list)
|
||||
|
||||
def counts(self) -> dict[str, int]:
|
||||
return {
|
||||
"tier_a": len(self.tier_a),
|
||||
"tier_b": len(self.tier_b),
|
||||
"ambiguous": len(self.ambiguous),
|
||||
"skipped_taken": len(self.skipped_taken),
|
||||
}
|
||||
|
||||
|
||||
# ── SQL: несопоставленные domrf ЕКБ-объекты, latest snapshot per obj_id ───────
|
||||
# ЛОВУШКА is_ekb (прод-проверка 2026-07-03): флаг проставляется per-snapshot и на
|
||||
# свежих снапшотах ПУСТ (все latest-строки is_ekb=false — см. также комментарий
|
||||
# в 155_objective_mapping_trgm_backfill.sql). `WHERE is_ekb = true` внутри
|
||||
# DISTINCT ON отдал бы устаревшие имена со старых снапшотов (или 0 строк после
|
||||
# DISTINCT). Поэтому: ЕКБ-принадлежность = «obj_id имел is_ekb=true хоть на одном
|
||||
# снапшоте» (1285 живых объектов), а строку берём с LATEST снапшота без флага.
|
||||
_DOMRF_UNMAPPED_SQL = text("""
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id, o.comm_name, o.dev_name
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.comm_name IS NOT NULL
|
||||
AND o.obj_id IN (
|
||||
SELECT DISTINCT e.obj_id FROM domrf_kn_objects e WHERE e.is_ekb = true
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM objective_complex_mapping cm
|
||||
WHERE cm.domrf_obj_id = o.obj_id
|
||||
)
|
||||
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
|
||||
""")
|
||||
|
||||
# ── SQL: objective-проекты — distinct project_name + агрегаты developer/id ────
|
||||
# objective_project_id: в objective_lots есть колонка objective_project_id
|
||||
# (68_schema_objective.sql). Берём MAX (стабилен per project_name; NULL если нет).
|
||||
_OBJECTIVE_PROJECTS_SQL = text("""
|
||||
SELECT
|
||||
ol.project_name,
|
||||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers,
|
||||
MAX(ol.objective_project_id) AS objective_project_id
|
||||
FROM objective_lots ol
|
||||
WHERE ol.project_name IS NOT NULL
|
||||
AND ol.project_name <> ''
|
||||
GROUP BY ol.project_name
|
||||
""")
|
||||
|
||||
# ── SQL: уже занятые objective_complex_name в текущей группе ──────────────────
|
||||
_TAKEN_NAMES_SQL = text("""
|
||||
SELECT objective_complex_name
|
||||
FROM objective_complex_mapping
|
||||
WHERE objective_group = CAST(:group AS text)
|
||||
""")
|
||||
|
||||
|
||||
def find_core_matches(db: Session) -> CoreMatchReport:
|
||||
"""Сопоставить несопоставленные domrf ЕКБ-объекты с objective-проектами по ядру.
|
||||
|
||||
Обе стороны нормализуются `normalize_complex_name` (#2198): обёртки вроде
|
||||
«Жилой комплекс "Малевич" (ЖК "Малевич")» схлопываются до ядра «малевич»,
|
||||
что даёт exact-core match там, где trgm по сырым именам не дотягивал.
|
||||
|
||||
Классификация (см. CoreMatchReport):
|
||||
tier_a — 1 objective-кандидат + dev-подтверждён (пишем);
|
||||
tier_b — 1 кандидат, dev НЕ подтверждён (DaData-гео проход, не пишем);
|
||||
ambiguous — >1 кандидата (ручное/гео разрешение);
|
||||
skipped — objective_complex_name уже в mapping (UNIQUE-констрейнт).
|
||||
|
||||
Реального обращения к БД в тестах нет — db.execute мокается по паттерну файла.
|
||||
"""
|
||||
report = CoreMatchReport()
|
||||
|
||||
# objective-сторона: core → [(project_name, {devs}, project_id), ...]
|
||||
objective_by_core: dict[str, list[tuple[str, set[str], int | None]]] = {}
|
||||
for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all():
|
||||
project_name = str(row[0])
|
||||
developers = {str(d) for d in (row[1] or []) if d}
|
||||
project_id = int(row[2]) if row[2] is not None else None
|
||||
core = normalize_complex_name(project_name)
|
||||
if not core:
|
||||
continue
|
||||
objective_by_core.setdefault(core, []).append((project_name, developers, project_id))
|
||||
|
||||
# уже занятые objective_complex_name (skip — их группа уже покрыта)
|
||||
taken_names: set[str] = {
|
||||
str(r[0]) for r in db.execute(_TAKEN_NAMES_SQL, {"group": OBJECTIVE_GROUP}).all()
|
||||
}
|
||||
|
||||
# domrf-сторона: несопоставленные ЕКБ, latest snapshot per obj_id
|
||||
for row in db.execute(_DOMRF_UNMAPPED_SQL).all():
|
||||
obj_id = int(row[0])
|
||||
comm_name = str(row[1])
|
||||
dev_name = str(row[2]) if row[2] is not None else None
|
||||
core = normalize_complex_name(comm_name)
|
||||
if not core:
|
||||
continue
|
||||
|
||||
candidates = objective_by_core.get(core)
|
||||
if not candidates:
|
||||
continue # нет objective-проекта с таким ядром — не наш случай
|
||||
|
||||
if len(candidates) > 1:
|
||||
# >1 objective project_name делят одно ядро → ambiguous (не выбираем вслепую)
|
||||
proj_name, devs, proj_id = candidates[0]
|
||||
report.ambiguous.append(
|
||||
_build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
|
||||
)
|
||||
continue
|
||||
|
||||
proj_name, devs, proj_id = candidates[0]
|
||||
|
||||
if proj_name in taken_names:
|
||||
report.skipped_taken.append(
|
||||
_build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
|
||||
)
|
||||
continue
|
||||
|
||||
match = _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
|
||||
if match.dev_confirmed:
|
||||
report.tier_a.append(match)
|
||||
else:
|
||||
report.tier_b.append(match)
|
||||
|
||||
logger.info("find_core_matches: %s", report.counts())
|
||||
return report
|
||||
|
||||
|
||||
def _build_core_match(
|
||||
obj_id: int,
|
||||
comm_name: str,
|
||||
dev_name: str | None,
|
||||
core: str,
|
||||
proj_name: str,
|
||||
proj_id: int | None,
|
||||
devs: set[str],
|
||||
) -> CoreMatch:
|
||||
"""Сконструировать CoreMatch + посчитать dev_confirmed."""
|
||||
return CoreMatch(
|
||||
domrf_obj_id=obj_id,
|
||||
domrf_comm_name=comm_name,
|
||||
domrf_dev_name=dev_name,
|
||||
core=core,
|
||||
objective_project_name=proj_name,
|
||||
objective_project_id=proj_id,
|
||||
objective_developers=sorted(devs),
|
||||
dev_confirmed=_dev_confirms(dev_name or "", devs),
|
||||
)
|
||||
|
||||
|
||||
def apply_core_matches(
|
||||
db: Session,
|
||||
report: CoreMatchReport,
|
||||
*,
|
||||
dry_run: bool = True,
|
||||
) -> dict[str, int]:
|
||||
"""Вставить ТОЛЬКО tier_a в objective_complex_mapping (auto_core_dev_v5).
|
||||
|
||||
tier_b / ambiguous / skipped НЕ пишутся (см. CoreMatchReport). Каждая вставка
|
||||
в per-row SAVEPOINT (backend.md) с ON CONFLICT (objective_complex_name,
|
||||
objective_group) DO NOTHING — гонка/дубль не роняет весь проход.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
report: результат find_core_matches().
|
||||
dry_run: True (default) — только логирует counts + первые 30 пар, БЕЗ insert.
|
||||
|
||||
Returns:
|
||||
dict: inserted, conflict_skipped, error_skipped, tier_a_total.
|
||||
"""
|
||||
counts = report.counts()
|
||||
logger.info(
|
||||
"apply_core_matches (dry_run=%s): tier_a=%d tier_b=%d ambiguous=%d skipped_taken=%d",
|
||||
dry_run,
|
||||
counts["tier_a"],
|
||||
counts["tier_b"],
|
||||
counts["ambiguous"],
|
||||
counts["skipped_taken"],
|
||||
)
|
||||
|
||||
for m in report.tier_a[:30]:
|
||||
logger.info(
|
||||
" tier_a: obj_id=%s core=%r domrf=%r → objective=%r (dev %r ~ %r)",
|
||||
m.domrf_obj_id,
|
||||
m.core,
|
||||
m.domrf_comm_name,
|
||||
m.objective_project_name,
|
||||
m.domrf_dev_name,
|
||||
m.objective_developers,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"inserted": 0,
|
||||
"conflict_skipped": 0,
|
||||
"error_skipped": 0,
|
||||
"tier_a_total": counts["tier_a"],
|
||||
}
|
||||
|
||||
inserted = 0
|
||||
conflict_skipped = 0
|
||||
error_skipped = 0
|
||||
for m in report.tier_a:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
result = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO objective_complex_mapping
|
||||
(objective_complex_name, objective_project_id, domrf_obj_id,
|
||||
objective_group, match_method, match_score, is_reviewed, note)
|
||||
VALUES (
|
||||
CAST(:name AS text),
|
||||
CAST(:project_id AS bigint),
|
||||
CAST(:obj_id AS bigint),
|
||||
CAST(:group AS text),
|
||||
CAST(:method AS text),
|
||||
CAST(:score AS numeric),
|
||||
CAST(:reviewed AS boolean),
|
||||
CAST(:note AS text)
|
||||
)
|
||||
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"name": m.objective_project_name,
|
||||
"project_id": m.objective_project_id,
|
||||
"obj_id": m.domrf_obj_id,
|
||||
"group": OBJECTIVE_GROUP,
|
||||
"method": CORE_MATCH_METHOD,
|
||||
"score": CORE_MATCH_SCORE,
|
||||
"reviewed": False,
|
||||
"note": CORE_MATCH_NOTE,
|
||||
},
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
inserted += 1
|
||||
else:
|
||||
conflict_skipped += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"core insert failed для %s ↔ %s: %s",
|
||||
m.domrf_comm_name,
|
||||
m.objective_project_name,
|
||||
e,
|
||||
)
|
||||
error_skipped += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"apply_core_matches done: inserted=%d conflict_skipped=%d error_skipped=%d",
|
||||
inserted,
|
||||
conflict_skipped,
|
||||
error_skipped,
|
||||
)
|
||||
return {
|
||||
"inserted": inserted,
|
||||
"conflict_skipped": conflict_skipped,
|
||||
"error_skipped": error_skipped,
|
||||
"tier_a_total": counts["tier_a"],
|
||||
}
|
||||
|
||||
|
||||
def run_core_pass(*, apply: bool = False) -> dict[str, int]:
|
||||
"""CLI-обёртка core-pass: открыть Session, найти матчи, (опц.) применить.
|
||||
|
||||
Дефолт — dry-run. Реальный insert только при apply=True.
|
||||
"""
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
report = find_core_matches(db)
|
||||
return apply_core_matches(db, report, dry_run=not apply)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Geo-pass (#2177) — auto_core_geo_v6
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Разрешает ОСТАТОК core-pass (tier_b + ambiguous) географией: адрес objective-
|
||||
# проекта → DaData-геокод → haversine до domrf-точки. tier_b подтверждается
|
||||
# близостью (гео вместо dev-confirm); ambiguous — только если РОВНО ОДИН из
|
||||
# кандидатов ядра в радиусе (иначе остаётся ambiguous).
|
||||
|
||||
GEO_MATCH_METHOD = "auto_core_geo_v6"
|
||||
GEO_MATCH_SCORE = 0.80
|
||||
GEO_MAX_DISTANCE_M = 500.0
|
||||
# Бережный потолок вызовов DaData за один прогон (МАКС-тариф, но не жжём квоту
|
||||
# на прогон-цикле). Превышение → стоп с warning, недобранные кандидаты reject'ятся
|
||||
# как «без геокода».
|
||||
GEO_DADATA_CALL_LIMIT = 200
|
||||
_EARTH_RADIUS_M = 6_371_000.0
|
||||
|
||||
|
||||
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Расстояние (метры) между двумя точками по формуле гаверсинуса."""
|
||||
p1 = math.radians(lat1)
|
||||
p2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
|
||||
return 2 * _EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a)))
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoMatch:
|
||||
"""Один подтверждённый гео-матч domrf-объекта к objective-проекту.
|
||||
|
||||
origin — 'tier_b' | 'ambiguous' (из какого класса core-pass пришёл).
|
||||
distance_m — гаверсинус адрес-проекта ↔ domrf-точки (метры).
|
||||
"""
|
||||
|
||||
domrf_obj_id: int
|
||||
domrf_comm_name: str
|
||||
core: str
|
||||
objective_project_name: str
|
||||
objective_project_id: int | None
|
||||
address: str
|
||||
distance_m: float
|
||||
origin: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoReject:
|
||||
"""Отклонённый гео-кандидат (для отчёта). reason: 'no_address' |
|
||||
'no_geocode' | 'too_far' | 'ambiguous_multi' | 'call_limit'.
|
||||
|
||||
distance_m None когда дистанцию посчитать не удалось (нет адреса/геокода/
|
||||
координат domrf).
|
||||
"""
|
||||
|
||||
domrf_obj_id: int
|
||||
domrf_comm_name: str
|
||||
objective_project_name: str
|
||||
origin: str
|
||||
reason: str
|
||||
distance_m: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoMatchReport:
|
||||
"""Итог geo-pass.
|
||||
|
||||
confirmed — подтверждённые гео-матчи (пишутся в apply_geo_matches).
|
||||
rejected — кандидаты без адреса/геокода/слишком далёкие/оставшиеся ambiguous.
|
||||
dadata_calls — сколько раз реально дёрнули DaData (для контроля квоты).
|
||||
call_limit_hit — True если упёрлись в GEO_DADATA_CALL_LIMIT (часть кандидатов
|
||||
не обработана — см. rejected c reason='call_limit').
|
||||
"""
|
||||
|
||||
confirmed: list[GeoMatch] = field(default_factory=list)
|
||||
rejected: list[GeoReject] = field(default_factory=list)
|
||||
dadata_calls: int = 0
|
||||
call_limit_hit: bool = False
|
||||
|
||||
def counts(self) -> dict[str, int]:
|
||||
return {
|
||||
"confirmed": len(self.confirmed),
|
||||
"rejected": len(self.rejected),
|
||||
"dadata_calls": self.dadata_calls,
|
||||
}
|
||||
|
||||
|
||||
# ── SQL: mode (самый частый непустой) адрес objective_lots per project_name ────
|
||||
# Адресов на проект много (по корпусам) — берём mode: GROUP BY (project_name,
|
||||
# address) → COUNT DESC, DISTINCT ON (project_name) отбирает верхнюю строку.
|
||||
# NULLIF(trim,'') отсекает пустые адреса до агрегации.
|
||||
_OBJECTIVE_ADDRESS_SQL = text("""
|
||||
SELECT DISTINCT ON (project_name)
|
||||
project_name, address
|
||||
FROM (
|
||||
SELECT
|
||||
ol.project_name AS project_name,
|
||||
NULLIF(trim(ol.address), '') AS address,
|
||||
COUNT(*) AS cnt
|
||||
FROM objective_lots ol
|
||||
WHERE ol.project_name IS NOT NULL
|
||||
AND ol.project_name <> ''
|
||||
AND NULLIF(trim(ol.address), '') IS NOT NULL
|
||||
GROUP BY ol.project_name, NULLIF(trim(ol.address), '')
|
||||
) agg
|
||||
ORDER BY project_name, cnt DESC, address
|
||||
""")
|
||||
|
||||
# ── SQL: latest-snapshot координаты domrf per obj_id (для набора obj_id) ───────
|
||||
# DISTINCT ON (obj_id) + snapshot_date DESC — берём свежую точку. Фильтр по
|
||||
# конкретным obj_id (кандидаты geo-pass), NULL-координаты отбрасываются.
|
||||
_DOMRF_COORDS_SQL = text("""
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id, o.latitude, o.longitude
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.obj_id = ANY(:obj_ids)
|
||||
AND o.latitude IS NOT NULL
|
||||
AND o.longitude IS NOT NULL
|
||||
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
|
||||
""")
|
||||
|
||||
|
||||
def _load_project_addresses(db: Session) -> dict[str, str]:
|
||||
"""project_name → mode-адрес (самый частый непустой) из objective_lots."""
|
||||
out: dict[str, str] = {}
|
||||
for row in db.execute(_OBJECTIVE_ADDRESS_SQL).all():
|
||||
name = str(row[0])
|
||||
addr = row[1]
|
||||
if addr:
|
||||
out[name] = str(addr)
|
||||
return out
|
||||
|
||||
|
||||
def _load_domrf_coords(db: Session, obj_ids: list[int]) -> dict[int, tuple[float, float]]:
|
||||
"""obj_id → (lat, lon) latest snapshot для заданного набора obj_id."""
|
||||
if not obj_ids:
|
||||
return {}
|
||||
out: dict[int, tuple[float, float]] = {}
|
||||
for row in db.execute(_DOMRF_COORDS_SQL, {"obj_ids": obj_ids}).all():
|
||||
lat = float(row[1])
|
||||
lon = float(row[2])
|
||||
out[int(row[0])] = (lat, lon)
|
||||
return out
|
||||
|
||||
|
||||
def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M) -> GeoMatchReport:
|
||||
"""Разрешить остаток core-pass (tier_b + ambiguous) через DaData-геокод.
|
||||
|
||||
Алгоритм:
|
||||
1. find_core_matches → берём report.tier_b и report.ambiguous.
|
||||
2. Для каждого кандидата: адрес проекта (mode objective_lots.address) →
|
||||
clean_address → (lat, lon); haversine до domrf-точки.
|
||||
• tier_b: дистанция ≤ max_distance_m → confirm (гео вместо dev);
|
||||
• ambiguous: геокодим ВСЕХ кандидатов ядра; confirm только если РОВНО
|
||||
ОДИН в радиусе (иначе остаётся ambiguous);
|
||||
• нет адреса / DaData null / дистанция больше → reject c дистанцией.
|
||||
3. Кэш геокода в памяти прогона (адреса повторяются). Лимит вызовов DaData
|
||||
≤ GEO_DADATA_CALL_LIMIT — при превышении стоп с warning, остаток reject.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
max_distance_m: радиус подтверждения (метры), default 500.
|
||||
|
||||
Returns:
|
||||
GeoMatchReport (confirmed / rejected / dadata_calls / call_limit_hit).
|
||||
|
||||
Реального обращения к БД / DaData в тестах нет — оба мокаются по паттерну файла.
|
||||
"""
|
||||
core_report = find_core_matches(db)
|
||||
report = GeoMatchReport()
|
||||
|
||||
tier_b = core_report.tier_b
|
||||
ambiguous = core_report.ambiguous
|
||||
if not tier_b and not ambiguous:
|
||||
logger.info("find_geo_matches: нет tier_b/ambiguous кандидатов — nothing to do")
|
||||
return report
|
||||
|
||||
addresses = _load_project_addresses(db)
|
||||
|
||||
# ── ambiguous: нужны ВСЕ objective-кандидаты ядра, не только первый ────────
|
||||
# core-report.ambiguous хранит по одному CoreMatch на domrf-obj_id (первый
|
||||
# кандидат). Восстанавливаем полный список project_name'ов ядра из objective-
|
||||
# стороны, чтобы геокодить всех и выбрать единственного в радиусе.
|
||||
objective_by_core = _load_objective_by_core(db)
|
||||
|
||||
# obj_id'ы, для которых нужны координаты domrf (tier_b + ambiguous)
|
||||
needed_obj_ids = [m.domrf_obj_id for m in tier_b] + [m.domrf_obj_id for m in ambiguous]
|
||||
domrf_coords = _load_domrf_coords(db, needed_obj_ids)
|
||||
|
||||
# Кэш геокода в памяти прогона: address → (lat, lon) | None (None = «пробовали,
|
||||
# DaData не дал»; кэшируем и его, чтобы не жечь квоту на повтор одного адреса).
|
||||
geo_cache: dict[str, tuple[float, float] | None] = {}
|
||||
|
||||
def _geocode(addr: str) -> tuple[float, float] | None:
|
||||
"""Геокод с кэшем + лимитом вызовов. None при отсутствии/лимите.
|
||||
|
||||
Адреса objective_lots идут БЕЗ города («ул. Гидрострой, д. 2а») —
|
||||
DaData на таком отдаёт qc_geo=5 (нет координат): прод-прогон 2026-07-03
|
||||
дал confirmed=0/45 именно из-за этого. Объектив покрывает только ЕКБ →
|
||||
безопасно префиксуем городом, если его нет в строке.
|
||||
"""
|
||||
if addr in geo_cache:
|
||||
return geo_cache[addr]
|
||||
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT:
|
||||
report.call_limit_hit = True
|
||||
return None
|
||||
query = addr if "екатеринбург" in addr.lower() else f"г Екатеринбург, {addr}"
|
||||
report.dadata_calls += 1
|
||||
coords = clean_address(query)
|
||||
geo_cache[addr] = coords
|
||||
return coords
|
||||
|
||||
# ── tier_b: 1 кандидат, подтверждаем близостью ────────────────────────────
|
||||
for m in tier_b:
|
||||
domrf_pt = domrf_coords.get(m.domrf_obj_id)
|
||||
addr = addresses.get(m.objective_project_name)
|
||||
if not addr:
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "no_address"))
|
||||
continue
|
||||
if domrf_pt is None:
|
||||
# у domrf нет координат — дистанцию не посчитать; не жжём квоту на геокод
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "no_geocode"))
|
||||
continue
|
||||
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache:
|
||||
report.call_limit_hit = True
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "call_limit"))
|
||||
continue
|
||||
coords = _geocode(addr)
|
||||
if coords is None:
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "no_geocode"))
|
||||
continue
|
||||
dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1])
|
||||
if dist <= max_distance_m:
|
||||
report.confirmed.append(_geo_confirm(m, addr, dist, "tier_b"))
|
||||
else:
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "too_far", dist))
|
||||
|
||||
# ── ambiguous: геокодим всех кандидатов ядра, confirm если РОВНО ОДИН близко ─
|
||||
for m in ambiguous:
|
||||
domrf_pt = domrf_coords.get(m.domrf_obj_id)
|
||||
if domrf_pt is None:
|
||||
report.rejected.append(_geo_reject(m, "ambiguous", "no_geocode"))
|
||||
continue
|
||||
candidates = objective_by_core.get(m.core, [])
|
||||
in_radius: list[tuple[str, int | None, str, float]] = []
|
||||
any_geocoded = False
|
||||
geocoded_count = 0
|
||||
for proj_name, _devs, proj_id in candidates:
|
||||
addr = addresses.get(proj_name)
|
||||
if not addr:
|
||||
continue
|
||||
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache:
|
||||
report.call_limit_hit = True
|
||||
continue
|
||||
coords = _geocode(addr)
|
||||
if coords is None:
|
||||
continue
|
||||
any_geocoded = True
|
||||
geocoded_count += 1
|
||||
dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1])
|
||||
if dist <= max_distance_m:
|
||||
in_radius.append((proj_name, proj_id, addr, dist))
|
||||
|
||||
# Ревью 2026-07-03: «ровно один в радиусе» доверяем ТОЛЬКО когда
|
||||
# геокодились ВСЕ кандидаты ядра. Иначе «один» — артефакт отсутствия
|
||||
# данных (сосед без адреса/геокода мог быть ближе) → слепой confirm
|
||||
# запрещён, остаётся честный reject partial_geocode.
|
||||
if len(in_radius) == 1 and geocoded_count < len(candidates):
|
||||
report.rejected.append(_geo_reject(m, "ambiguous", "partial_geocode", in_radius[0][3]))
|
||||
elif len(in_radius) == 1:
|
||||
proj_name, proj_id, addr, dist = in_radius[0]
|
||||
report.confirmed.append(
|
||||
GeoMatch(
|
||||
domrf_obj_id=m.domrf_obj_id,
|
||||
domrf_comm_name=m.domrf_comm_name,
|
||||
core=m.core,
|
||||
objective_project_name=proj_name,
|
||||
objective_project_id=proj_id,
|
||||
address=addr,
|
||||
distance_m=dist,
|
||||
origin="ambiguous",
|
||||
)
|
||||
)
|
||||
elif not any_geocoded:
|
||||
reason = "call_limit" if report.call_limit_hit else "no_geocode"
|
||||
report.rejected.append(_geo_reject(m, "ambiguous", reason))
|
||||
else:
|
||||
# 0 в радиусе, или >1 в радиусе → остаётся ambiguous
|
||||
nearest = min((d for *_, d in in_radius), default=None)
|
||||
report.rejected.append(_geo_reject(m, "ambiguous", "ambiguous_multi", nearest))
|
||||
|
||||
logger.info(
|
||||
"find_geo_matches: %s call_limit_hit=%s",
|
||||
report.counts(),
|
||||
report.call_limit_hit,
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def _load_objective_by_core(db: Session) -> dict[str, list[tuple[str, set[str], int | None]]]:
|
||||
"""core → [(project_name, {devs}, project_id), ...] (как в find_core_matches)."""
|
||||
out: dict[str, list[tuple[str, set[str], int | None]]] = {}
|
||||
for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all():
|
||||
project_name = str(row[0])
|
||||
developers = {str(d) for d in (row[1] or []) if d}
|
||||
project_id = int(row[2]) if row[2] is not None else None
|
||||
core = normalize_complex_name(project_name)
|
||||
if not core:
|
||||
continue
|
||||
out.setdefault(core, []).append((project_name, developers, project_id))
|
||||
return out
|
||||
|
||||
|
||||
def _geo_confirm(m: CoreMatch, address: str, distance_m: float, origin: str) -> GeoMatch:
|
||||
return GeoMatch(
|
||||
domrf_obj_id=m.domrf_obj_id,
|
||||
domrf_comm_name=m.domrf_comm_name,
|
||||
core=m.core,
|
||||
objective_project_name=m.objective_project_name,
|
||||
objective_project_id=m.objective_project_id,
|
||||
address=address,
|
||||
distance_m=distance_m,
|
||||
origin=origin,
|
||||
)
|
||||
|
||||
|
||||
def _geo_reject(
|
||||
m: CoreMatch, origin: str, reason: str, distance_m: float | None = None
|
||||
) -> GeoReject:
|
||||
return GeoReject(
|
||||
domrf_obj_id=m.domrf_obj_id,
|
||||
domrf_comm_name=m.domrf_comm_name,
|
||||
objective_project_name=m.objective_project_name,
|
||||
origin=origin,
|
||||
reason=reason,
|
||||
distance_m=distance_m,
|
||||
)
|
||||
|
||||
|
||||
def apply_geo_matches(
|
||||
db: Session,
|
||||
report: GeoMatchReport,
|
||||
*,
|
||||
dry_run: bool = True,
|
||||
) -> dict[str, int]:
|
||||
"""Вставить report.confirmed в objective_complex_mapping (auto_core_geo_v6).
|
||||
|
||||
Та же per-row SAVEPOINT + ON CONFLICT (objective_complex_name, objective_group)
|
||||
DO NOTHING механика, что в apply_core_matches. Каждая строка:
|
||||
match_method='auto_core_geo_v6', match_score=0.80, is_reviewed=false,
|
||||
note с дистанцией в метрах и origin (tier_b/ambiguous).
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
report: результат find_geo_matches().
|
||||
dry_run: True (default) — только counts + первые 30 пар с дистанциями,
|
||||
БЕЗ insert.
|
||||
|
||||
Returns:
|
||||
dict: inserted, conflict_skipped, error_skipped, confirmed_total.
|
||||
"""
|
||||
counts = report.counts()
|
||||
logger.info(
|
||||
"apply_geo_matches (dry_run=%s): confirmed=%d rejected=%d dadata_calls=%d",
|
||||
dry_run,
|
||||
counts["confirmed"],
|
||||
counts["rejected"],
|
||||
counts["dadata_calls"],
|
||||
)
|
||||
for gm in report.confirmed[:30]:
|
||||
logger.info(
|
||||
" confirm[%s]: obj_id=%s core=%r domrf=%r → objective=%r (%.0f м, addr=%r)",
|
||||
gm.origin,
|
||||
gm.domrf_obj_id,
|
||||
gm.core,
|
||||
gm.domrf_comm_name,
|
||||
gm.objective_project_name,
|
||||
gm.distance_m,
|
||||
gm.address[:60],
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"inserted": 0,
|
||||
"conflict_skipped": 0,
|
||||
"error_skipped": 0,
|
||||
"confirmed_total": counts["confirmed"],
|
||||
}
|
||||
|
||||
inserted = 0
|
||||
conflict_skipped = 0
|
||||
error_skipped = 0
|
||||
for gm in report.confirmed:
|
||||
note = f"#2177 geo-pass ({gm.origin}): {gm.distance_m:.0f} м to domrf point"
|
||||
try:
|
||||
with db.begin_nested():
|
||||
result = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO objective_complex_mapping
|
||||
(objective_complex_name, objective_project_id, domrf_obj_id,
|
||||
objective_group, match_method, match_score, is_reviewed, note)
|
||||
VALUES (
|
||||
CAST(:name AS text),
|
||||
CAST(:project_id AS bigint),
|
||||
CAST(:obj_id AS bigint),
|
||||
CAST(:group AS text),
|
||||
CAST(:method AS text),
|
||||
CAST(:score AS numeric),
|
||||
CAST(:reviewed AS boolean),
|
||||
CAST(:note AS text)
|
||||
)
|
||||
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"name": gm.objective_project_name,
|
||||
"project_id": gm.objective_project_id,
|
||||
"obj_id": gm.domrf_obj_id,
|
||||
"group": OBJECTIVE_GROUP,
|
||||
"method": GEO_MATCH_METHOD,
|
||||
"score": GEO_MATCH_SCORE,
|
||||
"reviewed": False,
|
||||
"note": note,
|
||||
},
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
inserted += 1
|
||||
else:
|
||||
conflict_skipped += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"geo insert failed для %s ↔ %s: %s",
|
||||
gm.domrf_comm_name,
|
||||
gm.objective_project_name,
|
||||
e,
|
||||
)
|
||||
error_skipped += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"apply_geo_matches done: inserted=%d conflict_skipped=%d error_skipped=%d",
|
||||
inserted,
|
||||
conflict_skipped,
|
||||
error_skipped,
|
||||
)
|
||||
return {
|
||||
"inserted": inserted,
|
||||
"conflict_skipped": conflict_skipped,
|
||||
"error_skipped": error_skipped,
|
||||
"confirmed_total": counts["confirmed"],
|
||||
}
|
||||
|
||||
|
||||
def run_geo_pass(
|
||||
*, apply: bool = False, max_distance_m: float = GEO_MAX_DISTANCE_M
|
||||
) -> dict[str, int]:
|
||||
"""CLI-обёртка geo-pass: открыть Session, найти гео-матчи, (опц.) применить.
|
||||
|
||||
Дефолт — dry-run. Реальный insert только при apply=True.
|
||||
"""
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
report = find_geo_matches(db, max_distance_m=max_distance_m)
|
||||
return apply_geo_matches(db, report, dry_run=not apply)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="objective_backfill passes (#2177): core-pass (auto_core_dev_v5), "
|
||||
"geo-pass (auto_core_geo_v6)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--core-pass",
|
||||
action="store_true",
|
||||
help="Запустить core-name + dev-confirm проход (auto_core_dev_v5).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geo-pass",
|
||||
action="store_true",
|
||||
help="Запустить DaData-гео проход по tier_b/ambiguous остатку core-pass "
|
||||
"(auto_core_geo_v6).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-distance-m",
|
||||
type=float,
|
||||
default=GEO_MAX_DISTANCE_M,
|
||||
help="Радиус подтверждения гео-матча в метрах (geo-pass; "
|
||||
f"default {GEO_MAX_DISTANCE_M:.0f}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Реально писать матчи в БД (по умолчанию — dry-run).",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
if not args.core_pass and not args.geo_pass:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
if args.core_pass:
|
||||
result = run_core_pass(apply=args.apply)
|
||||
logger.info("core-pass result: %s", result)
|
||||
|
||||
if args.geo_pass:
|
||||
geo_result = run_geo_pass(apply=args.apply, max_distance_m=args.max_distance_m)
|
||||
logger.info("geo-pass result: %s", geo_result)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
|
|
|
|||
1277
backend/app/services/exporters/full_report_docx.py
Normal file
1277
backend/app/services/exporters/full_report_docx.py
Normal file
File diff suppressed because it is too large
Load diff
1873
backend/app/services/exporters/full_report_html.py
Normal file
1873
backend/app/services/exporters/full_report_html.py
Normal file
File diff suppressed because it is too large
Load diff
494
backend/app/services/exporters/full_report_pdf.py
Normal file
494
backend/app/services/exporters/full_report_pdf.py
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
"""Оркестратор полного 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,15 +14,20 @@ from app.schemas.parcel import BestLayoutsResponse
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def render_layout_tz_pdf(
|
||||
def build_layout_tz_html(
|
||||
response: BestLayoutsResponse,
|
||||
*,
|
||||
cad_num: str,
|
||||
parcel_address: str | None = None,
|
||||
radius_km: float,
|
||||
time_window: str,
|
||||
) -> bytes:
|
||||
"""Render ТЗ PDF от best-layouts response.
|
||||
) -> str:
|
||||
"""Собрать HTML ТЗ из best-layouts response (PURE — без WeasyPrint/БД/сети).
|
||||
|
||||
Вынесено из :func:`render_layout_tz_pdf`, чтобы HTML-разметку (наличие блоков,
|
||||
RU-микрокопию, форматирование ₽/м²) можно было проверять юнит-тестами БЕЗ
|
||||
native GTK/Pango (WeasyPrint на macOS/Windows отсутствует). PDF-обёртка ниже
|
||||
просто рендерит этот HTML.
|
||||
|
||||
Args:
|
||||
response: BestLayoutsResponse от /best-layouts endpoint
|
||||
|
|
@ -32,7 +37,7 @@ def render_layout_tz_pdf(
|
|||
time_window: окно анализа (last_month/quarter/year)
|
||||
|
||||
Returns:
|
||||
PDF bytes (готово для StreamingResponse)
|
||||
HTML-строка документа (готова для WeasyPrint).
|
||||
"""
|
||||
today = dt.date.today().strftime("%d.%m.%Y")
|
||||
safe_cad = _html.escape(cad_num)
|
||||
|
|
@ -45,6 +50,18 @@ def render_layout_tz_pdf(
|
|||
return "<td>—</td>"
|
||||
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</td>"
|
||||
|
||||
def _price_m2_cell(val: float | None) -> str:
|
||||
"""Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful)."""
|
||||
if val is None:
|
||||
return "<td>—</td>"
|
||||
return f"<td>{val:,.0f}".replace(",", " ") + " ₽/м²</td>"
|
||||
|
||||
def _area_cell(val: float | None) -> str:
|
||||
"""Ячейка площади, м² (1 знак). None → «—» (graceful)."""
|
||||
if val is None:
|
||||
return "<td>—</td>"
|
||||
return f"<td>{val:.1f} м²</td>"
|
||||
|
||||
# Top layouts table rows
|
||||
top_rows = "".join(
|
||||
"<tr>"
|
||||
|
|
@ -70,6 +87,42 @@ def render_layout_tz_pdf(
|
|||
for m in response.recommendation_for_tz.mix
|
||||
)
|
||||
|
||||
# §4.2 «По предложению (без темпа продаж)» (#2177 шаг 3). Аддитивный fallback-блок:
|
||||
# структура текущего предложения (комплекс/тип/площадь/лотов/медиана ₽/м²) по
|
||||
# комплексам, сматченным по ядру имени, но БЕЗ velocity. Graceful — на старых
|
||||
# снапшотах поле отсутствует / пустой список (default []), тогда секцию не рисуем.
|
||||
supply_only = getattr(response, "supply_only_layouts", None) or []
|
||||
supply_only_rows = "".join(
|
||||
"<tr>"
|
||||
f"<td>{_html.escape(s.project_name)}</td>"
|
||||
f"<td>{_html.escape(s.room_bucket)}</td>"
|
||||
f"<td>{_html.escape(s.area_bin)}</td>"
|
||||
f"{_area_cell(s.median_area_m2)}"
|
||||
f"<td>{s.lots_count}</td>"
|
||||
f"{_price_m2_cell(s.median_price_m2)}"
|
||||
"</tr>"
|
||||
for s in supply_only
|
||||
)
|
||||
supply_only_section = (
|
||||
f"""
|
||||
<h2>По предложению (без темпа продаж)</h2>
|
||||
<p class="caveat">
|
||||
Комплексы сматчены по ядру имени на объявления Объектива, но по ним НЕТ сделок
|
||||
(темпа продаж) — ниже только структура остатков в продаже (objective_lots),
|
||||
БЕЗ ранжирования по скорости. Дополняет топ продаж выше, не заменяет его.
|
||||
</p>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Комплекс</th><th>Комнаты</th><th>Площадь</th>
|
||||
<th>Ср. площадь</th><th>Лотов в продаже</th><th>Медиана, ₽/м²</th>
|
||||
</tr></thead>
|
||||
<tbody>{supply_only_rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
if supply_only
|
||||
else ""
|
||||
)
|
||||
|
||||
rec = response.recommendation_for_tz
|
||||
safe_rationale = _html.escape(rec.rationale_text)
|
||||
weighted_price = (
|
||||
|
|
@ -95,6 +148,8 @@ def render_layout_tz_pdf(
|
|||
th {{ background: #f5f5f5; font-weight: bold; }}
|
||||
.rationale {{ background: #f8f8f8; padding: 10px; border-left: 3px solid #4a90e2;
|
||||
margin: 1em 0; }}
|
||||
.caveat {{ background: #fffbeb; padding: 8px 10px; border-left: 3px solid #c9a132;
|
||||
margin: 0.6em 0; color: #7a5c12; font-size: 9.5pt; }}
|
||||
.footer {{ margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd;
|
||||
color: #888; font-size: 9pt; }}
|
||||
.confidence-high {{ color: #2a8c2a; }}
|
||||
|
|
@ -133,11 +188,11 @@ def render_layout_tz_pdf(
|
|||
</tr></thead>
|
||||
<tbody>{top_rows}</tbody>
|
||||
</table>
|
||||
|
||||
{supply_only_section}
|
||||
<h2>Качество данных</h2>
|
||||
<p>
|
||||
Покрытие: {dq.objects_with_velocity_data} из
|
||||
{dq.objects_total_in_radius} ЖК с данными velocity
|
||||
{dq.objects_total_in_radius} комплексов с данными velocity
|
||||
({dq.velocity_coverage_pct:.1f}%)
|
||||
</p>
|
||||
<p>
|
||||
|
|
@ -154,6 +209,40 @@ def render_layout_tz_pdf(
|
|||
</body>
|
||||
</html>"""
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def render_layout_tz_pdf(
|
||||
response: BestLayoutsResponse,
|
||||
*,
|
||||
cad_num: str,
|
||||
parcel_address: str | None = None,
|
||||
radius_km: float,
|
||||
time_window: str,
|
||||
) -> bytes:
|
||||
"""Render ТЗ PDF от best-layouts response.
|
||||
|
||||
Тонкая обёртка над :func:`build_layout_tz_html` — собирает HTML (PURE) и
|
||||
рендерит его WeasyPrint'ом в PDF-байты.
|
||||
|
||||
Args:
|
||||
response: BestLayoutsResponse от /best-layouts endpoint
|
||||
cad_num: кадастровый номер участка
|
||||
parcel_address: optional human address (если known через geocoder)
|
||||
radius_km: радиус анализа конкурентов
|
||||
time_window: окно анализа (last_month/quarter/year)
|
||||
|
||||
Returns:
|
||||
PDF bytes (готово для StreamingResponse)
|
||||
"""
|
||||
html = build_layout_tz_html(
|
||||
response,
|
||||
cad_num=cad_num,
|
||||
parcel_address=parcel_address,
|
||||
radius_km=radius_km,
|
||||
time_window=time_window,
|
||||
)
|
||||
|
||||
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
|
||||
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
|
||||
from weasyprint import HTML
|
||||
|
|
|
|||
386
backend/app/services/exporters/report_maps.py
Normal file
386
backend/app/services/exporters/report_maps.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""Серверный статик-рендер карт для PDF-отчёта ПТИЦА (эпик #2259 PR-C).
|
||||
|
||||
Рендерит два вида PNG-карт БЕЗ браузера (headless matplotlib + geopandas + contextily):
|
||||
|
||||
• :func:`render_parcel_map_png` — карта участка (§1/§3): полигон участка (заливка +
|
||||
контур), поверх — риск-зоны НСПД / красные линии / ЗОУИТ-пересечения (если геометрии
|
||||
присутствуют в persist-payload) + конкуренты точками. Подложка — OSM-тайлы через
|
||||
contextily; auto-bbox = участок + буфер.
|
||||
• :func:`render_concept_footprint_png` — карта концепции (§7): контур участка + пятна
|
||||
застройки (footprint'ы корпусов из ``ConceptVariant.buildings_geojson``) с подписью
|
||||
этажности (``properties.floors``).
|
||||
|
||||
Плюс :func:`embed_map_png` — подстановка ``<img src="data:image/png;base64,…">`` вместо
|
||||
строки-плейсхолдера в готовом HTML (``png=None`` → плашка «карта недоступна»).
|
||||
|
||||
CRS-контракт: все входные геометрии — EPSG:4326 (GeoJSON ``geom_geojson`` /
|
||||
``geometry_geojson``, WKT ``geom_wkt`` в 4326, конкуренты lat/lng). Рисуем в Web Mercator
|
||||
(EPSG:3857), т.к. OSM-тайлы contextily идут в 3857 — иначе подложка не совпадёт со слоями.
|
||||
|
||||
GRACEFUL: нет ``geom_geojson`` → ``None`` (нечего рисовать). Тайлы OSM недоступны/таймаут →
|
||||
белый фон + слои, PNG всё равно возвращается (никогда не роняем экспорт из-за сети).
|
||||
Битые отдельные геометрии слоёв пропускаются с ``logger.warning``, не валят всю карту.
|
||||
|
||||
HEADLESS: ``matplotlib.use("Agg")`` вызывается на уровне модуля ДО импорта pyplot — импорт
|
||||
``report_maps`` не требует X11/display (запускается в Celery-воркере/Docker без GUI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import matplotlib
|
||||
|
||||
# Headless-бэкенд ДО импорта pyplot — модуль импортируется в Celery-воркере без display.
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.figure import Figure
|
||||
from shapely.geometry import Point, shape
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Константы рендера ──────────────────────────────────────────────────────────
|
||||
_WGS84 = "EPSG:4326"
|
||||
_WEB_MERCATOR = "EPSG:3857"
|
||||
|
||||
# Буфер вокруг участка при auto-bbox (доля от большей стороны bbox). Даёт «воздух»
|
||||
# по краям, чтобы контур участка не липнул к рамке и был виден контекст подложки.
|
||||
_BBOX_PAD_FRAC: float = 0.25
|
||||
|
||||
# Таймаут ожидания OSM-тайлов (сек). Тайлы тянутся синхронно внутри contextily —
|
||||
# оборачиваем в поток с bounded-таймаутом, чтобы медленный/недоступный tile-server
|
||||
# не подвешивал воркер. Превышение → graceful fallback на белый фон.
|
||||
_BASEMAP_TIMEOUT_S: float = 10.0
|
||||
|
||||
# Базовый физический размер фигуры (дюймы). Итоговый пиксель-размер = fig_inches * dpi,
|
||||
# ограничивается сверху ``max_px`` пересчётом dpi (см. ``_figure_to_png``).
|
||||
_FIG_INCHES: float = 6.4
|
||||
|
||||
# Палитра слоёв (edgecolor, facecolor, alpha) — консистентно с семантикой отчёта.
|
||||
_PARCEL_STYLE = {"edgecolor": "#1d4ed8", "facecolor": "#3b82f6", "alpha": 0.28, "linewidth": 1.8}
|
||||
_RISK_STYLE = {"edgecolor": "#b91c1c", "facecolor": "#ef4444", "alpha": 0.30, "linewidth": 1.0}
|
||||
_REDLINE_STYLE = {"color": "#dc2626", "linewidth": 1.6, "linestyle": "--"}
|
||||
_ZOUIT_STYLE = {"edgecolor": "#b45309", "facecolor": "#f59e0b", "alpha": 0.22, "linewidth": 1.0}
|
||||
_OPPORTUNITY_STYLE = {
|
||||
"edgecolor": "#15803d",
|
||||
"facecolor": "#22c55e",
|
||||
"alpha": 0.20,
|
||||
"linewidth": 0.9,
|
||||
}
|
||||
_COMPETITOR_STYLE = {"color": "#7c3aed", "marker": "o", "markersize": 5.0, "linestyle": "none"}
|
||||
_CONCEPT_FOOTPRINT_STYLE = {
|
||||
"edgecolor": "#0f766e",
|
||||
"facecolor": "#14b8a6",
|
||||
"alpha": 0.55,
|
||||
"linewidth": 1.2,
|
||||
}
|
||||
|
||||
_MAP_UNAVAILABLE_HTML = (
|
||||
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют '
|
||||
"в отчёте</div>"
|
||||
)
|
||||
|
||||
|
||||
# ── Парсинг входных геометрий (EPSG:4326) ──────────────────────────────────────
|
||||
def _geom_from_geojson(geojson: dict[str, Any] | None) -> BaseGeometry | None:
|
||||
"""GeoJSON-geometry dict → shapely-geометрия (EPSG:4326). Битый вход → None+warning."""
|
||||
if not isinstance(geojson, dict):
|
||||
return None
|
||||
try:
|
||||
geom = shape(geojson)
|
||||
except Exception as exc: # любой битый GeoJSON: пропустить слой, не валить всю карту
|
||||
logger.warning("report_maps: не разобрал GeoJSON-геометрию: %s", exc)
|
||||
return None
|
||||
if geom.is_empty:
|
||||
return None
|
||||
return geom
|
||||
|
||||
|
||||
def _geom_from_wkt(wkt: str | None) -> BaseGeometry | None:
|
||||
"""WKT-строка (EPSG:4326) → shapely-геометрия. Битый вход → None+warning."""
|
||||
if not wkt or not isinstance(wkt, str):
|
||||
return None
|
||||
try:
|
||||
from shapely import wkt as shapely_wkt
|
||||
|
||||
geom = shapely_wkt.loads(wkt)
|
||||
except Exception as exc: # битый WKT слоя: пропустить геометрию, не валить всю карту
|
||||
logger.warning("report_maps: не разобрал WKT-геометрию: %s", exc)
|
||||
return None
|
||||
if geom.is_empty:
|
||||
return None
|
||||
return geom
|
||||
|
||||
|
||||
def _to_mercator(geoms: list[BaseGeometry]) -> Any:
|
||||
"""Список shapely-геометрий (EPSG:4326) → GeoSeries в Web Mercator (EPSG:3857).
|
||||
|
||||
Импорт geopandas — локальный (тяжёлый), вызывается только когда есть что рисовать.
|
||||
"""
|
||||
import geopandas as gpd
|
||||
|
||||
series = gpd.GeoSeries(geoms, crs=_WGS84)
|
||||
return series.to_crs(_WEB_MERCATOR)
|
||||
|
||||
|
||||
# ── Basemap (OSM-тайлы) с bounded-таймаутом и graceful fallback ────────────────
|
||||
def _add_basemap(ax: Any) -> bool:
|
||||
"""Подложить OSM-тайлы под слои с таймаутом. True — легли, False — fallback (белый фон).
|
||||
|
||||
contextily тянет тайлы синхронно (сеть) — оборачиваем в поток с таймаутом, чтобы
|
||||
недоступный tile-сервер не подвешивал воркер. Любая ошибка/таймаут → белый фон,
|
||||
слои остаются видны, PNG всё равно рендерится.
|
||||
"""
|
||||
import contextily as cx
|
||||
|
||||
def _fetch() -> None:
|
||||
cx.add_basemap(ax, crs=_WEB_MERCATOR, source=cx.providers.OpenStreetMap.Mapnik)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S)
|
||||
return True
|
||||
except FuturesTimeoutError:
|
||||
logger.warning(
|
||||
"report_maps: OSM basemap timeout (%.0fs) — fallback белый фон", _BASEMAP_TIMEOUT_S
|
||||
)
|
||||
return False
|
||||
except Exception as exc: # тайлы недоступны: graceful fallback на белый фон, не валим экспорт
|
||||
logger.warning("report_maps: OSM basemap недоступен (%s) — fallback белый фон", exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── Общие хелперы фигуры ───────────────────────────────────────────────────────
|
||||
@contextmanager
|
||||
def _managed_figure() -> Iterator[tuple[Figure, Any]]:
|
||||
"""Контекст-менеджер фигуры: создать axes, ГАРАНТИРОВАННО закрыть в finally.
|
||||
|
||||
pyplot держит созданные фигуры в глобальном реестре (``plt.get_fignums()``) до
|
||||
явного ``plt.close``. В долгоживущем Celery-воркере любое исключение между
|
||||
созданием фигуры и ``savefig`` без close оставляло бы residual-окно → растущий
|
||||
RSS (утечка на 1 фигуру за каждый упавший рендер). ``finally`` закрывает фигуру
|
||||
на ЛЮБОМ пути выхода — успех, per-geometry-ошибка, неожиданный raise.
|
||||
"""
|
||||
fig = plt.figure(figsize=(_FIG_INCHES, _FIG_INCHES))
|
||||
try:
|
||||
ax = fig.add_subplot(1, 1, 1)
|
||||
ax.set_axis_off()
|
||||
ax.set_aspect("equal")
|
||||
yield fig, ax
|
||||
finally:
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _apply_bbox(ax: Any, parcel_merc: Any) -> None:
|
||||
"""Выставить границы axes по bbox участка + буфер (``_BBOX_PAD_FRAC``)."""
|
||||
minx, miny, maxx, maxy = parcel_merc.total_bounds
|
||||
span = max(maxx - minx, maxy - miny) or 1.0
|
||||
pad = span * _BBOX_PAD_FRAC
|
||||
ax.set_xlim(minx - pad, maxx + pad)
|
||||
ax.set_ylim(miny - pad, maxy + pad)
|
||||
|
||||
|
||||
def _figure_to_png(fig: Figure, max_px: int) -> bytes:
|
||||
"""Фигуру → PNG-bytes с dpi, подобранным так, чтобы длинная сторона ≤ ``max_px``.
|
||||
|
||||
Закрытие фигуры — НЕ здесь: им владеет ``_managed_figure`` (finally покрывает и
|
||||
путь, где ``savefig`` сам кинул исключение).
|
||||
"""
|
||||
dpi = max(72.0, min(300.0, float(max_px) / _FIG_INCHES))
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.05)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── Публичный API ──────────────────────────────────────────────────────────────
|
||||
def render_parcel_map_png(analyze_result: dict[str, Any], *, max_px: int = 1600) -> bytes | None:
|
||||
"""Карта участка + слои НСПД + конкуренты → PNG-bytes. Нет ``geom_geojson`` → None.
|
||||
|
||||
Args:
|
||||
analyze_result: persist-payload ``analysis_runs.result`` (schema analyze-1.0).
|
||||
Читаемые ключи с геометрией: ``geom_geojson`` (обязателен), ``nspd_risk_zones``,
|
||||
``nspd_red_lines``, ``nspd_opportunity_parcels`` (WKT ``geom_wkt``),
|
||||
``nspd_zouit_overlaps`` (``geometry_geojson``), ``competitors`` (``lat``/``lng``).
|
||||
max_px: верхняя граница длинной стороны PNG в пикселях.
|
||||
|
||||
Returns:
|
||||
PNG-bytes, либо None если геометрии участка нет (нечего рисовать).
|
||||
"""
|
||||
parcel = _geom_from_geojson(analyze_result.get("geom_geojson"))
|
||||
if parcel is None:
|
||||
return None
|
||||
|
||||
with _managed_figure() as (fig, ax):
|
||||
# Участок — базовый слой, задаёт bbox.
|
||||
parcel_merc = _to_mercator([parcel])
|
||||
parcel_merc.plot(ax=ax, **_PARCEL_STYLE)
|
||||
_apply_bbox(ax, parcel_merc)
|
||||
|
||||
# Риск-зоны (WKT).
|
||||
_plot_wkt_layer(ax, analyze_result.get("nspd_risk_zones"), _RISK_STYLE)
|
||||
# ЗОУИТ-пересечения (GeoJSON geometry_geojson).
|
||||
_plot_geojson_layer(ax, analyze_result.get("nspd_zouit_overlaps"), _ZOUIT_STYLE)
|
||||
# Opportunity-участки (WKT).
|
||||
_plot_wkt_layer(ax, analyze_result.get("nspd_opportunity_parcels"), _OPPORTUNITY_STYLE)
|
||||
# Красные линии (WKT, линии — без заливки: _REDLINE_STYLE несёт color, не facecolor).
|
||||
_plot_wkt_layer(ax, analyze_result.get("nspd_red_lines"), _REDLINE_STYLE)
|
||||
# Конкуренты — точки lat/lng.
|
||||
_plot_competitors(ax, analyze_result.get("competitors"))
|
||||
|
||||
_add_basemap(ax) # graceful: False → просто белый фон, PNG всё равно вернётся
|
||||
return _figure_to_png(fig, max_px)
|
||||
|
||||
|
||||
def render_concept_footprint_png(
|
||||
concept_variant: dict[str, Any],
|
||||
parcel_geojson: dict[str, Any] | None,
|
||||
*,
|
||||
max_px: int = 1600,
|
||||
) -> bytes | None:
|
||||
"""Карта концепции: контур участка + пятна корпусов с подписью этажности → PNG-bytes.
|
||||
|
||||
Args:
|
||||
concept_variant: dict-представление ``ConceptVariant`` с ключом ``buildings_geojson``
|
||||
(FeatureCollection, ``properties.floors`` / ``footprint_sqm`` / ``section_id``).
|
||||
parcel_geojson: GeoJSON-геометрия участка (контур-подложка). None → рисуем только
|
||||
корпуса (bbox по ним).
|
||||
max_px: верхняя граница длинной стороны PNG в пикселях.
|
||||
|
||||
Returns:
|
||||
PNG-bytes, либо None если нет ни участка, ни корпусов (нечего рисовать).
|
||||
"""
|
||||
fc = concept_variant.get("buildings_geojson") if isinstance(concept_variant, dict) else None
|
||||
features = fc.get("features", []) if isinstance(fc, dict) else []
|
||||
|
||||
footprints: list[BaseGeometry] = []
|
||||
floors_labels: list[int | None] = []
|
||||
for feat in features:
|
||||
if not isinstance(feat, dict):
|
||||
continue
|
||||
geom = _geom_from_geojson(feat.get("geometry"))
|
||||
if geom is None:
|
||||
continue
|
||||
footprints.append(geom)
|
||||
props = feat.get("properties") or {}
|
||||
raw_floors = props.get("floors") if isinstance(props, dict) else None
|
||||
floors_labels.append(int(raw_floors) if isinstance(raw_floors, int | float) else None)
|
||||
|
||||
parcel = _geom_from_geojson(parcel_geojson)
|
||||
if parcel is None and not footprints:
|
||||
return None
|
||||
|
||||
with _managed_figure() as (fig, ax):
|
||||
# bbox: по участку если есть, иначе по совокупности footprint'ов.
|
||||
bbox_source = _to_mercator([parcel]) if parcel is not None else _to_mercator(footprints)
|
||||
|
||||
if parcel is not None:
|
||||
# Участок — только контур (без заливки), корпуса рисуются поверх.
|
||||
_to_mercator([parcel]).plot(ax=ax, facecolor="none", edgecolor="#1d4ed8", linewidth=1.8)
|
||||
|
||||
if footprints:
|
||||
fp_merc = _to_mercator(footprints)
|
||||
fp_merc.plot(ax=ax, **_CONCEPT_FOOTPRINT_STYLE)
|
||||
# Подпись этажности в центроиде каждого корпуса.
|
||||
for geom_merc, floors in zip(fp_merc.geometry, floors_labels, strict=True):
|
||||
if floors is None:
|
||||
continue
|
||||
c = geom_merc.centroid
|
||||
ax.annotate(
|
||||
f"{floors} эт.",
|
||||
(c.x, c.y),
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=7.0,
|
||||
color="#0f172a",
|
||||
)
|
||||
|
||||
_apply_bbox(ax, bbox_source)
|
||||
_add_basemap(ax)
|
||||
return _figure_to_png(fig, max_px)
|
||||
|
||||
|
||||
def embed_map_png(html: str, placeholder: str, png: bytes | None) -> str:
|
||||
"""Заменить строку-``placeholder`` в HTML на ``<img>`` с data-URI PNG.
|
||||
|
||||
``png=None`` → плашка «карта недоступна» (тот же класс ``.map-placeholder``, что и
|
||||
в full_report_html — стиль уже есть в шаблоне).
|
||||
"""
|
||||
if png is None:
|
||||
return html.replace(placeholder, _MAP_UNAVAILABLE_HTML)
|
||||
b64 = base64.b64encode(png).decode("ascii")
|
||||
img = (
|
||||
f'<img class="report-map" src="data:image/png;base64,{b64}" '
|
||||
'style="width:100%;max-width:100%;height:auto;display:block;margin:8pt 0;'
|
||||
'border:1px solid #e5e7eb;border-radius:6pt;" alt="Карта участка" />'
|
||||
)
|
||||
return html.replace(placeholder, img)
|
||||
|
||||
|
||||
# ── Приватные слой-плоттеры ────────────────────────────────────────────────────
|
||||
def _plot_wkt_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
|
||||
"""Отрисовать слой из списка dict'ов с ключом ``geom_wkt`` (EPSG:4326).
|
||||
|
||||
Заливка/линия задаётся ``style`` (наличие ``facecolor`` → полигон, ``color`` без
|
||||
``facecolor`` → линия) — geopandas ``plot`` разбирает это сам по типу геометрии.
|
||||
"""
|
||||
geoms = _collect_wkt_geoms(items)
|
||||
if not geoms:
|
||||
return
|
||||
_to_mercator(geoms).plot(ax=ax, **style)
|
||||
|
||||
|
||||
def _plot_geojson_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
|
||||
"""Отрисовать слой из списка dict'ов с ключом ``geometry_geojson`` (EPSG:4326)."""
|
||||
if not isinstance(items, list):
|
||||
return
|
||||
geoms = [
|
||||
g
|
||||
for it in items
|
||||
if isinstance(it, dict)
|
||||
and (g := _geom_from_geojson(it.get("geometry_geojson"))) is not None
|
||||
]
|
||||
if not geoms:
|
||||
return
|
||||
_to_mercator(geoms).plot(ax=ax, **style)
|
||||
|
||||
|
||||
def _collect_wkt_geoms(items: Any) -> list[BaseGeometry]:
|
||||
"""Собрать shapely-геометрии из списка dict'ов по ключу ``geom_wkt``."""
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
out: list[BaseGeometry] = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
geom = _geom_from_wkt(it.get("geom_wkt"))
|
||||
if geom is not None:
|
||||
out.append(geom)
|
||||
return out
|
||||
|
||||
|
||||
def _plot_competitors(ax: Any, competitors: Any) -> None:
|
||||
"""Отрисовать конкурентов точками из ``lat``/``lng`` (EPSG:4326).
|
||||
|
||||
Внимание: у ``Competitor`` долгота в ключе ``lng`` (не ``lon``) — см. schemas/parcel.py.
|
||||
"""
|
||||
if not isinstance(competitors, list):
|
||||
return
|
||||
pts: list[BaseGeometry] = []
|
||||
for c in competitors:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
lat, lng = c.get("lat"), c.get("lng")
|
||||
if isinstance(lat, int | float) and isinstance(lng, int | float):
|
||||
pts.append(Point(float(lng), float(lat)))
|
||||
if not pts:
|
||||
return
|
||||
_to_mercator(pts).plot(ax=ax, **_COMPETITOR_STYLE)
|
||||
|
|
@ -32,7 +32,9 @@ from typing import Any
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
|
||||
# Тот же набор из шести содержательных секций, что рисует excel.py (#991).
|
||||
# Шесть секций зеркалят excel.py (#991); §13.7 «Уверенность» — доп. секция, портирована
|
||||
# из report_docx/report_md для parity между экспортёрами (audit epic #2445 item C2;
|
||||
# excel.py её пока не несёт — отдельный gap, вне scope этой правки).
|
||||
|
||||
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
|
||||
_TITLE_SUMMARY: str = "Сводка"
|
||||
|
|
@ -41,6 +43,7 @@ _TITLE_FUTURE_MARKET: str = "Будущий рынок"
|
|||
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
|
||||
_TITLE_SCENARIOS: str = "Сценарии"
|
||||
_TITLE_SCORING: str = "Скоринг"
|
||||
_TITLE_CONFIDENCE: str = "Уверенность"
|
||||
|
||||
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
|
||||
|
||||
|
|
@ -308,15 +311,21 @@ def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _build_summary(report: dict[str, Any]) -> str:
|
||||
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст."""
|
||||
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст.
|
||||
|
||||
Уровень уверенности здесь — только сводный badge (`overall_confidence`), зеркало
|
||||
report_docx/report_md._build_summary. Полный разбор (rationale + факторы-драйверы)
|
||||
живёт в отдельной секции §13.7 «Уверенность» (`_build_confidence`) — раньше
|
||||
(до parity-фикса #2445 C2) он дублировался здесь тонкой 2-строчной таблицей, что
|
||||
расходилось с docx/md и не переживало dict-значный фактор; убрано, чтобы не было
|
||||
двух версий одних и тех же данных в одном документе.
|
||||
"""
|
||||
exec_summary = _as_dict(report.get("exec_summary"))
|
||||
meta = _as_dict(report.get("meta"))
|
||||
confidence = _as_dict(report.get("confidence"))
|
||||
|
||||
headline = exec_summary.get("headline")
|
||||
verdict = exec_summary.get("verdict")
|
||||
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
||||
factors = _as_dict(confidence.get("factors"))
|
||||
|
||||
cad = _esc(meta.get("cad_num"))
|
||||
district = _esc(meta.get("district"))
|
||||
|
|
@ -329,10 +338,6 @@ def _build_summary(report: dict[str, Any]) -> str:
|
|||
("Сформировано", meta.get("generated_at")),
|
||||
("Версия схемы", meta.get("schema_version")),
|
||||
]
|
||||
confidence_pairs: list[tuple[str, Any]] = [
|
||||
("Уровень", _level_ru(confidence.get("level"))),
|
||||
("Обоснование", confidence.get("rationale")),
|
||||
]
|
||||
overall_conf = _esc(_level_ru(exec_summary.get("overall_confidence")))
|
||||
|
||||
return f"""
|
||||
|
|
@ -351,12 +356,6 @@ def _build_summary(report: dict[str, Any]) -> str:
|
|||
<h3>Ключевые числа</h3>
|
||||
{_dict_kv_table(key_numbers)}
|
||||
|
||||
<h3>Уверенность отчёта</h3>
|
||||
{_kv_table(confidence_pairs)}
|
||||
|
||||
<h3>Факторы уверенности</h3>
|
||||
{_dict_kv_table(factors)}
|
||||
|
||||
<h3>Контекст</h3>
|
||||
{_kv_table(context_pairs)}
|
||||
</div>
|
||||
|
|
@ -624,6 +623,43 @@ def _build_scoring(report: dict[str, Any]) -> str:
|
|||
|
||||
<h3>Специальные индексы</h3>
|
||||
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _build_confidence(report: dict[str, Any]) -> str:
|
||||
"""§13.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица).
|
||||
|
||||
Parity fix (audit epic #2445 item C2): report_docx/report_md уже несли эту секцию
|
||||
(§22.7/§13.7) — PDF был единственным экспортёром без нее. Портировано 1-в-1 (та же
|
||||
4-колоночная таблица «Фактор/Значение/Уровень/Комментарий»), но через HTML-примитивы
|
||||
report_pdf (`_data_table`), а не python-docx/Markdown API.
|
||||
"""
|
||||
confidence = _as_dict(report.get("confidence"))
|
||||
level = _level_ru(confidence.get("level"))
|
||||
rationale = confidence.get("rationale")
|
||||
factors = _as_dict(confidence.get("factors"))
|
||||
|
||||
# Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive:
|
||||
# если значение — dict, раскладываем на value/level/note; иначе кладём как есть
|
||||
# (зеркало report_docx._build_confidence / report_md._build_confidence).
|
||||
factor_rows: list[list[Any]] = []
|
||||
for name, payload in factors.items():
|
||||
if isinstance(payload, dict):
|
||||
factor_rows.append(
|
||||
[name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")]
|
||||
)
|
||||
else:
|
||||
factor_rows.append([name, payload, _DASH, _DASH])
|
||||
|
||||
return f"""
|
||||
<div class="section" id="confidence">
|
||||
<h2>{html.escape(_TITLE_CONFIDENCE)}</h2>
|
||||
<span class="badge">Уровень: {_esc(level)}</span>
|
||||
<p class="verdict">{_esc(rationale)}</p>
|
||||
|
||||
<h3>Факторы уверенности</h3>
|
||||
{_data_table(["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)}
|
||||
|
||||
<div class="footer">{html.escape(_FOOTER_NOTE)}</div>
|
||||
</div>
|
||||
|
|
@ -631,7 +667,8 @@ def _build_scoring(report: dict[str, Any]) -> str:
|
|||
|
||||
|
||||
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
|
||||
# `_SHEET_BUILDERS` у excel.py — тот же набор из шести содержательных секций §13).
|
||||
# `_SHEET_BUILDERS` у excel.py + report_docx/report_md — Сводка → Рынок сейчас →
|
||||
# Будущий рынок → Продукт ТЗ → Сценарии → Скоринг → Уверенность §13.7, #2445 C2).
|
||||
_SECTION_BUILDERS: tuple[Any, ...] = (
|
||||
_build_summary,
|
||||
_build_market_now,
|
||||
|
|
@ -639,11 +676,12 @@ _SECTION_BUILDERS: tuple[Any, ...] = (
|
|||
_build_product_tz,
|
||||
_build_scenarios,
|
||||
_build_scoring,
|
||||
_build_confidence,
|
||||
)
|
||||
|
||||
|
||||
def _build_html(report: dict[str, Any]) -> str:
|
||||
"""Склеить HTML-документ из шести секций §13. PURE (только строки). Graceful."""
|
||||
"""Склеить HTML-документ из семи секций §13 (+ §13.7 Уверенность). PURE. Graceful."""
|
||||
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
|
@ -666,10 +704,10 @@ def export_report_pdf(report: Any) -> bytes:
|
|||
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
|
||||
|
||||
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
|
||||
рынок / Продукт ТЗ / Сценарии / Скоринг — тот же набор, что и `export_report_xlsx`).
|
||||
Шапки таблиц с заливкой, RU-метки, числа округлены, None → "—". На блоке «Сводка» —
|
||||
заметный ADVISORY-маркер (отчёт советующий). ВСЕ динамические строки экранируются
|
||||
`html.escape`.
|
||||
рынок / Продукт ТЗ / Сценарии / Скоринг / Уверенность §13.7 — parity с
|
||||
report_docx/report_md, #2445 C2). Шапки таблиц с заливкой, RU-метки, числа
|
||||
округлены, None → "—". На блоке «Сводка» — заметный ADVISORY-маркер (отчёт
|
||||
советующий). ВСЕ динамические строки экранируются `html.escape`.
|
||||
|
||||
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
|
||||
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/
|
||||
|
|
|
|||
|
|
@ -371,12 +371,20 @@ def _month_grid(start: date, end: date) -> list[date]:
|
|||
|
||||
|
||||
def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
|
||||
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}."""
|
||||
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #2): `db` — общая §22-сессия отчёта; при сбое
|
||||
этого запроса БЕЗ SAVEPOINT транзакция Postgres остаётся aborted и следующие
|
||||
запросы get_monthly_macro (inflation, mortgage) + все ПОСЛЕДУЮЩИЕ §9.x-слои на
|
||||
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
|
||||
SAVEPOINT (ROLLBACK TO SAVEPOINT), внешняя транзакция остаётся рабочей.
|
||||
"""
|
||||
try:
|
||||
rows = db.execute(
|
||||
_KEY_RATE_MONTHLY_SQL,
|
||||
{"itype": "key_rate", "region": "rf", "since": since},
|
||||
).all()
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
_KEY_RATE_MONTHLY_SQL,
|
||||
{"itype": "key_rate", "region": "rf", "since": since},
|
||||
).all()
|
||||
except Exception:
|
||||
logger.exception("get_monthly_macro: key_rate query failed")
|
||||
return {}
|
||||
|
|
@ -389,9 +397,14 @@ def _query_inflation_monthly(db: Session, *, since: date) -> dict[date, float]:
|
|||
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) — берём как есть
|
||||
через reuse get_macro_series (свой SQL не пишем). _month_start — страховка.
|
||||
Сбой/пустой ряд → {} (НЕ crash), inflation_yoy тогда None по всей сетке.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #2): см. `_query_key_rate_monthly` — та же
|
||||
общая §22-сессия, тот же риск отравления транзакции для последующих запросов
|
||||
(mortgage-поля + §9.x-слои). `with db.begin_nested():` изолирует сбой в SAVEPOINT.
|
||||
"""
|
||||
try:
|
||||
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
|
||||
with db.begin_nested():
|
||||
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
|
||||
except Exception:
|
||||
logger.exception("get_monthly_macro: inflation_yoy query failed")
|
||||
return {}
|
||||
|
|
@ -404,11 +417,20 @@ def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date,
|
|||
Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу
|
||||
в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда
|
||||
не валит остальные (graceful: пустой подсловарь).
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #2): все 5 полей читаются на ОДНОЙ `db`-Session
|
||||
(get_monthly_macro вызывается внутри общей §22-сессии отчёта). Без SAVEPOINT сбой
|
||||
ОДНОГО поля оставляет транзакцию Postgres aborted — каждое СЛЕДУЮЩЕЕ поле в этом
|
||||
же цикле тоже падает (хотя его данные были бы доступны), а `except` здесь молча
|
||||
отдаёт [] по каждому, маскируя каскад под «нормальную» построчную деградацию.
|
||||
`with db.begin_nested():` — SAVEPOINT на КАЖДОЕ поле: сбой откатывает только его
|
||||
SAVEPOINT (ROLLBACK TO SAVEPOINT), сессия остаётся рабочей для следующего поля.
|
||||
"""
|
||||
out: dict[str, dict[date, float]] = {}
|
||||
for indicator_type, field in _MORTGAGE_FIELDS:
|
||||
try:
|
||||
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
|
||||
with db.begin_nested():
|
||||
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
|
||||
except Exception:
|
||||
logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type)
|
||||
series = []
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ def _primary_horizon(horizons: Sequence[int]) -> int:
|
|||
return horizons[0] if horizons else _PREFERRED_PRIMARY_HORIZON
|
||||
|
||||
|
||||
def _safe_call(label: str, fn: Any) -> Any:
|
||||
def _safe_call(label: str, db: Session, fn: Any) -> Any:
|
||||
"""Вызвать §9.x-сервис graceful: сбой → None + logger.exception (не crash отчёта).
|
||||
|
||||
Зеркало product_scoring._safe_call: любой §9.x-слой может бросить (тонкие данные / нет
|
||||
|
|
@ -133,15 +133,29 @@ def _safe_call(label: str, fn: Any) -> Any:
|
|||
широкий Exception (изоляция одного слоя от отчёта) с ОБЯЗАТЕЛЬНЫМ logger.exception —
|
||||
НЕ молчаливое глотание. §9.x уже graceful внутри; это belt-and-suspenders на шве.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #1): все §9.x-слои шарят ОДИН `db`-Session на
|
||||
отчёт (module docstring `forecast_request_cache.py`). Без обёртки сбойный
|
||||
`db.execute` внутри слоя оставляет транзакцию Postgres в состоянии aborted
|
||||
(«current transaction is aborted, commands ignored until end of transaction
|
||||
block») — КАЖДЫЙ последующий слой на той же сессии тоже падает, хотя его данные
|
||||
были бы доступны. `with db.begin_nested():` заводит SAVEPOINT НА ВЕСЬ вызов слоя
|
||||
(слой может делать несколько `db.execute` внутри себя — например §9.6 внутри
|
||||
§9.8/§11); при исключении SAVEPOINT откатывается автоматически (ROLLBACK TO
|
||||
SAVEPOINT), внешняя транзакция остаётся рабочей для следующего слоя. НЕ
|
||||
`db.rollback()` — тот откатил бы ВСЮ внешнюю транзакцию (см. `backend.md` §
|
||||
SAVEPOINT pattern, established anti-pattern).
|
||||
|
||||
Args:
|
||||
label: имя слоя для лога (диагностика какой §9.x-вызов деградировал).
|
||||
db: общая §22-сессия (для SAVEPOINT вокруг вызова слоя).
|
||||
fn: нулевой-аргумент thunk вокруг §9.x-вызова.
|
||||
|
||||
Returns:
|
||||
Результат `fn()` или None при исключении.
|
||||
"""
|
||||
try:
|
||||
return fn()
|
||||
with db.begin_nested():
|
||||
return fn()
|
||||
except Exception:
|
||||
logger.exception("orchestrator: §9.x layer %s failed → section degraded", label)
|
||||
return None
|
||||
|
|
@ -334,21 +348,25 @@ def _build_site_finder_report_impl(
|
|||
# ── 2. §9.x-слои, каждый graceful через _safe_call (ГЕТЕРОГЕННЫЕ сигнатуры) ──
|
||||
market_metrics = _safe_call(
|
||||
"market_metrics",
|
||||
db,
|
||||
lambda: compute_market_metrics(db, district=district, premise_kind=_PREMISE_KIND),
|
||||
)
|
||||
supply_rows = _safe_call(
|
||||
"supply_layers",
|
||||
db,
|
||||
lambda: compute_all_layers(db, district=district, premise_kind=_PREMISE_KIND),
|
||||
)
|
||||
supply_layers = _summarize_supply_layers(supply_rows)
|
||||
future_supply = _safe_call(
|
||||
"future_supply",
|
||||
db,
|
||||
lambda: compute_future_supply_pressure(
|
||||
db, district=district, horizon_months=primary, premise_kind=_PREMISE_KIND
|
||||
),
|
||||
)
|
||||
forecasts = _safe_call(
|
||||
"demand_supply_forecast",
|
||||
db,
|
||||
lambda: compute_demand_supply_forecast(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
|
|
@ -359,6 +377,7 @@ def _build_site_finder_report_impl(
|
|||
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
|
||||
scenarios_result = _safe_call(
|
||||
"scenarios",
|
||||
db,
|
||||
lambda: compute_scenarios(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
|
|
@ -371,18 +390,21 @@ def _build_site_finder_report_impl(
|
|||
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
|
||||
product_scores = _safe_call(
|
||||
"score_card",
|
||||
db,
|
||||
lambda: compute_score_card(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizon_months=primary
|
||||
),
|
||||
)
|
||||
special_indices = _safe_call(
|
||||
"special_indices",
|
||||
db,
|
||||
lambda: compute_special_indices(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
)
|
||||
recommendation_overlay = _safe_call(
|
||||
"forecast_overlay",
|
||||
db,
|
||||
lambda: build_forecast_overlay(
|
||||
db,
|
||||
district=district,
|
||||
|
|
@ -393,7 +415,7 @@ def _build_site_finder_report_impl(
|
|||
)
|
||||
|
||||
# ── Макро-свежесть (audit MEDIUM): только лог; проводка в отчёт — 3b ─────────
|
||||
macro = _safe_call("monthly_macro", lambda: get_monthly_macro(db))
|
||||
macro = _safe_call("monthly_macro", db, lambda: get_monthly_macro(db))
|
||||
macro_as_of = _macro_as_of(macro)
|
||||
if macro_as_of is not None:
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -835,13 +835,15 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
|||
compute_poi_weighted_top7. Нет геометрии / нет POI / сбой → None (infra_fit unavailable).
|
||||
"""
|
||||
try:
|
||||
coords = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL, {"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)}
|
||||
with db.begin_nested():
|
||||
coords = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL,
|
||||
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"product_scoring: centroid lookup failed (cad_num=%s) → infra n/a", cad_num
|
||||
|
|
@ -850,9 +852,10 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
|||
if not coords or coords.get("lat") is None or coords.get("lon") is None:
|
||||
return None
|
||||
try:
|
||||
response = compute_poi_weighted_top7(
|
||||
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
|
||||
)
|
||||
with db.begin_nested():
|
||||
response = compute_poi_weighted_top7(
|
||||
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("product_scoring: poi_score failed (cad_num=%s) → infra n/a", cad_num)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -549,6 +549,12 @@ def _query_source_a(
|
|||
|
||||
Graceful → {} при сбое/пустых данных. price_bucket в spec для Source A
|
||||
игнорируется (агрегат не несёт per-lot цены) — фиксируется логом.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #4): `db` — общая §22-сессия отчёта (может
|
||||
переиспользоваться другими §9.x-слоями после этого вызова). Без SAVEPOINT сбойный
|
||||
`db.execute` оставляет транзакцию Postgres aborted — все ПОСЛЕДУЮЩИЕ запросы на
|
||||
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
|
||||
SAVEPOINT при сбое, оставляя внешнюю транзакцию рабочей.
|
||||
"""
|
||||
if spec.price_bucket is not None:
|
||||
logger.info(
|
||||
|
|
@ -567,7 +573,8 @@ def _query_source_a(
|
|||
"room_bucket": spec.room_bucket,
|
||||
}
|
||||
try:
|
||||
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("build_sales_series: source A query failed")
|
||||
return {}
|
||||
|
|
@ -582,6 +589,9 @@ def _query_source_b(
|
|||
Graceful → {} при сбое/пустых данных. Передаёт bucket-пороги/-метки в SQL
|
||||
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
|
||||
правилом и в БД, и в Python.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #4): см. `_query_source_a` — та же общая
|
||||
§22-сессия, тот же риск отравления транзакции для последующих слоёв/запросов.
|
||||
"""
|
||||
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
|
||||
# None → EKB-wide (без district-фильтра).
|
||||
|
|
@ -613,7 +623,8 @@ def _query_source_b(
|
|||
"p_unknown": PRICE_BUCKET_UNKNOWN,
|
||||
}
|
||||
try:
|
||||
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("build_sales_series: source B query failed")
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -994,16 +994,29 @@ def _query_parcel_centroid(db: Session, *, cad_num: str) -> tuple[float, float]
|
|||
|
||||
Нет геометрии / сбой → None (гео-веса упадут на floor; overlap считается по остальным
|
||||
осям, индекс НЕ деградирует целиком). Параметризовано (psycopg v3). Детерминированно.
|
||||
|
||||
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): этот helper
|
||||
зовётся из `_build_cannibalization` — builder, обёрнутый внешним SAVEPOINT в
|
||||
`compute_special_indices._run`. БЕЗ собственного `db.begin_nested():` сбойный
|
||||
`db.execute` тут проглатывается локально (→ None), оставляя транзакцию Postgres
|
||||
aborted; тогда ВНЕШНИЙ `_run`-savepoint выходит «нормально» и делает RELEASE
|
||||
SAVEPOINT, а RELEASE в aborted-tx САМ падает (Postgres в aborted допускает только
|
||||
ROLLBACK / ROLLBACK TO SAVEPOINT) — внешний except ловит уже RELEASE-ошибку, но
|
||||
транзакция так и не откачена → отравление каскадит в следующий §25-индекс. Savepoint
|
||||
ИМЕННО ЗДЕСЬ (в точке перехвата) откатывает сбой (ROLLBACK TO SAVEPOINT), поэтому
|
||||
внешний RELEASE проходит. Тот же принцип, что saturation.py: savepoint у db.execute
|
||||
в точке, где ошибка ловится.
|
||||
"""
|
||||
try:
|
||||
row = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL,
|
||||
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
||||
with db.begin_nested():
|
||||
row = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL,
|
||||
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("cannibalization: centroid query failed for cad_num=%s", cad_num)
|
||||
return None
|
||||
|
|
@ -1657,9 +1670,20 @@ def compute_special_indices(
|
|||
segment = spec.as_dict()
|
||||
|
||||
def _run(key: str, builder: Any) -> SpecialIndex:
|
||||
"""Выполнить builder одного индекса в собственном try/except (graceful)."""
|
||||
"""Выполнить builder одного индекса в собственном try/except (graceful).
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #3): все шесть builder'ов делят ОДНУ `db`-
|
||||
Session (общая сессия §22-отчёта). Без SAVEPOINT сбойный `db.execute` внутри
|
||||
builder'а оставляет транзакцию Postgres aborted — КАЖДЫЙ следующий индекс на
|
||||
той же сессии тоже падает (хотя его данные были бы доступны), что маскируется
|
||||
под шесть независимых деградаций. `with db.begin_nested():` заводит SAVEPOINT
|
||||
на ВЕСЬ builder() (некоторые builder'ы делают несколько db.execute внутри
|
||||
себя) — сбой откатывает только его SAVEPOINT, сессия остаётся рабочей для
|
||||
следующего индекса.
|
||||
"""
|
||||
try:
|
||||
return builder() # type: ignore[no-any-return]
|
||||
with db.begin_nested():
|
||||
return builder() # type: ignore[no-any-return]
|
||||
except Exception:
|
||||
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
|
||||
logger.exception(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ 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
|
||||
|
|
@ -44,7 +49,7 @@ _LAYER_BUILDINGS = "BUILDINGS"
|
|||
_LABEL_HEIGHT_M = 2.0
|
||||
|
||||
|
||||
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||||
def _ring_points(coords: CoordinateSequence) -> list[tuple[float, float]]:
|
||||
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||
pts = list(coords)
|
||||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||||
|
|
@ -53,7 +58,7 @@ def _ring_points(coords: object) -> list[tuple[float, float]]:
|
|||
return [(float(x), float(y)) for x, y in pts]
|
||||
|
||||
|
||||
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
|
||||
def _add_polygon(msp: Modelspace, poly: Polygon, layer: str) -> None:
|
||||
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
|
||||
|
||||
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
|
||||
|
|
@ -151,15 +156,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,9 +66,7 @@ def _strategy_label(strategy: str) -> str:
|
|||
|
||||
def _teap_table(variants: Sequence[ConceptVariant]) -> str:
|
||||
"""HTML-таблица ТЭП по всем вариантам (строки — показатели, колонки — стратегии)."""
|
||||
headers = "".join(
|
||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
||||
)
|
||||
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
|
||||
rows: list[tuple[str, list[str]]] = [
|
||||
("Пятно застройки, кв.м", [_fmt_int(v.teap.built_area_sqm) for v in variants]),
|
||||
("Общая площадь (GFA), кв.м", [_fmt_int(v.teap.total_floor_area_sqm) for v in variants]),
|
||||
|
|
@ -107,9 +105,7 @@ def _fmt_irr(financial: FinancialModel) -> str:
|
|||
|
||||
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
|
||||
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF)."""
|
||||
headers = "".join(
|
||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
||||
)
|
||||
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
|
||||
rows: list[tuple[str, list[str]]] = [
|
||||
(
|
||||
"Выручка — жильё, млн руб",
|
||||
|
|
@ -188,9 +184,13 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
|
|||
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
|
||||
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
||||
"НДС — реализация жилья и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ), "
|
||||
"входной НДС по СМР встроен в себестоимость; НДС начисляется только на нежилую часть "
|
||||
"(машиноместа). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
|
||||
"— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
|
||||
"входной НДС по СМР встроен в себестоимость; НДС начисляется на нежилые части — "
|
||||
"машиноместа и коммерцию/офисы 1-го этажа (встроенный НДС в добавленной стоимости "
|
||||
"каждой части). Налог на прибыль — 25% (с 2025). Цены и себестоимость — рыночные "
|
||||
"ориентиры. Коммерция/офисы 1-го этажа учтены по нормативной доле от общей площади "
|
||||
"и продаются с умеренной наценкой к цене жилья того же класса; себестоимость СМР "
|
||||
"нежилого — по той же ставке, что и жильё (отдельной строки затрат нет, повторного "
|
||||
"учёта в затратах нет).</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ from dataclasses import dataclass
|
|||
from typing import Any
|
||||
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely.geometry import Polygon, mapping, shape
|
||||
from pyproj.exceptions import CRSError
|
||||
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
from shapely.ops import transform as shapely_transform
|
||||
|
||||
|
|
@ -53,6 +54,12 @@ MIN_BUILDABLE_AREA_SQM: float = 50.0
|
|||
# удержать время в бюджете (<=10 c/вариант). MVP-упрощение.
|
||||
MAX_GRID_CELLS: int = 20_000
|
||||
|
||||
# Валидные диапазоны WGS84 lon/lat (градусы). Используются, чтобы отловить участок,
|
||||
# присланный в проекции (метры, напр. МСК-66/UTM), ДО того как _metric_transformers
|
||||
# соберёт AEQD с lat_0/lon_0 далеко за пределами градусов -> pyproj.CRSError.
|
||||
_LON_RANGE: tuple[float, float] = (-180.0, 180.0)
|
||||
_LAT_RANGE: tuple[float, float] = (-90.0, 90.0)
|
||||
|
||||
# WGS84 (вход контракта).
|
||||
_WGS84 = CRS.from_epsg(4326)
|
||||
|
||||
|
|
@ -142,6 +149,19 @@ 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:
|
||||
|
|
@ -157,6 +177,27 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
|
|||
return polygon
|
||||
|
||||
|
||||
def _assert_wgs84_bounds(polygon: Polygon) -> None:
|
||||
"""Проверить, что bbox полигона лежит в валидных диапазонах WGS84 lon/lat.
|
||||
|
||||
Root-cause фикс: фронтовый баг / плохой геокодер / demo-payload иногда шлёт
|
||||
``parcel_geojson`` в проекции (метры МСК-66/UTM, напр. [500000, 6200000]) вместо
|
||||
WGS84 lon/lat. Такой полигон синтаксически валиден (это просто Polygon), но
|
||||
дальше он бы дошёл до :func:`_metric_transformers`, где centroid.y (~6_200_000)
|
||||
подставится в ``+lat_0=`` -> pyproj.CRS.from_proj4 упадёт CRSError (opaque 500).
|
||||
Ловим здесь, ДО построения проекции, с понятным сообщением (422).
|
||||
"""
|
||||
minx, miny, maxx, maxy = polygon.bounds
|
||||
lon_ok = _LON_RANGE[0] <= minx and maxx <= _LON_RANGE[1]
|
||||
lat_ok = _LAT_RANGE[0] <= miny and maxy <= _LAT_RANGE[1]
|
||||
if not (lon_ok and lat_ok):
|
||||
raise ParcelGeometryError(
|
||||
"parcel_geojson coordinates out of WGS84 lon/lat range "
|
||||
f"(lon∈{_LON_RANGE}, lat∈{_LAT_RANGE}, got bounds={polygon.bounds}) — "
|
||||
"coordinates appear to be projected (e.g. МСК-66/UTM metres), not WGS84 lon/lat"
|
||||
)
|
||||
|
||||
|
||||
def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transformer]:
|
||||
"""Построить пару трансформеров WGS84<->метрический AEQD вокруг центроида участка.
|
||||
|
||||
|
|
@ -164,10 +205,16 @@ def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transform
|
|||
координат и точен на масштабе квартала — не нужен выбор UTM-зоны.
|
||||
"""
|
||||
centroid = polygon_wgs84.centroid
|
||||
metric_crs = CRS.from_proj4(
|
||||
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
|
||||
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
|
||||
)
|
||||
try:
|
||||
metric_crs = CRS.from_proj4(
|
||||
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
|
||||
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
|
||||
)
|
||||
except CRSError as exc:
|
||||
# Belt-and-suspenders: _assert_wgs84_bounds должен был отловить это раньше,
|
||||
# но на случай иной координатной патологии не даём CRSError утечь наружу
|
||||
# непойманным 500 — это невалидная геометрия участка, т.е. 422.
|
||||
raise ParcelGeometryError(f"cannot build metric CRS for parcel centroid: {exc}") from exc
|
||||
to_metric = Transformer.from_crs(_WGS84, metric_crs, always_xy=True)
|
||||
to_wgs84 = Transformer.from_crs(metric_crs, _WGS84, always_xy=True)
|
||||
return to_metric, to_wgs84
|
||||
|
|
@ -240,9 +287,11 @@ def parse_parcel(
|
|||
"""Stage 1a: ConceptInput -> :class:`Parcel` (метрика + buildable + grid).
|
||||
|
||||
Raises:
|
||||
ParcelGeometryError: полигон невалиден или пятно застройки вырождается.
|
||||
ParcelGeometryError: полигон невалиден, координаты не в WGS84 lon/lat, или
|
||||
пятно застройки вырождается.
|
||||
"""
|
||||
polygon_wgs84 = _parse_polygon(payload.parcel_geojson)
|
||||
_assert_wgs84_bounds(polygon_wgs84)
|
||||
to_metric, to_wgs84 = _metric_transformers(polygon_wgs84)
|
||||
|
||||
def _fwd(xs: Any, ys: Any) -> tuple[Any, Any]:
|
||||
|
|
|
|||
|
|
@ -48,22 +48,24 @@ _SYSTEM_BASE = PromptTemplate(
|
|||
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
|
||||
_CHAT_SYSTEM = PromptTemplate(
|
||||
name="chat_system",
|
||||
version=2,
|
||||
version=3,
|
||||
template=(
|
||||
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
|
||||
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
|
||||
"ЖЁСТКИЕ ПРАВИЛА:\n"
|
||||
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
|
||||
"Чтобы получить нужные числа, вызови подходящий инструмент.\n"
|
||||
"Чтобы получить нужные числа, вызови подходящий инструмент. Для вопросов про сам "
|
||||
"участок — адрес, площадь, категорию земель, ВРИ, территориальную зону ПЗЗ и её "
|
||||
"код/название, лимиты застройки, ЗОУИТ-обременения — вызови get_parcel_info.\n"
|
||||
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
|
||||
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
|
||||
"отчёте нет. Не подставляй правдоподобные значения.\n"
|
||||
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
|
||||
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
|
||||
"инвестиционного решения. Не давай гарантий доходности.\n"
|
||||
"5. Вопросы вне отчёта по участку (градостроительная документация / ПЗЗ-разрешения, "
|
||||
"сравнение с другими участками, юридические заключения) — вежливо скажи, что это вне "
|
||||
"области отчёта, и предложи вопросы по самому форсайту.\n"
|
||||
"5. Вопросы, выходящие за рамки данных отчёта и паспорта участка (сравнение с другими "
|
||||
"участками, юридические заключения, получение разрешений/согласований) — вежливо "
|
||||
"скажи, что это вне области отчёта, и предложи вопросы по участку и его форсайту.\n"
|
||||
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
|
||||
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
|
||||
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
||||
|
|
|
|||
|
|
@ -33,15 +33,19 @@ Wiring (отдельный PR):
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.scrapers.flat_plans import download_plan_image, upsert_flat_plans
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -87,6 +91,51 @@ _STATUS_KW_RE = re.compile(
|
|||
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
|
||||
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
|
||||
|
||||
# ── plan-image extraction ─────────────────────────────────────────────────────
|
||||
#
|
||||
# Страница квартиры каталога DOM.РФ — Next.js SSR (тот же стек что и страница
|
||||
# объекта, см. domrf_catalog_object.py). План квартиры рендерится через next/image,
|
||||
# поэтому реальный URL картинки живёт в JSON-блоке <script id="__NEXT_DATA__">
|
||||
# внутри pageProps, а НЕ в готовом <img src>. Стратегия extraction (в порядке
|
||||
# приоритета, best-effort — DOM.РФ может переименовать ключи без уведомления):
|
||||
#
|
||||
# 1. __NEXT_DATA__ pageProps: ищем значение под одним из кандидат-ключей
|
||||
# (planImage / planImageUrl / layoutImage / floorPlan / scheme / image / photo).
|
||||
# 2. Fallback: bare <img>, чей src/alt/class намекают на планировку
|
||||
# (plan / layout / scheme / планировк), либо <img> рядом с текстом «Планировка».
|
||||
#
|
||||
# URL нормализуется в абсолютный через urljoin(BASE_URL, ...): DOM.РФ отдаёт файлы
|
||||
# и абсолютными (https://xn--80az8a.../api/ext/file/... — см. documents.py), и
|
||||
# относительными (/_next/image?url=..., /api/ext/file/...). Мусорные картинки
|
||||
# (data:-inline base64, captcha, лого застройщика, миниатюры) отсекаются.
|
||||
|
||||
# Ключи в pageProps, под которыми может лежать URL планировки. Проверяются по
|
||||
# порядку; первое непустое строковое значение с image-like URL выигрывает.
|
||||
_NEXT_PLAN_KEY_RE = re.compile(
|
||||
r"(?:plan|layout|scheme|планировк|floor)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Признак «это картинка» в значении: расширение файла ИЛИ file-API DOM.РФ.
|
||||
_IMAGE_URL_RE = re.compile(
|
||||
r"(?:\.(?:png|jpe?g|webp|svg)(?:\?|$)|/api/ext/file/|/_next/image)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Токены, по которым отсекаем НЕ-планировочные картинки (лого, аватары, иконки,
|
||||
# captcha, миниатюры, банеры). Проверяются по src/alt/class.
|
||||
_IMAGE_NOISE_RE = re.compile(
|
||||
r"(?:logo|avatar|icon|captcha|robot|sprite|banner|placeholder|thumb|"
|
||||
r"favicon|badge|marker|pin)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Признак «это планировка» в атрибутах <img> (src / alt / class).
|
||||
_IMAGE_PLAN_HINT_RE = re.compile(
|
||||
r"(?:plan|layout|scheme|планировк)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _classify_status_kw(matched_text: str) -> str | None:
|
||||
"""Классифицировать совпавшее ключевое слово в STATUS_*.
|
||||
|
|
@ -217,8 +266,19 @@ class _TextCollector(HTMLParser):
|
|||
# Список записей: (class_hint, full_text)
|
||||
self.blocks: list[tuple[str, str]] = []
|
||||
self._buf: list[str] = []
|
||||
# Отдельный, аддитивный сбор <img>-атрибутов (issue #2440): нужен для
|
||||
# extraction plan_image_url как fallback к __NEXT_DATA__. НЕ влияет на
|
||||
# text-extraction — img остаётся void-тегом, фрейм в стек не пушится.
|
||||
# Формат: (attrs_dict, index_в_blocks) — index привязывает картинку к
|
||||
# позиции в потоке текстовых блоков для proximity-поиска «Планировка».
|
||||
self.images: list[tuple[dict[str, str], int]] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag == "img":
|
||||
# Собираем атрибуты img ДО early-return (void-тег): index = текущая
|
||||
# длина blocks, т.е. картинка «привязана» к ближайшему предыдущему
|
||||
# закрытому текстовому блоку. Text-extraction ниже не меняется.
|
||||
self.images.append(({k: (v or "") for k, v in attrs}, len(self.blocks)))
|
||||
if tag in self._VOID_TAGS:
|
||||
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
||||
attr_dict = {k: (v or "") for k, v in attrs}
|
||||
|
|
@ -266,6 +326,134 @@ def _find_text_near(
|
|||
return None
|
||||
|
||||
|
||||
def _normalize_plan_url(url: str | None) -> str | None:
|
||||
"""Нормализовать URL картинки планировки в абсолютный, отсеять мусор.
|
||||
|
||||
- data:-inline (base64) отбрасываем (это не удалённый файл, часто captcha/robot).
|
||||
- Относительный путь ('/api/ext/file/...', '/_next/image?url=...') → absolute
|
||||
через urljoin(BASE_URL, ...).
|
||||
- Пустое/None → None.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
u = url.strip()
|
||||
if not u or u.lower().startswith("data:"):
|
||||
return None
|
||||
if u.startswith("//"):
|
||||
# protocol-relative → https
|
||||
return "https:" + u
|
||||
if u.startswith("http://") or u.startswith("https://"):
|
||||
return u
|
||||
return urljoin(BASE_URL + "/", u.lstrip("/"))
|
||||
|
||||
|
||||
def _extract_plan_from_next_data(html: str) -> str | None:
|
||||
"""Извлечь URL планировки из <script id="__NEXT_DATA__"> pageProps.
|
||||
|
||||
Страница квартиры каталога — Next.js SSR: план рендерится через next/image,
|
||||
URL картинки живёт в JSON-блоке. Рекурсивно обходим pageProps и ищем строковое
|
||||
значение под ключом, матчащим _NEXT_PLAN_KEY_RE, чьё значение выглядит как
|
||||
image-URL (_IMAGE_URL_RE) и НЕ является шумом (_IMAGE_NOISE_RE).
|
||||
|
||||
Возвращает первый подходящий URL (нормализованный) или None.
|
||||
"""
|
||||
match = re.search(
|
||||
r'<script\s+id=["\']__NEXT_DATA__["\'][^>]*>(.+?)</script>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
blob = json.loads(match.group(1).strip())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
page_props = blob.get("props", {}).get("pageProps")
|
||||
root: Any = page_props if isinstance(page_props, dict) else blob
|
||||
|
||||
# BFS по вложенному dict/list; ключ+значение проверяем на plan-hint.
|
||||
stack: list[Any] = [root]
|
||||
seen = 0
|
||||
while stack and seen < 20_000: # cap: защита от патологически глубокого JSON
|
||||
node = stack.pop()
|
||||
seen += 1
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and _NEXT_PLAN_KEY_RE.search(str(key))
|
||||
and _IMAGE_URL_RE.search(value)
|
||||
and not _IMAGE_NOISE_RE.search(value)
|
||||
):
|
||||
normalized = _normalize_plan_url(value)
|
||||
if normalized:
|
||||
return normalized
|
||||
if isinstance(value, dict | list):
|
||||
stack.append(value)
|
||||
elif isinstance(node, list):
|
||||
stack.extend(node)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_plan_from_imgs(
|
||||
images: list[tuple[dict[str, str], int]], blocks: list[tuple[str, str]]
|
||||
) -> str | None:
|
||||
"""Fallback: выбрать <img> планировки из собранных _TextCollector.images.
|
||||
|
||||
Приоритет:
|
||||
1. <img>, чей src/alt/class содержит plan/layout/scheme/планировк — прямое
|
||||
совпадение, самый надёжный сигнал.
|
||||
2. <img> рядом (в потоке DOM) с текстовым блоком «Планировка».
|
||||
Шумовые картинки (лого/иконки/captcha/миниатюры) отсекаются по _IMAGE_NOISE_RE,
|
||||
inline data:-картинки и пустые src — через _normalize_plan_url.
|
||||
"""
|
||||
# Уровень 1: явный plan-hint в атрибутах.
|
||||
for attrs, _idx in images:
|
||||
src = attrs.get("src", "")
|
||||
alt = attrs.get("alt", "")
|
||||
cls = attrs.get("class", "")
|
||||
haystack = f"{src} {alt} {cls}"
|
||||
if _IMAGE_NOISE_RE.search(haystack):
|
||||
continue
|
||||
if _IMAGE_PLAN_HINT_RE.search(haystack):
|
||||
normalized = _normalize_plan_url(src)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
# Уровень 2: <img>, чей index стоит сразу после блока «Планировка».
|
||||
plan_block_indices = [
|
||||
i for i, (_cls, text) in enumerate(blocks) if re.search(r"планировк", text, re.IGNORECASE)
|
||||
]
|
||||
if plan_block_indices:
|
||||
for attrs, idx in images:
|
||||
src = attrs.get("src", "")
|
||||
if _IMAGE_NOISE_RE.search(f"{src} {attrs.get('alt', '')} {attrs.get('class', '')}"):
|
||||
continue
|
||||
# картинка «принадлежит» блоку «Планировка», если её index в пределах
|
||||
# ±2 текстовых блоков от лейбла.
|
||||
if any(abs(idx - pbi) <= 2 for pbi in plan_block_indices):
|
||||
normalized = _normalize_plan_url(src)
|
||||
if normalized:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def extract_plan_image_url(html: str, collector: _TextCollector) -> str | None:
|
||||
"""Извлечь URL картинки планировки из SSR-HTML страницы квартиры.
|
||||
|
||||
Two-strategy (issue #2440):
|
||||
1. __NEXT_DATA__ pageProps (primary — DOM.РФ Next.js SSR).
|
||||
2. bare <img> fallback (по plan-hint атрибутам либо proximity к «Планировка»).
|
||||
|
||||
Возвращает абсолютный URL или None. Best-effort — структура DOM.РФ хрупкая.
|
||||
"""
|
||||
url = _extract_plan_from_next_data(html)
|
||||
if url:
|
||||
return url
|
||||
return _extract_plan_from_imgs(collector.images, collector.blocks)
|
||||
|
||||
|
||||
def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||
"""Извлечь поля из SSR-HTML страницы квартиры DOM.РФ.
|
||||
|
||||
|
|
@ -277,6 +465,7 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
- ceiling_height_m (float) Высота потолков в метрах
|
||||
- section_no (int) Номер подъезда / секции
|
||||
- catalog_updated_at (date) Дата обновления информации на странице
|
||||
- plan_image_url (str) URL картинки планировки (issue #2440)
|
||||
|
||||
Парсинг хрупкий по природе (SSR HTML DOM.РФ меняется без уведомлений).
|
||||
Все extraction best-effort — KeyError/AttributeError обёрнуты внутри.
|
||||
|
|
@ -468,6 +657,14 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
except ValueError:
|
||||
pass
|
||||
|
||||
# ── Шаг 4: plan_image_url (issue #2440) ──────────────────────────────────
|
||||
# __NEXT_DATA__ pageProps (primary) → bare <img> fallback. collector.images
|
||||
# собран на Шаге 1 тем же feed(). None если план не найден (напр. проданная
|
||||
# квартира без планировки на странице).
|
||||
plan_url = extract_plan_image_url(html, collector)
|
||||
if plan_url:
|
||||
result["plan_image_url"] = plan_url
|
||||
|
||||
logger.debug(
|
||||
"parse_catalog_flat: extracted fields=%s",
|
||||
list(result.keys()),
|
||||
|
|
@ -544,16 +741,63 @@ def upsert_catalog_data(
|
|||
# ── Per-flat scrape orchestration ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _handle_plan_image(
|
||||
db: Session,
|
||||
session: BrowserSession,
|
||||
ods_id: str,
|
||||
obj_id: int,
|
||||
plan_url: str,
|
||||
snapshot_date: date,
|
||||
outcome: dict[str, Any],
|
||||
) -> None:
|
||||
"""Записать URL планировки в flat_plans, скачать бинарник, дописать метаданные.
|
||||
|
||||
Порядок (issue #2440):
|
||||
1. upsert_flat_plans с одним только plan_image_url — URL зафиксирован даже
|
||||
если скачивание упадёт (частичный прогресс не теряется).
|
||||
2. download_plan_image — reuse BrowserSession (cookies/TLS/throttle).
|
||||
3. повторный upsert_flat_plans с local_path/размерами — COALESCE внутри
|
||||
upsert защищает уже скачанные файлы от перезаписи.
|
||||
|
||||
Мутирует outcome['plan_downloaded']. Ошибки логируются внутри download/upsert,
|
||||
наружу не пробрасываются — планировка не должна ронять catalog-scrape квартиры.
|
||||
"""
|
||||
plan_row: dict[str, Any] = {
|
||||
"ods_id": ods_id,
|
||||
"obj_id": obj_id,
|
||||
"plan_image_url": plan_url,
|
||||
}
|
||||
upsert_flat_plans(db, obj_id, [plan_row], snapshot_date)
|
||||
|
||||
meta = await download_plan_image(session, plan_url, ods_id, obj_id)
|
||||
if meta:
|
||||
outcome["plan_downloaded"] = True
|
||||
upsert_flat_plans(db, obj_id, [{**plan_row, **meta}], snapshot_date)
|
||||
|
||||
|
||||
async def scrape_one_flat(
|
||||
session: BrowserSession,
|
||||
db: Session,
|
||||
ods_id: str,
|
||||
catalog_url_hash: str,
|
||||
obj_id: int | None = None,
|
||||
snapshot_date: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Scrape одной квартиры: fetch HTML → parse → upsert.
|
||||
"""Scrape одной квартиры: fetch HTML → parse → upsert (+ планировка).
|
||||
|
||||
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated}.
|
||||
Ошибки fetch/parse логируются, не бросаются — caller обрабатывает результат.
|
||||
Помимо catalog-полей (price/status/finishing/...) извлекает plan_image_url
|
||||
(issue #2440): пишет URL в domrf_kn_flat_plans через upsert_flat_plans, затем
|
||||
скачивает бинарник (download_plan_image) и повторным upsert'ом дописывает
|
||||
local_path/размеры (COALESCE-защита уже скачанных файлов внутри upsert).
|
||||
|
||||
Скачивание — inline в рамках того же BrowserSession: reuse cookies/TLS,
|
||||
download_binary несёт свой лёгкий throttle (jitter 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 обрабатывает результат.
|
||||
"""
|
||||
outcome: dict[str, Any] = {
|
||||
"ods_id": ods_id,
|
||||
|
|
@ -561,6 +805,8 @@ async def scrape_one_flat(
|
|||
"success": False,
|
||||
"fields_extracted": 0,
|
||||
"updated": False,
|
||||
"plan_found": False,
|
||||
"plan_downloaded": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
|
@ -590,6 +836,19 @@ async def scrape_one_flat(
|
|||
|
||||
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
|
||||
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
|
||||
|
||||
# ── plan_image_url → domrf_kn_flat_plans (issue #2440) ───────────────────
|
||||
plan_url = data.get("plan_image_url")
|
||||
if plan_url:
|
||||
outcome["plan_found"] = True
|
||||
if obj_id is not None and snapshot_date is not None:
|
||||
await _handle_plan_image(db, session, ods_id, obj_id, plan_url, snapshot_date, outcome)
|
||||
else:
|
||||
logger.warning(
|
||||
"catalog scrape ods_id=%s: plan_image_url found but obj_id/snapshot_date "
|
||||
"missing — flat_plans upsert skipped",
|
||||
ods_id,
|
||||
)
|
||||
# success отражает прохождение пайплайна fetch+parse без исключения; реально
|
||||
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
||||
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
||||
|
|
@ -617,11 +876,12 @@ async def scrape_catalog_batch(
|
|||
region_code: int = 66,
|
||||
headed: bool = False,
|
||||
load_state: str | None = None,
|
||||
snapshot_date: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Scrape пачки квартир каталога DOM.РФ.
|
||||
|
||||
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash}.
|
||||
Типовой источник: SELECT ods_id, catalog_url_hash FROM domrf_kn_flats
|
||||
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash [, obj_id]}.
|
||||
Типовой источник: SELECT ods_id, obj_id, catalog_url_hash FROM domrf_kn_flats
|
||||
WHERE catalog_url_hash IS NOT NULL
|
||||
AND (catalog_updated_at IS NULL OR catalog_updated_at < NOW() - INTERVAL '30 days')
|
||||
LIMIT :batch_size.
|
||||
|
|
@ -629,18 +889,25 @@ async def scrape_catalog_batch(
|
|||
Использует один BrowserSession на весь пакет (bootstrapped 1 раз).
|
||||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||
|
||||
obj_id/snapshot_date нужны для записи планировки в domrf_kn_flat_plans (issue
|
||||
#2440). snapshot_date по умолчанию = сегодня (дата первичного обнаружения URL).
|
||||
|
||||
Returns:
|
||||
{total, success, updated, failed, fields_total}
|
||||
{total, success, updated, failed, fields_total, plans_found, plans_downloaded}
|
||||
- success: прошли fetch+parse без исключения
|
||||
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||
фактическое число записанных квартир, в отличие от success
|
||||
- plans_found / plans_downloaded: планировки найдены / скачаны (#2440)
|
||||
"""
|
||||
snap = snapshot_date or date.today()
|
||||
stats: dict[str, Any] = {
|
||||
"total": len(flats),
|
||||
"success": 0,
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"fields_total": 0,
|
||||
"plans_found": 0,
|
||||
"plans_downloaded": 0,
|
||||
}
|
||||
|
||||
if not flats:
|
||||
|
|
@ -648,9 +915,10 @@ async def scrape_catalog_batch(
|
|||
return stats
|
||||
|
||||
logger.info(
|
||||
"scrape_catalog_batch: starting %d flats region=%d",
|
||||
"scrape_catalog_batch: starting %d flats region=%d snapshot_date=%s",
|
||||
len(flats),
|
||||
region_code,
|
||||
snap,
|
||||
)
|
||||
|
||||
async with BrowserSession(
|
||||
|
|
@ -659,21 +927,42 @@ async def scrape_catalog_batch(
|
|||
load_state=load_state,
|
||||
# auth=None — страницы каталога публичные, Basic auth не нужен
|
||||
auth=None,
|
||||
# #2445 D2 anti-ban: тот же /сервисы/* path family, что вызвал DOM.РФ WAF
|
||||
# hard-ban 2026-05-24 (#2443). Без явного override BrowserSession тихо
|
||||
# наследует модульный дефолт stealth.py (concurrency=8, jitter 600-1500ms).
|
||||
# Переиспользуем throttled настройки, принятые scrape_kn.py после #1945
|
||||
# (settings.scrape_kn_* — общий anti-ban рычаг, НЕ специфичный для KN-sweep,
|
||||
# несмотря на имя).
|
||||
concurrency=settings.scrape_kn_browser_concurrency,
|
||||
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||||
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||||
) as session:
|
||||
# Warm-up: WAF DOM.РФ (2026-05-24) требует session cookies для SSR-страниц.
|
||||
# Зеркалит scrape_catalog_objects. Idempotent на весь batch.
|
||||
await session.warm_up()
|
||||
|
||||
for flat in flats:
|
||||
ods_id = flat.get("ods_id", "")
|
||||
catalog_url_hash = flat.get("catalog_url_hash", "")
|
||||
obj_id_raw = flat.get("obj_id")
|
||||
obj_id = int(obj_id_raw) if obj_id_raw is not None else None
|
||||
if not ods_id or not catalog_url_hash:
|
||||
logger.warning("scrape_catalog_batch: skip flat with missing ods_id/hash: %r", flat)
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
|
||||
outcome = await scrape_one_flat(session, db, ods_id, catalog_url_hash)
|
||||
outcome = await scrape_one_flat(
|
||||
session, db, ods_id, catalog_url_hash, obj_id=obj_id, snapshot_date=snap
|
||||
)
|
||||
if outcome["success"]:
|
||||
stats["success"] += 1
|
||||
stats["fields_total"] += outcome["fields_extracted"]
|
||||
if outcome["updated"]:
|
||||
stats["updated"] += 1
|
||||
if outcome.get("plan_found"):
|
||||
stats["plans_found"] += 1
|
||||
if outcome.get("plan_downloaded"):
|
||||
stats["plans_downloaded"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
|
|
@ -689,11 +978,14 @@ async def scrape_catalog_batch(
|
|||
raise
|
||||
|
||||
logger.info(
|
||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
|
||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d "
|
||||
"fields_total=%d plans_found=%d plans_downloaded=%d",
|
||||
stats["total"],
|
||||
stats["success"],
|
||||
stats["updated"],
|
||||
stats["failed"],
|
||||
stats["fields_total"],
|
||||
stats["plans_found"],
|
||||
stats["plans_downloaded"],
|
||||
)
|
||||
return stats
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from typing import Any
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -440,6 +441,16 @@ async def scrape_catalog_objects(
|
|||
region_code=region_code,
|
||||
# Страницы каталога публичные — Basic auth не нужен
|
||||
auth=None,
|
||||
# #2445 D2 anti-ban: этот scraper ходит по тому же /сервисы/* path family,
|
||||
# что вызвал DOM.РФ WAF hard-ban 2026-05-24 (#2443). Без явного override
|
||||
# BrowserSession тихо наследует модульный дефолт stealth.py (concurrency=8,
|
||||
# jitter 600-1500ms) — тот самый throttle-less профиль, что и раньше
|
||||
# триггерил volume-ban. Переиспользуем throttled настройки, принятые
|
||||
# scrape_kn.py после #1945 (settings.scrape_kn_* — общий anti-ban рычаг,
|
||||
# НЕ специфичный для KN-sweep, несмотря на имя).
|
||||
concurrency=settings.scrape_kn_browser_concurrency,
|
||||
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||||
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||||
) as session:
|
||||
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies.
|
||||
# DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих cookies.
|
||||
|
|
|
|||
|
|
@ -432,6 +432,14 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
|||
digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8]
|
||||
flat_id = int.from_bytes(digest, "big") % (2**63 - 1)
|
||||
|
||||
# catalog_url_hash — тот же elemId (hash-часть URL квартиры в каталоге DOM.РФ):
|
||||
# https://наш.дом.рф/сервисы/каталог-квартир/квартира/{catalog_url_hash}.
|
||||
# Читаем ЗДЕСЬ (а не только в fallback-ветке flat_id выше) — при наличии flatId
|
||||
# ветка fallback не выполняется и elem остаётся непрочитанным. Пустую строку
|
||||
# нормализуем в None: `or None` зеркалит трактовку `if elem:` в fallback-ветке
|
||||
# и общий null-handling соседних optional-полей (_g отдаёт None при отсутствии).
|
||||
catalog_url_hash = _g(row, "elemId") or None
|
||||
|
||||
price_rub = _g(row, "price")
|
||||
price_per_m2 = _g(row, "pricePerSquareMeter")
|
||||
total_area = _g(row, "totalArea")
|
||||
|
|
@ -476,7 +484,10 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
|||
"ceiling_height_m": None, # заполнит catalog scraper 22d
|
||||
"key_handover_dt": None, # заполнит catalog scraper 22d
|
||||
"catalog_updated_at": None, # заполнит catalog scraper 22d
|
||||
"catalog_url_hash": None, # заполнит catalog scraper 22d
|
||||
# catalog_url_hash приходит из kn-API (elemId) уже здесь — нужен catalog
|
||||
# scraper'у (22d) для построения URL страницы квартиры. Ранее хардкодился
|
||||
# в None, из-за чего flat-catalog scraper не мог выбрать ни одной строки.
|
||||
"catalog_url_hash": catalog_url_hash,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -524,14 +535,29 @@ UPSERT_OBJECT_SQL = text(
|
|||
:snapshot_date
|
||||
)
|
||||
ON CONFLICT (obj_id, snapshot_date) DO UPDATE SET
|
||||
-- #2464 cluster E: hobj_id/short_addr/dev_inn/region_cd/floor_min/floor_max/
|
||||
-- problem_flag/green_house были в INSERT (populated every call by
|
||||
-- _norm_object) но пропущены в DO UPDATE SET → re-run скрапера для
|
||||
-- уже существующего (obj_id, snapshot_date) оставлял эти 8 колонок stale.
|
||||
-- Все 8 — direct EXCLUDED (не COALESCE): freshly computed каждый вызов,
|
||||
-- в т.ч. problem_flag должен ОБНУЛЯТЬСЯ, если проблема на объекте снята
|
||||
-- (COALESCE зафиксировал бы устаревший "проблемный" статус навсегда).
|
||||
hobj_id = EXCLUDED.hobj_id,
|
||||
comm_name = EXCLUDED.comm_name,
|
||||
addr = EXCLUDED.addr,
|
||||
short_addr = EXCLUDED.short_addr,
|
||||
region_cd = EXCLUDED.region_cd,
|
||||
dev_id = EXCLUDED.dev_id,
|
||||
dev_name = EXCLUDED.dev_name,
|
||||
dev_inn = EXCLUDED.dev_inn,
|
||||
floor_min = EXCLUDED.floor_min,
|
||||
floor_max = EXCLUDED.floor_max,
|
||||
flat_count = EXCLUDED.flat_count,
|
||||
square_living = EXCLUDED.square_living,
|
||||
ready_dt = EXCLUDED.ready_dt,
|
||||
problem_flag = EXCLUDED.problem_flag,
|
||||
site_status = EXCLUDED.site_status,
|
||||
green_house = EXCLUDED.green_house,
|
||||
escrow = EXCLUDED.escrow,
|
||||
obj_class = COALESCE(EXCLUDED.obj_class, domrf_kn_objects.obj_class),
|
||||
wall_type = COALESCE(EXCLUDED.wall_type, domrf_kn_objects.wall_type),
|
||||
|
|
@ -910,6 +936,9 @@ UPSERT_PHOTO_SQL = text(
|
|||
:local_path, :thumb_path, :downloaded_at
|
||||
)
|
||||
ON CONFLICT (obj_id, obj_file_id) DO UPDATE SET
|
||||
-- #2464 cluster F: build_type в INSERT column list (populated every call)
|
||||
-- но пропущен здесь → re-run скрапера оставлял build_type stale.
|
||||
build_type = EXCLUDED.build_type,
|
||||
ord_num = EXCLUDED.ord_num,
|
||||
photo_url = EXCLUDED.photo_url,
|
||||
photo_dttm = EXCLUDED.photo_dttm,
|
||||
|
|
|
|||
|
|
@ -33,15 +33,36 @@ Issue #297 sub-task 22c.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from app.services.scrapers.stealth import BrowserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Базовая директория для скачанных бинарников планировок. Зеркалит конвенцию
|
||||
# domrf_kn.py:PHOTOS_DIR_DEFAULT (data/raw/domrf_photos) — тот же raw-корень,
|
||||
# отдельная папка. Файлы: {base}/{obj_id}/{safe_ods_id}.{ext}.
|
||||
FLAT_PLANS_DIR_DEFAULT = Path("data/raw/domrf_flat_plans")
|
||||
|
||||
# Разрешённые расширения картинок планировок. png — дефолт (DOM.РФ отдаёт планы
|
||||
# растром), svg на всякий случай (векторные схемы встречаются).
|
||||
_ALLOWED_PLAN_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".svg")
|
||||
|
||||
# Замена небезопасных для имени файла символов в ods_id (напр. "65136/1/1.4.3").
|
||||
_ODS_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
# ── payload parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -212,30 +233,131 @@ def upsert_flat_plans(
|
|||
return inserted
|
||||
|
||||
|
||||
# ── download stub ─────────────────────────────────────────────────────────────
|
||||
# ── image download ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def download_plan_image_stub(
|
||||
def _safe_ods_filename(ods_id: str) -> str:
|
||||
"""Превратить ods_id (напр. '65136/1/1.4.3') в безопасное имя файла.
|
||||
|
||||
Слэши и прочие небезопасные символы → '_'. Результат используется как
|
||||
basename в {base_dir}/{obj_id}/{safe}.{ext}.
|
||||
"""
|
||||
return _ODS_UNSAFE_RE.sub("_", ods_id).strip("_") or "plan"
|
||||
|
||||
|
||||
def _ext_from_url(url: str) -> str:
|
||||
"""Определить расширение файла по URL планировки. Default .png.
|
||||
|
||||
DOM.РФ отдаёт планы преимущественно PNG; расширение берём из path, если
|
||||
оно в белом списке, иначе .png (file-API URL /api/ext/file/... часто без
|
||||
расширения — контент-тип уточнится по факту, но png — безопасный дефолт).
|
||||
"""
|
||||
path = urlsplit(url).path.lower()
|
||||
for ext in _ALLOWED_PLAN_EXTS:
|
||||
if path.endswith(ext):
|
||||
return ".jpg" if ext == ".jpeg" else ext
|
||||
return ".png"
|
||||
|
||||
|
||||
def _image_dims(data: bytes) -> tuple[int | None, int | None]:
|
||||
"""Получить (width_px, height_px) картинки через Pillow. None при ошибке.
|
||||
|
||||
Pillow (PIL) уже зависимость репо (pyproject: pillow>=10.4.0, используется в
|
||||
app.services.photos.thumbs). SVG Pillow не парсит — вернёт (None, None),
|
||||
это ОК (размеры для векторных планов необязательны).
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
return int(im.width), int(im.height)
|
||||
except Exception as exc:
|
||||
logger.debug("flat plan image dims parse failed: %s", exc)
|
||||
return None, None
|
||||
|
||||
|
||||
async def download_plan_image(
|
||||
session: BrowserSession,
|
||||
plan_image_url: str,
|
||||
ods_id: str,
|
||||
dest_dir: str | None = None,
|
||||
) -> str:
|
||||
"""Заглушка для скачивания бинарника планировки.
|
||||
obj_id: int,
|
||||
base_dir: Path | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Скачать бинарник планировки квартиры и вернуть метаданные для upsert.
|
||||
|
||||
Реальная реализация — отдельный Celery task (22d-track, issue #299).
|
||||
До реализации бросает NotImplementedError, чтобы случайный вызов
|
||||
не прошёл незаметно.
|
||||
Реиспользует BrowserSession.download_binary — тот же паттерн что download_photos
|
||||
в domrf_kn.py: тянет файл через browser context (те же cookies / TLS-fingerprint,
|
||||
что прошли WAF), с лёгким throttle (jitter 200–500 мс встроен в download_binary).
|
||||
|
||||
Файл сохраняется в {base_dir}/{obj_id}/{safe_ods_id}.{ext}. Если файл уже есть
|
||||
на диске — повторно НЕ качаем (idempotent, экономит WAF-бюджет), но всё равно
|
||||
возвращаем метаданные (размеры/размер читаются с диска).
|
||||
|
||||
Args:
|
||||
plan_image_url: URL картинки планировки.
|
||||
session: Активный BrowserSession (bootstrapped + warm_up).
|
||||
plan_image_url: Абсолютный URL картинки планировки.
|
||||
ods_id: Идентификатор квартиры (для имени файла).
|
||||
dest_dir: Директория для сохранения (None = MEDIA_ROOT/flat_plans/).
|
||||
obj_id: ID объекта DOM.РФ (подпапка).
|
||||
base_dir: Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT).
|
||||
|
||||
Raises:
|
||||
NotImplementedError: всегда — до реализации.
|
||||
Returns:
|
||||
dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at —
|
||||
для передачи в upsert_flat_plans. None при ошибке скачивания.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"download_plan_image_stub: скачивание планировок не реализовано. "
|
||||
"Реальный downloader — отдельный Celery task (issue #299, 22d-track). "
|
||||
f"URL={plan_image_url!r} ods_id={ods_id!r} dest_dir={dest_dir!r}"
|
||||
root = base_dir or FLAT_PLANS_DIR_DEFAULT
|
||||
obj_dir = root / str(obj_id)
|
||||
ext = _ext_from_url(plan_image_url)
|
||||
local = obj_dir / f"{_safe_ods_filename(ods_id)}{ext}"
|
||||
|
||||
# Idempotent: уже скачан — читаем метаданные с диска, не жжём WAF-запрос.
|
||||
if local.exists() and local.stat().st_size > 0:
|
||||
data = local.read_bytes()
|
||||
width_px, height_px = _image_dims(data)
|
||||
logger.debug("flat plan already on disk ods_id=%s path=%s", ods_id, local)
|
||||
return {
|
||||
"local_path": str(local),
|
||||
"width_px": width_px,
|
||||
"height_px": height_px,
|
||||
"size_bytes": local.stat().st_size,
|
||||
"downloaded_at": datetime.now(UTC),
|
||||
}
|
||||
|
||||
try:
|
||||
data = await session.download_binary(plan_image_url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"download_plan_image failed ods_id=%s obj_id=%s url=%s: %s",
|
||||
ods_id,
|
||||
obj_id,
|
||||
plan_image_url,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
if not data:
|
||||
logger.warning("download_plan_image empty body ods_id=%s url=%s", ods_id, plan_image_url)
|
||||
return None
|
||||
|
||||
obj_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
local.write_bytes(data)
|
||||
except OSError as exc:
|
||||
logger.warning("download_plan_image write failed ods_id=%s path=%s: %s", ods_id, local, exc)
|
||||
return None
|
||||
|
||||
width_px, height_px = _image_dims(data)
|
||||
logger.info(
|
||||
"download_plan_image ok ods_id=%s obj_id=%s bytes=%d dims=%sx%s",
|
||||
ods_id,
|
||||
obj_id,
|
||||
len(data),
|
||||
width_px,
|
||||
height_px,
|
||||
)
|
||||
return {
|
||||
"local_path": str(local),
|
||||
"width_px": width_px,
|
||||
"height_px": height_px,
|
||||
"size_bytes": len(data),
|
||||
"downloaded_at": datetime.now(UTC),
|
||||
}
|
||||
|
|
|
|||
501
backend/app/services/scrapers/gisogd66.py
Normal file
501
backend/app/services/scrapers/gisogd66.py
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
"""Загрузчик реестра РНС/РВЭ Свердловской области из открытого 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,12 +19,21 @@ searchMap отдаёт в properties **только {id}** — название/
|
|||
|
||||
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
|
||||
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
|
||||
|
||||
WAF-hygiene (#2445 item D3): sibling-скрапер без backoff словил бан IP по WAF
|
||||
DOM.РФ 24.05.2026 (#2443) — okn-mk.mkrf.ru имеет тот же risk shape (гос. CMS,
|
||||
десятки последовательных запросов без паузы). Идиома пэйсинга/backoff/ретраев
|
||||
портирована из ``gisogd66.py`` (``_RETRY_BACKOFF_S`` + сон между попытками) и
|
||||
``objective.py`` (явная обработка 429/Retry-After): rate-limit между запросами
|
||||
(``_wait_rate_limit``), экспоненциальный backoff на 429/5xx с капом
|
||||
(``_do_request``/``_do_detail_request``), уважение заголовка Retry-After.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -42,6 +51,47 @@ _DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show"
|
|||
_REQUEST_TIMEOUT = 30.0
|
||||
_DETAIL_TIMEOUT = 10.0
|
||||
|
||||
# ── WAF-hygiene: пэйсинг + backoff (#2445 item D3) ────────────────────────────
|
||||
# Вежливая пауза между последовательными запросами (мс) — тот же порядок величины,
|
||||
# что и у objective.py (_DEFAULT_RATE_MS=500) / nspd_client.py (DEFAULT_RATE_MS=600).
|
||||
_RATE_LIMIT_MS = 400
|
||||
# Ретраи на 429/5xx (helper делает суммарно до 1 + _MAX_RETRIES попыток).
|
||||
_MAX_RETRIES = 3
|
||||
# Экспоненциальный backoff на 429/5xx: base * 2**attempt, капается _MAX_BACKOFF_S
|
||||
# (аналогично objective.py: 30 * 2**attempt, cap 300s — здесь эндпоинт легче,
|
||||
# поэтому база/потолок меньше, но идиома та же).
|
||||
_RETRY_BACKOFF_BASE_S = 2.0
|
||||
_MAX_BACKOFF_S = 60.0
|
||||
|
||||
# process-локальная метка времени последнего запроса (обеих эндпоинтов) —
|
||||
# rate-limit общий, чтобы 33 list-запроса и до 1500 detail-запросов не долбили
|
||||
# WAF без пауз даже при чередовании вызовов.
|
||||
_last_request_ts: float = 0.0
|
||||
|
||||
|
||||
def _wait_rate_limit() -> None:
|
||||
"""Выдержать паузу ``_RATE_LIMIT_MS`` с последнего запроса к okn-mk.mkrf.ru.
|
||||
|
||||
Общий для searchMap и detail — обе цели один и тот же WAF-хост.
|
||||
"""
|
||||
global _last_request_ts
|
||||
elapsed_ms = (time.time() - _last_request_ts) * 1000.0
|
||||
if elapsed_ms < _RATE_LIMIT_MS:
|
||||
time.sleep((_RATE_LIMIT_MS - elapsed_ms) / 1000.0)
|
||||
_last_request_ts = time.time()
|
||||
|
||||
|
||||
def _backoff_seconds(attempt: int, retry_after_hdr: str | None) -> float:
|
||||
"""Секунды ожидания перед повтором после 429/5xx.
|
||||
|
||||
Retry-After (если валиден) уважается первым, но капается _MAX_BACKOFF_S —
|
||||
аналогично objective.py. Иначе экспоненциальный backoff от attempt (0-based).
|
||||
"""
|
||||
if retry_after_hdr and retry_after_hdr.isdigit():
|
||||
return min(float(retry_after_hdr), _MAX_BACKOFF_S)
|
||||
return min(_RETRY_BACKOFF_BASE_S * (2**attempt), _MAX_BACKOFF_S)
|
||||
|
||||
|
||||
# Browser headers -- required: without User-Agent the server returns 403.
|
||||
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его
|
||||
# автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется.
|
||||
|
|
@ -99,7 +149,7 @@ def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]:
|
|||
Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0].
|
||||
"""
|
||||
form_data = _build_form_data(address, category_type)
|
||||
features = _do_request(form_data, verify=True)
|
||||
features = _do_request(form_data, verify=True, attempt=0)
|
||||
return features
|
||||
|
||||
|
||||
|
|
@ -123,19 +173,37 @@ def _do_request(
|
|||
form_data: list[tuple[str, str]],
|
||||
*,
|
||||
verify: bool,
|
||||
attempt: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False."""
|
||||
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False.
|
||||
|
||||
WAF-hygiene: rate-limit перед каждой попыткой (``_wait_rate_limit``), на 429/5xx —
|
||||
экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз (аналогично objective.py).
|
||||
"""
|
||||
# urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами
|
||||
# (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple]
|
||||
# конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает
|
||||
# «expected bytes, tuple found». content= передаёт тело как raw str.
|
||||
body = urllib.parse.urlencode(form_data)
|
||||
_wait_rate_limit()
|
||||
try:
|
||||
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client:
|
||||
resp = client.post(
|
||||
_SEARCHMAP_URL,
|
||||
content=body,
|
||||
)
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
|
||||
logger.warning(
|
||||
"okn_egrkn_client: HTTP %d от ЕГРКН (попытка %d/%d), retry через %.1fs",
|
||||
resp.status_code,
|
||||
attempt + 1,
|
||||
_MAX_RETRIES,
|
||||
wait,
|
||||
)
|
||||
time.sleep(wait)
|
||||
return _do_request(form_data, verify=verify, attempt=attempt + 1)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc)
|
||||
|
|
@ -145,7 +213,7 @@ def _do_request(
|
|||
logger.warning(
|
||||
"okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc
|
||||
)
|
||||
return _do_request(form_data, verify=False)
|
||||
return _do_request(form_data, verify=False, attempt=attempt)
|
||||
logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc)
|
||||
return []
|
||||
except Exception as exc:
|
||||
|
|
@ -275,17 +343,36 @@ def fetch_okn_detail(source_id: str) -> dict[str, Any]:
|
|||
"""
|
||||
if not source_id:
|
||||
return {}
|
||||
return _do_detail_request(source_id, verify=True)
|
||||
return _do_detail_request(source_id, verify=True, attempt=0)
|
||||
|
||||
|
||||
def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
|
||||
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap."""
|
||||
def _do_detail_request(source_id: str, *, verify: bool, attempt: int = 0) -> dict[str, Any]:
|
||||
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap.
|
||||
|
||||
WAF-hygiene: rate-limit перед каждой попыткой (общий с searchMap — один WAF-хост),
|
||||
на 429/5xx — экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз.
|
||||
"""
|
||||
params = {"id": source_id}
|
||||
_wait_rate_limit()
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
|
||||
) as client:
|
||||
resp = client.get(_DETAIL_URL, params=params)
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
|
||||
logger.warning(
|
||||
"okn_egrkn_client: detail HTTP %d source_id=%s "
|
||||
"(попытка %d/%d), retry через %.1fs",
|
||||
resp.status_code,
|
||||
source_id,
|
||||
attempt + 1,
|
||||
_MAX_RETRIES,
|
||||
wait,
|
||||
)
|
||||
time.sleep(wait)
|
||||
return _do_detail_request(source_id, verify=verify, attempt=attempt + 1)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
|
|
@ -299,7 +386,7 @@ def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
|
|||
logger.warning(
|
||||
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
|
||||
)
|
||||
return _do_detail_request(source_id, verify=False)
|
||||
return _do_detail_request(source_id, verify=False, attempt=attempt)
|
||||
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
|
||||
return {}
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ HTML server-rendered (нет JS-рендеринга), httpx достаточн
|
|||
|
||||
TLS: сайт использует российский УЦ → verify=False (аналогично ekburg_permits #242).
|
||||
|
||||
Politeness (#2445 D4): пауза ``_RATE_S`` перед каждым запросом (search / карточка
|
||||
документа / PDF) — тот же идиом, что nspd_client/objective/gisogd66 (300-500мс). Этот
|
||||
VPS уже словил WAF-бан от DOM.РФ 24.05.2026 (#2443) за непейсованные последовательные
|
||||
запросы; pravo.gov66.ru — сайт той же формы (гос-портал region, строгий WAF). Ретрай с
|
||||
backoff на 429/5xx — идиом gisogd66._get_json.
|
||||
|
||||
Публичный API:
|
||||
search_documents(query, law_type, since) → list[dict]
|
||||
get_document_pdf_url(doc_id) → str | None
|
||||
|
|
@ -15,6 +21,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -28,6 +35,14 @@ _SEARCH_URL = f"{_BASE_URL}/search/"
|
|||
# Тайм-аут щедрый: сервер иногда медленный.
|
||||
_TIMEOUT = httpx.Timeout(60.0, connect=15.0)
|
||||
|
||||
# Politeness pacing (#2445 D4): пауза ПЕРЕД каждым запросом к pravo.gov66.ru — тот же
|
||||
# идиом (300-500мс), что nspd_client/objective/gisogd66. Не непейсованный WAF-риск,
|
||||
# аналогичный DOM.РФ-бану #2443.
|
||||
_RATE_S = 0.4
|
||||
# Ретраи на 429/5xx: экспоненциальный backoff, суммарно до 1 + _MAX_RETRIES попыток.
|
||||
_MAX_RETRIES = 2
|
||||
_RETRY_BACKOFF_S = 2.0
|
||||
|
||||
# Числовые коды типов документов (select#id_law_type на сайте).
|
||||
# Текстовое значение «Постановление» — неверный параметр, сервер не матчит → 0 хитов.
|
||||
_LAW_TYPE_POSTANOVLENIE = "1" # Постановление Правительства Свердловской области
|
||||
|
|
@ -41,6 +56,43 @@ _RE_PDF_PATH = re.compile(r"/media/pravo/(?!ecp/)[^\s\"']+\.pdf", re.IGNORECASE)
|
|||
_RE_PDF_ECP_PATH = re.compile(r"/media/pravo/ecp/[^\s\"']+\.pdf", re.IGNORECASE)
|
||||
|
||||
|
||||
def _get_with_backoff(
|
||||
client: httpx.Client,
|
||||
url: str,
|
||||
*,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""GET с politeness-паузой перед запросом + backoff-ретраем на 429/5xx.
|
||||
|
||||
Пауза ``_RATE_S`` — ПЕРЕД каждой попыткой (в т.ч. первой): пейсинг
|
||||
последовательных запросов к pravo.gov66.ru, не только ретраев (#2445 D4).
|
||||
429/5xx ретраятся до ``_MAX_RETRIES`` раз с экспоненциальным backoff;
|
||||
прочие статусы (включая 4xx кроме 429) — сразу ``raise_for_status()``.
|
||||
"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(_MAX_RETRIES + 1):
|
||||
time.sleep(_RATE_S)
|
||||
resp = client.get(url, params=params)
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
last_exc = httpx.HTTPStatusError(
|
||||
f"{resp.status_code} on {url}", request=resp.request, response=resp
|
||||
)
|
||||
if attempt >= _MAX_RETRIES:
|
||||
raise last_exc
|
||||
logger.warning(
|
||||
"pravo_gov66_client: %s (попытка %d) на %s — backoff",
|
||||
resp.status_code,
|
||||
attempt + 1,
|
||||
url,
|
||||
)
|
||||
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
def search_documents(
|
||||
query: str,
|
||||
law_type: str = _LAW_TYPE_POSTANOVLENIE,
|
||||
|
|
@ -65,8 +117,7 @@ def search_documents(
|
|||
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||
resp = client.get(_SEARCH_URL, params=params)
|
||||
resp.raise_for_status()
|
||||
resp = _get_with_backoff(client, _SEARCH_URL, params=params)
|
||||
html = resp.text
|
||||
except Exception as exc:
|
||||
logger.error("pravo_gov66_client.search_documents: ошибка сети q=%r: %s", query, exc)
|
||||
|
|
@ -117,8 +168,7 @@ def get_document_pdf_url(doc_id: str) -> str | None:
|
|||
doc_url = f"{_BASE_URL}/{doc_id}/"
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||
resp = client.get(doc_url)
|
||||
resp.raise_for_status()
|
||||
resp = _get_with_backoff(client, doc_url)
|
||||
html = resp.text
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
|
|
@ -151,6 +201,10 @@ def _extract_pdf_url(html: str) -> str | None:
|
|||
def fetch_pdf(url: str) -> bytes:
|
||||
"""Загружает PDF по URL.
|
||||
|
||||
Пейсинг + backoff на 429/5xx через ``_get_with_backoff`` (#2445 D4) — caller
|
||||
(reservation_ingest) НЕ должен добавлять свой sleep перед этим вызовом, пауза
|
||||
уже внутри клиента.
|
||||
|
||||
Args:
|
||||
url: абсолютный URL PDF-файла.
|
||||
|
||||
|
|
@ -161,8 +215,7 @@ def fetch_pdf(url: str) -> bytes:
|
|||
httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully).
|
||||
"""
|
||||
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
resp = _get_with_backoff(client, url)
|
||||
return resp.content
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,11 @@ USER_AGENT = (
|
|||
|
||||
# Максимум одновременных in-page fetch() на одну BrowserSession;
|
||||
# поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant.
|
||||
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется catalog/nspd/прочими скраперами. KN-sweep с
|
||||
# 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
|
||||
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2.
|
||||
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется nspd/прочими скраперами вне /сервисы/* path
|
||||
# family. KN-sweep с 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
|
||||
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2; после
|
||||
# WAF hard-ban 2026-05-24 (#2443) domrf_catalog.py / domrf_catalog_object.py — тот же
|
||||
# /сервисы/* path family — тоже переопределяют на throttled настройки (#2445 D2).
|
||||
_BROWSER_CONCURRENCY = 8
|
||||
|
||||
# Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -449,16 +449,42 @@ _COMPETITORS_SQL = text("""
|
|||
# scraper UPSERT per snapshot). Без фильтра snapshot_date AVG усреднял
|
||||
# ИСТОРИЮ цен (stale на растущем рынке). Берём ТОЛЬКО latest snapshot — зеркало
|
||||
# паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON.
|
||||
#
|
||||
# === PER-OBJECT snapshot fix (#2445 A5, byte-identical bug к #1956 в best_layouts) ===
|
||||
# domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в СВОЙ день,
|
||||
# UNIQUE-снимок (obj_id, snapshot_date), см. migration 174_domrf_kn_flats_obj_
|
||||
# snapshot_idx.sql. Прежний фильтр брал ОДНУ глобальную дату
|
||||
# `f.snapshot_date = MAX(snapshot_date по всей таблице)` — на этой единственной
|
||||
# глобал-max дате присутствует обычно лишь 1 объект (скрейпнутый последним) →
|
||||
# у ВСЕХ остальных конкурентов в радиусе 0 строк flats → avg_price_per_m2=NULL
|
||||
# → _price_similarity(None, ...) молча возвращает нейтраль 0.5, тихо деградируя
|
||||
# relevance-ранжирование конкурентов без единого флага.
|
||||
# Фикс (зеркало best_layouts.flats_latest, #1956): flats_latest берёт для КАЖДОГО
|
||||
# obj_id его СОБСТВЕННЫЙ последний снимок (DISTINCT ON (f.obj_id) ... ORDER BY
|
||||
# f.obj_id, snapshot_date DESC, id DESC), затем self-join к domrf_kn_flats по
|
||||
# (obj_id, snapshot_date) достаёт квартиры именно этого снимка. AVG считается
|
||||
# per-object на СВЕЖЕЙ дате объекта, а не на единственной глобал-max дате.
|
||||
# Слоение с OBJ-3 (#307) не меняется: этот запрос остаётся ПЕРВЫМ источником
|
||||
# (domrf), _OBJECTIVE_PRICE_FALLBACK_SQL — additive fallback ТОЛЬКО для obj_id,
|
||||
# у которых и после фикса нет domrf-цены (см. get_competitors шаг 4a). Ожидаемый
|
||||
# эффект: domrf возвращает реальные цены для БОЛЬШЕ конкурентов → fallback нужен
|
||||
# реже, но композиция (missing_price_ids / price_source) не меняется.
|
||||
_AVG_PRICE_SQL = text("""
|
||||
WITH flats_latest AS (
|
||||
SELECT DISTINCT ON (f.obj_id)
|
||||
f.obj_id, f.snapshot_date
|
||||
FROM domrf_kn_flats f
|
||||
WHERE f.obj_id = ANY(:obj_ids)
|
||||
ORDER BY f.obj_id, f.snapshot_date DESC, f.id DESC
|
||||
)
|
||||
SELECT
|
||||
f.obj_id,
|
||||
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
|
||||
AS avg_price_per_m2
|
||||
FROM domrf_kn_flats f
|
||||
WHERE f.obj_id = ANY(:obj_ids)
|
||||
AND f.snapshot_date = (
|
||||
SELECT MAX(snapshot_date) FROM domrf_kn_flats
|
||||
)
|
||||
FROM flats_latest fl
|
||||
JOIN domrf_kn_flats f
|
||||
ON f.obj_id = fl.obj_id
|
||||
AND f.snapshot_date = fl.snapshot_date
|
||||
GROUP BY f.obj_id
|
||||
""")
|
||||
|
||||
|
|
@ -773,7 +799,12 @@ def get_competitors(
|
|||
avg_price_map: dict[int, float] = {}
|
||||
price_source_map: dict[int, str] = {}
|
||||
try:
|
||||
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
||||
# SAVEPOINT — db shared с caller (POST .../competitors, Depends(get_db)). Голый
|
||||
# db.rollback() orphan'ит outer SessionTransaction (velocity.py:170) — тем более
|
||||
# здесь ЕЩЁ ДВА db.execute идут следом в этой же функции (sold-count,
|
||||
# objective-fallback) на том же db; без savepoint сбой здесь отравил бы их тоже.
|
||||
with db.begin_nested():
|
||||
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
||||
for r in price_rows:
|
||||
oid = int(r["obj_id"])
|
||||
price = _row_get(r, "avg_price_per_m2")
|
||||
|
|
@ -792,11 +823,16 @@ def get_competitors(
|
|||
# flats_sold → None → нейтральный stage.
|
||||
sold_count_map: dict[int, int] = {}
|
||||
try:
|
||||
sold_rows = (
|
||||
db.execute(_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND})
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
# SAVEPOINT — db shared, см. avg_price блок выше (иначе отравляет objective
|
||||
# price fallback ниже на том же db).
|
||||
with db.begin_nested():
|
||||
sold_rows = (
|
||||
db.execute(
|
||||
_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND}
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
for r in sold_rows:
|
||||
oid = int(r["obj_id"])
|
||||
sold = _row_get(r, "flats_sold")
|
||||
|
|
@ -812,17 +848,21 @@ def get_competitors(
|
|||
missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map]
|
||||
if missing_price_ids:
|
||||
try:
|
||||
obj_price_rows = (
|
||||
db.execute(
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
{
|
||||
"obj_ids": missing_price_ids,
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
# SAVEPOINT — db shared, см. avg_price блок выше. Последний из трёх
|
||||
# db.execute в get_competitors — тоже под savepoint, чтобы дальнейшие
|
||||
# запросы analyze на этом же db (persist_analysis_run и т.п.) не отравились.
|
||||
with db.begin_nested():
|
||||
obj_price_rows = (
|
||||
db.execute(
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
{
|
||||
"obj_ids": missing_price_ids,
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
for r in obj_price_rows:
|
||||
oid = int(r["obj_id"])
|
||||
price = _row_get(r, "median_price_per_m2")
|
||||
|
|
|
|||
|
|
@ -9,8 +9,18 @@
|
|||
- 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).
|
||||
|
|
@ -19,13 +29,35 @@ 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 = "Резерв — по запросу в АО «ЕЭСК» (город)"
|
||||
|
|
@ -45,7 +77,8 @@ def get_connection_capacity(
|
|||
|
||||
Returns:
|
||||
{"power_points": [...], "power_summary": {...}, "water": [...],
|
||||
"gas": {...}|None, "heat": {...}|None}
|
||||
"gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None,
|
||||
"nearby_network_zones": [...]}
|
||||
|
||||
Raises:
|
||||
ValueError: участок не найден в БД (нет geom).
|
||||
|
|
@ -63,14 +96,18 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -211,33 +248,47 @@ def _query_gas_city_grs(db: Session) -> dict:
|
|||
→ city_grs=[] (не падаем — блок аддитивный, gas_grs_capacity может ещё не
|
||||
существовать до применения миграции 181).
|
||||
|
||||
Returns: {"city_grs": [...], "total_free_th_m3_h": float|None}.
|
||||
Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из
|
||||
gas_grs_outlet_points (форма 6 ФАС №960 АО «Екатеринбурггаз») за последний
|
||||
period_month — outlets_total / outlets_deficit (free<0) / outlets_needs_calc.
|
||||
См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС».
|
||||
|
||||
Returns: {"city_grs": [...], "total_free_th_m3_h": float|None,
|
||||
"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
|
||||
Размерности: свободная мощность ГРС — тыс. м³/ЧАС; свободная мощность точек выхода
|
||||
(в счётчиках/gas_outlet_points) — млн. м³/МЕС. НЕ путать при агрегации.
|
||||
"""
|
||||
outlet_counts = _query_gas_outlet_counts(db)
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT grs_name,
|
||||
SUM(design_capacity_th_m3_h) AS design_sum,
|
||||
SUM(free_capacity_th_m3_h) AS free_sum,
|
||||
AVG(free_capacity_pct) AS pct_avg,
|
||||
MAX(upgrade_due) AS upgrade_due,
|
||||
COUNT(*) AS outputs_count
|
||||
FROM gas_grs_capacity
|
||||
WHERE grs_name ILIKE '%свердловск%'
|
||||
OR grs_name ILIKE '%екатеринбург%'
|
||||
GROUP BY grs_name
|
||||
ORDER BY grs_name
|
||||
""")
|
||||
# SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)).
|
||||
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||||
# begin_nested откатывает только эту savepoint, outer tx остаётся usable.
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT grs_name,
|
||||
SUM(design_capacity_th_m3_h) AS design_sum,
|
||||
SUM(free_capacity_th_m3_h) AS free_sum,
|
||||
AVG(free_capacity_pct) AS pct_avg,
|
||||
MAX(upgrade_due) AS upgrade_due,
|
||||
COUNT(*) AS outputs_count
|
||||
FROM gas_grs_capacity
|
||||
WHERE grs_name ILIKE '%свердловск%'
|
||||
OR grs_name ILIKE '%екатеринбург%'
|
||||
GROUP BY grs_name
|
||||
ORDER BY grs_name
|
||||
""")
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception as e:
|
||||
# Таблица может не существовать до применения миграции 181 (schema-first).
|
||||
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
|
||||
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
|
||||
# точек выхода (миграция 184, независимая) — оставляем, что удалось собрать.
|
||||
logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e)
|
||||
return {"city_grs": [], "total_free_th_m3_h": None}
|
||||
return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts}
|
||||
|
||||
city_grs: list[dict] = []
|
||||
total_free = 0.0
|
||||
|
|
@ -262,9 +313,139 @@ 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:
|
||||
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.
|
||||
|
||||
|
|
@ -278,23 +459,25 @@ def _query_heat_latest(db: Session) -> dict:
|
|||
"total_reserve_gcal_h": float|None}.
|
||||
"""
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
|
||||
FROM heat_system_reserves h
|
||||
WHERE h.period IS NOT NULL
|
||||
AND h.period = (
|
||||
SELECT MAX(h2.period) FROM heat_system_reserves h2
|
||||
WHERE h2.period IS NOT NULL
|
||||
AND h2.org = h.org
|
||||
)
|
||||
ORDER BY h.org, h.system_name
|
||||
""")
|
||||
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
|
||||
FROM heat_system_reserves h
|
||||
WHERE h.period IS NOT NULL
|
||||
AND h.period = (
|
||||
SELECT MAX(h2.period) FROM heat_system_reserves h2
|
||||
WHERE h2.period IS NOT NULL
|
||||
AND h2.org = h.org
|
||||
)
|
||||
ORDER BY h.org, h.system_name
|
||||
""")
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception as e:
|
||||
# Таблица может не существовать до применения миграции 182 (schema-first).
|
||||
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
|
||||
|
|
@ -323,6 +506,129 @@ 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,14 +251,19 @@ def get_overlaps_for_scoring(
|
|||
"""
|
||||
_parcel_cad = parcel_cad or ""
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
text(_OVERLAPS_SQL),
|
||||
{"wkt": parcel_geom_wkt, "user_id": user_id, "parcel_cad": _parcel_cad},
|
||||
# SAVEPOINT — db shared с caller (POST .../analyze, Depends(get_db)). Голый
|
||||
# db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||||
# begin_nested откатывает только эту savepoint, оставляя db usable для
|
||||
# остальных запросов analyze_parcel на этой же сессии.
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
text(_OVERLAPS_SQL),
|
||||
{"wkt": parcel_geom_wkt, "user_id": user_id, "parcel_cad": _parcel_cad},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": int(r["id"]),
|
||||
|
|
|
|||
|
|
@ -142,14 +142,22 @@ def get_developer_attribution(
|
|||
- БД-ошибке, включая ещё не задеплоенную миграцию 149 (analyze не падает).
|
||||
"""
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
_DEVELOPER_FOR_PARCEL_SQL,
|
||||
{"cad_num": cad_num, "radius": radius_m},
|
||||
# SAVEPOINT — db получена от caller (shared request-scoped Session,
|
||||
# analyze_parcel в parcels.py). Голый db.rollback() здесь НЕЛЬЗЯ — он
|
||||
# orphan'ит outer SessionTransaction и роняет persist_analysis_run сразу
|
||||
# после (см. velocity.py:170 / connection_capacity_lookup._query_nearby_
|
||||
# network_zones). Ошибка внутри begin_nested() откатывает ТОЛЬКО эту
|
||||
# savepoint до propagate — outer-транзакция остаётся usable для
|
||||
# последующих db.execute (#2464 cluster A finding 1).
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
_DEVELOPER_FOR_PARCEL_SQL,
|
||||
{"cad_num": cad_num, "radius": radius_m},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except (OperationalError, ProgrammingError, DataError) as exc:
|
||||
# Миграция 149 ещё не задеплоена / БД-ошибка — graceful degrade.
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -68,12 +68,22 @@ def _admin_names(db: Session) -> frozenset[str]:
|
|||
|
||||
На ошибке БД возвращает пустой set (logged) — резолвер тогда трактует ЛЮБОЙ
|
||||
вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.
|
||||
|
||||
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): резолвер
|
||||
зовётся §22-форсайт-билдерами (sales_series `_query_source_a/_b`,
|
||||
`_query_artificial_demand`) на ОБЩЕЙ сессии отчёта, которые сами обёрнуты во
|
||||
внешние SAVEPOINT'ы. БЕЗ `db.begin_nested():` здесь сбойный `db.execute`
|
||||
проглатывается (→ frozenset()), оставляя транзакцию aborted; тогда внешний
|
||||
SAVEPOINT при RELEASE падает (RELEASE запрещён в aborted-tx) и отравление
|
||||
каскадит. Savepoint в точке перехвата откатывает сбой (ROLLBACK TO SAVEPOINT),
|
||||
сессия остаётся рабочей.
|
||||
"""
|
||||
global _ADMIN_NAMES_CACHE
|
||||
if _ADMIN_NAMES_CACHE is not None:
|
||||
return _ADMIN_NAMES_CACHE
|
||||
try:
|
||||
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
|
||||
_ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
|
||||
except Exception:
|
||||
# Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова.
|
||||
|
|
@ -122,8 +132,13 @@ def resolve_objective_districts(db: Session, district: str | None) -> list[str]
|
|||
return [district]
|
||||
|
||||
# district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
|
||||
# SAVEPOINT на внутреннем swallow-сайте (#2464 cluster A, RELEASE-trap): см.
|
||||
# `_admin_names` — тот же общий-сессионный вызов из §22-билдеров под внешним
|
||||
# SAVEPOINT. `db.begin_nested():` откатывает сбой в точке перехвата, чтобы
|
||||
# внешний RELEASE не упал в aborted-tx (RELEASE запрещён в aborted).
|
||||
try:
|
||||
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
|
||||
micros = [str(m) for m in rows]
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
|
|
|||
|
|
@ -69,9 +69,21 @@ _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.
|
||||
|
|
|
|||
804
backend/app/services/site_finder/ekgas_outlet_loader.py
Normal file
804
backend/app/services/site_finder/ekgas_outlet_loader.py
Normal file
|
|
@ -0,0 +1,804 @@
|
|||
"""Загрузчик адресных точек выхода газораспределительной сети (#2119 Phase B2, PR-2).
|
||||
|
||||
Источник — раскрытие информации АО «Екатеринбурггаз» (ГРО), форма 6 приложения №4
|
||||
к Приказу ФАС России №960 от 08.12.2022 «О порядке раскрытия информации…»: построчная
|
||||
выгрузка «точек выхода» ГРС (~1037 строк/мес) — объём заявок, удовлетворено, свободная
|
||||
мощность точки выхода (млн. м³/МЕСЯЦ — НЕ тыс. м³/ч как в gas_grs_capacity!). UPSERT-ит
|
||||
в ``gas_grs_outlet_points``.
|
||||
|
||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery monthly / manual
|
||||
docker exec) — как gas_grs_capacity (B1, ГТЕ) и rosseti-/vodokanal-/eias-лоадеры. Код не
|
||||
предполагает локальный сетевой доступ. ТОЛЬКО apex-домен ``ekgas.ru`` — ``www.ekgas.ru``
|
||||
даёт ``SSLV3_ALERT_HANDSHAKE_FAILURE``. SSL через общий ``_ru_ssl_context`` из
|
||||
gazprom_grs_loader (Russian Trusted CA Минцифры), httpx с явным таймаутом.
|
||||
|
||||
Данные лежат за 2 GET-переходами:
|
||||
|
||||
1. Каталог раскрытия (``/raskrytie-informacii/raskrytie-informatsii-1``, ~800KB HTML,
|
||||
585 ссылок на файлы). Целевой — «Форма 6 приложения № 4 к Приказу ФАС России № 960
|
||||
… (на|за) <месяц> <год>г..xlsx», путь ``/uploads/default/<hex>/<имя>``. Hex меняется
|
||||
с каждым файлом → URL НЕ ЯКОРИМ: парсим каталог, вытаскиваем период «(за|на) <месяц>
|
||||
<год>» + дату публикации (asof, DD.MM.YYYY рядом со ссылкой). Годовой «Форма №6
|
||||
(Приложение №2) … план на <год>» (.xls) — СКИП (не адресный, xlrd в стеке нет).
|
||||
|
||||
Выбор свежайшего: приоритет ФАКТА над прогнозом. Берём максимальный (год, месяц)
|
||||
среди «за» (is_forecast=False); если файлов «за» НЕТ вообще — максимальный «на»
|
||||
(is_forecast=True, прогноз). ВНИМАНИЕ: даже если «на» новее по (год, месяц), чем
|
||||
любой «за», всё равно берём «за» (факт важнее прогноза). См. select_latest_file.
|
||||
|
||||
2. XLSX (~91КБ, 1 лист): подписи в row 10 (1-indexed), нумерация 1..7 в row 11, данные
|
||||
с row 12 (~1037 строк, последняя — сноска про «*», single-cell → скип). Колонки
|
||||
(1-indexed): 1=«Точка входа» (ГРС, block-marker — заполнена ТОЛЬКО в первой строке
|
||||
блока → forward-fill; 5 блоков), 2=«Точка выхода» (свободный текст: юрлицо/объект,
|
||||
иногда адрес внутри), 3=тип (котельная/ТЭЦ/…), 4=группа потребления, 5=объём заявок,
|
||||
6=удовлетворено, 7=свободная мощность — float ИЛИ текст-маркер «0.000*» (НЕ ноль! →
|
||||
free_capacity_mln_m3=NULL, free_capacity_needs_calc=TRUE).
|
||||
|
||||
grs_name_norm нормализуется ТЕМ ЖЕ ``normalize_grs_name`` из gazprom_grs_loader (B1) —
|
||||
иначе JOIN «точка выхода → свободная мощность ГРС» на gas_grs_capacity порвётся.
|
||||
Числовой парс — общий ``parse_reserve_number`` из rosseti_reserve_loader (запятая, «—» →
|
||||
None, знак сохраняется: дефицит −0.084 хранится знаком). Вся строка целиком → ``raw``
|
||||
jsonb (будущие/неизвестные колонки не теряются).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.dadata_client import clean_address
|
||||
from app.services.site_finder.gazprom_grs_loader import _ru_ssl_context, normalize_grs_name
|
||||
from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ТОЛЬКО apex-домен — www.ekgas.ru отдаёт SSLV3_ALERT_HANDSHAKE_FAILURE (разведка
|
||||
# 2026-07-03). Каталог раскрытия информации со ссылками на помесячные формы 6.
|
||||
BASE_URL = "https://ekgas.ru"
|
||||
CATALOG_URL = "https://ekgas.ru/raskrytie-informacii/raskrytie-informatsii-1"
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# ── Геокод address_extracted → geom (PR-3, #2119 B2) ──────────────────────────
|
||||
# Cap вызовов DaData за один прогон (адресов на проде ~164 → влезает; страхует
|
||||
# квоту от разбухшего файла). Тот же порядок, что GEO_DADATA_CALL_LIMIT в
|
||||
# objective_backfill.
|
||||
_GEO_DADATA_CAP = 200
|
||||
|
||||
# Префикс города: address_extracted — адресный ХВОСТ без города («ул. Стрелочников,
|
||||
# д. 2»). DaData на строке без города отдаёт qc_geo=5 (нет координат) — прод-урок
|
||||
# objective_backfill 2026-07-03. Точки выхода ЕКБ-газа привязаны к Екатеринбургу и
|
||||
# окрестностям → безопасно префиксуем городом, если его в строке нет.
|
||||
_EKB_PREFIX = "г Екатеринбург, "
|
||||
|
||||
# Sanity-bbox Екатеринбурга и ближних окрестностей (Арамиль/Реж/Берёзовский/Кольцово):
|
||||
# DaData на кривом/усечённом адресе может «дорисовать» другой город — точку вне bbox
|
||||
# НЕ пишем (честность: лучше NULL geom, чем ложная координата в Москве). Границы
|
||||
# грубые с запасом: lat 56.5..57.2, lon 60.0..61.2.
|
||||
_EKB_BBOX_LAT = (56.5, 57.2)
|
||||
_EKB_BBOX_LON = (60.0, 61.2)
|
||||
|
||||
# Целевой лист XLSX (единственный) — берём активный, если имя не совпало.
|
||||
# 1-indexed строки листа: подписи колонок = row 10, нумерация 1..7 = row 11, данные с 12.
|
||||
# Сетка НЕ хардкодится по номерам строк/колонок: живой файл за май 2026 имеет
|
||||
# header на row 11 (не 10 из разведки) и колонку «№ п/п» слева (данные в col 2-8,
|
||||
# сдвиг +1) — первый прод-прогон распарсил 5 строк вместо 1037. Урок воды/тепла:
|
||||
# опорные строки и колонки ищем ПО ПОДПИСЯМ (_find_layout), скан первых N строк.
|
||||
_LAYOUT_SCAN_ROWS = 25
|
||||
|
||||
# Колонки формы 6 (0-indexed для списка ячеек строки).
|
||||
# Подписи колонок header-строки → внутренние ключи (substring-матч, lower).
|
||||
_COLUMN_LABELS: dict[str, str] = {
|
||||
"точка входа": "entry",
|
||||
"точка выхода": "outlet",
|
||||
"наименование потребителя": "consumer_type",
|
||||
"номер группы": "group",
|
||||
"поступивш": "requested",
|
||||
"удовлетво": "approved",
|
||||
"свободная мощность": "free",
|
||||
}
|
||||
|
||||
|
||||
def _find_layout(rows: list[list[object]]) -> tuple[int, dict[str, int]] | None:
|
||||
"""Ищет header-строку по подписям в первых _LAYOUT_SCAN_ROWS строках.
|
||||
|
||||
Returns (data_start_idx, {key: col_idx}) — данные начинаются через строку
|
||||
после header (между ними строка-нумерация «1..7»). None — сетка не распознана
|
||||
(graceful: 0 записей + warning, не мусор — урок vodokanal).
|
||||
"""
|
||||
for i, row in enumerate(rows[:_LAYOUT_SCAN_ROWS]):
|
||||
cols: dict[str, int] = {}
|
||||
for j, cell in enumerate(row):
|
||||
text = _clean_text(cell).lower()
|
||||
if not text:
|
||||
continue
|
||||
for label, key in _COLUMN_LABELS.items():
|
||||
if label in text and key not in cols:
|
||||
cols[key] = j
|
||||
if "entry" in cols and "outlet" in cols and "free" in cols:
|
||||
return (i + 2, cols) # +1 строка-нумерация, +1 → первая строка данных
|
||||
return None
|
||||
|
||||
|
||||
# «0.000*» (и вариации с запятой/пробелами) в колонке свободной мощности = НЕ ноль, а
|
||||
# «требуется гидравлический расчёт» → free_capacity_mln_m3=NULL, needs_calc=True.
|
||||
# Маркер = число, за которым идёт «*» (звёздочка). Настоящий 0 без «*» парсится как 0.0.
|
||||
_NEEDS_CALC_RE = re.compile(r"^\s*[\d.,\s\xa0]+\*\s*$")
|
||||
|
||||
# Русские месяцы → номер (родительный падеж как в «за январЯ»? нет — в именах файлов
|
||||
# именительный: «за январь 2026»). Ключи в нижнем регистре.
|
||||
_MONTHS: dict[str, int] = {
|
||||
"январь": 1,
|
||||
"февраль": 2,
|
||||
"март": 3,
|
||||
"апрель": 4,
|
||||
"май": 5,
|
||||
"июнь": 6,
|
||||
"июль": 7,
|
||||
"август": 8,
|
||||
"сентябрь": 9,
|
||||
"октябрь": 10,
|
||||
"ноябрь": 11,
|
||||
"декабрь": 12,
|
||||
}
|
||||
_MONTHS_ALT = "|".join(_MONTHS)
|
||||
|
||||
# Период в имени файла формы 6: «… (за|на) <месяц> <год>г..xlsx». «за» = факт
|
||||
# (is_forecast=False), «на» = прогноз (is_forecast=True). Год — 4 цифры.
|
||||
_PERIOD_RE = re.compile(rf"\b(за|на)\s+({_MONTHS_ALT})\s+(\d{{4}})", re.IGNORECASE)
|
||||
|
||||
# Целевой файл — форма 6 приложения №4 (адресная, помесячная, .xlsx). Годовой «Форма №6
|
||||
# (Приложение №2) … план на <год>» (.xls) сюда НЕ подходит: другое приложение + .xls.
|
||||
_FORM6_MARKER_RE = re.compile(r"форма\s*6.*приложени\w*\s*№?\s*4", re.IGNORECASE | re.DOTALL)
|
||||
_XLSX_RE = re.compile(r"\.xlsx(?:$|\?)", re.IGNORECASE)
|
||||
|
||||
# Дата публикации рядом со ссылкой (DD.MM.YYYY) → asof.
|
||||
_ASOF_RE = re.compile(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b")
|
||||
|
||||
# Адрес-экстрактор: улица-токен + номер дома. Город опционален. Консервативно —
|
||||
# нет уверенного адреса → None (не выдумываем). Паттерны из реальных строк:
|
||||
# «(г.Екатеринбург, Складской проезд,4а)» — в скобках;
|
||||
# «, пер. Базовый, 17» — хвостом;
|
||||
# «г.…, ул. К.Маркса,31».
|
||||
# Улица-токен: ул|пер|пр|просп|проезд|б-р|ш|тракт|наб (с точкой или без, регистронезав.).
|
||||
_STREET_TOKEN = r"(?:ул|пер|пр|просп|проспект|проезд|б-р|бульвар|ш|шоссе|тракт|наб)"
|
||||
# Опциональный город впереди: «г.Екатеринбург,» / «г. Реж,».
|
||||
_CITY_OPT = r"(?:г\.?\s*[А-ЯЁ][а-яё\-]+\s*,\s*)?"
|
||||
# Название улицы: слова с кириллицей/точками/дефисами (напр. «К.Маркса», «Базовый»,
|
||||
# «Складской проезд») — до номера дома. Номер: цифры + опц. буква/дробь («4а», «17»,
|
||||
# «31/2»). Токен улицы может стоять ПЕРЕД названием («ул. Базовый») или ПОСЛЕ
|
||||
# («Складской проезд»).
|
||||
_ADDRESS_RE = re.compile(
|
||||
rf"{_CITY_OPT}"
|
||||
rf"(?:{_STREET_TOKEN}\.?\s*)?" # ведущий токен («ул. …», «пер. …») — опционален
|
||||
r"[А-ЯЁ][А-Яа-яЁё.\- ]{1,40}?" # название улицы (капитализировано)
|
||||
rf"(?:\s+{_STREET_TOKEN})?" # хвостовой токен («… проезд», «… тракт») — опционален
|
||||
r"\s*,?\s*"
|
||||
r"(?:д\.?\s*)?" # опц. «д.»
|
||||
r"(\d+[а-яё]?(?:[/\-]\d+[а-яё]?)?)", # номер дома («4а», «17», «31/2»)
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Чтобы отсечь ложные срабатывания на юрлицах («ИП Рошканяну Владимир Антонович») —
|
||||
# требуем ЯВНЫЙ адресный сигнал: либо улица-токен, либо «г.<Город>» перед номером.
|
||||
_HAS_STREET_TOKEN_RE = re.compile(rf"\b{_STREET_TOKEN}\b\.?", re.IGNORECASE)
|
||||
_HAS_CITY_RE = re.compile(r"\bг\.?\s*[А-ЯЁ][а-яё\-]+", re.IGNORECASE)
|
||||
|
||||
_MULTISPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _clean_text(value: object) -> str:
|
||||
"""Ячейка/текст → strip-строка со схлопнутыми пробелами (\\xa0→пробел). None → ''."""
|
||||
if value is None:
|
||||
return ""
|
||||
return _MULTISPACE_RE.sub(" ", str(value).replace("\xa0", " ")).strip()
|
||||
|
||||
|
||||
def _parse_period(name: str) -> tuple[int, int, bool] | None:
|
||||
"""Имя файла → (year, month, is_forecast). «за»→факт(False), «на»→прогноз(True).
|
||||
|
||||
Нет распознанного «(за|на) <месяц> <год>» → None. Месяц вне словаря → None.
|
||||
"""
|
||||
m = _PERIOD_RE.search(name)
|
||||
if not m:
|
||||
return None
|
||||
kind = m.group(1).lower()
|
||||
month = _MONTHS.get(m.group(2).lower())
|
||||
if month is None:
|
||||
return None
|
||||
year = int(m.group(3))
|
||||
return (year, month, kind == "на")
|
||||
|
||||
|
||||
def _is_form6_monthly(link_text: str, href: str) -> bool:
|
||||
"""Ссылка — помесячная адресная форма 6 приложения №4 (.xlsx)?
|
||||
|
||||
Требуем: маркер «форма 6 … приложение № 4» (в тексте ссылки ИЛИ href), расширение
|
||||
.xlsx и распознаваемый период. Годовой «Форма №6 (Приложение №2) … план на <год>»
|
||||
(.xls) сюда НЕ проходит (другое приложение + не .xlsx).
|
||||
"""
|
||||
haystack = f"{link_text} {href}"
|
||||
if not _FORM6_MARKER_RE.search(haystack):
|
||||
return False
|
||||
if not _XLSX_RE.search(href) and not _XLSX_RE.search(link_text):
|
||||
return False
|
||||
return _parse_period(haystack) is not None
|
||||
|
||||
|
||||
def extract_form6_links(catalog_html: str) -> list[dict]:
|
||||
"""Из HTML каталога → список кандидатов формы 6: [{url, period_month, is_forecast, asof}].
|
||||
|
||||
По каждой <a>-ссылке, похожей на помесячную форму 6 приложения №4 (.xlsx),
|
||||
вытаскиваем период «(за|на) <месяц> <год>» из текста/href → period_month (первое
|
||||
число месяца) + is_forecast, и дату публикации (DD.MM.YYYY рядом со ссылкой) → asof.
|
||||
Годовые/не-xlsx/без периода ссылки игнорируются. URL резолвится к apex-домену.
|
||||
"""
|
||||
soup = BeautifulSoup(catalog_html, "html.parser")
|
||||
out: list[dict] = []
|
||||
for a in soup.find_all("a", href=True):
|
||||
href = a["href"].strip()
|
||||
link_text = _clean_text(a.get_text(" "))
|
||||
if not _is_form6_monthly(link_text, href):
|
||||
continue
|
||||
period = _parse_period(f"{link_text} {href}")
|
||||
if period is None: # защита (уже проверено в _is_form6_monthly)
|
||||
continue
|
||||
year, month, is_forecast = period
|
||||
# asof — дата публикации рядом со ссылкой (в тексте ссылки или соседних узлах).
|
||||
asof = _extract_asof_near(a, link_text)
|
||||
out.append(
|
||||
{
|
||||
"url": urljoin(BASE_URL, href),
|
||||
"period_month": date(year, month, 1),
|
||||
"is_forecast": is_forecast,
|
||||
"asof": asof,
|
||||
}
|
||||
)
|
||||
logger.info("ekgas: кандидатов формы 6 в каталоге: %d", len(out))
|
||||
return out
|
||||
|
||||
|
||||
def _extract_asof_near(anchor: object, link_text: str) -> date | None:
|
||||
"""Дата публикации (DD.MM.YYYY) рядом со ссылкой → date. Нет — None.
|
||||
|
||||
Ищем сначала в тексте самой ссылки, затем в тексте родительского контейнера
|
||||
(в каталоге дата обычно стоит в той же строке/ячейке, что и ссылка).
|
||||
"""
|
||||
candidates = [link_text]
|
||||
parent = getattr(anchor, "parent", None)
|
||||
if parent is not None:
|
||||
candidates.append(_clean_text(parent.get_text(" ")))
|
||||
for src in candidates:
|
||||
m = _ASOF_RE.search(src)
|
||||
if m:
|
||||
day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
try:
|
||||
return date(year, month, day)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def select_latest_file(candidates: list[dict]) -> dict | None:
|
||||
"""Выбирает свежайший файл: ПРИОРИТЕТ ФАКТА («за») над прогнозом («на»).
|
||||
|
||||
Правило (см. модульный docstring): если есть хоть один файл «за» (is_forecast=False),
|
||||
берём максимальный (year, month) СРЕДИ «за» — даже если какой-то «на» новее по
|
||||
(year, month). Файлов «за» нет вообще → максимальный «на» (is_forecast=True).
|
||||
Пусто → None.
|
||||
"""
|
||||
if not candidates:
|
||||
return None
|
||||
facts = [c for c in candidates if not c["is_forecast"]]
|
||||
pool = facts if facts else candidates
|
||||
return max(pool, key=lambda c: (c["period_month"].year, c["period_month"].month))
|
||||
|
||||
|
||||
def _parse_free_capacity(value: object) -> tuple[float | None, bool]:
|
||||
"""Ячейка свободной мощности → (free_capacity_mln_m3, needs_calc).
|
||||
|
||||
«0.000*» (число + звёздочка, любой вариант «0,000*»/«0.000 *») = НЕ ноль, а
|
||||
«требуется гидравлический расчёт» → (None, True). Иначе — обычный числовой парс:
|
||||
настоящий 0 (без «*») → (0.0, False); дефицит −0.084 сохраняется знаком; «—»/текст
|
||||
→ (None, False).
|
||||
"""
|
||||
if isinstance(value, str) and _NEEDS_CALC_RE.match(value):
|
||||
return None, True
|
||||
return parse_reserve_number(value), False
|
||||
|
||||
|
||||
def extract_address(outlet_name: str) -> str | None:
|
||||
"""Извлекает адресный хвост из «Точки выхода» для геокода DaData (PR-3). Нет — None.
|
||||
|
||||
Консервативно: возвращаем адрес ТОЛЬКО при явном сигнале (улица-токен ул|пер|пр|
|
||||
проезд|б-р|ш|тракт|наб ИЛИ «г.<Город>») + номер дома. Юрлицо без адреса
|
||||
(«ИП Рошканяну Владимир Антонович») → None (не выдумываем).
|
||||
|
||||
Examples:
|
||||
«…(г.Екатеринбург, Складской проезд,4а)» → «г.Екатеринбург, Складской проезд,4а»
|
||||
«…, пер. Базовый, 17» → «пер. Базовый, 17»
|
||||
«ИП Рошканяну Владимир Антонович» → None
|
||||
"""
|
||||
if not outlet_name:
|
||||
return None
|
||||
# Требуем явный адресный сигнал во всей строке — иначе номер в названии юрлица/объекта
|
||||
# («ООО ТЭЦ-2», «цех №5») ложно распознался бы как дом.
|
||||
if not (_HAS_STREET_TOKEN_RE.search(outlet_name) or _HAS_CITY_RE.search(outlet_name)):
|
||||
return None
|
||||
m = _ADDRESS_RE.search(outlet_name)
|
||||
if not m:
|
||||
return None
|
||||
address = _MULTISPACE_RE.sub(" ", m.group(0)).strip().strip("(),; ")
|
||||
return address or None
|
||||
|
||||
|
||||
def _forward_fill_grs(rows: list[list[object]], cols: dict[str, int]) -> list[dict]:
|
||||
"""Строки данных → записи с forward-fill «Точки входа» (block-marker ГРС).
|
||||
|
||||
«Точка входа» заполнена ТОЛЬКО в первой строке блока ГРС → протягиваем
|
||||
последнее непустое значение вниз (5 блоков). Индексы колонок — из
|
||||
_find_layout (label-детект). Строки до первого block-marker (нет ГРС) и
|
||||
single-cell строки (сноска «*» в конце листа) пропускаются.
|
||||
"""
|
||||
|
||||
def _cell(cells: list[object], key: str) -> object:
|
||||
idx = cols.get(key, -1)
|
||||
return cells[idx] if 0 <= idx < len(cells) else None
|
||||
|
||||
records: list[dict] = []
|
||||
current_grs: str | None = None
|
||||
for cells in rows:
|
||||
# Single-cell строка (сноска про «*» / служебная) → скип.
|
||||
non_empty = [c for c in cells if _clean_text(c)]
|
||||
if len(non_empty) <= 1:
|
||||
continue
|
||||
entry = _clean_text(_cell(cells, "entry"))
|
||||
if entry:
|
||||
current_grs = entry
|
||||
outlet = _clean_text(_cell(cells, "outlet"))
|
||||
if not current_grs or not outlet:
|
||||
continue # строка до первого block-marker ИЛИ без точки выхода → скип
|
||||
free_raw = _cell(cells, "free")
|
||||
free, needs_calc = _parse_free_capacity(free_raw)
|
||||
records.append(
|
||||
{
|
||||
"grs_name": current_grs,
|
||||
"outlet_name": outlet,
|
||||
"consumer_type": _clean_text(_cell(cells, "consumer_type")) or None,
|
||||
"consumption_group": _clean_text(_cell(cells, "group")) or None,
|
||||
"volume_requested_mln_m3": parse_reserve_number(_cell(cells, "requested")),
|
||||
"volume_approved_mln_m3": parse_reserve_number(_cell(cells, "approved")),
|
||||
"free_capacity_mln_m3": free,
|
||||
"free_capacity_needs_calc": needs_calc,
|
||||
"address_extracted": extract_address(outlet),
|
||||
"raw_cells": _cells_to_jsonable(cells),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _cells_to_jsonable(cells: list[object]) -> list[object]:
|
||||
"""Ячейки строки → JSON-совместимый вид (datetime/прочее → str, числа/None as-is)."""
|
||||
out: list[object] = []
|
||||
for value in cells:
|
||||
if value is None or isinstance(value, bool | int | float | str):
|
||||
out.append(value)
|
||||
else:
|
||||
out.append(str(value))
|
||||
return out
|
||||
|
||||
|
||||
def _dedup_outlet_names(records: list[dict]) -> list[dict]:
|
||||
"""Дедуп повторов outlet_name В ПРЕДЕЛАХ файла: 2-му и далее — суффикс « (N)».
|
||||
|
||||
Один и тот же outlet_name у одной ГРС может встречаться несколько раз (разные
|
||||
группы потребления / объекты одного юрлица). UPSERT по (grs_name_norm, outlet_name,
|
||||
period_month, is_forecast) схлопнул бы их с потерей данных (урок тепла/воды) —
|
||||
добавляем порядковый суффикс. Ключ дедупа — (grs_name_norm, outlet_name).
|
||||
"""
|
||||
seen: dict[tuple[str, str], int] = {}
|
||||
for rec in records:
|
||||
key = (normalize_grs_name(rec["grs_name"]), rec["outlet_name"])
|
||||
seen[key] = seen.get(key, 0) + 1
|
||||
if seen[key] > 1:
|
||||
rec["outlet_name"] = f"{rec['outlet_name']} ({seen[key]})"
|
||||
return records
|
||||
|
||||
|
||||
def parse_outlet_sheet(xlsx_bytes: bytes) -> list[dict]:
|
||||
"""Парсит лист формы 6 → записи точек выхода (forward-fill ГРС + дедуп имён).
|
||||
|
||||
Сетка детектится ПО ПОДПИСЯМ (_find_layout): живой файл за май 2026 — header
|
||||
row 11, слева колонка «№ п/п», данные с row 13 (разведка видела r10/r12 без
|
||||
«№ п/п» — формат плавает). Свободная мощность: float ИЛИ «0.000*» → needs_calc.
|
||||
Single-cell сноска про «*» в конце листа → скип. Сетка не распознана → 0 записей
|
||||
+ warning (не мусор — урок vodokanal).
|
||||
|
||||
Каждая запись: {grs_name, outlet_name, consumer_type, consumption_group,
|
||||
volume_requested_mln_m3, volume_approved_mln_m3, free_capacity_mln_m3,
|
||||
free_capacity_needs_calc, address_extracted, raw_cells}. outlet_name дедуплен
|
||||
суффиксом « (N)» в пределах файла.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
try:
|
||||
all_rows: list[list[object]] = [list(row) for row in ws.iter_rows(values_only=True)]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
layout = _find_layout(all_rows)
|
||||
if layout is None:
|
||||
logger.warning(
|
||||
"ekgas: сетка листа не распознана (нет header с подписями) — 0 записей; "
|
||||
"первые 5 строк: %s",
|
||||
[[_clean_text(c) for c in r[:9]] for r in all_rows[:5]],
|
||||
)
|
||||
return []
|
||||
data_start, cols = layout
|
||||
records = _forward_fill_grs(all_rows[data_start:], cols)
|
||||
records = _dedup_outlet_names(records)
|
||||
logger.info("ekgas: распознано точек выхода=%d", len(records))
|
||||
return records
|
||||
|
||||
|
||||
def fetch_catalog_html() -> str:
|
||||
"""Тянет HTML каталога раскрытия. RUN-ON-PROD (гео-блок). httpx с таймаутом, apex-SSL."""
|
||||
resp = httpx.get(
|
||||
CATALOG_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def fetch_xlsx(url: str) -> bytes:
|
||||
"""Тянет XLSX формы 6 по URL. RUN-ON-PROD (гео-блок). httpx с таймаутом, apex-SSL."""
|
||||
resp = httpx.get(url, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context())
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def _upsert_outlet_rows(db: Session, records: list[dict], meta: dict) -> dict[str, int]:
|
||||
"""UPSERT точек выхода в gas_grs_outlet_points. Per-row SAVEPOINT (битая строка не валит батч).
|
||||
|
||||
meta = {period_month: date, is_forecast: bool, asof: date|None, source_url: str}.
|
||||
ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast) DO UPDATE —
|
||||
обновляем объёмы/free/needs_calc/address/raw/fetched_at. Returns счётчики
|
||||
inserted/updated/skipped/needs_calc/with_address.
|
||||
"""
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
needs_calc = 0
|
||||
with_address = 0
|
||||
for rec in records:
|
||||
grs_name = rec["grs_name"]
|
||||
params = {
|
||||
"grs_name": grs_name,
|
||||
"grs_name_norm": normalize_grs_name(grs_name),
|
||||
"outlet_name": rec["outlet_name"],
|
||||
"consumer_type": rec["consumer_type"],
|
||||
"consumption_group": rec["consumption_group"],
|
||||
"requested": rec["volume_requested_mln_m3"],
|
||||
"approved": rec["volume_approved_mln_m3"],
|
||||
"free": rec["free_capacity_mln_m3"],
|
||||
"needs_calc": rec["free_capacity_needs_calc"],
|
||||
"address": rec["address_extracted"],
|
||||
"period_month": meta["period_month"],
|
||||
"is_forecast": meta["is_forecast"],
|
||||
"asof": meta.get("asof"),
|
||||
"source_url": meta.get("source_url"),
|
||||
"raw": json.dumps({"cells": rec["raw_cells"]}, ensure_ascii=False),
|
||||
}
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — откат только этой строки
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO gas_grs_outlet_points
|
||||
(grs_name, grs_name_norm, outlet_name, consumer_type,
|
||||
consumption_group, volume_requested_mln_m3,
|
||||
volume_approved_mln_m3, free_capacity_mln_m3,
|
||||
free_capacity_needs_calc, address_extracted,
|
||||
period_month, is_forecast, asof, source_url, raw, fetched_at)
|
||||
VALUES (
|
||||
:grs_name, :grs_name_norm, :outlet_name, :consumer_type,
|
||||
:consumption_group, :requested,
|
||||
:approved, :free,
|
||||
:needs_calc, :address,
|
||||
:period_month, :is_forecast, :asof, :source_url,
|
||||
CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast)
|
||||
DO UPDATE
|
||||
-- geom НАМЕРЕННО НЕ в SET: geocode_gas_outlets (PR-3)
|
||||
-- пишет его отдельным UPDATE. Помесячный рефреш INSERT-ит
|
||||
-- без geom → EXCLUDED.geom = NULL; включив его в SET, мы бы
|
||||
-- ЗАТЁРЛИ уже геокоженную точку в NULL. Оставляем как есть.
|
||||
SET grs_name = EXCLUDED.grs_name,
|
||||
consumer_type = EXCLUDED.consumer_type,
|
||||
consumption_group = EXCLUDED.consumption_group,
|
||||
volume_requested_mln_m3 = EXCLUDED.volume_requested_mln_m3,
|
||||
volume_approved_mln_m3 = EXCLUDED.volume_approved_mln_m3,
|
||||
free_capacity_mln_m3 = EXCLUDED.free_capacity_mln_m3,
|
||||
free_capacity_needs_calc = EXCLUDED.free_capacity_needs_calc,
|
||||
address_extracted = EXCLUDED.address_extracted,
|
||||
asof = EXCLUDED.asof,
|
||||
source_url = EXCLUDED.source_url,
|
||||
raw = EXCLUDED.raw,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
if rec["free_capacity_needs_calc"]:
|
||||
needs_calc += 1
|
||||
if rec["address_extracted"]:
|
||||
with_address += 1
|
||||
except Exception as e:
|
||||
logger.warning("ekgas upsert failed for %r/%r: %s", grs_name, rec["outlet_name"], e)
|
||||
skipped += 1
|
||||
return {
|
||||
"rows": len(records),
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"needs_calc": needs_calc,
|
||||
"with_address": with_address,
|
||||
}
|
||||
|
||||
|
||||
def load_gas_outlet_points(db: Session | None = None) -> dict:
|
||||
"""Тянет каталог ekgas → свежайшая форма 6 → парс XLSX → UPSERT точек выхода.
|
||||
|
||||
RUN-ON-PROD (гео-блок источника). Owns-session как соседи (gas/rosseti/eias): ``db``
|
||||
None → своя SessionLocal (fetch + запись sync). Выбор файла — приоритет факта («за»)
|
||||
над прогнозом («на»), см. select_latest_file.
|
||||
|
||||
Returns счётчики {rows, inserted, updated, skipped, needs_calc, with_address,
|
||||
period, is_forecast} или {"error": ...} если подходящий файл в каталоге не найден.
|
||||
"""
|
||||
catalog_html = fetch_catalog_html()
|
||||
candidates = extract_form6_links(catalog_html)
|
||||
chosen = select_latest_file(candidates)
|
||||
if chosen is None:
|
||||
logger.warning("ekgas: подходящая форма 6 в каталоге не найдена (0 кандидатов)")
|
||||
return {"error": "no form6 file found in catalog"}
|
||||
|
||||
meta = {
|
||||
"period_month": chosen["period_month"],
|
||||
"is_forecast": chosen["is_forecast"],
|
||||
"asof": chosen["asof"],
|
||||
"source_url": chosen["url"],
|
||||
}
|
||||
logger.info(
|
||||
"ekgas: выбран файл period=%s is_forecast=%s asof=%s url=%s",
|
||||
chosen["period_month"],
|
||||
chosen["is_forecast"],
|
||||
chosen["asof"],
|
||||
chosen["url"],
|
||||
)
|
||||
|
||||
xlsx_bytes = fetch_xlsx(chosen["url"])
|
||||
records = parse_outlet_sheet(xlsx_bytes)
|
||||
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
counts = _upsert_outlet_rows(db, records, meta)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_gas_outlet_points: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
counts["period"] = chosen["period_month"].isoformat()
|
||||
counts["is_forecast"] = chosen["is_forecast"]
|
||||
logger.info("load_gas_outlet_points done: %s", counts)
|
||||
return counts
|
||||
|
||||
|
||||
# ── Геокод address_extracted → geom (PR-3) ────────────────────────────────────
|
||||
|
||||
# Кандидаты геокода: строки последнего period_month с адресом, но БЕЗ geom.
|
||||
# MAX(period_month) в подзапросе — работаем только по свежайшему снапшоту (старые
|
||||
# месяцы не трогаем). geom стабилен между месяцами (UPSERT его не затирает — см.
|
||||
# DO UPDATE выше), так что уже геокоженные строки прошлых месяцев не пере-геокодим.
|
||||
_GEOCODE_CANDIDATES_SQL = text("""
|
||||
SELECT id, address_extracted
|
||||
FROM gas_grs_outlet_points
|
||||
WHERE address_extracted IS NOT NULL
|
||||
AND geom IS NULL
|
||||
AND period_month = (SELECT MAX(period_month) FROM gas_grs_outlet_points)
|
||||
ORDER BY id
|
||||
""")
|
||||
|
||||
# UPDATE geom = точка WGS84. CAST(:lon/:lat AS float) — psycopg3-канон (backend.md:
|
||||
# НИКОГДА :lon::float). Пишем ТОЛЬКО если geom всё ещё NULL (idempotent при повторе).
|
||||
_GEOCODE_UPDATE_SQL = text("""
|
||||
UPDATE gas_grs_outlet_points
|
||||
SET geom = ST_SetSRID(
|
||||
ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)),
|
||||
4326
|
||||
)
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
AND geom IS NULL
|
||||
""")
|
||||
|
||||
|
||||
def _ekb_prefixed(address: str) -> str:
|
||||
"""address_extracted → строка для DaData с городом-префиксом при его отсутствии.
|
||||
|
||||
«ул. Стрелочников, д. 2» → «г Екатеринбург, ул. Стрелочников, д. 2». Если город
|
||||
уже есть в строке («г.Екатеринбург, …» / «г. Реж, …») — не дублируем.
|
||||
"""
|
||||
if "екатеринбург" in address.lower() or _HAS_CITY_RE.search(address):
|
||||
return address
|
||||
return f"{_EKB_PREFIX}{address}"
|
||||
|
||||
|
||||
def _in_ekb_bbox(lat: float, lon: float) -> bool:
|
||||
"""Точка в bbox Екатеринбурга и окрестностей? (sanity-гейт против чужого города)."""
|
||||
return (
|
||||
_EKB_BBOX_LAT[0] <= lat <= _EKB_BBOX_LAT[1] and _EKB_BBOX_LON[0] <= lon <= _EKB_BBOX_LON[1]
|
||||
)
|
||||
|
||||
|
||||
def geocode_gas_outlets(db: Session | None = None, *, apply: bool = False) -> dict:
|
||||
"""Геокодит address_extracted точек выхода → geom (последний period_month).
|
||||
|
||||
Дефолт dry-run (apply=False): считает счётчики + печатает первые 20 пар
|
||||
адрес→(lat,lon), НО В БД НЕ ПИШЕТ. apply=True — UPDATE geom.
|
||||
|
||||
Алгоритм:
|
||||
1. SELECT строки последнего period_month c address_extracted IS NOT NULL AND
|
||||
geom IS NULL (уже геокоженные не трогаем — geom стабилен между месяцами).
|
||||
2. Каждый адрес: префикс «г Екатеринбург, » при отсутствии города → clean_address.
|
||||
3. Sanity-bbox ЕКБ+окрестности: координата вне bbox → failed (DaData «дорисовал»
|
||||
чужой город на кривом адресе) — geom НЕ пишем (честность).
|
||||
4. Кэш в памяти (адреса повторяются) + cap _GEO_DADATA_CAP вызовов DaData.
|
||||
Per-row SAVEPOINT (битая строка/UPDATE не валит батч, backend.md).
|
||||
|
||||
Returns счётчики {candidates, geocoded, failed, dadata_calls, cap_hit, applied}.
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
candidates: list[tuple[int, str]] = [
|
||||
(int(row[0]), str(row[1])) for row in db.execute(_GEOCODE_CANDIDATES_SQL).all()
|
||||
]
|
||||
|
||||
counts = {
|
||||
"candidates": len(candidates),
|
||||
"geocoded": 0,
|
||||
"failed": 0,
|
||||
"dadata_calls": 0,
|
||||
"cap_hit": False,
|
||||
"applied": apply,
|
||||
}
|
||||
# Кэш прогона: address → (lat, lon) | None (None = «пробовали, DaData не дал/вне
|
||||
# bbox» — кэшируем и его, чтобы не жечь квоту на повтор одного адреса).
|
||||
geo_cache: dict[str, tuple[float, float] | None] = {}
|
||||
sample: list[tuple[str, tuple[float, float]]] = []
|
||||
|
||||
try:
|
||||
for row_id, address in candidates:
|
||||
if address in geo_cache:
|
||||
coords = geo_cache[address]
|
||||
elif counts["dadata_calls"] >= _GEO_DADATA_CAP:
|
||||
counts["cap_hit"] = True
|
||||
counts["failed"] += 1
|
||||
continue
|
||||
else:
|
||||
counts["dadata_calls"] += 1
|
||||
raw = clean_address(_ekb_prefixed(address))
|
||||
# bbox-гейт ДО кэша: вне ЕКБ → трактуем как «нет координат» (None).
|
||||
if raw is not None and not _in_ekb_bbox(raw[0], raw[1]):
|
||||
logger.info(
|
||||
"ekgas geocode: %r вне bbox ЕКБ (lat=%.4f lon=%.4f) → failed",
|
||||
address[:60],
|
||||
raw[0],
|
||||
raw[1],
|
||||
)
|
||||
raw = None
|
||||
coords = raw
|
||||
geo_cache[address] = coords
|
||||
|
||||
if coords is None:
|
||||
counts["failed"] += 1
|
||||
continue
|
||||
|
||||
lat, lon = coords
|
||||
if len(sample) < 20:
|
||||
sample.append((address, (lat, lon)))
|
||||
|
||||
if not apply:
|
||||
counts["geocoded"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — откат только этой строки
|
||||
db.execute(_GEOCODE_UPDATE_SQL, {"id": row_id, "lat": lat, "lon": lon})
|
||||
counts["geocoded"] += 1
|
||||
except Exception as e:
|
||||
logger.warning("ekgas geocode UPDATE failed for id=%s %r: %s", row_id, address, e)
|
||||
counts["failed"] += 1
|
||||
|
||||
if apply:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
if apply:
|
||||
db.rollback()
|
||||
logger.exception("geocode_gas_outlets: rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("geocode_gas_outlets done (apply=%s): %s", apply, counts)
|
||||
if not apply:
|
||||
for addr, (lat, lon) in sample:
|
||||
logger.info(" sample: %r → (%.6f, %.6f)", addr[:80], lat, lon)
|
||||
return counts
|
||||
|
||||
|
||||
def _main(argv: list[str] | None = None) -> int:
|
||||
"""CLI: помесячная загрузка формы 6 (--load) и геокод адресов → geom (--geocode-outlets).
|
||||
|
||||
RUN-ON-PROD (гео-блок источника + DaData-ключи на проде). Пример:
|
||||
python -m app.services.site_finder.ekgas_outlet_loader --geocode-outlets
|
||||
python -m app.services.site_finder.ekgas_outlet_loader --geocode-outlets --apply
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Загрузчик точек выхода ГРС «Екатеринбурггаз» (#2119 B2): "
|
||||
"load формы 6 + геокод address_extracted → geom."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--load",
|
||||
action="store_true",
|
||||
help="Загрузить свежайшую форму 6 из каталога ekgas → UPSERT точек выхода.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geocode-outlets",
|
||||
action="store_true",
|
||||
help="Геокодить address_extracted (последний period_month, geom IS NULL) → geom.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Реально писать в БД (для --geocode-outlets). Без флага — dry-run.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
if not args.load and not args.geocode_outlets:
|
||||
parser.error("нужен --load и/или --geocode-outlets")
|
||||
|
||||
if args.load:
|
||||
logger.info("load result: %s", load_gas_outlet_points())
|
||||
if args.geocode_outlets:
|
||||
logger.info("geocode result: %s", geocode_gas_outlets(apply=args.apply))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(_main())
|
||||
|
|
@ -74,6 +74,7 @@ def _future_supply_key(
|
|||
"""
|
||||
return (district, horizon_months, premise_kind)
|
||||
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
|
||||
# Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД),
|
||||
|
|
@ -439,7 +440,12 @@ def _query_supply_rows(db: Session, params: Mapping[str, Any]) -> Sequence[Mappi
|
|||
(worker ещё не наполнил м.125) или сбой БД не должны валить расчёт индекса.
|
||||
"""
|
||||
try:
|
||||
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
|
||||
# SAVEPOINT — db shared с caller (compute_future_supply_pressure в цепочке
|
||||
# §22-форсайта / analyze). Голый db.rollback() orphan'ит outer SessionTransaction
|
||||
# (см. velocity.py:170) — begin_nested откатывает только эту savepoint, оставляя
|
||||
# db usable для последующих db.execute на этой же сессии.
|
||||
with db.begin_nested():
|
||||
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"future_supply: supply-layers view query failed (district=%s)",
|
||||
|
|
|
|||
|
|
@ -94,7 +94,10 @@ _SVERDLOVSK_TOKEN = "свердлов"
|
|||
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
|
||||
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
|
||||
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
|
||||
_OUTPUT_SUFFIX_RE = re.compile(r"\((выход\s*№?\s*\d+)\)", re.IGNORECASE)
|
||||
# Группы: 1=«выход», 2=номер (для канонизации «выход №N» без разнобоя пробелов вокруг №,
|
||||
# иначе «выход № 1» и «выход №1» породили бы РАЗНЫЕ output_name → дубли по
|
||||
# ON CONFLICT (grs_name_norm, output_name)).
|
||||
_OUTPUT_SUFFIX_RE = re.compile(r"\(\s*(выход)\s*№?\s*(\d+)\s*\)", re.IGNORECASE)
|
||||
|
||||
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
|
||||
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
|
||||
|
|
@ -168,12 +171,16 @@ 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 = _MULTISPACE_RE.sub(" ", m.group(1)).strip()
|
||||
# Канонический output_name: «выход №N» (число из группы 2, «№» приклеено к цифре).
|
||||
output = f"{m.group(1).lower()} №{m.group(2)}"
|
||||
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
|
||||
# двойные пробелы, оставшиеся на месте выреза в середине).
|
||||
grs_name = raw_name[: m.start()] + raw_name[m.end() :]
|
||||
|
|
|
|||
|
|
@ -81,20 +81,22 @@ def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
|
|||
[] при отсутствии пересечений или если таблица пуста/не существует.
|
||||
"""
|
||||
try:
|
||||
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
|
||||
return [
|
||||
{
|
||||
"num_oks_krt": row.num_oks_krt,
|
||||
"status": row.status,
|
||||
"full_name": row.full_name,
|
||||
"area": float(row.area) if row.area is not None else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
|
||||
except Exception as exc: # таблица не существует / DB-сбой → graceful
|
||||
logger.warning("ird_analyze: _krt_at DB query failed: %s", exc)
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"num_oks_krt": row.num_oks_krt,
|
||||
"status": row.status,
|
||||
"full_name": row.full_name,
|
||||
"area": float(row.area) if row.area is not None else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _zone_regulation_at(
|
||||
db: Session, client: EKBGeoportalClient, lon: float, lat: float
|
||||
|
|
|
|||
|
|
@ -130,7 +130,8 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
|
|||
return []
|
||||
|
||||
try:
|
||||
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("parcel_krt_requisites: таблица недоступна, skip: %s", exc)
|
||||
return []
|
||||
|
|
@ -173,9 +174,10 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
|
|||
|
||||
# Нет exact-матча — geom-only поиск по КРТ-топонимам
|
||||
try:
|
||||
no_match_rows = (
|
||||
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
)
|
||||
with db.begin_nested():
|
||||
no_match_rows = (
|
||||
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
)
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("parcel_krt_requisites: no-match fallback недоступен: %s", exc)
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -263,9 +263,7 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
|
|||
|
||||
# ── competition + raw demand-velocity ← market_metrics (ОДИН вызов, два сигнала) ─
|
||||
try:
|
||||
metrics = compute_market_metrics(
|
||||
db, district=district, window_months=_MARKET_WINDOW_MONTHS
|
||||
)
|
||||
metrics = compute_market_metrics(db, district=district, window_months=_MARKET_WINDOW_MONTHS)
|
||||
# competition ← overstock_index (доступное/затоваренное конкурирующее
|
||||
# предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с
|
||||
# velocity и инвертирован относительно «свободной конкуренции»).
|
||||
|
|
@ -300,6 +298,15 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
|
|||
logger.exception(
|
||||
"location_indices: infra (POI) failed (district=%s) → infra None", district
|
||||
)
|
||||
# Plain rollback OK here (unlike begin_nested at the pass-2 upsert loop below):
|
||||
# compute_location_indices только вызывается из refresh_locations, единственный
|
||||
# caller которой — Celery-таск location_refresh.py, открывающий СВОЙ SessionLocal()
|
||||
# (owned session, не shared request/report-scoped). На момент этого блока (pass 1,
|
||||
# read-only) в транзакции ещё нет незакоммиченных write'ов — откатывать нечего,
|
||||
# rollback() не сиротит внешний SessionTransaction (в отличие от velocity.py:170).
|
||||
# Без rollback аборченная Postgres-транзакция валит СЛЕДУЮЩИЙ район в цикле pass 1
|
||||
# (см. #2464) — как и все db.execute() в pass 2 upsert.
|
||||
db.rollback()
|
||||
|
||||
logger.info(
|
||||
"location_indices: district=%s infra=%s competition=%s raw_velocity=%s future_supply=%s",
|
||||
|
|
|
|||
|
|
@ -613,7 +613,13 @@ def compute_market_metrics(
|
|||
def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
|
||||
"""Текущий сток. На ошибке/пустых данных → все счётчики 0 (graceful)."""
|
||||
try:
|
||||
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
|
||||
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
|
||||
# / analyze). Голый db.rollback() orphan'ит outer SessionTransaction (см.
|
||||
# velocity.py:170) — begin_nested откатывает только эту savepoint, outer tx
|
||||
# остаётся usable для последующих db.execute (_query_sales_window/_price_sensitivity)
|
||||
# на этой же сессии.
|
||||
with db.begin_nested():
|
||||
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"market_metrics: stock query failed (districts=%s)", params.get("districts")
|
||||
|
|
@ -650,7 +656,12 @@ def _query_sales_window(
|
|||
затирать прежние счётчики. На ошибке/пусто → (0, 0.0, {}).
|
||||
"""
|
||||
try:
|
||||
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
||||
# SAVEPOINT — db shared с caller (см. _query_stock выше). CRITICAL: без него
|
||||
# сбой здесь оставляет транзакцию aborted, и следующий db.execute в этой же
|
||||
# цепочке (_price_sensitivity → _elasticity_coef) падает "current transaction
|
||||
# is aborted" вместо честного graceful-fallback.
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"market_metrics: sales-window query failed (districts=%s)", params.get("districts")
|
||||
|
|
@ -700,14 +711,19 @@ def _price_sensitivity(
|
|||
micros = resolve_objective_districts(db, district)
|
||||
elasticity_window = max(window_months, 24)
|
||||
try:
|
||||
elast = _elasticity_coef(
|
||||
db,
|
||||
region_code=_EKB_REGION_CODE,
|
||||
district_name=district,
|
||||
target_class=None,
|
||||
elasticity_window_months=elasticity_window,
|
||||
districts=micros if micros is not None else [],
|
||||
)
|
||||
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
|
||||
# / analyze). _elasticity_coef делает свой db.execute внутри без try/except —
|
||||
# оборачиваем ВЕСЬ вызов, чтобы сбой откатывал только эту savepoint (не outer
|
||||
# SessionTransaction, см. velocity.py:170), и db оставалась usable дальше.
|
||||
with db.begin_nested():
|
||||
elast = _elasticity_coef(
|
||||
db,
|
||||
region_code=_EKB_REGION_CODE,
|
||||
district_name=district,
|
||||
target_class=None,
|
||||
elasticity_window_months=elasticity_window,
|
||||
districts=micros if micros is not None else [],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"market_metrics: elasticity reuse failed (district=%s micros=%s)",
|
||||
|
|
@ -810,6 +826,9 @@ _OFFER_TREND_SQL = text(
|
|||
|
||||
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (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
|
||||
|
||||
|
|
@ -874,13 +893,22 @@ def compute_offer_price_trend(
|
|||
"price_max": _OFFER_PRICE_MAX,
|
||||
}
|
||||
try:
|
||||
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s)",
|
||||
# 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 = []
|
||||
|
||||
|
|
|
|||
168
backend/app/services/site_finder/network_zone_humanize.py
Normal file
168
backend/app/services/site_finder/network_zone_humanize.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Гуманизация «сетевых охранных зон» ЗОУИТ для §3 ПТИЦА (#2119 C3).
|
||||
|
||||
Позитив-разрез охранных зон инженерных сетей: рядом с участком физически ПРОХОДИТ
|
||||
сеть (охранная зона = коридор сети) → «теплотрасса в 24 м — врезка дешёвая». Pure /
|
||||
без БД: на вход ``type_zone`` + ``name_by_doc`` (детальные имена из cad_zouit), на
|
||||
выход — уточнённый ``net_kind`` + человекочитаемый ``human_label`` для финдиректора.
|
||||
|
||||
Зачем отдельно от ``network_obremenenie.classify_network_zone``:
|
||||
- ``classify_network_zone`` смотрит ТОЛЬКО на ``type_zone`` (грубый вид: thermal /
|
||||
general / …). На проде (2026-07-03) большинство «Охранная зона инженерных
|
||||
коммуникаций» (``general``) — по ``name_by_doc`` РЕАЛЬНО теплосети/газопроводы
|
||||
(«Трубопровод (тепловые сети) с кадастровым номером 66:41:…», «Газопровод
|
||||
низкого давления…»). Оставить их ``general`` = потерять решающий тепло-сигнал.
|
||||
- Поэтому здесь ``refine_net_kind`` дочищает ``general`` по ``name_by_doc``, а
|
||||
``human_label`` вытаскивает кад.номер трубопровода / напряжение ВЛ / номер ТП
|
||||
regex-ами. Классификатор type_zone НЕ дублируем — переиспользуем как базу.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from app.services.site_finder.network_obremenenie import NetworkKind, classify_network_zone
|
||||
|
||||
# Кад.номер РФ: 2:2:до7цифр:до-длинная последняя секция (напр. 66:41:0108079:76).
|
||||
# Последняя секция бывает с ведущими нулями («0036») — берём как есть, не нормализуем.
|
||||
_CAD_NUM_RE = re.compile(r"\b\d{2}:\d{2}:\d{6,7}:\d+\b")
|
||||
|
||||
# «ВЛ 0,4 кВ» / «КЛ 10кВ» / «ВЛИ-0,4 кВ» — тип линии + напряжение. Запятая-десятичный
|
||||
# разделитель (0,4) и точка (0.4) оба валидны; пробел перед «кВ» опционален.
|
||||
_VL_KV_RE = re.compile(
|
||||
r"\b(ВЛИ|ВЛЗ|ВЛ|КЛ|КВЛ)\b[\s-]*?(\d+(?:[.,]\d+)?)\s*кВ",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Номер ТП/РП/КТП: «ТП 3164», «ТП 32505», «ТП 3736-3747», «КТП №589». Берём первый
|
||||
# «якорный» ТП (источник питания ввода). Номер — цифры, опционально с дефисом-диапазоном.
|
||||
_TP_RE = re.compile(
|
||||
r"\b(ТП|РП|КТП|КТПН|БКТП|РТП)\b\s*(?:п/?№|№|N)?\s*(\d+(?:-\d+)?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Маркеры «на самом деле тепло» в name_by_doc для general-зон (инженерные коммуникации).
|
||||
_THERMAL_NEEDLES: tuple[str, ...] = (
|
||||
"теплоснабж",
|
||||
"тепловая сеть",
|
||||
"тепловые сети",
|
||||
"тепловых сетей",
|
||||
"теплосет",
|
||||
"теплотрасс",
|
||||
)
|
||||
# Маркеры «на самом деле газ» в name_by_doc для general-зон.
|
||||
_GAS_NEEDLES: tuple[str, ...] = (
|
||||
"газопров",
|
||||
"газораспредел",
|
||||
"газоснаб",
|
||||
"догазифик",
|
||||
)
|
||||
# Маркеры электро (ВЛ/КЛ/электросет/подстанция) в name_by_doc для general-зон.
|
||||
# «трансформаторная подстанция» / «распределительный пункт» — объект электросети без
|
||||
# явного «ВЛ N кВ» (прод-кейс 66:41:0404019:333). ТП здесь = трансформаторная (электро),
|
||||
# не путать с тепловой ТК (тепловая камера) — та ловится _THERMAL_NEEDLES раньше.
|
||||
_ELECTRICITY_NEEDLES: tuple[str, ...] = (
|
||||
"электросет",
|
||||
"электроснабж",
|
||||
"электросетевого",
|
||||
"кабельн",
|
||||
"трансформаторн",
|
||||
"распределительн",
|
||||
)
|
||||
|
||||
|
||||
def refine_net_kind(type_zone: str | None, name_by_doc: str | None) -> NetworkKind | None:
|
||||
"""Уточнить вид сети: база по ``type_zone``, дочистка ``general`` по ``name_by_doc``.
|
||||
|
||||
Возвращает ``None``, если зона вообще не сетевая (как ``classify_network_zone``).
|
||||
Для ``general`` («инженерные коммуникации») пытается распознать реальный вид по
|
||||
детальному имени: тепло → thermal, газ → gas, электро → electricity. Если вид не
|
||||
распознан — остаётся ``general`` («вид не уточнён»).
|
||||
|
||||
Специфичные type_zone (thermal / electricity / gas / …) НЕ переопределяем именем —
|
||||
для них type_zone уже точный, а name_by_doc лишь обогащает подпись.
|
||||
"""
|
||||
base = classify_network_zone(type_zone)
|
||||
if base != "general":
|
||||
return base
|
||||
name = (name_by_doc or "").lower()
|
||||
if any(n in name for n in _THERMAL_NEEDLES):
|
||||
return "thermal"
|
||||
if any(n in name for n in _GAS_NEEDLES):
|
||||
return "gas"
|
||||
# ВЛ/КЛ N кВ в имени → электро (regex поймает даже без слова «электросеть»).
|
||||
if any(n in name for n in _ELECTRICITY_NEEDLES) or _VL_KV_RE.search(name_by_doc or ""):
|
||||
return "electricity"
|
||||
return "general"
|
||||
|
||||
|
||||
def _fmt_voltage(raw: str) -> str:
|
||||
"""«0,4» / «0.4» / «10» → нормализованная строка напряжения («0,4», «10»)."""
|
||||
return raw.replace(".", ",")
|
||||
|
||||
|
||||
def _is_last_mile_voltage(raw: str) -> bool:
|
||||
"""Напряжение ≤1 кВ (0,4 кВ и т.п.) — last-mile ввод, не магистраль."""
|
||||
try:
|
||||
return float(raw.replace(",", ".")) <= 1.0
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def human_label(net_kind: NetworkKind | None, name_by_doc: str | None) -> str:
|
||||
"""Человекочитаемая подпись сетевой зоны для §3 (финдиректор-facing).
|
||||
|
||||
thermal → «Тепловая сеть (по охранной зоне)» + кад.номер трубопровода, если есть.
|
||||
electricity → «Ввод N кВ от ТП M (по охранной зоне)» (regex ВЛ/КЛ + ТП);
|
||||
напряжение ≤1 кВ подаём как «(ввод 0,4 кВ, last-mile)» — второстепенно.
|
||||
gas/pipeline → «Трубопровод (газо-/нефтепровод) по охранной зоне».
|
||||
general → «Инженерные коммуникации (вид не уточнён)».
|
||||
None → «Охранная зона» (fallback, не должно доходить для сетевых).
|
||||
"""
|
||||
name = name_by_doc or ""
|
||||
|
||||
if net_kind == "thermal":
|
||||
cad = _CAD_NUM_RE.search(name)
|
||||
base = "Тепловая сеть (по охранной зоне)"
|
||||
if cad:
|
||||
return f"{base}, кад. № {cad.group(0)}"
|
||||
return base
|
||||
|
||||
if net_kind == "electricity":
|
||||
vl = _VL_KV_RE.search(name)
|
||||
tp = _TP_RE.search(name)
|
||||
if vl:
|
||||
voltage = _fmt_voltage(vl.group(2))
|
||||
last_mile = _is_last_mile_voltage(vl.group(2))
|
||||
if tp:
|
||||
tp_num = tp.group(2)
|
||||
if last_mile:
|
||||
# ≤1 кВ — last-mile, подаём как второстепенный факт (уточнение в скобках).
|
||||
return f"Ввод от ТП {tp_num} (по охранной зоне, {voltage} кВ — last-mile)"
|
||||
return f"Ввод {voltage} кВ от ТП {tp_num} (по охранной зоне)"
|
||||
if last_mile:
|
||||
return f"Электроввод {voltage} кВ — last-mile (по охранной зоне)"
|
||||
return f"Ввод {voltage} кВ (по охранной зоне)"
|
||||
if tp:
|
||||
return f"Электросеть от ТП {tp.group(2)} (по охранной зоне)"
|
||||
return "Объект электросетевого хозяйства (по охранной зоне)"
|
||||
|
||||
if net_kind in ("gas", "pipeline"):
|
||||
cad = _CAD_NUM_RE.search(name)
|
||||
base = "Трубопровод (газо-/нефтепровод) по охранной зоне"
|
||||
if cad:
|
||||
return f"{base}, кад. № {cad.group(0)}"
|
||||
return base
|
||||
|
||||
if net_kind == "water":
|
||||
return "Сеть водоснабжения/водоотведения (по охранной зоне)"
|
||||
|
||||
if net_kind == "communication":
|
||||
return "Линия связи (по охранной зоне)"
|
||||
|
||||
if net_kind == "general":
|
||||
return "Инженерные коммуникации (вид не уточнён)"
|
||||
|
||||
return "Охранная зона"
|
||||
|
||||
|
||||
__all__ = ["human_label", "refine_net_kind"]
|
||||
|
|
@ -74,10 +74,11 @@ def parcel_okn_objects(
|
|||
if not parcel_wkt:
|
||||
return []
|
||||
try:
|
||||
rows = db.execute(
|
||||
_OKN_DWITHIN_SQL,
|
||||
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
|
||||
).fetchall()
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
_OKN_DWITHIN_SQL,
|
||||
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
|
||||
).fetchall()
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("parcel_okn_objects: okn_objects недоступна, skip: %s", exc)
|
||||
return []
|
||||
|
|
|
|||
319
backend/app/services/site_finder/osm_tp_geocoder.py
Normal file
319
backend/app/services/site_finder/osm_tp_geocoder.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
"""OSM-геокод городских ТП/РП ЕКБ → geom для power_tp_rp_reserves (#2119 B2, финал).
|
||||
|
||||
Хвост §3 «свободная мощность»: у ~2012 ЕКБ-строк power_tp_rp_reserves (ЕЭСК,
|
||||
«ниже 35» кВ) name_norm = чистый номер («10003»), но geom NULL. OSM в bbox ЕКБ
|
||||
несёт ~821 substation; часть подписана номером ТП/РП в теге ``ref`` или ``name``
|
||||
(«РП-101», «ТП-180», «ТП 1591») — по нему матчим наши строки и заливаем координату.
|
||||
|
||||
Разведка 2026-07-03: матч по номеру даёт ~119 наших ТП, 0 неоднозначностей в срезе.
|
||||
Консервативно: берём число только если это похоже на номер ТП/РП (ТП/РП/RP/TP-токен
|
||||
рядом ИЛИ голый ref-число) — «110 кВ» из voltage мимо. Дубль-номера (>1 OSM-фичи на
|
||||
один номер) честно скипаем в ambiguous.
|
||||
|
||||
RUN-ON-PROD-friendly, но источник (Overpass) без гео-блока — можно и локально.
|
||||
httpx с явным таймаутом, per-row SAVEPOINT (backend.md), idempotent UPDATE
|
||||
(geom IS NULL + geocode_status='osm'). Dry-run по умолчанию; CLI ``--apply``.
|
||||
|
||||
Overpass endpoint: overpass.kumi.systems (overpass-api.de даёт 406 на наш POST;
|
||||
area-запрос роняет соединение — используем ТОЛЬКО bbox).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Overpass-инстанс: overpass-api.de отдаёт 406 на наш POST, kumi стабилен (разведка).
|
||||
OVERPASS_URL = "https://overpass.kumi.systems/api/interpreter"
|
||||
|
||||
# bbox ЕКБ (south, west, north, east) для Overpass. area-запрос роняет соединение —
|
||||
# ТОЛЬКО bbox. Тот же прямоугольник используем и для sanity-гейта координаты.
|
||||
_EKB_BBOX = (56.70, 60.40, 56.97, 60.80)
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# Overpass QL: node/way/relation с power=substation в bbox, вывод с центроидом (center)
|
||||
# и тегами. `out center tags;` — для way/relation отдаёт center-точку, для node — саму.
|
||||
_OVERPASS_QL = (
|
||||
"[out:json][timeout:{timeout}];"
|
||||
"("
|
||||
'node["power"="substation"]({s},{w},{n},{e});'
|
||||
'way["power"="substation"]({s},{w},{n},{e});'
|
||||
'relation["power"="substation"]({s},{w},{n},{e});'
|
||||
");"
|
||||
"out center tags;"
|
||||
)
|
||||
|
||||
# Токен «ТП/РП» (кириллица и латиница-двойники) + число 2-5 цифр после него.
|
||||
# Примеры: «РП-101» → 101, «ТП-180» → 180, «ТП 1591» → 1591, «TP-42» → 42.
|
||||
# Разделитель между токеном и числом — дефис/пробел/точка/двоеточие (или отсутствует).
|
||||
_TP_TOKEN_NUM_RE = re.compile(
|
||||
r"(?:тп|рп|tp|rp)\s*[-–—.:№]?\s*(\d{2,5})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Голый ref-номер целиком: только цифры 2-5 знаков (напр. ref='101' без ТП-токена).
|
||||
_BARE_NUM_RE = re.compile(r"^\s*(\d{2,5})\s*$")
|
||||
|
||||
|
||||
def fetch_osm_substations() -> list[dict]:
|
||||
"""Тянет OSM-подстанции ЕКБ (power=substation) в bbox через Overpass → element-list.
|
||||
|
||||
POST data= (GET area роняет соединение). Скип ``substation=traction`` (тяговые
|
||||
ж/д подстанции — не городские ТП/РП). httpx с явным таймаутом. Возвращает
|
||||
сырые Overpass-элементы ``{"type","id","lat"/"center","tags"}``.
|
||||
"""
|
||||
ql = _OVERPASS_QL.format(
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
s=_EKB_BBOX[0],
|
||||
w=_EKB_BBOX[1],
|
||||
n=_EKB_BBOX[2],
|
||||
e=_EKB_BBOX[3],
|
||||
)
|
||||
resp = httpx.post(OVERPASS_URL, data={"data": ql}, timeout=_HTTP_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
elements: list[dict] = (data or {}).get("elements") or []
|
||||
|
||||
kept: list[dict] = []
|
||||
for el in elements:
|
||||
tags = el.get("tags") or {}
|
||||
# Тяговые ж/д подстанции — не городские ТП/РП, выкидываем.
|
||||
if tags.get("substation") == "traction":
|
||||
continue
|
||||
kept.append(el)
|
||||
|
||||
logger.info(
|
||||
"osm_tp_geocoder: Overpass отдал %d substation, после traction-скипа %d",
|
||||
len(elements),
|
||||
len(kept),
|
||||
)
|
||||
return kept
|
||||
|
||||
|
||||
def extract_tp_number(tags: dict) -> str | None:
|
||||
"""Извлекает номер ТП/РП из тегов OSM-фичи (``ref`` приоритетнее ``name``).
|
||||
|
||||
Консервативно — только когда число выглядит номером ТП/РП, не вольтажем:
|
||||
- ``ref`` = голое число 2-5 цифр («101») → берём;
|
||||
- в ``ref``/``name`` есть ТП/РП/TP/RP-токен + число («РП-101», «ТП 1591») → берём число;
|
||||
- «110 кВ» / «110 kV» БЕЗ ТП-токена → None (это класс напряжения, не номер).
|
||||
|
||||
Приоритет источника: ref, затем name. Возвращает строку-номер или None.
|
||||
"""
|
||||
ref = (tags.get("ref") or "").strip()
|
||||
name = (tags.get("name") or "").strip()
|
||||
|
||||
# 1. Голый ref-номер (наиболее надёжный сигнал: оператор проставил ref явно).
|
||||
if ref:
|
||||
m_bare = _BARE_NUM_RE.match(ref)
|
||||
if m_bare:
|
||||
return m_bare.group(1)
|
||||
|
||||
# 2. ТП/РП-токен + число — сперва в ref, потом в name.
|
||||
for source in (ref, name):
|
||||
if not source:
|
||||
continue
|
||||
m = _TP_TOKEN_NUM_RE.search(source)
|
||||
if m:
|
||||
return m.group(1)
|
||||
|
||||
# «110 кВ» и прочий вольтаж без ТП-токена и без голого ref — намеренно None.
|
||||
return None
|
||||
|
||||
|
||||
def _center_coords(el: dict) -> tuple[float, float] | None:
|
||||
"""(lat, lon) из Overpass-элемента: node → lat/lon, way/relation → center. None — нет."""
|
||||
if el.get("lat") is not None and el.get("lon") is not None:
|
||||
return float(el["lat"]), float(el["lon"])
|
||||
center = el.get("center") or {}
|
||||
if center.get("lat") is not None and center.get("lon") is not None:
|
||||
return float(center["lat"]), float(center["lon"])
|
||||
return None
|
||||
|
||||
|
||||
def _in_ekb_bbox(lat: float, lon: float) -> bool:
|
||||
"""Координата в bbox ЕКБ? Overpass и так ограничен bbox'ом — проверка дёшева/страховка."""
|
||||
s, w, n, e = _EKB_BBOX
|
||||
return s <= lat <= n and w <= lon <= e
|
||||
|
||||
|
||||
def _index_osm_by_number(elements: list[dict]) -> tuple[dict[str, tuple[float, float]], set[str]]:
|
||||
"""Строит {номер → (lat,lon)} по OSM-фичам; номера с >1 фичей → ambiguous-set.
|
||||
|
||||
Номер извлекается ``extract_tp_number``; берётся только фича с валидной координатой
|
||||
в bbox ЕКБ. Если один номер несут ≥2 разных фичи (разные координаты) — он попадает
|
||||
в ambiguous и НЕ матчится (честность: не угадываем, какая из ТП «та самая»).
|
||||
"""
|
||||
by_number: dict[str, tuple[float, float]] = {}
|
||||
seen_coords: dict[str, set[tuple[float, float]]] = {}
|
||||
ambiguous: set[str] = set()
|
||||
|
||||
for el in elements:
|
||||
tags = el.get("tags") or {}
|
||||
number = extract_tp_number(tags)
|
||||
if number is None:
|
||||
continue
|
||||
coords = _center_coords(el)
|
||||
if coords is None:
|
||||
continue
|
||||
lat, lon = coords
|
||||
if not _in_ekb_bbox(lat, lon):
|
||||
logger.info(
|
||||
"osm_tp_geocoder: номер %s вне bbox ЕКБ (lat=%.4f lon=%.4f) → скип",
|
||||
number,
|
||||
lat,
|
||||
lon,
|
||||
)
|
||||
continue
|
||||
|
||||
prev = seen_coords.setdefault(number, set())
|
||||
# Округляем до ~11 м, чтобы дубли-теги одной ТП (way+node) не считались коллизией.
|
||||
coord_key = (round(lat, 4), round(lon, 4))
|
||||
prev.add(coord_key)
|
||||
if len(prev) > 1:
|
||||
ambiguous.add(number)
|
||||
by_number.pop(number, None)
|
||||
continue
|
||||
if number not in ambiguous:
|
||||
by_number[number] = (lat, lon)
|
||||
|
||||
return by_number, ambiguous
|
||||
|
||||
|
||||
# UPDATE geom + geocode_status='osm'. CAST(:x AS ...) — psycopg3-канон (backend.md,
|
||||
# НИКОГДА :x::float). Пишем ТОЛЬКО ЕКБ + geom всё ещё NULL → idempotent при повторе.
|
||||
_UPDATE_SQL = text("""
|
||||
UPDATE power_tp_rp_reserves
|
||||
SET geom = ST_SetSRID(
|
||||
ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)),
|
||||
4326
|
||||
),
|
||||
geocode_status = 'osm'
|
||||
WHERE name_norm = CAST(:name_norm AS text)
|
||||
AND municipality = 'Екатеринбург'
|
||||
AND geom IS NULL
|
||||
""")
|
||||
|
||||
# Кандидаты на геокод: ЕКБ-строки без координаты. name_norm = чистый номер.
|
||||
_CANDIDATES_SQL = text("""
|
||||
SELECT name_norm
|
||||
FROM power_tp_rp_reserves
|
||||
WHERE municipality = 'Екатеринбург'
|
||||
AND geom IS NULL
|
||||
AND name_norm IS NOT NULL
|
||||
ORDER BY name_norm
|
||||
""")
|
||||
|
||||
|
||||
def geocode_tp_from_osm(db: Session | None = None, *, apply: bool = False) -> dict:
|
||||
"""Матч name_norm ЕКБ-ТП/РП ↔ OSM-номер → UPDATE geom + geocode_status='osm'.
|
||||
|
||||
Дефолт dry-run (apply=False): считает счётчики + логирует первые 20 пар
|
||||
номер→(lat,lon), НО В БД НЕ ПИШЕТ. apply=True — реально пишет.
|
||||
|
||||
Алгоритм:
|
||||
1. fetch OSM-substation ЕКБ (traction уже отсеян).
|
||||
2. индекс {номер → координата}; дубль-номер → ambiguous (не матчим).
|
||||
3. SELECT ЕКБ-строк с geom IS NULL; matched, если номер есть в индексе.
|
||||
4. UPDATE geom WHERE name_norm=... AND municipality='Екатеринбург' AND geom IS NULL
|
||||
(idempotent). Per-row SAVEPOINT — битый UPDATE не валит батч.
|
||||
|
||||
Returns счётчики {osm_features, numbered, matched, updated, ambiguous, applied}.
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
counts = {
|
||||
"osm_features": 0,
|
||||
"numbered": 0,
|
||||
"matched": 0,
|
||||
"updated": 0,
|
||||
"ambiguous": 0,
|
||||
"applied": apply,
|
||||
}
|
||||
sample: list[tuple[str, tuple[float, float]]] = []
|
||||
|
||||
try:
|
||||
elements = fetch_osm_substations()
|
||||
counts["osm_features"] = len(elements)
|
||||
|
||||
by_number, ambiguous = _index_osm_by_number(elements)
|
||||
counts["numbered"] = len(by_number)
|
||||
counts["ambiguous"] = len(ambiguous)
|
||||
|
||||
candidates: list[str] = [str(row[0]) for row in db.execute(_CANDIDATES_SQL).all()]
|
||||
|
||||
for name_norm in candidates:
|
||||
coords = by_number.get(name_norm)
|
||||
if coords is None:
|
||||
continue
|
||||
counts["matched"] += 1
|
||||
lat, lon = coords
|
||||
if len(sample) < 20:
|
||||
sample.append((name_norm, (lat, lon)))
|
||||
|
||||
if not apply:
|
||||
continue
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — откат только этой строки
|
||||
res = db.execute(_UPDATE_SQL, {"name_norm": name_norm, "lat": lat, "lon": lon})
|
||||
if res.rowcount and res.rowcount > 0:
|
||||
counts["updated"] += res.rowcount
|
||||
except Exception as e:
|
||||
logger.warning("osm_tp_geocoder UPDATE failed for %r: %s", name_norm, e)
|
||||
|
||||
if apply:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
if apply:
|
||||
db.rollback()
|
||||
logger.exception("geocode_tp_from_osm: rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("geocode_tp_from_osm done (apply=%s): %s", apply, counts)
|
||||
if not apply:
|
||||
for number, (lat, lon) in sample:
|
||||
logger.info(" sample: ТП/РП %s → (%.6f, %.6f)", number, lat, lon)
|
||||
return counts
|
||||
|
||||
|
||||
def _main(argv: list[str] | None = None) -> int:
|
||||
"""CLI: OSM-геокод городских ТП/РП ЕКБ → geom. Dry-run по умолчанию.
|
||||
|
||||
Примеры:
|
||||
python -m app.services.site_finder.osm_tp_geocoder # dry-run
|
||||
python -m app.services.site_finder.osm_tp_geocoder --apply # пишет в БД
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="OSM-геокод городских ТП/РП ЕКБ (#2119 B2): матч name_norm ↔ "
|
||||
"OSM substation ref/name → geom + geocode_status='osm'."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Реально писать в БД. Без флага — dry-run (только счётчики + сэмпл).",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
logger.info("osm_tp_geocoder result: %s", geocode_tp_from_osm(apply=args.apply))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(_main())
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue