Compare commits
311 commits
9835876ced
...
b22edc6d6f
| Author | SHA1 | Date | |
|---|---|---|---|
| b22edc6d6f | |||
|
|
25186a78ac | ||
| 666962185c | |||
| d6d4a1c94a | |||
| 2f127c4316 | |||
| 034d9f9bca | |||
| 0b3ec7f91b | |||
| e7881cf45c | |||
| c0c18f4ecc | |||
| 85435fbf1b | |||
| 8a42fba836 | |||
| 7c12812f81 | |||
| 99ee09e230 | |||
| dfc1ee8be0 | |||
| 039954580e | |||
|
|
6f81ee73d7 | ||
| 703f6f60c0 | |||
| 4a9ddb2a9a | |||
|
|
a3cab9023f | ||
|
|
9cdc7cef21 | ||
| fc5c4b1e40 | |||
|
|
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 | |||
|
|
8fd2be97e4 | ||
| 0937da4260 | |||
| ee47e331f3 | |||
| a9d06c9c44 | |||
|
|
e84b6e553a | ||
| bcdec5ebd4 | |||
| c0566705a1 | |||
|
|
03ece36abd | ||
| 139bf16a8a | |||
|
|
0eb033d77d | ||
| a3db6e4158 | |||
|
|
35a0e29122 | ||
| 3b9dfdb44e | |||
| b2d794587f | |||
| 1c520cadb7 | |||
|
|
333a36f049 | ||
|
|
3505a4e3e5 | ||
| d87ebcc30e | |||
| cdc1e03b32 | |||
|
|
c4b1992de5 | ||
| b5f8f7b437 | |||
|
|
67a852caf1 | ||
| 509cecfa6a | |||
| 94114adca1 | |||
|
|
3909db745b | ||
| abdb413a99 | |||
| 15224da3b5 | |||
|
|
3ad1550363 | ||
| 8a177df0e2 | |||
| f7063d49b5 | |||
| e501dedf78 | |||
| d7f4873a53 | |||
| 62fa1f732a | |||
| b8ed46d2dc | |||
| 90e4a741f7 | |||
| f937f5a168 | |||
| 86a22dfc8f | |||
| d63122d3e4 | |||
| 3be8062062 | |||
| 19c0be5d5a | |||
| b7abaeb569 | |||
| df5e9003df | |||
| 97acf5273c | |||
| 5c17d21f6c | |||
| 9d71d94586 | |||
| e4a2d58c0c | |||
| ecc3ab5aab | |||
| 6391587572 | |||
| b7d35cfee0 | |||
| abef3e6e02 | |||
| 1c184959a8 | |||
| 03b3cb9fe4 | |||
| 0a6281b6e2 | |||
| f02067c7af | |||
| 227b939778 | |||
| 6965fd0484 | |||
| 79e4528eeb | |||
| b29b69343b | |||
| 18b22c9df8 | |||
| 8d7feabf9f | |||
| ddf6cb7ce3 | |||
| 32d1818fd4 | |||
| e2bb5061f9 | |||
| 5f661bd0b7 | |||
| 3bf2c1a48a | |||
| 24b107ddb5 | |||
| 102ef8392b | |||
| da0b8f0374 | |||
| 5839680998 | |||
| 2bb3ceb9de | |||
| e1a75d98d4 | |||
| 4758ea5e2b | |||
| 4a7ce91db1 | |||
| babb2a3473 | |||
| a2ec1fbc90 | |||
| e52dc47067 | |||
| 663f04b777 | |||
| 02b5f0b5d1 | |||
| 6d6e0ce0ca | |||
| b024864518 | |||
| daf4ebefc2 | |||
| 53cfd3c3b8 | |||
| 4ffda3299c | |||
| fe85cbb7ae | |||
| 806428abc9 | |||
| 02f49e795c | |||
| de4704f6cd | |||
| 975c400536 | |||
| e359fb4b95 | |||
| 5f84008cb3 | |||
| 005ce2f4ad | |||
| f448b550b5 | |||
| d9915c7834 | |||
| 1337fadb49 | |||
| 86f0797499 | |||
| a35d42f1c7 | |||
| caffd9b28d | |||
| 267e5dfc5b | |||
| 11a7018e78 | |||
| 0ea850c957 | |||
| 4863e67f27 | |||
| c362b8592a | |||
| 69845ddcee | |||
| cbe56cab0e | |||
| 078dadc440 | |||
| 169201bb8c | |||
| bb54de6000 | |||
| 89fb666d88 | |||
| f5a7f9b437 | |||
| 31e7816967 | |||
| 7cf6267b48 | |||
| a96af4c19c | |||
| ebc3813648 | |||
| 1b515f4e94 | |||
| b105e0a500 | |||
| ec4c7e9e6a | |||
| 384c4e7ac3 | |||
| bd3562b6bd | |||
| f617c323bd | |||
| 0f82b58265 | |||
| 848a44c629 | |||
| 565d5c906b | |||
| 32a58c91c5 | |||
| ec03a43283 | |||
| 77c01b6d3b | |||
| f60f2b24e6 | |||
| 7066117cb8 | |||
| 0641183ecd | |||
| efa2dc75ea | |||
| b45a819cb6 | |||
| 3320805723 | |||
| eab3d928d6 | |||
| 1722309372 | |||
| 2a38561175 | |||
| c8e7cb1c79 | |||
| 03ca46fe1d | |||
| 566b2f9617 | |||
| e76030588c | |||
| 041589f2fc | |||
| e148e61d49 | |||
| 6f76b565e5 | |||
| 848e6d3bdd | |||
| 4d063d6827 | |||
| 77b4f7e64c | |||
| b8fd7771b8 | |||
| 30e158ad2a | |||
| 8aaa42f101 | |||
| 57ae2dfe00 | |||
| 579536b4ad | |||
| 83ae6f8a47 | |||
| bcbbb53495 | |||
| e23dabe4f5 | |||
| 123717994b | |||
| f8112b420d | |||
| c97dd57e1c | |||
| fb25180929 | |||
| 5a2b6d91cd | |||
| f3e10a04ad | |||
| 94d01ed066 | |||
| 9e2d9ddbea | |||
| a100f03aa7 | |||
| 81ae21ada9 | |||
| 3421b21632 | |||
| 0c0efb887a | |||
| 267585e312 | |||
| 510cf1cb80 | |||
| ad5c1913cb | |||
| e2ff8958ca | |||
| 890daa4a31 | |||
| d651dac99a | |||
| a50af40ad6 | |||
| 7b8ffd4200 | |||
| fcb85deab8 | |||
| fba41c0cce | |||
| ca4637e5c8 | |||
| 2184453d8e | |||
| 43b392b809 | |||
| 59ee8b672f | |||
| 74f1ffb500 | |||
| e67cb721bf | |||
| 91edef1524 | |||
| 8aec625134 | |||
| 2fb34112b5 | |||
| c55eb4f4d4 | |||
| 3b84609670 | |||
| e8b05d821e | |||
| 2ebf7468cd | |||
| 94cf1f6217 | |||
| 44b9305d89 | |||
| 5b4c0dd1b2 | |||
| a0232e22f1 | |||
| 01b0dc74de | |||
| ec0f113d4f | |||
| 7e824ce6e4 | |||
| 18d012da1b | |||
| d38d5d43c5 | |||
| fed33fce27 | |||
| e809063ca3 | |||
| 3187419eb8 | |||
| 96d5794b5c | |||
| e39fb86a9b | |||
| 0acd72a325 | |||
| 6aff5a000d | |||
| 275e7e9618 | |||
| 3cafe22c15 | |||
| c5227a164e | |||
| 41804ed70e | |||
| 440bb2ca51 | |||
| 50684ce87e | |||
| 4a93a9297c | |||
| 840d64a4a0 | |||
| c45445b726 | |||
| 9448a945d4 | |||
| 40b7ca4242 | |||
| 54a5d13c48 | |||
| 3cd1605c5c | |||
| ef51bb8b62 | |||
| ec9ed56fad | |||
| 1bbc032316 | |||
| e82761964d | |||
| 16f375782b | |||
| 63d685a105 | |||
| 727f9b1f05 | |||
| a9dfe44f5c | |||
| 58e71dab01 | |||
| c1e0ccb2e8 | |||
| 6adba229f0 | |||
| b2d4f980f0 | |||
| 89c31e7449 | |||
| d5e51ca809 | |||
| a184d38aa6 | |||
| 453a1f08da |
521 changed files with 100585 additions and 3689 deletions
|
|
@ -351,7 +351,7 @@ Claude имеет ДВА лимита: 5h-rolling (сам сбрасываетс
|
|||
- Пустая очередь + нет fixup-PR → выходить из loop, а не крутиться вхолостую.
|
||||
- Тяжёлые batch-прогоны — вне пиковых часов (≈5–11 PT) при возможности.
|
||||
- Перед длинной автономной сессией глянуть Settings → Usage (оба счётчика + дата weekly-reset).
|
||||
- **Окна — на Sonnet, не Opus** (`start-bot.ps1` уже запускает с `--model sonnet`). Opus — только main-оркестратору. Sonnet-пул отдельный, недельный All-models (Opus) пул так не горит.
|
||||
- **Окна — на Sonnet, не Opus** (`start-bot.ps1` уже запускает с `--model sonnet`; reviewer — opus). Opus — только main-оркестратору. Sonnet-пул отдельный, недельный All-models (Opus) пул так не горит.
|
||||
- **Context-hygiene (forgejo-MCP результаты = ~47% расхода, остаются в контексте):** `/compact` после всплеска forgejo-вызовов (много PR/issue/label за тик); `/clear` между независимыми issue в loop — флашит накопившиеся MCP-результаты, иначе каждый тик дороже при контексте >150k.
|
||||
|
||||
## Error escalation
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-analyst
|
|||
description: "[DRAFT — autonomous loop only] Analyst в режиме /loop 15m. Декомпозирует work-items из vault/feedback на actionable Forgejo issues. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Task, Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_get_recent_changes, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details
|
||||
---
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ tools: Task, Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp_
|
|||
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Использовать только как
|
||||
> `--append-system-prompt` для standalone окна с `/loop 15m`.
|
||||
>
|
||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> Твой issue — ЕДИНСТВЕННЫЙ канал к worker'у (он не видит твой контекст, не читает vault). Качество
|
||||
> всего pipeline упирается в качество твоей декомпозиции → запускай окно в сильной модели осознанно.
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
|||
|
||||
**Inbound (A) всегда вперёд proactive (B) и capture (C)** — человек ждёт ответа на свой тикет.
|
||||
|
||||
## Per-tick workflow (every 30 minutes)
|
||||
## Per-tick workflow (every 15 minutes)
|
||||
|
||||
```
|
||||
1. KILL-SWITCH check (см. _autonomous_pickup.md)
|
||||
|
|
@ -68,8 +68,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
|||
- По каждому scope узким запросом (labels=scope/X,status/Y — НЕ полный листинг):
|
||||
ready / wip / review / qa / needs-fix → карта «что уже в работе у backend/frontend/db/qa»,
|
||||
чтобы НЕ плодить дубль того, что воркер уже взял. Что закрылось: labels=status/done&since=30m.
|
||||
- ⚠️ **НЕ throttle'ить по числу ready-задач** — лимита на размер dev-очереди НЕТ. Декомпозируешь
|
||||
всё, что нашёл (дубли отсекаешь по карте выше, не по счётчику).
|
||||
- ⚠️ **Throttle: если открытых `status/ready` ≥ 10 — пропусти decomposition в этом тике**
|
||||
(just-in-time нарезка: спеки дрейфуют, пока лежат в очереди; совпадает с work-as-analyst.md).
|
||||
|
||||
3b. KNOWLEDGE CAPTURE (vault-write — режим C; ПОСЛЕ inbound, off hot-path):
|
||||
Для каждого issue, перешедшего в `status/done` за окно (из `labels=status/done&since=30m` шага 3):
|
||||
|
|
@ -185,7 +185,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
|||
- **Дедуп + claim — ДО спавна** (шаги 2/3): саб-агенты не знают про queue-state, могут продублировать.
|
||||
- Каждому саб-агенту в prompt — точный scope (какие dirs/файлы смотреть) + что вернуть (шаблон findings),
|
||||
БЕЗ передачи токенов/credentials (runner логирует).
|
||||
- Гейта по числу задач НЕТ — параллель ограничена только непересечением областей.
|
||||
- Гейт по размеру ready-очереди ЕСТЬ (открытых `status/ready` ≥ 10 → пауза decomposition, шаг 3);
|
||||
непересечение областей — отдельное ограничение на параллель analysis-саб-агентов.
|
||||
|
||||
## Запрос «поменяй лейблы» ⇒ также аудит тела issue
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-backend
|
|||
description: "[DRAFT — autonomous loop only] Backend engineer в режиме /loop dynamic. Polling Forgejo issues scope/backend, claim+work+push+PR. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query
|
||||
---
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_searc
|
|||
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
|
||||
> для standalone окна с `/loop dynamic`.
|
||||
>
|
||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn
|
||||
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn
|
||||
> (который запрещён). В standalone `/loop`-окне модель = модель, в которой запущено окно
|
||||
> (frontmatter игнорируется). Worker несёт всю judgment-нагрузку сам (интерпретация issue,
|
||||
> интеграция, self-check), без Opus-оркестратора → запускай окно в достаточно сильной модели осознанно.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-code-reviewer
|
|||
description: "[DRAFT — autonomous loop only] Code reviewer + merge authority в режиме /loop 2m. Читает PR diff, выносит verdict, мерджит APPROVE. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes
|
||||
---
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsid
|
|||
> **Forgejo API → ТОЛЬКО `mcp__forgejo__*` tools.** ❌ НЕ дёргай curl / python3 / `/tmp/*.json` руками —
|
||||
> на Windows это даёт `http=404` + `FileNotFoundError /tmp/...` (incident 2026-05-31, PR #893). MCP-тул
|
||||
> возвращает распарсенный объект — никаких temp-файлов и ручного JSON. Полный mapping в
|
||||
> [[_autonomous_pickup]] § «Forgejo операции». Запуск окна: `scripts/start-bot.ps1 reviewer` (Sonnet).
|
||||
> [[_autonomous_pickup]] § «Forgejo операции». Запуск окна: `scripts/start-bot.ps1 reviewer`.
|
||||
>
|
||||
> ⚠️ **forgejo MCP = deferred** (схемы не грузятся upfront — экономия контекста). В НАЧАЛЕ work-тика,
|
||||
> если forgejo-тулзы ещё не загружены, выполни ОДИН раз:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-frontend
|
|||
description: "[DRAFT — autonomous loop only] Frontend engineer в режиме /loop dynamic. Polling Forgejo issues scope/frontend, claim+work+push+PR. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents
|
||||
---
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_searc
|
|||
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
|
||||
> для standalone окна с `/loop dynamic`.
|
||||
>
|
||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> В standalone `/loop`-окне модель = модель окна. Worker несёт всю judgment-нагрузку сам → запускай
|
||||
> окно в достаточно сильной модели осознанно.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-qa-tester
|
|||
description: "[DRAFT — autonomous loop only] QA tester в режиме /loop 5m. Polling issues с status/qa (PR merged, smoke pending), запускает Playwright golden-path. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__playwright__browser_navigate, mcp__playwright__browser_click, mcp__playwright__browser_type, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_evaluate, mcp__playwright__browser_wait_for, mcp__playwright__browser_close
|
||||
---
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsid
|
|||
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
|
||||
> для standalone окна с `/loop 5m`.
|
||||
>
|
||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> В standalone `/loop`-окне модель = модель окна → запускай осознанно.
|
||||
|
||||
> **Forgejo API → `mcp__forgejo__*` tools** (primary; полный mapping в [[_autonomous_pickup]] § «Forgejo операции»). curl — только fallback. Запуск окна: `scripts/start-bot.ps1 qa`.
|
||||
|
|
@ -21,7 +21,7 @@ tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsid
|
|||
|
||||
QA в autonomous-pickup mode. Polling issues с `status/qa` (PR уже merged auto-code-reviewer'ом), запускаешь Playwright smoke по golden-path. OK → close issue + status/done. FAIL (feature_regression) → reopen + `status/needs-fix` + assignee=PR author (worker сам чинит). FAIL (prod_down) → `pause-bots` + needs-human.
|
||||
|
||||
## Per-tick workflow (every 10 minutes)
|
||||
## Per-tick workflow (every 5 minutes)
|
||||
|
||||
```
|
||||
1. KILL-SWITCH check (см. _autonomous_pickup.md)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
|
||||
## Запреты
|
||||
|
||||
- ❌ Не коммить (пользователь коммитит сам); пиши commit message в чат
|
||||
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||
- ❌ Не использовать `--no-verify` для обхода pre-commit
|
||||
- ❌ Не запускать миграции SQL — это работа database-expert (можешь делегировать через Agent tool если задача требует)
|
||||
- ❌ Не редактировать docker-compose / Caddyfile / .github/workflows/ — это работа devops-engineer
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: code-reviewer
|
||||
description: "Code reviewer для GenDesign — проверяет staged/recent changes на безопасность, корректность, производительность, conformance с project conventions. Read-only — НЕ пишет код, НЕ коммитит, НЕ пушит. Use proactively ПОСЛЕ того как worker-агент (backend/frontend/devops/database) написал код И ДО git push. Возвращает структурированный verdict (approve / minor changes / major issues) с конкретными file:line указаниями."
|
||||
tools: Read, Glob, Grep, Bash, WebFetch, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes
|
||||
model: opus
|
||||
model: sonnet
|
||||
memory: project
|
||||
color: red
|
||||
---
|
||||
|
|
@ -146,7 +146,7 @@ mcp__obsidian__obsidian_simple_search "<keyword>"
|
|||
- Удаление prod данных без явного approval
|
||||
- `--no-verify` / `--force` / `--amend` в push
|
||||
- **Прямой push в main** — нарушение PR workflow (см. CLAUDE.md). Должен быть feature branch + PR.
|
||||
- **Merge PR без user approval** — нарушение PR workflow.
|
||||
- **Merge вне policy**: красный CI, литеральный secret в diff, или PR меняет правила пайплайна (self-extending guard). Self-merge при зелёном CI разрешён с 2026-06-27 (git-pr.md § Auto-merge policy) — сам по себе НЕ блокер.
|
||||
|
||||
### Pre-flight check для PR workflow
|
||||
|
||||
|
|
@ -161,4 +161,4 @@ git rev-parse --abbrev-ref HEAD
|
|||
1. `git stash` или backup commit'ов
|
||||
2. Создать ветку `git checkout -b feat/foo`
|
||||
3. Перенести коммиты
|
||||
4. `git push -u origin feat/foo` + `gh pr create`
|
||||
4. `git push -u forgejo feat/foo` + `mcp__forgejo__create_pull_request` (gh CLI bypassed 2026-05-16)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: database-expert
|
||||
description: Database expert for GenDesign — PostgreSQL 16 + PostGIS 3.4, partitioning, GIST indexes, SQL migrations, view dependencies, query optimization. Use proactively for any work in `data/sql/NN_*.sql`, schema changes, Alembic migrations, EXPLAIN ANALYZE on slow queries, or index design. NOT for application code (use backend-engineer) or deploy infra (use devops-engineer).
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes, mcp__postgres-gendesign__analyze_workload_indexes, mcp__postgres-gendesign__analyze_db_health, mcp__postgres-gendesign__get_top_queries
|
||||
model: opus
|
||||
model: sonnet
|
||||
color: purple
|
||||
---
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
- PostgreSQL 16 + PostGIS 3.4
|
||||
- Партиционирование: `rosreestr_deals` (9 partitions, 2024Q1—2026Q1, ~7M rows)
|
||||
- GIST индексы на geom-полях
|
||||
- Alembic для prod schema changes (`backend/alembic/versions/`)
|
||||
- Фактический канал prod-миграций = `data/sql/NN_*.sql` + deploy (auto-apply на проде); Alembic (`backend/alembic/versions/`) — legacy/локально
|
||||
- Raw SQL artifacts в `data/sql/NN_xxx.sql` для больших миграций / views / bootstrap
|
||||
- psycopg v3 на стороне backend (НЕ psycopg2)
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
5. **Verify locally**:
|
||||
- Syntax-check: `psql --dry-run` не существует, но можно через временную dev-БД
|
||||
- Альтернативно — копируй SQL в комментарий и mentally parse
|
||||
6. **Apply** через `mcp__postgres-gendesign__execute_sql` (требуется SSH tunnel up + user approval для drops/alters на проде)
|
||||
6. **Apply**: prod schema changes ТОЛЬКО через `data/sql/NN_*.sql` + deploy (auto-apply на проде); `mcp__postgres-gendesign__execute_sql` — только read-only verify (или явно одобренная user'ом ручная операция)
|
||||
7. **Verify post-apply**:
|
||||
- `information_schema.columns` для column changes
|
||||
- `pg_indexes` / `pg_views` для indexes/views (если разрешено читать pg_*)
|
||||
|
|
@ -92,5 +92,5 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
- ❌ Изменять Alembic version files задним числом — только новые revisions
|
||||
- ❌ Запускать миграцию на проде без backup confirmation (если она destructive)
|
||||
- ❌ Использовать `psycopg2` в коде (только v3)
|
||||
- ❌ Commit'ить сам — пиши commit message в чат
|
||||
- ❌ Commit'ить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||
- ❌ Писать knowledge в `memory/memory-gendesign.jsonl` (deprecated) — только Obsidian vault
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ Short skeleton:
|
|||
|
||||
## Forgejo API conventions
|
||||
|
||||
- `$FORGEJO_URL` = `https://git.gendsgn.ru`, `$FORGEJO_TOKEN` = в env (из `~/.claude/settings.json`)
|
||||
- `$FORGEJO_URL` = `https://git.gendsgn.ru`, токен — `FORGEJO_ACCESS_TOKEN` / `FORGEJO_TOKEN_<ROLE>` из Windows User-scope env vars (выставляются ДО запуска claude; см. `_autonomous_pickup.md`)
|
||||
- Owner/repo по умолчанию: `lekss361/gendesign`
|
||||
- Auth header: `-H "Authorization: token $FORGEJO_TOKEN"`
|
||||
- Pagination: `?page=1&limit=50` (max 50 на странице)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@
|
|||
## Auto-merge policy (PR review mode)
|
||||
|
||||
При review открытых Forgejo PR — если verdict **✅ APPROVE** (нет 🔴/🟠/🟡):
|
||||
**мержи сам, любой scope** (user override 2026-05-16: «после аппрув в ревью можешь мерджить все что угодно»).
|
||||
**мержи сам, любой scope** (политика 2026-06-27, git-pr.md § Auto-merge policy).
|
||||
|
||||
### Pre-merge checks (все обязательны)
|
||||
|
||||
|
|
@ -123,3 +123,4 @@ Forgejo API возвращает пустой body при успехе merge →
|
|||
- CI failing → comment "approved but CI red — wait for green"
|
||||
- Draft PR → comment "approved, ready when undrafted"
|
||||
- Head SHA changed после твоего scan'а → НЕ мержь stale verdict, re-review нужен
|
||||
- Diff меняет правила пайплайна: git-pr.md § Auto-merge policy, CLAUDE.md Critical rules, `_autonomous_pickup.md`, `auto-code-reviewer.md`, любой `work-as-*.md` → НЕ merge, label `needs-human` (self-extending guard)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
name: devops-engineer
|
||||
description: DevOps engineer for GenDesign — Docker, docker-compose, Caddyfile, GitHub Actions workflows, SSH deploy, CouchDB stack, Obsidian LiveSync infra. Use proactively for any work in `docker-compose*.yml`, `Caddyfile`, `.github/workflows/`, `scripts/setup-*.sh`, `ops/`, or SSH-deploy issues. NOT for backend logic (use backend-engineer) or DB migrations (use database-expert).
|
||||
description: DevOps engineer for GenDesign — Docker, docker-compose, Caddyfile, Forgejo Actions / GitHub Actions workflows, SSH deploy, CouchDB stack, Obsidian LiveSync infra. Use proactively for any work in `docker-compose*.yml`, `Caddyfile`, `.forgejo/workflows/**`, `.github/workflows/**`, `scripts/setup-*.sh`, `ops/`, or SSH-deploy issues. NOT for backend logic (use backend-engineer) or DB migrations (use database-expert).
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__obsidian__obsidian_patch_content
|
||||
model: opus
|
||||
model: sonnet
|
||||
color: orange
|
||||
---
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
- Beget VPS, Москва, 2 vCPU / 4 GB / 40 GB NVMe
|
||||
- Docker + docker-compose (два стека: main + obsidian)
|
||||
- Caddy 2 (auto-TLS Let's Encrypt)
|
||||
- GitHub Actions (build → GHCR → SSH → compose up)
|
||||
- Forgejo Actions — фактический CI/deploy (`.forgejo/workflows/**`; build → GHCR → SSH → compose up); legacy GitHub Actions в `.github/workflows/**`
|
||||
- Docker images: backend (lean), worker (with-chromium), frontend, couchdb (вешний)
|
||||
- Shared external network `gendesign_shared` для связи main↔obsidian стеков
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
|
||||
## Запреты
|
||||
|
||||
- ❌ Не коммить сам — пиши commit message в чат
|
||||
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||
- ❌ Не push в main с `--force` (никогда)
|
||||
- ❌ Не редактировать `backend/`/`frontend/` исходники (только infra-конфиги)
|
||||
- ❌ Не выполнять prod SSH без явного approval пользователя (читать prod-логи — отдельно safety guard, нужен approval)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: frontend-engineer
|
||||
description: Frontend engineer for GenDesign — Next.js 15 app router, React 19, TypeScript 5 strict, Tailwind 4, TanStack Query. Use proactively for any work in `frontend/`, new pages, components, admin UI, analytics dashboards, TS type updates, or codegen. NOT for backend API contracts (use backend-engineer first).
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__obsidian__obsidian_patch_content
|
||||
model: opus
|
||||
model: sonnet
|
||||
color: green
|
||||
---
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
|
||||
## Запреты
|
||||
|
||||
- ❌ Не коммить сам — пиши commit message в чат
|
||||
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||
- ❌ Не редактировать `backend/` — это работа backend-engineer
|
||||
- ❌ Не использовать `any`
|
||||
- ❌ Не использовать pages router (`src/pages/`) — только app router (`src/app/`)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: qa-tester
|
||||
description: "QA tester for GenDesign — runs post-deploy verification после успешного merge+deploy. Use proactively СРАЗУ после того как deploy.yml завершился success на main. Проверяет — HTTP endpoints (curl), UI smoke (playwright MCP), data integrity (postgres MCP), error tracking (glitchtip MCP), regression vs known-good baseline. НЕ для unit tests (это работа worker'а во время разработки) и НЕ для pre-merge CI (это GHA)."
|
||||
tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__playwright__browser_navigate, mcp__playwright__browser_navigate_back, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_click, mcp__playwright__browser_fill_form, mcp__playwright__browser_type, mcp__playwright__browser_press_key, mcp__playwright__browser_wait_for, mcp__playwright__browser_close, mcp__playwright__browser_evaluate, mcp__glitchtip__glitchtip_issues, mcp__glitchtip__glitchtip_latest_event
|
||||
model: opus
|
||||
model: sonnet
|
||||
memory: project
|
||||
color: cyan
|
||||
---
|
||||
|
|
@ -50,7 +50,7 @@ POST-комменты — `--data-binary @file` (не inline `-d`: Windows ср
|
|||
|
||||
→ определи changed paths:
|
||||
- `backend/app/api/v1/<X>.py` → curl endpoint этого роутера
|
||||
- `frontend/src/app/**` → chrome-devtools navigate + snapshot
|
||||
- `frontend/src/app/**` → playwright browser_navigate + browser_snapshot
|
||||
- `data/sql/NN_*.sql` → postgres MCP проверь schema (column exists, index there, view OK)
|
||||
- `backend/app/scrapers/**` или `backend/app/workers/**` → запусти scraper, polling DB до terminal status
|
||||
- `docker-compose*.yml` / `Caddyfile` → smoke production URLs + проверь containers running
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"postgres-gendesign": {
|
||||
"type": "stdio",
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
|
||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"],
|
||||
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
|
||||
},
|
||||
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint=http://localhost:9222"], "alwaysLoad": true },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"postgres-gendesign": {
|
||||
"type": "stdio",
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
|
||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"],
|
||||
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
.claude/rules/delegation.md
Normal file
48
.claude/rules/delegation.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Delegation & task sizing
|
||||
|
||||
> Без `paths:` — загружается в каждой сессии. Канон лимитов делегирования (портировано из memory-фидбека 2026-06-27, routing/effort — из верифицированного ресёрча 2026-07-02).
|
||||
|
||||
## Routing (кому отдавать)
|
||||
|
||||
| Задача | Агент |
|
||||
|---|---|
|
||||
| Разведка: найти файлы / понять структуру | **Explore** (Haiku, read-only, дёшев) — НЕ general-purpose |
|
||||
| Спроектировать подход | **Plan** |
|
||||
| Код | доменный worker (backend/frontend/database/devops) + `isolation: "worktree"` |
|
||||
| Проверить результат | отдельный агент **в fresh context** — видит только diff и критерии, без bias автора |
|
||||
|
||||
- Продолжение работы существующего агента → `SendMessage` по agentId (контекст цел), НЕ новый спавн с пересказом.
|
||||
- Масштаб пачки: 1-3 независимых → параллельные Agent-вызовы одним сообщением; конвейер / 10+ агентов → Workflow (pipeline-default, `schema`-выход, adversarial verify находок). Caps на выборках — логируй отброшенное, молчаливое усечение читается как «покрыто всё».
|
||||
|
||||
## Бюджет одного сабагента (hard limits)
|
||||
|
||||
- **1 сабагент = 1 узкий deliverable.** Ориентиры: ~≤10 мин работы, ~≤20 tool-calls, ~≤150k токенов.
|
||||
Эмпирика 2026-06-27: агент-аудитор на 186k tok / 33 calls упал на StructuredOutput; 5 мелких параллельных прошли.
|
||||
- Поверхность больше бюджета → **дели на N узких сабагентов** (parallel при непересекающихся файлах, sequential при зависимостях). НЕ один большой.
|
||||
- Промпт сабагенту: конкретный deliverable + формат ответа + границы («что НЕ делать»). Расплывчатый scope = дубли и мусор.
|
||||
- Windows: очень длинный промпт субагенту может упасть на лимите командной строки (~8191 символ) — ещё один довод за компактность.
|
||||
|
||||
## Эскалация oversized-задачи (worker)
|
||||
|
||||
Issue/задача выглядит больше одного захода (эвристика: >5 файлов, ИЛИ >500 строк diff, ИЛИ >2ч) → **НЕ исполнять целиком**:
|
||||
- bot-pipeline: комментарий с планом сплита + label `status/needs-analysis`, снять claim
|
||||
- interactive: вернуть main-сессии план сплита вместо результата
|
||||
|
||||
## Единые пороги дробления (analyst / main)
|
||||
|
||||
- Estimate S(<2h) / M(2-8h) / **L(>8h) → обязан дробиться дальше** (до S/M)
|
||||
- Issue ≥1.5 дня → 3-4 sub-PR (Foundation → Schema → Workers → Integration), каждый ~200-500 строк — см. git-pr.md § Split big issues
|
||||
- 1 sub-issue ≈ 1-2 worker-захода, single-scope (не смешивать backend+frontend в одном issue)
|
||||
|
||||
## Параллелизм
|
||||
|
||||
Default = parallel на непересекающихся файлах (per-task worktree). Sequential — только overlap / hot-files / зависимый стек (git-pr.md § Parallel vs sequential PRs).
|
||||
|
||||
**Усилие ∝ сложности**: простой факт-запрос = 1 агент / 3-10 tool-calls; сложный разбор = N узких параллельных. Ширина пачки дешева, толщина одного агента — дорога и хрупка. Параллельные сессии/окна: практический потолок 3-5 (bottleneck — review, не Claude).
|
||||
|
||||
## Effort / model per agent
|
||||
|
||||
- Наследовать по умолчанию; модель НЕ переопределять без нужды (Explore и так на Haiku).
|
||||
- `effort: low/medium` — механика: точечные правки по списку, сбор данных, mass-grep, формат-конверсии.
|
||||
- `effort: high+` — только verify/judge-этапы, архитектурный синтез, решения.
|
||||
- Циклы/поллинг: prompt-cache живёт 5 мин — тик либо <~4.5 мин (кэш тёплый), либо сразу 20-30 мин; интервалы 5-15 мин = worst case (полный re-read контекста каждый тик без амортизации).
|
||||
|
|
@ -16,11 +16,11 @@ paths:
|
|||
```
|
||||
1. claude --bg --name "feat-<scope>" "task description"
|
||||
→ supervisor создаёт worktree от forgejo/main автоматически
|
||||
(settings worktree.baseRef=default, bgIsolation=git)
|
||||
(settings worktree.baseRef=fresh, bgIsolation=worktree)
|
||||
2. Session работает: код → commit → push → mcp__forgejo__create_pull_request
|
||||
3. PR URL появляется в agent view как row с зелёной/жёлтой/красной ● status dot
|
||||
4. User делает review через external Claude window (post-push)
|
||||
5. После merge — `Ctrl+X dwa раза` в agent view = удаление session + worktree
|
||||
5. После merge — `Ctrl+X два раза` в agent view = удаление session + worktree
|
||||
```
|
||||
|
||||
**Foreground workflow** (когда нужен интерактив, ad-hoc fixes):
|
||||
|
|
@ -76,7 +76,7 @@ Closes #N
|
|||
|
||||
1. `mcp__forgejo__get_pull_request` (или `curl -sH "$H" "$REPO/pulls/<N>"`) → читай `state`, `mergeable`, `head.sha`
|
||||
2. `state == merged` → stop polling
|
||||
3. Новый review/comment: `mcp__forgejo__list_pr_reviews` / `list_issue_comments`. Парсь marker `<!-- gendesign-review-bot: sha=<sha7> verdict=<approve|changes> -->`
|
||||
3. Новый review/comment: `mcp__forgejo__list_pull_reviews` / `list_issue_comments`. Парсь marker `<!-- gendesign-review-bot: sha=<sha7> verdict=<approve|changes> -->`
|
||||
- **SHA guard**: `marker.sha7 == head.sha[:7]` — иначе устаревший approval до fixup-push, игнорируй
|
||||
- `verdict=approve` + SHA match → `mcp__forgejo__merge_pull_request` (squash + delete branch)
|
||||
- `verdict=changes` → fixup commits + push в `forgejo feat/<scope>` + re-poll
|
||||
|
|
@ -91,16 +91,20 @@ Closes #N
|
|||
- Diff содержит литеральный secret/token/password/credential (40-char hex, API keys, JWT, и т.д.) — security tripwire.
|
||||
- PR меняет правила самого пайплайна: блок `## Auto-merge policy` здесь, `Critical rules` в CLAUDE.md, `_autonomous_pickup.md` (claim/kill-switch/merge-FSM), `auto-code-reviewer.md` или любой `work-as-*.md` — **self-extending guard** (расширение/снятие собственных merge-прав всегда через human, предотвращает bot-loop).
|
||||
|
||||
## Sequential PRs
|
||||
## Parallel vs sequential PRs
|
||||
|
||||
**Одна задача → один PR → merge → следующая.** Параллельно ТОЛЬКО если scope'ы строго orthogonal (разные файлы).
|
||||
**Default = параллельно**, если scope'ы НЕ пересекаются по файлам: каждая задача в своём worktree (`git worktree add` / isolation:"worktree"), своя ветка, свой PR.
|
||||
|
||||
Опасные файлы для конфликтов: `backend/app/api/v1/parcels.py`, `frontend/src/types/site-finder.ts`, OverviewTab/LandTab/MarketTab.
|
||||
**Sequential обязателен когда:**
|
||||
- Пересечение файлов / hot-files: `backend/app/api/v1/parcels.py`, `frontend/src/types/site-finder.ts`, OverviewTab/LandTab/MarketTab
|
||||
- Зависимый стек sub-PR'ов (Foundation → Schema → Workers → Integration): PR N+1 только после merge PR N
|
||||
|
||||
## Split big issues
|
||||
|
||||
Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Integration**. Каждый ~200-500 lines. PR N+1 только после merge PR N.
|
||||
|
||||
Бюджет одного subagent-захода и правила эскалации oversized-задач: `.claude/rules/delegation.md`.
|
||||
|
||||
## Review workflow (no conflict)
|
||||
|
||||
- **Pre-push** (локально): spawn `code-reviewer` subagent на staged changes → lint pass (security, correctness, conventions). Блокирует push при 🔴 критикал.
|
||||
|
|
@ -112,12 +116,12 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
|
|||
|
||||
- **Параллелизм**: 3-5 sessions одновременно (>5 = bottleneck на review, не на Claude per Anthropic метрика)
|
||||
- **Pin (`Ctrl+T`)** для long-running sessions (scraper monitors, deploy watchers) — supervisor не убьёт через 1h idle
|
||||
- **Cleanup**: `Ctrl+X dwa раза` после merge → session + worktree удалены атомарно
|
||||
- **NEVER** parallel sessions на one file — каждая ест в own worktree, last-merge wins (используй sequential PR rule выше)
|
||||
- **Cleanup**: `Ctrl+X два раза` после merge → session + worktree удалены атомарно
|
||||
- **NEVER** parallel sessions на one file — каждая ест в own worktree, last-merge wins (см. § Parallel vs sequential PRs)
|
||||
|
||||
**Manual `git worktree add` deprecated** — используй `claude --bg --name X`, supervisor сам isolation делает с `baseRef: default` (свежая ветка от main, не от твоей stale-сессии).
|
||||
**Manual `git worktree add` deprecated** — используй `claude --bg --name X`, supervisor сам isolation делает с `baseRef: fresh` (свежая ветка от main, не от твоей stale-сессии).
|
||||
|
||||
**Worktree cleanup** (cron weekly): `scripts/cleanup-merged-worktrees.sh` — удаляет worktrees для merged branches. Запускать вручную или weekly cron.
|
||||
**Worktree cleanup** (cron weekly): `scripts/cleanup-merged-worktrees.sh` — удаляет worktrees для merged branches. Запускать вручную или weekly cron.
|
||||
|
||||
## Запреты
|
||||
|
||||
|
|
@ -126,4 +130,4 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
|
|||
- ❌ `gh pr *` — bypassed 2026-05-16, используй Forgejo MCP или curl + `$FORGEJO_TOKEN`
|
||||
- ❌ `--no-verify` / `--amend` / `--no-edit` / `--force` без явного approval
|
||||
- ❌ `@claude` в PR comments — plain text only (`feedback_no_claude_mentions`)
|
||||
- ❌ Параллельные PR на одни файлы (`feedback_sequential_prs`)
|
||||
- ❌ Параллельные PR на одни файлы / hot-files (см. § Parallel vs sequential PRs)
|
||||
|
|
|
|||
144
.claude/settings.json
Normal file
144
.claude/settings.json
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__obsidian__obsidian_list_files_in_vault",
|
||||
"mcp__obsidian__obsidian_simple_search",
|
||||
"mcp__obsidian__obsidian_get_file_contents",
|
||||
"mcp__obsidian__obsidian_list_files_in_dir",
|
||||
"mcp__postgres-gendesign__get_object_details",
|
||||
"mcp__postgres-gendesign__explain_query",
|
||||
"mcp__postgres-gendesign__list_objects",
|
||||
"mcp__postgres-gendesign__list_schemas",
|
||||
"mcp__postgres-gendesign__analyze_query_indexes",
|
||||
"Bash(curl *)",
|
||||
"Bash(powershell *)",
|
||||
"Bash(gh pr comment:*)",
|
||||
"Bash(powershell.exe:*)",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(pwsh:*)",
|
||||
"mcp__forgejo",
|
||||
"mcp__forgejo__create_issue",
|
||||
"mcp__forgejo__update_issue",
|
||||
"mcp__forgejo__add_issue_labels",
|
||||
"mcp__forgejo__remove_issue_labels",
|
||||
"mcp__forgejo__issue_state_change",
|
||||
"mcp__forgejo__create_issue_comment",
|
||||
"mcp__forgejo__create_pull_request",
|
||||
"mcp__forgejo__merge_pull_request",
|
||||
"mcp__forgejo__create_pull_review",
|
||||
"mcp__forgejo__get_pull_request_diff",
|
||||
"mcp__forgejo__list_repo_issues",
|
||||
"mcp__forgejo__list_repo_pull_requests",
|
||||
"mcp__forgejo__get_pull_request_by_index",
|
||||
"mcp__forgejo__list_pull_request_files",
|
||||
"mcp__forgejo__list_pull_reviews",
|
||||
"mcp__forgejo__list_repo_labels",
|
||||
"mcp__forgejo__get_issue_by_index",
|
||||
"mcp__forgejo__list_issue_comments",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Read",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"EnterWorktree",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"NotebookEdit",
|
||||
"mcp__obsidian",
|
||||
"mcp__postgres-gendesign",
|
||||
"mcp__playwright",
|
||||
"mcp__a11y",
|
||||
"mcp__lighthouse",
|
||||
"mcp__shadcn",
|
||||
"mcp__glitchtip",
|
||||
"mcp__context7",
|
||||
"mcp__fetch",
|
||||
"Bash(*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf *)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(git push --force *)",
|
||||
"Bash(git push -f *)",
|
||||
"Bash(git reset --hard *)",
|
||||
"Bash(git commit --amend *)",
|
||||
"Bash(git rebase --interactive *)",
|
||||
"Bash(git commit --no-verify*)",
|
||||
"Bash(git push --no-verify*)",
|
||||
"Bash(git push --force-with-lease*)",
|
||||
"Bash(git push --force-if-includes*)",
|
||||
"Bash(docker compose down -v *)",
|
||||
"Bash(docker volume rm *)",
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)",
|
||||
"Read(./backend/.env)",
|
||||
"Read(./backend/.env.*)",
|
||||
"Read(**/.env)",
|
||||
"Read(**/.env.*)"
|
||||
],
|
||||
"defaultMode": "auto"
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"obsidian",
|
||||
"context7",
|
||||
"fetch",
|
||||
"forgejo",
|
||||
"a11y",
|
||||
"lighthouse",
|
||||
"shadcn"
|
||||
],
|
||||
"worktree": {
|
||||
"baseRef": "fresh",
|
||||
"bgIsolation": "worktree"
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-secret-read.py"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-dangerous-commands.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-no-print.py"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-sql-pitfalls.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/session-preflight.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/statusline.py"
|
||||
}
|
||||
}
|
||||
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
|
||||
|
|
@ -103,6 +103,10 @@ jobs:
|
|||
filters: |
|
||||
backend:
|
||||
- 'tradein-mvp/backend/**'
|
||||
# scraper-kit вкомпилирован в backend-образ (build context tradein-mvp/,
|
||||
# scheduler_main импортирует пакет) — kit-only изменение обязано
|
||||
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
browser:
|
||||
|
|
@ -117,11 +121,19 @@ jobs:
|
|||
- 'tradein-mvp/backend/app/services/scheduler.py'
|
||||
- 'tradein-mvp/backend/app/scheduler_main.py'
|
||||
- 'tradein-mvp/backend/app/tasks/**'
|
||||
# #2188: scheduler исполняет matching/dedup при каждом scrape-тике —
|
||||
# без этих путей scraper-контейнер оставался на старом коде
|
||||
# (2026-07-02: fias-dedup доехал до tradein-backend, но не до
|
||||
# tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть
|
||||
# scheduler — его правки тоже обязаны пересоздавать контейнер.
|
||||
- 'tradein-mvp/backend/app/services/matching/**'
|
||||
- 'tradein-mvp/backend/app/services/house_dedup_merge.py'
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
|
||||
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
|
||||
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
|
||||
# Tests use mocks + a stub DATABASE_URL — no real Postgres/Redis needed.
|
||||
# 2 pre-existing order-dependent tests are deselected (see DESELECT note below).
|
||||
# 1 pre-existing order-dependent test is deselected (see DESELECT note below).
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
@ -146,20 +158,21 @@ jobs:
|
|||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Sync deps (incl. dev group — pytest)
|
||||
# NB: tradein-mvp/backend/uv.lock is gitignored (see tradein-mvp/.gitignore),
|
||||
# so --frozen can't work in CI (no lockfile in a fresh checkout). Resolve
|
||||
# from pyproject.toml like the Dockerfile does (uv sync --no-dev there).
|
||||
run: uv sync
|
||||
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
|
||||
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
|
||||
# Dockerfile (uv sync --frozen --no-dev). Актуализировано в #2208.
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# DESELECT (2026-05): two pre-existing tests fail only in whole-suite
|
||||
# ordering (pass in isolation) due to global state leak from other test
|
||||
# modules — not introduced here. Excluded so the gate is reliably green;
|
||||
# tracked separately. Everything else (1087 tests) must pass.
|
||||
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
||||
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
||||
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
||||
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном
|
||||
# прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
|
||||
# совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
|
||||
run: |
|
||||
uv run pytest -q \
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit" \
|
||||
--deselect "tests/test_cian_valuation.py::test_cache_hit_returns_cached"
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit"
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -183,7 +196,10 @@ jobs:
|
|||
- name: Build & push tradein-backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp/backend
|
||||
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
|
||||
# для editable install (#2137). Dockerfile — в backend/.
|
||||
context: ./tradein-mvp
|
||||
file: ./tradein-mvp/backend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||
|
|
@ -216,10 +232,13 @@ jobs:
|
|||
context: ./tradein-mvp/frontend
|
||||
push: true
|
||||
# basePath=/trade-in baked-in во время build (Next.js)
|
||||
# NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут
|
||||
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
|
||||
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
|
||||
# уходит в notFound (404), не индексируется и не краулится.
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||
NEXT_PUBLIC_ENABLE_PREVIEW=1
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
|
|
@ -276,7 +295,13 @@ jobs:
|
|||
env:
|
||||
IMAGE_TAG: latest
|
||||
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
# Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT
|
||||
# recreate the scraper and SIGKILL a running multi-hour job. Only genuine
|
||||
# scraper-code paths (the `scraper` paths-filter already covers
|
||||
# app/services/scrapers/**, scrape_pipeline.py, scheduler.py,
|
||||
# scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch —
|
||||
# should trigger a scraper recreate. (infra term intentionally dropped.)
|
||||
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
|
|
@ -314,24 +339,44 @@ jobs:
|
|||
export IMAGE_TAG="$IMAGE_TAG"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
|
||||
|
||||
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
|
||||
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
|
||||
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — recreating tradein-scraper"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
|
||||
else
|
||||
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
|
||||
fi
|
||||
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
|
||||
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
|
||||
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
|
||||
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
|
||||
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
|
||||
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
|
||||
# нового кода → старые контейнеры продолжают работать на СТАРОМ коде +
|
||||
# СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый
|
||||
# код на старой схеме». Откат = просто ничего не поднимали.
|
||||
|
||||
# Применяем SQL миграции (если есть backend/data/sql/*.sql)
|
||||
# (1) Только БД — чтобы прогнать миграции до нового app-кода.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres
|
||||
|
||||
# (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep).
|
||||
echo "→ Ожидание готовности postgres..."
|
||||
pg_ready=""
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
pg_isready -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
|
||||
pg_ready="yes"; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$pg_ready" ]; then
|
||||
echo "ERROR: postgres не стал ready за отведённое время — прерываю деплой."
|
||||
echo " Новый app-код НЕ поднят; старые контейнеры не тронуты."
|
||||
exit 1
|
||||
fi
|
||||
echo "→ postgres ready."
|
||||
|
||||
# (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app.
|
||||
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
|
||||
# старте volume. Здесь — для повторных миграций после первого запуска.
|
||||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||
sleep 5
|
||||
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||
# #2216) glob'ом не подхватывается.
|
||||
|
||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||
|
|
@ -405,16 +450,27 @@ jobs:
|
|||
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
|
||||
fi
|
||||
|
||||
# Retry backend lifespan hook AFTER migrations applied.
|
||||
# tradein-backend startup runs ensure_fdw_user_mapping which needs
|
||||
# FOREIGN SERVER gendesign_remote (created by 060_postgres_fdw_extension.sql).
|
||||
# Without restart, the first compose-up's startup hook failed with
|
||||
# "server gendesign_remote does not exist" because migrations hadn't run yet.
|
||||
# See PR #493 deploy/1156 for the incident details.
|
||||
echo "→ Restarting tradein-backend so lifespan hook retries USER MAPPING setup"
|
||||
docker restart tradein-backend
|
||||
# Give backend time to come up before Caddy reload + health check below
|
||||
sleep 5
|
||||
# (4) Теперь — новый app-код: схема уже актуальна.
|
||||
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
|
||||
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
|
||||
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps browser backend frontend
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — recreating tradein-scraper"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
|
||||
else
|
||||
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
|
||||
fi
|
||||
|
||||
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
|
||||
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
|
||||
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с
|
||||
# "server gendesign_remote does not exist" — FOREIGN SERVER создаёт
|
||||
# 060_postgres_fdw_extension.sql, ещё не прогнанная на тот момент.
|
||||
# Требовался рестарт для повторной попытки хука. Теперь backend
|
||||
# стартует на шаге (4), т.е. ПОСЛЕ применения миграций (шаг 3) →
|
||||
# lifespan-hook гарантированно видит применённые миграции (FOREIGN
|
||||
# SERVER gendesign_remote существует) уже с первого старта. Рестарт удалён.
|
||||
|
||||
# Caddy reload — основной Caddyfile содержит inline tradein routes
|
||||
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
|
||||
|
|
@ -423,12 +479,28 @@ jobs:
|
|||
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
||||
caddy reload --config /etc/caddy/Caddyfile || true
|
||||
|
||||
# Health check
|
||||
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (#2214).
|
||||
# Раньше цикл после 30 неуспешных попыток молча продолжал скрипт и
|
||||
# доходил до записи success-маркера → мёртвый backend помечался
|
||||
# «задеплоено». Теперь: флаг healthy выставляется ТОЛЬКО при HTTP 200
|
||||
# на /health; после цикла — hard exit 1, если флаг пуст. exit 1
|
||||
# происходит ДО записи .tradein-deployed-sha (маркер пишется последним,
|
||||
# ниже) → следующий прогон changes-job возьмёт корректную базу.
|
||||
# NB set -e: curl стоит в условии `if` (exempt из errexit) — неуспешная
|
||||
# попытка НЕ фатальна, а лишь провоцирует следующую итерацию цикла.
|
||||
healthy=""
|
||||
for i in $(seq 1 30); do
|
||||
docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
||||
exec -T backend curl -fsS http://localhost:8000/health && break
|
||||
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
||||
exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
|
||||
healthy="yes"; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$healthy" ]; then
|
||||
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ backend healthy на /health."
|
||||
|
||||
# Cleanup старых образов
|
||||
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
|
||||
|
|
|
|||
|
|
@ -199,6 +199,14 @@ jobs:
|
|||
# but other callers may invoke the raw path).
|
||||
chmod +x ops/*.sh 2>/dev/null || true
|
||||
|
||||
# Full PDF report bind-source (#2259 PR-D). docker создаёт отсутствующий
|
||||
# bind-source как root:root — worker пишет PDF под uid 1000 → PermissionError
|
||||
# → вечный «building». Создаём каталог заранее + chown под контейнерный uid.
|
||||
# chown под non-root deploy-юзером требует sudo → fallback (|| true — если и
|
||||
# sudo нет, каталог уже наш и chown не нужен).
|
||||
mkdir -p reports
|
||||
chown 1000:1000 reports 2>/dev/null || sudo chown 1000:1000 reports 2>/dev/null || true
|
||||
|
||||
# Sentry release tracking
|
||||
mkdir -p backend
|
||||
touch backend/.env.runtime
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ repos:
|
|||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=512"]
|
||||
# #1966: the frozen backtest regression-gate fixture is gzipped prod
|
||||
# inputs (~3 MB). It is re-extracted rarely (only when ground-truth
|
||||
# refreshes), so it does not bloat history per estimator change — the
|
||||
# per-change artifact is the 3 KB backtest_baseline.json.
|
||||
exclude: ^tradein-mvp/backend/tests/fixtures/backtest_full_fixture\.json\.gz$
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -21,7 +21,7 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
|
|||
|
||||
2. **Branch + PR mandatory.** Никаких direct push в main. `feat/...` / `fix/...` / `refactor/...` / `docs/...` / `chore/...` → ветка от `forgejo/main` → `mcp__forgejo__create_pull_request` → PR URL пользователю. После PR create — сразу `Skill loop` polling. `gh` CLI bypassed (2026-05-16). **Полные правила: `.claude/rules/git-pr.md`**.
|
||||
|
||||
3. **Agent-first workflow.** Main session orchestrates ONLY — не пишет код inline. Любая задача >1 файл / >50 строк → subagent. Pre-push: spawn `code-reviewer` subagent на staged changes. Post-push review — внешнее окно Claude (НЕ дублировать).
|
||||
3. **Agent-first workflow.** Main session orchestrates ONLY — не пишет код inline. Любая задача >1 файл / >50 строк → subagent. Pre-push: spawn `code-reviewer` subagent на staged changes. Post-push review — внешнее окно Claude (НЕ дублировать). Лимиты размера subagent-задачи: `.claude/rules/delegation.md`.
|
||||
|
||||
4. **psycopg v3 only.** `import psycopg2` → ModuleNotFoundError. `CAST(:x AS type)` в SQL — никогда `:x::type`. **Полные правила: `.claude/rules/backend.md` + `sql.md`**.
|
||||
|
||||
|
|
@ -41,14 +41,16 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
|
|||
| `database-expert` | `data/sql/**.sql`, Alembic, EXPLAIN ANALYZE |
|
||||
| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**`, `.forgejo/workflows/**` |
|
||||
| `code-reviewer` | Pre-push lint (security, correctness, conventions) |
|
||||
| `deep-code-reviewer` | Тщательный review критичных PR (миграции / auth / scrapers) + merge authority при ✅ APPROVE |
|
||||
| `deep-code-reviewer` | Тщательный review критичных PR (миграции / auth / scrapers) + merge authority при ✅ APPROVE (не эксклюзивно: self-merge разрешён любой сессии с 2026-06-27) |
|
||||
| `qa-tester` | Post-deploy smoke (playwright / curl / SQL) сразу после merge+deploy — rule #7 |
|
||||
|
||||
`auto-*` в `.claude/agents/` — standalone bot-персоны (`/work-as-*`), НЕ для Task-spawn; общий контракт — `_autonomous_pickup.md`.
|
||||
|
||||
**Routing:** тривиально (typo, 1-line) → main session. Single-domain clear → worker. Cross-domain / нечётко → `tech-analyst` first. Worker → `code-reviewer` → main commits → push → PR.
|
||||
|
||||
## Where to look
|
||||
|
||||
- **Path-scoped rules:** `.claude/rules/` (backend, frontend, sql, git-pr, deploy, ui-tokens, ui-conventions, ui-microcopy) — auto-loaded при работе с соответствующими файлами через `paths:` frontmatter
|
||||
- **Path-scoped rules:** `.claude/rules/` (backend, frontend, sql, git-pr, deploy, tradein, ui-tokens, ui-conventions, ui-microcopy) — auto-loaded при работе с соответствующими файлами через `paths:` frontmatter; `delegation.md` — намеренно БЕЗ `paths:` (грузится в каждой сессии, не добавлять frontmatter)
|
||||
- **Personal preferences:** `~/.claude/CLAUDE.md` (cross-project: no auto-commit, no Co-Authored-By, no @claude)
|
||||
- **Workflow memory:** `~/.claude/projects/<repo>/memory/MEMORY.md` index
|
||||
- **Vault MOCs:** `meta/`, `domains/<area>/<area>-MOC.md`, `decisions/`, `fixes/`, `limitations/`, `code/patterns/`
|
||||
|
|
@ -56,7 +58,7 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
|
|||
|
||||
## Don't (cross-domain — specifics → `.claude/rules/`)
|
||||
|
||||
- ❌ Direct push в main / merge без approval
|
||||
- ❌ Direct push в main; merge вне policy (красный CI / tripwires — git-pr.md § Auto-merge policy)
|
||||
- ❌ `--no-verify` / `--amend` / `git push --force`
|
||||
- ❌ Knowledge-файлы вне vault (`docs/research/`, `notes/`, `wiki/`)
|
||||
- ❌ Hardcode credentials в коде / коммитах
|
||||
|
|
|
|||
33
Caddyfile
33
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,9 +86,23 @@ gendsgn.ru {
|
|||
uri strip_prefix /trade-in
|
||||
reverse_proxy tradein-backend:8000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
|
||||
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
|
||||
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
|
||||
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
|
||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||
}
|
||||
}
|
||||
|
||||
# gendsgn.ru/sale-share — короткий адрес standalone-продукта «Поиск домов».
|
||||
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
|
||||
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
|
||||
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
|
||||
@saleshare path /sale-share /sale-share/
|
||||
handle @saleshare {
|
||||
redir /trade-in/sale-share permanent
|
||||
}
|
||||
|
||||
# Matcher `path /trade-in /trade-in/*` ловит И /trade-in (без слеша),
|
||||
# И /trade-in/ + /trade-in/anything. Без обоих случаев `handle /trade-in/*`
|
||||
# пропускал /trade-in без слеша → попадал в общий frontend → пустой ответ.
|
||||
|
|
@ -99,6 +111,9 @@ gendsgn.ru {
|
|||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
|
||||
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
|
||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -684,12 +684,18 @@ def objective_coverage(
|
|||
"""
|
||||
from app.services.objective_etl import get_sqlite_info
|
||||
|
||||
# #1964: `lots` = СЫРОЙ COUNT(*) — намеренно. Этот счётчик сравнивается с
|
||||
# SQLite-источником Антона (ETL-fidelity: «свежее ли SQLite, чем БД»), а SQLite
|
||||
# хранит сырые строки → дедуп-view сломал бы сравнение. Рядом отдаём
|
||||
# `lots_physflat` (v_objective_lots_latest — по физлоту) для прозрачности
|
||||
# инфляции (~2.91×: сырых ~1.75M vs физлотов ~603k).
|
||||
pg_row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM objective_lots) AS lots,
|
||||
(SELECT COUNT(*) FROM v_objective_lots_latest) AS lots_physflat,
|
||||
(SELECT COUNT(*) FROM objective_corpus_room_month) AS crm,
|
||||
(SELECT COUNT(*) FROM objective_complex_mapping) AS mappings,
|
||||
(SELECT MAX(snapshot_date) FROM objective_lots) AS last_lot_snapshot,
|
||||
|
|
@ -706,6 +712,7 @@ def objective_coverage(
|
|||
return {
|
||||
"pg": {
|
||||
"lots": int(pg_row["lots"] or 0),
|
||||
"lots_physflat": int(pg_row["lots_physflat"] or 0),
|
||||
"corp_room_month": int(pg_row["crm"] or 0),
|
||||
"mappings": int(pg_row["mappings"] or 0),
|
||||
"last_lot_snapshot": pg_row["last_lot_snapshot"].isoformat()
|
||||
|
|
|
|||
|
|
@ -8,9 +8,19 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.schemas.concept import ConceptInput, ConceptOutput
|
||||
from app.schemas.concept import (
|
||||
ConceptInput,
|
||||
ConceptOutput,
|
||||
HouseTypeCatalog,
|
||||
HouseTypeCatalogItem,
|
||||
MassingProgram,
|
||||
MassingRecomputeOutput,
|
||||
)
|
||||
from app.services.generative import geometry
|
||||
from app.services.generative.catalog import HOUSE_TYPES, available_section_types
|
||||
from app.services.generative.financial import compute_financial
|
||||
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
|
||||
from app.services.generative.teap import synthesize_teap_from_program
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -33,6 +43,11 @@ _DISTRICT_SEARCH_RADIUS_M: int = 5000
|
|||
_PRICE_MIN_RUB: int = 30_000
|
||||
_PRICE_MAX_RUB: int = 600_000
|
||||
|
||||
# Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда
|
||||
# фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне
|
||||
# (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median.
|
||||
_DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median"
|
||||
|
||||
# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
|
||||
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
|
||||
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
|
||||
|
|
@ -57,13 +72,33 @@ _DISTRICT_FOR_POINT_SQL = text(
|
|||
|
||||
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
|
||||
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
|
||||
#
|
||||
# #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти objective_lot_id на
|
||||
# один физлот через пере-листинги) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE
|
||||
# были по пере-листингам, вес медианы смещался. Дедупим INLINE через DISTINCT ON
|
||||
# (physflat-ключ, последний снапшот snapshot_date DESC, id DESC) — НЕ через общий
|
||||
# physflat-VIEW. Причина (deep-review #1964, прод-EXPLAIN): view не несёт
|
||||
# district-фильтр внутри, qual не проталкивается ниже DISTINCT ON → view сортирует
|
||||
# ВСЮ таблицу 1.76M (~5.8 s, request-path внутри analyze_parcel). Inline с district
|
||||
# В CTE → bitmap по району ~240k строк + sort (~1.7 s), индекс по district работает.
|
||||
# Сегмент-фильтры (price NOT NULL / диапазон) объективны по физлоту → применяем
|
||||
# ПОСЛЕ дедупа. Зеркало паттерна market_metrics._STOCK_SQL.
|
||||
_OBJECTIVE_MEDIAN_SQL = text(
|
||||
"""
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (
|
||||
project_name, corpus_name, section, floor, lot_number
|
||||
)
|
||||
price_per_m2_rub
|
||||
FROM objective_lots
|
||||
WHERE district = CAST(:dn AS text)
|
||||
ORDER BY project_name, corpus_name, section, floor, lot_number,
|
||||
snapshot_date DESC, id DESC
|
||||
)
|
||||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
|
||||
COUNT(*) AS sample_size
|
||||
FROM objective_lots
|
||||
WHERE district = CAST(:dn AS text)
|
||||
AND price_per_m2_rub IS NOT NULL
|
||||
FROM latest
|
||||
WHERE price_per_m2_rub IS NOT NULL
|
||||
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
|
||||
"""
|
||||
)
|
||||
|
|
@ -163,6 +198,32 @@ def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str
|
|||
return None, "class_norm"
|
||||
|
||||
|
||||
@router.get("/house-types", response_model=HouseTypeCatalog)
|
||||
async def list_house_types() -> HouseTypeCatalog:
|
||||
"""Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера.
|
||||
|
||||
Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам
|
||||
секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт
|
||||
выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без
|
||||
side-effects — справочник захардкожен в коде (см. модуль ``catalog``). Так
|
||||
фронт НЕ хардкодит габариты/этажности — они приходят отсюда.
|
||||
"""
|
||||
return HouseTypeCatalog(
|
||||
house_types=[
|
||||
HouseTypeCatalogItem(
|
||||
section_type=ht.section_type,
|
||||
label_ru=ht.label_ru,
|
||||
footprint_w_m=ht.footprint_w_m,
|
||||
footprint_d_m=ht.footprint_d_m,
|
||||
footprint_sqm=ht.footprint_sqm,
|
||||
default_floors=ht.default_floors,
|
||||
housing_class=ht.housing_class,
|
||||
)
|
||||
for ht in HOUSE_TYPES
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ConceptOutput)
|
||||
async def create_concept(
|
||||
payload: ConceptInput,
|
||||
|
|
@ -179,7 +240,26 @@ async def create_concept(
|
|||
|
||||
A degenerate parcel (setback consumes everything, malformed geometry) yields a
|
||||
422 rather than empty variants — that is a bad request, not a valid empty result.
|
||||
|
||||
Stage 3a (#1965): если задана ``building_program`` (типовые дома из каталога), кладём
|
||||
РОВНО эту программу вместо жадной max-FAR раскладки (один вариант). Неизвестный
|
||||
``section_type`` → 422 (валидируется тут, до размещения). Если участок не вмещает всю
|
||||
программу — НЕ 422: кладём сколько влезло и отдаём честный ``placed_count`` < requested.
|
||||
"""
|
||||
# Stage 3a: валидируем ключи программы по каталогу ДО размещения — неизвестный
|
||||
# section_type это bad request (422), а не 500 из KeyError в глубине placement.
|
||||
if payload.building_program:
|
||||
known = available_section_types()
|
||||
unknown = sorted({item.section_type for item in payload.building_program} - known)
|
||||
if unknown:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"unknown house type(s): {', '.join(unknown)}; "
|
||||
f"available: {', '.join(sorted(known))}"
|
||||
),
|
||||
)
|
||||
|
||||
# Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
|
||||
# стратегий). DB-lookup — синхронный SQLAlchemy → run_in_threadpool, чтобы не
|
||||
# блокировать event loop. Падение lookup'а не должно ронять генерацию.
|
||||
|
|
@ -187,9 +267,7 @@ async def create_concept(
|
|||
price_source: str = "class_norm"
|
||||
try:
|
||||
wkt_point = await run_in_threadpool(_parcel_centroid_wkt, payload)
|
||||
market_price, price_source = await run_in_threadpool(
|
||||
_lookup_market_price, db, wkt_point
|
||||
)
|
||||
market_price, price_source = await run_in_threadpool(_lookup_market_price, db, wkt_point)
|
||||
except ParcelGeometryError:
|
||||
# Невалидная геометрия — пусть geometry.generate поднимет её ниже (один 422).
|
||||
pass
|
||||
|
|
@ -210,3 +288,70 @@ async def create_concept(
|
|||
logger.warning("concept generation rejected parcel: %s", exc)
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return ConceptOutput(variants=variants)
|
||||
|
||||
|
||||
@router.post("/recompute", response_model=MassingRecomputeOutput)
|
||||
async def recompute_massing(
|
||||
payload: MassingProgram,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> MassingRecomputeOutput:
|
||||
"""Stage 2a (#1965, эпик #1953) — LIVE-пересчёт экономики из 3D-массинга.
|
||||
|
||||
Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый
|
||||
``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность),
|
||||
мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` — те же нормативные
|
||||
константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial``
|
||||
→ пересчитанный ТЭП + финмодель.
|
||||
|
||||
Цена продажи жилья:
|
||||
* ``market_price_per_sqm`` в теле → используется как есть (FAST path, БЕЗ БД) —
|
||||
фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. Источник
|
||||
берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из
|
||||
financial_estimate — objective_geo_radius / objective_district_median /
|
||||
district_reference / class_norm); если фронт его не прислал — дефолтный лейбл.
|
||||
НЕ хардкодим source — иначе honest-флаг в UI/PDF врёт о происхождении цены.
|
||||
* иначе, если передан ``parcel_centroid_wkt`` → ``_lookup_market_price`` по центроиду
|
||||
через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy
|
||||
не должен блокировать event loop). Падение lookup'а → норматив класса, не 500.
|
||||
* иначе (нет ни цены, ни центроида) → норматив класса (``price_source="class_norm"``).
|
||||
"""
|
||||
# ── Цена продажи: предрезолвленная (fast) → DB-fallback → норматив класса ───────
|
||||
market_price: float | None = payload.market_price_per_sqm
|
||||
price_source: str = "class_norm"
|
||||
if market_price is not None:
|
||||
# FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke.
|
||||
# Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если
|
||||
# его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно
|
||||
# форсит class_norm, но сюда мы попадаем только при заданной цене.)
|
||||
price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE
|
||||
elif payload.parcel_centroid_wkt:
|
||||
# Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept).
|
||||
# Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри
|
||||
# _lookup_market_price, защитный пояс на непредвиденное оставляем тут.
|
||||
try:
|
||||
market_price, price_source = await run_in_threadpool(
|
||||
_lookup_market_price, db, payload.parcel_centroid_wkt
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("recompute market-price: unexpected lookup error, class_norm: %s", exc)
|
||||
market_price, price_source = None, "class_norm"
|
||||
|
||||
teap = synthesize_teap_from_program(
|
||||
total_footprint_sqm=payload.total_footprint_sqm,
|
||||
floors=payload.floors,
|
||||
site_area_sqm=payload.site_area_sqm,
|
||||
housing_class=payload.housing_class,
|
||||
sections=payload.sections,
|
||||
)
|
||||
# compute_financial — чистая, CPU-bound, лёгкая (без БД) → можно вызывать прямо в
|
||||
# event loop (hot path; threadpool-overhead тут не нужен). price_source игнорируется
|
||||
# внутри, когда market_price_per_sqm is None (форсится class_norm) — корректно.
|
||||
financial = compute_financial(
|
||||
teap=teap,
|
||||
housing_class=payload.housing_class,
|
||||
land_cost_rub=payload.land_cost_rub,
|
||||
market_price_per_sqm=market_price,
|
||||
price_source=price_source,
|
||||
development_type=payload.development_type,
|
||||
)
|
||||
return MassingRecomputeOutput(teap=teap, financial=financial)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import date
|
||||
from typing import Annotated, Any
|
||||
|
||||
|
|
@ -18,6 +20,32 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process TTL-кэш результата _query_stats (перф-батч #2, #1953-followup)
|
||||
# ---------------------------------------------------------------------------
|
||||
# /landing/stats бьётся на КАЖДЫЙ load хоумпейджа и НЕ кэширован. KPI 3 (доля квартир
|
||||
# с ценой) делает полный агрегат по physflat-дедупу objective_lots: price_per_m2_rub
|
||||
# нет в covering-INCLUDE и фильтра нет → ~6.7 s heap-скан 603k физлотов на каждый
|
||||
# запрос. Данные дневной гранулярности (snapshot_date) → 1 ч staleness приемлем.
|
||||
#
|
||||
# Кэшируем ВЕСЬ dict (все 5 KPI), не только KPI 3 — там ещё pg_class deals + 2
|
||||
# MAX(snapshot_date). Только УСПЕШНЫЙ результат (None/fallback НЕ кэшируем — пустая
|
||||
# dev-БД / сбой запроса не должны залипать на час). Потокобезопасно (threading.Lock,
|
||||
# удерживается только на check/store); analyze идёт в threadpool под Uvicorn. Стиль —
|
||||
# зеркало app.services.weather_cache. time.monotonic устойчив к скачкам системных часов.
|
||||
_STATS_TTL_S: float = 3600.0 # 1 час — дневная гранулярность данных
|
||||
|
||||
# (result_dict, expires_at_monotonic_seconds). Только успешный результат.
|
||||
_STATS_CACHE: dict[str, Any] | None = None
|
||||
_STATS_EXPIRES_AT: float = 0.0
|
||||
_STATS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
"""Монотонный таймер (тесты подменяют этот helper, а не сам `time`)."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -69,12 +97,14 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
|
|||
deals_total = int(row_deals or 0)
|
||||
|
||||
# KPI 3: % objective_lots с ценой
|
||||
# #1964: physflat-дедуп view — доля квартир с ценой по ФИЗИЧЕСКИМ лотам, а не
|
||||
# по пере-листингам (сырой objective_lots раздут ~2.91× мульти lot_id/физлот).
|
||||
row_price = db.execute(
|
||||
text(
|
||||
"SELECT"
|
||||
" COUNT(*) FILTER (WHERE price_per_m2_rub IS NOT NULL) * 100.0"
|
||||
" / NULLIF(COUNT(*), 0)"
|
||||
" FROM objective_lots"
|
||||
" FROM v_objective_lots_latest"
|
||||
)
|
||||
).scalar()
|
||||
price_coverage_pct = round(float(row_price or 0.0), 1)
|
||||
|
|
@ -127,6 +157,30 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
|
|||
return None
|
||||
|
||||
|
||||
def _query_stats_cached(db: Session) -> dict[str, Any] | None:
|
||||
"""`_query_stats` с in-process TTL-кэшем (TTL=1 ч). См. блок-комментарий вверху.
|
||||
|
||||
Кэширует ТОЛЬКО успешный dict; None (сбой/пустая БД) не кэшируется — следующий
|
||||
запрос попробует снова. Lock удерживается лишь на check/store; сам DB-запрос идёт
|
||||
ВНЕ lock'а (не сериализуем одновременные cold-start, last-write wins — запрос
|
||||
идемпотентен, TTL длинный).
|
||||
"""
|
||||
global _STATS_CACHE, _STATS_EXPIRES_AT
|
||||
now = _now()
|
||||
with _STATS_LOCK:
|
||||
if _STATS_CACHE is not None and _STATS_EXPIRES_AT > now:
|
||||
return _STATS_CACHE
|
||||
# MISS / истёк → DB-запрос ВНЕ lock'а.
|
||||
result = _query_stats(db)
|
||||
if result is None:
|
||||
# Не кэшируем fallback — пусть следующий запрос повторит попытку.
|
||||
return None
|
||||
with _STATS_LOCK:
|
||||
_STATS_CACHE = result
|
||||
_STATS_EXPIRES_AT = _now() + _STATS_TTL_S
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/landing/stats", response_model=LandingStatsOut)
|
||||
def landing_stats(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
|
|
@ -142,7 +196,7 @@ def landing_stats(
|
|||
- paradox: строка-парадокс портфеля для hero CTA
|
||||
- stale: true если данные недоступны (DB ошибка или пустая БД)
|
||||
"""
|
||||
result = _query_stats(db)
|
||||
result = _query_stats_cached(db)
|
||||
if result is None:
|
||||
return LandingStatsOut(**_FALLBACK_DATA)
|
||||
return LandingStatsOut(**result)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -114,6 +114,46 @@ class Settings(BaseSettings):
|
|||
# Empty string = endpoint disabled.
|
||||
scrape_admin_token: str = ""
|
||||
|
||||
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
|
||||
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
|
||||
# captcha — подтверждено): per-region sweep гонит ~1548 объектов × 11
|
||||
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
|
||||
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
|
||||
#
|
||||
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch()
|
||||
# ТОЛЬКО для KN-sweep BrowserSession (другие скраперы — nspd/catalog —
|
||||
# продолжают использовать модульный дефолт _BROWSER_CONCURRENCY=8 без
|
||||
# изменений). 2 — эмпирически безопасный потолок против volume-бана.
|
||||
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
|
||||
scrape_kn_browser_concurrency: int = 2
|
||||
# Окно случайной паузы (мс) между запросами KN-sweep. Шире дефолта
|
||||
# (600–1500), чтобы размазать запросы во времени и не триггерить rate-ban.
|
||||
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
|
||||
# ширим до 1200–3000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
|
||||
scrape_kn_request_jitter_min_ms: int = 1200
|
||||
scrape_kn_request_jitter_max_ms: int = 3000
|
||||
# Рычаг 2 — прокси (ОПЦИОНАЛЬНО, default None → прямое подключение, поведение
|
||||
# без изменений). Когда задан — KN-sweep BrowserSession запускает Chromium
|
||||
# через этот прокси (формат http://user:pass@host:port; парсится в
|
||||
# Playwright proxy={server,username,password}). Сильнейший рычаг против
|
||||
# IP-бана: переиспользует тот же mobile-proxy паттерн, что tradein-стек
|
||||
# (SCRAPER_PROXY_URL). Заводится ТОЛЬКО через окружение прод-контейнера.
|
||||
# ENV: SCRAPE_KN_PROXY_URL.
|
||||
scrape_kn_proxy_url: str | None = None
|
||||
# Рычаг 3 (ГЛАВНЫЙ unblock #1945) — изоляция flats от extras.
|
||||
# Эмпирически (prod, 2026-06-27): flats endpoint /portal-kn/api/sales/portal/table
|
||||
# на concurrency=2 = 0 WAF-бан на 100+ объектах; extras /сервисы/api/object/{id}/*
|
||||
# отдают 403 СРАЗУ (volume-independent, мертвы с 2026-06-03) И ТРАВЯТ cookies
|
||||
# сессии → последующие flats на той же сессии тоже 403. flats_count (метрика
|
||||
# #1945) рухнул 3670→9 именно из-за этого.
|
||||
#
|
||||
# True (дефолт, NEW): flats тянутся в ЧИСТОЙ flats-only сессии (extras на ней
|
||||
# НЕ дёргаются НИКОГДА) → flats_count восстанавливается; extras идут отдельным
|
||||
# best-effort проходом с recycle сессии на каждый WAF-403 (яд не накапливается
|
||||
# и НЕ касается flats). False: старое поведение (flats+extras в одной сессии,
|
||||
# poison-prone). ENV: SCRAPE_KN_EXTRAS_ISOLATED.
|
||||
scrape_kn_extras_isolated: bool = True
|
||||
|
||||
# NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
|
||||
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
|
||||
# Settings оставлены deprecated на случай отката (можно удалить позже).
|
||||
|
|
@ -164,6 +204,13 @@ class Settings(BaseSettings):
|
|||
# Default rate-limit для nspd_lite fetcher (мс между запросами).
|
||||
nspd_lite_rate_ms: int = 600
|
||||
|
||||
# Каталог хранения собранных PDF-отчётов ПТИЦА (эпик #2259 PR-D). Worker пишет
|
||||
# PDF сюда, backend читает файл для /report/download. На проде — общий writable
|
||||
# bind-mount `./reports:/app/reports` у сервисов backend+worker (docker-compose.prod.yml),
|
||||
# иначе backend не увидит файл, записанный воркером. Для локальной разработки —
|
||||
# относительный путь ОК (тест мокает render_full_report_pdf, файл не пишется).
|
||||
reports_dir: str = "/app/reports"
|
||||
|
||||
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
|
||||
# На проде монтируется bind-mount-ом docker-compose:
|
||||
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
||||
|
|
@ -306,5 +353,18 @@ class Settings(BaseSettings):
|
|||
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
|
||||
llm_max_retries: int = 2
|
||||
|
||||
# ── DaData /clean/address геокод (objective_backfill geo-pass, #2177) ──────
|
||||
# Токен + секрет для DaData /clean/address (обогащение адреса → geo_lat/geo_lon).
|
||||
# На gendesign-проде уже заведены в окружении контейнера (проверено 2026-07-03).
|
||||
# Оба нужны для clean_address (в отличие от suggest, которому хватает токена).
|
||||
# None/пусто → dadata_client.clean_address graceful-возвращает None (geo-pass
|
||||
# тогда reject'ит всё «нет геокода», не падает). ENV: DADATA_API_TOKEN /
|
||||
# DADATA_API_SECRET. Литерала ключа в коде/тестах нет.
|
||||
dadata_api_token: str | None = None
|
||||
dadata_api_secret: str | None = None
|
||||
# Таймаут одного HTTP-вызова к DaData (сек). Воркер geo-pass не должен висеть
|
||||
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
|
||||
dadata_timeout_s: float = 8.0
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
155
backend/app/resources/certs/russian_trusted_ca_bundle.pem
Normal file
155
backend/app/resources/certs/russian_trusted_ca_bundle.pem
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# Russian Trusted CA bundle (Минцифры РФ) — публичные сертификаты с gu-st.ru (Госуслуги).
|
||||
# Нужны для https-доменов с российским УЦ И/ИЛИ неполной серверной цепочкой (#2119).
|
||||
# Скачаны 2026-07-02: russian_trusted_root_ca_pem.crt + russian_trusted_sub_ca_pem.crt.
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
||||
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
||||
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
||||
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
|
||||
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
||||
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
|
||||
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
|
||||
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
|
||||
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
|
||||
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
|
||||
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
|
||||
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
|
||||
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
|
||||
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
|
||||
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
|
||||
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
|
||||
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
|
||||
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
|
||||
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
|
||||
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
|
||||
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
|
||||
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
|
||||
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
|
||||
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
|
||||
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
|
||||
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
|
||||
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
|
||||
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
|
||||
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
|
||||
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
|
||||
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIHQjCCBSqgAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
||||
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
||||
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
||||
Q0EwHhcNMjIwMzAyMTEyNTE5WhcNMjcwMzA2MTEyNTE5WjBvMQswCQYDVQQGEwJS
|
||||
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
||||
YW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQgU3Vi
|
||||
IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9YPqBKOk19NFymrE
|
||||
wehzrhBEgT2atLezpduB24mQ7CiOa/HVpFCDRZzdxqlh8drku408/tTmWzlNH/br
|
||||
HuQhZ/miWKOf35lpKzjyBd6TPM23uAfJvEOQ2/dnKGGJbsUo1/udKSvxQwVHpVv3
|
||||
S80OlluKfhWPDEXQpgyFqIzPoxIQTLZ0deirZwMVHarZ5u8HqHetRuAtmO2ZDGQn
|
||||
vVOJYAjls+Hiueq7Lj7Oce7CQsTwVZeP+XQx28PAaEZ3y6sQEt6rL06ddpSdoTMp
|
||||
BnCqTbxW+eWMyjkIn6t9GBtUV45yB1EkHNnj2Ex4GwCiN9T84QQjKSr+8f0psGrZ
|
||||
vPbCbQAwNFJjisLixnjlGPLKa5vOmNwIh/LAyUW5DjpkCx004LPDuqPpFsKXNKpa
|
||||
L2Dm6uc0x4Jo5m+gUTVORB6hOSzWnWDj2GWfomLzzyjG81DRGFBpco/O93zecsIN
|
||||
3SL2Ysjpq1zdoS01CMYxie//9zWvYwzI25/OZigtnpCIrcd2j1Y6dMUFQAzAtHE+
|
||||
qsXflSL8HIS+IJEFIQobLlYhHkoE3avgNx5jlu+OLYe0dF0Ykx1PGNjbwqvTX37R
|
||||
Cn32NMjlotW2QcGEZhDKj+3urZizp5xdTPZitA+aEjZM/Ni71VOdiOP0igbw6asZ
|
||||
2fxdozZ1TnSSYNYvNATwthNmZysCAwEAAaOCAeUwggHhMBIGA1UdEwEB/wQIMAYB
|
||||
Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTR4XENCy2BTm6KSo9MI7NM
|
||||
XqtpCzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzCBxwYIKwYBBQUH
|
||||
AQEEgbowgbcwOwYIKwYBBQUHMAKGL2h0dHA6Ly9yb3N0ZWxlY29tLnJ1L2NkcC9y
|
||||
b290Y2Ffc3NsX3JzYTIwMjIuY3J0MDsGCCsGAQUFBzAChi9odHRwOi8vY29tcGFu
|
||||
eS5ydC5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNydDA7BggrBgEFBQcwAoYv
|
||||
aHR0cDovL3JlZXN0ci1wa2kucnUvY2RwL3Jvb3RjYV9zc2xfcnNhMjAyMi5jcnQw
|
||||
gbAGA1UdHwSBqDCBpTA1oDOgMYYvaHR0cDovL3Jvc3RlbGVjb20ucnUvY2RwL3Jv
|
||||
b3RjYV9zc2xfcnNhMjAyMi5jcmwwNaAzoDGGL2h0dHA6Ly9jb21wYW55LnJ0LnJ1
|
||||
L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMDWgM6Axhi9odHRwOi8vcmVlc3Ry
|
||||
LXBraS5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG9w0BAQsF
|
||||
AAOCAgEARBVzZls79AdiSCpar15dA5Hr/rrT4WbrOfzlpI+xrLeRPrUG6eUWIW4v
|
||||
Sui1yx3iqGLCjPcKb+HOTwoRMbI6ytP/ndp3TlYua2advYBEhSvjs+4vDZNwXr/D
|
||||
anbwIWdurZmViQRBDFebpkvnIvru/RpWud/5r624Wp8voZMRtj/cm6aI9LtvBfT9
|
||||
cfzhOaexI/99c14dyiuk1+6QhdwKaCRTc1mdfNQmnfWNRbfWhWBlK3h4GGE9JK33
|
||||
Gk8ZS8DMrkdAh0xby4xAQ/mSWAfWrBmfzlOqGyoB1U47WTOeqNbWkkoAP2ys94+s
|
||||
Jg4NTkiDVtXRF6nr6fYi0bSOvOFg0IQrMXO2Y8gyg9ARdPJwKtvWX8VPADCYMiWH
|
||||
h4n8bZokIrImVKLDQKHY4jCsND2HHdJfnrdL2YJw1qFskNO4cSNmZydw0Wkgjv9k
|
||||
F+KxqrDKlB8MZu2Hclph6v/CZ0fQ9YuE8/lsHZ0Qc2HyiSMnvjgK5fDc3TD4fa8F
|
||||
E8gMNurM+kV8PT8LNIM+4Zs+LKEV8nqRWBaxkIVJGekkVKO8xDBOG/aN62AZKHOe
|
||||
GcyIdu7yNMMRihGVZCYr8rYiJoKiOzDqOkPkLOPdhtVlgnhowzHDxMHND/E2WA5p
|
||||
ZHuNM/m0TXt2wTTPL7JH2YC0gPz/BvvSzjksgzU5rLbRyUKQkgU=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# HARICA DV TLS Sub R1 (промежуточный, crt.harica.gr) — ekaterinburg-tr.gazprom.ru
|
||||
# отдаёт ТОЛЬКО leaf без intermediate (misconfig сервера); Python AIA-chasing не делает.
|
||||
# Добавлен 2026-07-02.
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIGwTCCBKmgAwIBAgIQFVuBuF22xppLHnbzSLBwXDANBgkqhkiG9w0BAQsFADBs
|
||||
MQswCQYDVQQGEwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJl
|
||||
c2VhcmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0Eg
|
||||
Um9vdCBDQSAyMDIxMB4XDTIxMDMxOTA5MjQwNFoXDTM2MDMxNTA5MjQwM1owYjEL
|
||||
MAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl
|
||||
YXJjaCBJbnN0aXR1dGlvbnMgQ0ExGjAYBgNVBAMMEUhBUklDQSBEViBUTFMgUlNB
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvmwtJxeWVmV1TqMSobWh
|
||||
KsaKFvcBVNNfr2swkNI6l9PbH8e7uXVbpMCfG7VV3N22v65pJZR7Faxlfafs+hxt
|
||||
qJNS6SunkXsc2368zlVD8ckRJl+ktVz2Ps+q/VsCvG+hcgUxShlM6BpqJ6aJqH3a
|
||||
zLyP7b98/r+wnrmktQLLATJ9keWFC/tngso76KNctRR92br+cv0niYoacw2+cuWr
|
||||
Y43J60DcE2fN07hrt4NA2V6I4d2GNWVJHteNSwMsC5GY/opmoN4bhObbAIEne3oZ
|
||||
SKP/4pzC9aA9pIaD/JYA5jiD2R85neoYVXeR3PzWqHe+3I62JFbDdLgyjgzVu0lE
|
||||
RkzmIg3UioLBgT7aRxkEQTiiIgdXYHi9CQ00Y9idgBKE0E5GmuJw0JegWk/kozdF
|
||||
acjUPmTdq7AA1wbrTWOAKJr541B1ObOUe+FG9G1dDwcpSx6OIqNjq04ifoKlSPcN
|
||||
RoFakaSW2lEeOg/1SCieLtDniftIOnzz0+kGIVU4TTTHrWwPGZs3a96u/JlZb87D
|
||||
xZGbyxfRgIonWw/WVjBwas4GIGY8oECS43iJh4JaZaqdt4LdE+cwvpZWaKi8N+o1
|
||||
qovjMLpcnAUZ/cWaH2t266z+ATCGMdEtorhf0fQxE/dUYgJEBmw2r9olJJRkXVDU
|
||||
t/1shKYedGuayZucrhiCL6sCAwEAAaOCAWcwggFjMBIGA1UdEwEB/wQIMAYBAf8C
|
||||
AQAwHwYDVR0jBBgwFoAUCkgjpmCkkgoz6pNbxVfqJU29Eu4wVAYIKwYBBQUHAQEE
|
||||
SDBGMEQGCCsGAQUFBzAChjhodHRwOi8vcmVwby5oYXJpY2EuZ3IvY2VydHMvSEFS
|
||||
SUNBLVRMUy1Sb290LTIwMjEtUlNBLmNlcjBEBgNVHSAEPTA7MDkGBFUdIAAwMTAv
|
||||
BggrBgEFBQcCARYjaHR0cDovL3JlcG8uaGFyaWNhLmdyL2RvY3VtZW50cy9DUFMw
|
||||
HQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMEIGA1UdHwQ7MDkwN6A1oDOG
|
||||
MWh0dHA6Ly9jcmwuaGFyaWNhLmdyL0hBUklDQS1UTFMtUm9vdC0yMDIxLVJTQS5j
|
||||
cmwwHQYDVR0OBBYEFAqIq7yL8PVK7GFEUEBEwYdm3t5RMA4GA1UdDwEB/wQEAwIB
|
||||
hjANBgkqhkiG9w0BAQsFAAOCAgEAiFIdKz8OS9mWAcAsENHqY0S1qTG+KD8IX6yU
|
||||
0Ycn8thjErylOClJtq/DAmPGUylA0b2ZctLrIPRWBDqXI0f6VMPDBk7kCBwX8F/T
|
||||
l6JSBQ+sGpdAx5XQxIurJE3cKbduhetf5mpIGTsIDh2V094bJlxiufi0mmB1M/QE
|
||||
sohaEXfmQn6nQQtiVZOmTtNr2dZ1lyTRsKHjE9McFIeczZNP/sEgYI+SWijNjp8K
|
||||
7DAJOmw+8NfUjf7dq4HqLl2MbJSPEaLfNOENjWT+wygPzAKyKl2lcBgHl3FYR14u
|
||||
qkqMUg25Euq2IbkDPpaiq8i1+3A2w+SPRH5sU+g13dlFyCal47yQIIFbeA+JXSPg
|
||||
i1PK/ORDJuBAszOD3q7PNf64FPAjN1OKIZAGoCBjHaS0aGED5Jviqao81hSlo0A0
|
||||
y5zb9JaJPoSV/wyIc3bgS+niWDhBuBCsbmfH1TfRBTfI8O9mXrCSxSxcJ10fhZVy
|
||||
8SyJc+PeOJcU+VM4emO9mAhV6sv5ioKXdrhUlgLIhbsRJW30EyV5H7Z2MGAGGMSP
|
||||
Rp1k43BrIngThpXwdQmGOsj6KS7PuqkKhMvIZVQ9lDcFnNI0ySk3BPsuTVO7yo92
|
||||
03GT/P4nsjwqpCc6XoCpPb42EqyFRHH5GJ9ca1ePkKxs3lApHnW9E4rmIf5tSOnt
|
||||
jISnqoU=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# GlobalSign GCC R6 AlphaSSL CA 2025 (промежуточный, AIA secure.globalsign.com) —
|
||||
# ri.eias.ru / ri-loader.eias.ru не шлют intermediate. Добавлен 2026-07-02.
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFjTCCA3WgAwIBAgIRAIN9TriekS/nLK07x2kt3CAwDQYJKoZIhvcNAQELBQAw
|
||||
TDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkds
|
||||
b2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMjUwNTIxMDIzNjUyWhcN
|
||||
MjcwNTIxMDAwMDAwWjBVMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2ln
|
||||
biBudi1zYTErMCkGA1UEAxMiR2xvYmFsU2lnbiBHQ0MgUjYgQWxwaGFTU0wgQ0Eg
|
||||
MjAyNTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ/oiu0Bviq52UUE
|
||||
ADbFWmgu3rC7KDSMoorLN1Wd03McG3Z1aP71DlPCE33838r72Dfuj5M9LXfiQLJp
|
||||
Au6MwNExmKOzothw4x0zGf5oBYyrCMGm3fBpLPafwYQ3MchBOWMTbf83rKUPLH48
|
||||
KCJ0MnU8GUl8oA/J81wIvbbKPuNrFf6hvJDccjzc4NyxLz3A89zjV2g5whCg5O0u
|
||||
9YX4Zxk9JHuc/LvllOJO4waAYLjbWBJkz3rV3ts1SmSYnJqmyRTIjXwQgRvhEYqt
|
||||
DbRskt0W7M6cPwCze3GTBN2UHNpHkMs3YmVxku68I0aOQn5+uz//fDROP3z1Z/7I
|
||||
APteRtECAwEAAaOCAV8wggFbMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr
|
||||
BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU
|
||||
xbSTj28r3B5Iv7cQMIXO0bK7SC0wHwYDVR0jBBgwFoAUrmwFo5MT4qLn4tcc1sfw
|
||||
f8hnU6AwewYIKwYBBQUHAQEEbzBtMC4GCCsGAQUFBzABhiJodHRwOi8vb2NzcDIu
|
||||
Z2xvYmFsc2lnbi5jb20vcm9vdHI2MDsGCCsGAQUFBzAChi9odHRwOi8vc2VjdXJl
|
||||
Lmdsb2JhbHNpZ24uY29tL2NhY2VydC9yb290LXI2LmNydDA2BgNVHR8ELzAtMCug
|
||||
KaAnhiVodHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL3Jvb3QtcjYuY3JsMCEGA1Ud
|
||||
IAQaMBgwCAYGZ4EMAQIBMAwGCisGAQQBoDIKAQMwDQYJKoZIhvcNAQELBQADggIB
|
||||
AB/uvBuZf4CiuSahwiXn4geF52roAH+6jxsEPTXTfb7bbeMDXsYgRRsOTNA70ruZ
|
||||
Tnz5DfFMuBhNoFhIFb0qR1izdy6VkdKOqFPNF2dOFI1EcnY9l2ory9mrzHqVbrL4
|
||||
vzUd17FLUVyjTVU7PAv4nxyhnO1GTeT83YlrdRF31NyR6bvZVTEERHmpbWSgeveJ
|
||||
LRtaMzlGWiLZ8IwkH7o6GH3jp/KPtDW4Npu8w64HrRZdN2pqQhi7+YKwfHM7H+2U
|
||||
dM1BGN0sjOWMVbMSB9MtCsleS2Mb7TRZEbOHxECJLLIluQypZr7Pol3+hAqrhyKI
|
||||
k+6y+Da0NeDuWxW59Ku4NvClqW1UFX1SpfNGhzVfp/CH+vPM1tySomx2jE0EnYZu
|
||||
GwVucXPBsp5nUWqUV9+143glVuS7GTg9hFPjNBInn17HbCoIIQIOzj5Vd9bK3A9U
|
||||
GxXNpwenDHEalCsD/4eQYDHPhFE7sNe0D/OXu+FAM02VZkARx37Jp4bDdujvgL9P
|
||||
vZPR3wThvDN1CTU8Bc3xea3yKFAraKcPZLkhReQUAm2VpR+HSJRPlUpYizlF9WkL
|
||||
h3KcAVCBJWvnOkVwxyU5QJMcnwW95JlOtx+9100GL99jHE5rs3gXp7F4bg8H01QT
|
||||
9jVOhBBmQ7nQoXuwI0tqal2QUqZz3eeu62CU7xBwtfYR
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -1,8 +1,72 @@
|
|||
from typing import Any, Literal
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BuildingProgramItem(BaseModel):
|
||||
"""Stage 3a (#1965, эпик #1953) — один пункт программы застройки (типовой дом × N).
|
||||
|
||||
Пользователь набирает программу из ТИПОВЫХ домов каталога
|
||||
(``app.services.generative.catalog.HOUSE_TYPES``) вместо max-FAR жадной раскладки:
|
||||
«поставь ``count`` секций типа ``section_type`` этажностью ``floors``». Габариты
|
||||
пятна секции берутся из каталога по ключу ``section_type`` (контракт несёт только
|
||||
ключ, не геометрию — единый справочник на бэке).
|
||||
"""
|
||||
|
||||
# Ключ типа дома из каталога (``HouseType.section_type``). Валидируется на слое
|
||||
# размещения по ``catalog.available_section_types`` — неизвестный ключ → 422.
|
||||
section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)")
|
||||
# Этажность этой группы секций; диапазон шире, чем у target_floors (до 40 — башни).
|
||||
floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group")
|
||||
# Сколько секций этого типа разместить.
|
||||
count: int = Field(..., ge=1, le=50, description="Number of sections to place")
|
||||
# ADDITIVE (Stage 3c): опциональное ручное пятно секции (ширина × глубина, м).
|
||||
# None → габариты берутся из каталога по ``section_type`` (byte-for-byte
|
||||
# backward-compat). Заданы → place_program строит footprint из них вместо
|
||||
# каталожных (пользователь «вписал пятно»). Оба должны быть заданы вместе;
|
||||
# частичное задание (только w / только d) трактуется как «не задано» на слое
|
||||
# размещения. Границы [4, 120] м — вменяемый габарит секции/корпуса.
|
||||
footprint_w_m: float | None = Field(
|
||||
None, ge=4, le=120, description="Optional manual section footprint width, m"
|
||||
)
|
||||
footprint_d_m: float | None = Field(
|
||||
None, ge=4, le=120, description="Optional manual section footprint depth, m"
|
||||
)
|
||||
|
||||
|
||||
class HouseTypeCatalogItem(BaseModel):
|
||||
"""Stage 3b (#1965, эпик #1953) — один тип дома каталога, ОТДАВАЕМЫЙ фронту.
|
||||
|
||||
Read-only проекция ``app.services.generative.catalog.HouseType`` для пикера
|
||||
Stage 3b: фронт показывает ``label_ru`` + габариты пятна + дефолтную этажность
|
||||
+ класс, а в ``BuildingProgramItem.section_type`` кладёт ``section_type``. Это
|
||||
делает каталог ЕДИНЫМ источником истины — фронт ничего не хардкодит. Поля
|
||||
зеркалят dataclass на бэке (см. ``GET /api/v1/concepts/house-types``).
|
||||
"""
|
||||
|
||||
# Стабильный машинный ключ типа — значение для BuildingProgramItem.section_type.
|
||||
section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)")
|
||||
# Человекочитаемый русский лейбл для UI.
|
||||
label_ru: str = Field(..., description="Human-readable Russian label")
|
||||
# Габариты пятна секции, метры (ширина × глубина).
|
||||
footprint_w_m: float = Field(..., description="Section footprint width, m")
|
||||
footprint_d_m: float = Field(..., description="Section footprint depth, m")
|
||||
# Площадь пятна секции, кв.м (helper для UI: ширина × глубина).
|
||||
footprint_sqm: float = Field(..., description="Section footprint area, m² (w × d)")
|
||||
# Дефолтная этажность типа — UI подставляет, пользователь меняет в [1, 40].
|
||||
default_floors: int = Field(..., description="Default floors for this type")
|
||||
# Подходящий класс жилья (econom / comfort / business).
|
||||
housing_class: Literal["econom", "comfort", "business"] = Field(
|
||||
..., description="Suitable housing class"
|
||||
)
|
||||
|
||||
|
||||
class HouseTypeCatalog(BaseModel):
|
||||
"""Stage 3b (#1965) — каталог типовых домов, отдаваемый ``GET /concepts/house-types``."""
|
||||
|
||||
house_types: list[HouseTypeCatalogItem]
|
||||
|
||||
|
||||
class ConceptInput(BaseModel):
|
||||
"""Stage 1a — input contract. Frozen interface for frontend codegen."""
|
||||
|
||||
|
|
@ -15,6 +79,15 @@ class ConceptInput(BaseModel):
|
|||
land_cost_rub: float | None = Field(
|
||||
None, ge=0, description="Optional land cost for financial model"
|
||||
)
|
||||
# Stage 3a (#1965): ОПЦИОНАЛЬНАЯ программа застройки из типовых домов каталога.
|
||||
# ADDITIVE поле (default None). None → существующая жадная раскладка (Stage 1b,
|
||||
# byte-for-byte backward-compat). Задано → раскладка кладёт РОВНО эту программу
|
||||
# (place_program): для каждого пункта ставит count секций каталожного пятна с его
|
||||
# floors, вместо coverage-cap sweep. См. app.services.generative.placement.
|
||||
building_program: list[BuildingProgramItem] | None = Field(
|
||||
None,
|
||||
description="Optional typed house program; None → greedy max-FAR placement",
|
||||
)
|
||||
|
||||
|
||||
class TEAP(BaseModel):
|
||||
|
|
@ -147,7 +220,71 @@ class ConceptVariant(BaseModel):
|
|||
buildings_geojson: dict[str, Any]
|
||||
teap: TEAP
|
||||
financial: FinancialModel
|
||||
# Stage 3a (#1965) — честный сигнал частичного размещения для program-режима.
|
||||
# Когда задан ``ConceptInput.building_program``: ``requested_count`` — сколько секций
|
||||
# просили (Σ count по программе), ``placed_count`` — сколько реально влезло в участок.
|
||||
# placed < requested → участок мал, разместилось N из M (фронт Stage 3b показывает
|
||||
# «разместилось N из M», без hard-422). Оба None в greedy-режиме (программа не задана)
|
||||
# → ADDITIVE, backward-compat: старый ответ концепции не меняется. Annotated+`= None`:
|
||||
# рантайм-дефолт виден mypy (без pydantic-плагина), Field несёт только OpenAPI-описание.
|
||||
placed_count: Annotated[
|
||||
int | None, Field(description="Stage 3a: sections actually placed (program mode only)")
|
||||
] = None
|
||||
requested_count: Annotated[
|
||||
int | None, Field(description="Stage 3a: sections requested by program (program mode only)")
|
||||
] = None
|
||||
|
||||
|
||||
class ConceptOutput(BaseModel):
|
||||
variants: list[ConceptVariant]
|
||||
|
||||
|
||||
class MassingProgram(BaseModel):
|
||||
"""Stage 2a (#1965) — агрегированная массинг-программа для LIVE-пересчёта экономики.
|
||||
|
||||
Контракт POST ``/api/v1/concepts/recompute``: вход — уже СВЁРНУТАЯ программа из
|
||||
фронтового ``computeModel`` интерактивного 3D-массинга (суммарное пятно × этажность),
|
||||
без покомпонентной геометрии секций. Эндпоинт синтезирует из неё :class:`TEAP` и
|
||||
прогоняет готовый ``compute_financial`` → пересчитанная экономика для дебаунс-слайдеров
|
||||
Stage 2b.
|
||||
|
||||
Класс жилья / тип застройки — те же Literal-множества, что у :class:`ConceptInput`
|
||||
и ``compute_financial`` (single source of truth по допустимым значениям).
|
||||
"""
|
||||
|
||||
total_footprint_sqm: float = Field(
|
||||
..., ge=0, description="Суммарное пятно застройки всех секций, кв.м (скаляр)"
|
||||
)
|
||||
floors: int = Field(..., ge=1, le=40, description="Этажность программы")
|
||||
sections: int = Field(1, ge=1, le=6, description="Число секций (метаданные программы)")
|
||||
site_area_sqm: float = Field(..., ge=0, description="Площадь участка для плотности (FAR)")
|
||||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
||||
land_cost_rub: float | None = Field(
|
||||
None, ge=0, description="Стоимость участка для финмодели (опционально)"
|
||||
)
|
||||
# Предрезолвленная рыночная цена жилья, руб/кв.м — позволяет hot-эндпоинту ПРОПУСТИТЬ
|
||||
# per-keystroke DB-lookup (фронт резолвит цену один раз и шлёт её в каждом запросе).
|
||||
# None → эндпоинт сам сделает _lookup_market_price по центроиду (медленный путь).
|
||||
market_price_per_sqm: float | None = Field(
|
||||
None, ge=0, description="Предрезолвленная рыночная цена жилья, руб/кв.м (skip DB lookup)"
|
||||
)
|
||||
# ПОДЛИННЫЙ источник предрезолвленной цены, который фронт получил из financial_estimate
|
||||
# (objective_geo_radius / objective_district_median / district_reference / class_norm).
|
||||
# Honest-флаг для UI/PDF: НЕ подменяем его захардкоженным лейблом. Применяется ТОЛЬКО
|
||||
# вместе с market_price_per_sqm; None → дефолтный лейбл (см. эндпоинт recompute_massing).
|
||||
price_source: str | None = Field(
|
||||
None, description="Подлинный источник предрезолвленной цены (forward из financial_estimate)"
|
||||
)
|
||||
# Центроид участка (WKT-точка WGS84) для DB-fallback цены, когда market_price_per_sqm
|
||||
# не передан. None → пропускаем lookup и берём норматив класса (нет геопривязки).
|
||||
parcel_centroid_wkt: str | None = Field(
|
||||
None, description="WKT-точка центроида участка (WGS84) для DB-резолва цены при fallback"
|
||||
)
|
||||
|
||||
|
||||
class MassingRecomputeOutput(BaseModel):
|
||||
"""Stage 2a (#1965) — результат LIVE-пересчёта: синтезированный ТЭП + финмодель."""
|
||||
|
||||
teap: TEAP
|
||||
financial: FinancialModel
|
||||
|
|
|
|||
|
|
@ -36,9 +36,16 @@ class EngineeringStructure(BaseModel):
|
|||
name: str | None
|
||||
type: str | None
|
||||
cad_num: str | None
|
||||
# Назначение сооружения из НСПД-дампа (params_purpose / object_type_value),
|
||||
# напр. «1.1. Сооружения электроэнергетики». Заполняется ТОЛЬКО когда отличается
|
||||
# от `type` (иначе дубль-шум). ADDITIVE (default None).
|
||||
purpose: str | None = None
|
||||
distance_to_boundary_m: float
|
||||
geometry_geojson: dict[str, Any]
|
||||
readable_address: str | None
|
||||
# Доп. характеристики из НСПД-дампа (год постройки / длина / собственность),
|
||||
# собранные в одну читаемую строку. None — данных нет. ADDITIVE (default None).
|
||||
characteristics: str | None = None
|
||||
raw_props: dict[str, Any]
|
||||
source: str
|
||||
|
||||
|
|
@ -82,6 +89,10 @@ class UtilityInfrastructureFeature(BaseModel):
|
|||
source_tag: str | None
|
||||
distance_m: float
|
||||
geometry_geojson: dict[str, Any]
|
||||
# Гуманизированные характеристики сети из OSM tags (напряжение / оператор /
|
||||
# диаметр / материал), собранные в одну строку. None — данных нет.
|
||||
# ADDITIVE (default None) → не ломает существующих потребителей.
|
||||
characteristics: str | None = None
|
||||
|
||||
|
||||
class UtilityInfrastructureSummary(BaseModel):
|
||||
|
|
@ -96,6 +107,187 @@ class UtilityInfrastructureResponse(BaseModel):
|
|||
summary: UtilityInfrastructureSummary
|
||||
|
||||
|
||||
# ── Connection-capacity schemas (Forgejo #2119 Phase A) ──────────────────────
|
||||
# «Точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3: центры питания
|
||||
# (power_supply_centers) с резервом + city-level резервы воды (water_supply_reserves).
|
||||
|
||||
|
||||
class PowerConnectionPoint(BaseModel):
|
||||
"""Центр питания (ЦП, ПС 35/110 кВ) вблизи участка со свободной мощностью."""
|
||||
|
||||
name: str
|
||||
dzo_name: str | None
|
||||
voltage_class: str | None # напр. '110/35/10'
|
||||
load_index: str | None # 'open'|'limited'|'closed'|None
|
||||
installed_capacity_mva: float | None
|
||||
current_load_mva: float | None
|
||||
reserve_mva: float | None # свободная мощность, МВА
|
||||
reserve_asof: str | None # ISO-дата актуальности резерва
|
||||
distance_m: float
|
||||
lat: float | None
|
||||
lon: float | None
|
||||
# Пометка для ПС ЕЭСК без опубликованного резерва: свободная мощность у АО «ЕЭСК»
|
||||
# (городская сеть ЕКБ) не раскрывается WFS/xlsx — берётся по запросу. #2119 B1.
|
||||
reserve_note: str | None = None
|
||||
# Единица reserve_mva/installed_capacity_mva: 'МВА' (Россети) или 'МВт'
|
||||
# (ЕЭСК ПС 35-220). district — адм. район ЕКБ (из xlsx ЕЭСК; NULL у Россети).
|
||||
# ADDITIVE (default None). #2119 Phase B2.
|
||||
reserve_unit: str | None = None
|
||||
district: str | None = None
|
||||
|
||||
|
||||
class PowerCapacitySummary(BaseModel):
|
||||
"""Сводка по электрике: ближайший ЦП с резервом + count по индексу загрузки."""
|
||||
|
||||
total_power_points: int
|
||||
# Карта индекс загрузки ('open'|'limited'|'closed'|'unknown') → count.
|
||||
by_load_index: dict[str, int]
|
||||
# Ближайший ЦП с положительным резервом (или null, если резерв нигде не задан).
|
||||
nearest_with_reserve: PowerConnectionPoint | None
|
||||
|
||||
|
||||
class WaterCapacityRow(BaseModel):
|
||||
"""Строка резерва ЦСВ/ЦСК Водоканала (city-level) за последний период."""
|
||||
|
||||
system_kind: str # 'water'|'sewerage'
|
||||
system_name: str # ЦСВ/ЦСК, напр. «ЗФС, ГСВ»
|
||||
reserve_thousand_m3_day: float | None # может быть отрицательным (дефицит)
|
||||
note: str | None
|
||||
period: str | None
|
||||
|
||||
|
||||
class GasGrsRow(BaseModel):
|
||||
"""Свободная мощность одной городской ГРС (агрегация по выходам). #2119 B1."""
|
||||
|
||||
grs_name: str
|
||||
design_capacity_th_m3_h: float | None # проектная (сумма по выходам), тыс. м³/ч
|
||||
free_capacity_th_m3_h: float | None # свободная (сумма по выходам), тыс. м³/ч
|
||||
free_capacity_pct: float | None # средняя свободная, %
|
||||
upgrade_due: str | None # срок расшивки (макс. по выходам, свободный текст)
|
||||
outputs_count: int # число строк-выходов ГРС, вошедших в агрегацию
|
||||
|
||||
|
||||
class GasCapacityBlock(BaseModel):
|
||||
"""Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.
|
||||
|
||||
+ счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС №960 АО
|
||||
«Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4.
|
||||
"""
|
||||
|
||||
# Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если
|
||||
# таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD).
|
||||
city_grs: list[GasGrsRow]
|
||||
# Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет).
|
||||
total_free_th_m3_h: float | None
|
||||
# Счётчики точек выхода последнего period_month (по ВСЕМ строкам среза, city-level —
|
||||
# см. _query_gas_outlet_counts). ADDITIVE (default 0): 0, если таблица
|
||||
# gas_grs_outlet_points ещё не наполнена (миграция 184 / monthly-sync). #2119 B2 PR-4.
|
||||
# ВНИМАНИЕ: свободная мощность точек выхода — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!).
|
||||
outlets_total: int = 0 # всего точек выхода в последнем срезе
|
||||
outlets_deficit: int = 0 # из них с дефицитом (free_capacity_mln_m3 < 0)
|
||||
outlets_needs_calc: int = 0 # из них «0.000*» — нужен гидравлический расчёт
|
||||
|
||||
|
||||
class GasOutletPoint(BaseModel):
|
||||
"""Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4."""
|
||||
|
||||
outlet_name: str # «Точка выхода» — юрлицо/объект/промплощадка (полный текст)
|
||||
consumer_type: str | None # котельная / ТЭЦ / площадка …
|
||||
# Свободная мощность точки выхода, млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Может быть
|
||||
# <0 (дефицит со знаком). NULL, когда в источнике «0.000*» (needs_calc=True).
|
||||
free_capacity_mln_m3: float | None
|
||||
needs_calc: bool # «0.000*» — свободная мощность не определена, нужен гидрорасчёт
|
||||
distance_m: float # расстояние от центроида участка, м
|
||||
lat: float | None
|
||||
lon: float | None
|
||||
|
||||
|
||||
class HeatSystemRow(BaseModel):
|
||||
"""Резерв тепловой мощности одной системы теплоснабжения. #2119 Phase B2."""
|
||||
|
||||
org: str # организация-держатель (ЕТК / Екатеринбургэнерго)
|
||||
system_name: str # имя системы теплоснабжения (напр. «СТ №54»)
|
||||
reserve_gcal_h: float | None # свободная мощность, Гкал/ч (может быть <0 — дефицит)
|
||||
period: str | None # напр. '2026-Q2'
|
||||
|
||||
|
||||
class HeatCapacityBlock(BaseModel):
|
||||
"""Тепловой блок: резервы систем теплоснабжения за последний период. #2119 B2."""
|
||||
|
||||
# Строки heat_system_reserves за MAX(period) ПО КАЖДОЙ организации. Пусто, если
|
||||
# таблица heat_system_reserves ещё не наполнена (weekly-sync, RUN-ON-PROD).
|
||||
systems: list[HeatSystemRow]
|
||||
# Суммарная свободная тепловая мощность, Гкал/ч (None если данных нет).
|
||||
total_reserve_gcal_h: float | None
|
||||
|
||||
|
||||
class NearbyNetworkZone(BaseModel):
|
||||
"""Сетевая охранная зона cad_zouit рядом с участком — позитив-сигнал §3. #2119 C2/C3.
|
||||
|
||||
Охранная зона сети = коридор, где физически проходит сеть → «теплотрасса в 24 м,
|
||||
врезка дешёвая». Зона, ПЕРЕСЕКАЮЩАЯ участок, отдаётся с distance_m=0.
|
||||
"""
|
||||
|
||||
# Вид сети: thermal/electricity/gas/water/communication/pipeline/general.
|
||||
# general дочищен по name_by_doc (инж.коммуникации на проде часто реально тепло/газ).
|
||||
net_kind: str
|
||||
distance_m: int # расстояние от участка до охранной зоны, м (0 = пересекает участок)
|
||||
name_by_doc: str | None # сырое детальное имя из cad_zouit (для тултипа/дебага)
|
||||
# Гуманизированная подпись (C3): «Тепловая сеть (по охранной зоне), кад. № …» /
|
||||
# «Ввод 10 кВ от ТП 3164 (по охранной зоне)» / «Инженерные коммуникации (вид не уточнён)».
|
||||
human_label: str
|
||||
|
||||
|
||||
class ConnectionCapacityResponse(BaseModel):
|
||||
power_points: list[PowerConnectionPoint]
|
||||
power_summary: PowerCapacitySummary
|
||||
water: list[WaterCapacityRow]
|
||||
# Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран.
|
||||
gas: GasCapacityBlock | None = None
|
||||
# Гео-точки выхода газосети в радиусе от участка (слой карты §3). ADDITIVE (Phase B2
|
||||
# PR-4): [] если таблица gas_grs_outlet_points пуста / нет geom в радиусе.
|
||||
gas_outlet_points: list[GasOutletPoint] = []
|
||||
# Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран.
|
||||
heat: HeatCapacityBlock | None = None
|
||||
# Позитив-разрез (Phase C2/C3): сетевые охранные зоны cad_zouit в радиусе 300 м —
|
||||
# до 2 ближайших на net_kind, гуманизированы. ADDITIVE (default []): [] если сеть
|
||||
# рядом не найдена / нет geom участка.
|
||||
nearby_network_zones: list[NearbyNetworkZone] = []
|
||||
|
||||
|
||||
# ── Full PDF report (эпик #2259 PR-D) ────────────────────────────────────────
|
||||
|
||||
|
||||
class ReportBuildResponse(BaseModel):
|
||||
"""Ответ POST /{cad}/report — enqueue сборки полного PDF-отчёта (#2259 PR-D).
|
||||
|
||||
status="building" (202) — таска поставлена в очередь, PDF собирается в фоне;
|
||||
status="ready" (200) — готовый кэш (тот же analyze+forecast ран) уже на диске,
|
||||
качать сразу через /report/download.
|
||||
"""
|
||||
|
||||
status: Literal["building", "ready"]
|
||||
# Даты базовых ранов (ISO) — контекст, по каким данным собирается/собран отчёт.
|
||||
analyze_run_at: str | None = None
|
||||
forecast_run_at: str | None = None
|
||||
|
||||
|
||||
class ReportStatusResponse(BaseModel):
|
||||
"""Ответ GET /{cad}/report/status — состояние полного PDF-отчёта (#2259 PR-D).
|
||||
|
||||
status: "ready" (метадата-ран report-pdf-1.0 есть И файл на диске),
|
||||
"building" (есть analyze-ран, но готового отчёта нет — вероятно собирается / ещё не
|
||||
запускался), "none" (нет даже analyze-рана — отчётировать нечего).
|
||||
"""
|
||||
|
||||
status: Literal["ready", "building", "none"]
|
||||
# Даты базовых ранов (ISO) — по каким данным собран/будет собран отчёт.
|
||||
analyze_run_at: str | None = None
|
||||
forecast_run_at: str | None = None
|
||||
# Дата генерации готового PDF (ISO) — только при status="ready".
|
||||
report_generated_at: str | None = None
|
||||
|
||||
|
||||
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -400,6 +592,25 @@ class TopLayoutRow(BaseModel):
|
|||
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)
|
||||
|
||||
|
||||
class SupplyOnlyLayoutRow(BaseModel):
|
||||
"""Строка блока «по предложению» (#2177 шаг 3).
|
||||
|
||||
Структура текущего предложения (комнатность × площадь × цена) для комплексов,
|
||||
которые сматчены на objective project_name по ЯДРУ имени, но у которых НЕТ
|
||||
сделок (velocity). Источник — objective_lots (снапшот предложения). Это НЕ
|
||||
velocity-ранжир: темпа продаж здесь нет, только структура остатков в продаже.
|
||||
"""
|
||||
|
||||
project_name: str
|
||||
room_bucket: str # {studio,1,2,3,4+} — вокабуляр velocity-стороны
|
||||
area_bin: str
|
||||
lots_count: int
|
||||
median_price_m2: float | None # NULL если цены не прошли price-sanity
|
||||
median_area_m2: float | None
|
||||
# Маркер источника: всегда 'objective_lots' — честная семантика «без темпа продаж».
|
||||
source: str = "objective_lots"
|
||||
|
||||
|
||||
class LayoutTzMixRow(BaseModel):
|
||||
"""Строка рекомендации unit-mix для ТЗ."""
|
||||
|
||||
|
|
@ -425,10 +636,28 @@ class LayoutTzRecommendation(BaseModel):
|
|||
|
||||
|
||||
class LayoutDataQuality(BaseModel):
|
||||
"""Метаданные качества данных (coverage)."""
|
||||
"""Метаданные качества данных (coverage).
|
||||
|
||||
objects_with_velocity_data: int
|
||||
objects_total_in_radius: int
|
||||
Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
|
||||
domrf_kn_objects дробит один ЖК на несколько obj_id (разные очереди /
|
||||
дубли snapshot-строк / гео-кучки безымянных корпусов), из-за чего сырой
|
||||
знаменатель завышал число «конкурентов» (прод-кейс: 12 obj_id = 5 реальных
|
||||
комплексов → «2 из 12» вместо честных «2 из 5»). Поля objects_* теперь
|
||||
считают комплексы; raw_objects_total — старый счёт obj_id для прозрачности.
|
||||
"""
|
||||
|
||||
objects_with_velocity_data: int # число комплексов с velocity-данными
|
||||
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
|
||||
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
|
||||
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
||||
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
||||
# прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в
|
||||
# радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping.
|
||||
groups_matched_by_core: int = 0
|
||||
# Issue #2177 шаг 3: сколько комплексов попало в блок «по предложению» —
|
||||
# сматчены по ядру на objective project_name, БЕЗ velocity, но с лотами в
|
||||
# продаже (objective_lots). Аддитивно, не пересекается с velocity-покрытием.
|
||||
groups_with_supply_only: int = 0
|
||||
velocity_coverage_pct: float
|
||||
confidence: Literal["high", "medium", "low"]
|
||||
|
||||
|
|
@ -439,6 +668,10 @@ class BestLayoutsResponse(BaseModel):
|
|||
top_layouts: list[TopLayoutRow]
|
||||
recommendation_for_tz: LayoutTzRecommendation
|
||||
data_quality: LayoutDataQuality
|
||||
# Issue #2177 шаг 3: fallback-блок «по предложению без темпа продаж» —
|
||||
# структура текущего предложения для сматченных по ядру комплексов БЕЗ velocity.
|
||||
# Аддитивно; НЕ смешивается с top_layouts (там velocity-ранжир). Пусто по умолчанию.
|
||||
supply_only_layouts: list[SupplyOnlyLayoutRow] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Analyze endpoint market price (#33) ──────────────────────────────────────
|
||||
|
|
@ -623,6 +856,11 @@ class AnalyzeResponse(BaseModel):
|
|||
score_label: str | None = None
|
||||
score_max_reference: float | None = None
|
||||
score_explanation: str | None = None
|
||||
# #1926: машиночитаемый advisory-флаг — score это POI-density-эвристика
|
||||
# (0..score_max_reference), а не абсолютный инвест-рейтинг. Optional для
|
||||
# обратной совместимости (старые моки/202-стаб → null).
|
||||
score_is_advisory: bool | None = None
|
||||
score_advisory: dict[str, Any] | None = None
|
||||
score_breakdown: dict[str, Any] | None = None
|
||||
score_breakdown_detailed: list[dict[str, Any]] | None = None
|
||||
score_top_3_positives: list[dict[str, Any]] | None = None
|
||||
|
|
@ -638,6 +876,9 @@ class AnalyzeResponse(BaseModel):
|
|||
market_data_coverage_pct: float | None = None
|
||||
pipeline_24mo: dict[str, Any] | None = None
|
||||
velocity: dict[str, Any] | None = None
|
||||
# market_trend: {status: ok|offer_only|source_stale|no_deals, ...}. #2178 добавил
|
||||
# вложенный offer_trend (динамика цен предложения из objective_lots_history) и
|
||||
# status='offer_only'. Схема loose (dict) — новые ключи проходят без правки модели.
|
||||
market_trend: dict[str, Any] | None = None
|
||||
market_price: dict[str, Any] | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -1814,8 +1814,7 @@ def _active_competitors_count(
|
|||
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
|
||||
if target_class:
|
||||
n = _q(
|
||||
"AND district_name = :dn"
|
||||
" AND COALESCE(obj_class, obj_class_fallback) = :cls",
|
||||
"AND district_name = :dn" " AND COALESCE(obj_class, obj_class_fallback) = :cls",
|
||||
{"rc": region_code, "dn": district_name, "cls": target_class},
|
||||
)
|
||||
if n >= 2:
|
||||
|
|
@ -2221,9 +2220,7 @@ def _competitors_two_dim(
|
|||
return 0, n, float(n), scope
|
||||
|
||||
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
|
||||
class_filter = (
|
||||
"AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else ""
|
||||
)
|
||||
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else ""
|
||||
params: dict[str, Any] = {
|
||||
"rc": region_code,
|
||||
"dn": district_name,
|
||||
|
|
@ -2291,6 +2288,17 @@ def _bucket_success_ranking(
|
|||
Возвращает список dict {bucket, success_score, n_deals, velocity_z,
|
||||
price_z, area_z}, sorted DESC by success_score. Пустой список если
|
||||
данных нет или district_name не передан.
|
||||
|
||||
target_class ДОЛЖЕН быть в словаре БД (русский) — caller переводит через
|
||||
_class_to_db_vocab. Default-класс (когда target_class is None) = 'Комфорт':
|
||||
после миграции 172 view хранит только канонические русские классы
|
||||
('Комфорт'/'Типовой'/'Бизнес'/'Элит'/'Премиум'/'не указан'), английский
|
||||
'Comfort' больше не существует и матчил бы 0 строк (#1955). 'Комфорт' —
|
||||
самый массовый класс ЕКБ (723 объекта) → разумный baseline для
|
||||
«класс не указан в запросе». NULL-class строки ('не указан', бывшие
|
||||
obj_class IS NULL) в default-путь НЕ попадают намеренно: для рекомендации
|
||||
квартирографии лучше показать реальный массовый класс, чем агрегат «не
|
||||
указан»; явный запрос с target_class='не указан' их при этом достанет.
|
||||
"""
|
||||
if not district_name:
|
||||
return []
|
||||
|
|
@ -2301,7 +2309,7 @@ def _bucket_success_ranking(
|
|||
SELECT bucket, success_score, n_deals, velocity_z, price_z, area_z
|
||||
FROM v_bucket_success_score
|
||||
WHERE district_name = :dn
|
||||
AND obj_class = COALESCE(:cls, 'Comfort')
|
||||
AND obj_class = COALESCE(:cls, 'Комфорт')
|
||||
ORDER BY success_score DESC
|
||||
"""
|
||||
),
|
||||
|
|
@ -2742,8 +2750,11 @@ def recommend_mix(
|
|||
# #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены
|
||||
noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"])
|
||||
|
||||
# #25 Success-driven ranking из v_bucket_success_score
|
||||
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class)
|
||||
# #25 Success-driven ranking из v_bucket_success_score.
|
||||
# #1955: передаём target_class_db (русский словарь БД), НЕ сырой английский
|
||||
# target_class — иначе obj_class = :cls молча даёт ноль строк ("Comfort" !=
|
||||
# "Комфорт"), success-boost тихо деградирует в [].
|
||||
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class_db)
|
||||
|
||||
# 5b-3) Per-bucket project velocity at price_factor=1.0:
|
||||
# bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region.
|
||||
|
|
|
|||
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
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
"""Cross-load ETL: tradein.houses → gendesign.newbuilding_listings (#976 950-E5).
|
||||
|
||||
Читает houses + house_sources из tradein-БД (gendesign_reader role),
|
||||
Читает market.v_houses + market.v_house_sources из tradein-БД (gendesign_reader role),
|
||||
UPSERT-ит в gendesign newbuilding_listings по (source, ext_house_id).
|
||||
Соединение к tradein через psycopg v3 (прямой коннект, НЕ SQLAlchemy).
|
||||
|
||||
Источник — contract-view market.v_* (#2138), а не сырые public.*-таблицы: явный
|
||||
column-контракт, устойчивый к scraper-driven ALTER TABLE на стороне tradein.
|
||||
|
||||
Запускается:
|
||||
- ночью Celery beat (03:30 МСК, tasks/etl_newbuilding_crossload.py)
|
||||
- вручную через POST /api/v1/admin/scrape/newbuilding-crossload
|
||||
|
|
@ -54,8 +57,10 @@ SELECT
|
|||
h.last_scraped_at,
|
||||
-- yandex_jk_id из house_sources (LEFT JOIN — может отсутствовать)
|
||||
hs_y.ext_id AS yandex_jk_id
|
||||
FROM houses h
|
||||
LEFT JOIN house_sources hs_y
|
||||
-- Читаем через contract-view market.v_* (#2138 step 2/3), НЕ сырые public.* —
|
||||
-- стабильный явный column-контракт, не ломается на scraper-driven ALTER TABLE.
|
||||
FROM market.v_houses h
|
||||
LEFT JOIN market.v_house_sources hs_y
|
||||
ON hs_y.house_id = h.id
|
||||
AND hs_y.ext_source = 'yandex'
|
||||
WHERE h.lat IS NOT NULL
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
1537
backend/app/services/exporters/full_report_html.py
Normal file
1537
backend/app/services/exporters/full_report_html.py
Normal file
File diff suppressed because it is too large
Load diff
325
backend/app/services/exporters/full_report_pdf.py
Normal file
325
backend/app/services/exporters/full_report_pdf.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"""Оркестратор полного PDF-отчёта ПТИЦА (эпик #2259 PR-D).
|
||||
|
||||
Собирает единый PDF `/site-finder/analysis/{cad}` из уже-персистнутых ранов + дотянутых
|
||||
на лету блоков (connection-capacity, концепция), рендерит карты (PR-C) и пишет файл на
|
||||
volume `/app/reports/` с метадата-строкой в `analysis_runs` (schema `report-pdf-1.0`).
|
||||
|
||||
ПОТОК (:func:`build_full_report`):
|
||||
1. analyze-ран (`latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)`) — нет →
|
||||
ValueError (отчёт без базового анализа бессмысленен).
|
||||
2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) — нет → Part B (§4–§6)
|
||||
деградирует «нет данных», отчёт всё равно валиден (передаём {} в part_b).
|
||||
3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с
|
||||
теми же id уже есть И файл на месте → cache-hit, PDF не пере-рендерим.
|
||||
4. connection-capacity (`get_connection_capacity`) — best-effort, для §3-резервов.
|
||||
5. концепция — server-side генерация (как POST /concepts): `ConceptInput` из geom
|
||||
участка (дефолт count=3 comfort) → `geometry.generate`. Best-effort: упала/нет geom
|
||||
→ отчёт без §7-концепции (§5 деградирует в рыночный контекст).
|
||||
6. HTML (PR-A/B) + карты (PR-C: `render_parcel_map_png` / `render_concept_footprint_png`
|
||||
→ `embed_map_png`, PNG max_px=1400) → PDF (:func:`render_full_report_pdf`).
|
||||
7. Запись файла + метадата-ран `report-pdf-1.0` (result = pdf_path/analyze_run_id/
|
||||
forecast_run_id/generated_at/size_bytes).
|
||||
|
||||
WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native —
|
||||
ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.analysis_runs.repository import (
|
||||
ANALYZE_SCHEMA_VERSION,
|
||||
latest_run_for,
|
||||
persist_analysis_run,
|
||||
)
|
||||
from app.services.exporters.full_report_html import (
|
||||
MAP_CONCEPT_PLACEHOLDER,
|
||||
MAP_PARCEL_PLACEHOLDER,
|
||||
build_full_report_html,
|
||||
build_full_report_html_part_a,
|
||||
build_full_report_html_part_b,
|
||||
)
|
||||
from app.services.exporters.report_maps import (
|
||||
embed_map_png,
|
||||
render_concept_footprint_png,
|
||||
render_parcel_map_png,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# schema_version метадата-строки отчёта в analysis_runs. Собственная схема (НЕ analyze /
|
||||
# НЕ §22-форсайт "1.0") — по ней ищем существующий отчёт для cache-hit.
|
||||
REPORT_SCHEMA_VERSION = "report-pdf-1.0"
|
||||
|
||||
# schema_version §22-форсайт-рана (зеркало `_FORECAST_SCHEMA_VERSION` в parcels.py — НЕ
|
||||
# "forecast-1.0", а именно "1.0", это SiteFinderReport._SCHEMA_VERSION).
|
||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||
|
||||
# Верхняя граница длинной стороны карт-PNG (px) — печатный A4, 1400 достаточно для
|
||||
# ~150 dpi на ширину колонки, но не раздувает PDF гигабайтными растрами.
|
||||
_MAP_MAX_PX = 1400
|
||||
|
||||
# Радиус поиска ЦП для connection-capacity (м) — тот же дефолт, что у эндпоинта
|
||||
# GET /{cad}/connection-capacity (parcels.py) и функции get_connection_capacity.
|
||||
_CONNECTION_CAPACITY_RADIUS_M = 3000
|
||||
|
||||
|
||||
def render_full_report_pdf(html: str) -> bytes:
|
||||
"""Отрендерить готовый HTML полного отчёта в PDF-байты (WeasyPrint).
|
||||
|
||||
Тонкая обёртка: WeasyPrint импортируется ЛОКАЛЬНО (тяжёлый native — не нужен при
|
||||
импорте модуля, иначе ломает pytest-сбор на macOS без GTK/Pango). Образец —
|
||||
`layout_tz_pdf.render_layout_tz_pdf`.
|
||||
|
||||
Args:
|
||||
html: полный HTML-документ (`build_full_report_html`).
|
||||
|
||||
Returns:
|
||||
PDF-байты (готовы к записи файла / FileResponse).
|
||||
"""
|
||||
from weasyprint import HTML
|
||||
|
||||
pdf_bytes: bytes = HTML(string=html).write_pdf()
|
||||
logger.info("render_full_report_pdf: %d bytes", len(pdf_bytes))
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Сгенерировать концепцию server-side как это делает POST /concepts (best-effort).
|
||||
|
||||
Концепция НЕ персистится — генерируем на лету из геометрии участка с ДЕФОЛТНОЙ
|
||||
программой (count=3 comfort — три стратегии жадной раскладки). Повторяем поток
|
||||
`create_concept` (concepts.py): рыночная цена через `_lookup_market_price` по
|
||||
центроиду → `geometry.generate` → сериализуем `ConceptOutput`. Любая ошибка (нет
|
||||
geom / вырожденный участок / сбой БД) → None: отчёт валиден без §7-концепции,
|
||||
§5 деградирует в рыночный контекст.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session (для lookup рыночной цены).
|
||||
analyze: persist-payload analyze-рана (несёт `geom_geojson` участка).
|
||||
|
||||
Returns:
|
||||
Сериализованный `ConceptOutput` (dict `{"variants": [...]}`) или None.
|
||||
"""
|
||||
geom = analyze.get("geom_geojson")
|
||||
if not isinstance(geom, dict):
|
||||
logger.info("build_full_report: analyze-payload без geom_geojson → §7-концепция пропущена")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Lazy import — тяжёлый generative-стек не нужен на module-load; concepts-хелперы
|
||||
# цены живут в API-слое (он знает БД), переиспользуем ИМЕННО их (single source).
|
||||
from app.api.v1.concepts import _lookup_market_price, _parcel_centroid_wkt
|
||||
from app.schemas.concept import ConceptInput, ConceptOutput
|
||||
from app.services.generative import geometry
|
||||
|
||||
payload = ConceptInput(parcel_geojson=geom) # дефолт: housing_class="comfort", 3 стратегии
|
||||
|
||||
market_price: float | None = None
|
||||
price_source = "class_norm"
|
||||
try:
|
||||
wkt_point = _parcel_centroid_wkt(payload)
|
||||
market_price, price_source = _lookup_market_price(db, wkt_point)
|
||||
except Exception as exc:
|
||||
# Рыночная цена best-effort — деградируем в норматив класса, не роняем концепцию.
|
||||
logger.warning("build_full_report: market-price lookup упал, class_norm: %s", exc)
|
||||
|
||||
variants = geometry.generate(
|
||||
payload,
|
||||
market_price_per_sqm=market_price,
|
||||
price_source=price_source,
|
||||
)
|
||||
result: dict[str, Any] = ConceptOutput(variants=variants).model_dump()
|
||||
logger.info("build_full_report: концепция сгенерирована (%d вариантов)", len(variants))
|
||||
return result
|
||||
except Exception:
|
||||
# Вырожденный участок / сбой generative-стека — отчёт валиден БЕЗ §7-концепции.
|
||||
logger.exception("build_full_report: генерация концепции упала → §7 без концепции")
|
||||
return None
|
||||
|
||||
|
||||
def _get_connection_capacity(db: Session, cad: str) -> dict[str, Any] | None:
|
||||
"""Дотянуть connection-capacity для §3-резервов (best-effort).
|
||||
|
||||
connection-capacity НЕ в persist-payload (ленивый эндпоинт) — тянем `get_connection_capacity`
|
||||
как это делает GET /{cad}/connection-capacity. Ошибка (участок без geom / сбой БД) → None:
|
||||
§3 деградирует к тому, что есть в analyze-payload (OSM-сети + НСПД-сооружения).
|
||||
"""
|
||||
try:
|
||||
from app.services.site_finder.connection_capacity_lookup import get_connection_capacity
|
||||
|
||||
return get_connection_capacity(db, cad, _CONNECTION_CAPACITY_RADIUS_M)
|
||||
except Exception:
|
||||
logger.exception("build_full_report: connection-capacity lookup упал → §3 без резервов")
|
||||
return None
|
||||
|
||||
|
||||
def _find_cached_report(
|
||||
db: Session,
|
||||
cad: str,
|
||||
analyze_run_id: int | None,
|
||||
forecast_run_id: int | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Найти готовый метадата-ран `report-pdf-1.0` с тем же кэш-ключом + существующим файлом.
|
||||
|
||||
Кэш-ключ = (analyze_run_id, forecast_run_id). Совпали ОБА id И файл `pdf_path` на
|
||||
месте → возвращаем result метадата-рана (cache-hit, не пере-рендерим). Иначе None.
|
||||
"""
|
||||
row = latest_run_for(db, cad, schema_version=REPORT_SCHEMA_VERSION)
|
||||
if row is None:
|
||||
return None
|
||||
result = row.result if isinstance(row.result, dict) else {}
|
||||
if (
|
||||
result.get("analyze_run_id") == analyze_run_id
|
||||
and result.get("forecast_run_id") == forecast_run_id
|
||||
):
|
||||
pdf_path = result.get("pdf_path")
|
||||
if isinstance(pdf_path, str) and os.path.exists(pdf_path):
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _cad_safe(cad: str) -> str:
|
||||
"""Кад.номер → безопасный компонент имени файла (whitelist).
|
||||
|
||||
Кад.номер приходит из URL-path — жёстко ограничиваем алфавит `[0-9:]` (цифры +
|
||||
разделители кад.номера РФ), ВСЁ прочее вырезаем, затем `:` → `_`. Так в имя файла
|
||||
физически не попадут `/`, `..`, пробелы или спецсимволы (path-traversal / инъекция
|
||||
в путь), даже если path-param окажется мусорным.
|
||||
"""
|
||||
return re.sub(r"[^0-9:]", "", cad).replace(":", "_")
|
||||
|
||||
|
||||
def build_full_report(db: Session, cad: str) -> dict[str, Any]:
|
||||
"""Собрать (или вернуть из кэша) полный PDF-отчёт участка + метадата-ран. #2259 PR-D.
|
||||
|
||||
Оркестрация: analyze-ран (нет → ValueError) → forecast-ран (нет → Part B «нет данных»)
|
||||
→ cache-check по (analyze_run_id, forecast_run_id) → connection-capacity (§3-резервы,
|
||||
best-effort) → концепция server-side (best-effort) → HTML (PR-A/B) + карты (PR-C) →
|
||||
PDF (WeasyPrint) → запись файла на volume + метадата-ран `report-pdf-1.0`.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session.
|
||||
cad: кадастровый номер участка.
|
||||
|
||||
Returns:
|
||||
Метадата отчёта: {pdf_path, analyze_run_id, forecast_run_id, generated_at,
|
||||
size_bytes} (+ `cache_hit: True` если вернули существующий файл).
|
||||
|
||||
Raises:
|
||||
ValueError: нет analyze-рана для участка (нечего отчётировать).
|
||||
"""
|
||||
analyze_row = latest_run_for(db, cad, schema_version=ANALYZE_SCHEMA_VERSION)
|
||||
if analyze_row is None:
|
||||
raise ValueError(f"нет analyze-рана для {cad!r} — сначала запустите /analyze")
|
||||
analyze: dict[str, Any] = analyze_row.result or {}
|
||||
analyze_run_id = int(analyze_row.id)
|
||||
|
||||
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||
forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {}
|
||||
forecast_run_id = int(forecast_row.id) if forecast_row is not None else None
|
||||
|
||||
# Cache-hit: тот же (analyze, forecast) ран-ключ + файл на месте → не пере-рендерим.
|
||||
cached = _find_cached_report(db, cad, analyze_run_id, forecast_run_id)
|
||||
if cached is not None:
|
||||
logger.info(
|
||||
"build_full_report: cache-hit cad=%s analyze=%s forecast=%s path=%s",
|
||||
cad,
|
||||
analyze_run_id,
|
||||
forecast_run_id,
|
||||
cached.get("pdf_path"),
|
||||
)
|
||||
return {**cached, "cache_hit": True}
|
||||
|
||||
# connection-capacity (§3-резервы) + концепция (§7) — оба best-effort.
|
||||
connection_capacity = _get_connection_capacity(db, cad)
|
||||
concept = _generate_concept_result(db, analyze)
|
||||
|
||||
# Адрес участка для титула (egrn.address; None → строка адреса не рисуется).
|
||||
egrn = analyze.get("egrn") if isinstance(analyze.get("egrn"), dict) else {}
|
||||
address = egrn.get("address") if isinstance(egrn, dict) else None
|
||||
generated_at = _dt.datetime.now(_dt.UTC)
|
||||
generated_at_ru = generated_at.strftime("%d.%m.%Y")
|
||||
|
||||
# HTML (PR-A/B): §1–§3 (+ connection-capacity в §3) и §4–§7 (пустой forecast → «нет данных»).
|
||||
part_a = build_full_report_html_part_a(
|
||||
analyze, cad=cad, connection_capacity=connection_capacity
|
||||
)
|
||||
part_b = build_full_report_html_part_b(forecast, concept, cad=cad)
|
||||
html = build_full_report_html(
|
||||
part_a,
|
||||
part_b,
|
||||
cad=cad,
|
||||
address=address if isinstance(address, str) else None,
|
||||
generated_at=generated_at_ru,
|
||||
)
|
||||
|
||||
# Карты (PR-C): участок (§1/§3) + концепция (§7, лучший вариант). Оба graceful:
|
||||
# None → embed_map_png ставит плашку «карта недоступна».
|
||||
parcel_png = render_parcel_map_png(analyze, max_px=_MAP_MAX_PX)
|
||||
html = embed_map_png(html, MAP_PARCEL_PLACEHOLDER, parcel_png)
|
||||
|
||||
concept_png: bytes | None = None
|
||||
if isinstance(concept, dict):
|
||||
variants = concept.get("variants")
|
||||
if isinstance(variants, list) and variants and isinstance(variants[0], dict):
|
||||
concept_png = render_concept_footprint_png(
|
||||
variants[0], analyze.get("geom_geojson"), max_px=_MAP_MAX_PX
|
||||
)
|
||||
html = embed_map_png(html, MAP_CONCEPT_PLACEHOLDER, concept_png)
|
||||
|
||||
pdf_bytes = render_full_report_pdf(html)
|
||||
|
||||
# Запись файла на volume + метадата-ран. Каталог создаём (parents, exist_ok).
|
||||
reports_dir = Path(settings.reports_dir)
|
||||
reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}.pdf"
|
||||
pdf_path = reports_dir / file_name
|
||||
# АТОМАРНАЯ запись: пишем в .tmp рядом и os.replace → финальный путь. Два конкурентных
|
||||
# POST в один день целятся в ОДИН pdf_path (имя несёт только дату) — прямой write_bytes
|
||||
# мог бы interleave-писать байты обоих рендеров в один файл (битый PDF). os.replace
|
||||
# атомарен в пределах одной FS → download всегда видит целый файл (свой или чужой).
|
||||
tmp_path = pdf_path.with_suffix(f".{os.getpid()}.tmp")
|
||||
tmp_path.write_bytes(pdf_bytes)
|
||||
os.replace(tmp_path, pdf_path)
|
||||
size_bytes = len(pdf_bytes)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pdf_path": str(pdf_path),
|
||||
"analyze_run_id": analyze_run_id,
|
||||
"forecast_run_id": forecast_run_id,
|
||||
"generated_at": generated_at.isoformat(),
|
||||
"size_bytes": size_bytes,
|
||||
}
|
||||
|
||||
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — PDF
|
||||
# уже записан, просто следующий вызов не поймает cache-hit и пере-рендерит).
|
||||
persist_analysis_run(
|
||||
db,
|
||||
cad_num=cad,
|
||||
result=result,
|
||||
params={"source": "report-pdf-task"},
|
||||
district=analyze_row.district,
|
||||
confidence=None,
|
||||
status="done",
|
||||
schema_version=REPORT_SCHEMA_VERSION,
|
||||
created_by=None,
|
||||
)
|
||||
logger.info(
|
||||
"build_full_report: cad=%s written path=%s size=%d analyze=%s forecast=%s",
|
||||
cad,
|
||||
pdf_path,
|
||||
size_bytes,
|
||||
analyze_run_id,
|
||||
forecast_run_id,
|
||||
)
|
||||
return result
|
||||
|
|
@ -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)
|
||||
|
|
@ -259,13 +259,46 @@ def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
|
|||
"""
|
||||
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
|
||||
if coverage is None:
|
||||
note = "покрытие domrf↔objective неизвестно"
|
||||
note = (
|
||||
"Доля будущих проектов с известными планировками и площадями неизвестна — "
|
||||
"оценка будущего предложения и конкуренции менее надёжна"
|
||||
)
|
||||
else:
|
||||
pct = round(float(coverage) * 100.0, 1)
|
||||
note = f"покрытие domrf↔objective {pct}% — {_QUALITY_WORD[level]}"
|
||||
note = (
|
||||
f"Известные планировки и площади есть у {pct}% будущих проектов "
|
||||
f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза "
|
||||
"будущего предложения и конкуренции"
|
||||
)
|
||||
return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)
|
||||
|
||||
|
||||
def _history_factor(history_months: int | None) -> ConfidenceFactor:
|
||||
"""Глубина ряда (мес) → ConfidenceFactor с «на что влияет» + связью с 6.2. PURE.
|
||||
|
||||
Короткий ряд не даёт оценить тренды и чувствительность к ставке (§9.6) — а это
|
||||
та же причина, по которой сценарии (6.2) схлопываются в один. Нота называет
|
||||
реальное число месяцев И что от этого зависит. None → low (нет сигнала). PURE.
|
||||
"""
|
||||
level = _level_from_value(
|
||||
history_months, high_at=_HISTORY_MONTHS_HIGH, low_below=_HISTORY_MONTHS_LOW
|
||||
)
|
||||
if history_months is None:
|
||||
note = (
|
||||
"Длина истории продаж неизвестна — тренды и чувствительность к ставке "
|
||||
"оценить нельзя (см. сценарии в 6.2)"
|
||||
)
|
||||
else:
|
||||
note = (
|
||||
f"{int(history_months)} мес истории ({_QUALITY_WORD[level]}) — на коротком "
|
||||
"ряде тренды и чувствительность спроса к ставке оцениваются хуже "
|
||||
"(поэтому в 6.2 может остаться один сценарий вместо трёх)"
|
||||
)
|
||||
return ConfidenceFactor(
|
||||
name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note
|
||||
)
|
||||
|
||||
|
||||
def _confounded_factor(confounded: bool) -> ConfidenceFactor:
|
||||
"""Шок-окно → ConfidenceFactor. PURE.
|
||||
|
||||
|
|
@ -483,15 +516,7 @@ def compute_report_confidence(
|
|||
if domrf_coverage is not None:
|
||||
factors.append(_coverage_factor(domrf_coverage))
|
||||
if history_months is not None:
|
||||
factors.append(
|
||||
_factor_from_count(
|
||||
_F_HISTORY_MONTHS,
|
||||
history_months,
|
||||
high_at=_HISTORY_MONTHS_HIGH,
|
||||
low_below=_HISTORY_MONTHS_LOW,
|
||||
unit="мес истории",
|
||||
)
|
||||
)
|
||||
factors.append(_history_factor(history_months))
|
||||
# Шок-окно учитываем ТОЛЬКО когда оно есть (True): чистое окно не должно
|
||||
# искусственно тянуть к 'high', если других сигналов нет (см. graceful ниже).
|
||||
if confounded:
|
||||
|
|
|
|||
|
|
@ -73,7 +73,14 @@ from app.services.forecasting.demand_normalization import compute_demand_normali
|
|||
from app.services.forecasting.macro_coefficient import compute_macro_coefficient
|
||||
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
|
||||
from app.services.forecasting.regression import compute_rate_regime_sensitivity
|
||||
from app.services.forecasting.sales_series import SegmentSpec
|
||||
from app.services.forecasting.sales_series import (
|
||||
ROOM_AREA_BUCKET_1K,
|
||||
ROOM_AREA_BUCKET_2K,
|
||||
ROOM_AREA_BUCKET_3K,
|
||||
ROOM_AREA_BUCKET_LARGE,
|
||||
ROOM_AREA_BUCKET_STUDIO,
|
||||
SegmentSpec,
|
||||
)
|
||||
from app.services.site_finder.competitors import get_competitors
|
||||
from app.services.site_finder.future_supply import compute_future_supply_pressure
|
||||
from app.services.site_finder.market_metrics import compute_market_metrics
|
||||
|
|
@ -121,6 +128,51 @@ _CONFIDENCE_CAP: Confidence = "medium"
|
|||
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
||||
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
|
||||
|
||||
# #1959 VOCAB-мост (load-bearing): SegmentSpec.room_bucket приходит из what_to_build
|
||||
# в Source-B room_area-вокабуляре ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/
|
||||
# "3-к 60-80"/"80+ м²"). compute_market_metrics (после #1959) принимает ИМЕННО этот
|
||||
# вокабуляр напрямую (его room CASE = зеркало sales_series.room_area_bucket_of). Эта
|
||||
# white-list страхует от чужих/неизвестных меток: распознанные пробрасываем как есть,
|
||||
# неизвестные (или None) → None = «без room-фильтра» (district-wide темп, но НЕ тихий
|
||||
# 0-rows из-за опечатки в вокабуляре). Меняешь сетку room_buckets — обнови white-list.
|
||||
_FORECAST_ROOM_BUCKETS: frozenset[str] = frozenset(
|
||||
{
|
||||
ROOM_AREA_BUCKET_STUDIO,
|
||||
ROOM_AREA_BUCKET_1K,
|
||||
ROOM_AREA_BUCKET_2K,
|
||||
ROOM_AREA_BUCKET_3K,
|
||||
ROOM_AREA_BUCKET_LARGE,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _market_room_bucket(room_bucket: str | None) -> str | None:
|
||||
"""Перевести SegmentSpec.room_bucket в room-фильтр compute_market_metrics. PURE.
|
||||
|
||||
Вокабуляр уже выровнен (#1959): market_metrics принимает Source-B room_area-метки
|
||||
напрямую. Эта функция — VALIDATING pass-through: распознанная Source-B метка
|
||||
возвращается КАК ЕСТЬ; None / неизвестная метка → None (= «без room-фильтра»,
|
||||
district-wide темп). Без этой проверки опечатка/чужой вокабуляр дали бы тихий
|
||||
0-rows фильтр → метрики None → ячейка молча выпадала бы из ранкинга. PURE.
|
||||
|
||||
Args:
|
||||
room_bucket: room_bucket сегмента (ожидается Source-B вокабуляр) или None.
|
||||
|
||||
Returns:
|
||||
Та же Source-B метка (если в белом списке) либо None.
|
||||
"""
|
||||
if room_bucket is None:
|
||||
return None
|
||||
if room_bucket in _FORECAST_ROOM_BUCKETS:
|
||||
return room_bucket
|
||||
logger.warning(
|
||||
"demand_supply_forecast: unrecognized room_bucket %r — dropping room filter "
|
||||
"(district-wide pace). Expected Source-B vocab %s.",
|
||||
room_bucket,
|
||||
sorted(_FORECAST_ROOM_BUCKETS),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemandSupplyForecast:
|
||||
|
|
@ -555,6 +607,15 @@ def compute_demand_supply_forecast(
|
|||
(она сама применяет β внутри); §9.6 compute_rate_sensitivity вызывается ТОЛЬКО
|
||||
ради explainability-фразы — её β/x_pct в арифметику спроса НЕ входят.
|
||||
|
||||
#1959 ПОСЕГМЕНТНЫЙ баланс (фикс «−1.00 везде»): и СПРОС, и ПРЕДЛОЖЕНИЕ теперь
|
||||
сужаются под spec (obj_class/room_bucket), а не берутся district-wide:
|
||||
• base_pace = compute_market_metrics(obj_class, room_bucket).unit_velocity —
|
||||
дедуплицированный темп ИМЕННО сегмента (раньше один темп на все ячейки →
|
||||
каждая what_to_build-ячейка делила одно и то же → дефицит = −1.0 везде).
|
||||
• open-сток = market_metrics.n_available того же сегмента (дедуп). При заданном
|
||||
сегменте L2/L3 (hidden/future) ИСКЛЮЧАЮТСЯ из баланса — они класс/формат-
|
||||
агностичны, иначе один объём двоился бы по всем ячейкам (снова −1.0).
|
||||
|
||||
Один раз на вызов:
|
||||
• macro = get_monthly_macro(db) — для дефолтного rate-path (hold_last_rate).
|
||||
• base_pace = compute_market_metrics(...).unit_velocity (§9.2 ед./мес).
|
||||
|
|
@ -565,9 +626,10 @@ def compute_demand_supply_forecast(
|
|||
• rate_future = rate_path[h] (или hold_last_rate).
|
||||
• §9.4 compute_demand_normalization(rate_future=rate_future) → norm (β внутри).
|
||||
• projected_demand = base_pace × norm × §9.5 × h (линейно, без компаунда).
|
||||
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future;
|
||||
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
|
||||
open + hidden_release + future − поглощённое спросом (clamp ≥0).
|
||||
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future
|
||||
(для НЕзаданного сегмента); при заданном сегменте open = n_available,
|
||||
hidden/future = 0. Чистое предложение = open + hidden_release + future −
|
||||
поглощённое спросом (clamp ≥0).
|
||||
• balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
|
||||
(ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
|
||||
• §9.7 future_competitors = top-N get_competitors(horizon_months=h).
|
||||
|
|
@ -600,7 +662,19 @@ def compute_demand_supply_forecast(
|
|||
)
|
||||
|
||||
# ── Один раз: §9.2 наблюдаемый темп (base_pace) ────────────────────────────
|
||||
metrics = compute_market_metrics(db, district=district, premise_kind=premise_kind)
|
||||
# #1959: ПОСЕГМЕНТНЫЙ темп — фильтруем market_metrics по obj_class + room_bucket
|
||||
# сегмента. Раньше брался district-wide unit_velocity (один темп на ВСЕ ячейки
|
||||
# what_to_build → дефицит вырождался в −1.0 везде). room_bucket идёт из Source-B
|
||||
# вокабуляра what_to_build ("Студии 15-30"/…); market_metrics теперь принимает
|
||||
# его напрямую (room CASE = зеркало room_area_bucket_of) → переводчик-валидатор
|
||||
# (см. _market_room_bucket) только страхует от неизвестных меток.
|
||||
metrics = compute_market_metrics(
|
||||
db,
|
||||
district=district,
|
||||
premise_kind=premise_kind,
|
||||
obj_class=spec.obj_class,
|
||||
room_bucket=_market_room_bucket(spec.room_bucket),
|
||||
)
|
||||
base_pace = metrics.unit_velocity
|
||||
|
||||
# ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
|
||||
|
|
@ -616,6 +690,15 @@ def compute_demand_supply_forecast(
|
|||
# значение).
|
||||
confounded = _series_confounded(macro_coef, sensitivity)
|
||||
|
||||
# #1959: сегмент «задан», если сужена хотя бы одна предметная ось (класс/формат).
|
||||
# Тогда ПРЕДЛОЖЕНИЕ берём ПОСЕГМЕНТНОЕ — open = market_metrics.n_available
|
||||
# (дедуплицированный сток ИМЕННО этого сегмента), а L2/L3 (hidden/future)
|
||||
# ИСКЛЮЧАЕМ из баланса: они класс/формат-агностичны (нет room-оси) → один и тот
|
||||
# же объём прибавился бы КАЖДОЙ ячейке → двойной счёт и снова вырождение к −1.0.
|
||||
# Незаданный сегмент (оба None) → прежний district-wide путь (open из §9.3 view +
|
||||
# hidden + future), обратная совместимость для агрегатных вызовов.
|
||||
segmented = spec.obj_class is not None or spec.room_bucket is not None
|
||||
|
||||
out: list[DemandSupplyForecast] = []
|
||||
for h in horizon_list:
|
||||
out.append(
|
||||
|
|
@ -629,6 +712,8 @@ def compute_demand_supply_forecast(
|
|||
rate_future=effective_rate_path.get(h),
|
||||
base_pace=base_pace,
|
||||
market_confidence=metrics.confidence,
|
||||
segment_open_units=metrics.n_available,
|
||||
segmented=segmented,
|
||||
macro_coef=macro_coef,
|
||||
sensitivity_phrase=sensitivity.phrase,
|
||||
confounded=confounded,
|
||||
|
|
@ -663,6 +748,8 @@ def _forecast_for_horizon(
|
|||
rate_future: float | None,
|
||||
base_pace: float | None,
|
||||
market_confidence: Confidence,
|
||||
segment_open_units: int,
|
||||
segmented: bool,
|
||||
macro_coef: Any,
|
||||
sensitivity_phrase: str | None,
|
||||
confounded: bool,
|
||||
|
|
@ -680,16 +767,28 @@ def _forecast_for_horizon(
|
|||
projected_demand = _project_demand(base_pace, norm_coefficient, macro_coefficient, horizon)
|
||||
|
||||
# ── ПРЕДЛОЖЕНИЕ: §9.3 слои → фазированный hidden → чистое (− поглощённое) ───
|
||||
# fsp читаем ВСЕГДА (нужен fsp.confidence для итоговой уверенности; для незаданного
|
||||
# сегмента — ещё и open/hidden/future как раньше).
|
||||
fsp = compute_future_supply_pressure(
|
||||
db, district=district, horizon_months=horizon, premise_kind=premise_kind
|
||||
)
|
||||
hidden_fraction = _hidden_release_fraction(horizon)
|
||||
hidden_release = fsp.hidden_units * hidden_fraction
|
||||
future_online = fsp.future_units_by_horizon
|
||||
gross_supply = _gross_supply(fsp.open_units, hidden_release, future_online)
|
||||
projected_supply = _project_supply(
|
||||
fsp.open_units, hidden_release, future_online, projected_demand
|
||||
)
|
||||
if segmented:
|
||||
# #1959: ПОСЕГМЕНТНОЕ предложение = открытый сток ИМЕННО этого сегмента
|
||||
# (market_metrics.n_available, дедуплицированный). L2/L3 (hidden/future)
|
||||
# класс/формат-агностичны (нет room-оси) → если прибавить их каждой ячейке,
|
||||
# один и тот же объём двоится по всем сегментам → дефицит снова липнет к −1.0.
|
||||
# Поэтому в посегментном балансе hidden/future = 0 (в dataclass их тоже не
|
||||
# показываем, чтобы explain не вводил в заблуждение «этот сегмент имеет L2/L3»).
|
||||
open_units = segment_open_units
|
||||
hidden_release = 0.0
|
||||
future_online = 0.0
|
||||
else:
|
||||
open_units = fsp.open_units
|
||||
hidden_fraction = _hidden_release_fraction(horizon)
|
||||
hidden_release = fsp.hidden_units * hidden_fraction
|
||||
future_online = fsp.future_units_by_horizon
|
||||
gross_supply = _gross_supply(open_units, hidden_release, future_online)
|
||||
projected_supply = _project_supply(open_units, hidden_release, future_online, projected_demand)
|
||||
|
||||
# ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
|
||||
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
|
||||
|
|
@ -737,7 +836,7 @@ def _forecast_for_horizon(
|
|||
demand_norm_coefficient=norm_coefficient,
|
||||
macro_coefficient=macro_coefficient,
|
||||
projected_demand_units=projected_demand,
|
||||
open_units=fsp.open_units,
|
||||
open_units=open_units,
|
||||
hidden_release_units=hidden_release,
|
||||
future_online_units=future_online,
|
||||
projected_supply_units=projected_supply,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ _CLASS_TO_FORECAST: dict[str, str] = {
|
|||
"Comfort+": "комфорт",
|
||||
"Business": "бизнес",
|
||||
"Elite": "бизнес",
|
||||
"Economy": "эконом",
|
||||
# #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
|
||||
"Economy": "стандарт",
|
||||
}
|
||||
|
||||
# ── #983 named-константы (§10/§16) ─────────────────────────────────────────────
|
||||
|
|
@ -145,8 +146,9 @@ def map_class(target_class: str | None) -> str | None:
|
|||
"""Live target_class → forecast obj_class. PURE. None/неизвестное → None.
|
||||
|
||||
Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite — в «бизнес»,
|
||||
Economy — в «эконом» (3 массовых класса §9.7). None → None = «без фильтра
|
||||
класса», вызывающий отдаёт движку дефолтную сетку классов.
|
||||
Economy — в «стандарт» (#1959: нижний массовый класс objective_lots —
|
||||
«стандарт», эконома в данных нет). 3 массовых класса §9.7. None → None =
|
||||
«без фильтра класса», вызывающий отдаёт движку дефолтную сетку классов.
|
||||
"""
|
||||
if target_class is None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ _STRONG_DEFICIT_THRESHOLD: float = 0.25
|
|||
# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05.
|
||||
_SIGNAL_BALANCE_EPS: float = 0.05
|
||||
|
||||
# #1959 honesty-guard: если ВСЕ ячейки сетки прижаты к ±1.0 (дефицит не различает
|
||||
# сегменты — degenerate-fallback, как было до фикса посегментации), категоричные
|
||||
# вердикты «строить»/«избегать» вводят в заблуждение (на самом деле сигнал не
|
||||
# дискриминирует). Тогда вместо вердикта показываем честную плашку. |di| ≥ этого
|
||||
# порога считаем «прижато к пределу шкалы».
|
||||
_DEGENERATE_CLAMP_THRESHOLD: float = 0.999
|
||||
_DEGENERATE_SEGMENT_NOTE: str = "недостаточно гранулярных данных для посегментного вывода"
|
||||
|
||||
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
|
||||
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
|
||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||
|
|
@ -144,17 +152,31 @@ def _as_dict_list(items: Sequence[Any] | None) -> list[dict[str, Any]]:
|
|||
|
||||
|
||||
def _deal_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
|
||||
"""Число сделок (продаж) за окно — для deal_count #990. PURE.
|
||||
"""Число сделок (продаж) ЗА ОКНО — для deal_count #990. PURE.
|
||||
|
||||
Предпочитаем `market_metrics.n_sold` (§9.2 — прямой счётчик проданных лотов выборки);
|
||||
fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
|
||||
сделок — пошло бы в analog_count). Нет сигнала → None (#990 → тянет в low).
|
||||
#1964: НЕ берём `market_metrics.n_sold` — это КУМУЛЯТИВНЫЙ all-time проданный сток
|
||||
(десятки-сотни тысяч), а #990/confidence_engine помечает фактор «за {window} мес»
|
||||
(6) и гейтит порогами 6-мес окна (high≥50). n_sold ≫ 50 ВСЕГДА → гейт бессмыслен и
|
||||
подпись врёт. Берём оконное число = unit_velocity × window_months, где unit_velocity
|
||||
(§9.2) = units_sold_window / window_months из _SALES_WINDOW_SQL (он
|
||||
contract_date-windowed) → произведение восстанавливает units_sold за окно. Так
|
||||
подпись «за 6 мес» честная и порог 50/15 осмысленный (прод EKB: ~24 876 за 6 мес
|
||||
vs ~380 921 кумулятивно).
|
||||
|
||||
unit_velocity отсутствует (None / нет market_metrics) → None (#990 трактует как
|
||||
«нет данных», тянет в low). window_months ≤ 0 / нет → None (нечем масштабировать).
|
||||
Fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
|
||||
сделок — пошло бы в analog_count).
|
||||
"""
|
||||
if market_metrics is not None:
|
||||
n_sold = market_metrics.get("n_sold")
|
||||
if isinstance(n_sold, int):
|
||||
return n_sold
|
||||
return None
|
||||
if market_metrics is None:
|
||||
return None
|
||||
unit_velocity = market_metrics.get("unit_velocity")
|
||||
window_months = market_metrics.get("window_months")
|
||||
if not isinstance(unit_velocity, int | float):
|
||||
return None
|
||||
if not isinstance(window_months, int) or window_months <= 0:
|
||||
return None
|
||||
return round(float(unit_velocity) * window_months)
|
||||
|
||||
|
||||
def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
|
||||
|
|
@ -231,7 +253,8 @@ def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None:
|
|||
"""Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE.
|
||||
|
||||
Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months` —
|
||||
именно за это окно считается n_sold. Нет → None (#990 пропускает суффикс «за N мес»).
|
||||
#1964: именно за это окно `_deal_count` считает сделки (unit_velocity ×
|
||||
window_months). Нет → None (#990 пропускает суффикс «за N мес»).
|
||||
"""
|
||||
if market_metrics is not None:
|
||||
window = market_metrics.get("window_months")
|
||||
|
|
@ -532,33 +555,71 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
|
|||
"""
|
||||
explicit = product_tz.get("mix")
|
||||
if isinstance(explicit, list):
|
||||
return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)]
|
||||
entries = [m for m in explicit if isinstance(m, dict)]
|
||||
degenerate = _all_cells_clamped([e.get("deficit_index") for e in entries])
|
||||
return [_enrich_mix_entry(m, degenerate=degenerate) for m in entries]
|
||||
ranked = product_tz.get("ranked_segments")
|
||||
if isinstance(ranked, list):
|
||||
segs = [seg for seg in ranked if isinstance(seg, dict)]
|
||||
# #1959 honesty-guard: вся сетка прижата к ±1.0 → дефицит не дискриминирует
|
||||
# сегменты (degenerate-fallback). Не эмитим категоричные «строить»/«избегать»
|
||||
# — вместо них честная плашка `_DEGENERATE_SEGMENT_NOTE`.
|
||||
degenerate = _all_cells_clamped([seg.get("deficit_index") for seg in segs])
|
||||
return [
|
||||
{
|
||||
"bucket": seg.get("bucket"),
|
||||
"obj_class": seg.get("obj_class"),
|
||||
"deficit_index": seg.get("deficit_index"),
|
||||
"projected_demand_units": seg.get("projected_demand_units"),
|
||||
"signal": _build_signal(seg.get("deficit_index")),
|
||||
"signal": (
|
||||
_DEGENERATE_SEGMENT_NOTE
|
||||
if degenerate
|
||||
else _build_signal(seg.get("deficit_index"))
|
||||
),
|
||||
}
|
||||
for seg in ranked
|
||||
if isinstance(seg, dict)
|
||||
for seg in segs
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _enrich_mix_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
def _all_cells_clamped(deficit_indices: Sequence[Any]) -> bool:
|
||||
"""True если ВСЕ измеримые ячейки прижаты к ±1.0 (degenerate-fallback). PURE.
|
||||
|
||||
#1959 honesty-guard. «Измеримые» = числовые deficit_index (None-ячейки —
|
||||
тонкие данные, в знаменатель не идут). Пусто / нет измеримых → False (нечего
|
||||
защищать — обычный graceful-путь). Если хотя бы одна ячейка различается
|
||||
(|di| < порога), сетка ДИСКРИМИНИРУЕТ → вердикты честны → False. PURE.
|
||||
|
||||
Args:
|
||||
deficit_indices: deficit_index ячеек (числа / None / мусор).
|
||||
|
||||
Returns:
|
||||
True только когда есть ≥1 измеримая ячейка и ВСЕ они |di| ≥ порога.
|
||||
"""
|
||||
measured = [
|
||||
float(d) for d in deficit_indices if isinstance(d, int | float) and not isinstance(d, bool)
|
||||
]
|
||||
if not measured:
|
||||
return False
|
||||
return all(abs(d) >= _DEGENERATE_CLAMP_THRESHOLD for d in measured)
|
||||
|
||||
|
||||
def _enrich_mix_entry(entry: dict[str, Any], *, degenerate: bool = False) -> dict[str, Any]:
|
||||
"""Дополнить явную mix-ячейку деривированным `signal` (#1745). PURE.
|
||||
|
||||
Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет
|
||||
`signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует
|
||||
вход — assembler-чистота).
|
||||
|
||||
#1959 honesty-guard: `degenerate=True` (вся сетка прижата к ±1.0) → вместо
|
||||
категоричного сигнала ставим честную плашку `_DEGENERATE_SEGMENT_NOTE` (только
|
||||
когда у ячейки не было своего явного `signal`).
|
||||
"""
|
||||
enriched = dict(entry)
|
||||
if enriched.get("signal") is None:
|
||||
enriched["signal"] = _build_signal(enriched.get("deficit_index"))
|
||||
enriched["signal"] = (
|
||||
_DEGENERATE_SEGMENT_NOTE if degenerate else _build_signal(enriched.get("deficit_index"))
|
||||
)
|
||||
return enriched
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -96,9 +96,10 @@ _MIN_RATE_PCT: float = 0.0
|
|||
# читается как баг. Текст уходит в отчёт через ReportScenarios.scenarios_collapse_reason
|
||||
# и далее в PDF/Excel-плашку вместо таблицы трёх одинаковых столбцов.
|
||||
_COLLAPSE_REASON_LOW_BETA: Final[str] = (
|
||||
"β rate-sensitivity не прошёл gate (n≥30 ∧ R²≥0.1 ∧ slope<0) на текущих "
|
||||
"данных ЕКБ — сценарная дифференциация conservative/aggressive восстановится "
|
||||
"после расширения окна по ключевой ставке."
|
||||
"Чувствительность спроса к ключевой ставке не удалось оценить на коротком "
|
||||
"ряде по Екатеринбургу, поэтому консервативный и агрессивный сценарии "
|
||||
"совпали с базовым — показываем один базовый сценарий вместо трёх. Три "
|
||||
"сценария вернутся, когда накопится более длинная история по ставке."
|
||||
)
|
||||
|
||||
# Допуски сравнения metric'ов сценариев при детекции collapse (math.isclose). Берём
|
||||
|
|
|
|||
|
|
@ -828,22 +828,62 @@ def _unavailable(key: str, *, reason: str) -> SpecialIndex:
|
|||
# `market_metrics._SALES_WINDOW_SQL`: bind `:has_district` (boolean) переключает
|
||||
# ветку, `:districts` (text[]) — список микро. «Продан» = is_sold IS TRUE ИЛИ
|
||||
# contract_date заполнена. psycopg v3: CAST, НИКОГДА :x::type.
|
||||
# #1964: считаем по ФИЗИЧЕСКИМ лотам (дедуп) — сырой objective_lots раздут ~2.91×
|
||||
# (мульти lot_id на физлот); доля ипотеки — отношение, к дублям устойчива, но
|
||||
# n_sold/n_mortgage теперь честные (по физлотам).
|
||||
#
|
||||
# Перф-батч #2 (#1953-followup): дедупим INLINE через DISTINCT ON (physflat-ключ,
|
||||
# последний снапшот), НЕ через v_objective_lots_latest. Причина: view (DISTINCT ON ol.*)
|
||||
# — оптимизационный барьер: WHERE снаружи НЕ проталкивается ниже DISTINCT ON →
|
||||
# планировщик материализует ВСЮ таблицу (1.76M, Parallel Seq Scan + external merge sort
|
||||
# 66-81 МБ/воркер) ПЕРЕД фильтром (~5.4-6.4 s district-scoped, прод-EXPLAIN). Inline:
|
||||
# bitmap по objective_lots_district_class_idx + узкий sort (~3-5 МБ/воркер) ~0.37 s
|
||||
# (≈15×).
|
||||
#
|
||||
# ⚠️ КОРРЕКТНОСТЬ дедупа (слои фильтров РАЗНЫЕ — не путать):
|
||||
# • premise_kind — ПОЛНОСТЬЮ стабилен (0 mixed-групп на проде) → в WHERE ДО DISTINCT ON.
|
||||
# • district — почти стабилен (4 transient-группы на 605k физлотов). Протолкнут ДО
|
||||
# DISTINCT ON ОСОЗНАННО: это index-драйвер objective_lots_district_class_idx; увод
|
||||
# фильтра ПОСЛЕ дедупа = full seq-scan, убивает перф-фикс. Принятый компромисс
|
||||
# паттерна parcels.py/#1964 (≤4 возможных дрейфа при district-scoped запросе).
|
||||
# • class — МЕНЯЮЩИЙСЯ (248 transient-групп на проде): класс физлота дрейфует между
|
||||
# снапшотами. Фильтр lower(class)=… ДО DISTINCT ON ловил бы СТАРЫЙ снапшот под
|
||||
# старым классом (прод-замер: бизнес 16637→16657 +20, стандарт 128721→128914 +193
|
||||
# vs view). View фильтрует class на ПОСЛЕДНЕМ снапшоте → class-фильтр применяем
|
||||
# ПОСЛЕ дедупа (в sold-CTE). Тогда эквивалентность view бит-в-бит по ВСЕМ классам.
|
||||
# • sold-фильтр (is_sold / contract_date) — тоже МЕНЯЮЩИЙСЯ: ПОСЛЕ дедупа (на
|
||||
# ПОСЛЕДНЕМ снапшоте), как делал view. Протолкнуть ДО DISTINCT ON СЛОМАЛО БЫ
|
||||
# семантику (включил бы когда-либо-проданные + дедуп среди sold-снапшотов).
|
||||
_ARTIFICIAL_DEMAND_SQL = text(
|
||||
"""
|
||||
WITH sold AS (
|
||||
SELECT
|
||||
ol.objective_lot_id,
|
||||
(ol.encumbrance_type IS NOT NULL OR ol.bank_name IS NOT NULL) AS has_mortgage
|
||||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = CAST(:premise_kind AS text)
|
||||
AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL)
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (
|
||||
project_name, corpus_name, section, floor, lot_number
|
||||
)
|
||||
objective_lot_id,
|
||||
is_sold,
|
||||
contract_date,
|
||||
class,
|
||||
encumbrance_type,
|
||||
bank_name
|
||||
FROM objective_lots
|
||||
WHERE premise_kind = CAST(:premise_kind AS text)
|
||||
AND (
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
OR district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
ORDER BY project_name, corpus_name, section, floor,
|
||||
lot_number, snapshot_date DESC, id DESC
|
||||
),
|
||||
sold AS (
|
||||
SELECT
|
||||
objective_lot_id,
|
||||
(encumbrance_type IS NOT NULL OR bank_name IS NOT NULL) AS has_mortgage
|
||||
FROM latest
|
||||
WHERE (is_sold IS TRUE OR contract_date IS NOT NULL)
|
||||
AND (
|
||||
CAST(:obj_class AS text) IS NULL
|
||||
OR lower(ol.class) = lower(CAST(:obj_class AS text))
|
||||
OR lower(class) = lower(CAST(:obj_class AS text))
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ Confidence = Literal["high", "medium", "low"]
|
|||
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
|
||||
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
|
||||
# может добавить). Держим явной константой для детерминизма сетки.
|
||||
_DEFAULT_CLASSES: tuple[str, ...] = ("эконом", "комфорт", "бизнес")
|
||||
# #1959: нижний массовый класс objective_lots.class — «стандарт», НЕ «эконом»
|
||||
# (verified на prod: стандарт=483k лотов, эконома НЕТ → раньше эта ячейка матчила
|
||||
# 0 строк → молча выпадала из сетки; посегментный дефицит её не видел).
|
||||
_DEFAULT_CLASSES: tuple[str, ...] = ("стандарт", "комфорт", "бизнес")
|
||||
|
||||
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
|
||||
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это
|
||||
|
|
|
|||
141
backend/app/services/generative/catalog.py
Normal file
141
backend/app/services/generative/catalog.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Generative Design — Stage 3a (#1965): каталог типовых домов (house-type catalog).
|
||||
|
||||
Контракт Stage 3a: вместо max-FAR жадной раскладки (Stage 1b) пользователь выбирает
|
||||
ТИПОВЫЕ дома и говорит, сколько секций каждого типа поставить. Этот модуль — справочник
|
||||
таких типов: для каждого ``section_type`` он несёт габариты пятна секции (ширина × глубина,
|
||||
метры), дефолтную этажность и подходящий класс жилья.
|
||||
|
||||
ИСТОЧНИК: это РАЗУМНЫЙ ДЕФОЛТНЫЙ каталог (sane-default), а не выгрузка из БД. Габариты —
|
||||
типовые размеры секций массового жилья РФ (панель/монолит/башня/малоэтажка), округлённые
|
||||
до реалистичных значений. Каталог намеренно захардкожен в коде на Stage 3a: миграции БД
|
||||
сейчас нет (см. эпик #1953). Когда понадобится редактируемый застройщиком каталог — его
|
||||
ПРОДВИГАЮТ в БД-таблицу с тем же контрактом (``section_type`` → footprint/floors/class),
|
||||
а этот модуль станет seed'ом/фолбэком. До тех пор — single source of truth по типам.
|
||||
|
||||
Детерминированно, без LLM / внешних API / БД.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Класс жилья — то же Literal-множество, что у ConceptInput / compute_teap (single
|
||||
# source of truth по допустимым значениям; рассинхрон тут = ошибка типов в mypy-strict).
|
||||
HousingClass = Literal["econom", "comfort", "business"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HouseType:
|
||||
"""Один типовой дом каталога (тип секции МКД / малоэтажки).
|
||||
|
||||
section_type — стабильный машинный КЛЮЧ типа (латиница, snake_case); это и есть
|
||||
значение, которое фронт кладёт в ``BuildingProgramItem.section_type``.
|
||||
label_ru — человекочитаемый русский лейбл для UI (Stage 3b его показывает).
|
||||
footprint_w_m — ширина пятна секции, метры.
|
||||
footprint_d_m — глубина пятна секции, метры.
|
||||
default_floors — дефолтная этажность типа (UI подставляет, пользователь может менять
|
||||
в пределах контракта BuildingProgramItem [1, 40]).
|
||||
housing_class — подходящий класс жилья (драйвит ТЭП/финмодель ниже по конвейеру).
|
||||
"""
|
||||
|
||||
section_type: str
|
||||
label_ru: str
|
||||
footprint_w_m: float
|
||||
footprint_d_m: float
|
||||
default_floors: int
|
||||
housing_class: HousingClass
|
||||
|
||||
@property
|
||||
def footprint_sqm(self) -> float:
|
||||
"""Площадь пятна секции, кв.м (ширина × глубина)."""
|
||||
return self.footprint_w_m * self.footprint_d_m
|
||||
|
||||
|
||||
# ── Каталог типовых домов (sane-default, см. модульный docstring про источник) ──────
|
||||
# Покрываем основные форматы массового жилья РФ:
|
||||
# * панель-эконом — длинная неглубокая секция, средняя этажность, эконом-класс;
|
||||
# * монолит-комфорт— чуть шире/глубже, комфорт-класс, типовая «свечка» 14 этажей;
|
||||
# * башня-бизнес — компактное квадратное пятно, высотная (точечная) застройка;
|
||||
# * малоэтажка-комфорт — широкая невысокая секция (3 этажа), низкоплотная застройка;
|
||||
# * таунхаус — узкое неглубокое пятно блокированной застройки, 3 этажа.
|
||||
# Габариты — реалистичные типовые размеры; этажность — характерная для формата.
|
||||
HOUSE_TYPES: tuple[HouseType, ...] = (
|
||||
HouseType(
|
||||
section_type="panel_econom",
|
||||
label_ru="Панельная секция (эконом)",
|
||||
footprint_w_m=24.0,
|
||||
footprint_d_m=15.0,
|
||||
default_floors=9,
|
||||
housing_class="econom",
|
||||
),
|
||||
HouseType(
|
||||
section_type="monolith_comfort",
|
||||
label_ru="Монолитная секция (комфорт)",
|
||||
footprint_w_m=21.0,
|
||||
footprint_d_m=18.0,
|
||||
default_floors=14,
|
||||
housing_class="comfort",
|
||||
),
|
||||
HouseType(
|
||||
section_type="tower_business",
|
||||
label_ru="Башня (бизнес)",
|
||||
footprint_w_m=18.0,
|
||||
footprint_d_m=18.0,
|
||||
default_floors=25,
|
||||
housing_class="business",
|
||||
),
|
||||
HouseType(
|
||||
section_type="lowrise_comfort",
|
||||
label_ru="Малоэтажная секция (комфорт)",
|
||||
footprint_w_m=30.0,
|
||||
footprint_d_m=14.0,
|
||||
default_floors=3,
|
||||
housing_class="comfort",
|
||||
),
|
||||
HouseType(
|
||||
section_type="townhouse",
|
||||
label_ru="Таунхаус",
|
||||
footprint_w_m=12.0,
|
||||
footprint_d_m=10.0,
|
||||
default_floors=3,
|
||||
housing_class="comfort",
|
||||
),
|
||||
)
|
||||
|
||||
# Индекс по ключу для O(1)-лукапа. Построен один раз при импорте; ключи уникальны
|
||||
# (assert ниже ловит дубликат типа на старте, а не молча затирает запись).
|
||||
_BY_KEY: dict[str, HouseType] = {ht.section_type: ht for ht in HOUSE_TYPES}
|
||||
assert len(_BY_KEY) == len(HOUSE_TYPES), "duplicate section_type key in HOUSE_TYPES"
|
||||
|
||||
|
||||
def get_house_type(section_type: str) -> HouseType:
|
||||
"""Найти тип дома по ключу ``section_type``. Бросает :class:`KeyError`, если нет.
|
||||
|
||||
Вызывающий слой (placement) обязан валидировать ключи программы заранее (см.
|
||||
:func:`available_section_types`) — неизвестный ключ здесь это программная ошибка,
|
||||
а не пользовательский ввод, поэтому KeyError, а не тихий None.
|
||||
"""
|
||||
try:
|
||||
return _BY_KEY[section_type]
|
||||
except KeyError as exc:
|
||||
raise KeyError(
|
||||
f"unknown house type {section_type!r}; " f"available: {', '.join(sorted(_BY_KEY))}"
|
||||
) from exc
|
||||
|
||||
|
||||
def available_section_types() -> frozenset[str]:
|
||||
"""Множество допустимых ключей ``section_type`` каталога (для валидации программы)."""
|
||||
return frozenset(_BY_KEY)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HOUSE_TYPES",
|
||||
"HouseType",
|
||||
"HousingClass",
|
||||
"available_section_types",
|
||||
"get_house_type",
|
||||
]
|
||||
|
|
@ -23,13 +23,19 @@ Deterministic, no LLM / no external API / no DB.
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from shapely.geometry import Polygon, box
|
||||
from shapely.strtree import STRtree
|
||||
|
||||
from app.schemas.concept import ConceptInput, ConceptVariant
|
||||
from app.services.generative import financial, teap
|
||||
from app.schemas.concept import (
|
||||
TEAP,
|
||||
BuildingProgramItem,
|
||||
ConceptInput,
|
||||
ConceptVariant,
|
||||
)
|
||||
from app.services.generative import catalog, financial, teap
|
||||
from app.services.generative.geometry import Parcel, ParcelGeometryError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -92,6 +98,53 @@ def _resolve_floors(target_floors: int, factor: float) -> int:
|
|||
return max(_FLOORS_MIN, min(_FLOORS_MAX, floors))
|
||||
|
||||
|
||||
class _Placer:
|
||||
"""Аккумулятор размещённых секций + STRtree-индекс для проверки разрывов.
|
||||
|
||||
Извлечён из жадной раскладки, чтобы и :func:`_greedy_place` (coverage-cap sweep), и
|
||||
:func:`place_program` (фиксированная программа типовых домов, Stage 3a) пользовались
|
||||
ОДНОЙ И ТОЙ ЖЕ collision/setback-машиной, а не дублировали её. Состояние: принятые
|
||||
footprints, их буферизованные на ``half_gap`` версии и перестраиваемый по ним STRtree.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.placed: list[Polygon] = []
|
||||
self.built_area: float = 0.0
|
||||
# Буферизованные footprints для проверки разрыва; индекс STRtree по ним.
|
||||
self._buffered: list[Polygon] = []
|
||||
self._tree: STRtree | None = None
|
||||
|
||||
def try_place(self, footprint: Polygon, buildable: Polygon, half_gap: float) -> bool:
|
||||
"""Попытаться принять ``footprint``. True — принят, False — не лёг.
|
||||
|
||||
Принимается, если целиком внутри ``buildable`` (covers допускает касание границы)
|
||||
И не нарушает разрыв ``half_gap*2`` с уже принятыми (буферим кандидата на
|
||||
``half_gap`` и проверяем пересечение с буферизованными соседями через STRtree —
|
||||
две секции с зазором >= gap не пересекутся).
|
||||
"""
|
||||
if not buildable.covers(footprint):
|
||||
return False
|
||||
|
||||
candidate_buf = footprint.buffer(half_gap, join_style="mitre")
|
||||
if self._tree is not None:
|
||||
for idx in self._tree.query(candidate_buf):
|
||||
if candidate_buf.intersects(self._buffered[idx]):
|
||||
return False
|
||||
|
||||
self.placed.append(footprint)
|
||||
self.built_area += footprint.area
|
||||
self._buffered.append(candidate_buf)
|
||||
self._tree = STRtree(self._buffered)
|
||||
return True
|
||||
|
||||
|
||||
def _centered_footprint(cx: float, cy: float, width: float, depth: float) -> Polygon:
|
||||
"""Прямоугольное пятно секции ``width × depth``, центрированное на (cx, cy), метры."""
|
||||
half_w = width / 2.0
|
||||
half_d = depth / 2.0
|
||||
return box(cx - half_w, cy - half_d, cx + half_w, cy + half_d)
|
||||
|
||||
|
||||
def _greedy_place(
|
||||
parcel: Parcel,
|
||||
spec: StrategySpec,
|
||||
|
|
@ -103,63 +156,32 @@ def _greedy_place(
|
|||
* кандидат-якоря — центры ячеек сетки в фиксированном порядке;
|
||||
* footprint строится центрированно на якоре;
|
||||
* принимается, если целиком внутри buildable area И не нарушает разрыв ``gap_m``
|
||||
с уже принятыми (проверка через STRtree по buffered-footprints);
|
||||
с уже принятыми (проверка через STRtree, см. :class:`_Placer`);
|
||||
* раскладка останавливается, когда пятно достигает ``coverage_cap`` от buildable
|
||||
area (регулятор плотности по типу застройки) — это также ограничивает число
|
||||
размещений и держит O(n^2)-перестройку STRtree в бюджете.
|
||||
"""
|
||||
buildable = parcel.buildable_m
|
||||
max_built = buildable.area * coverage_cap
|
||||
placed: list[Polygon] = []
|
||||
built_area = 0.0
|
||||
# Буферизованные footprints для проверки разрыва; индекс STRtree по ним.
|
||||
buffered: list[Polygon] = []
|
||||
tree: STRtree | None = None
|
||||
|
||||
half_w = spec.section_w / 2.0
|
||||
half_d = spec.section_d / 2.0
|
||||
placer = _Placer()
|
||||
half_gap = spec.gap_m / 2.0
|
||||
|
||||
for cell in parcel.grid:
|
||||
if built_area >= max_built:
|
||||
if placer.built_area >= max_built:
|
||||
break
|
||||
footprint = box(
|
||||
cell.cx - half_w,
|
||||
cell.cy - half_d,
|
||||
cell.cx + half_w,
|
||||
cell.cy + half_d,
|
||||
)
|
||||
# Целиком внутри пятна застройки (covers допускает касание границы).
|
||||
if not buildable.covers(footprint):
|
||||
continue
|
||||
|
||||
# Разрыв между секциями: буферим кандидата на half_gap и проверяем пересечение
|
||||
# с буферизованными соседями — две секции с зазором >= gap_m не пересекутся.
|
||||
candidate_buf = footprint.buffer(half_gap, join_style="mitre")
|
||||
if tree is not None:
|
||||
collision = False
|
||||
for idx in tree.query(candidate_buf):
|
||||
if candidate_buf.intersects(buffered[idx]):
|
||||
collision = True
|
||||
break
|
||||
if collision:
|
||||
continue
|
||||
|
||||
placed.append(footprint)
|
||||
built_area += footprint.area
|
||||
buffered.append(candidate_buf)
|
||||
tree = STRtree(buffered)
|
||||
footprint = _centered_footprint(cell.cx, cell.cy, spec.section_w, spec.section_d)
|
||||
placer.try_place(footprint, buildable, half_gap)
|
||||
|
||||
logger.info(
|
||||
"strategy=%s placed %d sections (%.0fx%.0f m, gap=%.0f m, coverage<=%.0f%%)",
|
||||
spec.name,
|
||||
len(placed),
|
||||
len(placer.placed),
|
||||
spec.section_w,
|
||||
spec.section_d,
|
||||
spec.gap_m,
|
||||
coverage_cap * 100,
|
||||
)
|
||||
return placed
|
||||
return placer.placed
|
||||
|
||||
|
||||
def _footprints_to_geojson(
|
||||
|
|
@ -187,6 +209,268 @@ def _footprints_to_geojson(
|
|||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
# ── Stage 3a (#1965): program-driven placement (типовые дома вместо max-FAR sweep) ──
|
||||
# Разрыв между секциями в program-режиме (м). Пункт программы не несёт gap (контракт —
|
||||
# только тип/этажность/количество), поэтому берём один нормативный противопожарный/
|
||||
# инсоляционный зазор для всех секций программы — середина диапазона стратегий 1b
|
||||
# (max_area gap=6 … max_insolation gap=15). Достаточно консервативно для реалистичной
|
||||
# раскладки, не патологически разрежено.
|
||||
_PROGRAM_GAP_M: float = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlacedSection:
|
||||
"""Одна размещённая секция программы: пятно + этажность + тип каталога (для GeoJSON)."""
|
||||
|
||||
footprint: Polygon
|
||||
floors: int
|
||||
section_type: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlacedProgram:
|
||||
"""Результат program-раскладки: что легло + честный счётчик «N из M».
|
||||
|
||||
``sections`` — размещённые секции (пятна + этажность + тип) в порядке программы.
|
||||
``requested_count`` — сколько секций просили (Σ count по программе).
|
||||
``placed_count`` — сколько реально влезло (== len(sections)). placed < requested →
|
||||
участок мал, разместилось N из M (без hard-422 — честный сигнал для Stage 3b).
|
||||
"""
|
||||
|
||||
sections: tuple[_PlacedSection, ...]
|
||||
requested_count: int
|
||||
placed_count: int
|
||||
|
||||
|
||||
def place_program(
|
||||
parcel: Parcel,
|
||||
program: list[BuildingProgramItem],
|
||||
*,
|
||||
gap_m: float = _PROGRAM_GAP_M,
|
||||
) -> PlacedProgram:
|
||||
"""Stage 3a: разложить РОВНО заданную программу типовых домов на участок.
|
||||
|
||||
Для каждого пункта программы (``section_type`` из каталога × ``count`` секций) кладём
|
||||
до ``count`` секций каталожного пятна на сетку участка, переиспользуя ТУ ЖЕ
|
||||
collision/STRtree/setback-машину, что и жадная раскладка (:class:`_Placer`) — никакого
|
||||
coverage-cap, стоп-критерий = достигнут ``count`` для пункта или кончились свободные
|
||||
якоря. Пункты обрабатываются по порядку; накопленные секции участвуют в проверке
|
||||
разрыва для последующих (общий :class:`_Placer`).
|
||||
|
||||
Если участок не вмещает все запрошенные секции, НЕ роняем 422 — кладём сколько влезло
|
||||
и возвращаем честный ``placed_count``/``requested_count`` (разместилось N из M).
|
||||
|
||||
Raises:
|
||||
KeyError: ``section_type`` пункта нет в каталоге (валидируется на API-слое до
|
||||
размещения; здесь это программная ошибка контракта, не пользовательский ввод).
|
||||
"""
|
||||
buildable = parcel.buildable_m
|
||||
half_gap = gap_m / 2.0
|
||||
placer = _Placer()
|
||||
placed_sections: list[_PlacedSection] = []
|
||||
requested = 0
|
||||
|
||||
for item in program:
|
||||
house = catalog.get_house_type(item.section_type)
|
||||
requested += item.count
|
||||
# Ручное пятно (Stage 3c): если пользователь задал ОБА габарита — кладём
|
||||
# его вместо каталожного («вписать пятно»). Частичное задание игнорируем
|
||||
# (нужны и ширина, и глубина), падая обратно на каталог.
|
||||
if item.footprint_w_m is not None and item.footprint_d_m is not None:
|
||||
fp_w, fp_d = item.footprint_w_m, item.footprint_d_m
|
||||
else:
|
||||
fp_w, fp_d = house.footprint_w_m, house.footprint_d_m
|
||||
placed_for_item = 0
|
||||
for cell in parcel.grid:
|
||||
if placed_for_item >= item.count:
|
||||
break
|
||||
footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
|
||||
if placer.try_place(footprint, buildable, half_gap):
|
||||
placed_sections.append(
|
||||
_PlacedSection(
|
||||
footprint=footprint,
|
||||
floors=item.floors,
|
||||
section_type=item.section_type,
|
||||
)
|
||||
)
|
||||
placed_for_item += 1
|
||||
if placed_for_item < item.count:
|
||||
logger.warning(
|
||||
"program: type=%s placed %d of %d sections (%.0fx%.0f m) — участок мал",
|
||||
item.section_type,
|
||||
placed_for_item,
|
||||
item.count,
|
||||
house.footprint_w_m,
|
||||
house.footprint_d_m,
|
||||
)
|
||||
|
||||
result = PlacedProgram(
|
||||
sections=tuple(placed_sections),
|
||||
requested_count=requested,
|
||||
placed_count=len(placed_sections),
|
||||
)
|
||||
logger.info(
|
||||
"program placed %d of %d sections across %d type(s)",
|
||||
result.placed_count,
|
||||
result.requested_count,
|
||||
len(program),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _placed_program_to_geojson(
|
||||
parcel: Parcel,
|
||||
sections: tuple[_PlacedSection, ...],
|
||||
) -> dict[str, object]:
|
||||
"""Размещённые секции программы -> WGS84 FeatureCollection (контракт buildings_geojson).
|
||||
|
||||
Зеркалит :func:`_footprints_to_geojson`, но каждая секция несёт СВОИ floors и тип
|
||||
каталога (program-режим смешивает типы/этажности), а ``strategy`` помечается
|
||||
``"program"`` — маркер, что вариант построен из программы, а не из 1b-стратегии.
|
||||
"""
|
||||
features: list[dict[str, object]] = []
|
||||
for i, sec in enumerate(sections):
|
||||
geom_wgs = parcel.metric_geom_to_wgs84(sec.footprint)
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": geom_wgs,
|
||||
"properties": {
|
||||
"section_id": i + 1,
|
||||
"floors": sec.floors,
|
||||
"footprint_sqm": round(float(sec.footprint.area), 1),
|
||||
"section_type": sec.section_type,
|
||||
"strategy": "program",
|
||||
},
|
||||
}
|
||||
)
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def _aggregate_program_teap(
|
||||
sections: tuple[_PlacedSection, ...],
|
||||
*,
|
||||
site_area_sqm: float,
|
||||
housing_class: teap.HousingClass,
|
||||
) -> TEAP:
|
||||
"""Свести размещённую программу (СМЕШАННАЯ этажность) в один :class:`TEAP` — ТОЧНО.
|
||||
|
||||
``compute_teap`` берёт ОДНУ этажность на список пятен, поэтому при смешанной по типам
|
||||
этажности нельзя просто скормить ему все пятна с одним числом (округлённая «средняя»
|
||||
этажность даёт дрейф GFA ~1%). Вместо этого группируем секции по этажности, считаем
|
||||
``compute_teap`` для каждой однородной группы и СУММИРУЕМ результаты:
|
||||
|
||||
* built / GFA / office / residential — аддитивны → сумма точна (GFA = Σ площадь_i×floors_i);
|
||||
* apartments — ``Σ floor(жилая_g / avg)`` по группам: физически корректнее, чем
|
||||
``floor(Σжилая / avg)`` (нельзя «склеивать» дробные квартиры между корпусами);
|
||||
* parking — пересчитываем от ИТОГОВОГО числа квартир по той же норме класса (ceil от
|
||||
суммы, а не сумма ceil — иначе пер-группное округление вверх задвоит места);
|
||||
* density (FAR) — от суммарной GFA и площади участка (защита от деления на ноль).
|
||||
|
||||
Единый источник всех нормативных коэффициентов остаётся ``teap``-модуль (классовые
|
||||
словари), новых магических чисел нет.
|
||||
"""
|
||||
if not sections:
|
||||
return teap.compute_teap(
|
||||
footprints=[], floors=0, site_area_sqm=site_area_sqm, housing_class=housing_class
|
||||
)
|
||||
|
||||
# Группируем по этажности; внутри группы compute_teap корректен (одна этажность).
|
||||
groups: dict[int, list[Polygon]] = {}
|
||||
for sec in sections:
|
||||
groups.setdefault(sec.floors, []).append(sec.footprint)
|
||||
|
||||
built = 0.0
|
||||
gfa = 0.0
|
||||
office = 0.0
|
||||
residential = 0.0
|
||||
apartments = 0
|
||||
for floors, fps in groups.items():
|
||||
# site_area_sqm здесь не важна для аддитивных полей — FAR пересчитаем в конце.
|
||||
group = teap.compute_teap(
|
||||
footprints=fps,
|
||||
floors=floors,
|
||||
site_area_sqm=site_area_sqm,
|
||||
housing_class=housing_class,
|
||||
)
|
||||
built += group.built_area_sqm
|
||||
gfa += group.total_floor_area_sqm
|
||||
office += group.office_area_sqm
|
||||
residential += group.residential_area_sqm
|
||||
apartments += group.apartments_count
|
||||
|
||||
density = gfa / site_area_sqm if site_area_sqm > 0 else 0.0
|
||||
parking_norm = teap._PARKING_PER_APARTMENT[housing_class]
|
||||
parking_spaces = math.ceil(apartments * parking_norm)
|
||||
|
||||
return TEAP(
|
||||
built_area_sqm=round(built, 1),
|
||||
total_floor_area_sqm=round(gfa, 1),
|
||||
office_area_sqm=round(office, 1),
|
||||
residential_area_sqm=round(residential, 1),
|
||||
apartments_count=apartments,
|
||||
density=round(density, 3),
|
||||
parking_spaces=parking_spaces,
|
||||
)
|
||||
|
||||
|
||||
def place_program_variant(
|
||||
parcel: Parcel,
|
||||
payload: ConceptInput,
|
||||
*,
|
||||
market_price_per_sqm: float | None = None,
|
||||
price_source: str = "class_norm",
|
||||
) -> ConceptVariant | None:
|
||||
"""Stage 3a: построить ОДИН вариант из ``payload.building_program`` (типовые дома).
|
||||
|
||||
Раскладывает программу (:func:`place_program`), сводит размещённые пятна в ТЭП
|
||||
(:func:`_aggregate_program_teap` — точная GFA по группам этажности) и финмодель, и
|
||||
наклеивает честный сигнал частичного размещения (``placed_count``/``requested_count``).
|
||||
Возвращает ``None``, если ни одна секция не легла (участок не вмещает даже одну секцию
|
||||
программы) — вызывающий отбракует, как и в жадном пути.
|
||||
|
||||
``payload.building_program`` ДОЛЖЕН быть задан (вызывается только из program-ветки).
|
||||
"""
|
||||
program = payload.building_program
|
||||
if not program: # защитный инвариант: эту ветку зовут только при заданной программе
|
||||
raise ValueError("place_program_variant called without building_program")
|
||||
|
||||
placed = place_program(parcel, program)
|
||||
if placed.placed_count == 0:
|
||||
logger.warning(
|
||||
"program placed 0 of %d sections — участок не вмещает программу, отбраковка",
|
||||
placed.requested_count,
|
||||
)
|
||||
return None
|
||||
|
||||
teap_result = _aggregate_program_teap(
|
||||
placed.sections,
|
||||
site_area_sqm=parcel.site_area_sqm,
|
||||
housing_class=payload.housing_class,
|
||||
)
|
||||
financial_result = financial.compute_financial(
|
||||
teap=teap_result,
|
||||
housing_class=payload.housing_class,
|
||||
land_cost_rub=payload.land_cost_rub,
|
||||
market_price_per_sqm=market_price_per_sqm,
|
||||
price_source=price_source,
|
||||
development_type=payload.development_type,
|
||||
)
|
||||
buildings_geojson = _placed_program_to_geojson(parcel, placed.sections)
|
||||
|
||||
# Program-вариант репортуется под "balanced" (контракт strategy — фиксированный
|
||||
# Literal трёх стратегий 1b; program-режим не вводит новую стратегию, маркер режима
|
||||
# лежит в properties.strategy="program" каждой фичи GeoJSON). Один вариант на программу.
|
||||
return ConceptVariant(
|
||||
strategy="balanced",
|
||||
buildings_geojson=buildings_geojson,
|
||||
teap=teap_result,
|
||||
financial=financial_result,
|
||||
placed_count=placed.placed_count,
|
||||
requested_count=placed.requested_count,
|
||||
)
|
||||
|
||||
|
||||
def place_strategy(
|
||||
parcel: Parcel,
|
||||
payload: ConceptInput,
|
||||
|
|
@ -260,7 +544,31 @@ def place_all_strategies(
|
|||
|
||||
``market_price_per_sqm`` / ``price_source`` (рыночная калибровка цены жилья, PR-2)
|
||||
прокидываются неизменными в каждую стратегию — цена едина для участка.
|
||||
|
||||
Stage 3a (#1965): если задана ``payload.building_program`` — раскладываем РОВНО эту
|
||||
программу типовых домов (:func:`place_program_variant`, один вариант), а НЕ три жадные
|
||||
стратегии. ``building_program is None`` → существующий жадный путь без изменений.
|
||||
"""
|
||||
if payload.building_program:
|
||||
program_variant = place_program_variant(
|
||||
parcel,
|
||||
payload,
|
||||
market_price_per_sqm=market_price_per_sqm,
|
||||
price_source=price_source,
|
||||
)
|
||||
if program_variant is None:
|
||||
raise ParcelGeometryError(
|
||||
"программа застройки не вместила ни одной секции — "
|
||||
"участок слишком узкий/мелкий для выбранных типов домов"
|
||||
)
|
||||
logger.info(
|
||||
"placed program variant: %d of %d sections, %dкв",
|
||||
program_variant.placed_count,
|
||||
program_variant.requested_count,
|
||||
program_variant.teap.apartments_count,
|
||||
)
|
||||
return [program_variant]
|
||||
|
||||
variants = [
|
||||
variant
|
||||
for spec in _STRATEGIES
|
||||
|
|
@ -288,7 +596,10 @@ def place_all_strategies(
|
|||
|
||||
__all__ = [
|
||||
"FLOOR_HEIGHT_M",
|
||||
"PlacedProgram",
|
||||
"StrategySpec",
|
||||
"place_all_strategies",
|
||||
"place_program",
|
||||
"place_program_variant",
|
||||
"place_strategy",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -136,4 +136,76 @@ def compute_teap(
|
|||
return teap
|
||||
|
||||
|
||||
__all__ = ["HousingClass", "compute_teap"]
|
||||
def synthesize_teap_from_program(
|
||||
*,
|
||||
total_footprint_sqm: float,
|
||||
floors: int,
|
||||
site_area_sqm: float,
|
||||
housing_class: HousingClass,
|
||||
sections: int = 1,
|
||||
) -> TEAP:
|
||||
"""Свести АГРЕГИРОВАННУЮ массинг-программу (скалярное пятно × этажность) в :class:`TEAP`.
|
||||
|
||||
Stage 2a (#1965): LIVE-пересчёт экономики из интерактивного 3D-массинга. Фронтовый
|
||||
``computeModel`` отдаёт уже СВЁРНУТУЮ программу — суммарное пятно застройки (кв.м) и
|
||||
этажность, без покомпонентной геометрии секций. Здесь мы строим из этого тот же
|
||||
:class:`TEAP`, что и :func:`compute_teap`/``synthesize_teap_from_buildability``, чтобы
|
||||
прогнать его через готовый :func:`compute_financial`.
|
||||
|
||||
Зеркалит :func:`synthesize_teap_from_buildability` ОДИН-В-ОДИН, только источник
|
||||
площадей — СКАЛЯРНОЕ пятно, а не ``max_far × area``:
|
||||
|
||||
* ``built`` (пятно) = ``total_footprint_sqm`` (как есть, сумма по секциям).
|
||||
* ``GFA`` (total_floor) = ``total_footprint_sqm × floors``.
|
||||
* ``office`` (нежилое) = ``GFA × _OFFICE_SHARE_OF_GFA[class]`` — вырезается из GFA
|
||||
до расчёта жилой (жилая ужимается ровно на эту площадь, total GFA не меняется).
|
||||
* ``residential`` = ``(GFA − office) × _EFFICIENCY_BY_CLASS[class]``.
|
||||
* ``apartments`` = ``floor(residential / _AVG_APARTMENT_SQM[class])``.
|
||||
* ``parking`` = ``ceil(apartments × _PARKING_PER_APARTMENT[class])``.
|
||||
* ``density`` = ``GFA / site_area_sqm`` (FAR), защита от деления на ноль.
|
||||
|
||||
Чистая функция — без БД / LLM / внешних API. Те же нормативные константы, что и
|
||||
остальные синтезаторы (single source of truth, без новых магических чисел).
|
||||
|
||||
Args:
|
||||
total_footprint_sqm: суммарное пятно застройки всех секций, кв.м (скаляр).
|
||||
floors: этажность (общая для программы); вне диапазона → клампится к >= 0.
|
||||
site_area_sqm: площадь участка для плотности (FAR).
|
||||
housing_class: класс жилья — задаёт эффективность/средний лот/парковку/нежилое.
|
||||
sections: число секций — метаданные программы; на ТЭП НЕ влияет (площади уже
|
||||
свёрнуты во ``total_footprint_sqm``). Принимается для симметрии с контрактом
|
||||
фронта и будущей пер-секционной логики; здесь намеренно не используется.
|
||||
"""
|
||||
del sections # метаданные программы; площади уже агрегированы — на ТЭП не влияют
|
||||
built_area = max(0.0, total_footprint_sqm)
|
||||
total_floor_area = built_area * max(0, floors)
|
||||
|
||||
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA ДО расчёта жилой — точно как
|
||||
# compute_teap / synthesize_teap_from_buildability (single source of truth по долям).
|
||||
office_share = _OFFICE_SHARE_OF_GFA[housing_class]
|
||||
office_area = total_floor_area * office_share
|
||||
residential_gfa = total_floor_area - office_area
|
||||
|
||||
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
|
||||
residential_area = residential_gfa * efficiency
|
||||
|
||||
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
|
||||
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
|
||||
|
||||
density = total_floor_area / site_area_sqm if site_area_sqm > 0 else 0.0
|
||||
|
||||
parking_norm = _PARKING_PER_APARTMENT[housing_class]
|
||||
parking_spaces = math.ceil(apartments_count * parking_norm)
|
||||
|
||||
return TEAP(
|
||||
built_area_sqm=round(built_area, 1),
|
||||
total_floor_area_sqm=round(total_floor_area, 1),
|
||||
office_area_sqm=round(office_area, 1),
|
||||
residential_area_sqm=round(residential_area, 1),
|
||||
apartments_count=apartments_count,
|
||||
density=round(density, 3),
|
||||
parking_spaces=parking_spaces,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["HousingClass", "compute_teap", "synthesize_teap_from_program"]
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ from app.services.scrapers.documents import extract_documents, upsert_documents
|
|||
# obj_checks import temporarily disabled — endpoint /checks returns 404 (run #19).
|
||||
# Re-enable with _fetch_obj_checks_safe when endpoint is found (see TODO in Phase B/C).
|
||||
# from app.services.scrapers.obj_checks import extract_obj_checks, upsert_obj_checks
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -1142,7 +1142,7 @@ async def fetch_obj_checks(sess: BrowserSession, obj_id: int) -> tuple[Any, str]
|
|||
# ── _fetch_*_safe wrappers for asyncio.gather in Phase B/C ───────────────────
|
||||
# Каждый wrapper возвращает (kind, full_url, result_or_exception).
|
||||
# Exceptions НЕ raise — помещаются в возвращаемый tuple.
|
||||
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) bounds concurrency per-request.
|
||||
# BrowserSession._sem (size = per-instance concurrency, #1945 KN=2) bounds concurrency.
|
||||
|
||||
|
||||
async def _fetch_flats_safe(
|
||||
|
|
@ -1450,6 +1450,247 @@ async def download_photos(
|
|||
return locals_out, thumbs_out
|
||||
|
||||
|
||||
# ── result processing (shared by combined + isolated #1945 passes) ────────────
|
||||
|
||||
# #1945 isolated extras pass: после стольких подряд полностью-WAF объектов
|
||||
# обрываем пас (extras заблокированы целиком — нет смысла жечь ~1500 recycle).
|
||||
_EXTRAS_ABORT_AFTER_CONSEC_WAF = 5
|
||||
|
||||
# 5 doc-endpoint kinds, accumulated into a single per-object documents upsert.
|
||||
_DOC_KINDS = frozenset(
|
||||
(
|
||||
"doc_rpd",
|
||||
"doc_developer_report",
|
||||
"doc_project_documentation",
|
||||
"doc_documentation_other",
|
||||
"doc_permits",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _process_flats_result(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
obj_id: int,
|
||||
full_url: str,
|
||||
result: list[dict[str, Any]] | Exception,
|
||||
snapshot_date: date,
|
||||
region_code: int,
|
||||
) -> int:
|
||||
"""Upsert flats from a _fetch_flats_safe result. Returns flats inserted.
|
||||
|
||||
Errors → kn_scrape_failures (via _classify_and_log), 0 returned. Вынесено из
|
||||
Phase-B/C loop чтобы isolated-flats-pass (#1945) и combined-loop делили один код.
|
||||
"""
|
||||
if isinstance(result, Exception):
|
||||
_classify_and_log(db, run_id, obj_id, "flats", full_url, result)
|
||||
return 0
|
||||
if result:
|
||||
return upsert_flats(db, result, snapshot_date, region_code)
|
||||
return 0
|
||||
|
||||
|
||||
async def _process_extras_result(
|
||||
sess: BrowserSession,
|
||||
db: Session,
|
||||
run_id: int,
|
||||
obj_id: int,
|
||||
kind_tag: str,
|
||||
full_url: str,
|
||||
result: Any,
|
||||
snapshot_date: date,
|
||||
extras_counts: dict[str, int],
|
||||
all_docs: list[dict[str, Any]],
|
||||
*,
|
||||
download_photos_binary: bool,
|
||||
pdir: Path,
|
||||
) -> None:
|
||||
"""Handle one extras endpoint result (sale_graph/sales_agg/infra/photos/docs).
|
||||
|
||||
Mutates extras_counts / all_docs in place. Errors → kn_scrape_failures.
|
||||
Doc-kinds накапливаются в all_docs (единый upsert делает вызывающий код).
|
||||
Вынесено из loop чтобы combined- и isolated-extras-pass (#1945) делили код.
|
||||
"""
|
||||
if isinstance(result, Exception):
|
||||
_classify_and_log(db, run_id, obj_id, kind_tag, full_url, result)
|
||||
return
|
||||
|
||||
if kind_tag in ("sale_graph_apartments", "sale_graph_parking"):
|
||||
sg_type = kind_tag.replace("sale_graph_", "")
|
||||
rows_sg, _ = result
|
||||
extras_counts["sale_graph_rows"] += upsert_sale_graph(
|
||||
db, obj_id, sg_type, rows_sg, snapshot_date
|
||||
)
|
||||
elif kind_tag == "sales_agg":
|
||||
agg_data, _ = result
|
||||
extras_counts["sales_agg_rows"] += upsert_sales_agg(db, obj_id, agg_data, snapshot_date)
|
||||
elif kind_tag == "infrastructure":
|
||||
pois_data, _ = result
|
||||
extras_counts["infra_rows"] += upsert_infrastructure(db, obj_id, pois_data, snapshot_date)
|
||||
elif kind_tag == "photos":
|
||||
photos_data, _ = result
|
||||
local_paths: dict[str, str] = {}
|
||||
thumb_paths: dict[str, str] = {}
|
||||
if download_photos_binary and photos_data:
|
||||
local_paths, thumb_paths = await download_photos(sess, obj_id, photos_data, pdir)
|
||||
extras_counts["photos_downloaded"] += len(local_paths)
|
||||
extras_counts["photos_rows"] += upsert_photos(
|
||||
db, obj_id, photos_data, local_paths, thumb_paths
|
||||
)
|
||||
elif kind_tag in _DOC_KINDS:
|
||||
doc_items, _ = result
|
||||
all_docs.extend(extract_documents(doc_items or []))
|
||||
|
||||
|
||||
def _extras_coros(sess: BrowserSession, obj_id: int) -> list[Any]:
|
||||
"""The 10 extras-endpoint coroutines for one object (flats NOT included)."""
|
||||
return [
|
||||
_fetch_sale_graph_safe(sess, obj_id, "apartments"),
|
||||
_fetch_sale_graph_safe(sess, obj_id, "parking"),
|
||||
_fetch_sales_agg_safe(sess, obj_id),
|
||||
_fetch_infrastructure_safe(sess, obj_id),
|
||||
_fetch_photos_safe(sess, obj_id),
|
||||
_fetch_doc_rpd_safe(sess, obj_id),
|
||||
_fetch_doc_developer_report_safe(sess, obj_id),
|
||||
_fetch_doc_project_documentation_safe(sess, obj_id),
|
||||
_fetch_doc_documentation_other_safe(sess, obj_id),
|
||||
_fetch_doc_permits_safe(sess, obj_id),
|
||||
]
|
||||
|
||||
|
||||
def _is_waf_poisoned(results: list[tuple[str, str, Any]]) -> bool:
|
||||
"""Любой WafBlockedError среди результатов = сессия отравлена (cookies флагнуты).
|
||||
|
||||
После этого ВСЕ запросы на этой сессии (incl. следующего объекта) пойдут в 403,
|
||||
поэтому вызывающий код должен пересоздать BrowserSession перед продолжением.
|
||||
"""
|
||||
return any(isinstance(r, WafBlockedError) for _kind, _url, r in results)
|
||||
|
||||
|
||||
async def _run_extras_pass(
|
||||
*,
|
||||
db: Session,
|
||||
run_id: int,
|
||||
region_code: int,
|
||||
all_objects: list[dict[str, Any]],
|
||||
start_index: int,
|
||||
snapshot_date: date,
|
||||
extras_counts: dict[str, int],
|
||||
pdir: Path,
|
||||
download_photos_binary: bool,
|
||||
load_state: str | None,
|
||||
headed: bool,
|
||||
browser_concurrency: int | None,
|
||||
request_jitter_min_ms: int | None,
|
||||
request_jitter_max_ms: int | None,
|
||||
proxy_url: str | None,
|
||||
) -> int:
|
||||
"""#1945 isolated extras pass: extras в ОТДЕЛЬНОЙ BrowserSession от flats.
|
||||
|
||||
Ключ изоляции: на ЛЮБОЙ WafBlockedError (extras-403 травит cookies сессии)
|
||||
закрываем сессию и открываем НОВУЮ (свежие cookies/контекст) перед следующим
|
||||
объектом — яд не накапливается и НИКОГДА не касается flats (flats-сессия уже
|
||||
закрыта). Best-effort: каждый сбой → kn_scrape_failures, run не падает.
|
||||
|
||||
Возвращает суммарный request_count всех extras-сессий (для метрики run'а).
|
||||
"""
|
||||
total = len(all_objects)
|
||||
total_requests = 0
|
||||
log_progress(
|
||||
db, run_id, f"extras-пас (isolated): старт {total - start_index} объектов", stage="extras"
|
||||
)
|
||||
|
||||
def _new_session() -> BrowserSession:
|
||||
return BrowserSession(
|
||||
region_code=region_code,
|
||||
headed=headed,
|
||||
load_state=load_state,
|
||||
concurrency=browser_concurrency,
|
||||
jitter_min_ms=request_jitter_min_ms,
|
||||
jitter_max_ms=request_jitter_max_ms,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
|
||||
# extras сейчас мертвы (403 с 2026-06-03). Recycle на КАЖДЫЙ объект (полный
|
||||
# перезапуск браузера ~1500×) бессмыслен, если extras заблокированы целиком.
|
||||
# Поэтому считаем подряд идущие полностью-WAF объекты и обрываем пас после
|
||||
# порога — extras остаются best-effort opt-in, но не жгут час впустую.
|
||||
consecutive_all_waf = 0
|
||||
aborted = False
|
||||
sess = _new_session()
|
||||
await sess.__aenter__()
|
||||
try:
|
||||
await sess.warm_up()
|
||||
for i in range(start_index, total):
|
||||
obj = all_objects[i]
|
||||
obj_id = obj.get("objId") or obj.get("obj_id")
|
||||
if not obj_id:
|
||||
continue
|
||||
|
||||
results = await asyncio.gather(*_extras_coros(sess, obj_id), return_exceptions=False)
|
||||
all_docs: list[dict[str, Any]] = []
|
||||
for kind_tag, full_url, result in results:
|
||||
await _process_extras_result(
|
||||
sess,
|
||||
db,
|
||||
run_id,
|
||||
obj_id,
|
||||
kind_tag,
|
||||
full_url,
|
||||
result,
|
||||
snapshot_date,
|
||||
extras_counts,
|
||||
all_docs,
|
||||
download_photos_binary=download_photos_binary,
|
||||
pdir=pdir,
|
||||
)
|
||||
if all_docs:
|
||||
ins, _skip = upsert_documents(db, obj_id, all_docs)
|
||||
extras_counts["documents_rows"] += ins
|
||||
|
||||
poisoned = _is_waf_poisoned(results)
|
||||
# Полностью-WAF объект = ни один extras не отдал JSON (все 403).
|
||||
all_waf = all(isinstance(r, WafBlockedError) for _k, _u, r in results)
|
||||
consecutive_all_waf = consecutive_all_waf + 1 if all_waf else 0
|
||||
if consecutive_all_waf >= _EXTRAS_ABORT_AFTER_CONSEC_WAF:
|
||||
aborted = True
|
||||
log_progress(
|
||||
db,
|
||||
run_id,
|
||||
f"extras-пас оборван: {consecutive_all_waf} объектов подряд"
|
||||
" полностью WAF-403 (extras заблокированы целиком)",
|
||||
level="warn",
|
||||
stage="extras",
|
||||
)
|
||||
break
|
||||
|
||||
# RECYCLE: если сессия отравлена WAF-403 — пересоздаём перед next obj.
|
||||
if poisoned:
|
||||
total_requests += sess.request_count
|
||||
await sess.__aexit__(None, None, None)
|
||||
sess = _new_session()
|
||||
await sess.__aenter__()
|
||||
await sess.warm_up()
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
_checkpoint(db, run_id, i + 1)
|
||||
finally:
|
||||
total_requests += sess.request_count
|
||||
await sess.__aexit__(None, None, None)
|
||||
if aborted:
|
||||
logger.info("extras isolated pass aborted early — extras WAF-blocked wholesale")
|
||||
|
||||
log_progress(
|
||||
db,
|
||||
run_id,
|
||||
f"extras-пас done: sale_graph={extras_counts['sale_graph_rows']}"
|
||||
f" agg={extras_counts['sales_agg_rows']} infra={extras_counts['infra_rows']}"
|
||||
f" photos={extras_counts['photos_rows']} docs={extras_counts['documents_rows']}",
|
||||
stage="extras",
|
||||
)
|
||||
return total_requests
|
||||
|
||||
|
||||
# ── orchestrator ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -1660,9 +1901,25 @@ async def run_region_sweep(
|
|||
photos_dir: Path | str | None = None,
|
||||
place_override: str | None = None,
|
||||
resume_from_run_id: int | None = None,
|
||||
browser_concurrency: int | None = None,
|
||||
request_jitter_min_ms: int | None = None,
|
||||
request_jitter_max_ms: int | None = None,
|
||||
proxy_url: str | None = None,
|
||||
extras_isolated: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Full sweep for one region: bootstrap → fetch objects → per-object processing.
|
||||
|
||||
#1945 anti-ban: ``browser_concurrency`` / ``request_jitter_*_ms`` / ``proxy_url``
|
||||
прокидываются в BrowserSession sweep'а, чтобы троттлить/проксировать ТОЛЬКО KN-
|
||||
прогон (другие скраперы используют модульные дефолты stealth.py без изменений).
|
||||
None у каждого = дефолт stealth.py (concurrency=8, jitter 600–1500, без прокси).
|
||||
|
||||
``extras_isolated`` (#1945 ГЛАВНЫЙ unblock): True (дефолт) → flats тянутся в
|
||||
ЧИСТОЙ flats-only сессии (extras на ней НЕ дёргаются НИКОГДА → 403-яд extras не
|
||||
травит cookies flats-сессии → flats_count восстанавливается), а extras идут
|
||||
ОТДЕЛЬНЫМ best-effort проходом с recycle сессии на WAF-403. False → старое
|
||||
поведение (flats+extras в одной сессии, poison-prone).
|
||||
|
||||
Resume-aware: если `resume_from_run_id` задан, читаем objects_snapshot и
|
||||
progress_obj_index из исходного run и продолжаем с этого индекса. Новый
|
||||
run_id создаётся (с resumed_from_run_id ссылкой), исходный остаётся
|
||||
|
|
@ -1802,6 +2059,10 @@ async def run_region_sweep(
|
|||
headed=headed,
|
||||
load_state=load_state,
|
||||
save_state=save_state,
|
||||
concurrency=browser_concurrency,
|
||||
jitter_min_ms=request_jitter_min_ms,
|
||||
jitter_max_ms=request_jitter_max_ms,
|
||||
proxy_url=proxy_url,
|
||||
) as sess:
|
||||
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies
|
||||
# (___dmpkit___, domain_sid). Required since 2026-05-24 — without these
|
||||
|
|
@ -1881,9 +2142,16 @@ async def run_region_sweep(
|
|||
|
||||
# ── Phase B/C — per-object processing (resumable, parallel per-object) ─
|
||||
# Все endpoint'ы одного obj_id запускаются параллельно через asyncio.gather.
|
||||
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) ограничивает запросы.
|
||||
# BrowserSession._sem (size = browser_concurrency, #1945 троттлит KN до 2)
|
||||
# ограничивает запросы.
|
||||
# DB upserts выполняются последовательно после gather — один db Session
|
||||
# не thread-safe для параллельной записи.
|
||||
#
|
||||
# #1945 ИЗОЛЯЦИЯ: при extras_isolated=True эта (warm) сессия делает
|
||||
# ТОЛЬКО flats — extras на ней НЕ дёргаются (их 403-яд не травит cookies
|
||||
# flats-сессии → flats_count восстанавливается). extras уходят отдельным
|
||||
# best-effort проходом ниже (после закрытия flats-сессии) с recycle на
|
||||
# WAF-403. При extras_isolated=False — старое combined-поведение.
|
||||
pdir = Path(photos_dir) if photos_dir else PHOTOS_DIR_DEFAULT
|
||||
total = len(all_objects)
|
||||
for i in range(start_index, total):
|
||||
|
|
@ -1898,17 +2166,12 @@ async def run_region_sweep(
|
|||
if fetch_flats:
|
||||
coros.append(_fetch_flats_safe(sess, obj_id))
|
||||
|
||||
if extras:
|
||||
coros.append(_fetch_sale_graph_safe(sess, obj_id, "apartments"))
|
||||
coros.append(_fetch_sale_graph_safe(sess, obj_id, "parking"))
|
||||
coros.append(_fetch_sales_agg_safe(sess, obj_id))
|
||||
coros.append(_fetch_infrastructure_safe(sess, obj_id))
|
||||
coros.append(_fetch_photos_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_rpd_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_developer_report_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_project_documentation_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_documentation_other_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_permits_safe(sess, obj_id))
|
||||
# extras в combined-сессии ТОЛЬКО когда изоляция выключена —
|
||||
# иначе они отравят flats (#1945). В isolated-режиме extras
|
||||
# обрабатываются отдельным проходом ниже.
|
||||
combined_extras = extras and not extras_isolated
|
||||
if combined_extras:
|
||||
coros.extend(_extras_coros(sess, obj_id))
|
||||
# TODO: obj_checks endpoint not found at /api/object/{id}/checks (404).
|
||||
# 6 чек-боксов "Проверено на наш.дом.рф" вероятно inline в kn/object payload.
|
||||
# Re-enable после investigation структуры объекта (separate PR).
|
||||
|
|
@ -1922,64 +2185,28 @@ async def run_region_sweep(
|
|||
|
||||
# Sequential upsert — DB session не thread-safe
|
||||
all_docs: list[dict[str, Any]] = []
|
||||
_doc_kinds = frozenset(
|
||||
(
|
||||
"doc_rpd",
|
||||
"doc_developer_report",
|
||||
"doc_project_documentation",
|
||||
"doc_documentation_other",
|
||||
"doc_permits",
|
||||
)
|
||||
)
|
||||
for kind_tag, full_url, result in results:
|
||||
if isinstance(result, Exception):
|
||||
_classify_and_log(db, run_id, obj_id, kind_tag, full_url, result)
|
||||
continue
|
||||
|
||||
if kind_tag == "flats":
|
||||
flats_list: list[dict[str, Any]] = result # type: ignore[assignment]
|
||||
if flats_list:
|
||||
total_flats += upsert_flats(db, flats_list, snapshot_date, region_code)
|
||||
|
||||
elif kind_tag in ("sale_graph_apartments", "sale_graph_parking"):
|
||||
sg_type = kind_tag.replace("sale_graph_", "")
|
||||
rows_sg, _ = result # type: ignore[misc]
|
||||
extras_counts["sale_graph_rows"] += upsert_sale_graph(
|
||||
db, obj_id, sg_type, rows_sg, snapshot_date
|
||||
total_flats += _process_flats_result(
|
||||
db, run_id, obj_id, full_url, result, snapshot_date, region_code
|
||||
)
|
||||
else:
|
||||
await _process_extras_result(
|
||||
sess,
|
||||
db,
|
||||
run_id,
|
||||
obj_id,
|
||||
kind_tag,
|
||||
full_url,
|
||||
result,
|
||||
snapshot_date,
|
||||
extras_counts,
|
||||
all_docs,
|
||||
download_photos_binary=download_photos_binary,
|
||||
pdir=pdir,
|
||||
)
|
||||
|
||||
elif kind_tag == "sales_agg":
|
||||
agg_data, _ = result # type: ignore[misc]
|
||||
extras_counts["sales_agg_rows"] += upsert_sales_agg(
|
||||
db, obj_id, agg_data, snapshot_date
|
||||
)
|
||||
|
||||
elif kind_tag == "infrastructure":
|
||||
pois_data, _ = result # type: ignore[misc]
|
||||
extras_counts["infra_rows"] += upsert_infrastructure(
|
||||
db, obj_id, pois_data, snapshot_date
|
||||
)
|
||||
|
||||
elif kind_tag == "photos":
|
||||
photos_data, _ = result # type: ignore[misc]
|
||||
local_paths: dict[str, str] = {}
|
||||
thumb_paths: dict[str, str] = {}
|
||||
if download_photos_binary and photos_data:
|
||||
local_paths, thumb_paths = await download_photos(
|
||||
sess, obj_id, photos_data, pdir
|
||||
)
|
||||
extras_counts["photos_downloaded"] += len(local_paths)
|
||||
extras_counts["photos_rows"] += upsert_photos(
|
||||
db, obj_id, photos_data, local_paths, thumb_paths
|
||||
)
|
||||
|
||||
elif kind_tag in _doc_kinds:
|
||||
# Каждый из 5 doc-endpoint'ов отдаёт свой список документов.
|
||||
# Накапливаем в all_docs — единый upsert после цикла.
|
||||
doc_items, _ = result # type: ignore[misc]
|
||||
all_docs.extend(extract_documents(doc_items or []))
|
||||
|
||||
# Единый upsert всех документов объекта после обработки 5 endpoint'ов.
|
||||
# Единый upsert всех документов объекта после обработки doc-endpoint'ов.
|
||||
if all_docs:
|
||||
ins, _skip = upsert_documents(db, obj_id, all_docs)
|
||||
extras_counts["documents_rows"] += ins
|
||||
|
|
@ -1998,12 +2225,49 @@ async def run_region_sweep(
|
|||
f" downloaded={extras_counts['photos_downloaded']}"
|
||||
f" docs={extras_counts['documents_rows']}"
|
||||
f" checks={extras_counts['checks_rows']}",
|
||||
stage="extras" if extras else "fetch_flats",
|
||||
stage="extras" if combined_extras else "fetch_flats",
|
||||
)
|
||||
|
||||
_checkpoint(db, run_id, total)
|
||||
request_count = sess.request_count
|
||||
|
||||
# ── Phase C (isolated) — extras best-effort пас в ОТДЕЛЬНОЙ сессии ──────
|
||||
# flats-сессия закрыта. Теперь (только если extras=True И extras_isolated)
|
||||
# тянем extras в disposable-сессиях: на каждый WAF-403 recycle всю
|
||||
# BrowserSession (свежие cookies/контекст) перед следующим объектом, чтобы
|
||||
# яд НЕ накапливался. extras сейчас мертвы (403 с 2026-06-03) — пас целиком
|
||||
# best-effort: любые сбои уходят в kn_scrape_failures и НЕ роняют run.
|
||||
if extras and extras_isolated:
|
||||
try:
|
||||
ex_req = await _run_extras_pass(
|
||||
db=db,
|
||||
run_id=run_id,
|
||||
region_code=region_code,
|
||||
all_objects=all_objects,
|
||||
start_index=start_index,
|
||||
snapshot_date=snapshot_date,
|
||||
extras_counts=extras_counts,
|
||||
pdir=pdir,
|
||||
download_photos_binary=download_photos_binary,
|
||||
load_state=load_state,
|
||||
headed=headed,
|
||||
browser_concurrency=browser_concurrency,
|
||||
request_jitter_min_ms=request_jitter_min_ms,
|
||||
request_jitter_max_ms=request_jitter_max_ms,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
request_count += ex_req
|
||||
except Exception as e:
|
||||
# best-effort: extras-пас НИКОГДА не должен ронять уже собранные flats.
|
||||
logger.warning("extras isolated pass failed (best-effort): %s", e)
|
||||
log_progress(
|
||||
db,
|
||||
run_id,
|
||||
f"extras-пас (best-effort) упал: {type(e).__name__}: {str(e)[:200]}",
|
||||
level="warn",
|
||||
stage="extras",
|
||||
)
|
||||
|
||||
# ── Phase D — finalize ─────────────────────────────────────────────
|
||||
db.execute(
|
||||
text(
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import json
|
|||
import logging
|
||||
import random
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
||||
|
||||
|
|
@ -33,8 +33,45 @@ USER_AGENT = (
|
|||
|
||||
# Максимум одновременных in-page fetch() на одну BrowserSession;
|
||||
# поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant.
|
||||
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется catalog/nspd/прочими скраперами. KN-sweep с
|
||||
# 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
|
||||
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2.
|
||||
_BROWSER_CONCURRENCY = 8
|
||||
|
||||
# Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через
|
||||
# BrowserSession(jitter_min_ms=, jitter_max_ms=) против rate-бана (#1945).
|
||||
_DEFAULT_JITTER_MIN_MS = 600
|
||||
_DEFAULT_JITTER_MAX_MS = 1500
|
||||
|
||||
|
||||
def parse_proxy_url(proxy_url: str | None) -> dict[str, str] | None:
|
||||
"""Распарсить ``http://user:pass@host:port`` в Playwright proxy-dict.
|
||||
|
||||
Playwright ждёт ``{"server": "http://host:port", "username": ..., "password": ...}``
|
||||
(creds — отдельные ключи, НЕ в server-URL). Возвращает None для пустого/None
|
||||
входа → вызывающий код запускает Chromium без прокси (поведение по умолчанию).
|
||||
|
||||
Тот же URL-формат, что использует tradein-стек (SCRAPER_PROXY_URL).
|
||||
"""
|
||||
if not proxy_url:
|
||||
return None
|
||||
parts = urlsplit(proxy_url)
|
||||
if not parts.hostname:
|
||||
# НЕ эхо-им сырой proxy_url — он содержит пароль, а это исключение
|
||||
# всплывает в kn_scrape_runs.error / log_progress / Sentry (#1945 sec-review).
|
||||
raise ValueError(f"proxy URL без host (scheme={parts.scheme!r})")
|
||||
scheme = parts.scheme or "http"
|
||||
server = f"{scheme}://{parts.hostname}"
|
||||
if parts.port:
|
||||
server += f":{parts.port}"
|
||||
out: dict[str, str] = {"server": server}
|
||||
if parts.username:
|
||||
out["username"] = parts.username
|
||||
if parts.password:
|
||||
out["password"] = parts.password
|
||||
return out
|
||||
|
||||
|
||||
# Маппинг region_code → URL-сегмент города для реалистичного Referer.
|
||||
# Не обязан быть исчерпывающим — fallback на /новостройки/строящиеся/.
|
||||
REGION_LANDING_PATH = {
|
||||
|
|
@ -94,6 +131,10 @@ class BrowserSession:
|
|||
auth: str | None = "Basic MTpxd2U=",
|
||||
load_state: str | None = None,
|
||||
save_state: str | None = None,
|
||||
concurrency: int | None = None,
|
||||
jitter_min_ms: int | None = None,
|
||||
jitter_max_ms: int | None = None,
|
||||
proxy_url: str | None = None,
|
||||
) -> None:
|
||||
self.region_code = region_code
|
||||
self.headed = headed
|
||||
|
|
@ -105,11 +146,20 @@ class BrowserSession:
|
|||
# so it can be committed and reused on a server.
|
||||
self.load_state = load_state
|
||||
self.save_state = save_state
|
||||
# Per-instance concurrency (#1945): KN-sweep throttles to 2 against the
|
||||
# volume-ban; catalog/nspd callers pass nothing → keep the module default
|
||||
# _BROWSER_CONCURRENCY=8 (их поведение НЕ меняется).
|
||||
self._concurrency = concurrency if concurrency is not None else _BROWSER_CONCURRENCY
|
||||
# Per-instance inter-request jitter window (#1945). None → module default.
|
||||
self._jitter_min_ms = jitter_min_ms if jitter_min_ms is not None else _DEFAULT_JITTER_MIN_MS
|
||||
self._jitter_max_ms = jitter_max_ms if jitter_max_ms is not None else _DEFAULT_JITTER_MAX_MS
|
||||
# Optional rotating proxy (#1945). None → Chromium launches direct (default).
|
||||
self._proxy = parse_proxy_url(proxy_url)
|
||||
self._pw: Any = None
|
||||
self._browser: Browser | None = None
|
||||
self._context: BrowserContext | None = None
|
||||
self._page: Page | None = None
|
||||
self._sem = asyncio.Semaphore(_BROWSER_CONCURRENCY)
|
||||
self._sem = asyncio.Semaphore(self._concurrency)
|
||||
self._request_count = 0
|
||||
self._warmed_up = False
|
||||
|
||||
|
|
@ -127,12 +177,19 @@ class BrowserSession:
|
|||
landing_url = make_referer(self.region_code)
|
||||
logger.info("bootstrap: opening %s", landing_url)
|
||||
self._pw = await async_playwright().start()
|
||||
self._browser = await self._pw.chromium.launch(headless=not self.headed)
|
||||
launch_kwargs: dict[str, Any] = {"headless": not self.headed}
|
||||
ctx_kwargs: dict[str, Any] = {
|
||||
"user_agent": USER_AGENT,
|
||||
"locale": "ru-RU",
|
||||
"viewport": {"width": 1920, "height": 1080},
|
||||
}
|
||||
if self._proxy is not None:
|
||||
# Прокси задаётся и на launch, и на context (Playwright требует на
|
||||
# browser-level для Chromium; context-level дублирует для надёжности).
|
||||
launch_kwargs["proxy"] = self._proxy
|
||||
ctx_kwargs["proxy"] = self._proxy
|
||||
logger.info("bootstrap: routing Chromium through proxy %s", self._proxy["server"])
|
||||
self._browser = await self._pw.chromium.launch(**launch_kwargs)
|
||||
if self.load_state:
|
||||
ctx_kwargs["storage_state"] = self.load_state
|
||||
logger.info("bootstrap: loading saved storage_state from %s", self.load_state)
|
||||
|
|
@ -193,7 +250,7 @@ class BrowserSession:
|
|||
last_err: Exception | None = None
|
||||
for attempt in range(5):
|
||||
async with self._sem:
|
||||
await jitter_sleep()
|
||||
await jitter_sleep(self._jitter_min_ms, self._jitter_max_ms)
|
||||
try:
|
||||
self._request_count += 1
|
||||
result = await self._page.evaluate(_FETCH_JS, {"url": url, "auth": self.auth})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -491,28 +491,68 @@ _AVG_PRICE_SQL = text("""
|
|||
# COUNT(DISTINCT ol.objective_lot_id) — дедуп на уровне лота, fan-out-safe.
|
||||
# (_OBJECTIVE_PRICE_FALLBACK не прецедент: percentile/median dup-нечувствительны.)
|
||||
#
|
||||
# #1964: objective_lots — current-state UPSERT, но НЕ «ровно одна строка на лот».
|
||||
# UNIQUE = objective_lot_id, а Объектив за пере-листинги присваивает ОДНОМУ
|
||||
# физлоту (project,corpus,section,floor,lot) НЕСКОЛЬКО objective_lot_id → таблица
|
||||
# раздута ~2.91× → flats_sold завышался. Поэтому ОБЯЗАТЕЛЕН physflat-дедуп до
|
||||
# последнего снапшота на физлот (DISTINCT ON по physflat-ключу, snapshot_date DESC,
|
||||
# id DESC).
|
||||
#
|
||||
# #1964→hunt #1953: РАНЬШЕ дедуп брался из v_objective_lots_latest (JOIN ON
|
||||
# project_name). Регрессия: view материализует ВЕСЬ DISTINCT ON по 1.76M строк ДО
|
||||
# того, как применится фильтр маппинга (cm.domrf_obj_id = ANY) — qual не
|
||||
# проталкивается ниже DISTINCT ON view → Parallel Seq Scan + external sort всей
|
||||
# таблицы (~213 MB temp, ~6896 ms на каждый cold /analyze), хотя нужны лоты лишь
|
||||
# ~15 ЖК. FIX (зеркало #1964 inline-pushdown для analyze-консьюмеров +
|
||||
# market_metrics._STOCK_SQL): сперва фильтруем objective_complex_mapping в CTE
|
||||
# `mapped`, затем DISTINCT ON по СЫРОЙ objective_lots, JOIN ON project_name — так
|
||||
# objective_lots_project_idx (project_name, snapshot_date DESC) обслуживает фильтр
|
||||
# и дедуп материализуется ТОЛЬКО по лотам нужных ЖК. Прод-EXPLAIN: 6896 → ~437 ms
|
||||
# (15.7×), вывод побайтово идентичен (hunt: 7082=7082, 0 mismatch).
|
||||
#
|
||||
# COUNT(DISTINCT objective_lot_id) сохраняем (fan-out-safety из #1964): маппинг 1:N
|
||||
# по domrf_obj_id (UNIQUE = (objective_complex_name, objective_group), см. выше) при
|
||||
# fan-out задвоил бы строки, COUNT(*) был бы не fan-out-safe. DISTINCT ON даёт
|
||||
# physflat-дедуп, COUNT(DISTINCT) — fan-out-защиту по маппингу.
|
||||
#
|
||||
# ⚠ Читаем СЫРУЮ objective_lots inline, НЕ v_objective_lots_latest (см. выше).
|
||||
#
|
||||
# premise_kind='квартира' — как в _STOCK_SQL: без него проданные паркинги/
|
||||
# кладовые/нежилое считались бы как проданные квартиры (over-count).
|
||||
# objective_lots — current-state UPSERT (ON CONFLICT (objective_lot_id) DO
|
||||
# UPDATE, ровно одна строка на лот) → snapshot-дедуп НЕ нужен (как в _STOCK_SQL).
|
||||
#
|
||||
# Дефолт premise_kind зеркалит market_metrics.compute_market_metrics
|
||||
# (premise_kind="квартира") — оба считают продажи жилых лотов.
|
||||
_SOLD_PREMISE_KIND = "квартира"
|
||||
_SOLD_COUNT_SQL = text("""
|
||||
WITH mapped AS (
|
||||
SELECT cm.domrf_obj_id, cm.objective_complex_name
|
||||
FROM objective_complex_mapping cm
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
),
|
||||
latest AS (
|
||||
SELECT DISTINCT ON (
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
|
||||
)
|
||||
m.domrf_obj_id,
|
||||
ol.objective_lot_id,
|
||||
(
|
||||
ol.is_sold IS TRUE
|
||||
OR ol.contract_date IS NOT NULL
|
||||
OR LOWER(COALESCE(ol.status, '')) = 'продан'
|
||||
) AS sold_now
|
||||
FROM mapped m
|
||||
JOIN objective_lots ol
|
||||
ON ol.project_name = m.objective_complex_name
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
ORDER BY
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
||||
ol.snapshot_date DESC, ol.id DESC
|
||||
)
|
||||
SELECT
|
||||
cm.domrf_obj_id AS obj_id,
|
||||
COUNT(DISTINCT ol.objective_lot_id) FILTER (
|
||||
WHERE ol.is_sold IS TRUE
|
||||
OR ol.contract_date IS NOT NULL
|
||||
OR LOWER(COALESCE(ol.status, '')) = 'продан'
|
||||
) AS flats_sold
|
||||
FROM objective_complex_mapping cm
|
||||
JOIN objective_lots ol
|
||||
ON ol.project_name = cm.objective_complex_name
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND ol.premise_kind = :premise_kind
|
||||
GROUP BY cm.domrf_obj_id
|
||||
domrf_obj_id AS obj_id,
|
||||
COUNT(DISTINCT objective_lot_id) FILTER (WHERE sold_now) AS flats_sold
|
||||
FROM latest
|
||||
GROUP BY domrf_obj_id
|
||||
""")
|
||||
|
||||
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.
|
||||
|
|
|
|||
624
backend/app/services/site_finder/connection_capacity_lookup.py
Normal file
624
backend/app/services/site_finder/connection_capacity_lookup.py
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3 (#2119).
|
||||
|
||||
Собирает по участку:
|
||||
- power_points: центры питания (power_supply_centers) в радиусе от центроида
|
||||
участка (ST_DWithin geography) с классом напряжения / индексом загрузки /
|
||||
резервом свободной мощности. Для ПС ЕЭСК без раскрытого резерва — reserve_note
|
||||
«резерв по запросу в АО «ЕЭСК» (город)».
|
||||
- power_summary: ближайший ЦП с резервом + count по load_index.
|
||||
- water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level).
|
||||
- gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью
|
||||
(gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1.
|
||||
+ счётчики точек выхода (gas_grs_outlet_points, форма 6 ФАС №960 АО
|
||||
«Екатеринбурггаз») последнего period_month: всего / дефицитных / «нужен
|
||||
гидрорасчёт». #2119 B2 PR-4.
|
||||
- gas_outlet_points: гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от
|
||||
участка (ST_DWithin geom ~2000 м, latest period, LIMIT 40 по расстоянию) — для слоя
|
||||
на карте §3. Свободная мощность в млн.куб.м/МЕС (НЕ тыс.м³/ч как у ГРС!). #2119 B2.
|
||||
- heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves)
|
||||
за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2.
|
||||
- nearby_network_zones: ПОЗИТИВ-разрез — сетевые охранные зоны из cad_zouit в радиусе
|
||||
300 м от участка (там физически ПРОХОДИТ сеть → «теплотрасса в 24 м, врезка
|
||||
дешёвая»). До 2 ближайших зон НА КАЖДЫЙ net_kind (thermal/electricity/gas/…), с
|
||||
гуманизированной подписью (кад.номер трубопровода / напряжение ВЛ + ТП). #2119 C2/C3.
|
||||
|
||||
Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал
|
||||
fallback). Радиус по умолчанию 3000 м (cap 10000).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.site_finder.network_zone_humanize import human_label, refine_net_kind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_RADIUS_M = 3000
|
||||
_MAX_RADIUS_M = 10000
|
||||
|
||||
# Радиус позитив-разреза сетевых охранных зон (cad_zouit): фиксированный 300 м — «сеть
|
||||
# рядом с участком». Не зависит от radius_m ЦП (тот про магистральные ЦП за км).
|
||||
# Degree-эквивалент для GIST-префильтра: 300 м / ~60.8 км/° долготы у ЕКБ (56.8° N) ≈
|
||||
# 0.0050° — берём с запасом 0.006°, точный отсев делает geography ST_DWithin поверх. C2.
|
||||
_NETWORK_ZONE_RADIUS_M = 300
|
||||
_NETWORK_ZONE_RADIUS_DEG = 0.006
|
||||
# До скольких ближайших зон отдаём НА КАЖДЫЙ net_kind (thermal/electricity/gas/…). C2.
|
||||
_NETWORK_ZONE_PER_KIND = 2
|
||||
# Предохранитель: запрос GIST-индексирован (~70 мс на плотном центре ЕКБ, EXPLAIN прод
|
||||
# 2026-07-03), но эндпоинт синхронный — таймаут-guard как у offer_trend (parcels.py).
|
||||
_NETWORK_ZONE_TIMEOUT_MS = 4000
|
||||
|
||||
# Радиус для гео-точек выхода газосети (gas_grs_outlet_points): фиксированный ~2000 м —
|
||||
# точки привязаны к конкретным объектам (котельные/промплощадки), не к магистральным
|
||||
# ЦП, поэтому свой узкий радиус независимо от radius_m ЦП. LIMIT — топ-N по расстоянию,
|
||||
# чтобы слой на карте §3 не разбухал (у ~19% строк есть geom после геокода). #2119 B2.
|
||||
_GAS_OUTLET_RADIUS_M = 2000
|
||||
_GAS_OUTLET_LIMIT = 40
|
||||
|
||||
# Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная
|
||||
# мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1.
|
||||
_EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)"
|
||||
|
||||
|
||||
def get_connection_capacity(
|
||||
db: Session,
|
||||
cad_num: str,
|
||||
radius_m: int = _DEFAULT_RADIUS_M,
|
||||
) -> dict:
|
||||
"""Точки подключения со свободной мощностью вблизи участка.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session.
|
||||
cad_num: кадастровый номер участка.
|
||||
radius_m: радиус поиска ЦП, м (caller клампит 1..10000).
|
||||
|
||||
Returns:
|
||||
{"power_points": [...], "power_summary": {...}, "water": [...],
|
||||
"gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None,
|
||||
"nearby_network_zones": [...]}
|
||||
|
||||
Raises:
|
||||
ValueError: участок не найден в БД (нет geom).
|
||||
"""
|
||||
# Lazy import — избегаем циклического импорта на module-load.
|
||||
from app.services.site_finder.quarter_dump_lookup import _get_parcel_wkt
|
||||
|
||||
radius_m = max(1, min(int(radius_m), _MAX_RADIUS_M))
|
||||
|
||||
parcel_wkt = _get_parcel_wkt(db, cad_num)
|
||||
if parcel_wkt is None:
|
||||
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
||||
|
||||
power_points = _query_power_points(db, parcel_wkt, radius_m)
|
||||
power_summary = _build_power_summary(power_points)
|
||||
water = _query_water_latest(db)
|
||||
gas = _query_gas_city_grs(db)
|
||||
gas_outlet_points = _query_gas_outlet_points(db, parcel_wkt)
|
||||
heat = _query_heat_latest(db)
|
||||
nearby_network_zones = _query_nearby_network_zones(db, parcel_wkt)
|
||||
|
||||
return {
|
||||
"power_points": power_points,
|
||||
"power_summary": power_summary,
|
||||
"water": water,
|
||||
"gas": gas,
|
||||
"gas_outlet_points": gas_outlet_points,
|
||||
"heat": heat,
|
||||
"nearby_network_zones": nearby_network_zones,
|
||||
}
|
||||
|
||||
|
||||
def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]:
|
||||
"""Центры питания в радиусе от центроида участка (ST_DWithin geography)."""
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT sc_name, dzo_name, voltage_class, load_index,
|
||||
installed_capacity_mva, current_load_mva, reserve_mva,
|
||||
reserve_unit, district, reserve_asof,
|
||||
ST_Distance(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
) AS distance_m,
|
||||
ST_Y(geom) AS lat,
|
||||
ST_X(geom) AS lon
|
||||
FROM power_supply_centers
|
||||
WHERE geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
"""),
|
||||
{"wkt": parcel_wkt, "radius_m": radius_m},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
points: list[dict] = []
|
||||
for r in rows:
|
||||
reserve_mva = _num(r["reserve_mva"])
|
||||
points.append(
|
||||
{
|
||||
"name": r["sc_name"],
|
||||
"dzo_name": r["dzo_name"],
|
||||
"voltage_class": r["voltage_class"],
|
||||
"load_index": r["load_index"],
|
||||
"installed_capacity_mva": _num(r["installed_capacity_mva"]),
|
||||
"current_load_mva": _num(r["current_load_mva"]),
|
||||
"reserve_mva": reserve_mva,
|
||||
# Единица резерва: 'МВА' (Россети, default) или 'МВт' (ЕЭСК ПС 35-220).
|
||||
# district — адм. район ЕКБ из xlsx ЕЭСК (NULL у Россети-строк). #2119 B2.
|
||||
"reserve_unit": r["reserve_unit"],
|
||||
"district": r["district"],
|
||||
"reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None,
|
||||
"reserve_note": _reserve_note(r["dzo_name"], reserve_mva),
|
||||
"distance_m": round(float(r["distance_m"]), 1),
|
||||
"lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
|
||||
"lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
|
||||
}
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
def _reserve_note(dzo_name: str | None, reserve_mva: float | None) -> str | None:
|
||||
"""Пометка резерва для ПС ЕЭСК без опубликованного резерва (#2119 B1).
|
||||
|
||||
Для ПС АО «ЕЭСК» (городская сеть ЕКБ) свободная мощность не раскрывается в
|
||||
WFS/xlsx-источниках → reserve_mva IS NULL. Показываем честную пометку «резерв по
|
||||
запросу». Для ПС других ДЗО (или ЕЭСК с известным резервом) → None.
|
||||
"""
|
||||
if reserve_mva is not None:
|
||||
return None
|
||||
if dzo_name and "еэск" in dzo_name.lower():
|
||||
return _EESK_RESERVE_NOTE
|
||||
return None
|
||||
|
||||
|
||||
def _build_power_summary(points: list[dict]) -> dict:
|
||||
"""Сводка: ближайший ЦП с положительным резервом + count по load_index."""
|
||||
by_load_index: dict[str, int] = {}
|
||||
for p in points:
|
||||
li = p["load_index"] or "unknown"
|
||||
by_load_index[li] = by_load_index.get(li, 0) + 1
|
||||
|
||||
# points уже отсортированы по distance ASC → первый с reserve_mva>0 = ближайший.
|
||||
nearest_with_reserve = next(
|
||||
(p for p in points if p["reserve_mva"] is not None and p["reserve_mva"] > 0),
|
||||
None,
|
||||
)
|
||||
|
||||
return {
|
||||
"total_power_points": len(points),
|
||||
"by_load_index": by_load_index,
|
||||
"nearest_with_reserve": nearest_with_reserve,
|
||||
}
|
||||
|
||||
|
||||
def _query_water_latest(db: Session) -> list[dict]:
|
||||
"""Все строки water_supply_reserves за ПОСЛЕДНИЙ period ПО КАЖДОМУ system_kind.
|
||||
|
||||
«Последний» — max(period) лексикографически ('2026-Q1' < '2026-Q2' < …),
|
||||
что для формата 'YYYY-Qn' совпадает с хронологией. period IS NULL исключаем.
|
||||
MAX(period) берём per-system_kind (коррелированный подзапрос): Водоканал может
|
||||
опубликовать водоотведение на квартал позже водоснабжения — глобальный MAX
|
||||
молча выкинул бы отстающий вид целиком.
|
||||
"""
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day,
|
||||
w.note, w.period
|
||||
FROM water_supply_reserves w
|
||||
WHERE w.period IS NOT NULL
|
||||
AND w.period = (
|
||||
SELECT MAX(w2.period) FROM water_supply_reserves w2
|
||||
WHERE w2.period IS NOT NULL
|
||||
AND w2.system_kind = w.system_kind
|
||||
)
|
||||
ORDER BY w.system_kind, w.system_name
|
||||
""")
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"system_kind": r["system_kind"],
|
||||
"system_name": r["system_name"],
|
||||
"reserve_thousand_m3_day": _num(r["reserve_thousand_m3_day"]),
|
||||
"note": r["note"],
|
||||
"period": r["period"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _query_gas_city_grs(db: Session) -> dict:
|
||||
"""Городские ГРС ЕКБ со свободной мощностью (gas_grs_capacity), агрегация по ГРС.
|
||||
|
||||
Фильтр «городских» ГРС по имени (ILIKE '%свердловск%' / '%екатеринбург%').
|
||||
Агрегация по grs_name (у ГРС несколько строк-выходов): SUM(design), SUM(free),
|
||||
AVG(pct), MAX(upgrade_due), COUNT(*) outputs_count. Пустая/несуществующая таблица
|
||||
→ city_grs=[] (не падаем — блок аддитивный, gas_grs_capacity может ещё не
|
||||
существовать до применения миграции 181).
|
||||
|
||||
Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из
|
||||
gas_grs_outlet_points (форма 6 ФАС №960 АО «Екатеринбурггаз») за последний
|
||||
period_month — outlets_total / outlets_deficit (free<0) / outlets_needs_calc.
|
||||
См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС».
|
||||
|
||||
Returns: {"city_grs": [...], "total_free_th_m3_h": float|None,
|
||||
"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
|
||||
Размерности: свободная мощность ГРС — тыс. м³/ЧАС; свободная мощность точек выхода
|
||||
(в счётчиках/gas_outlet_points) — млн. м³/МЕС. НЕ путать при агрегации.
|
||||
"""
|
||||
outlet_counts = _query_gas_outlet_counts(db)
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT grs_name,
|
||||
SUM(design_capacity_th_m3_h) AS design_sum,
|
||||
SUM(free_capacity_th_m3_h) AS free_sum,
|
||||
AVG(free_capacity_pct) AS pct_avg,
|
||||
MAX(upgrade_due) AS upgrade_due,
|
||||
COUNT(*) AS outputs_count
|
||||
FROM gas_grs_capacity
|
||||
WHERE grs_name ILIKE '%свердловск%'
|
||||
OR grs_name ILIKE '%екатеринбург%'
|
||||
GROUP BY grs_name
|
||||
ORDER BY grs_name
|
||||
""")
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception as e:
|
||||
# Таблица может не существовать до применения миграции 181 (schema-first).
|
||||
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
|
||||
# точек выхода (миграция 184, независимая) — оставляем, что удалось собрать.
|
||||
logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e)
|
||||
return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts}
|
||||
|
||||
city_grs: list[dict] = []
|
||||
total_free = 0.0
|
||||
has_free = False
|
||||
for r in rows:
|
||||
free = _num(r["free_sum"])
|
||||
if free is not None:
|
||||
total_free += free
|
||||
has_free = True
|
||||
city_grs.append(
|
||||
{
|
||||
"grs_name": r["grs_name"],
|
||||
"design_capacity_th_m3_h": _num(r["design_sum"]),
|
||||
"free_capacity_th_m3_h": free,
|
||||
"free_capacity_pct": (
|
||||
round(float(r["pct_avg"]), 1) if r["pct_avg"] is not None else None
|
||||
),
|
||||
"upgrade_due": r["upgrade_due"],
|
||||
"outputs_count": int(r["outputs_count"]),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"city_grs": city_grs,
|
||||
"total_free_th_m3_h": round(total_free, 3) if has_free else None,
|
||||
**outlet_counts,
|
||||
}
|
||||
|
||||
|
||||
def _query_gas_outlet_counts(db: Session) -> dict:
|
||||
"""Счётчики точек выхода газосети (gas_grs_outlet_points) за последний period_month.
|
||||
|
||||
По ВСЕМ строкам последнего среза БЕЗ разбивки по ГРС (обоснование): grs_name_norm в
|
||||
форме 6 АО «Екатеринбурггаз» — это block-marker «Точка входа» (5 блоков ГТЕ вроде
|
||||
«грс-1 свердловск»), НЕ строгий ключ городской связки gas_grs_capacity (ГТЕ, другой
|
||||
источник). Матч outlet→ГРС по grs_name_norm без FK ненадёжен на per-набор уровне
|
||||
(имена ГРС в двух раскрытиях расходятся) → честнее агрегировать счётчики ПО ГОРОДУ
|
||||
целиком, чем молча терять строки на непопавшем join. Это city-level индикатор §3
|
||||
«сколько точек выхода в дефиците / требуют гидрорасчёта», не per-ГРС.
|
||||
|
||||
Считаем за MAX(period_month) (свежайший помесячный срез); is_forecast не фильтруем —
|
||||
в проде факт/прогноз разведены, но для счётчика важен объём точек последнего среза.
|
||||
|
||||
Returns: {"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
|
||||
- outlets_deficit — free_capacity_mln_m3 < 0 (дефицит со знаком, млн.куб.м/МЕС);
|
||||
- outlets_needs_calc — free_capacity_needs_calc = TRUE («0.000*», нужен гидрорасчёт).
|
||||
Таблицы может не быть (миграция 184 не применена) → все нули (graceful, аддитивно).
|
||||
"""
|
||||
try:
|
||||
row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) AS outlets_total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE free_capacity_mln_m3 < 0
|
||||
) AS outlets_deficit,
|
||||
COUNT(*) FILTER (
|
||||
WHERE free_capacity_needs_calc
|
||||
) AS outlets_needs_calc
|
||||
FROM gas_grs_outlet_points
|
||||
WHERE period_month = (
|
||||
SELECT MAX(period_month) FROM gas_grs_outlet_points
|
||||
)
|
||||
""")
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
except Exception as e:
|
||||
# Таблица может не существовать до применения миграции 184 (schema-first).
|
||||
# Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
|
||||
logger.warning("gas_grs_outlet_points недоступна (миграция 184 не применена?): %s", e)
|
||||
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
|
||||
|
||||
if row is None:
|
||||
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
|
||||
return {
|
||||
"outlets_total": int(row["outlets_total"] or 0),
|
||||
"outlets_deficit": int(row["outlets_deficit"] or 0),
|
||||
"outlets_needs_calc": int(row["outlets_needs_calc"] or 0),
|
||||
}
|
||||
|
||||
|
||||
def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
|
||||
"""Гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка — слой §3.
|
||||
|
||||
ST_DWithin(geom::geography, центроид::geography, _GAS_OUTLET_RADIUS_M) за последний
|
||||
period_month, ORDER BY distance ASC, LIMIT _GAS_OUTLET_LIMIT. geom есть лишь у ~19%
|
||||
строк (геокод DaData, PR-3) → в радиус попадает малое подмножество. Размерность
|
||||
free_capacity_mln_m3 — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Таблицы может не быть
|
||||
(миграция 184) → [] (graceful, блок аддитивный).
|
||||
|
||||
План (EXPLAIN прод 2026-07-03): Seq Scan ~13 мс — geography-каст НЕ использует
|
||||
geometry-GIST; при 1037 строках это норм. Вырастет 10-50× → функциональный GIST
|
||||
по (CAST(geom AS geography)) или ST_DWithin по geometry с градусным радиусом.
|
||||
|
||||
Returns: [{outlet_name, consumer_type, free_capacity_mln_m3, needs_calc,
|
||||
distance_m, lat, lon}] — отсортировано по distance ASC.
|
||||
"""
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
|
||||
free_capacity_needs_calc AS needs_calc,
|
||||
ST_Distance(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
) AS distance_m,
|
||||
ST_Y(geom) AS lat,
|
||||
ST_X(geom) AS lon
|
||||
FROM gas_grs_outlet_points
|
||||
WHERE geom IS NOT NULL
|
||||
AND period_month = (
|
||||
SELECT MAX(period_month) FROM gas_grs_outlet_points
|
||||
)
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT CAST(:limit AS int)
|
||||
"""),
|
||||
{
|
||||
"wkt": parcel_wkt,
|
||||
"radius_m": _GAS_OUTLET_RADIUS_M,
|
||||
"limit": _GAS_OUTLET_LIMIT,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception as e:
|
||||
# Таблица может не существовать до применения миграции 184 (schema-first).
|
||||
# Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
|
||||
logger.warning("gas_grs_outlet_points (радиус) недоступна (миграция 184?): %s", e)
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"outlet_name": r["outlet_name"],
|
||||
"consumer_type": r["consumer_type"],
|
||||
# млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!); может быть <0 — дефицит со знаком.
|
||||
"free_capacity_mln_m3": _num(r["free_capacity_mln_m3"]),
|
||||
"needs_calc": bool(r["needs_calc"]),
|
||||
"distance_m": round(float(r["distance_m"]), 1),
|
||||
"lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
|
||||
"lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _query_heat_latest(db: Session) -> dict:
|
||||
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.
|
||||
|
||||
Строки heat_system_reserves за MAX(period) PER org (коррелированный подзапрос —
|
||||
как в _query_water_latest: одна организация может опубликовать свежий квартал
|
||||
раньше другой, глобальный MAX молча выкинул бы отстающую). period IS NULL
|
||||
исключаем; MAX(period) лексикографически совпадает с хронологией для 'YYYY-Qn'.
|
||||
Сортировка org, system_name. Graceful при отсутствии таблицы (как gas-блок).
|
||||
|
||||
Returns: {"systems": [{org, system_name, reserve_gcal_h, period}],
|
||||
"total_reserve_gcal_h": float|None}.
|
||||
"""
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
|
||||
FROM heat_system_reserves h
|
||||
WHERE h.period IS NOT NULL
|
||||
AND h.period = (
|
||||
SELECT MAX(h2.period) FROM heat_system_reserves h2
|
||||
WHERE h2.period IS NOT NULL
|
||||
AND h2.org = h.org
|
||||
)
|
||||
ORDER BY h.org, h.system_name
|
||||
""")
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception as e:
|
||||
# Таблица может не существовать до применения миграции 182 (schema-first).
|
||||
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
|
||||
logger.warning("heat_system_reserves недоступна (миграция 182 не применена?): %s", e)
|
||||
return {"systems": [], "total_reserve_gcal_h": None}
|
||||
|
||||
systems: list[dict] = []
|
||||
total_reserve = 0.0
|
||||
has_reserve = False
|
||||
for r in rows:
|
||||
reserve = _num(r["reserve_gcal_h"])
|
||||
if reserve is not None:
|
||||
total_reserve += reserve
|
||||
has_reserve = True
|
||||
systems.append(
|
||||
{
|
||||
"org": r["org"],
|
||||
"system_name": r["system_name"],
|
||||
"reserve_gcal_h": reserve,
|
||||
"period": r["period"],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"systems": systems,
|
||||
"total_reserve_gcal_h": round(total_reserve, 3) if has_reserve else None,
|
||||
}
|
||||
|
||||
|
||||
def _query_nearby_network_zones(db: Session, parcel_wkt: str) -> list[dict]:
|
||||
"""Сетевые охранные зоны cad_zouit в радиусе 300 м от участка — позитив-развёртка §3.
|
||||
|
||||
Охранная зона сети = коридор, где физически ПРОХОДИТ сеть. «Теплотрасса в 24 м →
|
||||
врезка дешёвая» — решающий сигнал для финдиректора (особенно heat, у которого в §3
|
||||
нет своей гео-привязки). Зоны, ПЕРЕСЕКАЮЩИЕ участок, включаем с distance_m=0.
|
||||
|
||||
План (EXPLAIN прод 2026-07-03, cad_zouit_geom_gist существует): GIST-префильтр
|
||||
``ST_DWithin(geom, parcel, 0.006°)`` + точный ``ST_DWithin(geom::geography,
|
||||
parcel::geography, 300)`` — Index Scan, ~70 мс на плотном центре ЕКБ. cad_zouit.geom
|
||||
= geometry(MultiPolygon,4326); geography-каст приклеен к ``)``, не к bind-имени
|
||||
(psycopg3-safe, как ``ARRAY[...]::int[]`` / power_points-запрос).
|
||||
|
||||
#1957: одна физическая зона хранится 2× (одинаковый reg_numb_border, разный
|
||||
category_name) → DISTINCT ON (reg_numb_border) схлопывает, оставляя ближайшую строку.
|
||||
|
||||
Гуманизация (C3, ``network_zone_humanize``): net_kind дочищается по name_by_doc
|
||||
(general «инженерные коммуникации» на проде часто РЕАЛЬНО теплосеть/газопровод),
|
||||
human_label вытаскивает кад.номер трубопровода / напряжение ВЛ + ТП. Итог — до
|
||||
_NETWORK_ZONE_PER_KIND ближайших зон НА КАЖДЫЙ net_kind (сорт по distance ASC).
|
||||
|
||||
Таймаут-guard в savepoint: запрос лёгкий, но эндпоинт синхронный — при аномально
|
||||
медленном скане (QueryCanceled) деградируем в [], не роняя весь эндпоинт. Блок
|
||||
аддитивный (позитив-сигнал), отсутствие данных читается как «сеть рядом не найдена».
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
# SET не принимает bind-параметры (extended protocol → syntax error
|
||||
# at or near "$1") — только литерал, как у offer_trend (parcels.py).
|
||||
db.execute(text(f"SET LOCAL statement_timeout = '{_NETWORK_ZONE_TIMEOUT_MS:d}'"))
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT ON (z.reg_numb_border)
|
||||
z.type_zone,
|
||||
z.name_by_doc,
|
||||
z.reg_numb_border,
|
||||
round(
|
||||
ST_Distance(
|
||||
z.geom::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography
|
||||
)::numeric
|
||||
) AS distance_m
|
||||
FROM cad_zouit z
|
||||
WHERE z.geom IS NOT NULL
|
||||
AND z.type_zone IN (
|
||||
'Охранная зона тепловых сетей',
|
||||
'Охранная зона инженерных коммуникаций',
|
||||
'Охранная зона объектов электроэнергетики'
|
||||
' (объектов электросетевого хозяйства и объектов по'
|
||||
' производству электрической энергии)',
|
||||
'Охранная зона трубопроводов (газопроводов,'
|
||||
' нефтепроводов и нефтепродуктопроводов, аммиакопроводов)',
|
||||
'Охранная зона линий и сооружений связи'
|
||||
)
|
||||
AND ST_DWithin(
|
||||
z.geom,
|
||||
ST_GeomFromText(:wkt, 4326),
|
||||
CAST(:radius_deg AS float)
|
||||
)
|
||||
AND ST_DWithin(
|
||||
z.geom::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
ORDER BY z.reg_numb_border, distance_m ASC
|
||||
"""),
|
||||
{
|
||||
"wkt": parcel_wkt,
|
||||
"radius_deg": _NETWORK_ZONE_RADIUS_DEG,
|
||||
"radius_m": _NETWORK_ZONE_RADIUS_M,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
# SET LOCAL переживает RELEASE SAVEPOINT (transaction-scoped) —
|
||||
# сбрасываем явно, как в offer_trend (parcels.py), чтобы таймаут
|
||||
# не утёк на последующие запросы этой транзакции.
|
||||
db.execute(text("SET LOCAL statement_timeout = 0"))
|
||||
except SQLAlchemyError as e:
|
||||
# QueryCanceled (таймаут) / отсутствие таблицы / прочие SQL-сбои — блок аддитивный
|
||||
# (позитив-сигнал), деградируем в пустой, не роняя весь эндпоинт connection-capacity.
|
||||
logger.warning("nearby_network_zones query failed: %s", e)
|
||||
return []
|
||||
|
||||
return _shape_network_zones(rows)
|
||||
|
||||
|
||||
def _shape_network_zones(rows: list) -> list[dict]:
|
||||
"""DISTINCT-строки cad_zouit → гуманизированные зоны, до N ближайших per net_kind.
|
||||
|
||||
Классификацию net_kind (refine) и подпись (human_label) считаем в Python (regex по
|
||||
name_by_doc), затем сортируем по distance ASC и берём _NETWORK_ZONE_PER_KIND на вид.
|
||||
Возвращаемый список отсортирован по (distance_m, net_kind) для стабильного порядка.
|
||||
"""
|
||||
per_kind: dict[str, list[dict]] = {}
|
||||
for r in rows:
|
||||
type_zone = r["type_zone"] or ""
|
||||
name_by_doc = r["name_by_doc"]
|
||||
net_kind = refine_net_kind(type_zone, name_by_doc)
|
||||
if net_kind is None:
|
||||
# type_zone из белого списка, но не классифицировался — пропускаем (не должно).
|
||||
continue
|
||||
distance_m = int(r["distance_m"]) if r["distance_m"] is not None else 0
|
||||
per_kind.setdefault(net_kind, []).append(
|
||||
{
|
||||
"net_kind": net_kind,
|
||||
"distance_m": distance_m,
|
||||
"name_by_doc": name_by_doc,
|
||||
"human_label": human_label(net_kind, name_by_doc),
|
||||
}
|
||||
)
|
||||
|
||||
result: list[dict] = []
|
||||
for kind_rows in per_kind.values():
|
||||
kind_rows.sort(key=lambda z: z["distance_m"])
|
||||
result.extend(kind_rows[:_NETWORK_ZONE_PER_KIND])
|
||||
# Стабильный внешний порядок: ближайшее сверху, затем по виду сети.
|
||||
result.sort(key=lambda z: (z["distance_m"], z["net_kind"]))
|
||||
return result
|
||||
|
||||
|
||||
def _num(value: object) -> float | None:
|
||||
"""NUMERIC (Decimal) из БД → float | None."""
|
||||
return float(value) if value is not None else None # type: ignore[arg-type]
|
||||
432
backend/app/services/site_finder/eesk_reserve_loader.py
Normal file
432
backend/app/services/site_finder/eesk_reserve_loader.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
"""Загрузчик резервов свободной мощности АО «ЕЭСК» из xlsx-раскрытия (#2119 B2).
|
||||
|
||||
Хаб eesk.ru «Наличие объёма свободной для технологического присоединения
|
||||
мощности» — статический HTML со ссылками на xlsx двух серий:
|
||||
(а) ПС 35-220 кВ («резерв мощности по центрам питания», текст ссылки БЕЗ «ниже»
|
||||
/ содержит «35-220») → апдейт ``power_supply_centers`` (резерв в МВт!);
|
||||
(б) ТП/РП «ниже 35» кВ (текст ссылки содержит «ниже 35»)
|
||||
→ UPSERT ``power_tp_rp_reserves``.
|
||||
|
||||
Свежайший файл каждой серии выбирается по max(YYYY, N-квартал) из ТЕКСТА ссылки
|
||||
(«…N квартал(а) YYYY…»). period='YYYY-QN', reserve_asof = последний день квартала.
|
||||
|
||||
Источник — обычный SSL, БЕЗ гео-блока, но лоадер всё равно RUN-ON-PROD рядом с
|
||||
rosseti/gas/vodokanal (единый weekly-воркер). httpx с явным таймаутом, per-row
|
||||
SAVEPOINT при UPSERT (битая строка не валит батч).
|
||||
|
||||
Числовой парс/дата — переиспользуют ``parse_reserve_number`` / ``parse_asof_date``
|
||||
из ``rosseti_reserve_loader``; матч ЦП — общий ``normalize_sc_name``.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.site_finder.rosseti_reserve_loader import (
|
||||
parse_asof_date, # noqa: F401 — переэкспорт для соседей/тестов симметрии
|
||||
parse_reserve_number,
|
||||
)
|
||||
from app.services.site_finder.rosseti_wfs_loader import normalize_sc_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Хаб раскрытия (статический HTML). ЕЭСК — латиница, punycode не требуется.
|
||||
HUB_URL = (
|
||||
"https://www.eesk.ru/Raskritie_informacii/standarty_raskrytia_info/"
|
||||
"Ob_osnovnih_potrebitelskih_harakteristik/"
|
||||
"Informacija_o_tehnicheskom_sostojanii_se/"
|
||||
"Nalichie_obema_svobodnoj_dlja_tehnologic"
|
||||
)
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# Ссылки на xlsx-файлы в загрузках сайта: /upload/site1/document_file/<hash>.xls[x].
|
||||
# Захватываем href + видимый ТЕКСТ ссылки (в тексте — серия и квартал/год).
|
||||
_LINK_RE = re.compile(
|
||||
r'<a\b[^>]*href=["\'](?P<href>[^"\']*/upload/site1/document_file/'
|
||||
r'[^"\']+?\.xlsx?)["\'][^>]*>(?P<text>.*?)</a>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# «N квартал[а] YYYY» в тексте ссылки → (N, YYYY). Слово «квартал» в любой форме.
|
||||
_QUARTER_RE = re.compile(r"(\d)\s*квартал\w*\s*(\d{4})", re.IGNORECASE)
|
||||
|
||||
_BASE = "https://www.eesk.ru"
|
||||
|
||||
# Последний день квартала (месяц, день) по номеру квартала.
|
||||
_QUARTER_LAST_DAY: dict[int, tuple[int, int]] = {
|
||||
1: (3, 31),
|
||||
2: (6, 30),
|
||||
3: (9, 30),
|
||||
4: (12, 31),
|
||||
}
|
||||
|
||||
|
||||
def _strip_tags(html_fragment: str) -> str:
|
||||
"""Убирает html-теги/entity-мусор из текста ссылки → плоская строка."""
|
||||
text_only = re.sub(r"<[^>]+>", " ", html_fragment)
|
||||
text_only = text_only.replace(" ", " ").replace("\xa0", " ")
|
||||
return re.sub(r"\s+", " ", text_only).strip()
|
||||
|
||||
|
||||
def _classify_series(link_text_low: str) -> str | None:
|
||||
"""Классифицирует ссылку по её тексту: 'ps_35_220' | 'tp_rp_sub35' | None.
|
||||
|
||||
Различаем серии ТОЛЬКО по тексту ссылки:
|
||||
(a) ПС 35-220 — содержит «35-220» ИЛИ «резерв мощности по центрам питания»,
|
||||
при этом НЕ содержит «ниже» (иначе это серия б);
|
||||
(b) ТП/РП <35 — содержит «ниже 35».
|
||||
"""
|
||||
has_below = "ниже 35" in link_text_low or "ниже35" in link_text_low
|
||||
if has_below:
|
||||
return "tp_rp_sub35"
|
||||
if "35-220" in link_text_low or "по центрам питания" in link_text_low:
|
||||
return "ps_35_220"
|
||||
return None
|
||||
|
||||
|
||||
def _quarter_from_text(link_text: str) -> tuple[int, int] | None:
|
||||
"""Извлекает (year, quarter) из текста ссылки «…N квартал(а) YYYY…». Нет → None."""
|
||||
m = _QUARTER_RE.search(link_text)
|
||||
if not m:
|
||||
return None
|
||||
quarter = int(m.group(1))
|
||||
year = int(m.group(2))
|
||||
if quarter not in _QUARTER_LAST_DAY:
|
||||
return None
|
||||
return (year, quarter)
|
||||
|
||||
|
||||
def quarter_period(year: int, quarter: int) -> str:
|
||||
"""(2026, 2) → '2026-Q2'."""
|
||||
return f"{year}-Q{quarter}"
|
||||
|
||||
|
||||
def quarter_last_day(year: int, quarter: int) -> date:
|
||||
"""Последний календарный день квартала → date (для reserve_asof)."""
|
||||
month, day = _QUARTER_LAST_DAY[quarter]
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
def fetch_hub_links() -> dict[str, dict]:
|
||||
"""Скрейпит хаб → СВЕЖАЙШИЙ xlsx каждой серии (ps_35_220 + tp_rp_sub35).
|
||||
|
||||
RUN-ON-PROD. GET с follow_redirects, regex по <a href …upload…xlsx>ТЕКСТ</a>,
|
||||
классификация серии + квартал по ТЕКСТУ ссылки, выбор max(year, quarter).
|
||||
|
||||
Returns: {series: {"url": str, "year": int, "quarter": int}} — только найденные.
|
||||
"""
|
||||
resp = httpx.get(HUB_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return select_freshest_links(resp.text)
|
||||
|
||||
|
||||
def select_freshest_links(html: str) -> dict[str, dict]:
|
||||
"""Из HTML-хаба выбирает свежайшую ссылку каждой серии (чистая, тестируемая).
|
||||
|
||||
Свежайший = max((year, quarter)) среди ссылок одной серии. Ссылки без
|
||||
распознанного квартала игнорируются (нельзя датировать).
|
||||
"""
|
||||
best: dict[str, dict] = {}
|
||||
n_links = 0
|
||||
for m in _LINK_RE.finditer(html):
|
||||
n_links += 1
|
||||
link_text = _strip_tags(m.group("text"))
|
||||
series = _classify_series(link_text.lower())
|
||||
if series is None:
|
||||
continue
|
||||
yq = _quarter_from_text(link_text)
|
||||
if yq is None:
|
||||
continue
|
||||
year, quarter = yq
|
||||
href = m.group("href")
|
||||
url = href if href.startswith("http") else _BASE + href
|
||||
prev = best.get(series)
|
||||
if prev is None or (year, quarter) > (prev["year"], prev["quarter"]):
|
||||
best[series] = {"url": url, "year": year, "quarter": quarter}
|
||||
|
||||
logger.info(
|
||||
"eesk hub links: ps_35_220=%r tp_rp_sub35=%r (from %d xlsx links)",
|
||||
best.get("ps_35_220"),
|
||||
best.get("tp_rp_sub35"),
|
||||
n_links,
|
||||
)
|
||||
return best
|
||||
|
||||
|
||||
def _cell(row: tuple, idx: int) -> object:
|
||||
"""Безопасно достаёт ячейку row по 0-based индексу (None если за границей)."""
|
||||
return row[idx] if idx < len(row) else None
|
||||
|
||||
|
||||
def _pct_share_to_percent(value: object) -> float | None:
|
||||
"""Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем.
|
||||
|
||||
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах.
|
||||
None/мусор → None.
|
||||
"""
|
||||
num = parse_reserve_number(value)
|
||||
if num is None:
|
||||
return None
|
||||
# Доля 0..1 → *100. Значения >1 считаем уже процентами (защита от смешанного ввода).
|
||||
return round(num * 100.0, 2) if -1.0 <= num <= 1.0 else num
|
||||
|
||||
|
||||
def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> dict[str, int]:
|
||||
"""Апдейт power_supply_centers резервом ЕЭСК ПС 35-220 из xlsx.
|
||||
|
||||
ВЕРИФИЦИРОВАННАЯ структура (лист ``Лист1``, снята с живого файла): шапка строка
|
||||
4 (1-based), данные строки 7-63 и 65-83, между секциями строки-разделители.
|
||||
Скипаем строки где колонка C (имя ПС) пуста или D (мощность) не парсится числом.
|
||||
Колонки 0-based: A=№, B=адм. район, C=имя ПС, D=макс полная мощность МВА,
|
||||
E=% загрузки ДОЛЯ (0.41→41.0), F=степень МУ250 (в raw), G=свободная МВт.
|
||||
|
||||
Матч к power_supply_centers по normalize_sc_name(C) AND dzo_name ILIKE '%ЕЭСК%'.
|
||||
reserve_mva=G, reserve_unit='МВт', installed_capacity_mva=D, district=B,
|
||||
capacity_source='eesk_35_220', reserve_asof. Unmatched имена — warning-лог.
|
||||
|
||||
Returns: счётчики rows/matched/unmatched.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb["Лист1"] if "Лист1" in wb.sheetnames else wb.active
|
||||
|
||||
rows_seen = 0
|
||||
matched = 0
|
||||
unmatched = 0
|
||||
unmatched_names: list[str] = []
|
||||
|
||||
try:
|
||||
# min_row=7 (1-based) → данные с 7-й строки; секции 7-63 и 65-83 идут подряд,
|
||||
# строки-разделители отсеиваются проверкой C пусто / D не число.
|
||||
for row in ws.iter_rows(min_row=7, values_only=True):
|
||||
sc_name = _cell(row, 2) # C
|
||||
installed = parse_reserve_number(_cell(row, 3)) # D
|
||||
# Строка-разделитель между секциями: нет имени ПС ИЛИ мощность не число.
|
||||
if sc_name is None or not str(sc_name).strip() or installed is None:
|
||||
continue
|
||||
rows_seen += 1
|
||||
|
||||
district = _cell(row, 1) # B
|
||||
load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %)
|
||||
reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт)
|
||||
name_norm = normalize_sc_name(str(sc_name))
|
||||
|
||||
params = {
|
||||
"installed": installed,
|
||||
"reserve": reserve,
|
||||
"asof": reserve_asof,
|
||||
"district": str(district).strip() if district else None,
|
||||
"load_pct": load_pct,
|
||||
"name_norm": name_norm,
|
||||
}
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
res = db.execute(
|
||||
text("""
|
||||
UPDATE power_supply_centers
|
||||
SET reserve_mva = :reserve,
|
||||
reserve_unit = 'МВт',
|
||||
installed_capacity_mva = :installed,
|
||||
district = :district,
|
||||
load_index = COALESCE(
|
||||
load_index,
|
||||
CAST(:load_pct AS text)
|
||||
),
|
||||
capacity_source = 'eesk_35_220',
|
||||
reserve_asof = :asof
|
||||
WHERE sc_name_norm = :name_norm
|
||||
AND dzo_name ILIKE '%ЕЭСК%'
|
||||
"""),
|
||||
params,
|
||||
)
|
||||
if res.rowcount and res.rowcount > 0:
|
||||
matched += res.rowcount
|
||||
else:
|
||||
unmatched += 1
|
||||
if len(unmatched_names) < 50:
|
||||
unmatched_names.append(str(sc_name).strip())
|
||||
except Exception as e:
|
||||
logger.warning("eesk ps_35_220 update failed for %r: %s", sc_name, e)
|
||||
unmatched += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_ps_35_220: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
if unmatched_names:
|
||||
logger.warning(
|
||||
"eesk ps_35_220 unmatched (%d) sc_name список: %s",
|
||||
unmatched,
|
||||
unmatched_names,
|
||||
)
|
||||
logger.info(
|
||||
"load_ps_35_220 done: rows=%d matched=%d unmatched=%d",
|
||||
rows_seen,
|
||||
matched,
|
||||
unmatched,
|
||||
)
|
||||
return {"rows": rows_seen, "matched": matched, "unmatched": unmatched}
|
||||
|
||||
|
||||
def load_tp_rp_sub35(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> dict[str, int]:
|
||||
"""UPSERT резервов ЕЭСК ТП/РП «ниже 35» кВ в power_tp_rp_reserves из xlsx.
|
||||
|
||||
ВЕРИФИЦИРОВАННАЯ структура (лист ``Публикация``): строка 1 — title, строка 3 —
|
||||
шапка, ДАННЫЕ С СТРОКИ 4 (~2014 строк). Колонки 0-based: A=номер ТП/РП
|
||||
(«10003»), B=предельно допустимая загрузка МВт, C=загрузка по 4 замерам МВт,
|
||||
D=текущий резерв МВт.
|
||||
|
||||
UPSERT: name=«ТП/РП <A>», name_norm=str(A).strip().lower(),
|
||||
municipality='Екатеринбург', voltage_kv=NULL, installed=B, current=C,
|
||||
reserve=D, reserve_asof. ON CONFLICT (name_norm, municipality, voltage_kv) DO
|
||||
UPDATE. БЕЗ кВА-санитайза. Per-row SAVEPOINT.
|
||||
|
||||
Returns: счётчики rows/inserted/updated/skipped.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb["Публикация"] if "Публикация" in wb.sheetnames else wb.active
|
||||
|
||||
rows_seen = 0
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
|
||||
try:
|
||||
# min_row=4 (1-based) → данные с 4-й строки; 1 title, 3 шапка выше.
|
||||
for row in ws.iter_rows(min_row=4, values_only=True):
|
||||
num = _cell(row, 0) # A
|
||||
if num is None or not str(num).strip():
|
||||
continue
|
||||
# Номер ТП/РП бывает float из Excel (10003.0) — нормализуем к целому виду.
|
||||
num_raw = num
|
||||
if isinstance(num, float) and num.is_integer():
|
||||
num_raw = int(num)
|
||||
num_str = str(num_raw).strip()
|
||||
rows_seen += 1
|
||||
|
||||
installed = parse_reserve_number(_cell(row, 1)) # B
|
||||
current = parse_reserve_number(_cell(row, 2)) # C
|
||||
reserve = parse_reserve_number(_cell(row, 3)) # D
|
||||
|
||||
params = {
|
||||
"name": f"ТП/РП {num_str}",
|
||||
"name_norm": num_str.lower(),
|
||||
"municipality": "Екатеринбург",
|
||||
"installed": installed,
|
||||
"current": current,
|
||||
"reserve": reserve,
|
||||
"asof": reserve_asof,
|
||||
}
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
result = db.execute(
|
||||
# voltage_kv намеренно NULL: у ЕЭСК-серии «ниже 35» отдельного
|
||||
# класса напряжения нет; settlement=municipality (одна колонка).
|
||||
text("""
|
||||
INSERT INTO power_tp_rp_reserves
|
||||
(name, name_norm, municipality, settlement,
|
||||
voltage_kv, installed_capacity_mva, current_load_mva,
|
||||
reserve_mva, reserve_asof, fetched_at)
|
||||
VALUES (
|
||||
:name, :name_norm, :municipality, :municipality,
|
||||
NULL, :installed, :current,
|
||||
:reserve, :asof, NOW()
|
||||
)
|
||||
ON CONFLICT (name_norm, municipality, voltage_kv) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
installed_capacity_mva = EXCLUDED.installed_capacity_mva,
|
||||
current_load_mva = EXCLUDED.current_load_mva,
|
||||
reserve_mva = EXCLUDED.reserve_mva,
|
||||
reserve_asof = EXCLUDED.reserve_asof,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning("eesk tp_rp_sub35 upsert failed for %r: %s", num_str, e)
|
||||
skipped += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_tp_rp_sub35: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
result_dict = {
|
||||
"rows": rows_seen,
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
}
|
||||
logger.info("load_tp_rp_sub35 done: %s", result_dict)
|
||||
return result_dict
|
||||
|
||||
|
||||
def load_eesk_reserves(db: Session | None = None) -> dict[str, dict]:
|
||||
"""Полный прогон ЕЭСК: discovery ссылок + скачивание + загрузка ПС и ТП/РП.
|
||||
|
||||
RUN-ON-PROD. Owns-session как соседи (rosseti/gas/vodokanal). Каждый шаг
|
||||
graceful: сбой одного файла не валит другой.
|
||||
Returns: {"ps": {...}, "tp_rp": {...}} со счётчиками (или {"error": ...}).
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
links = fetch_hub_links()
|
||||
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
|
||||
ps = links.get("ps_35_220")
|
||||
if ps:
|
||||
try:
|
||||
r = client.get(ps["url"])
|
||||
r.raise_for_status()
|
||||
asof = quarter_last_day(ps["year"], ps["quarter"])
|
||||
out["ps"] = load_ps_35_220(db, r.content, asof)
|
||||
out["ps"]["period"] = quarter_period(ps["year"], ps["quarter"])
|
||||
except Exception as e:
|
||||
logger.exception("load_eesk_reserves: ps file failed: %s", e)
|
||||
out["ps"] = {"error": str(e)}
|
||||
else:
|
||||
out["ps"] = {"error": "no ps_35_220 link found"}
|
||||
|
||||
tp = links.get("tp_rp_sub35")
|
||||
if tp:
|
||||
try:
|
||||
r = client.get(tp["url"])
|
||||
r.raise_for_status()
|
||||
asof = quarter_last_day(tp["year"], tp["quarter"])
|
||||
out["tp_rp"] = load_tp_rp_sub35(db, r.content, asof)
|
||||
out["tp_rp"]["period"] = quarter_period(tp["year"], tp["quarter"])
|
||||
except Exception as e:
|
||||
logger.exception("load_eesk_reserves: tp_rp file failed: %s", e)
|
||||
out["tp_rp"] = {"error": str(e)}
|
||||
else:
|
||||
out["tp_rp"] = {"error": "no tp_rp_sub35 link found"}
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_eesk_reserves done: %s", out)
|
||||
return out
|
||||
529
backend/app/services/site_finder/eias_heat_loader.py
Normal file
529
backend/app/services/site_finder/eias_heat_loader.py
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
"""Загрузчик резервов тепловой мощности из ФГИС ФАС (ri.eias.ru) (#2119 Phase B2).
|
||||
|
||||
Источник — реестр раскрытия информации ФАС по сфере теплоснабжения (ri.eias.ru,
|
||||
reg=2644). Данные лежат за 3 GET-переходами:
|
||||
|
||||
1. Карточка организации (``PublicDisclosureInfoOrg.aspx``) — HTML-грид (DevExpress)
|
||||
со списком публикаций форм F_W_Q_14 / F_W_Q_4_6; из строк грида собираем ссылки
|
||||
``TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId>`` + текст периода.
|
||||
На проде ссылка живёт НЕ в href (там ``href="#"``), а в JS
|
||||
``onclick="openTemplateDialog('https://ri-loader.eias.ru/TemplatePrinter.aspx?…', …)"``,
|
||||
амперсанды бывают HTML-энкожены (``&``) → чанк строки перед парсом
|
||||
прогоняем через ``html.unescape``. Период — «N кв. YYYY года» (реже «N квартал
|
||||
YYYY»). Берём ВСЕ публикации свежайшего квартала (max(year, quarter)) — их может
|
||||
быть несколько (по одной на центральную систему / ЦС).
|
||||
2. TemplatePrinter (ri-loader.eias.ru) — HTML с ``<div id="dataFile"
|
||||
data-file="<base64>">``; base64 → ZIP (in-memory) → член ``tpl`` (XLSX).
|
||||
3. Лист ``ТП`` формы 14 — «Резерв мощности источников тепловой энергии»
|
||||
(живой пример: СТ №54 → 3.26 Гкал/час).
|
||||
|
||||
Реальная сетка листа снята с прода (raw jsonb, все 4 публикации единообразны) —
|
||||
парсинг COLUMN-ALIGNED, а НЕ «правейшее число в строке»:
|
||||
|
||||
* Строка-заголовок систем: ``col4 == 'Централизованная система'``; ИМЕНА систем
|
||||
лежат в колонках ``j ≥ 6`` (текст: «СТ № 56», «СТ № 54 » с trailing-space, «Локальные
|
||||
зоны теплоснабжения МУП…»). В хвосте той же строки бывают СЛУЖЕБНЫЕ ЧИСЛА (шаблон
|
||||
ЕИАС) — отсекаем их требованием «имя содержит хотя бы одну букву».
|
||||
* Строка резерва (№5): ``col3.strip() == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
|
||||
мощности источников тепловой энергии» — значения Гкал/ч стоят в ТЕХ ЖЕ колонках
|
||||
``j``, что и имена систем. В хвосте — длинный текст-примечание «Указывается резерв…»
|
||||
и служебные числа: они не в колонках систем и отсекаются сами.
|
||||
* ЛОВУШКА ``Flag_Row_Size``: последняя КОЛОНКА листа (col25) — служебные числа-высоты
|
||||
строк шаблона ЕИАС (74, 52, 15 у рубрик анкеты). Старый «правейшее число строки»
|
||||
хватал именно их и распознавал рубрики анкеты («Количество заявок…», «Причины
|
||||
отказа…», кнопку «Добавить систему») как системы. Column-aligned парс их не видит.
|
||||
|
||||
Все ячейки листа целиком → ``raw`` jsonb (будущие колонки не теряются). Нет строки
|
||||
систем ИЛИ строки резерва → warning-лог + parsed=0, НЕ падаем.
|
||||
|
||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP + шлёт российский УЦ без intermediate → загрузчик
|
||||
РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec), SSL через общий
|
||||
``_ru_ssl_context`` (бандл содержит GlobalSign-intermediate для eias.ru). httpx с
|
||||
явным таймаутом, per-row SAVEPOINT при UPSERT.
|
||||
|
||||
Числовой парс переиспользует ``parse_reserve_number`` из rosseti_reserve_loader.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from html import unescape
|
||||
|
||||
import httpx
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.site_finder.gazprom_grs_loader import _ru_ssl_context
|
||||
from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Реестр ФАС по сфере «теплоснабжение», регион Свердловская область (reg=2644).
|
||||
_REG = "2644"
|
||||
_REG_TEMPLATE = "RU.5.66"
|
||||
|
||||
# Организации-держатели: (человекочитаемое имя, orgId в реестре ФАС).
|
||||
# Расширение 2026-07-03 (разведка реестра ri.eias.ru, 500 орг области, 11 страниц
|
||||
# грида): добавлены операторы ЕКБ со СВЕЖИМИ публикациями формы 14 (2026-Q1) и
|
||||
# ненулевыми резервами. Регионгаз-инвест (16/16 ненулевых) + Т Плюс Свердловский
|
||||
# (15/13) дают ×10 к базе ненулевых резервов; хвост — по +1-2 системы. Свежие-но-
|
||||
# нулевые операторы (ТЭЦ ВИЗа, Солнечное тепло...) намеренно НЕ добавлены — только
|
||||
# раздули бы нулевые строки. orgId верифицированы по ИНН через грид-ключи DevExpress.
|
||||
ORGS: list[tuple[str, int]] = [
|
||||
("ЕТК", 30814352),
|
||||
("Екатеринбургэнерго", 26479450),
|
||||
("Регионгаз-инвест", 26479474),
|
||||
("Т Плюс (Свердловский)", 28951102),
|
||||
("АкадемТеплоСети", 31516224),
|
||||
("Химмаш Энерго", 28881099),
|
||||
("Энергоснабжающая компания", 26322592),
|
||||
("ЕТК (ООО)", 31889852),
|
||||
]
|
||||
|
||||
# Карточка организации: грид с публикациями форм 14 / 4_6 сферы WARM, раздел QUARTER.
|
||||
_CARD_URL = "https://ri.eias.ru/Discl/PublicDisclosureInfoOrg.aspx"
|
||||
_CARD_FORMS = "F_W_Q_14;F_W_Q_4_6;"
|
||||
|
||||
# Выгрузка публикации: HTML с base64-ZIP в data-file. Хост loader-домена.
|
||||
_TEMPLATE_URL = "https://ri-loader.eias.ru/TemplatePrinter.aspx"
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# Лист формы 14 с резервом мощности источников тепла.
|
||||
_HEAT_SHEET = "ТП"
|
||||
|
||||
# Ссылка на публикацию в HTML грида карточки: захватываем guid + id (pubId).
|
||||
# TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId> (порядок параметров может
|
||||
# варьироваться — берём каждый независимым lookahead-регэкспом на href).
|
||||
# URL живёт либо в старом href="/Discl/TemplatePrinter.aspx?...", либо (реальная
|
||||
# разметка прода) в JS onclick="openTemplateDialog('https://ri-loader.eias.ru/
|
||||
# TemplatePrinter.aspx?...', ...)" при href="#" — поэтому ищем URL где угодно в чанке,
|
||||
# останавливаясь на кавычках / скобках / пробелах (границы JS-строки и href-атрибута).
|
||||
_PUB_HREF_RE = re.compile(
|
||||
r"(?P<href>(?:https?://[^\"'()\s]+)?/?[^\"'()\s]*TemplatePrinter\.aspx[^\"'()\s]*)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GUID_RE = re.compile(r"[?&]guid=([^&\"'\s]+)", re.IGNORECASE)
|
||||
_PUBID_RE = re.compile(r"[?&]id=(\d+)", re.IGNORECASE)
|
||||
|
||||
# Период в тексте строки грида → (year, quarter). Реальная разметка прода —
|
||||
# «1 кв. 2026 года»; встречаются также «N квартал YYYY» / «N квартала YYYY» / «N кв YYYY».
|
||||
_QUARTER_RE = re.compile(r"(\d)\s*кв(?:артал\w*|\.)?\s*(\d{4})", re.IGNORECASE)
|
||||
|
||||
# base64-payload файла: <div id="dataFile" ... data-file="<base64>">.
|
||||
_DATAFILE_RE = re.compile(
|
||||
r'<div\b[^>]*id=["\']dataFile["\'][^>]*data-file=["\'](?P<b64>[^"\']+)["\']',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# Заголовок листа со строкой систем: в этой строке col4 == этому значению, а имена
|
||||
# систем стоят в колонках j ≥ 6 (см. docstring модуля, п.3).
|
||||
_SYSTEMS_HEADER = "Централизованная система"
|
||||
|
||||
# Строка резерва мощности (№5 формы 14): col3 == '5' И/ИЛИ col4 начинается с этого.
|
||||
_RESERVE_ROW_NUM = "5"
|
||||
_RESERVE_ROW_PREFIX = "Резерв мощности источников тепловой энергии"
|
||||
|
||||
# Первая колонка листа, где могут стоять имена систем (col0..col5 — служебные/№/ед.изм.).
|
||||
_SYSTEM_COL_START = 6
|
||||
|
||||
# Имя системы обязано содержать хотя бы одну букву (кириллица/латиница) — иначе это
|
||||
# служебное число из хвоста строки-заголовка (Flag_Row_Size шаблона ЕИАС).
|
||||
_HAS_LETTER_RE = re.compile(r"[А-Яа-яЁёA-Za-z]")
|
||||
|
||||
# Разбивка HTML грида на строки (<tr>) — публикации живут построчно, период и
|
||||
# ссылка в одной строке. Достаточно грубого split по <tr, полноценный DOM не нужен.
|
||||
_TR_SPLIT_RE = re.compile(r"<tr\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _quarter_from_text(row_text: str) -> tuple[int, int] | None:
|
||||
"""«…N квартал YYYY…» → (year, quarter). Квартал вне 1..4 / нет совпадения → None."""
|
||||
m = _QUARTER_RE.search(row_text)
|
||||
if not m:
|
||||
return None
|
||||
quarter = int(m.group(1))
|
||||
year = int(m.group(2))
|
||||
if quarter not in (1, 2, 3, 4):
|
||||
return None
|
||||
return (year, quarter)
|
||||
|
||||
|
||||
def build_card_url(org_id: int) -> str:
|
||||
"""URL карточки организации в реестре ФАС (грид публикаций форм 14 / 4_6)."""
|
||||
return (
|
||||
f"{_CARD_URL}?reg={_REG}&orgId={org_id}" f"&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}"
|
||||
)
|
||||
|
||||
|
||||
def build_template_url(guid: str, pub_id: str) -> str:
|
||||
"""URL выгрузки публикации (TemplatePrinter, base64-ZIP в data-file)."""
|
||||
return f"{_TEMPLATE_URL}?reg={_REG_TEMPLATE}&guid={guid}&id={pub_id}"
|
||||
|
||||
|
||||
def extract_publications(card_html: str) -> list[dict]:
|
||||
"""Из HTML карточки → публикации свежайшего квартала: [{guid, id, year, quarter}].
|
||||
|
||||
По каждой <tr>-строке грида ищем ссылку на TemplatePrinter (guid+id) И период
|
||||
«N квартал YYYY» в тексте той же строки. Собираем max((year, quarter)) и
|
||||
возвращаем ВСЕ публикации именно этого квартала (их бывает несколько — по ЦС).
|
||||
Строки без ссылки ИЛИ без распознанного периода игнорируются. Одна публикация
|
||||
встречается в нескольких строках грида (по видам деятельности: передача/сбыт) —
|
||||
дедупим по (guid, id), иначе один и тот же ZIP качается многократно.
|
||||
"""
|
||||
pubs: list[dict] = []
|
||||
for raw_chunk in _TR_SPLIT_RE.split(card_html):
|
||||
# Атрибуты грида могут содержать HTML-энтити (`&guid=` вместо `&guid=`) —
|
||||
# раскрываем до поиска, иначе `[?&]guid=` / `[?&]id=` не сматчатся.
|
||||
chunk = unescape(raw_chunk)
|
||||
href_m = _PUB_HREF_RE.search(chunk)
|
||||
if not href_m:
|
||||
continue
|
||||
href = href_m.group("href")
|
||||
guid_m = _GUID_RE.search(href)
|
||||
pubid_m = _PUBID_RE.search(href)
|
||||
if not guid_m or not pubid_m:
|
||||
continue
|
||||
yq = _quarter_from_text(chunk)
|
||||
if yq is None:
|
||||
continue
|
||||
year, quarter = yq
|
||||
pubs.append(
|
||||
{"guid": guid_m.group(1), "id": pubid_m.group(1), "year": year, "quarter": quarter}
|
||||
)
|
||||
|
||||
if not pubs:
|
||||
logger.warning("eias_heat: публикации не найдены в карточке (0 строк с guid+период)")
|
||||
return []
|
||||
|
||||
freshest = max((p["year"], p["quarter"]) for p in pubs)
|
||||
latest: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for p in pubs:
|
||||
if (p["year"], p["quarter"]) != freshest or (p["guid"], p["id"]) in seen:
|
||||
continue
|
||||
seen.add((p["guid"], p["id"]))
|
||||
latest.append(p)
|
||||
logger.info(
|
||||
"eias_heat: публикаций всего=%d, свежайший квартал=%d-Q%d → %d публикаций",
|
||||
len(pubs),
|
||||
freshest[0],
|
||||
freshest[1],
|
||||
len(latest),
|
||||
)
|
||||
return latest
|
||||
|
||||
|
||||
def extract_tpl_xlsx(template_html: str) -> bytes | None:
|
||||
"""HTML выгрузки → байты XLSX (член ``tpl`` из base64-ZIP в data-file). None — нет.
|
||||
|
||||
``<div id="dataFile" data-file="<base64>">`` → base64decode → ZIP → член ``tpl``.
|
||||
Битый base64 / отсутствие члена ``tpl`` → warning-лог + None (шаг graceful).
|
||||
"""
|
||||
m = _DATAFILE_RE.search(template_html)
|
||||
if not m:
|
||||
logger.warning("eias_heat: <div id=dataFile data-file> не найден в TemplatePrinter HTML")
|
||||
return None
|
||||
try:
|
||||
zip_bytes = base64.b64decode(m.group("b64"), validate=False)
|
||||
except (binascii.Error, ValueError) as e:
|
||||
logger.warning("eias_heat: base64-декод data-file упал: %s", e)
|
||||
return None
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
names = zf.namelist()
|
||||
member = next((n for n in names if n == "tpl" or n.rsplit("/", 1)[-1] == "tpl"), None)
|
||||
if member is None:
|
||||
logger.warning("eias_heat: член 'tpl' не найден в ZIP (члены: %s)", names[:20])
|
||||
return None
|
||||
return zf.read(member)
|
||||
except zipfile.BadZipFile as e:
|
||||
logger.warning("eias_heat: data-file не является валидным ZIP: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _cell_str(value: object) -> str:
|
||||
"""Ячейка → strip-строка (None → '')."""
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def _is_systems_header(cells: list[object]) -> bool:
|
||||
"""Строка-заголовок систем: col4 == 'Централизованная система'."""
|
||||
return len(cells) > 4 and _cell_str(cells[4]) == _SYSTEMS_HEADER
|
||||
|
||||
|
||||
def _is_reserve_row(cells: list[object]) -> bool:
|
||||
"""Строка резерва (№5): col3.strip() == '5' И/ИЛИ col4 начинается с «Резерв мощности…»."""
|
||||
if len(cells) <= 4:
|
||||
return False
|
||||
return _cell_str(cells[3]) == _RESERVE_ROW_NUM or _cell_str(cells[4]).startswith(
|
||||
_RESERVE_ROW_PREFIX
|
||||
)
|
||||
|
||||
|
||||
def _system_columns(header_cells: list[object]) -> list[tuple[int, str]]:
|
||||
"""Из строки-заголовка → пары (col_index, system_name) для j ≥ 6.
|
||||
|
||||
Имя обязано быть текстовым и содержать хотя бы одну букву — иначе это служебное
|
||||
число из хвоста строки (Flag_Row_Size шаблона ЕИАС), а не система.
|
||||
"""
|
||||
result: list[tuple[int, str]] = []
|
||||
for j in range(_SYSTEM_COL_START, len(header_cells)):
|
||||
value = header_cells[j]
|
||||
if value is None:
|
||||
continue
|
||||
name = str(value).strip()
|
||||
if name and _HAS_LETTER_RE.search(name):
|
||||
result.append((j, name))
|
||||
return result
|
||||
|
||||
|
||||
def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]:
|
||||
"""Парсит лист ``ТП`` формы 14 → (systems, all_cells).
|
||||
|
||||
COLUMN-ALIGNED по РЕАЛЬНОЙ сетке (не «правейшее число в строке»):
|
||||
|
||||
1. Строка-заголовок систем — ``col4 == 'Централизованная система'``; имена систем
|
||||
берём из колонок ``j ≥ 6`` (текст, содержит букву — служебные числа хвоста
|
||||
отсеиваются), имя .strip() (у части систем trailing-space).
|
||||
2. Строка резерва (№5) — ``col3 == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
|
||||
мощности источников тепловой энергии»; резерв каждой системы читаем из ТОЙ ЖЕ
|
||||
колонки ``j``, что и её имя. Нулевой резерв (0.0) — честный дефицит, СОХРАНЯЕМ;
|
||||
непарсибельная ячейка (текст/пусто) — систему пропускаем.
|
||||
|
||||
Нет строки систем ИЛИ строки резерва → warning-лог + ([], all_cells) (graceful).
|
||||
Служебная колонка 25 ``Flag_Row_Size`` (числа-высоты строк) в парс не попадает —
|
||||
именно она заставляла старый парсер хватать рубрики анкеты как «системы».
|
||||
|
||||
``all_cells`` — ВСЕ ячейки листа (список строк-списков) для сохранения в raw.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb[_HEAT_SHEET] if _HEAT_SHEET in wb.sheetnames else wb.active
|
||||
|
||||
all_cells: list[list[object]] = []
|
||||
header_cells: list[object] | None = None
|
||||
reserve_cells: list[object] | None = None
|
||||
try:
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
cells = list(row)
|
||||
all_cells.append(cells)
|
||||
if header_cells is None and _is_systems_header(cells):
|
||||
header_cells = cells
|
||||
elif reserve_cells is None and _is_reserve_row(cells):
|
||||
reserve_cells = cells
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
if header_cells is None or reserve_cells is None:
|
||||
logger.warning(
|
||||
"eias_heat: сетка не распознана (header=%s, reserve=%s) — 0 систем",
|
||||
header_cells is not None,
|
||||
reserve_cells is not None,
|
||||
)
|
||||
return [], all_cells
|
||||
|
||||
systems: list[dict] = []
|
||||
name_counts: dict[str, int] = {}
|
||||
for j, name in _system_columns(header_cells):
|
||||
reserve = parse_reserve_number(reserve_cells[j] if j < len(reserve_cells) else None)
|
||||
if reserve is None:
|
||||
continue # непарсибельная ячейка резерва (текст/пусто) → систему скипаем
|
||||
# Разные системы могут носить БАЙТ-В-БАЙТ одинаковое имя в соседних колонках
|
||||
# (живой пример ЕЭ: две «Локальные зоны … МУП "Екатеринбургэнерго"» с 62.198
|
||||
# и 0). UPSERT по (org, sphere, system_name, period) схлопнул бы их с потерей
|
||||
# данных — вторым и далее вхождениям добавляем порядковый суффикс « (N)».
|
||||
name_counts[name] = name_counts.get(name, 0) + 1
|
||||
if name_counts[name] > 1:
|
||||
name = f"{name} ({name_counts[name]})"
|
||||
systems.append({"system_name": name, "reserve_gcal_h": reserve})
|
||||
|
||||
return systems, all_cells
|
||||
|
||||
|
||||
def _cells_to_jsonable(all_cells: list[list[object]]) -> list[list[object]]:
|
||||
"""Ячейки листа → JSON-совместимый вид (datetime/прочее → str, числа/None as-is)."""
|
||||
out: list[list[object]] = []
|
||||
for row in all_cells:
|
||||
json_row: list[object] = []
|
||||
for value in row:
|
||||
if value is None or isinstance(value, bool | int | float | str):
|
||||
json_row.append(value)
|
||||
else:
|
||||
json_row.append(str(value))
|
||||
out.append(json_row)
|
||||
return out
|
||||
|
||||
|
||||
def _upsert_heat_rows(
|
||||
db: Session,
|
||||
org: str,
|
||||
period: str,
|
||||
source_guid: str,
|
||||
systems: list[dict],
|
||||
all_cells: list[list[object]],
|
||||
) -> dict[str, int]:
|
||||
"""UPSERT распознанных систем публикации в heat_system_reserves. Per-row SAVEPOINT.
|
||||
|
||||
raw = {cells: <весь лист>} сохраняется у КАЖДОЙ строки публикации (одна выгрузка =
|
||||
один лист). ON CONFLICT (org, sphere, system_name, period) DO UPDATE.
|
||||
Returns: счётчики parsed/inserted/updated/skipped.
|
||||
"""
|
||||
raw_json = json.dumps({"cells": _cells_to_jsonable(all_cells)}, ensure_ascii=False)
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for sysrec in systems:
|
||||
params = {
|
||||
"org": org,
|
||||
"system_name": sysrec["system_name"],
|
||||
"reserve": sysrec["reserve_gcal_h"],
|
||||
"period": period,
|
||||
"source_guid": source_guid,
|
||||
"raw": raw_json,
|
||||
}
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO heat_system_reserves
|
||||
(org, sphere, system_name, reserve_gcal_h,
|
||||
period, source_guid, raw, fetched_at)
|
||||
VALUES (
|
||||
:org, 'heat', :system_name, :reserve,
|
||||
:period, :source_guid, CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (org, sphere, system_name, period) DO UPDATE
|
||||
SET reserve_gcal_h = EXCLUDED.reserve_gcal_h,
|
||||
source_guid = EXCLUDED.source_guid,
|
||||
raw = EXCLUDED.raw,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning("eias_heat upsert failed for %r/%r: %s", org, sysrec["system_name"], e)
|
||||
skipped += 1
|
||||
return {
|
||||
"parsed": len(systems),
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
||||
|
||||
def fetch_card_html(org_id: int) -> str:
|
||||
"""Тянет HTML карточки организации. RUN-ON-PROD (гео-блок + российский УЦ)."""
|
||||
resp = httpx.get(
|
||||
build_card_url(org_id),
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
verify=_ru_ssl_context(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def fetch_template_html(client: httpx.Client, guid: str, pub_id: str) -> str:
|
||||
"""Тянет HTML выгрузки публикации (base64-ZIP в data-file). RUN-ON-PROD."""
|
||||
resp = client.get(build_template_url(guid, pub_id))
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def load_org_reserves(db: Session, org: str, org_id: int) -> dict[str, int]:
|
||||
"""Полный прогон одной организации: карточка → публикации → выгрузки → UPSERT.
|
||||
|
||||
Аккумулирует счётчики по всем публикациям свежайшего квартала (по ЦС).
|
||||
Ноль распознанных систем во всех публикациях → warning-лог с первыми 20 строками
|
||||
последнего листа, parsed=0, НЕ падаем.
|
||||
"""
|
||||
counts = {"parsed": 0, "inserted": 0, "updated": 0, "skipped": 0, "publications": 0}
|
||||
|
||||
card_html = fetch_card_html(org_id)
|
||||
pubs = extract_publications(card_html)
|
||||
if not pubs:
|
||||
logger.warning("eias_heat[%s]: нет публикаций свежайшего квартала", org)
|
||||
return counts
|
||||
|
||||
period = f"{pubs[0]['year']}-Q{pubs[0]['quarter']}"
|
||||
last_cells: list[list[object]] = []
|
||||
|
||||
with httpx.Client(
|
||||
timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
|
||||
) as client:
|
||||
for pub in pubs:
|
||||
try:
|
||||
template_html = fetch_template_html(client, pub["guid"], pub["id"])
|
||||
xlsx_bytes = extract_tpl_xlsx(template_html)
|
||||
if xlsx_bytes is None:
|
||||
continue
|
||||
systems, all_cells = parse_heat_sheet(xlsx_bytes)
|
||||
last_cells = all_cells
|
||||
counts["publications"] += 1
|
||||
if not systems:
|
||||
continue
|
||||
sub = _upsert_heat_rows(db, org, period, pub["guid"], systems, all_cells)
|
||||
counts["parsed"] += sub["parsed"]
|
||||
counts["inserted"] += sub["inserted"]
|
||||
counts["updated"] += sub["updated"]
|
||||
counts["skipped"] += sub["skipped"]
|
||||
except Exception as e:
|
||||
logger.exception("eias_heat[%s]: публикация guid=%s упала: %s", org, pub["guid"], e)
|
||||
|
||||
if counts["parsed"] == 0:
|
||||
logger.warning(
|
||||
"eias_heat[%s]: 0 систем распознано (period=%s); первые 20 строк листа: %s",
|
||||
org,
|
||||
period,
|
||||
last_cells[:20],
|
||||
)
|
||||
|
||||
counts["period"] = period # type: ignore[assignment]
|
||||
logger.info("eias_heat[%s] done: %s", org, counts)
|
||||
return counts
|
||||
|
||||
|
||||
def load_heat_reserves(db: Session | None = None) -> dict[str, dict]:
|
||||
"""Полный прогон тепло-резервов ФАС по всем организациям (ЕТК, Екатеринбургэнерго).
|
||||
|
||||
RUN-ON-PROD. Owns-session как соседи (rosseti/gas/vodokanal/eesk). Каждая
|
||||
организация graceful: падение одной не валит вторую. Коммит один в конце
|
||||
(per-row SAVEPOINT уже изолирует битые строки).
|
||||
|
||||
Returns: {org_name: {parsed, inserted, updated, skipped, publications, period}}
|
||||
или {org_name: {"error": ...}} при падении организации.
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
for org, org_id in ORGS:
|
||||
try:
|
||||
out[org] = load_org_reserves(db, org, org_id)
|
||||
except Exception as e:
|
||||
logger.exception("load_heat_reserves: org %s failed: %s", org, e)
|
||||
out[org] = {"error": str(e)}
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_heat_reserves: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_heat_reserves done: %s", out)
|
||||
return out
|
||||
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())
|
||||
458
backend/app/services/site_finder/gazprom_grs_loader.py
Normal file
458
backend/app/services/site_finder/gazprom_grs_loader.py
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1).
|
||||
|
||||
No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН
|
||||
HTML-таблицу «Информация о наличии (отсутствии) технической возможности…»
|
||||
(~350 строк) со свободной мощностью газораспределительных станций (ГРС) —
|
||||
проектная / текущая загрузка / свободная мощность (тыс. м³/ч) + уведомление о
|
||||
расшивке. UPSERT-ит в ``gas_grs_capacity``.
|
||||
|
||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly /
|
||||
manual docker exec) — как rosseti-/vodokanal-лоадеры. Код не предполагает
|
||||
локальный сетевой доступ.
|
||||
|
||||
Парсинг под РЕАЛЬНУЮ структуру таблицы ГТЕ (снята с прода 2026-07-02):
|
||||
|
||||
* caption-строка (1 ячейка) → скип;
|
||||
* «по состоянию на DD.MM.YYYY» в первых строках → asof;
|
||||
* строка-заголовок = ячейки содержат «наименование» И «проектн» (НЕ caption);
|
||||
* ниже идут суб-заголовки + строка нумерации «0..7» → скипаются как не-данные;
|
||||
* single-cell строка = регион-разделитель («СВЕРДЛОВСКАЯ ОБЛАСТЬ») → задаёт
|
||||
``current_region``; данные пишутся ТОЛЬКО пока текущий регион — Свердловский;
|
||||
* FULL-строка (len>=10): idx 2=имя, 3=проектная, 4=загрузка, 5=объём по ТУ,
|
||||
6=свободная, 7=свободная %, 8+=уведомление (основание/срок/параметры);
|
||||
* CONTINUATION-строка (5<=len<=9, доп. выход): сдвиг на 1 — idx 0=имя (с выходом),
|
||||
1=проектная, 2=загрузка, 3=объём ТУ, 4=свободная, 5=%, 6=уведомление;
|
||||
* имя с суффиксом «(выход №N)» → output_name отдельно; суффикс-число «(5)» —
|
||||
НЕ выход, остаётся в имени.
|
||||
|
||||
Числа — через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая,
|
||||
«—» → None). Объём по ТУ хранится ТОЛЬКО в ``raw`` (без миграции колонки).
|
||||
Полная строка целиком → ``raw`` jsonb (неизвестные/будущие колонки не теряются).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.site_finder.rosseti_reserve_loader import (
|
||||
parse_asof_date,
|
||||
parse_reserve_number,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Страница расклытия «Газпром трансгаз Екатеринбург» с инлайн HTML-таблицей.
|
||||
# Punycode не требуется — латинский хост.
|
||||
DISCLOSURE_URL = "https://ekaterinburg-tr.gazprom.ru/about/informatsiya-o-tekhnicheskoj-vozmo/"
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# Домен ГТЕ использует сертификат российского УЦ (Russian Trusted CA, Минцифры) —
|
||||
# его нет в стандартном CA-bundle → CERTIFICATE_VERIFY_FAILED (прод-инцидент
|
||||
# 2026-07-02). Подключаем публичный бандл (Госуслуги) ТОЛЬКО для этого лоадера,
|
||||
# системные CA остаются в контексте (create_default_context + load_verify_locations).
|
||||
_RU_CA_BUNDLE = (
|
||||
Path(__file__).resolve().parents[2] / "resources" / "certs" / "russian_trusted_ca_bundle.pem"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _ru_ssl_context() -> ssl.SSLContext:
|
||||
"""SSL-контекст: системные CA + Russian Trusted Root/Sub CA (Минцифры)."""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.load_verify_locations(cafile=str(_RU_CA_BUNDLE))
|
||||
return ctx
|
||||
|
||||
|
||||
_MULTISPACE_RE = re.compile(r"\s+")
|
||||
|
||||
# «н/д» / «-» / прочерк / пусто в текстовой ячейке (напр. срок расшивки) → None.
|
||||
_NODATA_TEXT_TOKENS = frozenset(
|
||||
{"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных", "х", "x"}
|
||||
)
|
||||
|
||||
# Строка-заголовок таблицы ГТЕ: ячейки содержат «наименование» И «проектн».
|
||||
# Отличает РЕАЛЬНЫЙ header от caption-строки («Информация о наличии…») и от
|
||||
# суб-заголовков / строки нумерации.
|
||||
_HEADER_MARKERS = ("наименование", "проектн")
|
||||
|
||||
# Свердловская область — единственный регион, который грузим (ГТЕ отдаёт и
|
||||
# сопредельные: Курганская, Челябинская и т.д. — они отсекаются region-фильтром).
|
||||
_SVERDLOVSK_TOKEN = "свердлов"
|
||||
|
||||
# Фрагмент «(выход №N)» в имени ГРС → отдельный output_name. «(5)» (только цифры) —
|
||||
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
|
||||
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
|
||||
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
|
||||
# Группы: 1=«выход», 2=номер (для канонизации «выход №N» без разнобоя пробелов вокруг №,
|
||||
# иначе «выход № 1» и «выход №1» породили бы РАЗНЫЕ output_name → дубли по
|
||||
# ON CONFLICT (grs_name_norm, output_name)).
|
||||
_OUTPUT_SUFFIX_RE = re.compile(r"\(\s*(выход)\s*№?\s*(\d+)\s*\)", re.IGNORECASE)
|
||||
|
||||
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
|
||||
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
|
||||
_FULL_IDX = {
|
||||
"grs_name": 2,
|
||||
"design": 3,
|
||||
"load": 4,
|
||||
"volume_tu": 5,
|
||||
"free": 6,
|
||||
"pct": 7,
|
||||
"notice": (8, 9, 10),
|
||||
}
|
||||
# CONTINUATION-строка (доп. выход, сдвиг на -1): имя=0, проектная=1, загрузка=2,
|
||||
# объём ТУ=3, свободная=4, %=5, уведомление=6+.
|
||||
_CONT_IDX = {
|
||||
"grs_name": 0,
|
||||
"design": 1,
|
||||
"load": 2,
|
||||
"volume_tu": 3,
|
||||
"free": 4,
|
||||
"pct": 5,
|
||||
"notice": (6, 7, 8),
|
||||
}
|
||||
|
||||
# Границы длины строки: full = данные с полным набором столбцов; continuation =
|
||||
# усечённый доп. выход. Иначе строка не-данные (шапка/суб/нумерация) → скип.
|
||||
_FULL_MIN_LEN = 10
|
||||
_CONT_MIN_LEN = 5
|
||||
_CONT_MAX_LEN = 9
|
||||
|
||||
|
||||
def normalize_grs_name(name: str | None) -> str:
|
||||
"""Нормализует имя ГРС для матча/дедупа.
|
||||
|
||||
Правила: lower, ё→е, схлопывание пробелов и дефисов (все виды тире → одиночный
|
||||
дефис без пробелов вокруг), удаление кавычек. Префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ —
|
||||
они различают станции. Пусто → ''.
|
||||
|
||||
Examples:
|
||||
«ГРС Кольцово» → «грс кольцово»
|
||||
«ГРС Берёзовский» → «грс березовский»
|
||||
«ГРС Северная — 2» → «грс северная-2»
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
s = name.strip().lower().replace("ё", "е")
|
||||
s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "")
|
||||
# Все виды тире (—, –, -) в одиночный дефис, пробелы вокруг убираем.
|
||||
s = re.sub(r"\s*[—–-]\s*", "-", s)
|
||||
s = _MULTISPACE_RE.sub(" ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _parse_pct(value: str | None) -> float | None:
|
||||
"""Процент свободной мощности «(96 %)» → 96.0. «(—)»/пусто → None.
|
||||
|
||||
Ячейка приходит в скобках и с неразрывным пробелом перед «%». Снимаем скобки,
|
||||
«%», пробелы — и парсим числом. Отрицательные (дефицит) сохраняются знаком.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip("()%\xa0 ").strip()
|
||||
if not stripped or stripped in _NODATA_TEXT_TOKENS:
|
||||
return None
|
||||
return parse_reserve_number(stripped)
|
||||
|
||||
|
||||
def _split_grs_name(raw_name: str) -> tuple[str, str | None]:
|
||||
"""Имя ячейки → (grs_name без выхода, output_name|None).
|
||||
|
||||
«ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); фрагмент «(выход №N)» бывает
|
||||
и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)» → («ГРС-1 Свердловск (5)»,
|
||||
«выход №1»)) — вырезаем его, остаток склеиваем и нормализуем двойные пробелы.
|
||||
output_name канонизируется в «выход №N» (нижний регистр, ровно один пробел после
|
||||
«выход», сразу «№N» без пробела) — разнобой пробелов вокруг «№» иначе дал бы
|
||||
разные output_name и дубли по ON CONFLICT (grs_name_norm, output_name).
|
||||
«ГРС Арамиль (5)» → без изменений (цифра в скобках — НЕ выход, часть имени).
|
||||
"""
|
||||
m = _OUTPUT_SUFFIX_RE.search(raw_name)
|
||||
if not m:
|
||||
return raw_name, None
|
||||
# Канонический output_name: «выход №N» (число из группы 2, «№» приклеено к цифре).
|
||||
output = f"{m.group(1).lower()} №{m.group(2)}"
|
||||
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
|
||||
# двойные пробелы, оставшиеся на месте выреза в середине).
|
||||
grs_name = raw_name[: m.start()] + raw_name[m.end() :]
|
||||
grs_name = _MULTISPACE_RE.sub(" ", grs_name).strip()
|
||||
return grs_name, output
|
||||
|
||||
|
||||
def _is_header_row(texts: list[str]) -> bool:
|
||||
"""Строка — РЕАЛЬНЫЙ заголовок (ячейки содержат «наименование» И «проектн»)?
|
||||
|
||||
Отличает header от caption («Информация о наличии…») и суб-заголовков.
|
||||
"""
|
||||
joined = " ".join(texts).lower()
|
||||
return all(marker in joined for marker in _HEADER_MARKERS)
|
||||
|
||||
|
||||
def _extract_table(html: str) -> object | None:
|
||||
"""Находит наиболее «содержательную» <table> (с макс. числом строк). None — нет."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
tables = soup.find_all("table")
|
||||
if not tables:
|
||||
return None
|
||||
# Основная таблица — с наибольшим числом строк (шапки/навигация мелкие).
|
||||
return max(tables, key=lambda t: len(t.find_all("tr")))
|
||||
|
||||
|
||||
def _row_cells(tr: object) -> list[object]:
|
||||
"""Ячейки строки (<th> и <td> в порядке появления)."""
|
||||
return tr.find_all(["th", "td"]) # type: ignore[union-attr]
|
||||
|
||||
|
||||
def _row_texts(cells: list[object]) -> list[str]:
|
||||
"""Ячейки строки → список очищенных (collapse пробелов, \\xa0→пробел) текстов."""
|
||||
out: list[str] = []
|
||||
for cell in cells:
|
||||
raw = cell.get_text(" ").replace("\xa0", " ") # type: ignore[union-attr]
|
||||
out.append(_MULTISPACE_RE.sub(" ", raw).strip())
|
||||
return out
|
||||
|
||||
|
||||
def _cell_at(texts: list[str], idx: int) -> str | None:
|
||||
"""Текст ячейки по индексу → None если пусто / «—»-токен / за границей."""
|
||||
if idx >= len(texts):
|
||||
return None
|
||||
s = texts[idx].strip()
|
||||
if not s or s.lower() in _NODATA_TEXT_TOKENS:
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def _build_upgrade_due(texts: list[str], notice_idxs: tuple[int, ...]) -> str | None:
|
||||
"""Ячейки уведомления (Основание/Срок/Параметры) → « · »-join непустых.
|
||||
|
||||
Все «—»/пусто → None (нет уведомления о расшивке).
|
||||
"""
|
||||
parts = [t for i in notice_idxs if (t := _cell_at(texts, i)) is not None]
|
||||
return " · ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _build_record(
|
||||
texts: list[str], idx: dict, current_region: str, asof: date | None
|
||||
) -> dict | None:
|
||||
"""Строит запись ГРС из строки данных по карте индексов (FULL или CONTINUATION).
|
||||
|
||||
Имя обязательно — иначе None (не-данные). Объём по ТУ уходит ТОЛЬКО в raw.
|
||||
"""
|
||||
raw_name = _cell_at(texts, idx["grs_name"])
|
||||
if not raw_name:
|
||||
return None
|
||||
grs_name, output_name = _split_grs_name(raw_name)
|
||||
return {
|
||||
"grs_name": grs_name,
|
||||
"output_name": output_name,
|
||||
"region": current_region,
|
||||
"design_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["design"])),
|
||||
"current_load_th_m3_h": parse_reserve_number(_cell_at(texts, idx["load"])),
|
||||
"free_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["free"])),
|
||||
"free_capacity_pct": _parse_pct(_cell_at(texts, idx["pct"])),
|
||||
"upgrade_due": _build_upgrade_due(texts, idx["notice"]),
|
||||
"asof": asof,
|
||||
# Объём газа по ТУ — БЕЗ отдельной колонки в схеме → сохраняем в raw.
|
||||
"raw": {"cells": texts, "volume_by_tu": _cell_at(texts, idx["volume_tu"])},
|
||||
}
|
||||
|
||||
|
||||
def parse_grs_table(html: str) -> list[dict]:
|
||||
"""Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам).
|
||||
|
||||
Под РЕАЛЬНУЮ структуру таблицы (см. модульный docstring):
|
||||
- основная <table> = с макс. числом строк;
|
||||
- asof («по состоянию на DD.MM.YYYY») ищется в первых 4 строках;
|
||||
- строка-заголовок детектится по маркерам «наименование» + «проектн»; всё до
|
||||
неё (caption / asof) и суб-заголовки/нумерация сразу после — скипаются;
|
||||
- single-cell строка = регион-разделитель → current_region (title-case);
|
||||
- данные пишутся ТОЛЬКО когда current_region содержит «свердлов»;
|
||||
- FULL-строка (len>=10) и CONTINUATION-строка (5<=len<=9) → фиксированные
|
||||
индексы; иначе строка не-данные → скип с warning-логом.
|
||||
|
||||
Каждая запись:
|
||||
{grs_name, output_name, region, design_capacity_th_m3_h, current_load_th_m3_h,
|
||||
free_capacity_th_m3_h, free_capacity_pct, upgrade_due, asof, raw}.
|
||||
"""
|
||||
table = _extract_table(html)
|
||||
if table is None:
|
||||
logger.warning("gazprom_grs: <table> не найдена в HTML")
|
||||
return []
|
||||
|
||||
rows = table.find_all("tr") # type: ignore[union-attr]
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
row_texts = [_row_texts(_row_cells(tr)) for tr in rows]
|
||||
|
||||
# asof: «по состоянию на DD.MM.YYYY» в первых 4 строках (caption/asof-строка).
|
||||
asof = parse_asof_date(*(" ".join(t) for t in row_texts[:4]))
|
||||
|
||||
# Находим строку-заголовок (маркеры «наименование» + «проектн»). Данные — ниже.
|
||||
header_idx: int | None = None
|
||||
for i, texts in enumerate(row_texts):
|
||||
if _is_header_row(texts):
|
||||
header_idx = i
|
||||
break
|
||||
if header_idx is None:
|
||||
logger.warning("gazprom_grs: строка-заголовок не найдена (маркеры %s)", _HEADER_MARKERS)
|
||||
return []
|
||||
|
||||
records: list[dict] = []
|
||||
current_region: str | None = None
|
||||
in_sverdlovsk = False
|
||||
|
||||
for texts in row_texts[header_idx + 1 :]:
|
||||
n = len(texts)
|
||||
if n == 0 or not any(t for t in texts):
|
||||
continue # пустая строка → скип
|
||||
|
||||
if n == 1:
|
||||
# Single-cell → регион-разделитель. title-case, обновляем контекст.
|
||||
region = _cell_at(texts, 0)
|
||||
if region:
|
||||
current_region = region.title()
|
||||
in_sverdlovsk = _SVERDLOVSK_TOKEN in region.lower()
|
||||
continue
|
||||
|
||||
if not in_sverdlovsk:
|
||||
continue # регион не Свердловский → строку не пишем
|
||||
|
||||
if n >= _FULL_MIN_LEN:
|
||||
idx_map = _FULL_IDX
|
||||
elif _CONT_MIN_LEN <= n <= _CONT_MAX_LEN:
|
||||
idx_map = _CONT_IDX
|
||||
else:
|
||||
logger.warning("gazprom_grs: строка неожиданной длины n=%d → скип: %s", n, texts)
|
||||
continue
|
||||
|
||||
rec = _build_record(texts, idx_map, current_region or "", asof)
|
||||
if rec is not None:
|
||||
records.append(rec)
|
||||
|
||||
logger.info("gazprom_grs: распознано записей ГРС=%d (asof=%s)", len(records), asof)
|
||||
return records
|
||||
|
||||
|
||||
def _passes_region_filter(records: list[dict]) -> list[dict]:
|
||||
"""Фильтр Свердловской области по полю region записи.
|
||||
|
||||
Основной регион-фильтр УЖЕ применён в parse_grs_table (по current_region-
|
||||
разделителям). Эта функция — второй барьер на случай записей с чужим/пустым
|
||||
region (совместимость с прежним пайплайном load_grs_capacity). Если region есть
|
||||
хоть у одной — оставляем только «свердлов»; иначе всё.
|
||||
"""
|
||||
if any(r.get("region") for r in records):
|
||||
return [r for r in records if _SVERDLOVSK_TOKEN in (r.get("region") or "").lower()]
|
||||
return records
|
||||
|
||||
|
||||
def fetch_grs_html() -> str:
|
||||
"""Тянет HTML страницы раскрытия ГТЕ. RUN-ON-PROD (гео-блок). httpx с таймаутом."""
|
||||
resp = httpx.get(
|
||||
DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]:
|
||||
"""UPSERT записей ГРС в gas_grs_capacity. Per-row SAVEPOINT (битая строка не валит батч).
|
||||
|
||||
ON CONFLICT (grs_name_norm, output_name) — NULLS NOT DISTINCT в схеме (output_name
|
||||
nullable). Returns: счётчики rows/inserted/updated/skipped.
|
||||
"""
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for rec in records:
|
||||
grs_name = rec["grs_name"]
|
||||
params = {
|
||||
"grs_name": grs_name,
|
||||
"grs_name_norm": normalize_grs_name(grs_name),
|
||||
"output_name": rec["output_name"],
|
||||
"region": rec["region"],
|
||||
"design": rec["design_capacity_th_m3_h"],
|
||||
"load": rec["current_load_th_m3_h"],
|
||||
"free": rec["free_capacity_th_m3_h"],
|
||||
"pct": rec["free_capacity_pct"],
|
||||
"upgrade_due": rec["upgrade_due"],
|
||||
"asof": rec.get("asof"),
|
||||
"raw": json.dumps(rec["raw"], ensure_ascii=False),
|
||||
}
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — откат только этой строки
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO gas_grs_capacity
|
||||
(grs_name, grs_name_norm, output_name, region,
|
||||
design_capacity_th_m3_h, current_load_th_m3_h,
|
||||
free_capacity_th_m3_h, free_capacity_pct,
|
||||
upgrade_due, asof, raw, fetched_at)
|
||||
VALUES (
|
||||
:grs_name, :grs_name_norm, :output_name, :region,
|
||||
:design, :load, :free, :pct,
|
||||
:upgrade_due, :asof, CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (grs_name_norm, output_name) DO UPDATE
|
||||
SET grs_name = EXCLUDED.grs_name,
|
||||
region = EXCLUDED.region,
|
||||
design_capacity_th_m3_h = EXCLUDED.design_capacity_th_m3_h,
|
||||
current_load_th_m3_h = EXCLUDED.current_load_th_m3_h,
|
||||
free_capacity_th_m3_h = EXCLUDED.free_capacity_th_m3_h,
|
||||
free_capacity_pct = EXCLUDED.free_capacity_pct,
|
||||
upgrade_due = EXCLUDED.upgrade_due,
|
||||
asof = EXCLUDED.asof,
|
||||
raw = EXCLUDED.raw,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning("gas_grs upsert failed for %r/%r: %s", grs_name, rec["output_name"], e)
|
||||
skipped += 1
|
||||
return {"rows": len(records), "inserted": inserted, "updated": updated, "skipped": skipped}
|
||||
|
||||
|
||||
def load_grs_capacity(db: Session | None = None) -> dict[str, int]:
|
||||
"""Тянет страницу ГТЕ + парсит HTML-таблицу + UPSERT свободной мощности ГРС.
|
||||
|
||||
RUN-ON-PROD (гео-блок источника). ``db`` для совместимости сигнатуры; None →
|
||||
своя SessionLocal (fetch + запись sync, как rosseti_wfs_loader).
|
||||
|
||||
Returns: счётчики {rows, inserted, updated, skipped}.
|
||||
"""
|
||||
html = fetch_grs_html()
|
||||
records = _passes_region_filter(parse_grs_table(html))
|
||||
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
counts = _upsert_grs_rows(db, records)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_grs_capacity: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_grs_capacity done: %s", counts)
|
||||
return counts
|
||||
|
|
@ -51,18 +51,23 @@ def _market_metrics_key(
|
|||
obj_ids: Sequence[int] | None = None,
|
||||
window_months: int = 6,
|
||||
premise_kind: str = "квартира",
|
||||
obj_class: str | None = None,
|
||||
room_bucket: str | None = None,
|
||||
) -> tuple[Any, ...]:
|
||||
"""Ключ кэша §22-форсайта для compute_market_metrics (#1129).
|
||||
|
||||
Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для
|
||||
hashability), окно, premise_kind. В форсайт-пути obj_ids всегда None → tuple()
|
||||
устойчив. `db` не в ключе (одна сессия на отчёт).
|
||||
hashability), окно, premise_kind, + сегмент-оси obj_class/room_bucket (#1959 —
|
||||
посегментный дефицит). В форсайт-пути obj_ids всегда None → tuple() устойчив.
|
||||
`db` не в ключе (одна сессия на отчёт).
|
||||
"""
|
||||
return (
|
||||
district,
|
||||
tuple(obj_ids) if obj_ids is not None else None,
|
||||
window_months,
|
||||
premise_kind,
|
||||
obj_class,
|
||||
room_bucket,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -269,15 +274,45 @@ def _room_bucket(rooms_int: int | None) -> str:
|
|||
# SQL aggregation
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Текущий сток per-flat. Считаем по objective_lots (последний UPSERT-снапшот).
|
||||
# Текущий сток per-flat. ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959):
|
||||
# objective_lots — append-per-snapshot (~2.9× инфляция строк), без дедупа сток
|
||||
# раздут и симметрично раздувает спрос И предложение → деградирует посегментный
|
||||
# дефицит. Ключ физлота = (project_name, corpus_name, section, floor, lot_number);
|
||||
# берём строку с MAX(snapshot_date), tie-break MAX(id). Поддерживается индексом
|
||||
# objective_lots_physflat_latest_idx (создаётся в миграции 173) — он совпадает с
|
||||
# ключом и направлениями этого DISTINCT ON → Index Only Scan + Unique без Sort.
|
||||
# project_name NOT NULL; corpus/section/floor/lot_number могут быть NULL (~2.5%
|
||||
# лотов) — для таких лотов DISTINCT ON схлопывает NULL=NULL в одну группу
|
||||
# (неидентифицируемые физлоты, в дефицит-расчёте безопасно), для остального —
|
||||
# точный per-flat дедуп.
|
||||
#
|
||||
# ⚠️ ДЕДУП БЕЗУСЛОВНЫЙ — применяется ко ВСЕМ вызовам compute_market_metrics, не
|
||||
# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных
|
||||
# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated),
|
||||
# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп
|
||||
# здесь касается ТОЛЬКО выборки внутри метрик; #1964 ввёл общий view
|
||||
# v_objective_lots_latest для ОСТАЛЬНЫХ current-state консьюмеров — он использует
|
||||
# ТОТ ЖЕ physflat-ключ/направления (project,corpus,section,floor,lot_number;
|
||||
# snapshot_date DESC, id DESC), что и этот inline DISTINCT ON (инвариант общий).
|
||||
# Этот блок НЕ рефакторим на view (zero behavior change, лишний риск).
|
||||
#
|
||||
# Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим
|
||||
# LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр
|
||||
# ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²"), ТОЧНОЕ зеркало
|
||||
# sales_series.room_area_bucket_of (и _SOURCE_B_SQL): площадь ≥ :large_area → LARGE,
|
||||
# иначе по rooms_int; так what_to_build (Source-B сетка) фильтрует БЕЗ перевода
|
||||
# вокабуляра. Применяются ДО DISTINCT ON: сегментация объективна по физлоту
|
||||
# (класс/комнатность/площадь не меняются между снапшотами).
|
||||
#
|
||||
# is_sold распознаём И через флаг is_sold, И через наличие contract_date / статус
|
||||
# 'продан' — Объектив заполняет их неконсистентно. n_long_unsold: непродан и
|
||||
# в продаже > N мес (sales_start_date — самый надёжный «когда вышел на рынок»).
|
||||
_STOCK_SQL = text(
|
||||
"""
|
||||
WITH lots AS (
|
||||
SELECT
|
||||
ol.objective_lot_id,
|
||||
SELECT DISTINCT ON (
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
|
||||
)
|
||||
ol.project_name,
|
||||
ol.rooms_int,
|
||||
ol.area_pd,
|
||||
|
|
@ -297,6 +332,26 @@ _STOCK_SQL = text(
|
|||
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
|
||||
)
|
||||
AND (
|
||||
CAST(:cls AS text) IS NULL
|
||||
OR LOWER(ol.class) = LOWER(CAST(:cls AS text))
|
||||
)
|
||||
AND (
|
||||
CAST(:room_bucket AS text) IS NULL
|
||||
OR (CASE
|
||||
WHEN ol.area_pd >= CAST(:large_area AS numeric)
|
||||
THEN CAST(:b_large AS text)
|
||||
WHEN ol.rooms_int IS NULL THEN CAST(:b_unknown AS text)
|
||||
WHEN ol.rooms_int <= 0 THEN CAST(:b_studio AS text)
|
||||
WHEN ol.rooms_int = 1 THEN CAST(:b_1k AS text)
|
||||
WHEN ol.rooms_int = 2 THEN CAST(:b_2k AS text)
|
||||
WHEN ol.rooms_int = 3 THEN CAST(:b_3k AS text)
|
||||
ELSE CAST(:b_large AS text)
|
||||
END) = CAST(:room_bucket AS text)
|
||||
)
|
||||
ORDER BY
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
||||
ol.snapshot_date DESC, ol.id DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS n_lots,
|
||||
|
|
@ -320,9 +375,45 @@ _STOCK_SQL = text(
|
|||
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
|
||||
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
|
||||
# Bug #949: Автовокзал 6mo давал ~33 245 ед. вместо реальных ~2 308.)
|
||||
# area_pd берём из самого objective_lots (текущий per-flat area).
|
||||
#
|
||||
# ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959, симметрично _STOCK_SQL):
|
||||
# без дедупа один проданный физлот, присутствующий в N снапшотах, считается N раз
|
||||
# → velocity/absorption раздуты ~2.9× (раздувают спрос симметрично раздутому
|
||||
# предложению → дефицит вырождается в −1.0). Дедупим в CTE `latest`, окно по
|
||||
# contract_date и сегмент-фильтры применяем уже к дедуплицированному набору.
|
||||
# Ключ/направления дедупа — зеркало _STOCK_SQL (оба поддержаны индексом
|
||||
# objective_lots_physflat_latest_idx из миграции 173). contract_date / rooms_int /
|
||||
# area_pd берём из последнего снапшота физлота.
|
||||
# Сегмент-фильтры (#1959): obj_class (lowercase, LOWER=LOWER) + room_bucket
|
||||
# (Source-B room_area-вокабуляр "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/
|
||||
# "80+ м²" — ТОЧНОЕ зеркало sales_series.room_area_bucket_of, как в _STOCK_SQL) —
|
||||
# применяются ПОСЛЕ дедупа (объективны по физлоту: класс/комнатность/площадь не
|
||||
# меняются между снапшотами). ROLLUP остаётся: при room_bucket-фильтре отдаёт
|
||||
# {bucket, total}.
|
||||
_SALES_WINDOW_SQL = text(
|
||||
"""
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
|
||||
)
|
||||
ol.rooms_int,
|
||||
ol.area_pd,
|
||||
ol.contract_date,
|
||||
ol.class
|
||||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
AND (
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
AND (
|
||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
|
||||
)
|
||||
ORDER BY
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
||||
ol.snapshot_date DESC, ol.id DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS units_sold_window,
|
||||
COALESCE(SUM(area_pd), 0) AS area_sold_window,
|
||||
|
|
@ -334,18 +425,26 @@ _SALES_WINDOW_SQL = text(
|
|||
-- эмитит итог ПЕРВЫМ → NULL-группа затирает units_total частичным
|
||||
-- счётом → unit_velocity/absorption занижены, MoS завышен.
|
||||
GROUPING(rooms_int) AS is_total
|
||||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
FROM latest
|
||||
WHERE contract_date IS NOT NULL
|
||||
AND contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
|
||||
AND (
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
CAST(:cls AS text) IS NULL
|
||||
OR LOWER(class) = LOWER(CAST(:cls AS text))
|
||||
)
|
||||
AND (
|
||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
|
||||
CAST(:room_bucket AS text) IS NULL
|
||||
OR (CASE
|
||||
WHEN area_pd >= CAST(:large_area AS numeric)
|
||||
THEN CAST(:b_large AS text)
|
||||
WHEN rooms_int IS NULL THEN CAST(:b_unknown AS text)
|
||||
WHEN rooms_int <= 0 THEN CAST(:b_studio AS text)
|
||||
WHEN rooms_int = 1 THEN CAST(:b_1k AS text)
|
||||
WHEN rooms_int = 2 THEN CAST(:b_2k AS text)
|
||||
WHEN rooms_int = 3 THEN CAST(:b_3k AS text)
|
||||
ELSE CAST(:b_large AS text)
|
||||
END) = CAST(:room_bucket AS text)
|
||||
)
|
||||
AND ol.contract_date IS NOT NULL
|
||||
AND ol.contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
|
||||
GROUP BY ROLLUP (rooms_int)
|
||||
"""
|
||||
)
|
||||
|
|
@ -359,6 +458,8 @@ def compute_market_metrics(
|
|||
obj_ids: Sequence[int] | None = None,
|
||||
window_months: int = 6,
|
||||
premise_kind: str = "квартира",
|
||||
obj_class: str | None = None,
|
||||
room_bucket: str | None = None,
|
||||
) -> MarketMetrics:
|
||||
"""Вычислить рыночные метрики ТЗ §9.2 для локации.
|
||||
|
||||
|
|
@ -366,9 +467,34 @@ def compute_market_metrics(
|
|||
заданы). Если ни один не задан — считается по всей выборке premise_kind
|
||||
(имеет смысл для ЕКБ-wide baseline).
|
||||
|
||||
#1959 (посегментный дефицит): `obj_class` (lowercase в objective_lots,
|
||||
матчим регистронезависимо) и `room_bucket` (Source-B room_area-вокабуляр —
|
||||
"Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²", зеркало
|
||||
`sales_series.room_area_bucket_of` → what_to_build фильтрует БЕЗ перевода)
|
||||
сужают выборку до одного сегмента. Так base_pace (unit_velocity) и доступный
|
||||
сток становятся ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки
|
||||
what_to_build делят один темп → дефицит вырождается в −1.0). None по обеим осям
|
||||
→ прежнее district-wide поведение. ВАЖНО: SQL дедуплицирует objective_lots до
|
||||
последнего снапшота на физлот → counts (а значит и confidence) считаются на
|
||||
дедуплицированном наборе.
|
||||
|
||||
Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики =
|
||||
None, confidence='low'. Никогда не бросает на отсутствии данных.
|
||||
"""
|
||||
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of /
|
||||
# _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без перевода.
|
||||
# Локальный (lazy) импорт: модульный импорт sales_series тянет forecasting/__init__
|
||||
# → demand_supply_forecast → future_supply → market_metrics (циклический импорт).
|
||||
from app.services.forecasting.sales_series import (
|
||||
_LARGE_AREA_THRESHOLD_M2,
|
||||
ROOM_AREA_BUCKET_1K,
|
||||
ROOM_AREA_BUCKET_2K,
|
||||
ROOM_AREA_BUCKET_3K,
|
||||
ROOM_AREA_BUCKET_LARGE,
|
||||
ROOM_AREA_BUCKET_STUDIO,
|
||||
ROOM_AREA_BUCKET_UNKNOWN,
|
||||
)
|
||||
|
||||
obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
|
||||
has_obj_ids = bool(obj_id_list)
|
||||
# Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
|
||||
|
|
@ -386,6 +512,19 @@ def compute_market_metrics(
|
|||
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
|
||||
"obj_ids": obj_id_list,
|
||||
"overstock_interval": f"{_OVERSTOCK_MONTHS_THRESHOLD} months",
|
||||
# #1959 сегмент-фильтры (None → ось не сужается).
|
||||
"cls": obj_class,
|
||||
"room_bucket": room_bucket,
|
||||
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of
|
||||
# / _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без
|
||||
# перевода. Меняешь bucketing — правь в ОБОИХ местах (тут + sales_series).
|
||||
"large_area": _LARGE_AREA_THRESHOLD_M2,
|
||||
"b_studio": ROOM_AREA_BUCKET_STUDIO,
|
||||
"b_1k": ROOM_AREA_BUCKET_1K,
|
||||
"b_2k": ROOM_AREA_BUCKET_2K,
|
||||
"b_3k": ROOM_AREA_BUCKET_3K,
|
||||
"b_large": ROOM_AREA_BUCKET_LARGE,
|
||||
"b_unknown": ROOM_AREA_BUCKET_UNKNOWN,
|
||||
}
|
||||
|
||||
# ── Текущий сток ──────────────────────────────────────────────────────────
|
||||
|
|
@ -433,10 +572,12 @@ def compute_market_metrics(
|
|||
confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)
|
||||
|
||||
logger.info(
|
||||
"market_metrics: district=%s micros=%s obj_ids=%d n_lots=%d n_sold=%d "
|
||||
"n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
|
||||
"market_metrics: district=%s micros=%s class=%s room=%s obj_ids=%d n_lots=%d "
|
||||
"n_sold=%d n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
|
||||
district,
|
||||
micros,
|
||||
obj_class,
|
||||
room_bucket,
|
||||
len(obj_id_list),
|
||||
n_lots,
|
||||
n_sold_total,
|
||||
|
|
@ -575,3 +716,218 @@ def _price_sensitivity(
|
|||
)
|
||||
return None, None
|
||||
return float(elast["elasticity"]), str(elast["source"])
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Offer-price trend (#2178) — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history.
|
||||
#
|
||||
# Мотивация: §4.3 «Тренд рынка» строится на rosreestr_deals, чей поток встал
|
||||
# (max дата 2026-01-01) → recent-окно пусто → status='source_stale' и пустая
|
||||
# карточка. objective_lots_history даёт weekly-снапшоты цен предложения активных
|
||||
# лотов — это ВТОРОЙ источник, живой (снимки до 2026-06-30), которым мы заполняем
|
||||
# карточку, когда сделки устарели.
|
||||
#
|
||||
# СВЯЗКА С ЖК РАДИУСА — тот же путь, что best_layouts/competitors (маппинг, НЕ
|
||||
# ненадёжный complex_id-FK, который джойнит лишь ~28% и мультигородской, см.
|
||||
# 131_district_vocab_map.sql):
|
||||
# radius (ST_DWithin по domrf_kn_objects)
|
||||
# → domrf_obj_id
|
||||
# → objective_complex_mapping.domrf_obj_id → objective_complex_name
|
||||
# → objective_lots.project_name → objective_lot_id-набор
|
||||
# → objective_lots_history (per-snapshot цены этих лотов).
|
||||
# objective_lots_history НЕ содержит project_name — поэтому набор objective_lot_id
|
||||
# фиксируем из objective_lots (текущее состояние), а историю берём по нему.
|
||||
#
|
||||
# ДЕДУП задвоенного снапшота (КРИТИЧНО, #2178): 2026-06-22 был просвипан дважды
|
||||
# (~621k строк = два свипа в один день). На (objective_lot_id, snapshot_date) в
|
||||
# истории стоит UNIQUE-констрейнт → физически дубля быть не должно, НО на проде
|
||||
# он есть (два ETL-пути до починки триггера). Безусловно дедупим до одной строки
|
||||
# на (objective_lot_id, snapshot_date): DISTINCT ON (...) ORDER BY fetched_at DESC
|
||||
# — берём последний свип дня. Медиану считаем УЖЕ по дедуплицированному набору,
|
||||
# иначе задвоенный день весит ×2 и смещает медиану.
|
||||
#
|
||||
# ФИЛЬТР «в продаже» (честный, по реальным status в objective_lots_history —
|
||||
# "резерв"|"свободна"|"продан"|"бронь", см. 68_schema_objective.sql): лот активен
|
||||
# = is_sold IS NOT TRUE И LOWER(status) не равен 'продан'. Бронь/резерв считаем
|
||||
# «в продаже» (цена ещё котируется рынку). contract_date-признак здесь не
|
||||
# используем: в истории он часто NULL у активных лотов, а нам важна котировка.
|
||||
#
|
||||
# ФИЛЬТР ВМЕНЯЕМОСТИ ЦЕНЫ: price_per_m2_rub BETWEEN 10000 AND 1000000 — отсекает
|
||||
# ETL-мусор (0 / гросс-суммы, ошибочно попавшие в per-m2). Границы шире, чем у
|
||||
# сделок-тренда (30k–500k), т.к. предложение включает премиум/апартаменты.
|
||||
_OFFER_TREND_SQL = text(
|
||||
"""
|
||||
WITH radius_obj AS (
|
||||
SELECT DISTINCT ON (o.obj_id) o.obj_id
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:center_lon AS float), CAST(:center_lat AS float)
|
||||
),
|
||||
4326
|
||||
)::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
|
||||
),
|
||||
radius_projects AS (
|
||||
SELECT DISTINCT cm.objective_complex_name AS project_name
|
||||
FROM objective_complex_mapping cm
|
||||
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM radius_obj)
|
||||
),
|
||||
radius_lots AS (
|
||||
SELECT DISTINCT ol.objective_lot_id
|
||||
FROM objective_lots ol
|
||||
JOIN radius_projects rp ON rp.project_name = ol.project_name
|
||||
WHERE ol.premise_kind = CAST(:premise_kind AS text)
|
||||
),
|
||||
hist AS (
|
||||
-- Дедуп задвоенного снапшота: одна строка на (лот, день), последний свип.
|
||||
SELECT DISTINCT ON (h.objective_lot_id, h.snapshot_date)
|
||||
h.snapshot_date,
|
||||
h.price_per_m2_rub
|
||||
FROM objective_lots_history h
|
||||
JOIN radius_lots rl ON rl.objective_lot_id = h.objective_lot_id
|
||||
WHERE h.price_per_m2_rub BETWEEN CAST(:price_min AS numeric)
|
||||
AND CAST(:price_max AS numeric)
|
||||
AND (h.is_sold IS NOT TRUE)
|
||||
AND LOWER(COALESCE(h.status, '')) <> 'продан'
|
||||
ORDER BY h.objective_lot_id, h.snapshot_date, h.fetched_at DESC
|
||||
)
|
||||
SELECT
|
||||
snapshot_date AS snapshot_date,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_price_m2,
|
||||
COUNT(*) AS lots_count,
|
||||
(SELECT COUNT(*) FROM radius_projects) AS complexes_covered
|
||||
FROM hist
|
||||
GROUP BY snapshot_date
|
||||
ORDER BY snapshot_date
|
||||
"""
|
||||
)
|
||||
|
||||
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k–500k):
|
||||
# предложение включает премиум/апартаменты; отсекаем только явный ETL-мусор.
|
||||
_OFFER_PRICE_MIN: int = 10000
|
||||
_OFFER_PRICE_MAX: int = 1000000
|
||||
|
||||
# Радиус ЖК-подбора — зеркало market_trend-хендлера parcels.py (3 км от центроида).
|
||||
_OFFER_TREND_RADIUS_M: int = 3000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OfferPriceTrend:
|
||||
"""Динамика медианной цены предложения (руб/м²) по снапшотам Объектива (#2178).
|
||||
|
||||
Источник — objective_lots_history (weekly-снапшоты активных лотов ЖК в радиусе).
|
||||
Всегда конструируется через ``from_points``; пустой набор точек → None-поля,
|
||||
``points=[]`` (никогда не бросает на отсутствии данных).
|
||||
"""
|
||||
|
||||
points: list[dict[str, Any]] # [{date: 'YYYY-MM-DD', median_price_m2, lots_count}]
|
||||
delta_pct: float | None # (последняя медиана − первая) / первая × 100
|
||||
lots_latest: int | None # lots_count последнего снапшота
|
||||
complexes_covered: int # сколько ЖК (project_name) попало в радиус-выборку
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"points": self.points,
|
||||
"delta_pct": _round_or_none(self.delta_pct, 1),
|
||||
"lots_latest": self.lots_latest,
|
||||
"complexes_covered": self.complexes_covered,
|
||||
}
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""True если есть ≥1 снапшот с медианой — тогда карточку можно показать."""
|
||||
return len(self.points) > 0
|
||||
|
||||
|
||||
def compute_offer_price_trend(
|
||||
db: Session,
|
||||
*,
|
||||
center_lat: float,
|
||||
center_lon: float,
|
||||
radius_m: int = _OFFER_TREND_RADIUS_M,
|
||||
premise_kind: str = "квартира",
|
||||
) -> OfferPriceTrend:
|
||||
"""Медианная цена предложения по снапшотам Объектива для ЖК в радиусе (#2178).
|
||||
|
||||
ЖК определяются тем же путём, что market_trend-хендлер / best_layouts: ЖК в
|
||||
радиусе ``radius_m`` от (``center_lat``, ``center_lon``) по domrf_kn_objects →
|
||||
objective_complex_mapping → objective_lots. По их лотам считается per-snapshot
|
||||
медиана price_per_m2_rub активных лотов (дедуп задвоенного дня, фильтр
|
||||
вменяемости цены).
|
||||
|
||||
Возвращает OfferPriceTrend ВСЕГДА (даже на пустых данных: points=[], поля None,
|
||||
complexes_covered=0). Никогда не бросает на отсутствии данных — на ошибке SQL
|
||||
логирует и возвращает пустой результат (graceful, зеркало _query_stock).
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"center_lat": float(center_lat),
|
||||
"center_lon": float(center_lon),
|
||||
"radius_m": float(radius_m),
|
||||
"premise_kind": premise_kind,
|
||||
"price_min": _OFFER_PRICE_MIN,
|
||||
"price_max": _OFFER_PRICE_MAX,
|
||||
}
|
||||
try:
|
||||
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s)",
|
||||
center_lat,
|
||||
center_lon,
|
||||
radius_m,
|
||||
)
|
||||
rows = []
|
||||
|
||||
points: list[dict[str, Any]] = []
|
||||
complexes_covered = 0
|
||||
for r in rows:
|
||||
# complexes_covered — константа по всем строкам (scalar-subquery), берём
|
||||
# из первой встреченной строки. При пустом hist rows=[] → остаётся 0.
|
||||
complexes_covered = int(r["complexes_covered"] or 0)
|
||||
median = r["median_price_m2"]
|
||||
if median is None:
|
||||
continue
|
||||
snap = r["snapshot_date"]
|
||||
# snapshot_date — DATE; нормализуем (isoformat) вне зависимости от того,
|
||||
# date или datetime отдал драйвер.
|
||||
date_str = snap.isoformat() if hasattr(snap, "isoformat") else str(snap)
|
||||
points.append(
|
||||
{
|
||||
"date": date_str,
|
||||
"median_price_m2": round(float(median)),
|
||||
"lots_count": int(r["lots_count"] or 0),
|
||||
}
|
||||
)
|
||||
|
||||
delta_pct: float | None = None
|
||||
lots_latest: int | None = None
|
||||
if points:
|
||||
first_median = points[0]["median_price_m2"]
|
||||
last_median = points[-1]["median_price_m2"]
|
||||
lots_latest = points[-1]["lots_count"]
|
||||
if first_median and first_median > 0:
|
||||
delta_pct = (last_median - first_median) / first_median * 100.0
|
||||
|
||||
logger.info(
|
||||
"offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d "
|
||||
"lots_latest=%s delta_pct=%s",
|
||||
center_lat,
|
||||
center_lon,
|
||||
radius_m,
|
||||
len(points),
|
||||
lots_latest,
|
||||
_round_or_none(delta_pct, 1),
|
||||
)
|
||||
|
||||
return OfferPriceTrend(
|
||||
points=points,
|
||||
delta_pct=delta_pct,
|
||||
lots_latest=lots_latest,
|
||||
complexes_covered=complexes_covered,
|
||||
)
|
||||
|
|
|
|||
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"]
|
||||
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())
|
||||
|
|
@ -87,6 +87,37 @@ def _infer_development_type(max_floors: int | None) -> str:
|
|||
return "high_rise"
|
||||
|
||||
|
||||
def select_calibrated_price(
|
||||
*,
|
||||
market_price: dict[str, Any] | None,
|
||||
geo_radius_price_median: float | None,
|
||||
district_price_median: float | None,
|
||||
) -> tuple[float | None, str]:
|
||||
"""Выбрать откалиброванную цену продажи жилья + её источник (single source of truth).
|
||||
|
||||
Приоритет от точного рынка к нормативу (совпадает с исходной логикой
|
||||
:func:`synthesize_parcel_financial`, вынесен сюда для переиспользования оптимизатором
|
||||
программы, чтобы выбор цены не разошёлся):
|
||||
|
||||
1. ``market_price.median`` — квартальная MV перепродаж → ``objective_district_median``.
|
||||
2. ``geo_radius_price_median`` — медиана новостроек Objective в гео-радиусе →
|
||||
``objective_geo_radius``.
|
||||
3. ``district_price_median`` — name-match медиана админ-района → ``district_reference``.
|
||||
4. ничего из рынка → ``(None, "class_norm")`` — caller использует норматив класса.
|
||||
|
||||
Returns:
|
||||
``(price, price_source)`` — ``price`` может быть ``None`` (тогда source
|
||||
``"class_norm"``), что сигналит caller-у про fallback на норматив класса.
|
||||
"""
|
||||
if market_price and _as_float(market_price.get("median")) is not None:
|
||||
return _as_float(market_price.get("median")), "objective_district_median"
|
||||
if _as_float(geo_radius_price_median) is not None:
|
||||
return _as_float(geo_radius_price_median), "objective_geo_radius"
|
||||
if district_price_median is not None:
|
||||
return float(district_price_median), "district_reference"
|
||||
return None, "class_norm"
|
||||
|
||||
|
||||
def synthesize_teap_from_buildability(
|
||||
*,
|
||||
area_m2: float | None,
|
||||
|
|
@ -238,18 +269,13 @@ def synthesize_parcel_financial(
|
|||
|
||||
# ── Цена продажи: quarter-MV → гео-радиус → district median (рыночный прокси) ─
|
||||
# Приоритет от точного рынка к нормативу. Гео-радиус вставлен МЕЖДУ квартальной MV
|
||||
# и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ.
|
||||
price: float | None = None
|
||||
price_source = "class_norm"
|
||||
if market_price and _as_float(market_price.get("median")) is not None:
|
||||
price = _as_float(market_price.get("median"))
|
||||
price_source = "objective_district_median"
|
||||
elif _as_float(geo_radius_price_median) is not None:
|
||||
price = _as_float(geo_radius_price_median)
|
||||
price_source = "objective_geo_radius"
|
||||
elif district_price_median is not None:
|
||||
price = float(district_price_median)
|
||||
price_source = "district_reference"
|
||||
# и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ. Логика
|
||||
# вынесена в общий helper (single-source-of-truth с program_optimizer).
|
||||
price, price_source = select_calibrated_price(
|
||||
market_price=market_price,
|
||||
geo_radius_price_median=geo_radius_price_median,
|
||||
district_price_median=district_price_median,
|
||||
)
|
||||
|
||||
housing_class = _infer_housing_class(price)
|
||||
development_type = _infer_development_type(max_floors)
|
||||
|
|
@ -331,6 +357,7 @@ def _as_int(value: Any) -> int | None:
|
|||
|
||||
|
||||
__all__ = [
|
||||
"select_calibrated_price",
|
||||
"synthesize_parcel_financial",
|
||||
"synthesize_teap_from_buildability",
|
||||
]
|
||||
|
|
|
|||
229
backend/app/services/site_finder/program_optimizer.py
Normal file
229
backend/app/services/site_finder/program_optimizer.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Оптимизатор программы застройки — топ-N вариантов (класс × этажность) по NPV (#2181).
|
||||
|
||||
Когда финмодель участка (``financial_estimate`` в ``/analyze``) даёт ОТРИЦАТЕЛЬНЫЙ
|
||||
вердикт (NPV < 0 или ROI < 0), девелоперу мало «не выгодно» — важно честно показать,
|
||||
существует ли ВООБЩЕ прибыльная раскладка на этом участке при других параметрах
|
||||
(класс жилья + этажность), и если нет — какие варианты «наименее убыточные».
|
||||
|
||||
Этот модуль перебирает небольшую сетку (класс × этажность), прогоняет каждый комбо
|
||||
через ту же :func:`compute_financial`, что и основная финмодель, и возвращает топ-N по
|
||||
NPV. Всё детерминированно и **быстро** (≈15 мс на 12 комбо → считается СИНХРОННО в теле
|
||||
запроса, без Celery), и **чисто** — без БД / LLM / внешних API. Данные участка
|
||||
(площадь, предельные параметры зоны, откалиброванная цена, стоимость земли, абсорбция)
|
||||
прокидывает caller — ровно те же входы, что использованы для основного
|
||||
``financial_estimate`` (см. ``synthesize_parcel_financial``).
|
||||
|
||||
КРИТИЧНО — ЕДИНАЯ ЦЕНА ВО ВСЕХ КОМБО
|
||||
------------------------------------
|
||||
Когда рыночная цена известна, ОДНА И ТА ЖЕ откалиброванная ``market_price_per_sqm`` идёт
|
||||
во все комбо (класс двигает ТОЛЬКО себестоимость/эффективность/паркинг/ср.лот, но НЕ
|
||||
цену продажи). Иначе «business» ложно выиграл бы за счёт норматива продажи 210k против
|
||||
110k у эконома — сравнивали бы цены, а не экономику застройки. Норматив класса
|
||||
(``_SALE_PRICE_PER_SQM``) применяется ТОЛЬКО когда рынка нет — то же поведение, что в
|
||||
:func:`synthesize_parcel_financial`.
|
||||
|
||||
HONEST CAVEAT
|
||||
-------------
|
||||
Как и основная финмодель — это оценка по МАКСИМАЛЬНОЙ застройке зоны (синтез ТЭП из
|
||||
предельных параметров градрегламента), а НЕ по реальной проектной раскладке. Caveat
|
||||
(``_FINANCIAL_CAVEAT``) кладётся в результат и обязан показываться в UI/PDF.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, get_args
|
||||
|
||||
from app.services.generative.financial import HousingClass, compute_financial
|
||||
from app.services.site_finder.parcel_financial import (
|
||||
_FINANCIAL_CAVEAT,
|
||||
_infer_development_type,
|
||||
synthesize_teap_from_buildability,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Классы жилья для перебора — весь набор (econom / comfort / business). Берём из
|
||||
# Literal-типа HousingClass, чтобы сетка не разошлась с финмоделью при добавлении класса.
|
||||
_CLASSES_GRID: tuple[HousingClass, ...] = get_args(HousingClass)
|
||||
|
||||
# Опорная сетка этажности (кламп по max_floors_allowed зоны). Значения — типовые
|
||||
# высотные пороги РФ (5 — среднеэтажка, 9 — «хрущёвочный» потолок, 14 — высотка,
|
||||
# 25 — башня); двигают GFA и development_type (порог spot/mid_rise/high_rise в
|
||||
# _infer_development_type → длительность СМР в DCF-графике).
|
||||
_FLOORS_GRID: tuple[int, ...] = (5, 9, 14, 25)
|
||||
|
||||
_DEFAULT_TOP_N: int = 3
|
||||
|
||||
|
||||
def _build_floors_grid(max_floors_allowed: int | None) -> list[int]:
|
||||
"""Сетка этажности, клампнутая по предельной этажности зоны.
|
||||
|
||||
* ``max_floors_allowed`` не задан (``None``) → весь опорный набор (зона неизвестна,
|
||||
перебираем всё).
|
||||
* задан → оставляем только этажи ``<= max_floors_allowed``.
|
||||
* после клампа пусто (предел ниже минимального опорного, напр. 3) → берём сам
|
||||
``max_floors_allowed`` (единственная физически допустимая этажность).
|
||||
"""
|
||||
if max_floors_allowed is None:
|
||||
return list(_FLOORS_GRID)
|
||||
clamped = [f for f in _FLOORS_GRID if f <= max_floors_allowed]
|
||||
if not clamped:
|
||||
# Предел ниже минимального опорного (напр. 3 этажа) — считаем по самому пределу.
|
||||
return [max_floors_allowed]
|
||||
return clamped
|
||||
|
||||
|
||||
def optimize_program(
|
||||
*,
|
||||
area_m2: float | None,
|
||||
max_far: float | None,
|
||||
max_building_pct: float | None,
|
||||
max_floors_allowed: int | None,
|
||||
land_cost_rub: float | None,
|
||||
market_price_per_sqm: float | None,
|
||||
price_source: str,
|
||||
velocity_sqm_per_month: float | None,
|
||||
top_n: int = _DEFAULT_TOP_N,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Перебрать сетку (класс × этажность) → топ-N вариантов программы по NPV.
|
||||
|
||||
Прогоняет каждый комбо через :func:`compute_financial` (та же финмодель, что и
|
||||
основной ``financial_estimate``) и ранжирует по NPV убыв. (тай-брейк — ROI убыв.).
|
||||
|
||||
Args:
|
||||
area_m2: площадь участка, кв.м. ``None``/<=0 → ``None`` (нечего считать).
|
||||
max_far: КСИТ/max_far зоны (руб/кв.м GFA). Задаёт GFA при синтезе ТЭП. Если
|
||||
``None``, GFA выводится из ``max_building_pct × floors`` (по каждому комбо
|
||||
своя этажность из сетки).
|
||||
max_building_pct: % застройки зоны. Нужен вместе с этажностью, если нет ``max_far``.
|
||||
max_floors_allowed: предельная этажность зоны — кламп сетки этажности. ``None`` →
|
||||
весь опорный набор.
|
||||
land_cost_rub: стоимость участка (обычно кадастровая) → land_cost. ``None`` → 0.
|
||||
market_price_per_sqm: ЕДИНАЯ откалиброванная рыночная цена продажи жилья во ВСЕ
|
||||
комбо. ``None`` → каждый комбо использует норматив своего класса
|
||||
(``_SALE_PRICE_PER_SQM``) — то же поведение, что в основной финмодели.
|
||||
price_source: honest-метка источника цены (из ``select_calibrated_price``).
|
||||
Игнорируется финмоделью, если ``market_price_per_sqm is None`` (форсится
|
||||
``class_norm``).
|
||||
velocity_sqm_per_month: темп абсорбции района (м²/мес) — окно продаж DCF. ``None``
|
||||
→ дефолт-30-мес график (``schedule_is_default``).
|
||||
top_n: сколько лучших вариантов вернуть (по умолчанию 3).
|
||||
|
||||
Returns:
|
||||
``dict`` с ключами ``variants`` (список топ-N), ``any_viable`` (хоть один вариант
|
||||
с NPV > 0), ``grid_size`` (сколько комбо реально посчиталось), ``caveat``. Либо
|
||||
``None``, если считать нельзя (нет площади / ни один комбо не дал ТЭП).
|
||||
"""
|
||||
if area_m2 is None or area_m2 <= 0:
|
||||
return None
|
||||
|
||||
floors_grid = _build_floors_grid(max_floors_allowed)
|
||||
|
||||
variants: list[dict[str, Any]] = []
|
||||
for housing_class in _CLASSES_GRID:
|
||||
for floors in floors_grid:
|
||||
variant = _evaluate_combo(
|
||||
housing_class=housing_class,
|
||||
floors=floors,
|
||||
area_m2=area_m2,
|
||||
max_far=max_far,
|
||||
max_building_pct=max_building_pct,
|
||||
land_cost_rub=land_cost_rub,
|
||||
market_price_per_sqm=market_price_per_sqm,
|
||||
price_source=price_source,
|
||||
velocity_sqm_per_month=velocity_sqm_per_month,
|
||||
)
|
||||
if variant is not None:
|
||||
variants.append(variant)
|
||||
|
||||
if not variants:
|
||||
# Ни один комбо не дал ТЭП (нет ни far, ни pct+floors) — считать нечего.
|
||||
return None
|
||||
|
||||
# Ранжир по NPV убыв.; тай-брейк — ROI убыв. (при равном NPV предпочитаем
|
||||
# эффективность капитала). При all-negative топ-N — «наименее убыточные».
|
||||
variants.sort(key=lambda v: (v["npv_rub"], v["roi"]), reverse=True)
|
||||
|
||||
any_viable = any(v["npv_rub"] > 0 for v in variants)
|
||||
|
||||
return {
|
||||
"variants": variants[:top_n],
|
||||
"any_viable": any_viable,
|
||||
"grid_size": len(variants),
|
||||
# HEAVY caveat — тот же, что у основной финмодели (оценка по МАКС. застройке
|
||||
# зоны, НЕ по реальной раскладке). Обязан показываться в UI/PDF.
|
||||
"caveat": _FINANCIAL_CAVEAT,
|
||||
}
|
||||
|
||||
|
||||
def _evaluate_combo(
|
||||
*,
|
||||
housing_class: HousingClass,
|
||||
floors: int,
|
||||
area_m2: float,
|
||||
max_far: float | None,
|
||||
max_building_pct: float | None,
|
||||
land_cost_rub: float | None,
|
||||
market_price_per_sqm: float | None,
|
||||
price_source: str,
|
||||
velocity_sqm_per_month: float | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Посчитать один комбо (класс × этажность) → компактный вариант программы.
|
||||
|
||||
Сбой одного комбо (например, ТЭП не вывелся) НЕ валит весь грид — логируется и
|
||||
возвращается ``None`` (caller скипает). Это тот же «пояс безопасности», что у
|
||||
``financial_estimate`` в analyze-эндпоинте.
|
||||
"""
|
||||
try:
|
||||
# development_type по ЭТАЖНОСТИ комбо (не по зоне) — этаж двигает график СМР в DCF.
|
||||
development_type = _infer_development_type(floors)
|
||||
|
||||
teap = synthesize_teap_from_buildability(
|
||||
area_m2=area_m2,
|
||||
max_far=max_far,
|
||||
max_building_pct=max_building_pct,
|
||||
max_floors=floors,
|
||||
housing_class=housing_class,
|
||||
)
|
||||
if teap is None:
|
||||
return None
|
||||
|
||||
fin = compute_financial(
|
||||
teap=teap,
|
||||
housing_class=housing_class,
|
||||
land_cost_rub=land_cost_rub,
|
||||
# ЕДИНАЯ цена во все комбо (когда рынок есть) — класс НЕ двигает цену продажи.
|
||||
market_price_per_sqm=market_price_per_sqm,
|
||||
price_source=price_source,
|
||||
development_type=development_type,
|
||||
market_velocity_sqm_per_month=velocity_sqm_per_month,
|
||||
)
|
||||
|
||||
return {
|
||||
"housing_class": housing_class,
|
||||
"floors": floors,
|
||||
"development_type": development_type,
|
||||
"npv_rub": fin.npv_rub,
|
||||
"irr": fin.irr,
|
||||
"roi": fin.roi,
|
||||
"net_profit_rub": fin.net_profit_rub,
|
||||
"residential_sqm": teap.residential_area_sqm,
|
||||
"apartments_count": teap.apartments_count,
|
||||
# honest-поля источника/графика цены — те же флаги, что в основной финмодели.
|
||||
"price_per_sqm_used": fin.price_per_sqm_used,
|
||||
"price_source": fin.price_source,
|
||||
"schedule_is_default": fin.schedule_is_default,
|
||||
}
|
||||
except Exception:
|
||||
# Один сбойный комбо не валит грид — логируем и скипаем (не глотаем молча).
|
||||
logger.exception(
|
||||
"program_optimizer combo failed: class=%s floors=%d area=%.0f",
|
||||
housing_class,
|
||||
floors,
|
||||
area_m2,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["optimize_program"]
|
||||
|
|
@ -1300,14 +1300,51 @@ def _get_engineering_structures_by_boundary(
|
|||
except (ValueError, json.JSONDecodeError):
|
||||
geom_dict = {}
|
||||
|
||||
# Реальные атрибуты НСПД-дампа (cat 36328) лежат в props["options"]:
|
||||
# params_name — имя с типом/напряжением («КЛ 10кВ ТП 1077-ТП 5419»)
|
||||
# params_purpose — назначение («1.1. Сооружения электроэнергетики»)
|
||||
# object_type_value — «Сооружение»
|
||||
# address_readable_address, cad_number, год/протяжённость/собственность
|
||||
# Top-level props их НЕ содержит (только опаковые category/subcategory),
|
||||
# поэтому раньше попап показывал generic «Объект». Читаем options.* с
|
||||
# fallback на top-level/legacy ключи.
|
||||
opts: dict[str, Any] = props["options"] if isinstance(props.get("options"), dict) else {}
|
||||
name = (
|
||||
opts.get("params_name")
|
||||
or props.get("name")
|
||||
or props.get("object_name")
|
||||
or opts.get("object_type_value")
|
||||
)
|
||||
ptype = (
|
||||
opts.get("params_purpose")
|
||||
or opts.get("object_type_value")
|
||||
or props.get("purpose")
|
||||
or props.get("object_type")
|
||||
or props.get("type_zone")
|
||||
)
|
||||
cad = opts.get("cad_number") or props.get("cad_num") or props.get("cadastral_number")
|
||||
address = (
|
||||
opts.get("address_readable_address")
|
||||
or props.get("readable_address")
|
||||
or props.get("address")
|
||||
)
|
||||
clean_type = _clean_struct_str(ptype)
|
||||
# purpose («1.1. Сооружения электроэнергетики») — первый непустой из
|
||||
# params_purpose / object_type_value. Показываем ТОЛЬКО когда отличается от
|
||||
# уже вычисленного type (иначе дубль-шум в попапе → оставляем None).
|
||||
purpose_raw = opts.get("params_purpose") or opts.get("object_type_value")
|
||||
clean_purpose = _clean_struct_str(purpose_raw)
|
||||
purpose = clean_purpose if clean_purpose and clean_purpose != clean_type else None
|
||||
result.append(
|
||||
{
|
||||
"name": props.get("name") or props.get("object_name"),
|
||||
"type": props.get("purpose") or props.get("object_type") or props.get("type_zone"),
|
||||
"cad_num": props.get("cad_num") or props.get("cadastral_number"),
|
||||
"name": _clean_struct_str(name),
|
||||
"type": clean_type,
|
||||
"cad_num": _clean_struct_str(cad),
|
||||
"purpose": purpose,
|
||||
"distance_to_boundary_m": round(distance_m, 1),
|
||||
"geometry_geojson": geom_dict,
|
||||
"readable_address": props.get("readable_address") or props.get("address"),
|
||||
"readable_address": _clean_struct_str(address),
|
||||
"characteristics": _build_structure_characteristics(opts),
|
||||
"raw_props": props,
|
||||
"source": "nspd_36328",
|
||||
}
|
||||
|
|
@ -1315,6 +1352,44 @@ def _get_engineering_structures_by_boundary(
|
|||
return result
|
||||
|
||||
|
||||
def _clean_struct_str(value: Any) -> str | None:
|
||||
"""Нормализовать строковый атрибут НСПД: strip, None для пустого / «-»."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
return s if s and s != "-" else None
|
||||
|
||||
|
||||
def _build_structure_characteristics(opts: dict[str, Any]) -> str | None:
|
||||
"""Собрать читаемую строку характеристик из options НСПД-дампа (cat 36328).
|
||||
|
||||
Год постройки · протяжённость · форма собственности — то, что реально есть в
|
||||
дампе. Чисто презентационная агрегация; None, если ничего нет.
|
||||
"""
|
||||
if not isinstance(opts, dict):
|
||||
return None
|
||||
parts: list[str] = []
|
||||
year = _clean_struct_str(opts.get("params_year_built"))
|
||||
if year:
|
||||
parts.append(f"{year} г.")
|
||||
ext = opts.get("params_extension")
|
||||
try:
|
||||
ext_val = float(ext) if ext not in (None, "") else 0.0
|
||||
except (TypeError, ValueError):
|
||||
ext_val = 0.0
|
||||
if ext_val > 0:
|
||||
parts.append(f"протяжённость {round(ext_val)} м")
|
||||
ownership = _clean_struct_str(opts.get("ownership_type"))
|
||||
if ownership:
|
||||
low = ownership.lower()
|
||||
parts.append(
|
||||
f"{low} собственность"
|
||||
if low in ("частная", "государственная", "муниципальная", "смешанная")
|
||||
else ownership
|
||||
)
|
||||
return " · ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _get_zouit_engineering_overlaps(
|
||||
db: Session,
|
||||
quarter: str,
|
||||
|
|
|
|||
408
backend/app/services/site_finder/rosseti_reserve_loader.py
Normal file
408
backend/app/services/site_finder/rosseti_reserve_loader.py
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
"""Загрузчик резервов свободной мощности Россети Урал из xlsx-раскрытия (#2119).
|
||||
|
||||
Два xlsx-файла раскрытия информации ФАС (rosseti-ural.ru):
|
||||
(а) «свободная мощность по ЦП» → апдейт power_supply_centers
|
||||
(б) «свободная мощность для ТП по ПС и РП <35» → UPSERT power_tp_rp_reserves
|
||||
|
||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → РАБОТАЕТ НА ПРОДЕ (Celery / docker exec).
|
||||
|
||||
Структуры xlsx ВЕРИФИЦИРОВАНЫ (см. docstring-и функций): фиксированные offset'ы
|
||||
строк заголовков/нумерации/данных. openpyxl read_only для памяти на 10k+ строках.
|
||||
Матч ЦП-резерва к power_supply_centers — по ОБЩЕМУ нормализатору normalize_sc_name.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.site_finder.rosseti_wfs_loader import normalize_sc_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Punycode-host не требуется — rosseti-ural.ru латиница.
|
||||
DISCLOSURE_URL = "https://rosseti-ural.ru/company/disclosure/monopoly/characteristic/bandwidth/"
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# «н/д» / «-» / пусто → None (нет данных).
|
||||
_NODATA_TOKENS = frozenset({"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных"})
|
||||
|
||||
# Дата актуальности «на 30.06.2026» из имени файла или заголовка листа.
|
||||
_ASOF_RE = re.compile(r"на\s+(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})", re.IGNORECASE)
|
||||
# Дата в имени файла для выбора «свежайшего» (DD.MM.YYYY / DD-MM-YYYY / YYYY-MM-DD).
|
||||
_FILE_DATE_RE = re.compile(
|
||||
r"(\d{1,2})[.\-](\d{1,2})[.\-](\d{4})|(\d{4})[.\-](\d{1,2})[.\-](\d{1,2})"
|
||||
)
|
||||
|
||||
# Порог кВА-санитайза: у ТП установленная мощность в МВА физически <2.5;
|
||||
# значение выше — почти наверняка забито в кВА (÷1000). РП не трогаем.
|
||||
_TP_KVA_THRESHOLD_MVA = 2.5
|
||||
|
||||
|
||||
def parse_reserve_number(value: object) -> float | None:
|
||||
"""Парсит числовую ячейку резерва/мощности xlsx → float.
|
||||
|
||||
«н/д»/пусто → None. Запятая-десятичный → точка. Сохраняет знак
|
||||
(резервы бывают отрицательными = дефицит). Нечисловой мусор → None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int | float):
|
||||
return float(value)
|
||||
s = str(value).strip().lower()
|
||||
if s in _NODATA_TOKENS:
|
||||
return None
|
||||
# Убираем пробелы-разделители тысяч (в т.ч. неразрывные) и нормализуем запятую.
|
||||
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||
# Оставляем ведущий минус + цифры + точку.
|
||||
m = re.match(r"^-?\d+(?:\.\d+)?$", s)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_asof_date(*sources: str | None) -> date | None:
|
||||
"""Ищет дату «на DD.MM.YYYY» в переданных строках (имя файла / заголовок листа).
|
||||
|
||||
Возвращает первую найденную date. None — если нигде нет.
|
||||
"""
|
||||
for src in sources:
|
||||
if not src:
|
||||
continue
|
||||
m = _ASOF_RE.search(src)
|
||||
if m:
|
||||
day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
try:
|
||||
return date(year, month, day)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _file_date_key(filename: str) -> tuple[int, int, int]:
|
||||
"""Ключ сортировки «свежести» по дате в имени файла. Нет даты → (0,0,0)."""
|
||||
m = _FILE_DATE_RE.search(filename)
|
||||
if not m:
|
||||
return (0, 0, 0)
|
||||
if m.group(1): # DD-MM-YYYY
|
||||
return (int(m.group(3)), int(m.group(2)), int(m.group(1)))
|
||||
return (int(m.group(4)), int(m.group(5)), int(m.group(6))) # YYYY-MM-DD
|
||||
|
||||
|
||||
def sanitize_tp_capacity_mva(installed: float | None, is_rp: bool) -> tuple[float | None, bool]:
|
||||
"""кВА-санитайз установленной мощности ТП.
|
||||
|
||||
Часть строк ошибочно забивает кВА в колонку МВА. Если это ТП (не РП) и
|
||||
installed > порога (2.5 МВА физически невозможно для ТП) — считаем что это
|
||||
кВА и делим на 1000. Возвращает (значение_МВА, был_ли_санитайз).
|
||||
РП не трогаем (у РП мощность легитимно бывает высокой).
|
||||
"""
|
||||
if installed is None or is_rp:
|
||||
return installed, False
|
||||
if installed > _TP_KVA_THRESHOLD_MVA:
|
||||
return installed / 1000.0, True
|
||||
return installed, False
|
||||
|
||||
|
||||
def fetch_disclosure_links() -> dict[str, str]:
|
||||
"""Скрейпит страницу раскрытия → ссылки на СВЕЖАЙШИЕ xlsx (ЦП + ТП/РП).
|
||||
|
||||
RUN-ON-PROD (гео-блок). GET с follow_redirects, regex по href .xlsx, фильтр
|
||||
Свердловск-related, выбор LATEST по дате-в-имени для двух типов файлов.
|
||||
|
||||
Returns: {"cp": url|'', "tp_rp": url|''} — пустые если не нашлось.
|
||||
"""
|
||||
resp = httpx.get(DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
|
||||
# href на .xlsx (относительные и абсолютные).
|
||||
hrefs = re.findall(r'href=["\']([^"\']+?\.xlsx)["\']', html, re.IGNORECASE)
|
||||
base = "https://rosseti-ural.ru"
|
||||
|
||||
# ВАЖНО (прод-инцидент 2026-07-02): href'ы URL-энкоженные (кириллица как
|
||||
# %D0%A1...) — фильтр по СЫРОЙ строке никогда не матчился → 'no cp link
|
||||
# found'. Фильтруем/классифицируем/датируем по ДЕКОДИРОВАННОМУ имени, а
|
||||
# скачиваем по сырому href (энкоженный URL корректен для httpx).
|
||||
cp_candidates: list[tuple[str, str]] = [] # (url, decoded_low)
|
||||
tp_candidates: list[tuple[str, str]] = []
|
||||
for href in hrefs:
|
||||
decoded = unquote(href).lower()
|
||||
# Свердловск-фильтр: «Свердловской области» (ЦП) / «Свердловэнерго» (ТП/РП).
|
||||
if "свердлов" not in decoded and "sverdl" not in decoded:
|
||||
continue
|
||||
url = href if href.startswith("http") else base + href
|
||||
if "по цп" in decoded:
|
||||
cp_candidates.append((url, decoded))
|
||||
elif "для тп" in decoded and ("рп" in decoded or "35" in decoded):
|
||||
tp_candidates.append((url, decoded))
|
||||
|
||||
def _latest(cands: list[tuple[str, str]]) -> str:
|
||||
# Дата «на DD.MM.YYYY» — в декодированном имени файла.
|
||||
return max(cands, key=lambda c: _file_date_key(c[1]))[0] if cands else ""
|
||||
|
||||
result = {"cp": _latest(cp_candidates), "tp_rp": _latest(tp_candidates)}
|
||||
logger.info(
|
||||
"rosseti disclosure links: cp=%r tp_rp=%r (from %d hrefs)",
|
||||
result["cp"],
|
||||
result["tp_rp"],
|
||||
len(hrefs),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _cell(row: tuple, idx: int) -> object:
|
||||
"""Безопасно достаёт ячейку row по 0-based индексу (None если за границей)."""
|
||||
return row[idx] if idx < len(row) else None
|
||||
|
||||
|
||||
def load_cp_reserves(db: Session, xlsx_bytes: bytes, filename: str = "") -> dict[str, int]:
|
||||
"""Апдейт power_supply_centers резервами по ЦП из xlsx-раскрытия.
|
||||
|
||||
ВЕРИФИЦИРОВАННАЯ структура ЦП-файла: заголовки строки 5-6, строка 7 =
|
||||
нумерация столбцов, ДАННЫЕ С СТРОКИ 8. Колонки (1-based Excel → 0-based idx):
|
||||
B(1)=ЦП name, C(2)=ПО (баланс), E(4)=municipality, F(5)=voltage class,
|
||||
G(6)=installed МВА, H(7)=current load МВА, I(8)=reserve МВА.
|
||||
Матч к power_supply_centers по sc_name_norm (тот же нормализатор!) →
|
||||
installed/current/reserve/capacity_source='cp_35_110'. Unmatched → лог count.
|
||||
|
||||
Returns: счётчики rows/matched/unmatched.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
asof = parse_asof_date(filename, ws.title if ws is not None else None)
|
||||
|
||||
rows_seen = 0
|
||||
matched = 0
|
||||
unmatched = 0
|
||||
unmatched_names: list[str] = []
|
||||
|
||||
try:
|
||||
# min_row=8 → данные с 8-й строки (1-based). iter_rows отдаёт кортежи values.
|
||||
for row in ws.iter_rows(min_row=8, values_only=True):
|
||||
cp_name = _cell(row, 1) # B
|
||||
if cp_name is None or not str(cp_name).strip():
|
||||
continue
|
||||
rows_seen += 1
|
||||
|
||||
name_norm = normalize_sc_name(str(cp_name))
|
||||
installed = parse_reserve_number(_cell(row, 6)) # G
|
||||
current = parse_reserve_number(_cell(row, 7)) # H
|
||||
reserve = parse_reserve_number(_cell(row, 8)) # I
|
||||
municipality = _cell(row, 4) # E
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
res = db.execute(
|
||||
text("""
|
||||
UPDATE power_supply_centers
|
||||
SET installed_capacity_mva = :installed,
|
||||
current_load_mva = :current,
|
||||
reserve_mva = :reserve,
|
||||
reserve_asof = :asof,
|
||||
capacity_source = 'cp_35_110',
|
||||
municipality = COALESCE(municipality, :municipality)
|
||||
WHERE sc_name_norm = :name_norm
|
||||
"""),
|
||||
{
|
||||
"installed": installed,
|
||||
"current": current,
|
||||
"reserve": reserve,
|
||||
"asof": asof,
|
||||
"municipality": (str(municipality).strip() if municipality else None),
|
||||
"name_norm": name_norm,
|
||||
},
|
||||
)
|
||||
if res.rowcount and res.rowcount > 0:
|
||||
matched += res.rowcount
|
||||
else:
|
||||
unmatched += 1
|
||||
if len(unmatched_names) < 20:
|
||||
unmatched_names.append(str(cp_name).strip())
|
||||
except Exception as e:
|
||||
logger.warning("cp_reserve update failed for %r: %s", cp_name, e)
|
||||
unmatched += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_cp_reserves: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
logger.info(
|
||||
"load_cp_reserves done: rows=%d matched=%d unmatched=%d (sample unmatched=%s)",
|
||||
rows_seen,
|
||||
matched,
|
||||
unmatched,
|
||||
unmatched_names[:10],
|
||||
)
|
||||
return {"rows": rows_seen, "matched": matched, "unmatched": unmatched}
|
||||
|
||||
|
||||
def _is_rp(name: str) -> bool:
|
||||
"""Строка про РП (распределительный пункт), а не ТП? Влияет на кВА-санитайз."""
|
||||
return bool(re.search(r"\bрп\b|рп[\s-]?\d", name.lower()))
|
||||
|
||||
|
||||
def load_tp_rp_reserves(db: Session, xlsx_bytes: bytes, filename: str = "") -> dict[str, int]:
|
||||
"""UPSERT резервов ТП/РП <35 кВ в power_tp_rp_reserves из xlsx-раскрытия.
|
||||
|
||||
ВЕРИФИЦИРОВАННАЯ структура: заголовки строки 6-7, нумерация строка 8,
|
||||
ДАННЫЕ С СТРОКИ 9 (~10 741 строк). Колонки (0-based idx):
|
||||
0=name (ТП-721…), 1=РЭС, 2=region, 3=municipality/settlement,
|
||||
4=voltage kV, 5=installed МВА, 6=load МВА, 7=reserve МВА.
|
||||
кВА-санитайз: у ТП (не РП) installed > 2.5 → делим на 1000 (лог count).
|
||||
«н/д» → NULL. UPSERT по UNIQUE(name_norm, municipality, voltage_kv).
|
||||
Per-row SAVEPOINT. Returns: счётчики.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
asof = parse_asof_date(filename, ws.title if ws is not None else None)
|
||||
|
||||
rows_seen = 0
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
kva_sanitized = 0
|
||||
|
||||
try:
|
||||
for row in ws.iter_rows(min_row=9, values_only=True):
|
||||
name = _cell(row, 0)
|
||||
if name is None or not str(name).strip():
|
||||
continue
|
||||
name_str = str(name).strip()
|
||||
rows_seen += 1
|
||||
|
||||
res_unit = _cell(row, 1)
|
||||
municipality = _cell(row, 3)
|
||||
voltage_kv = _cell(row, 4)
|
||||
installed = parse_reserve_number(_cell(row, 5))
|
||||
current = parse_reserve_number(_cell(row, 6))
|
||||
reserve = parse_reserve_number(_cell(row, 7))
|
||||
|
||||
installed, sanitized = sanitize_tp_capacity_mva(installed, _is_rp(name_str))
|
||||
if sanitized:
|
||||
kva_sanitized += 1
|
||||
|
||||
params = {
|
||||
"name": name_str,
|
||||
"name_norm": normalize_sc_name(name_str),
|
||||
"res_unit": str(res_unit).strip() if res_unit else None,
|
||||
"municipality": str(municipality).strip() if municipality else None,
|
||||
"voltage_kv": str(voltage_kv).strip() if voltage_kv else None,
|
||||
"installed": installed,
|
||||
"current": current,
|
||||
"reserve": reserve,
|
||||
"asof": asof,
|
||||
}
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
result = db.execute(
|
||||
# settlement намеренно = municipality: в xlsx Россетей это ОДНА
|
||||
# колонка «МО/населённый пункт»; отдельного settlement источник
|
||||
# не даёт (колонка в схеме — задел под Фазу B/геокод).
|
||||
text("""
|
||||
INSERT INTO power_tp_rp_reserves
|
||||
(name, name_norm, res_unit, municipality, settlement,
|
||||
voltage_kv, installed_capacity_mva, current_load_mva,
|
||||
reserve_mva, reserve_asof, fetched_at)
|
||||
VALUES (
|
||||
:name, :name_norm, :res_unit, :municipality, :municipality,
|
||||
:voltage_kv, :installed, :current,
|
||||
:reserve, :asof, NOW()
|
||||
)
|
||||
ON CONFLICT (name_norm, municipality, voltage_kv) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
res_unit = EXCLUDED.res_unit,
|
||||
installed_capacity_mva = EXCLUDED.installed_capacity_mva,
|
||||
current_load_mva = EXCLUDED.current_load_mva,
|
||||
reserve_mva = EXCLUDED.reserve_mva,
|
||||
reserve_asof = EXCLUDED.reserve_asof,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning("tp_rp_reserve upsert failed for %r: %s", name_str, e)
|
||||
skipped += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_tp_rp_reserves: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
result_dict = {
|
||||
"rows": rows_seen,
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"kva_sanitized": kva_sanitized,
|
||||
}
|
||||
logger.info("load_tp_rp_reserves done: %s", result_dict)
|
||||
return result_dict
|
||||
|
||||
|
||||
def load_all_reserves(db: Session | None = None) -> dict[str, dict]:
|
||||
"""Полный прогон: discovery ссылок + скачивание + загрузка ЦП и ТП/РП резервов.
|
||||
|
||||
RUN-ON-PROD. Каждый шаг graceful: сбой одного файла не валит другой.
|
||||
Returns: {"cp": {...}, "tp_rp": {...}} со счётчиками (или {"error": ...}).
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
links = fetch_disclosure_links()
|
||||
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
|
||||
if links.get("cp"):
|
||||
try:
|
||||
r = client.get(links["cp"])
|
||||
r.raise_for_status()
|
||||
# unquote: дата «на DD.MM.YYYY» ищется в ДЕКОДИРОВАННОМ имени.
|
||||
out["cp"] = load_cp_reserves(db, r.content, unquote(links["cp"]))
|
||||
except Exception as e:
|
||||
logger.exception("load_all_reserves: cp file failed: %s", e)
|
||||
out["cp"] = {"error": str(e)}
|
||||
else:
|
||||
out["cp"] = {"error": "no cp link found"}
|
||||
|
||||
if links.get("tp_rp"):
|
||||
try:
|
||||
r = client.get(links["tp_rp"])
|
||||
r.raise_for_status()
|
||||
out["tp_rp"] = load_tp_rp_reserves(db, r.content, unquote(links["tp_rp"]))
|
||||
except Exception as e:
|
||||
logger.exception("load_all_reserves: tp_rp file failed: %s", e)
|
||||
out["tp_rp"] = {"error": str(e)}
|
||||
else:
|
||||
out["tp_rp"] = {"error": "no tp_rp link found"}
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_all_reserves done: %s", out)
|
||||
return out
|
||||
269
backend/app/services/site_finder/rosseti_wfs_loader.py
Normal file
269
backend/app/services/site_finder/rosseti_wfs_loader.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""Загрузчик центров питания (ЦП) Россети из WFS — свободная мощность §3 (#2119).
|
||||
|
||||
No-B2B верифицированный источник: WFS геосервера портала-тп.рф отдаёт ~488
|
||||
центров питания (ПС 35/110 кВ) по Свердловской области с координатами (WGS84,
|
||||
округлены ~100 м), классом напряжения и индексом загрузки (открыт/ограничен/
|
||||
закрыт для техприсоединения). UPSERT-ит в ``power_supply_centers``.
|
||||
|
||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly /
|
||||
manual docker exec). Punycode-host ОБЯЗАТЕЛЕН — кириллический алиас портал-тп.рф
|
||||
не резолвится корректно из curl/httpx.
|
||||
|
||||
Структура зеркалит utility_infrastructure_loader: httpx с явным таймаутом,
|
||||
per-row SAVEPOINT при UPSERT (битая фича не валит weekly-sync).
|
||||
|
||||
Также экспортирует ``normalize_sc_name`` — общий нормализатор имён ЦП, которым
|
||||
пользуется и ``rosseti_reserve_loader`` (матч xlsx-резерва по имени), и endpoint.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Punycode-host ОБЯЗАТЕЛЕН (кириллический алиас портал-тп.рф не для curl/httpx).
|
||||
# xn----7sb7akeedqd.xn--p1ai == портал-тп.рф.
|
||||
WFS_BASE_URL = "https://xn----7sb7akeedqd.xn--p1ai/geoserver/wfs"
|
||||
|
||||
# Слой центров питания (полный вид с резервами/напряжением).
|
||||
_WFS_TYPE_NAME = "gisosslabs:sc_points_fullview"
|
||||
|
||||
# CQL-фильтр по региону — только Свердловская область.
|
||||
_WFS_CQL_FILTER = "rg_code='SverdlovskOblast'"
|
||||
|
||||
# Таймаут WFS-запроса (сек). ~488 фич — с запасом.
|
||||
_WFS_TIMEOUT = 60
|
||||
|
||||
# sc_indexload_id (справочник Россети) → индекс загрузки ЦП. Неизвестный id →
|
||||
# None (сырое значение остаётся в raw). Верифицированные id из справочника:
|
||||
_INDEXLOAD_MAP: dict[int, str] = {
|
||||
256184: "open", # открыт для техприсоединения (есть резерв)
|
||||
256186: "limited", # ограниченно
|
||||
256188: "closed", # закрыт (резерва нет)
|
||||
}
|
||||
|
||||
# «ПС»/«подстанция» + класс напряжения-префикс — срезаем при нормализации имени.
|
||||
# Оба компонента опциональны, но хотя бы один должен присутствовать (иначе матч
|
||||
# пустой и sub ничего не делает). Примеры:
|
||||
# «ПС 110/35/10 Нижне-Исетская» → «нижне-исетская»
|
||||
# «ПС Уктус» → «уктус» (только «ПС», без напряжения)
|
||||
# «110/10 Уктус» → «уктус» (только напряжение)
|
||||
_VOLTAGE_PREFIX_RE = re.compile(
|
||||
r"^\s*(?:(?:пс|подстанци\w*)\s*)?(?:[\d/.,]+\s*(?:кв)?\s*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Класс напряжения внутри имени ЦП, напр. «110/35/10» или «110/10».
|
||||
_VOLTAGE_CLASS_RE = re.compile(r"\b(\d{1,3}(?:[/.]\d{1,3}){1,3})\b")
|
||||
_MULTISPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def normalize_sc_name(name: str | None) -> str:
|
||||
"""Нормализует имя ЦП для матча xlsx-резерва с WFS-фичей.
|
||||
|
||||
Правила: lower, ё→е, срез «ПС»/подстанция + класс напряжения-префикса,
|
||||
удаление кавычек, схлопывание тире/пробелов. Пусто → ''.
|
||||
|
||||
Examples:
|
||||
«ПС 110/35/10 Нижне-Исетская» → «нижне-исетская»
|
||||
«Нижне — Исетская» → «нижне-исетская»
|
||||
«ПС "Уктус"» → «уктус»
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
s = name.strip().lower().replace("ё", "е")
|
||||
s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "")
|
||||
# Срез voltage/«ПС»-префикса в начале имени (не трогаем цифры внутри имени).
|
||||
s = _VOLTAGE_PREFIX_RE.sub("", s, count=1)
|
||||
# Схлопываем все виды тире (—, –, -) в одиночный дефис, пробелы вокруг него убираем.
|
||||
s = re.sub(r"\s*[—–-]\s*", "-", s)
|
||||
s = _MULTISPACE_RE.sub(" ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def parse_voltage_class(name: str | None) -> str | None:
|
||||
"""Извлекает класс напряжения из текста имени ЦП («ПС 110/35/10 …» → '110/35/10').
|
||||
|
||||
Возвращает первую последовательность вида «NNN/NNN[/NNN…]». None — если нет.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
m = _VOLTAGE_CLASS_RE.search(name)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1).replace(".", "/")
|
||||
|
||||
|
||||
def _stable_external_id(feature: dict, props: dict) -> str:
|
||||
"""Стабильный external_id фичи: feature['id'] или хэш ключевых полей.
|
||||
|
||||
WFS обычно отдаёт стабильный ``feature['id']``; если его нет — детерминированный
|
||||
sha1 по (sc_name, координаты) чтобы UPSERT оставался идемпотентным.
|
||||
"""
|
||||
fid = feature.get("id")
|
||||
if fid:
|
||||
return str(fid)
|
||||
geom = feature.get("geometry") or {}
|
||||
coords = geom.get("coordinates")
|
||||
seed = f"{props.get('sc_name', '')}|{coords}"
|
||||
# sha1 здесь — стабильный дедуп-id фичи, не криптография.
|
||||
return "h:" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _map_load_index(props: dict) -> str | None:
|
||||
"""sc_indexload_id → 'open'|'limited'|'closed'|None (неизвестный → None)."""
|
||||
raw = props.get("sc_indexload_id")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return _INDEXLOAD_MAP.get(int(raw))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def fetch_power_supply_centers() -> list[dict]:
|
||||
"""Тянет WFS-фичи центров питания Свердловской области (GeoJSON Feature-list).
|
||||
|
||||
RUN-ON-PROD: источник гео-блокирует non-RU IP. httpx с явным таймаутом,
|
||||
verify по умолчанию (валидный TLS у геосервера). Возвращает list feature-dict'ов
|
||||
``{"id", "geometry": {...}, "properties": {...}}``.
|
||||
"""
|
||||
params = {
|
||||
"service": "WFS",
|
||||
"version": "1.1.0",
|
||||
"request": "GetFeature",
|
||||
"typeName": _WFS_TYPE_NAME,
|
||||
"outputFormat": "json",
|
||||
"CQL_FILTER": _WFS_CQL_FILTER,
|
||||
}
|
||||
resp = httpx.get(WFS_BASE_URL, params=params, timeout=_WFS_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
features: list[dict] = (data or {}).get("features") or []
|
||||
logger.info("rosseti_wfs: загружено центров питания=%d", len(features))
|
||||
return features
|
||||
|
||||
|
||||
def _point_geom_sql(feature: dict) -> tuple[str, dict] | None:
|
||||
"""SQL-выражение + params для geom из GeoJSON Point. None — если не Point/нет коорд."""
|
||||
geom = feature.get("geometry") or {}
|
||||
if geom.get("type") != "Point":
|
||||
return None
|
||||
coords = geom.get("coordinates")
|
||||
if not coords or len(coords) < 2:
|
||||
return None
|
||||
lon, lat = coords[0], coords[1]
|
||||
if lon is None or lat is None:
|
||||
return None
|
||||
return "ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)", {"lon": lon, "lat": lat}
|
||||
|
||||
|
||||
def load_power_supply_centers(db: Session | None = None) -> dict[str, int]:
|
||||
"""Тянет WFS + UPSERT-ит центры питания в power_supply_centers.
|
||||
|
||||
UPSERT по UNIQUE(source, external_id). Per-row SAVEPOINT — битая фича не валит
|
||||
weekly-sync. ``db`` принимается для совместимости сигнатуры; если None — своя
|
||||
SessionLocal (WFS-fetch + запись в БД sync, как utility_infrastructure_loader).
|
||||
|
||||
Returns: счётчики fetched/inserted/updated/skipped.
|
||||
"""
|
||||
features = fetch_power_supply_centers()
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
fetched = len(features)
|
||||
try:
|
||||
for feature in features:
|
||||
props: dict = feature.get("properties") or {}
|
||||
sc_name = props.get("sc_name")
|
||||
if not sc_name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
geom_pair = _point_geom_sql(feature)
|
||||
geom_sql = geom_pair[0] if geom_pair else "NULL"
|
||||
geom_params = geom_pair[1] if geom_pair else {}
|
||||
|
||||
params: dict = {
|
||||
"source": "rosseti_wfs",
|
||||
"external_id": _stable_external_id(feature, props),
|
||||
"sc_name": sc_name,
|
||||
"sc_name_norm": normalize_sc_name(sc_name),
|
||||
"dzo_name": props.get("dzo_name"),
|
||||
"org_name": props.get("org_name"),
|
||||
"branch_name": props.get("br_name"),
|
||||
"voltage_class": parse_voltage_class(sc_name),
|
||||
"load_index": _map_load_index(props),
|
||||
"municipality": props.get("municipality"),
|
||||
"place_city": props.get("sc_placecity"),
|
||||
"raw": json.dumps(feature, ensure_ascii=False),
|
||||
**geom_params,
|
||||
}
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — откат только этой фичи
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
INSERT INTO power_supply_centers
|
||||
(source, external_id, sc_name, sc_name_norm, dzo_name,
|
||||
org_name, branch_name, voltage_class, load_index,
|
||||
municipality, place_city, geom, raw, fetched_at)
|
||||
VALUES (
|
||||
:source, :external_id, :sc_name, :sc_name_norm, :dzo_name,
|
||||
:org_name, :branch_name, :voltage_class, :load_index,
|
||||
:municipality, :place_city, {geom_sql},
|
||||
CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (source, external_id) DO UPDATE
|
||||
SET sc_name = EXCLUDED.sc_name,
|
||||
sc_name_norm = EXCLUDED.sc_name_norm,
|
||||
dzo_name = EXCLUDED.dzo_name,
|
||||
org_name = EXCLUDED.org_name,
|
||||
branch_name = EXCLUDED.branch_name,
|
||||
voltage_class = EXCLUDED.voltage_class,
|
||||
load_index = EXCLUDED.load_index,
|
||||
municipality = EXCLUDED.municipality,
|
||||
place_city = EXCLUDED.place_city,
|
||||
geom = EXCLUDED.geom,
|
||||
raw = EXCLUDED.raw,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning("rosseti_wfs upsert failed for %r: %s", sc_name, e)
|
||||
skipped += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("rosseti_wfs: unexpected error, outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
result_dict = {
|
||||
"fetched": fetched,
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
}
|
||||
logger.info("rosseti_wfs load done: %s", result_dict)
|
||||
return result_dict
|
||||
|
|
@ -184,24 +184,47 @@ def _l2_confidence(n_objects_total: int, n_objects_with_free_flats: int) -> Conf
|
|||
# Доступно = выставлено и НЕ продано. is_sold распознаём И флагом, И статусом 'продан'
|
||||
# / наличием contract_date (Объектив заполняет неконсистентно — зеркало _STOCK_SQL).
|
||||
# Группируем по (district, class). class — lowercase в lots (м.68 коммент).
|
||||
#
|
||||
# #1964: дедуп до ФИЗИЧЕСКОГО лота. Сырая objective_lots раздута ~2.91× (мульти
|
||||
# objective_lot_id на один физлот через пере-листинги) → units_available по району
|
||||
# был завышен ~3-4.6× (прод: Юго-Западный комфорт 58 606 → 12 620) → дефицит-форсайт
|
||||
# переоценивал открытое предложение L1.
|
||||
#
|
||||
# Перф-батч #2 (#1953-followup): дедупим INLINE через DISTINCT ON (physflat-ключ,
|
||||
# последний снапшот), НЕ через v_objective_lots_latest. Причина: view селектит ol.* и
|
||||
# его DISTINCT ON — оптимизационный барьер: WHERE снаружи (premise_kind/district) НЕ
|
||||
# проталкивается ниже DISTINCT ON → планировщик материализует ВСЮ таблицу (1.76M,
|
||||
# Parallel Seq Scan width≈891 + external merge sort 76-101 МБ/воркер) ПЕРЕД фильтром
|
||||
# (~6.0 s, прод-EXPLAIN). Inline протолкивает СТАБИЛЬНЫЕ фильтры (premise_kind, district
|
||||
# — НЕ меняются между снапшотами физлота: премайз-кайнд 0 mixed-групп на проде, район ~4
|
||||
# transient на 605k) В WHERE ДО DISTINCT ON → bitmap по objective_lots_district_class_idx
|
||||
# + узкий sort (~5 МБ/воркер) ~0.47 s district-scoped (≈13× быстрее; EKB-wide остаётся
|
||||
# seq-scan, но width≈99 вместо 891 → ~4.6 s). Эквивалентность view-vs-inline доказана
|
||||
# бит-в-бит на проде (все district×class пары, 0 расхождений) — зеркало parcels.py.
|
||||
# sold_now (is_sold/contract_date/status='продан') — МЕНЯЮЩИЕСЯ атрибуты → считаем
|
||||
# ПОСЛЕ дедупа (на последнем снапшоте), как делал view.
|
||||
_L1_OPEN_SQL = text(
|
||||
"""
|
||||
WITH lots AS (
|
||||
SELECT
|
||||
ol.district,
|
||||
ol.class AS obj_class,
|
||||
ol.area_pd,
|
||||
SELECT DISTINCT ON (
|
||||
project_name, corpus_name, section, floor, lot_number
|
||||
)
|
||||
district,
|
||||
class AS obj_class,
|
||||
area_pd,
|
||||
(
|
||||
ol.is_sold IS TRUE
|
||||
OR ol.contract_date IS NOT NULL
|
||||
OR LOWER(COALESCE(ol.status, '')) = 'продан'
|
||||
is_sold IS TRUE
|
||||
OR contract_date IS NOT NULL
|
||||
OR LOWER(COALESCE(status, '')) = 'продан'
|
||||
) AS sold_now
|
||||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
FROM objective_lots
|
||||
WHERE premise_kind = CAST(:premise_kind AS text)
|
||||
AND (
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
OR district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
ORDER BY project_name, corpus_name, section, floor,
|
||||
lot_number, snapshot_date DESC, id DESC
|
||||
)
|
||||
SELECT
|
||||
district,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Загрузчик OSM инженерных сетей («инженерные сети») из Overpass API (#1746).
|
||||
|
||||
No-B2B open-data путь: тянет ЛЭП / подстанции / трубопроводы (газ/вода) / ЦТП /
|
||||
вышки связи через Overpass для bbox ЕКБ и UPSERT-ит в
|
||||
No-B2B open-data путь: тянет ЛЭП / подстанции / трубопроводы (газ/вода/теплотрассы) /
|
||||
ЦТП / вышки связи через Overpass для bbox ЕКБ и UPSERT-ит в
|
||||
``osm_utility_infrastructure_ekb``. Живой тест дал 3081+ utility-элементов ЕКБ.
|
||||
|
||||
Структура зеркалит ``noise_loader.py``: httpx-клиент с явным UA + таймаутом,
|
||||
|
|
@ -22,6 +22,7 @@ import asyncio
|
|||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
|
@ -72,8 +73,8 @@ _HEADERS = {
|
|||
|
||||
# Описание запросов: (overpass_key, overpass_value, el_type, infrastructure_kind).
|
||||
# el_type: 'way' (out geom) | 'node' (out body) | 'nwr' (out geom, node+way).
|
||||
# substance: для pipeline-веток газ/вода дополнительно фильтруем по substance,
|
||||
# чтобы не смешивать водопровод с газопроводом — см. _SUBSTANCE_FILTER.
|
||||
# substance: generic man_made=pipeline фильтруем+классифицируем по substance —
|
||||
# газопровод vs теплотрасса, см. _SUBSTANCE_FILTER / _PIPELINE_SUBSTANCE_KIND.
|
||||
# Виды сети (infrastructure_kind):
|
||||
# power | water | gas | heat | communication | sewage
|
||||
_UTILITY_QUERIES: list[tuple[str, str, str, str]] = [
|
||||
|
|
@ -88,8 +89,13 @@ _UTILITY_QUERIES: list[tuple[str, str, str, str]] = [
|
|||
("pipeline", "water", "way", "water"),
|
||||
# gas — магистральные газопроводы (way), pipeline substance=gas
|
||||
("pipeline", "gas", "way", "gas"),
|
||||
("man_made", "pipeline", "way", "gas"), # substance=gas фильтруется ниже
|
||||
# heat — ЦТП / теплопункты (sparse — это нормально)
|
||||
# generic man_made=pipeline тянет ВСЕ трубы; substance разводит их на
|
||||
# gas / heat (см. _PIPELINE_SUBSTANCE_KIND). kind ниже — дефолтный для строки
|
||||
# (gas), реальный вид переопределяется по substance в _upsert_elements.
|
||||
("man_made", "pipeline", "way", "gas"),
|
||||
# heat — теплотрассы (man_made=pipeline substance∈{heat,hot_water,steam},
|
||||
# разводятся из строки выше) + ЦТП / теплопункты (man_made=heat_substation,
|
||||
# в ЕКБ sparse/0 — это нормально; линии-теплотрассы = единственный гео-источник §3).
|
||||
("man_made", "heat_substation", "nwr", "heat"),
|
||||
# communication — вышки связи (точка/площадка) tower:type=communication
|
||||
("man_made", "tower", "nwr", "communication"),
|
||||
|
|
@ -98,13 +104,46 @@ _UTILITY_QUERIES: list[tuple[str, str, str, str]] = [
|
|||
("man_made", "wastewater_plant", "nwr", "sewage"),
|
||||
]
|
||||
|
||||
# Для generic man_made=pipeline нужно различать вещество по тегу `substance`
|
||||
# (или legacy `type`): иначе под газ попадут все трубы. Ключ — (key, value),
|
||||
# значение — ожидаемый substance. Элементы без совпадения substance пропускаем.
|
||||
_SUBSTANCE_FILTER: dict[tuple[str, str], frozenset[str]] = {
|
||||
("man_made", "pipeline"): frozenset({"gas", "natural_gas", "cng"}),
|
||||
# Для generic man_made=pipeline вещество (тег `substance`, или legacy `type`)
|
||||
# определяет ВИД сети: газопровод (gas) vs теплотрасса (heat). Раньше брали только
|
||||
# газ, остальное скипали — теплотрассы (единственный гео-источник §3, ~135 way в
|
||||
# ЕКБ) терялись. Теперь substance → kind. Труба без известного substance
|
||||
# по-прежнему пропускается (не под газ), чтобы нефтепроводы/химию не примешивать.
|
||||
# gas — газоснабжение: gas / natural_gas / cng
|
||||
# heat — теплоснабжение: heat / hot_water / steam
|
||||
_PIPELINE_SUBSTANCE_KIND: dict[str, str] = {
|
||||
"gas": "gas",
|
||||
"natural_gas": "gas",
|
||||
"cng": "gas",
|
||||
"heat": "heat",
|
||||
"hot_water": "heat",
|
||||
"steam": "heat",
|
||||
}
|
||||
|
||||
# Substance-фильтр для generic man_made=pipeline: пропускаем трубу только если её
|
||||
# substance ведёт в известный вид сети (_PIPELINE_SUBSTANCE_KIND). Ключ — (key,
|
||||
# value), значение — допустимые substance. Собирается из маппинга выше, чтобы
|
||||
# фильтр и классификация не разошлись.
|
||||
_SUBSTANCE_FILTER: dict[tuple[str, str], frozenset[str]] = {
|
||||
("man_made", "pipeline"): frozenset(_PIPELINE_SUBSTANCE_KIND),
|
||||
}
|
||||
|
||||
|
||||
def _substance_of(tags: dict) -> str:
|
||||
"""Вещество трубы: тег `substance` (или legacy `type`), lower-case, «» если нет."""
|
||||
return (tags.get("substance") or tags.get("type") or "").lower()
|
||||
|
||||
|
||||
def _pipeline_kind_from_substance(tags: dict, default_kind: str) -> str:
|
||||
"""Вид сети для generic man_made=pipeline по substance.
|
||||
|
||||
substance∈{gas,natural_gas,cng} → gas; {heat,hot_water,steam} → heat.
|
||||
Неизвестный/пустой substance → ``default_kind`` (труба уже прошла
|
||||
_passes_tag_filters, до сюда без совпадения не доходит).
|
||||
"""
|
||||
return _PIPELINE_SUBSTANCE_KIND.get(_substance_of(tags), default_kind)
|
||||
|
||||
|
||||
# tower:type=communication — для man_made=tower оставляем только вышки связи
|
||||
# (иначе под communication попадут смотровые/водонапорные башни).
|
||||
_TOWER_TYPE_REQUIRED: dict[tuple[str, str], frozenset[str]] = {
|
||||
|
|
@ -121,23 +160,23 @@ def _build_overpass_query(key: str, value: str, el_type: str) -> str:
|
|||
south, west, north, east = EKB_BBOX
|
||||
bbox = f"({south},{west},{north},{east})"
|
||||
if el_type == "nwr":
|
||||
return f"[out:json][timeout:30];" f'nwr["{key}"="{value}"]{bbox};' f"out geom;"
|
||||
return f'[out:json][timeout:30];nwr["{key}"="{value}"]{bbox};out geom;'
|
||||
if el_type == "way":
|
||||
return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;"
|
||||
return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;"
|
||||
return f'[out:json][timeout:30];way["{key}"="{value}"]{bbox};out geom;'
|
||||
return f'[out:json][timeout:30];node["{key}"="{value}"]{bbox};out body;'
|
||||
|
||||
|
||||
def _passes_tag_filters(key: str, value: str, tags: dict) -> bool:
|
||||
"""Доп-фильтр по тегам элемента (substance / tower:type).
|
||||
|
||||
Для generic ``man_made=pipeline`` оставляем только газовые трубы (substance),
|
||||
для ``man_made=tower`` — только вышки связи (tower:type). Остальные ключи
|
||||
проходят без фильтра.
|
||||
Для generic ``man_made=pipeline`` оставляем только трубы с известным веществом
|
||||
(газ или теплоноситель — см. _PIPELINE_SUBSTANCE_KIND); нефтепроводы/химию и
|
||||
трубы без substance пропускаем. Для ``man_made=tower`` — только вышки связи
|
||||
(tower:type). Остальные ключи проходят без фильтра.
|
||||
"""
|
||||
substance_ok = _SUBSTANCE_FILTER.get((key, value))
|
||||
if substance_ok is not None:
|
||||
substance = (tags.get("substance") or tags.get("type") or "").lower()
|
||||
if substance not in substance_ok:
|
||||
if _substance_of(tags) not in substance_ok:
|
||||
return False
|
||||
tower_ok = _TOWER_TYPE_REQUIRED.get((key, value))
|
||||
if tower_ok is not None:
|
||||
|
|
@ -178,9 +217,7 @@ def _backoff_delay(attempt_idx: int, retry_after: float | None) -> float:
|
|||
return base + random.uniform(0.0, _RETRY_JITTER_SECONDS)
|
||||
|
||||
|
||||
async def _fetch_one_query(
|
||||
client: httpx.AsyncClient, query: str, label: str
|
||||
) -> list[dict] | None:
|
||||
async def _fetch_one_query(client: httpx.AsyncClient, query: str, label: str) -> list[dict] | None:
|
||||
"""Один Overpass-запрос с retry-backoff и фолбэком на зеркала (#1746).
|
||||
|
||||
На каждом endpoint из ``OVERPASS_ENDPOINTS`` делаем до
|
||||
|
|
@ -332,6 +369,10 @@ def _upsert_elements(elements: list[dict]) -> dict[str, int]:
|
|||
osm_id: int = el["id"]
|
||||
osm_type: str = el["type"] # 'way' | 'node'
|
||||
kind: str = el.get("_infrastructure_kind", "power")
|
||||
# generic man_made=pipeline: реальный вид (gas vs heat) по substance —
|
||||
# одна overpass-строка тянет и газопроводы, и теплотрассы (#2119).
|
||||
if (key, value) == ("man_made", "pipeline"):
|
||||
kind = _pipeline_kind_from_substance(tags, kind)
|
||||
source_tag: str | None = el.get("_source_tag")
|
||||
name: str | None = tags.get("name")
|
||||
|
||||
|
|
@ -443,6 +484,146 @@ async def load_utility_infrastructure(db: Session | None = None) -> dict[str, in
|
|||
return _upsert_elements(elements)
|
||||
|
||||
|
||||
# Junk-значения OSM tags, которые не несут смысла для попапа.
|
||||
_JUNK_TAG_VALUES = frozenset({"", "yes", "no", "fixme", "unknown", "-"})
|
||||
|
||||
|
||||
def _clean_utility_tag(value: object) -> str | None:
|
||||
"""Нормализовать значение OSM tag: strip, None для пустого / junk."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
return s if s and s.lower() not in _JUNK_TAG_VALUES else None
|
||||
|
||||
|
||||
# OSM material (en) → RU («человеческим языком», правило ui-microcopy). Неизвестное
|
||||
# значение остаётся как есть (лучше сырое, чем потерять).
|
||||
_MATERIAL_RU: dict[str, str] = {
|
||||
"metal": "металл",
|
||||
"steel": "сталь",
|
||||
"iron": "железо",
|
||||
"cast_iron": "чугун",
|
||||
"ductile_iron": "высокопрочный чугун",
|
||||
"aluminium": "алюминий",
|
||||
"aluminum": "алюминий",
|
||||
"copper": "медь",
|
||||
"concrete": "бетон",
|
||||
"reinforced_concrete": "железобетон",
|
||||
"wood": "дерево",
|
||||
"wooden": "дерево",
|
||||
"plastic": "пластик",
|
||||
"pvc": "ПВХ",
|
||||
"polyethylene": "полиэтилен",
|
||||
"pe": "полиэтилен",
|
||||
"asbestos_cement": "асбестоцемент",
|
||||
"ceramic": "керамика",
|
||||
}
|
||||
|
||||
|
||||
def _format_voltage(raw: str) -> str | None:
|
||||
"""OSM voltage (вольты, возможно multi «110000;10000») → «110 кВ/10 кВ».
|
||||
|
||||
≥1000 В → «<kV> кВ», иначе «<V> В». Multi-токены через «;» join'ятся «/».
|
||||
Нечисловые токены пропускаются. None — если ни одного валидного.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for token in raw.split(";"):
|
||||
tok = token.strip()
|
||||
if not tok:
|
||||
continue
|
||||
try:
|
||||
volts = int(float(tok))
|
||||
except (TypeError, ValueError):
|
||||
continue # нечисловой мусор — пропускаем
|
||||
if volts >= 1000:
|
||||
kv = volts / 1000
|
||||
kv_str = f"{kv:g}" # 110.0 → «110», 0.4 → «0.4»
|
||||
parts.append(f"{kv_str} кВ")
|
||||
else:
|
||||
parts.append(f"{volts} В")
|
||||
return "/".join(parts) if parts else None
|
||||
|
||||
|
||||
def _format_diameter(raw: str) -> str | None:
|
||||
"""Трубопровод diameter → «⌀ <n> мм». Берём ПЕРВЫЙ числовой run (отбрасываем
|
||||
суффиксы «mm»/«мм» и не склеиваем мульти-размеры «500x300» → «500»). None — если
|
||||
цифр нет.
|
||||
"""
|
||||
m = re.search(r"\d+", raw)
|
||||
return f"⌀ {m.group()} мм" if m else None
|
||||
|
||||
|
||||
# Теплоноситель теплотрассы (OSM substance) → RU для попапа. «heat» — обобщённая
|
||||
# тепловая сеть (без уточнения носителя) — подписи не даём (kind=heat уже читается
|
||||
# фронтом как «Теплотрасса»), чтобы не дублировать. hot_water / steam — уточняют.
|
||||
_HEAT_SUBSTANCE_RU: dict[str, str] = {
|
||||
"hot_water": "горячая вода",
|
||||
"steam": "пар",
|
||||
}
|
||||
|
||||
|
||||
def _heat_substance_label(tags: dict) -> str | None:
|
||||
"""Теплоноситель теплотрассы (hot_water→«горячая вода», steam→«пар»).
|
||||
|
||||
None для substance=heat (обобщённо — не уточняем) или неизвестного — попап и
|
||||
так знает, что это теплотрасса (kind=heat → «Теплотрасса» на фронте).
|
||||
"""
|
||||
return _HEAT_SUBSTANCE_RU.get(_substance_of(tags))
|
||||
|
||||
|
||||
def _utility_characteristics(kind: str, tags: dict | None) -> str | None:
|
||||
"""Одна гуманизированная RU-строка ключевых характеристик OSM-сети.
|
||||
|
||||
Стиль совпадает с connection-point ``_build_structure_characteristics``
|
||||
(quarter_dump_lookup): « · »-join непустых частей. Включает (при наличии):
|
||||
теплоноситель (для heat), напряжение (multi «/»), оператор, диаметр (⌀ мм),
|
||||
материал, кол-во цепей. None — если ничего пригодного.
|
||||
|
||||
Examples:
|
||||
power voltage=110000;10000 operator=«МРСК Урала» →
|
||||
«110 кВ/10 кВ · МРСК Урала»
|
||||
gas diameter=500mm material=steel → «⌀ 500 мм · сталь»
|
||||
heat substance=hot_water diameter=200 → «горячая вода · ⌀ 200 мм»
|
||||
voltage=400 → «400 В»; voltage=yes → None
|
||||
"""
|
||||
if not isinstance(tags, dict):
|
||||
return None
|
||||
parts: list[str] = []
|
||||
|
||||
# Теплоноситель — только для теплотрасс (kind=heat), первым: «горячая вода» / «пар».
|
||||
if kind == "heat":
|
||||
heat_label = _heat_substance_label(tags)
|
||||
if heat_label:
|
||||
parts.append(heat_label)
|
||||
|
||||
voltage = _clean_utility_tag(tags.get("voltage"))
|
||||
if voltage:
|
||||
vstr = _format_voltage(voltage)
|
||||
if vstr:
|
||||
parts.append(vstr)
|
||||
|
||||
operator = _clean_utility_tag(tags.get("operator"))
|
||||
if operator:
|
||||
parts.append(operator)
|
||||
|
||||
diameter = _clean_utility_tag(tags.get("diameter"))
|
||||
if diameter:
|
||||
dstr = _format_diameter(diameter)
|
||||
if dstr:
|
||||
parts.append(dstr)
|
||||
|
||||
material = _clean_utility_tag(tags.get("material"))
|
||||
if material:
|
||||
parts.append(_MATERIAL_RU.get(material.lower(), material))
|
||||
|
||||
# Кол-во цепей/кабелей ЛЭП — только если значение чисто числовое.
|
||||
circuits = _clean_utility_tag(tags.get("circuits")) or _clean_utility_tag(tags.get("cables"))
|
||||
if circuits and circuits.isdigit():
|
||||
parts.append(f"{circuits} цеп.")
|
||||
|
||||
return " · ".join(parts) if parts else None
|
||||
|
||||
|
||||
def get_nearby_utility_infrastructure(
|
||||
db: Session,
|
||||
cad_num: str,
|
||||
|
|
@ -480,6 +661,7 @@ def get_nearby_utility_infrastructure(
|
|||
db.execute(
|
||||
text("""
|
||||
SELECT osm_id, osm_type, infrastructure_kind, name, source_tag,
|
||||
u.tags AS tags,
|
||||
ST_Distance(
|
||||
u.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
|
|
@ -519,6 +701,7 @@ def get_nearby_utility_infrastructure(
|
|||
"name": r["name"],
|
||||
"source_tag": r["source_tag"],
|
||||
"distance_m": dist,
|
||||
"characteristics": _utility_characteristics(kind, r["tags"]),
|
||||
"geometry_geojson": json.loads(r["geojson"]) if r["geojson"] else {},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
523
backend/app/services/site_finder/vodokanal_reserve_loader.py
Normal file
523
backend/app/services/site_finder/vodokanal_reserve_loader.py
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
"""Загрузчик резервов ЦСВ/ЦСК Водоканала ЕКБ из docx-раскрытия (#2119, фикс #2126).
|
||||
|
||||
Две страницы водоканалекб.рф (водоснабжение / водоотведение) публикуют docx с
|
||||
таблицами свободной мощности ЦСВ (центр. система водоснабжения) / ЦСК (центр.
|
||||
система водоотведения). UPSERT-ит в ``water_supply_reserves``.
|
||||
|
||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → РАБОТАЕТ НА ПРОДЕ (Celery / docker exec).
|
||||
Punycode-host обязателен (водоканалекб.рф → xn--80aadbki6adhshb.xn--p1ai).
|
||||
|
||||
Парсим таблицы через stdlib (zipfile + ElementTree по word/document.xml) — это
|
||||
надёжнее python-docx для vMerge-детекта и тривиально тестируется на синтетическом
|
||||
xml-фрагменте. Резерв бывает ОТРИЦАТЕЛЬНЫМ (дефицит, напр. −168,5) — знак сохраняем,
|
||||
запятая-десятичный → float. period инферится из имени файла (1kv.-2026 → '2026-Q1').
|
||||
|
||||
РЕАЛЬНАЯ СЕТКА (снята с прода 2026-07-03, обе страницы — 1 таблица, но РАЗНЫЙ layout).
|
||||
Парсинг COLUMN-ALIGNED по ПОДПИСЯМ ШАПКИ и метке показателя (НЕ по индексам вслепую):
|
||||
|
||||
* Страница «водоснабжение» (system_kind='water') — 6 колонок, «длинный» layout:
|
||||
каждый ЦСВ занимает 4 СТРОКИ (по строке на показатель). Шапка: col2=='Наименование
|
||||
показателя'. col0='№ ЦСВ' (номер п/п: 1.1, 2.1…2.26, ПТВ), col1='Название ЦСВ'
|
||||
(имя системы, повторяется в каждой строке блока), col2=показатель, col3=ед.изм,
|
||||
col4=значение периода, col5=примечание. РЕЗЕРВ = строка, где col2 начинается с
|
||||
«Резерв мощности централизованной системы…» И col3 — «тыс…м3/сут» → число из col4.
|
||||
Заявки/отказы — строки «Количество поданных…» / «…отказе в подключении».
|
||||
Живые резервы: ЗФС,ГСВ = −168,5; Изоплит = 0,0; Шабровский = 1,03; ПТВ = 73,34.
|
||||
* Страница «водоотведение» (system_kind='sewerage') — 5 колонок, «блочный» layout:
|
||||
имя системы — ОТДЕЛЬНАЯ строка-заголовок (только col0, напр. «Централизованная
|
||||
система водоотведения "Южный бассейн канализования"»), под ней строка-шапка
|
||||
(col0='№ п/п') и 4 строки показателей (col0 = 1..4 ИЛИ пусто). РЕЗЕРВ = строка,
|
||||
где col1 начинается с «Резерв мощности централизованной системы…» И col2 —
|
||||
«тыс…м3/сут» → число из col3. Имя системы forward-fill-ится из последней
|
||||
строки-заголовка. Живые резервы: Южный бассейн = −296,85; Северный = 2,02.
|
||||
|
||||
ЛОВУШКА (прод-баг #2126, был мусор 28+6 строк): старый парсер брал «первое число
|
||||
строки» как резерв и «первую текстовую ячейку» как имя. На проде это давало № п/п
|
||||
(1.1, 2.18…) в поле резерва и рубрики анкеты («Количество поданных…», «Итого:»,
|
||||
служебные шапки «№ ЦСВ»/«№ п/п») как «системы». Column-aligned парс по метке
|
||||
показателя их не видит: строки-рубрики, «Итого:», шапки и легенда отфильтрованы;
|
||||
резерв читается ТОЛЬКО из строки «Резерв мощности…». НУЛИ («Отсутствие резерва
|
||||
мощности», 0,0) сохраняем честно (0.0 + note), как в eias_heat_loader.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# WordprocessingML namespace.
|
||||
_W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
_NS = {"w": _W}
|
||||
|
||||
# Punycode-host обязателен (водоканалекб.рф). xn--80aadbki6adhshb.xn--p1ai.
|
||||
_BASE = "https://xn--80aadbki6adhshb.xn--p1ai"
|
||||
PAGES: dict[str, str] = {
|
||||
"water": f"{_BASE}/info/vodosnabzhenie/",
|
||||
"sewerage": f"{_BASE}/info/vodotvedenie/",
|
||||
}
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# docx-ссылка «для сайта»: dlya-sajta-*.docx.
|
||||
_DOCX_HREF_RE = re.compile(r'href=["\']([^"\']*dlya-sajta-[^"\']*\.docx)["\']', re.IGNORECASE)
|
||||
# Дата в имени файла для выбора свежайшего.
|
||||
_FILE_DATE_RE = re.compile(r"(\d{4})|(\d{1,2})[.\-](\d{4})")
|
||||
# Период вида «1kv.-2026» / «1кв-2026» / «1 квартал 2026».
|
||||
_PERIOD_RE = re.compile(r"(\d)\s*(?:kv|кв)\w*[.\s-]*(\d{4})", re.IGNORECASE)
|
||||
|
||||
_NODATA_TOKENS = frozenset({"", "н/д", "нд", "n/a", "-", "—", "–", "нет данных", "х", "x"})
|
||||
|
||||
# Метки показателей формы раскрытия (нормализуем casefold + схлопнутые пробелы перед
|
||||
# сравнением: в docx встречаются \xa0 и двойные пробелы). Резерв — по префиксу, т.к.
|
||||
# хвост различается («…холодного водоснабжения» / «…водоотведения»).
|
||||
_RESERVE_LABEL_PREFIX = "резерв мощности централизованной системы"
|
||||
_REQUESTED_LABEL = "количество поданных и зарегистрированных заявок на подключение"
|
||||
_REFUSED_LABEL_PREFIX = "количество заявок на подключение, по которым принято решение об отказе"
|
||||
# Ед.изм. резерва: «тыс. м3/сут» / «тыс.м3/сут» (пробелы схлопнуты) — защита от того,
|
||||
# чтобы «Резерв…» из чужой формы с иной размерностью не попал в тыс.м³/сут.
|
||||
# Оба токена обязательны: одного «тыс» мало — «тыс. м3/час» прошёл бы с чужой
|
||||
# временной базой (ревью 2026-07-03).
|
||||
_RESERVE_UNIT_MARKERS = ("тыс", "сут")
|
||||
# Шапки таблицы и служебные строки — не данные.
|
||||
_HEADER_MARKERS = frozenset({"№ п/п", "№ цсв", "наименование показателя", "название цсв"})
|
||||
_TOTAL_MARKER = "итого"
|
||||
# Строка-заголовок системы в «блочном» layout водоотведения: только col0 заполнен и
|
||||
# начинается с одного из этих префиксов (сама система, а не показатель/шапка).
|
||||
_SYSTEM_TITLE_PREFIXES = (
|
||||
"централизованная система",
|
||||
"транспортировка сточных вод",
|
||||
)
|
||||
# Легенда в конце водоснабжения: «ЦСВ – централизованная система…» / «ПТВ – …».
|
||||
_LEGEND_MARKER = " – "
|
||||
|
||||
|
||||
def parse_water_number(value: str | None) -> float | None:
|
||||
"""Числовая ячейка резерва тыс. м³/сут → float. Сохраняет знак (дефицит <0).
|
||||
|
||||
Запятая-десятичный → точка, минусы всех видов (−/–/-) нормализуются.
|
||||
«н/д»/пусто → None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip().lower()
|
||||
if s in _NODATA_TOKENS:
|
||||
return None
|
||||
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||
# Нормализуем юникод-минусы к ASCII '-'.
|
||||
s = s.replace("−", "-").replace("–", "-").replace("—", "-")
|
||||
m = re.search(r"-?\d+(?:\.\d+)?", s)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return float(m.group())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def infer_period(filename: str) -> str | None:
|
||||
"""Инферит период отчёта из имени файла: «1kv.-2026» → '2026-Q1'. None — нет."""
|
||||
m = _PERIOD_RE.search(filename)
|
||||
if not m:
|
||||
return None
|
||||
quarter, year = m.group(1), m.group(2)
|
||||
return f"{year}-Q{quarter}"
|
||||
|
||||
|
||||
def _cell_text(tc: ET.Element) -> str:
|
||||
"""Весь текст ячейки таблицы (<w:tc>) — конкатенация всех <w:t>."""
|
||||
parts = [t.text or "" for t in tc.iter(f"{{{_W}}}t")]
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def _vmerge_state(tc: ET.Element) -> str:
|
||||
"""Состояние вертикального merge ячейки: 'restart' | 'continue' | 'none'.
|
||||
|
||||
<w:vMerge w:val="restart"/> — начало блока (значение здесь).
|
||||
<w:vMerge/> (без val или val='continue') — продолжение (forward-fill сверху).
|
||||
Нет <w:vMerge> — обычная ячейка.
|
||||
"""
|
||||
tc_pr = tc.find(f"{{{_W}}}tcPr")
|
||||
if tc_pr is None:
|
||||
return "none"
|
||||
vmerge = tc_pr.find(f"{{{_W}}}vMerge")
|
||||
if vmerge is None:
|
||||
return "none"
|
||||
val = vmerge.get(f"{{{_W}}}val")
|
||||
return "restart" if val == "restart" else "continue"
|
||||
|
||||
|
||||
def parse_docx_table_rows(document_xml: bytes) -> list[list[str]]:
|
||||
"""Парсит ПЕРВУЮ таблицу document.xml → list строк (list ячеек-строк).
|
||||
|
||||
Forward-fill по вертикальному merge: ячейка в состоянии 'continue' наследует
|
||||
текст ячейки той же колонки из предыдущей строки (system-name + note мержатся).
|
||||
Возвращает матрицу текстов [row][col]. Пусто → [].
|
||||
"""
|
||||
root = ET.fromstring(document_xml)
|
||||
tables = root.iter(f"{{{_W}}}tbl")
|
||||
tbl = next(tables, None)
|
||||
if tbl is None:
|
||||
return []
|
||||
|
||||
matrix: list[list[str]] = []
|
||||
prev_row: list[str] = []
|
||||
for tr in tbl.findall(f"{{{_W}}}tr"):
|
||||
cells = tr.findall(f"{{{_W}}}tc")
|
||||
row: list[str] = []
|
||||
for col_idx, tc in enumerate(cells):
|
||||
state = _vmerge_state(tc)
|
||||
if state == "continue":
|
||||
# forward-fill из той же колонки предыдущей строки.
|
||||
filled = prev_row[col_idx] if col_idx < len(prev_row) else ""
|
||||
row.append(filled)
|
||||
else:
|
||||
row.append(_cell_text(tc))
|
||||
matrix.append(row)
|
||||
prev_row = row
|
||||
return matrix
|
||||
|
||||
|
||||
def _norm(s: str | None) -> str:
|
||||
"""Ячейку → casefold + схлопнутые пробелы (\xa0/двойные) для сравнения с метками."""
|
||||
if not s:
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", s.replace("\xa0", " ")).strip().casefold()
|
||||
|
||||
|
||||
def _cell(row: list[str], idx: int) -> str:
|
||||
"""Ячейка row[idx] со .strip() или '' если колонки нет."""
|
||||
return row[idx].strip() if idx < len(row) and row[idx] else ""
|
||||
|
||||
|
||||
def _is_header_row(row: list[str]) -> bool:
|
||||
"""Строка-шапка таблицы (№ п/п / Наименование показателя / …)?"""
|
||||
return any(_norm(c) in _HEADER_MARKERS for c in row)
|
||||
|
||||
|
||||
def _is_total_row(row: list[str]) -> bool:
|
||||
"""Строка «Итого:» (агрегат по всем системам — не резерв конкретной ЦСВ)."""
|
||||
return _norm(_cell(row, 0)).startswith(_TOTAL_MARKER)
|
||||
|
||||
|
||||
def _row_metric(row: list[str], label_col: int) -> str:
|
||||
"""Метка показателя строки (нормализованная) из колонки label_col."""
|
||||
return _norm(_cell(row, label_col))
|
||||
|
||||
|
||||
def _is_reserve_metric(metric: str) -> bool:
|
||||
return metric.startswith(_RESERVE_LABEL_PREFIX)
|
||||
|
||||
|
||||
def _is_requested_metric(metric: str) -> bool:
|
||||
return metric == _REQUESTED_LABEL
|
||||
|
||||
|
||||
def _is_refused_metric(metric: str) -> bool:
|
||||
return metric.startswith(_REFUSED_LABEL_PREFIX)
|
||||
|
||||
|
||||
def _system_title(row: list[str]) -> str | None:
|
||||
"""Строка-заголовок системы «блочного» layout (водоотведение): имя или None.
|
||||
|
||||
Заполнена ТОЛЬКО col0, текст начинается с «Централизованная система…» /
|
||||
«Транспортировка сточных вод…». Возвращает имя системы (col0.strip()).
|
||||
"""
|
||||
if any(_cell(row, i) for i in range(1, len(row))):
|
||||
return None # не одиночный заголовок — заполнены и другие колонки
|
||||
text0 = _cell(row, 0)
|
||||
norm0 = _norm(text0)
|
||||
if any(norm0.startswith(p) for p in _SYSTEM_TITLE_PREFIXES):
|
||||
return text0
|
||||
return None
|
||||
|
||||
|
||||
def _detect_layout(matrix: list[list[str]]) -> tuple[str, int, int, int]:
|
||||
"""По шапке определяет layout → (kind, name_col, label_col, value_col).
|
||||
|
||||
kind='water' : шапка с col2=='Наименование показателя', имя ЦСВ в col1,
|
||||
показатель col2, значение col4.
|
||||
kind='block' : шапка с col1=='Наименование показателя' (водоотведение),
|
||||
имя системы — отдельная строка-заголовок, значение col3.
|
||||
Не нашли шапку → ('', -1, -1, -1) (graceful).
|
||||
"""
|
||||
for row in matrix:
|
||||
if _norm(_cell(row, 2)) == "наименование показателя":
|
||||
return ("water", 1, 2, 4)
|
||||
if _norm(_cell(row, 1)) == "наименование показателя":
|
||||
return ("block", -1, 1, 3)
|
||||
return ("", -1, -1, -1)
|
||||
|
||||
|
||||
def _flush_group(
|
||||
name: str,
|
||||
reserve_cell: str | None,
|
||||
note_cell: str,
|
||||
requested: int | None,
|
||||
refused: int | None,
|
||||
records: list[dict],
|
||||
) -> None:
|
||||
"""Собирает запись ЦСВ/ЦСК из накопленной группы строк, если есть имя + резерв."""
|
||||
if not name or reserve_cell is None:
|
||||
return
|
||||
reserve = parse_water_number(reserve_cell)
|
||||
# note: явное примечание строки резерва (напр. «Отсутствие резерва мощности»)
|
||||
# честно фиксирует ноль-резерв; иначе None.
|
||||
note = note_cell.strip() or None
|
||||
records.append(
|
||||
{
|
||||
"system_name": name,
|
||||
"reserve_thousand_m3_day": reserve,
|
||||
"connections_requested": requested,
|
||||
"connections_refused": refused,
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def extract_water_rows(matrix: list[list[str]]) -> list[dict]:
|
||||
"""Из матрицы docx → записи ЦСВ/ЦСК, COLUMN-ALIGNED по метке показателя.
|
||||
|
||||
Определяет layout по шапке (см. ``_detect_layout``): «water» — имя ЦСВ в col1,
|
||||
«block» (водоотведение) — имя в отдельной строке-заголовке. Затем группирует
|
||||
строки показателей по системе и берёт резерв ТОЛЬКО из строки «Резерв мощности
|
||||
централизованной системы…» (col — value_col), заявки/отказы — из строк
|
||||
«Количество поданных…» / «…отказе…». Ноль-резерв (0,0 + «Отсутствие резерва
|
||||
мощности») сохраняется честно. Шапки, «Итого:», легенда — отфильтрованы.
|
||||
|
||||
Не распознан layout → [] (graceful, лог наверху).
|
||||
"""
|
||||
kind, name_col, label_col, value_col = _detect_layout(matrix)
|
||||
if not kind:
|
||||
return []
|
||||
|
||||
records: list[dict] = []
|
||||
# Накопитель текущей группы (одна система = один блок строк показателей).
|
||||
cur_name = ""
|
||||
cur_reserve: str | None = None
|
||||
cur_note = ""
|
||||
cur_requested: int | None = None
|
||||
cur_refused: int | None = None
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal cur_name, cur_reserve, cur_note, cur_requested, cur_refused
|
||||
_flush_group(cur_name, cur_reserve, cur_note, cur_requested, cur_refused, records)
|
||||
cur_name, cur_reserve, cur_note = "", None, ""
|
||||
cur_requested, cur_refused = None, None
|
||||
|
||||
for row in matrix:
|
||||
if not any(c.strip() for c in row):
|
||||
continue
|
||||
if _is_header_row(row):
|
||||
continue
|
||||
if _is_total_row(row):
|
||||
flush() # «Итого:» закрывает предыдущую систему, само не данные
|
||||
continue
|
||||
# Легенда в конце водоснабжения («ЦСВ – централизованная система…»).
|
||||
if _norm(_cell(row, 0)).startswith(("цсв ", "птв ")) and _LEGEND_MARKER in _cell(row, 0):
|
||||
flush()
|
||||
continue
|
||||
|
||||
if kind == "block":
|
||||
title = _system_title(row)
|
||||
if title is not None:
|
||||
flush() # новая система-заголовок
|
||||
cur_name = title
|
||||
continue
|
||||
|
||||
metric = _row_metric(row, label_col)
|
||||
if kind == "water":
|
||||
# Имя ЦСВ повторяется в каждой строке блока (col1). Смена имени → новая
|
||||
# система: сбрасываем накопитель.
|
||||
row_name = _cell(row, name_col)
|
||||
if row_name and row_name != cur_name:
|
||||
flush()
|
||||
cur_name = row_name
|
||||
|
||||
value = _cell(row, value_col)
|
||||
if _is_reserve_metric(metric):
|
||||
# Защита размерности: у резерва ед.изм. — «тыс…м3/сут» (col label+1).
|
||||
unit = _norm(_cell(row, label_col + 1))
|
||||
if all(m in unit for m in _RESERVE_UNIT_MARKERS):
|
||||
cur_reserve = value
|
||||
cur_note = _cell(row, value_col + 1) # Примечание строки резерва
|
||||
elif _is_requested_metric(metric):
|
||||
n = parse_water_number(value)
|
||||
if n is not None and float(n).is_integer():
|
||||
cur_requested = int(n)
|
||||
elif _is_refused_metric(metric):
|
||||
n = parse_water_number(value)
|
||||
if n is not None and float(n).is_integer():
|
||||
cur_refused = int(n)
|
||||
|
||||
flush() # последняя система
|
||||
return records
|
||||
|
||||
|
||||
def fetch_docx_link(page_url: str) -> str:
|
||||
"""Скрейпит страницу → ссылка на СВЕЖАЙШИЙ dlya-sajta-*.docx. '' если нет.
|
||||
|
||||
RUN-ON-PROD (гео-блок). GET follow_redirects, regex по href.
|
||||
"""
|
||||
resp = httpx.get(page_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
hrefs = _DOCX_HREF_RE.findall(resp.text)
|
||||
if not hrefs:
|
||||
return ""
|
||||
|
||||
def _key(h: str) -> tuple:
|
||||
years = [int(y) for grp in _FILE_DATE_RE.findall(h) for y in grp if y]
|
||||
return (max(years) if years else 0, h)
|
||||
|
||||
best = max(hrefs, key=_key)
|
||||
return best if best.startswith("http") else _BASE + best
|
||||
|
||||
|
||||
def _dedupe_names(records: list[dict]) -> list[dict]:
|
||||
"""Разводит БАЙТ-В-БАЙТ одинаковые system_name суффиксом « (N)».
|
||||
|
||||
UNIQUE (system_kind, system_name, period) схлопнул бы две разные системы с
|
||||
одинаковым именем в одну (с потерей резерва). Имена ЦСВ на проде обычно уникальны
|
||||
(well-суффиксы), но защищаемся — как в eias_heat_loader.
|
||||
"""
|
||||
seen: dict[str, int] = {}
|
||||
for rec in records:
|
||||
name = rec["system_name"]
|
||||
seen[name] = seen.get(name, 0) + 1
|
||||
if seen[name] > 1:
|
||||
rec["system_name"] = f"{name} ({seen[name]})"
|
||||
return records
|
||||
|
||||
|
||||
def _upsert_water_rows(
|
||||
db: Session,
|
||||
system_kind: str,
|
||||
records: list[dict],
|
||||
period: str | None,
|
||||
source_url: str,
|
||||
) -> dict[str, int]:
|
||||
"""UPSERT записей ЦСВ/ЦСК в water_supply_reserves. Per-row SAVEPOINT."""
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for rec in records:
|
||||
params = {
|
||||
"system_kind": system_kind,
|
||||
"system_name": rec["system_name"],
|
||||
"reserve": rec["reserve_thousand_m3_day"],
|
||||
"requested": rec["connections_requested"],
|
||||
"refused": rec["connections_refused"],
|
||||
"note": rec["note"],
|
||||
"period": period,
|
||||
"source_url": source_url,
|
||||
}
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO water_supply_reserves
|
||||
(system_kind, system_name, reserve_thousand_m3_day,
|
||||
connections_requested, connections_refused, note,
|
||||
period, source_url, fetched_at)
|
||||
VALUES (
|
||||
:system_kind, :system_name, :reserve,
|
||||
:requested, :refused, :note,
|
||||
:period, :source_url, NOW()
|
||||
)
|
||||
ON CONFLICT (system_kind, system_name, period) DO UPDATE
|
||||
SET reserve_thousand_m3_day = EXCLUDED.reserve_thousand_m3_day,
|
||||
connections_requested = EXCLUDED.connections_requested,
|
||||
connections_refused = EXCLUDED.connections_refused,
|
||||
note = EXCLUDED.note,
|
||||
source_url = EXCLUDED.source_url,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning("water_reserve upsert failed for %r: %s", rec["system_name"], e)
|
||||
skipped += 1
|
||||
return {"inserted": inserted, "updated": updated, "skipped": skipped}
|
||||
|
||||
|
||||
def load_water_reserves_from_docx(
|
||||
db: Session,
|
||||
system_kind: str,
|
||||
docx_bytes: bytes,
|
||||
source_url: str = "",
|
||||
) -> dict[str, int]:
|
||||
"""Парсит docx-байты → UPSERT ЦСВ/ЦСК в water_supply_reserves.
|
||||
|
||||
Выделено из load_water_reserves для юнит-теста на синтетическом docx.
|
||||
Читает word/document.xml из zip, forward-fill vMerge, извлечение записей.
|
||||
"""
|
||||
with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf:
|
||||
document_xml = zf.read("word/document.xml")
|
||||
matrix = parse_docx_table_rows(document_xml)
|
||||
records = _dedupe_names(extract_water_rows(matrix))
|
||||
period = infer_period(source_url)
|
||||
if not records:
|
||||
logger.warning(
|
||||
"water_reserves[%s]: 0 записей распознано (layout не найден или пустая "
|
||||
"таблица); первые 6 строк матрицы: %s",
|
||||
system_kind,
|
||||
matrix[:6],
|
||||
)
|
||||
|
||||
try:
|
||||
counts = _upsert_water_rows(db, system_kind, records, period, source_url)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_water_reserves_from_docx: outer tx rolled back: %s", e)
|
||||
raise
|
||||
|
||||
result = {"records": len(records), **counts, "period": period} # type: ignore[dict-item]
|
||||
logger.info("water_reserves[%s] done: %s", system_kind, result)
|
||||
return {k: v for k, v in result.items() if isinstance(v, int)}
|
||||
|
||||
|
||||
def load_water_reserves(db: Session | None = None) -> dict[str, dict]:
|
||||
"""Полный прогон: обе страницы (водоснабжение/водоотведение) → UPSERT.
|
||||
|
||||
RUN-ON-PROD. Каждая страница graceful: сбой одной не валит другую.
|
||||
Returns: {"water": {...}, "sewerage": {...}}.
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
|
||||
for kind, page_url in PAGES.items():
|
||||
try:
|
||||
link = fetch_docx_link(page_url)
|
||||
if not link:
|
||||
out[kind] = {"error": "no docx link found"}
|
||||
continue
|
||||
r = client.get(link)
|
||||
r.raise_for_status()
|
||||
out[kind] = load_water_reserves_from_docx(db, kind, r.content, link)
|
||||
except Exception as e:
|
||||
logger.exception("load_water_reserves: %s failed: %s", kind, e)
|
||||
out[kind] = {"error": str(e)}
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_water_reserves done: %s", out)
|
||||
return out
|
||||
|
|
@ -283,6 +283,76 @@ def build_beat_schedule() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# Точки подключения со свободной мощностью (Forgejo #2119 Phase A):
|
||||
# электро — Россети WFS (~488 ЦП) + xlsx-резервы (ЦП + 10.7k ТП/РП);
|
||||
# вода — Водоканал ЕКБ docx (ЦСВ/ЦСК, city-level).
|
||||
# Источники ГЕО-БЛОКИРУЮТ non-RU IP → RUN-ON-PROD (Celery worker на VPS).
|
||||
# Меняются медленно (раскрытие ФАС квартальное) — еженедельно с запасом.
|
||||
# Вторник 04:00 МСК — НАМЕРЕННО вне monday-кластера тяжёлых Overpass/site_finder
|
||||
# рефрешей (не конкурируем за БД/сеть). Электро (WFS+xlsx) и вода (docx) —
|
||||
# разные хосты, ставим с интервалом 30 мин, чтобы не бить оба одновременно.
|
||||
schedule["power-capacity-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_power_supply_centers",
|
||||
"schedule": _parse_cron("0 4 * * tue"), # 04:00 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
schedule["water-capacity-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_water_reserves",
|
||||
"schedule": _parse_cron("30 4 * * tue"), # 04:30 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
# Газовая вертикаль (Forgejo #2119 Фаза B1): свободная мощность ГРС «Газпром
|
||||
# трансгаз Екатеринбург» из инлайн HTML-таблицы раскрытия. Источник ГЕО-БЛОКИРУЕТ
|
||||
# non-RU IP → RUN-ON-PROD. 05:00 МСК вторник — через 30 мин после воды (разные
|
||||
# хосты, не бьём одновременно), в том же вторник-кластере вне monday-нагрузки.
|
||||
schedule["gas-grs-capacity-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_gas_grs_capacity",
|
||||
"schedule": _parse_cron("0 5 * * tue"), # 05:00 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
# Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960 → gas_grs_outlet_points +
|
||||
# догеокод адресов → geom). Forgejo #2119 Фаза B2, PR-4. Источник ГЕО-БЛОКИРУЕТ
|
||||
# non-RU IP + DaData-ключи на проде → RUN-ON-PROD. Источник публикуется ~5-го числа
|
||||
# месяца, НО beat не поддерживает «раз в месяц по факту публикации» надёжно (jitter
|
||||
# даты) → еженедельно вторник, идемпотентно: помесячный UPSERT + geom сохраняется,
|
||||
# не-новый период просто пере-UPSERT-ится без эффекта. 05:15 МСК — в газовом суб-
|
||||
# кластере сразу после gas-grs (05:00, тот же apex-родственник ekgas/ГТЕ), до ЕЭСК
|
||||
# (05:30); намеренно вне monday-нагрузки. Целимся ~05:30-окно спеки, но 05:30 занят
|
||||
# ЕЭСК → 05:15 (не бьём оба газовых источника ekgas одновременно).
|
||||
schedule["gas-outlet-points-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_gas_outlet_points",
|
||||
"schedule": _parse_cron("15 5 * * tue"), # 05:15 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
# ЕЭСК-резервы (Forgejo #2119 Фаза B2): ПС 35-220 (МВт) + ТП/РП «ниже 35» из
|
||||
# xlsx-раскрытия eesk.ru. Обычный SSL БЕЗ гео-блока, но держим в том же вторник-
|
||||
# кластере рядом с rosseti/gas/vodokanal. 05:30 МСК — через 30 мин после газа
|
||||
# (разные хосты, не бьём одновременно), вне monday-нагрузки.
|
||||
schedule["eesk-reserves-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_eesk_reserves",
|
||||
"schedule": _parse_cron("30 5 * * tue"), # 05:30 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
# OSM-геокод городских ТП/РП ЕКБ → geom (Forgejo #2119 Фаза B2, финал). Матч
|
||||
# name_norm (чистый номер) ↔ OSM substation ref/name через Overpass →
|
||||
# power_tp_rp_reserves.geom. Источник (Overpass) БЕЗ гео-блока, но держим в том же
|
||||
# вторник-кластере рядом с ЕЭСК. 05:45 МСК — после газа/ЕЭСК (05:30), до тепла
|
||||
# (06:00); идемпотентно (geom IS NULL-гейт), вне monday-Overpass-нагрузки.
|
||||
schedule["osm-tp-geocode-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_osm_tp_geocode",
|
||||
"schedule": _parse_cron("45 5 * * tue"), # 05:45 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
# Тепло-резервы (Forgejo #2119 Фаза B2): резерв мощности источников тепла из
|
||||
# реестра раскрытия ФАС (ri.eias.ru, ЕТК + Екатеринбургэнерго). Источник ГЕО-
|
||||
# БЛОКИРУЕТ non-RU IP + российский УЦ → RUN-ON-PROD. 06:00 МСК — через 30 мин
|
||||
# после ЕЭСК (разные хосты), в том же вторник-кластере вне monday-нагрузки.
|
||||
schedule["heat-reserves-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_heat_reserves",
|
||||
"schedule": _parse_cron("0 6 * * tue"), # 06:00 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК.
|
||||
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
|
||||
schedule["ekburg-permits-monthly"] = {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.supply_layers_refresh",
|
||||
"app.workers.tasks.location_refresh",
|
||||
"app.workers.tasks.forecast",
|
||||
"app.workers.tasks.full_report",
|
||||
"app.workers.tasks.ird_harvest",
|
||||
"app.workers.tasks.ekb_krt_sync",
|
||||
"app.workers.tasks.gknspecial_harvest",
|
||||
|
|
@ -87,6 +88,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.riasurt_sverdl_harvest",
|
||||
"app.workers.tasks.mv_sales_tracker_refresh",
|
||||
"app.workers.tasks.scrape_freshness_check",
|
||||
"app.workers.tasks.connection_capacity_sync",
|
||||
],
|
||||
)
|
||||
celery_app.conf.timezone = "Europe/Moscow"
|
||||
|
|
|
|||
242
backend/app/workers/tasks/connection_capacity_sync.py
Normal file
242
backend/app/workers/tasks/connection_capacity_sync.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"""Celery tasks: еженедельная синхронизация точек подключения со свободной
|
||||
мощностью — электро (Россети WFS + xlsx-резервы) и вода (Водоканал docx). #2119.
|
||||
|
||||
Источники ГЕО-БЛОКИРУЮТ non-RU IP → таски выполняются НА ПРОДЕ (Celery worker
|
||||
на VPS). Persistent breadcrumbs в nspd_geo_log (как utility/poi/noise sync).
|
||||
Import-light: тяжёлые загрузчики импортируются лениво внутри тасков.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_breadcrumb(stage: str, level: str, message: str) -> None:
|
||||
"""Записать строку в nspd_geo_log (job_id=NULL) — диагностика без SSH."""
|
||||
try:
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO nspd_geo_log (job_id, level, stage, message) "
|
||||
"VALUES (NULL, :lvl, :stage, :msg)"
|
||||
),
|
||||
{"lvl": level, "stage": stage, "msg": message[:500]},
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning("connection_capacity breadcrumb failed: %s", e)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_power_supply_centers",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_power_supply_centers() -> dict:
|
||||
"""WFS центры питания + xlsx-резервы (ЦП + ТП/РП) → power_supply_centers / ТП-РП.
|
||||
|
||||
RUN-ON-PROD (гео-блок источников). Сначала WFS (координаты + напряжение +
|
||||
индекс загрузки), затем резервы из xlsx-раскрытия. Каждый шаг graceful —
|
||||
сбой резервов не откатывает загруженные WFS-точки.
|
||||
"""
|
||||
_log_breadcrumb("power_capacity_sync", "info", "task started")
|
||||
result: dict = {}
|
||||
try:
|
||||
from app.services.site_finder.rosseti_wfs_loader import load_power_supply_centers
|
||||
|
||||
result["wfs"] = load_power_supply_centers()
|
||||
except Exception as e:
|
||||
logger.exception("sync_power_supply_centers: WFS failed: %s", e)
|
||||
_log_breadcrumb("power_capacity_sync", "error", f"wfs {type(e).__name__}: {e}")
|
||||
result["wfs"] = {"error": str(e)}
|
||||
|
||||
try:
|
||||
from app.services.site_finder.rosseti_reserve_loader import load_all_reserves
|
||||
|
||||
result["reserves"] = load_all_reserves()
|
||||
except Exception as e:
|
||||
logger.exception("sync_power_supply_centers: reserves failed: %s", e)
|
||||
_log_breadcrumb("power_capacity_sync", "error", f"reserves {type(e).__name__}: {e}")
|
||||
result["reserves"] = {"error": str(e)}
|
||||
|
||||
logger.info("sync_power_supply_centers done: %s", result)
|
||||
_log_breadcrumb("power_capacity_sync", "info", f"done: {result}")
|
||||
return result
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_water_reserves",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_water_reserves() -> dict:
|
||||
"""Резервы ЦСВ/ЦСК Водоканала ЕКБ (docx) → water_supply_reserves.
|
||||
|
||||
RUN-ON-PROD (гео-блок). Обе страницы (водоснабжение/водоотведение) graceful.
|
||||
"""
|
||||
_log_breadcrumb("water_capacity_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.vodokanal_reserve_loader import load_water_reserves
|
||||
|
||||
result = load_water_reserves()
|
||||
logger.info("sync_water_reserves done: %s", result)
|
||||
_log_breadcrumb("water_capacity_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_water_reserves failed: %s", e)
|
||||
_log_breadcrumb("water_capacity_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_gas_grs_capacity",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_gas_grs_capacity() -> dict:
|
||||
"""Свободная мощность ГРС «Газпром трансгаз Екатеринбург» (HTML) → gas_grs_capacity.
|
||||
|
||||
RUN-ON-PROD (гео-блок источника). Инлайн HTML-таблица раскрытия ГТЕ → UPSERT
|
||||
свободной мощности ГРС (по выходам). #2119 Фаза B1.
|
||||
"""
|
||||
_log_breadcrumb("gas_grs_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.gazprom_grs_loader import load_grs_capacity
|
||||
|
||||
result = load_grs_capacity()
|
||||
logger.info("sync_gas_grs_capacity done: %s", result)
|
||||
_log_breadcrumb("gas_grs_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_gas_grs_capacity failed: %s", e)
|
||||
_log_breadcrumb("gas_grs_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_eesk_reserves",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_eesk_reserves() -> dict:
|
||||
"""Резервы АО «ЕЭСК» (xlsx-раскрытие) → power_supply_centers (ПС 35-220, МВт) +
|
||||
power_tp_rp_reserves (ТП/РП «ниже 35»). #2119 Фаза B2.
|
||||
|
||||
RUN-ON-PROD рядом с rosseti/gas/vodokanal (единый weekly-кластер). discovery
|
||||
свежайшего xlsx + загрузка ПС и ТП/РП — каждый файл graceful (сбой одного не
|
||||
валит другой; лоадер сам изолирует per-row SAVEPOINT-ами).
|
||||
"""
|
||||
_log_breadcrumb("eesk_reserves_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.eesk_reserve_loader import load_eesk_reserves
|
||||
|
||||
result = load_eesk_reserves()
|
||||
logger.info("sync_eesk_reserves done: %s", result)
|
||||
_log_breadcrumb("eesk_reserves_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_eesk_reserves failed: %s", e)
|
||||
_log_breadcrumb("eesk_reserves_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_osm_tp_geocode",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_osm_tp_geocode() -> dict:
|
||||
"""OSM-геокод городских ТП/РП ЕКБ → geom для power_tp_rp_reserves. #2119 B2 (финал).
|
||||
|
||||
RUN-ON-PROD рядом с ЕЭСК (тот же вторник-кластер). Матч name_norm (чистый номер)
|
||||
↔ OSM substation ref/name → UPDATE geom + geocode_status='osm'. Идемпотентно
|
||||
(geom IS NULL-гейт): не-новые точки просто пере-скипаются без эффекта. apply=True.
|
||||
Источник (Overpass) без гео-блока, но держим на проде в едином weekly-кластере.
|
||||
"""
|
||||
_log_breadcrumb("osm_tp_geocode_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.osm_tp_geocoder import geocode_tp_from_osm
|
||||
|
||||
result = geocode_tp_from_osm(apply=True)
|
||||
logger.info("sync_osm_tp_geocode done: %s", result)
|
||||
_log_breadcrumb("osm_tp_geocode_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_osm_tp_geocode failed: %s", e)
|
||||
_log_breadcrumb("osm_tp_geocode_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_gas_outlet_points",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_gas_outlet_points() -> dict:
|
||||
"""Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960) → gas_grs_outlet_points,
|
||||
затем догеокод новых адресных строк → geom. #2119 Фаза B2, PR-4.
|
||||
|
||||
RUN-ON-PROD (гео-блок источника ekgas.ru + DaData-ключи на проде). Два шага:
|
||||
1. load_gas_outlet_points() — свежайшая форма 6 из каталога → UPSERT (идемпотентно:
|
||||
помесячный UPSERT по (grs_name_norm, outlet_name, period_month, is_forecast),
|
||||
geom НЕ затирается в DO UPDATE);
|
||||
2. geocode_gas_outlets(apply=True) — новые адресные строки последнего period_month
|
||||
с geom IS NULL догеокодятся через DaData (in-memory кэш + cap внутри лоадера;
|
||||
уже геокоженные строки прошлых месяцев не трогаются).
|
||||
|
||||
Источник обновляется ~5-го числа месяца, но расписание еженедельное (вторник) —
|
||||
не-новый период просто пере-UPSERT-ится без эффекта (geom сохраняется). Геокод
|
||||
graceful: сбой шага 2 не откатывает загруженные строки (шаг 1 уже закоммичен своей
|
||||
сессией). Каждый шаг логируется breadcrumb-ом в nspd_geo_log.
|
||||
"""
|
||||
_log_breadcrumb("gas_outlet_sync", "info", "task started")
|
||||
result: dict = {}
|
||||
try:
|
||||
from app.services.site_finder.ekgas_outlet_loader import load_gas_outlet_points
|
||||
|
||||
result["load"] = load_gas_outlet_points()
|
||||
except Exception as e:
|
||||
logger.exception("sync_gas_outlet_points: load failed: %s", e)
|
||||
_log_breadcrumb("gas_outlet_sync", "error", f"load {type(e).__name__}: {e}")
|
||||
result["load"] = {"error": str(e)}
|
||||
|
||||
try:
|
||||
from app.services.site_finder.ekgas_outlet_loader import geocode_gas_outlets
|
||||
|
||||
result["geocode"] = geocode_gas_outlets(apply=True)
|
||||
except Exception as e:
|
||||
logger.exception("sync_gas_outlet_points: geocode failed: %s", e)
|
||||
_log_breadcrumb("gas_outlet_sync", "error", f"geocode {type(e).__name__}: {e}")
|
||||
result["geocode"] = {"error": str(e)}
|
||||
|
||||
logger.info("sync_gas_outlet_points done: %s", result)
|
||||
_log_breadcrumb("gas_outlet_sync", "info", f"done: {result}")
|
||||
return result
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_heat_reserves",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_heat_reserves() -> dict:
|
||||
"""Резервы тепловой мощности ФАС (ri.eias.ru) → heat_system_reserves. #2119 B2.
|
||||
|
||||
RUN-ON-PROD (гео-блок non-RU + российский УЦ). По всем организациям-держателям
|
||||
(ЕТК, Екатеринбургэнерго): карточка ФАС → публикации свежайшего квартала →
|
||||
выгрузки xlsx → UPSERT. Каждая организация graceful (падение одной не валит вторую).
|
||||
"""
|
||||
_log_breadcrumb("heat_reserves_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.eias_heat_loader import load_heat_reserves
|
||||
|
||||
result = load_heat_reserves()
|
||||
logger.info("sync_heat_reserves done: %s", result)
|
||||
_log_breadcrumb("heat_reserves_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_heat_reserves failed: %s", e)
|
||||
_log_breadcrumb("heat_reserves_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
80
backend/app/workers/tasks/full_report.py
Normal file
80
backend/app/workers/tasks/full_report.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Celery task: собрать полный PDF-отчёт ПТИЦА в фоне (эпик #2259 PR-D).
|
||||
|
||||
Сборка полного PDF `/site-finder/analysis/{cad}` (§1–§7 + карты) занимает секунды-десятки
|
||||
секунд (WeasyPrint-рендер + OSM-тайлы карт + server-side концепция), поэтому НЕ крутится
|
||||
inline на синхронном эндпоинте — здесь фоновая on-demand таска, оркестрирующая
|
||||
:func:`build_full_report` (full_report_pdf.py) и пишущая PDF-файл + метадата-ран.
|
||||
|
||||
ПОТОК: `SessionLocal()` → `build_full_report(db, cad)` → метадата-dict (pdf_path/…) →
|
||||
close. Best-effort: нет analyze-рана → ValueError внутри оркестратора → ловим, warning,
|
||||
return None (таска не валит воркер — отчётировать нечего). Прочий сбой → logger.exception
|
||||
+ None (карты/концепция/форсайт сами best-effort внутри оркестратора).
|
||||
|
||||
Mirror conventions (forecast.py): `@celery_app.task`, `SessionLocal()` + try/finally close,
|
||||
`logger` (не print), синхронный `def`. Очередь — дефолтная `celery` (как forecast-таска: у
|
||||
неё нет `queue=` в celery_app routing → default; воркер слушает `--queues=celery,scrape_kn,geo`).
|
||||
|
||||
TIME LIMIT: WeasyPrint-рендер + карты могут занять до ~минуты на тяжёлом участке —
|
||||
щедрый per-task лимит (time_limit=600 / soft_time_limit=540), как у forecast-таски.
|
||||
|
||||
REGISTRATION: добавлено в `include=[...]` celery_app.py (зеркало forecast). Beat НЕТ —
|
||||
таска enqueue-on-demand (триггер из POST /{cad}/report).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.exporters.full_report_pdf import build_full_report
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="tasks.full_report.build_full_report",
|
||||
time_limit=600,
|
||||
soft_time_limit=540,
|
||||
)
|
||||
def build_full_report_task(self: Any, cad: str) -> dict[str, Any] | None:
|
||||
"""Собрать полный PDF-отчёт участка в фоне и записать файл + метадата-ран.
|
||||
|
||||
Оркестрация целиком в :func:`build_full_report` (full_report_pdf.py): analyze-ран
|
||||
(нет → ValueError → None), forecast-ран (нет → Part B «нет данных»), connection-capacity
|
||||
и концепция best-effort, карты + WeasyPrint-рендер → файл на volume `/app/reports/` +
|
||||
метадата-ран `report-pdf-1.0`. Кэш по (analyze_run_id, forecast_run_id): тот же ключ +
|
||||
файл на месте → cache-hit, не пере-рендерит.
|
||||
|
||||
Args:
|
||||
cad: кадастровый номер участка.
|
||||
|
||||
Returns:
|
||||
Метадата отчёта {pdf_path, analyze_run_id, forecast_run_id, generated_at,
|
||||
size_bytes} (+ `cache_hit` при кэш-хите), либо None если нет analyze-рана /
|
||||
неожиданный сбой (таска failed gracefully, воркер не падает).
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
logger.info("build_full_report_task: старт cad=%s", cad)
|
||||
result = build_full_report(db, cad)
|
||||
logger.info(
|
||||
"build_full_report_task: готово cad=%s path=%s cache_hit=%s",
|
||||
cad,
|
||||
result.get("pdf_path"),
|
||||
result.get("cache_hit", False),
|
||||
)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
# Нет analyze-рана — отчётировать нечего (graceful, не crash воркера).
|
||||
logger.warning("build_full_report_task: нечего собирать cad=%s: %s", cad, exc)
|
||||
return None
|
||||
except Exception:
|
||||
# Неожиданный сбой рендера/записи: traceback + None (карты/концепция/форсайт
|
||||
# уже best-effort внутри оркестратора — сюда всплывает только фатальное).
|
||||
logger.exception("build_full_report_task failed for cad=%s (returning None)", cad)
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -210,6 +210,12 @@ def scrape_kn_region(
|
|||
fetch_flats=fetch_flats,
|
||||
extras=extras,
|
||||
download_photos_binary=download_photos,
|
||||
# #1945 anti-ban: throttle + optional proxy + flats/extras isolation.
|
||||
browser_concurrency=settings.scrape_kn_browser_concurrency,
|
||||
request_jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||||
request_jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||||
proxy_url=settings.scrape_kn_proxy_url,
|
||||
extras_isolated=settings.scrape_kn_extras_isolated,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -289,5 +295,11 @@ def resume_kn_run(self: Any, run_id: int) -> dict[str, Any]:
|
|||
developers=developers,
|
||||
load_state=state_path,
|
||||
resume_from_run_id=run_id,
|
||||
# #1945 anti-ban: throttle + optional proxy + flats/extras isolation.
|
||||
browser_concurrency=settings.scrape_kn_browser_concurrency,
|
||||
request_jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||||
request_jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||||
proxy_url=settings.scrape_kn_proxy_url,
|
||||
extras_isolated=settings.scrape_kn_extras_isolated,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ dependencies = [
|
|||
"beautifulsoup4>=4.12.0", # HTML-парсер реестра КРТ-площадок ЕКБ (#1060)
|
||||
"pymupdf>=1.24.0", # растеризация PDF→image (без poppler) для OCR (#1062)
|
||||
"pytesseract>=0.3.13", # OCR сканов через Tesseract для изъятия ЕКБ (#1062)
|
||||
"contextily>=1.7.0", # OSM basemap-тайлы для серверного рендера карт отчёта (#2259 PR-C)
|
||||
"matplotlib>=3.11.0", # headless (Agg) рендер PNG-карт участка/концепции (#2259 PR-C)
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
|
@ -91,11 +93,16 @@ strict = true
|
|||
|
||||
# Геометрия/экспорт-библиотеки без type stubs (shapely/ezdxf/weasyprint не несут
|
||||
# py.typed) — игнорируем missing-imports, чтобы strict-модули generative проходили.
|
||||
# geopandas/matplotlib/contextily добавлены для report_maps (#2259 PR-C, серверный
|
||||
# рендер карт отчёта) — те же py.typed-less библиотеки.
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"shapely.*",
|
||||
"ezdxf.*",
|
||||
"weasyprint.*",
|
||||
"geopandas.*",
|
||||
"matplotlib.*",
|
||||
"contextily.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
|
|
|||
|
|
@ -174,12 +174,76 @@ class TestObjPricingPushdown:
|
|||
"совпадал с выводимыми конкурентами"
|
||||
)
|
||||
|
||||
def test_aggregation_unchanged(self) -> None:
|
||||
"""Агрегатные выражения (AVG/COUNT FILTER) не тронуты — менялся только scope."""
|
||||
def test_aggregation_per_domrf_obj_id(self) -> None:
|
||||
"""Агрегаты (AVG/COUNT FILTER) считаются per domrf_obj_id (логика цен #1332).
|
||||
|
||||
#1964: источник агрегатов сменился с сырого objective_lots (alias ol) на
|
||||
physflat-дедуп CTE obj_lots_latest (alias oll) — см. test_obj_pricing_*_physflat
|
||||
ниже. Сами агрегатные выражения и группировка per-obj_id неизменны.
|
||||
"""
|
||||
sql = self._competitor_sql()
|
||||
# Логика цен/fallback (#1332) неизменна: те же агрегаты per domrf_obj_id.
|
||||
assert "ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
|
||||
assert "COUNT(*) FILTER (WHERE ol.price_per_m2_rub IS NOT NULL )" in sql.replace(
|
||||
assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
|
||||
assert "lots_with_price" in sql
|
||||
assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
|
||||
assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
|
||||
assert "GROUP BY np.domrf_obj_id" in sql
|
||||
|
||||
def test_obj_pricing_dedups_physflat_inline(self) -> None:
|
||||
"""#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.
|
||||
|
||||
objective_lots раздут ~2.91× (мульти objective_lot_id на физлот через
|
||||
пере-листинги) → units_sold/units_available карточки конкурента были завышены.
|
||||
Дедупим INLINE (DISTINCT ON physflat-ключ, последний снапшот) со scope по
|
||||
project_name ближних ЖК. НЕ через v_objective_lots_latest: view материализует
|
||||
ВСЮ таблицу 1.76M (join не проходит ниже DISTINCT ON → seq-scan+sort ~5.7 s на
|
||||
request-path analyze_parcel; inline scoped → ~0.4 s, прод-EXPLAIN #1964).
|
||||
"""
|
||||
sql = self._competitor_sql()
|
||||
assert "obj_lots_latest AS (" in sql, "должен быть physflat-дедуп CTE (#1964)"
|
||||
assert "DISTINCT ON ( ol.project_name, ol.corpus_name, ol.section," in sql.replace(
|
||||
"\n", " "
|
||||
) or "lots_with_price" in sql
|
||||
assert "GROUP BY cm.domrf_obj_id" in sql
|
||||
), "obj_lots_latest должен дедупить по physflat-ключу"
|
||||
assert "snapshot_date DESC, ol.id DESC" in sql, "берём последний снапшот физлота"
|
||||
assert (
|
||||
"v_objective_lots_latest" not in sql
|
||||
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
|
||||
|
||||
|
||||
class TestCompetitorAvgAreaPd:
|
||||
"""#2111: средняя площадь квартиры конкурента (avg_area_pd) доходит до попапа.
|
||||
|
||||
obj_pricing CTE считает ROUND(AVG(oll.area_pd),1) AS avg_area_pd, финальный
|
||||
SELECT выбирает p.avg_area_pd, а _competitor_with_coords должен протолкнуть
|
||||
ключ в loose-dict competitors[] (parcel.py:651). Mock-based, без БД.
|
||||
"""
|
||||
|
||||
def test_avg_area_pd_passes_through(self) -> None:
|
||||
"""_competitor_with_coords сохраняет avg_area_pd из входной строки."""
|
||||
row = {
|
||||
"obj_id": 1,
|
||||
"comm_name": "ЖК Тест",
|
||||
"dev_name": "TestDev",
|
||||
"avg_price_per_m2_rub": 150000,
|
||||
"avg_area_pd": 47.3,
|
||||
"units_sold": 10,
|
||||
"units_available": 5,
|
||||
"lat": 56.838011,
|
||||
"lon": 60.597474,
|
||||
}
|
||||
out = parcels_module._competitor_with_coords(row)
|
||||
assert out["avg_area_pd"] == 47.3
|
||||
|
||||
def test_avg_area_pd_none_when_absent(self) -> None:
|
||||
"""Отсутствие avg_area_pd → ключ есть, значение None (graceful, ЖК без маппинга)."""
|
||||
row = {"obj_id": 2, "comm_name": "ЖК Без Цен", "lat": None, "lon": None}
|
||||
out = parcels_module._competitor_with_coords(row)
|
||||
assert out["avg_area_pd"] is None
|
||||
|
||||
def test_sql_computes_and_selects_avg_area_pd(self) -> None:
|
||||
"""Competitor-SQL вычисляет AVG(area_pd) и выбирает avg_area_pd в финале."""
|
||||
src = inspect.getsource(parcels_module.analyze_parcel)
|
||||
marker = "WITH latest_obj AS ("
|
||||
start = src.index(marker)
|
||||
block = re.sub(r"\s+", " ", src[start : src.index('"""', start)])
|
||||
assert "ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd" in block
|
||||
assert "p.avg_area_pd" in block
|
||||
|
|
|
|||
|
|
@ -70,7 +70,9 @@ def _make_db_for_analyze(
|
|||
_make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
@ -91,7 +93,7 @@ def _make_db_for_analyze(
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
|
|
|
|||
|
|
@ -53,11 +53,19 @@ def _make_db_for_analyze(
|
|||
geom_found: bool = True,
|
||||
district_found: bool = True,
|
||||
market_price_row: dict[str, Any] | None = None,
|
||||
district_row_override: dict[str, Any] | None = None,
|
||||
success_rows: list[dict[str, Any]] | None = None,
|
||||
geo_radius_row: dict[str, Any] | None = None,
|
||||
district_price_row: dict[str, Any] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Сконструировать mock DB Session для analyze_parcel.
|
||||
|
||||
market_price_row=None → имитирует "нет данных в MV" (mp_row is None → source='no_data').
|
||||
market_price_row={...} → имитирует найденную строку в MV.
|
||||
district_row_override → переопределяет raw-district mapping (#1960 basis chain).
|
||||
success_rows → строки v_bucket_success_score (#1955 obj_class passthrough).
|
||||
geo_radius_row → строка geo_radius_price (objective lots в радиусе, #1960 basis #2).
|
||||
district_price_row → строка district_price_block (objective по имени, #1960 basis #1).
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
|
|
@ -67,20 +75,20 @@ def _make_db_for_analyze(
|
|||
else None
|
||||
)
|
||||
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
|
||||
district_row = (
|
||||
_make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
if district_found
|
||||
else None
|
||||
)
|
||||
_district_data = district_row_override or {
|
||||
"district_name": "Октябрьский",
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
district_row = _make_mapping(_district_data) if district_found else None
|
||||
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||
|
||||
mp_mock = _make_mapping(market_price_row) if market_price_row is not None else None
|
||||
success_mocks = [_make_mapping(r) for r in success_rows] if success_rows is not None else []
|
||||
geo_mock = _make_mapping(geo_radius_row) if geo_radius_row is not None else None
|
||||
dp_mock = _make_mapping(district_price_row) if district_price_row is not None else None
|
||||
|
||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||
# Нормализуем SQL для сигнатурного матчинга (collapse whitespace).
|
||||
|
|
@ -94,13 +102,22 @@ def _make_db_for_analyze(
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
# market-price (#33) — уникальная сигнатура трёх скользящих медиан.
|
||||
elif "median_6m" in sql and "median_12m" in sql and "median_24m" in sql:
|
||||
first_val = mp_mock
|
||||
# #1955: success-ranking из v_bucket_success_score.
|
||||
elif "v_bucket_success_score" in sql:
|
||||
all_val = success_mocks
|
||||
# #1960 basis #1: district_price_block (Objective по имени района).
|
||||
elif "AS sample_size" in sql and "objective_lots" in sql:
|
||||
first_val = dp_mock
|
||||
# #1960 basis #2: geo_radius_price (Objective в радиусе).
|
||||
elif "AS n_complexes" in sql and "objective_lots" in sql:
|
||||
first_val = geo_mock
|
||||
|
||||
r = MagicMock()
|
||||
r.mappings.return_value.first.return_value = first_val
|
||||
|
|
@ -254,3 +271,201 @@ def test_market_price_invalid_cad_returns_404() -> None:
|
|||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
# ── #1960: «Медиана рынка» = newbuild-consistent basis ──────────────────────────
|
||||
|
||||
|
||||
def test_district_median_uses_geo_radius_not_quarter_rosreestr() -> None:
|
||||
"""#1960: при тонкой росреестровской квартальной медиане (deals<5) карточка
|
||||
district.median_price_per_m2 берёт newbuild geo_radius медиану, НЕ 64k.
|
||||
|
||||
Репродукция бага для 66:41:0205010:287: quarter_median_12m=63694 (1 ДКП),
|
||||
geo_radius медиана=132690. Objective-по-имени отсутствует (name-match gap) →
|
||||
basis должен упасть на geo_radius_price.
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = _make_db_for_analyze(
|
||||
district_row_override={
|
||||
"district_name": "Железнодорожный",
|
||||
"ekb_reference_median": 69687,
|
||||
"quarter_median_12m": 63694, # загрязнённая росреестровская квартальная
|
||||
"quarter_deals_count": 6, # 24-мес окно ≥5, но 12-мес — тонкое
|
||||
"dist_to_center": 0.0,
|
||||
},
|
||||
# Objective по имени района отсутствует (5/9 районов ЕКБ без name-match).
|
||||
district_price_row=None,
|
||||
# geo_radius — newbuild basis (что и остальная страница).
|
||||
geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
|
||||
)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
district = body["district"]
|
||||
assert district["median_price_basis"] == "objective_geo_radius"
|
||||
assert district["median_price_per_m2"] == pytest.approx(132690.0)
|
||||
# КЛЮЧЕВОЕ: загрязнённая 64k росреестровская медиана НЕ выигрывает.
|
||||
assert district["median_price_per_m2"] != pytest.approx(63694.0)
|
||||
# И значение в newbuild-диапазоне (~130-200k).
|
||||
assert 130000 <= district["median_price_per_m2"] <= 200000
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_district_median_prefers_objective_district_when_available() -> None:
|
||||
"""#1960: если есть Objective-медиана по имени района — она имеет приоритет
|
||||
над geo_radius и квартальной росреестровской."""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = _make_db_for_analyze(
|
||||
district_row_override={
|
||||
"district_name": "Октябрьский",
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": 64000,
|
||||
"quarter_deals_count": 6,
|
||||
"dist_to_center": 1500.0,
|
||||
},
|
||||
district_price_row={
|
||||
"price_min": 90000,
|
||||
"price_max": 250000,
|
||||
"price_median": 165000,
|
||||
"sample_size": 42,
|
||||
},
|
||||
geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
|
||||
)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
district = resp.json()["district"]
|
||||
assert district["median_price_basis"] == "objective_district"
|
||||
assert district["median_price_per_m2"] == pytest.approx(165000.0)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_district_median_quarter_rosreestr_gated_by_min_deals() -> None:
|
||||
"""#1960: квартальная росреестровская медиана может стать basis ТОЛЬКО как
|
||||
последний fallback и ТОЛЬКО при deals_count ≥ 5. Тонкий квартал (deals<5)
|
||||
→ median None (не показываем загрязнённое значение)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
# Нет Objective-данных вообще, нет ekb_reference, тонкий квартал (1 сделка).
|
||||
db = _make_db_for_analyze(
|
||||
district_row_override={
|
||||
"district_name": "Глухой",
|
||||
"ekb_reference_median": None,
|
||||
"quarter_median_12m": 64000,
|
||||
"quarter_deals_count": 1, # < 5 → не проходит guard
|
||||
"dist_to_center": 3000.0,
|
||||
},
|
||||
district_price_row=None,
|
||||
geo_radius_row=None,
|
||||
)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
district = resp.json()["district"]
|
||||
assert district["median_price_basis"] == "none"
|
||||
assert district["median_price_per_m2"] is None
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_district_median_quarter_rosreestr_selected_as_last_resort() -> None:
|
||||
"""#1960: если ВСЕ newbuild-fallback'и None (нет Objective-по-имени, нет
|
||||
geo_radius, нет ekb_reference), но квартальная росреестровская медиана имеет
|
||||
deals_count ≥ 5 — она выбирается как basis (4-й fallback) с явным источником."""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = _make_db_for_analyze(
|
||||
district_row_override={
|
||||
"district_name": "Глухой",
|
||||
"ekb_reference_median": None, # нет справочной DDU-медианы
|
||||
"quarter_median_12m": 88000,
|
||||
"quarter_deals_count": 7, # ≥ 5 → проходит guard
|
||||
"dist_to_center": 3000.0,
|
||||
},
|
||||
district_price_row=None, # нет Objective по имени
|
||||
geo_radius_row=None, # нет geo_radius
|
||||
)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
district = resp.json()["district"]
|
||||
assert district["median_price_basis"] == "quarter_rosreestr"
|
||||
assert district["median_price_per_m2"] == pytest.approx(88000.0)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
# ── #1955: obj_class passthrough в success_recommendation ────────────────────────
|
||||
|
||||
|
||||
def test_success_recommendation_carries_obj_class() -> None:
|
||||
"""#1955: ranking-строки несут obj_class — UI может различать строки с
|
||||
одинаковым area-label (Комфорт vs Типовой vs «не указан»)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
success = [
|
||||
{
|
||||
"bucket": "Студии 15-30",
|
||||
"obj_class": "Комфорт",
|
||||
"success_score": 1.2,
|
||||
"n_deals": 4857,
|
||||
"avg_price_per_m2": 194000,
|
||||
"avg_area_m2": 25.0,
|
||||
"velocity_z": 0.5,
|
||||
"price_z": 0.3,
|
||||
"area_z": -0.2,
|
||||
},
|
||||
{
|
||||
"bucket": "Студии 15-30",
|
||||
"obj_class": "не указан",
|
||||
"success_score": 0.4,
|
||||
"n_deals": 165,
|
||||
"avg_price_per_m2": 142000,
|
||||
"avg_area_m2": 26.0,
|
||||
"velocity_z": 0.1,
|
||||
"price_z": -0.1,
|
||||
"area_z": 0.0,
|
||||
},
|
||||
]
|
||||
db = _make_db_for_analyze(success_rows=success)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
rec = resp.json()["success_recommendation"]
|
||||
assert rec is not None
|
||||
ranking = rec["ranking"]
|
||||
assert len(ranking) == 2
|
||||
assert ranking[0]["obj_class"] == "Комфорт"
|
||||
assert ranking[1]["obj_class"] == "не указан"
|
||||
# Один area-label, но разные классы → больше не визуально-дубль.
|
||||
assert ranking[0]["bucket"] == ranking[1]["bucket"]
|
||||
assert ranking[0]["obj_class"] != ranking[1]["obj_class"]
|
||||
# И никакого английского фантома 'Comfort'.
|
||||
assert all(r["obj_class"] != "Comfort" for r in ranking)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
|
|
|||
|
|
@ -40,14 +40,14 @@ def _make_mapping(data: dict[str, Any]) -> MagicMock:
|
|||
def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
|
||||
db = MagicMock()
|
||||
|
||||
geom_row = _make_mapping(
|
||||
{"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}
|
||||
)
|
||||
geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
|
||||
wkt_row = _make_mapping({"wkt": _WKT})
|
||||
district_row = _make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
@ -63,7 +63,7 @@ def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
# И centroid_row для блока 6, и OSRM-origin SELECT (тот же shape).
|
||||
|
|
@ -170,9 +170,7 @@ def test_flag_on_osrm_replaces_distance(monkeypatch) -> None:
|
|||
monkeypatch.setattr(settings, "use_osrm_distances", True)
|
||||
_start_patches()
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_road_distances_m", return_value=[700.0]
|
||||
) as mock_osrm:
|
||||
with patch("app.api.v1.parcels.get_road_distances_m", return_value=[700.0]) as mock_osrm:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ def _make_db_for_analyze(
|
|||
district_row = _make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ def _make_db_for_analyze(
|
|||
district_row = _make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
@ -83,7 +85,7 @@ def _make_db_for_analyze(
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
|
|
|
|||
|
|
@ -110,7 +110,9 @@ def _make_db_for_analyze() -> MagicMock:
|
|||
district_row = _make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
162
backend/tests/api/v1/test_concept_recompute.py
Normal file
162
backend/tests/api/v1/test_concept_recompute.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Stage 2a (#1965, эпик #1953) — POST /api/v1/concepts/recompute endpoint tests.
|
||||
|
||||
LIVE-пересчёт экономики из агрегированной массинг-программы (пятно × этажность) для
|
||||
дебаунс-слайдеров Stage 2b. Проверяем: 200 + связный вывод (ТЭП-математика, revenue>0,
|
||||
NPV посчитан), FAST path с предрезолвленной ценой (БЕЗ обращения к БД) и DB-fallback по
|
||||
центроиду (через dependency override get_db мок-сессией). compute_financial остаётся
|
||||
чистой — её арифметика покрыта в tests/services/generative; тут — контракт эндпоинта.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _program(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"total_footprint_sqm": 3000.0,
|
||||
"floors": 12,
|
||||
"sections": 2,
|
||||
"site_area_sqm": 10_000.0,
|
||||
"housing_class": "comfort",
|
||||
"development_type": "mid_rise",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_recompute_returns_200_and_coherent_output() -> None:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/concepts/recompute",
|
||||
json=_program(market_price_per_sqm=150_000.0),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
teap = body["teap"]
|
||||
fin = body["financial"]
|
||||
# ТЭП-математика: GFA = пятно × этажность; пятно = total_footprint_sqm.
|
||||
assert teap["built_area_sqm"] == 3000.0
|
||||
assert teap["total_floor_area_sqm"] == 3000.0 * 12
|
||||
# Финмодель связна: выручка/затраты > 0, NPV посчитан (поле присутствует).
|
||||
assert fin["revenue_rub"] > 0
|
||||
assert fin["cost_rub"] > 0
|
||||
assert isinstance(fin["npv_rub"], float)
|
||||
|
||||
|
||||
def test_recompute_market_price_passthrough_skips_db() -> None:
|
||||
# Предрезолвленная цена в теле → используется как есть, БЕЗ обращения к БД.
|
||||
# get_db переопределён сессией, которая ВЗРЫВАЕТСЯ при любом execute — если бы
|
||||
# эндпоинт пошёл в БД, тест бы упал. Зелёный тест доказывает skip DB lookup.
|
||||
class _ExplodingSession:
|
||||
def execute(self, *_a: object, **_k: object) -> None:
|
||||
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/concepts/recompute",
|
||||
json=_program(market_price_per_sqm=180_000.0),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
fin = resp.json()["financial"]
|
||||
# Цена прокинута в выручку жилья → price_per_sqm_used == переданная цена.
|
||||
assert fin["price_per_sqm_used"] == 180_000.0
|
||||
assert fin["price_is_calibrated"] is True
|
||||
# Без body price_source → дефолтный лейбл (фронт не форвардил подлинный source).
|
||||
assert fin["price_source"] == "objective_district_median"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
def test_recompute_body_price_source_echoed_through() -> None:
|
||||
# Фронт форвардит ПОДЛИННЫЙ source цены (из financial_estimate, напр. geo_radius) —
|
||||
# эндпоинт НЕ подменяет его захардкоженным дефолтом; honest-флаг доходит до UI/PDF.
|
||||
# get_db взрывается → доказывает, что и source-passthrough идёт по FAST path без БД.
|
||||
class _ExplodingSession:
|
||||
def execute(self, *_a: object, **_k: object) -> None:
|
||||
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/concepts/recompute",
|
||||
json=_program(market_price_per_sqm=160_000.0, price_source="objective_geo_radius"),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
fin = resp.json()["financial"]
|
||||
assert fin["price_per_sqm_used"] == 160_000.0
|
||||
assert fin["price_is_calibrated"] is True
|
||||
# Подлинный source эхнут наружу, НЕ перетёрт дефолтом "objective_district_median".
|
||||
assert fin["price_source"] == "objective_geo_radius"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
def test_recompute_db_fallback_when_no_price_but_centroid() -> None:
|
||||
# Нет market_price_per_sqm, но передан centroid → эндпоинт делает _lookup_market_price.
|
||||
# Мок-сессия отдаёт район + Objective-медиану (n>=10) → цена калибруется из БД.
|
||||
class _Result:
|
||||
def __init__(self, row: dict[str, Any] | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def mappings(self) -> _Result:
|
||||
return self
|
||||
|
||||
def first(self) -> dict[str, Any] | None:
|
||||
return self._row
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, rows: list[dict[str, Any] | None]) -> None:
|
||||
self._rows = rows
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, *_a: object, **_k: object) -> _Result:
|
||||
row = self._rows[self.calls] if self.calls < len(self._rows) else None
|
||||
self.calls += 1
|
||||
return _Result(row)
|
||||
|
||||
rows = [
|
||||
{"district_name": "Кировский", "median_price_per_m2": 130_000},
|
||||
{"median_ppm2": 175_000.0, "sample_size": 42},
|
||||
]
|
||||
app.dependency_overrides[get_db] = lambda: _FakeSession(rows)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/concepts/recompute",
|
||||
json=_program(parcel_centroid_wkt="POINT (60.6 56.83)"),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
fin = resp.json()["financial"]
|
||||
# Цена пришла из Objective-медианы района (DB-fallback).
|
||||
assert fin["price_per_sqm_used"] == 175_000.0
|
||||
assert fin["price_source"] == "objective_district_median"
|
||||
assert fin["price_is_calibrated"] is True
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
def test_recompute_no_price_no_centroid_uses_class_norm() -> None:
|
||||
# Ни цены, ни центроида → норматив класса comfort (145_000), не калибровано.
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/concepts/recompute", json=_program())
|
||||
assert resp.status_code == 200, resp.text
|
||||
fin = resp.json()["financial"]
|
||||
assert fin["price_per_sqm_used"] == 145_000.0
|
||||
assert fin["price_is_calibrated"] is False
|
||||
assert fin["price_source"] == "class_norm"
|
||||
|
||||
|
||||
def test_recompute_validates_floors_bounds() -> None:
|
||||
# floors вне [1, 40] → 422 (Pydantic-валидация контракта).
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/concepts/recompute", json=_program(floors=99))
|
||||
assert resp.status_code == 422
|
||||
|
|
@ -259,7 +259,9 @@ def _make_db_for_analyze() -> MagicMock:
|
|||
district_row = _make_mapping_analyze(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ Mock-based — не требуют живой БД.
|
|||
Паттерн mock DB: аналогично test_parcel_competitors.py — dependency_overrides[get_db].
|
||||
|
||||
Порядок вызовов в get_best_layouts (Fix SF-01 — inline velocity):
|
||||
db.scalar() → MAX(snapshot_date) (только когда vel_rows non-empty)
|
||||
db.execute() calls:
|
||||
1. _PARCEL_CENTROID_SQL → .mappings().first()
|
||||
2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
|
||||
3. _INLINE_VELOCITY_SQL → .mappings().all()
|
||||
4. _SUPPLY_BATCH_SQL → .mappings().all() (пропускается если latest_snap is None)
|
||||
4. _SUPPLY_BATCH_SQL → .mappings().all()
|
||||
|
||||
Fix #1956: supply берёт per-object последний snapshot внутри _SUPPLY_BATCH_SQL
|
||||
(flats_latest CTE) — отдельного db.scalar(MAX(snapshot_date)) больше нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -21,6 +23,14 @@ import pytest
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.services.site_finder.best_layouts import (
|
||||
_COMPETITORS_IN_RADIUS_SQL,
|
||||
_INLINE_VELOCITY_SQL,
|
||||
_OBJECTIVE_DEVELOPERS_SQL,
|
||||
_OBJECTIVE_PROJECT_NAMES_SQL,
|
||||
_PARCEL_CENTROID_SQL,
|
||||
_SUPPLY_BATCH_SQL,
|
||||
)
|
||||
|
||||
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -35,11 +45,21 @@ def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
|
|||
return r
|
||||
|
||||
|
||||
def _obj_id_row(obj_id: int) -> MagicMock:
|
||||
"""Строка obj_id из _COMPETITORS_IN_RADIUS_SQL."""
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
|
||||
return r
|
||||
def _obj_id_row(obj_id: int) -> dict[str, object]:
|
||||
"""Строка из _COMPETITORS_IN_RADIUS_SQL.
|
||||
|
||||
Issue #2177: SQL возвращает comm_name/dev_name/гео для группировки obj_id в
|
||||
комплексы, а get_best_layouts делает dict(r). Возвращаем реальный dict (как
|
||||
SQLAlchemy RowMapping). Уникальное comm_name на obj_id → каждый = своя группа,
|
||||
поэтому существующие coverage-инварианты этих API-тестов не меняются.
|
||||
"""
|
||||
return {
|
||||
"obj_id": obj_id,
|
||||
"comm_name": f"obj-{obj_id}",
|
||||
"dev_name": f"dev-{obj_id}",
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
}
|
||||
|
||||
|
||||
def _vel_row(
|
||||
|
|
@ -64,6 +84,8 @@ def _vel_row(
|
|||
"avg_price_per_m2_rub": avg_price_rub,
|
||||
"competitor_obj_ids": oids,
|
||||
"competitor_count": len(oids),
|
||||
# Issue #2177 шаг 2b: project_name'ы с velocity в bucket'е (mapping ∪ core-match).
|
||||
"matched_project_names": [],
|
||||
"window_start": ws,
|
||||
"window_end": we,
|
||||
}[k]
|
||||
|
|
@ -85,45 +107,56 @@ def _make_db(
|
|||
id_rows: list[MagicMock] | None = None,
|
||||
vel_rows: list[MagicMock] | None = None,
|
||||
supply_rows: list[MagicMock] | None = None,
|
||||
latest_snap: dt.date | None = None,
|
||||
project_names: list[str] | None = None,
|
||||
developers_by_project: dict[str, list[str]] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Сконструировать mock Session.
|
||||
"""Сконструировать mock Session с диспатчем по SQL-стейтменту.
|
||||
|
||||
db.scalar() возвращает latest_snap (MAX snapshot_date) — вызывается перед supply.
|
||||
Порядок db.execute():
|
||||
1. centroid → .mappings().first()
|
||||
2. competitors-in-radius → .mappings().all()
|
||||
3. velocity → .mappings().all()
|
||||
4. supply → .mappings().all() (только если latest_snap is not None)
|
||||
Fix #1956: per-object последний снимок считается внутри _SUPPLY_BATCH_SQL.
|
||||
Issue #2177 шаг 2b добавил objective-projects/developers (core-attribution) перед
|
||||
velocity — роутим по идентичности SQL-констант, не позиционно. db.execute():
|
||||
• _PARCEL_CENTROID_SQL → .mappings().first()
|
||||
• _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
|
||||
• _OBJECTIVE_PROJECT_NAMES_SQL → .all()
|
||||
• _OBJECTIVE_DEVELOPERS_SQL → .all()
|
||||
• _INLINE_VELOCITY_SQL → .mappings().all()
|
||||
• _SUPPLY_BATCH_SQL → .mappings().all()
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
# db.scalar — pre-computed MAX(snapshot_date) для supply query
|
||||
db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY
|
||||
r_centroid = MagicMock()
|
||||
r_centroid.mappings.return_value.first.return_value = coord
|
||||
|
||||
results: list[MagicMock] = []
|
||||
r_comp = MagicMock()
|
||||
r_comp.mappings.return_value.all.return_value = id_rows or []
|
||||
|
||||
# 1: centroid
|
||||
r0 = MagicMock()
|
||||
r0.mappings.return_value.first.return_value = coord
|
||||
results.append(r0)
|
||||
r_proj = MagicMock()
|
||||
r_proj.all.return_value = [(n,) for n in (project_names or [])]
|
||||
|
||||
# 2: competitors-in-radius
|
||||
r1 = MagicMock()
|
||||
r1.mappings.return_value.all.return_value = id_rows or []
|
||||
results.append(r1)
|
||||
r_dev = MagicMock()
|
||||
r_dev.all.return_value = [
|
||||
(pname, devs) for pname, devs in (developers_by_project or {}).items()
|
||||
]
|
||||
|
||||
# 3: velocity (only queried if id_rows non-empty)
|
||||
r2 = MagicMock()
|
||||
r2.mappings.return_value.all.return_value = vel_rows or []
|
||||
results.append(r2)
|
||||
r_vel = MagicMock()
|
||||
r_vel.mappings.return_value.all.return_value = vel_rows or []
|
||||
|
||||
# 4: supply
|
||||
r3 = MagicMock()
|
||||
r3.mappings.return_value.all.return_value = supply_rows or []
|
||||
results.append(r3)
|
||||
r_supply = MagicMock()
|
||||
r_supply.mappings.return_value.all.return_value = supply_rows or []
|
||||
|
||||
db.execute.side_effect = results
|
||||
dispatch = {
|
||||
_PARCEL_CENTROID_SQL: r_centroid,
|
||||
_COMPETITORS_IN_RADIUS_SQL: r_comp,
|
||||
_OBJECTIVE_PROJECT_NAMES_SQL: r_proj,
|
||||
_OBJECTIVE_DEVELOPERS_SQL: r_dev,
|
||||
_INLINE_VELOCITY_SQL: r_vel,
|
||||
_SUPPLY_BATCH_SQL: r_supply,
|
||||
}
|
||||
|
||||
def _execute(stmt, *_args, **_kwargs):
|
||||
return dispatch[stmt]
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
return db
|
||||
|
||||
|
||||
|
|
@ -378,7 +411,6 @@ def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None:
|
|||
id_rows=id_rows,
|
||||
vel_rows=vel_rows,
|
||||
supply_rows=supply_rows,
|
||||
latest_snap=dt.date.today(),
|
||||
)
|
||||
from app.core.db import get_db
|
||||
|
||||
|
|
@ -407,7 +439,6 @@ def test_sold_pct_below_100_is_not_oversold() -> None:
|
|||
id_rows=id_rows,
|
||||
vel_rows=vel_rows,
|
||||
supply_rows=supply_rows,
|
||||
latest_snap=dt.date.today(),
|
||||
)
|
||||
from app.core.db import get_db
|
||||
|
||||
|
|
|
|||
|
|
@ -449,9 +449,9 @@ def test_competitors_avg_price_populated() -> None:
|
|||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
comp = resp.json()["competitors"][0]
|
||||
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
|
||||
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
|
||||
)
|
||||
assert comp["avg_price_per_m2"] == pytest.approx(
|
||||
150_000.0
|
||||
), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
|
||||
# OBJ-3 #307: domrf-hit → price_source='domrf'.
|
||||
assert comp["price_source"] == "domrf"
|
||||
finally:
|
||||
|
|
@ -471,9 +471,7 @@ def test_competitors_objective_price_fallback_fills_gap() -> None:
|
|||
rows = [_obj_row(obj_id=1)]
|
||||
# domrf price пуст → fallback на objective median.
|
||||
obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
|
||||
db = _make_db(
|
||||
coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows
|
||||
)
|
||||
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows)
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
|
|
@ -597,14 +595,41 @@ def test_sold_count_sql_is_fanout_safe() -> None:
|
|||
НЕ domrf_obj_id → один obj_id может иметь несколько mapping-строк (то же имя,
|
||||
разный objective_group) → JOIN по project_name задвоил бы лоты. DISTINCT по
|
||||
objective_lot_id дедуплицирует на уровне лота. Регресс к COUNT(*) → fail.
|
||||
|
||||
#1964: DISTINCT СОХРАНЯЕТСЯ и в inline-варианте (hunt #1953): будущий fan-out
|
||||
маппинга задвоил бы строки, и COUNT(*) перестал бы быть fan-out-safe. В новом
|
||||
SQL final-SELECT агрегирует по CTE `latest` (без ol-алиаса) → COUNT(DISTINCT
|
||||
objective_lot_id).
|
||||
"""
|
||||
sql = _sold_sql_text()
|
||||
assert "COUNT(DISTINCT ol.objective_lot_id)" in sql, (
|
||||
"fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
|
||||
)
|
||||
assert (
|
||||
"COUNT(DISTINCT objective_lot_id)" in sql
|
||||
), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
|
||||
# COUNT(*) допустим внутри как агрегат? нет — sold-count агрегирует только distinct lot.
|
||||
assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге"
|
||||
|
||||
|
||||
def test_sold_count_sql_reads_raw_objective_lots_inline() -> None:
|
||||
"""hunt #1953 (регрессия #1964): sold-count читает СЫРУЮ objective_lots inline с
|
||||
DISTINCT ON по physflat-ключу, НЕ v_objective_lots_latest.
|
||||
|
||||
Через view DISTINCT ON материализует ВСЮ 1.76M-таблицу ДО фильтра маппинга (qual
|
||||
не проталкивается ниже view-DISTINCT ON) → Parallel Seq Scan + external sort
|
||||
(~6896 ms, ~213 MB temp на каждый cold /analyze). Inline-pushdown: фильтр
|
||||
маппинга в CTE → DISTINCT ON по сырой objective_lots с JOIN ON project_name
|
||||
(objective_lots_project_idx) → ~437 ms, вывод побайтово идентичен. Регресс к
|
||||
`v_objective_lots_latest` → fail.
|
||||
"""
|
||||
sql = _sold_sql_text()
|
||||
assert "v_objective_lots_latest" not in sql, "не должен читать view (#1953 regression)"
|
||||
assert "JOIN objective_lots" in sql, "должен джойнить СЫРУЮ objective_lots inline"
|
||||
# physflat-ключ inline DISTINCT ON.
|
||||
assert "DISTINCT ON (" in sql
|
||||
for col in ("project_name", "corpus_name", "section", "floor", "lot_number"):
|
||||
assert col in sql, f"physflat-ключ должен включать {col}"
|
||||
assert "snapshot_date DESC" in sql and "id DESC" in sql
|
||||
|
||||
|
||||
def test_sold_count_sql_filters_premise_kind() -> None:
|
||||
"""premise_kind-фильтр (как в MarketMetrics._STOCK_SQL) — без него проданные
|
||||
паркинги/кладовые/нежилое считались бы проданными квартирами (over-count)."""
|
||||
|
|
|
|||
393
backend/tests/api/v1/test_parcel_connection_capacity.py
Normal file
393
backend/tests/api/v1/test_parcel_connection_capacity.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
"""Тесты для GET /{cad_num}/connection-capacity (Forgejo #2119 Phase A).
|
||||
|
||||
FastAPI TestClient + mock сервиса get_connection_capacity — без Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
_VALID_CAD = "66:41:0204016:10"
|
||||
|
||||
|
||||
def _power_point(
|
||||
name: str = "ПС 110/10 Уктус",
|
||||
reserve: float | None = 15.0,
|
||||
load_index: str | None = "open",
|
||||
distance_m: float = 850.0,
|
||||
reserve_unit: str | None = "МВА",
|
||||
district: str | None = None,
|
||||
reserve_note: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"dzo_name": "Россети Урал",
|
||||
"voltage_class": "110/10",
|
||||
"load_index": load_index,
|
||||
"installed_capacity_mva": 40.0,
|
||||
"current_load_mva": 25.0,
|
||||
"reserve_mva": reserve,
|
||||
"reserve_unit": reserve_unit,
|
||||
"district": district,
|
||||
"reserve_asof": "2026-06-30",
|
||||
"reserve_note": reserve_note,
|
||||
"distance_m": distance_m,
|
||||
"lat": 56.78,
|
||||
"lon": 60.61,
|
||||
}
|
||||
|
||||
|
||||
def _heat_block(
|
||||
systems: list[dict[str, Any]] | None = None,
|
||||
total: float | None = 3.26,
|
||||
) -> dict[str, Any]:
|
||||
if systems is None:
|
||||
systems = [
|
||||
{
|
||||
"org": "ЕТК",
|
||||
"system_name": "СТ №54",
|
||||
"reserve_gcal_h": 3.26,
|
||||
"period": "2026-Q2",
|
||||
}
|
||||
]
|
||||
return {"systems": systems, "total_reserve_gcal_h": total}
|
||||
|
||||
|
||||
def _gas_block(
|
||||
outlets_total: int = 1037,
|
||||
outlets_deficit: int = 218,
|
||||
outlets_needs_calc: int = 144,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"city_grs": [
|
||||
{
|
||||
"grs_name": "ГРС Свердловская",
|
||||
"design_capacity_th_m3_h": 210.0,
|
||||
"free_capacity_th_m3_h": 40.5,
|
||||
"free_capacity_pct": 20.2,
|
||||
"upgrade_due": "2028",
|
||||
"outputs_count": 3,
|
||||
}
|
||||
],
|
||||
"total_free_th_m3_h": 40.5,
|
||||
"outlets_total": outlets_total,
|
||||
"outlets_deficit": outlets_deficit,
|
||||
"outlets_needs_calc": outlets_needs_calc,
|
||||
}
|
||||
|
||||
|
||||
def _gas_outlet_point(
|
||||
outlet_name: str = "Котельная (Складской проезд,4а)",
|
||||
free: float | None = 0.512,
|
||||
needs_calc: bool = False,
|
||||
distance_m: float = 145.3,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"outlet_name": outlet_name,
|
||||
"consumer_type": "котельная",
|
||||
"free_capacity_mln_m3": free,
|
||||
"needs_calc": needs_calc,
|
||||
"distance_m": distance_m,
|
||||
"lat": 56.812345,
|
||||
"lon": 60.611111,
|
||||
}
|
||||
|
||||
|
||||
def _make_response(
|
||||
power_points: list[dict[str, Any]] | None = None,
|
||||
water: list[dict[str, Any]] | None = None,
|
||||
heat: dict[str, Any] | None = None,
|
||||
gas: dict[str, Any] | None = None,
|
||||
gas_outlet_points: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if power_points is None:
|
||||
power_points = [_power_point()]
|
||||
if water is None:
|
||||
water = [
|
||||
{
|
||||
"system_kind": "water",
|
||||
"system_name": "ЗФС, ГСВ",
|
||||
"reserve_thousand_m3_day": -168.5,
|
||||
"note": "дефицит",
|
||||
"period": "2026-Q1",
|
||||
}
|
||||
]
|
||||
by_load_index: dict[str, int] = {}
|
||||
for p in power_points:
|
||||
li = p["load_index"] or "unknown"
|
||||
by_load_index[li] = by_load_index.get(li, 0) + 1
|
||||
nearest = next((p for p in power_points if p["reserve_mva"] and p["reserve_mva"] > 0), None)
|
||||
return {
|
||||
"power_points": power_points,
|
||||
"power_summary": {
|
||||
"total_power_points": len(power_points),
|
||||
"by_load_index": by_load_index,
|
||||
"nearest_with_reserve": nearest,
|
||||
},
|
||||
"water": water,
|
||||
"gas": gas if gas is not None else _gas_block(),
|
||||
"gas_outlet_points": (
|
||||
gas_outlet_points if gas_outlet_points is not None else [_gas_outlet_point()]
|
||||
),
|
||||
"heat": heat if heat is not None else _heat_block(),
|
||||
}
|
||||
|
||||
|
||||
def test_connection_capacity_returns_power_and_water() -> None:
|
||||
full = _make_response()
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=full,
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert len(body["power_points"]) == 1
|
||||
assert body["power_points"][0]["voltage_class"] == "110/10"
|
||||
assert body["power_summary"]["total_power_points"] == 1
|
||||
assert body["power_summary"]["by_load_index"]["open"] == 1
|
||||
assert body["power_summary"]["nearest_with_reserve"]["reserve_mva"] == pytest.approx(15.0)
|
||||
# water: отрицательный резерв (дефицит) сохраняет знак.
|
||||
assert body["water"][0]["reserve_thousand_m3_day"] == pytest.approx(-168.5)
|
||||
assert body["water"][0]["system_name"] == "ЗФС, ГСВ"
|
||||
|
||||
|
||||
def test_connection_capacity_empty() -> None:
|
||||
empty = _make_response(power_points=[], water=[])
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=empty,
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["power_points"] == []
|
||||
assert body["power_summary"]["total_power_points"] == 0
|
||||
assert body["power_summary"]["nearest_with_reserve"] is None
|
||||
assert body["water"] == []
|
||||
|
||||
|
||||
def test_connection_capacity_no_reserve_nearest_null() -> None:
|
||||
"""Все ЦП без положительного резерва → nearest_with_reserve=null."""
|
||||
pts = [_power_point(reserve=None, load_index="closed")]
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(power_points=pts),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["power_summary"]["nearest_with_reserve"] is None
|
||||
assert body["power_summary"]["by_load_index"]["closed"] == 1
|
||||
|
||||
|
||||
def test_connection_capacity_parcel_not_found_404() -> None:
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
side_effect=ValueError("Участок '66:41:9999999:1' не найден в БД"),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/v1/parcels/66:41:9999999:1/connection-capacity")
|
||||
|
||||
assert r.status_code == 404
|
||||
assert "не найден" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_connection_capacity_passes_radius() -> None:
|
||||
with patch("app.api.v1.parcels.get_connection_capacity") as mock_svc:
|
||||
mock_svc.return_value = _make_response()
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=5000")
|
||||
assert r.status_code == 200
|
||||
call = mock_svc.call_args
|
||||
assert call[0][2] == 5000 or call[1].get("radius_m") == 5000
|
||||
|
||||
|
||||
def test_connection_capacity_radius_too_small_422() -> None:
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=50")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_connection_capacity_radius_too_large_422() -> None:
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=20000")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ── Phase B2: heat-блок + reserve_unit/district + reserve_note ────────────────
|
||||
|
||||
|
||||
def test_connection_capacity_heat_block_per_org() -> None:
|
||||
"""Heat-блок: строки за последний период per-org + суммарный резерв. #2119 B2."""
|
||||
heat = _heat_block(
|
||||
systems=[
|
||||
{"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2"},
|
||||
# Другая организация может публиковать свежий квартал раньше/позже —
|
||||
# per-org MAX(period) сервиса это учитывает (тут просто передаём готовое).
|
||||
{
|
||||
"org": "Екатеринбургэнерго",
|
||||
"system_name": "ТЭЦ Зона-1",
|
||||
"reserve_gcal_h": -5.0, # дефицит — знак сохраняется
|
||||
"period": "2026-Q1",
|
||||
},
|
||||
],
|
||||
total=-1.74,
|
||||
)
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(heat=heat),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert len(body["heat"]["systems"]) == 2
|
||||
assert body["heat"]["systems"][0]["org"] == "ЕТК"
|
||||
assert body["heat"]["systems"][0]["reserve_gcal_h"] == pytest.approx(3.26)
|
||||
# дефицит (отрицательный резерв) сохраняет знак.
|
||||
assert body["heat"]["systems"][1]["reserve_gcal_h"] == pytest.approx(-5.0)
|
||||
assert body["heat"]["systems"][1]["period"] == "2026-Q1"
|
||||
assert body["heat"]["total_reserve_gcal_h"] == pytest.approx(-1.74)
|
||||
|
||||
|
||||
def test_connection_capacity_heat_graceful_absent() -> None:
|
||||
"""Ключ 'heat' отсутствует (таблица не наполнена / миграция не применена) → null."""
|
||||
resp = _make_response()
|
||||
del resp["heat"]
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=resp,
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["heat"] is None
|
||||
|
||||
|
||||
def test_connection_capacity_heat_empty_systems() -> None:
|
||||
"""Таблица есть, но систем нет → пустой блок, total=null (graceful degrade)."""
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(heat=_heat_block(systems=[], total=None)),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["heat"]["systems"] == []
|
||||
assert body["heat"]["total_reserve_gcal_h"] is None
|
||||
|
||||
|
||||
def test_connection_capacity_power_reserve_unit_and_district() -> None:
|
||||
"""power_points пробрасывает reserve_unit ('МВт' у ЕЭСК) + district. #2119 B2."""
|
||||
pt = _power_point(
|
||||
name="ПС 110/10 Свердловск",
|
||||
reserve_unit="МВт",
|
||||
district="Октябрьский",
|
||||
)
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(power_points=[pt]),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
p0 = r.json()["power_points"][0]
|
||||
assert p0["reserve_unit"] == "МВт"
|
||||
assert p0["district"] == "Октябрьский"
|
||||
|
||||
|
||||
def test_connection_capacity_reserve_note_null_when_reserve_filled() -> None:
|
||||
"""ЕЭСК теперь с числами → reserve_note NULL когда reserve_mva заполнен. #2119 B2."""
|
||||
# Заполненный резерв → сервис отдаёт reserve_note=None (даже для ЕЭСК-строк).
|
||||
pt = _power_point(reserve=12.5, reserve_note=None)
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(power_points=[pt]),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["power_points"][0]["reserve_note"] is None
|
||||
|
||||
|
||||
# ── Phase B2 PR-4: газовые точки выхода (счётчики в gas + радиус-слой) ─────────
|
||||
|
||||
|
||||
def test_connection_capacity_gas_outlet_counters() -> None:
|
||||
"""gas-блок несёт счётчики точек выхода последнего среза (city-level). #2119 B2 PR-4."""
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
gas = r.json()["gas"]
|
||||
assert gas["outlets_total"] == 1037
|
||||
assert gas["outlets_deficit"] == 218
|
||||
assert gas["outlets_needs_calc"] == 144
|
||||
# ГРС-часть B1 не сломана.
|
||||
assert gas["total_free_th_m3_h"] == pytest.approx(40.5)
|
||||
|
||||
|
||||
def test_connection_capacity_gas_outlet_points_layer() -> None:
|
||||
"""gas_outlet_points — гео-слой точек выхода: дистанция/needs_calc/дефицит. #2119 B2 PR-4."""
|
||||
pts = [
|
||||
_gas_outlet_point(distance_m=145.3, free=0.512, needs_calc=False),
|
||||
# дефицит со знаком.
|
||||
_gas_outlet_point(
|
||||
outlet_name="Промплощадка №2", distance_m=980.4, free=-0.084, needs_calc=False
|
||||
),
|
||||
# «0.000*» → NULL мощность + needs_calc=True.
|
||||
_gas_outlet_point(outlet_name="ТЭЦ узел", distance_m=1500.0, free=None, needs_calc=True),
|
||||
]
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(gas_outlet_points=pts),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
outlets = r.json()["gas_outlet_points"]
|
||||
assert len(outlets) == 3
|
||||
assert outlets[0]["distance_m"] == pytest.approx(145.3)
|
||||
assert outlets[0]["free_capacity_mln_m3"] == pytest.approx(0.512)
|
||||
assert outlets[1]["free_capacity_mln_m3"] == pytest.approx(-0.084) # дефицит-знак
|
||||
assert outlets[2]["free_capacity_mln_m3"] is None
|
||||
assert outlets[2]["needs_calc"] is True
|
||||
|
||||
|
||||
def test_connection_capacity_gas_outlet_points_empty_default() -> None:
|
||||
"""gas_outlet_points пуст (нет geom в радиусе) → []; ключ может отсутствовать → []."""
|
||||
resp = _make_response(gas_outlet_points=[])
|
||||
del resp["gas_outlet_points"] # сервис может не собрать ключ (миграция 184 не применена)
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=resp,
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["gas_outlet_points"] == []
|
||||
244
backend/tests/api/v1/test_parcel_neighbor_buildings.py
Normal file
244
backend/tests/api/v1/test_parcel_neighbor_buildings.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""Тесты GET /{cad_num}/neighbor-buildings (Forgejo #2180).
|
||||
|
||||
Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены §7 «Размещение
|
||||
застройки»: GeoJSON FeatureCollection упрощённых полигонов cad_buildings вокруг
|
||||
участка.
|
||||
|
||||
Механика моков: get_db переопределяем через app.dependency_overrides (FastAPI
|
||||
Depends держит оригинальную ссылку — unittest.mock.patch неэффективен). Хендлер
|
||||
делает один основной `db.execute(...).mappings().all()` (соседи), и ТОЛЬКО при
|
||||
пустом результате — второй `db.execute(...).first()` (EXISTS-пробник участка,
|
||||
404 vs пустой 200). Мок покрывает обе формы. Очистку dependency_overrides делает
|
||||
autouse-фикстура в tests/conftest.py. RBAC-гейт в test-mode (settings.testing)
|
||||
байпасится — auth-заголовок не нужен.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.main import app
|
||||
|
||||
_VALID_CAD = "66:41:0204016:10"
|
||||
|
||||
# Валидный GeoJSON-полигон footprint'а — как отдаёт ST_AsGeoJSON (строка).
|
||||
_POLYGON_GEOJSON = json.dumps(
|
||||
{
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[60.640, 56.830],
|
||||
[60.641, 56.830],
|
||||
[60.641, 56.831],
|
||||
[60.640, 56.831],
|
||||
[60.640, 56.830],
|
||||
]
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_building_row(
|
||||
cad_num: str = "66:41:0204016:100",
|
||||
floors: int | None = 9,
|
||||
building_name: str | None = "Жилой дом",
|
||||
year_built: int | None = 1998,
|
||||
distance_m: float = 42.0,
|
||||
geojson: str | None = _POLYGON_GEOJSON,
|
||||
) -> dict[str, Any]:
|
||||
"""Одна строка результата _NEIGHBOR_BUILDINGS_SQL (mappings())."""
|
||||
return {
|
||||
"cad_num": cad_num,
|
||||
"floors": floors,
|
||||
"building_name": building_name,
|
||||
"year_built": year_built,
|
||||
"geojson": geojson,
|
||||
"distance_m": distance_m,
|
||||
}
|
||||
|
||||
|
||||
def _build_mock_db(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
parcel_exists: bool = True,
|
||||
) -> MagicMock:
|
||||
"""Mock Session.
|
||||
|
||||
Основной запрос соседей → .mappings().all() = rows.
|
||||
Fallback EXISTS-пробник (вызывается лишь при пустом rows) → .first() =
|
||||
(1,) если parcel_exists иначе None.
|
||||
"""
|
||||
mock_db = MagicMock()
|
||||
|
||||
mappings_mock = MagicMock()
|
||||
mappings_mock.all.return_value = rows
|
||||
|
||||
execute_result = MagicMock()
|
||||
execute_result.mappings.return_value = mappings_mock
|
||||
# .first() дёргается только на EXISTS-пробнике (тот же execute_result — ок,
|
||||
# т.к. пробник запускается только когда rows пуст).
|
||||
execute_result.first.return_value = (1,) if parcel_exists else None
|
||||
|
||||
mock_db.execute.return_value = execute_result
|
||||
return mock_db
|
||||
|
||||
|
||||
def _override_db(db: MagicMock):
|
||||
def _get_db_override():
|
||||
yield db
|
||||
|
||||
return _get_db_override
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── happy path: фичи + floors → height_m ──────────────────────────────────
|
||||
|
||||
|
||||
def test_neighbor_buildings_happy_path(client: TestClient) -> None:
|
||||
rows = [
|
||||
_make_building_row("66:41:0204016:100", floors=9, distance_m=30.0),
|
||||
_make_building_row("66:41:0204016:101", floors=16, distance_m=85.0),
|
||||
_make_building_row(
|
||||
"66:41:0204016:102",
|
||||
floors=None, # floors неизвестны → height_m null
|
||||
building_name="", # пустое имя → null
|
||||
year_built=None,
|
||||
distance_m=120.0,
|
||||
),
|
||||
]
|
||||
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
|
||||
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["type"] == "FeatureCollection"
|
||||
assert body["count"] == 3
|
||||
assert body["radius_m"] == 300
|
||||
assert body["truncated"] is False
|
||||
assert len(body["features"]) == 3
|
||||
|
||||
f0 = body["features"][0]
|
||||
assert f0["type"] == "Feature"
|
||||
assert f0["geometry"]["type"] == "Polygon" # распарсен из строки, не escaped
|
||||
props0 = f0["properties"]
|
||||
assert props0["cad_num"] == "66:41:0204016:100"
|
||||
assert props0["floors"] == 9
|
||||
assert props0["height_m"] == pytest.approx(27.0) # 9 × 3.0
|
||||
assert props0["building_name"] == "Жилой дом"
|
||||
assert props0["year_built"] == 1998
|
||||
assert props0["is_neighbor"] is True
|
||||
|
||||
# floors=16 → height_m 48.0
|
||||
assert body["features"][1]["properties"]["height_m"] == pytest.approx(48.0)
|
||||
|
||||
# floors=None → height_m null, building_name пустой → null, year_built null
|
||||
props2 = body["features"][2]["properties"]
|
||||
assert props2["floors"] is None
|
||||
assert props2["height_m"] is None
|
||||
assert props2["building_name"] is None
|
||||
assert props2["year_built"] is None
|
||||
assert props2["is_neighbor"] is True
|
||||
|
||||
|
||||
# ── пустой результат (участок есть, соседей нет) → 200 empty ───────────────
|
||||
|
||||
|
||||
def test_neighbor_buildings_empty_ok(client: TestClient) -> None:
|
||||
"""Участок в БД есть (EXISTS=true), но соседей в радиусе нет → 200 пустой."""
|
||||
app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=True))
|
||||
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["type"] == "FeatureCollection"
|
||||
assert body["features"] == []
|
||||
assert body["count"] == 0
|
||||
assert body["truncated"] is False
|
||||
|
||||
|
||||
# ── участок не найден (нет geom) → 404 ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_neighbor_buildings_parcel_not_found_404(client: TestClient) -> None:
|
||||
"""Пустой результат + EXISTS-пробник вернул None (нет geom) → 404."""
|
||||
app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=False))
|
||||
|
||||
r = client.get("/api/v1/parcels/66:41:9999999:1/neighbor-buildings")
|
||||
|
||||
assert r.status_code == 404
|
||||
assert "не найдена" in r.json()["detail"]
|
||||
|
||||
|
||||
# ── truncated: >300 соседей → флаг + обрезка до 300 ────────────────────────
|
||||
|
||||
|
||||
def test_neighbor_buildings_truncated(client: TestClient) -> None:
|
||||
"""SQL LIMIT 301 → если пришёл 301, отдаём 300 + truncated=true."""
|
||||
rows = [
|
||||
_make_building_row(f"66:41:0204016:{i}", floors=(i % 20) + 1, distance_m=float(i))
|
||||
for i in range(301)
|
||||
]
|
||||
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
|
||||
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["count"] == 300
|
||||
assert len(body["features"]) == 300
|
||||
assert body["truncated"] is True
|
||||
|
||||
|
||||
# ── radius валидация → 422 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_neighbor_buildings_radius_too_small_422(client: TestClient) -> None:
|
||||
"""radius_m=10 (< min 50) → 422."""
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=10")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_neighbor_buildings_radius_too_large_422(client: TestClient) -> None:
|
||||
"""radius_m=2000 (> max 1000) → 422."""
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=2000")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_neighbor_buildings_radius_passthrough(client: TestClient) -> None:
|
||||
"""radius_m из query отражается в ответе (и внутри — в bind SQL)."""
|
||||
app.dependency_overrides[get_db] = _override_db(_build_mock_db([_make_building_row()]))
|
||||
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=500")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["radius_m"] == 500
|
||||
|
||||
|
||||
# ── битая геометрия (ST_AsGeoJSON NULL) пропускается, не роняет сцену ───────
|
||||
|
||||
|
||||
def test_neighbor_buildings_skips_null_geometry(client: TestClient) -> None:
|
||||
rows = [
|
||||
_make_building_row("66:41:0204016:100", geojson=None), # битый polygon
|
||||
_make_building_row("66:41:0204016:101", geojson=_POLYGON_GEOJSON),
|
||||
]
|
||||
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
|
||||
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["count"] == 1
|
||||
assert body["features"][0]["properties"]["cad_num"] == "66:41:0204016:101"
|
||||
275
backend/tests/api/v1/test_parcel_report.py
Normal file
275
backend/tests/api/v1/test_parcel_report.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"""Тесты 3 эндпоинтов полного PDF-отчёта в parcels API (эпик #2259 PR-D).
|
||||
|
||||
Покрывает:
|
||||
• POST /{cad}/report: нет analyze-рана → 404; enqueue → 202 {status:"building"};
|
||||
готовый кэш (файл на диске) → 200 {status:"ready"} без повторного enqueue;
|
||||
провал брокера → 503.
|
||||
• GET /{cad}/report/status: none (нет analyze) / building (analyze есть, файла нет) /
|
||||
ready (метадата-ран + файл на диске).
|
||||
• GET /{cad}/report/download: файл на диске → 200 application/pdf + attachment;
|
||||
отчёт не готов → 404.
|
||||
|
||||
Стратегия mock (зеркало test_parcels_forecast.py): DB через dependency_overrides,
|
||||
`latest_run_for` патчим, Celery `.delay` — на реальной таске (lazy import в хендлере
|
||||
резолвит атрибут в момент вызова). RBAC обходится settings.testing=True (conftest).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.services.exporters.full_report_pdf import REPORT_SCHEMA_VERSION
|
||||
|
||||
_CAD = "66:41:0204016:10"
|
||||
_ANALYZE_SV = "analyze-1.0"
|
||||
_FORECAST_SV = "1.0"
|
||||
|
||||
|
||||
def _override_db(db: MagicMock):
|
||||
def _get_db_override():
|
||||
yield db
|
||||
|
||||
return _get_db_override
|
||||
|
||||
|
||||
def _run(run_id: int, result: dict[str, Any] | None = None) -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.id = run_id
|
||||
r.result = result or {}
|
||||
r.district = "Кировский"
|
||||
r.created_at = dt.datetime(2026, 7, 3, 10, 0, 0)
|
||||
return r
|
||||
|
||||
|
||||
def _lrf_router(rows: dict[str, MagicMock | None]):
|
||||
"""Фабрика side_effect для latest_run_for: row по kwargs['schema_version']."""
|
||||
|
||||
def _side(db: Any, cad: str, *, schema_version: str | None = None) -> MagicMock | None:
|
||||
return rows.get(schema_version)
|
||||
|
||||
return _side
|
||||
|
||||
|
||||
# ── POST /{cad}/report ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_post_report_no_analyze_returns_404() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.parcels.latest_run_for", side_effect=_lrf_router({_ANALYZE_SV: None})
|
||||
):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "анализа" in resp.json()["detail"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_post_report_enqueues_and_returns_202() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with (
|
||||
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
|
||||
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
|
||||
):
|
||||
# _cached_report_result: report-row None → кэша нет, delay вызывается.
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||||
assert resp.status_code == 202, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "building"
|
||||
assert body["analyze_run_at"] is not None
|
||||
delay_mock.assert_called_once_with(_CAD)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_post_report_cache_hit_returns_200_ready(tmp_path: Path) -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
pdf = tmp_path / "report.pdf"
|
||||
pdf.write_bytes(b"%PDF-cached")
|
||||
report_row = _run(
|
||||
500,
|
||||
{
|
||||
"pdf_path": str(pdf),
|
||||
"analyze_run_id": 101,
|
||||
"forecast_run_id": 202,
|
||||
"generated_at": "2026-07-03T09:00:00+00:00",
|
||||
"size_bytes": 10,
|
||||
},
|
||||
)
|
||||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: report_row}
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with (
|
||||
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
|
||||
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
|
||||
):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "ready"
|
||||
delay_mock.assert_not_called() # кэш есть → не enqueue'им
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_post_report_broker_down_returns_503() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
|
||||
delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
|
||||
try:
|
||||
with (
|
||||
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
|
||||
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
|
||||
):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||||
assert resp.status_code == 503, resp.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── GET /{cad}/report/status ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_status_none_when_no_analyze() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
rows = {_ANALYZE_SV: None, _FORECAST_SV: None}
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "none"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_status_building_when_analyze_but_no_file() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "building"
|
||||
assert body["analyze_run_at"] is not None
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_status_ready_when_file_on_disk(tmp_path: Path) -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
pdf = tmp_path / "report.pdf"
|
||||
pdf.write_bytes(b"%PDF")
|
||||
report_row = _run(
|
||||
500,
|
||||
{
|
||||
"pdf_path": str(pdf),
|
||||
"analyze_run_id": 101,
|
||||
"forecast_run_id": 202,
|
||||
"generated_at": "2026-07-03T09:00:00+00:00",
|
||||
"size_bytes": 4,
|
||||
},
|
||||
)
|
||||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: report_row}
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "ready"
|
||||
assert body["report_generated_at"] == "2026-07-03T09:00:00+00:00"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── GET /{cad}/report/download ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_download_returns_pdf_when_ready(tmp_path: Path) -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
pdf = tmp_path / "report.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.7 payload")
|
||||
# generated_at — ВЧЕРАШНЯЯ дата (cache-hit со вчера): имя файла должно взять ЕЁ,
|
||||
# НЕ today (иначе имя врёт про дату сборки — ревью PR-D п.3).
|
||||
report_row = _run(
|
||||
500,
|
||||
{
|
||||
"pdf_path": str(pdf),
|
||||
"analyze_run_id": 101,
|
||||
"forecast_run_id": 202,
|
||||
"generated_at": "2026-07-01T09:00:00+00:00",
|
||||
"size_bytes": 16,
|
||||
},
|
||||
)
|
||||
rows = {REPORT_SCHEMA_VERSION: report_row}
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
cd = resp.headers["content-disposition"]
|
||||
assert "attachment;" in cd
|
||||
assert cd.endswith('.pdf"')
|
||||
assert f"gendesign_report_{_CAD.replace(':', '_')}_" in cd
|
||||
# Дата в имени — из метадаты (2026-07-01), НЕ today.
|
||||
assert "2026-07-01" in cd
|
||||
assert resp.content == b"%PDF-1.7 payload"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_download_404_when_not_ready() -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
rows = {REPORT_SCHEMA_VERSION: None}
|
||||
try:
|
||||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download")
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "готов" in resp.json()["detail"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
|
@ -77,7 +77,13 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
|
|||
)
|
||||
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
|
||||
district_row = _make_mapping(
|
||||
{"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0}
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||
|
||||
|
|
@ -88,7 +94,7 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
|
|
@ -157,9 +163,7 @@ def test_horizon_default_12_and_enqueued() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
@ -186,9 +190,7 @@ def test_horizon_valid_values_accepted() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
|
||||
assert resp.status_code == 200, f"horizon={h}: {resp.text}"
|
||||
|
|
@ -213,9 +215,7 @@ def test_horizon_invalid_returns_422() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
|
||||
assert resp.status_code == 422, f"horizon={h}: {resp.text}"
|
||||
|
|
@ -234,9 +234,7 @@ def test_enqueue_passes_created_by_header() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
f"/api/v1/parcels/{_CAD}/analyze",
|
||||
|
|
@ -261,9 +259,7 @@ def test_enqueue_failure_returns_200_unavailable() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
@ -314,9 +310,7 @@ def test_get_forecast_ready_when_run_present() -> None:
|
|||
try:
|
||||
# 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
|
||||
# здесь None (gate не применяется).
|
||||
with patch(
|
||||
"app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]
|
||||
) as lrf:
|
||||
with patch("app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]) as lrf:
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
@ -354,9 +348,7 @@ def test_get_forecast_db_error_returns_pending_not_500() -> None:
|
|||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")
|
||||
):
|
||||
with patch("app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
|
||||
assert resp.status_code == 202, resp.text
|
||||
|
|
|
|||
69
backend/tests/api/v1/test_poi_detail_from_tags.py
Normal file
69
backend/tests/api/v1/test_poi_detail_from_tags.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Тесты `_poi_detail_from_tags` / `_clean_tag` — обогащение POI-точек §1 карты
|
||||
«максимумом информации» из OSM tags jsonb (адрес, оператор, часы, сайт, телефон).
|
||||
|
||||
Покрывает:
|
||||
(a) полный набор тегов → все поля заполнены, адрес склеен «улица, дом»;
|
||||
(b) приоритет contact:* над «голым» website/phone, brand как fallback оператора;
|
||||
(c) фиксированный набор ключей (всегда 5) с None при отсутствии тега;
|
||||
(d) мусорные значения («yes», пустые строки, не-строки) отбрасываются.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.api.v1.parcels import _clean_tag, _poi_detail_from_tags
|
||||
|
||||
_KEYS = {"address", "operator", "opening_hours", "website", "phone"}
|
||||
|
||||
|
||||
def test_full_tags_all_fields_populated() -> None:
|
||||
out = _poi_detail_from_tags(
|
||||
{
|
||||
"addr:street": "улица Маяковского",
|
||||
"addr:housenumber": "12",
|
||||
"operator": 'ООО "Аптека+"',
|
||||
"opening_hours": "Mo-Su 08:00-22:00",
|
||||
"contact:website": "https://apteka.example",
|
||||
"contact:phone": "+7 343 000-00-00",
|
||||
}
|
||||
)
|
||||
assert set(out) == _KEYS
|
||||
assert out["address"] == "улица Маяковского, 12"
|
||||
assert out["operator"] == 'ООО "Аптека+"'
|
||||
assert out["opening_hours"] == "Mo-Su 08:00-22:00"
|
||||
assert out["website"] == "https://apteka.example"
|
||||
assert out["phone"] == "+7 343 000-00-00"
|
||||
|
||||
|
||||
def test_contact_priority_and_brand_fallback() -> None:
|
||||
out = _poi_detail_from_tags(
|
||||
{
|
||||
"brand": "Пятёрочка", # operator отсутствует → берём brand
|
||||
"website": "http://bare.example",
|
||||
"contact:website": "https://contact.example", # приоритетнее
|
||||
"phone": "+7 000",
|
||||
"contact:phone": "+7 111", # приоритетнее
|
||||
}
|
||||
)
|
||||
assert out["operator"] == "Пятёрочка"
|
||||
assert out["website"] == "https://contact.example"
|
||||
assert out["phone"] == "+7 111"
|
||||
assert out["address"] is None
|
||||
|
||||
|
||||
def test_fixed_keyset_with_none_when_absent() -> None:
|
||||
for tags in (None, {}, {"amenity": "pharmacy"}):
|
||||
out = _poi_detail_from_tags(tags)
|
||||
assert set(out) == _KEYS
|
||||
assert all(v is None for v in out.values())
|
||||
|
||||
|
||||
def test_house_only_address() -> None:
|
||||
assert _poi_detail_from_tags({"addr:housenumber": "5А"})["address"] == "5А"
|
||||
|
||||
|
||||
def test_clean_tag_drops_junk() -> None:
|
||||
assert _clean_tag("yes") is None
|
||||
assert _clean_tag(" ") is None
|
||||
assert _clean_tag(None) is None
|
||||
assert _clean_tag(123) is None
|
||||
assert _clean_tag(" Реальное значение ") == "Реальное значение"
|
||||
|
|
@ -54,7 +54,13 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
|
|||
)
|
||||
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
|
||||
district_row = _make_mapping(
|
||||
{"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0}
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||
|
||||
|
|
@ -65,7 +71,7 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
|
|
|
|||
430
backend/tests/services/exporters/test_full_report_html_part_a.py
Normal file
430
backend/tests/services/exporters/test_full_report_html_part_a.py
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
"""Unit-тесты PURE HTML-агрегатора §1–§3 + альтернативы (эпик #2259 PR-A).
|
||||
|
||||
Чистые тесты БЕЗ native libs (WeasyPrint не импортируется) и БЕЗ БД/сети: агрегатор
|
||||
только ПОТРЕБЛЯЕТ уже-персистнутый `analysis_runs.result` (loose dict `analyze-1.0`).
|
||||
Фикстур — СИНТЕТИЧЕСКИЙ (структура зеркалит прод-payload, но без реальных строк).
|
||||
|
||||
Покрываем:
|
||||
• реалистичный полный payload → все секции §1–§3 + якоря + факты;
|
||||
• отсутствующие секции / None-поля → «нет данных», без падения (graceful);
|
||||
• пустой / не-dict payload → валидный HTML с «нет данных»;
|
||||
• HTML-escape динамических строк payload (XSS-вектор через внешние источники);
|
||||
• плейсхолдер карты `{{MAP_PARCEL}}` присутствует;
|
||||
• блок альтернатив рисуется только при наличии `program_alternatives`;
|
||||
• каркас `build_full_report_html`: титул / оглавление / опциональный Part B.
|
||||
|
||||
DATABASE_URL выставляем до импорта app-модулей (зеркало test_report_md.py) — на случай
|
||||
side-effect'ов импорта пакета app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.exporters.full_report_html import (
|
||||
_ANCHOR_ALT,
|
||||
_ANCHOR_S1,
|
||||
_ANCHOR_S2,
|
||||
_ANCHOR_S3,
|
||||
_NO_DATA,
|
||||
MAP_PARCEL_PLACEHOLDER,
|
||||
build_full_report_html,
|
||||
build_full_report_html_part_a,
|
||||
)
|
||||
|
||||
# ── Синтетический фикстур (структура прод-payload, без реальных строк) ──────────
|
||||
|
||||
|
||||
def _synthetic_result() -> dict[str, Any]:
|
||||
"""Реалистичный полный analyze-payload: §1 участок + §2 окружение + §3 сети."""
|
||||
return {
|
||||
"cad_num": "00:00:0000000:0000",
|
||||
# §1 — кадастр / регламент / ЗОУИТ / gate
|
||||
"egrn": {
|
||||
"address": "Тестовый регион, тестовый город, улица Примерная, 1",
|
||||
"area_m2": 12345.0,
|
||||
"land_category": "Категория земель тестовая",
|
||||
"permitted_use_text": "тестовое разрешённое использование",
|
||||
"parcel_status": "Учтенный",
|
||||
"ownership_type": "Частная",
|
||||
"right_type": "Собственность",
|
||||
"cadastral_value_rub": 9876543.0,
|
||||
},
|
||||
"parcel_meta": {
|
||||
"land_subtype": "Землепользование",
|
||||
"land_category": "Категория земель тестовая",
|
||||
"permitted_use": "тестовое разрешённое использование",
|
||||
"cad_cost": 9876543.0,
|
||||
},
|
||||
"geometry_suitability": {"area_m2": 12345, "label": "подходящий"},
|
||||
"nspd_zoning": {
|
||||
"zone_code": "ТЗ-1",
|
||||
"zone_name": None,
|
||||
"max_far": 2.0,
|
||||
"max_floors": 12,
|
||||
"max_height_m": 40,
|
||||
"max_building_pct": 60,
|
||||
"min_parcel_area_m2": None,
|
||||
"regulation_source": "test-geoportal",
|
||||
"regulation_zone_index": "ТЗ-1",
|
||||
"main_vri": ["2.6 Тестовая многоэтажная застройка", "3.1 Тестовое обслуживание"],
|
||||
},
|
||||
"encumbrance": {
|
||||
"has_zouit": True,
|
||||
"zouit_count": 2,
|
||||
"zouit_types": ["Тестовая охранная зона", "Тестовая санитарная зона"],
|
||||
},
|
||||
"nspd_zouit_overlaps": [
|
||||
{
|
||||
"type_zone": "Тестовая охранная зона сетей",
|
||||
"name": "Тестовая охранная зона сетей",
|
||||
"reg_numb_border": "00:00-0.0000",
|
||||
"coverage_pct": 0.5,
|
||||
}
|
||||
],
|
||||
"gate_verdict": {
|
||||
"source": "nspd_dump",
|
||||
"can_build_mkd": False,
|
||||
"verdict_label": "Нельзя",
|
||||
"blockers": [{"code": "TEST_BLOCK", "detail": "Тестовый блокер застройки"}],
|
||||
"warnings": [{"code": "TEST_WARN", "detail": "Тестовое предупреждение"}],
|
||||
},
|
||||
# §2 — окружение
|
||||
"location": {"distance_to_center_km": 7.5},
|
||||
"metro": {"nearest_top3": [{"name": "Тестовая станция", "distance_m": 4200}]},
|
||||
"noise": {"level": "тихо", "estimated_db": 43.2},
|
||||
"air_quality": {"no2": 1.6, "pm10": 4.9, "pm2_5": 4.1, "source": "test-source"},
|
||||
"geotech_risk": {
|
||||
"seismic_label": "минимальная сейсмика",
|
||||
"seismic_intensity_balls": 5,
|
||||
"permafrost": False,
|
||||
"industrial_within_500m": 3,
|
||||
},
|
||||
"hydrology": {
|
||||
"flood_risk_flag": False,
|
||||
"nearest": [{"name": "Тестовая река", "subtype": "river", "distance_m": 500}],
|
||||
},
|
||||
"neighbors_summary": {
|
||||
"neighbors": [
|
||||
{
|
||||
"building_name": "Тестовое здание",
|
||||
"cad_num": "00:00:0000000:0001",
|
||||
"purpose": "Нежилое",
|
||||
"floors_parsed": 2,
|
||||
"distance_m": 0,
|
||||
}
|
||||
]
|
||||
},
|
||||
# §3 — сети
|
||||
"utilities": {
|
||||
"nearest_substation_m": 120,
|
||||
"nearest_water_main_m": None,
|
||||
"nearest_gas_m": None,
|
||||
"nearest_heat_m": None,
|
||||
"power_line_охранная_зона_flag": False,
|
||||
"summary": [
|
||||
{"subtype": "substation", "nearest_m": 120, "count_within_2km": 2},
|
||||
{"subtype": "pipeline", "nearest_m": 175, "count_within_2km": 4},
|
||||
],
|
||||
},
|
||||
"nspd_engineering_nearby": [
|
||||
{
|
||||
"name": None,
|
||||
"type": None,
|
||||
"distance_m": 50,
|
||||
"raw_props": {
|
||||
"params_name": "Тестовый трубопровод",
|
||||
"params_purpose": "тестовое коммунальное сооружение",
|
||||
"cad_number": "00:00:0000000:0002",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _synthetic_alternatives() -> dict[str, Any]:
|
||||
"""Блок `program_alternatives` (отрицательный фин-вердикт → топ вариантов)."""
|
||||
return {
|
||||
"variants": [
|
||||
{
|
||||
"housing_class": "comfort",
|
||||
"floors": 12,
|
||||
"development_type": "high_rise",
|
||||
"npv_rub": -216_000_000.0,
|
||||
"irr": -0.05,
|
||||
"roi": -0.12,
|
||||
"residential_sqm": 20000.0,
|
||||
"apartments_count": 350,
|
||||
"price_per_sqm_used": 142500.0,
|
||||
"price_source": "objective_district_median",
|
||||
"schedule_is_default": True,
|
||||
},
|
||||
{
|
||||
"housing_class": "econom",
|
||||
"floors": 9,
|
||||
"development_type": "mid_rise",
|
||||
"npv_rub": -300_000_000.0,
|
||||
"irr": -0.09,
|
||||
"roi": -0.2,
|
||||
"residential_sqm": 15000.0,
|
||||
"apartments_count": 280,
|
||||
"price_per_sqm_used": 110000.0,
|
||||
"price_source": "class_norm",
|
||||
"schedule_is_default": False,
|
||||
},
|
||||
],
|
||||
"any_viable": False,
|
||||
"grid_size": 9,
|
||||
"caveat": "Оценка по максимальной застройке зоны, не инвест-рекомендация.",
|
||||
}
|
||||
|
||||
|
||||
# ── 1. Реалистичный полный payload → все секции + якоря ─────────────────────────
|
||||
|
||||
|
||||
def test_full_payload_renders_all_sections_and_anchors() -> None:
|
||||
html = build_full_report_html_part_a(_synthetic_result(), cad="00:00:0000000:0000")
|
||||
for anchor in (_ANCHOR_S1, _ANCHOR_S2, _ANCHOR_S3):
|
||||
assert f'id="{anchor}"' in html
|
||||
# Ключевые факты §1
|
||||
assert "улица Примерная, 1" in html
|
||||
assert "ТЗ-1" in html # зона ПЗЗ
|
||||
assert "12 345" in html # площадь с пробелом-разрядом
|
||||
# §2 окружение
|
||||
assert "Тестовая станция" in html
|
||||
assert "минимальная сейсмика" in html
|
||||
# §3 сети
|
||||
assert "substation" in html
|
||||
assert "Тестовый трубопровод" in html
|
||||
|
||||
|
||||
# ── 2. Плейсхолдер карты присутствует ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_map_placeholder_present() -> None:
|
||||
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
|
||||
assert MAP_PARCEL_PLACEHOLDER in html
|
||||
assert "{{MAP_PARCEL}}" in html
|
||||
|
||||
|
||||
# ── 3. gate-вердикт: «Нельзя» + блокеры/предупреждения ──────────────────────────
|
||||
|
||||
|
||||
def test_gate_verdict_renders_blockers_and_warnings() -> None:
|
||||
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
|
||||
assert "Нельзя" in html
|
||||
assert "Тестовый блокер застройки" in html
|
||||
assert "Тестовое предупреждение" in html
|
||||
|
||||
|
||||
# ── 4. Отсутствующие секции → «нет данных», без падения ─────────────────────────
|
||||
|
||||
|
||||
def test_missing_sections_render_no_data() -> None:
|
||||
html = build_full_report_html_part_a({}, cad="X")
|
||||
# Секции и якоря всё равно на месте (каркас граничный).
|
||||
for anchor in (_ANCHOR_S1, _ANCHOR_S2, _ANCHOR_S3):
|
||||
assert f'id="{anchor}"' in html
|
||||
assert _NO_DATA in html
|
||||
# Плейсхолдер карты рисуется даже на пустом payload.
|
||||
assert MAP_PARCEL_PLACEHOLDER in html
|
||||
|
||||
|
||||
# ── 5. Не-dict / None payload → валидный HTML с «нет данных» ─────────────────────
|
||||
|
||||
|
||||
def test_non_dict_payload_is_graceful() -> None:
|
||||
for junk in (None, [], "строка", 42):
|
||||
html = build_full_report_html_part_a(junk, cad="X") # type: ignore[arg-type]
|
||||
assert f'id="{_ANCHOR_S1}"' in html
|
||||
assert _NO_DATA in html
|
||||
|
||||
|
||||
# ── 6. None-поля внутри существующих секций не ломают рендер ─────────────────────
|
||||
|
||||
|
||||
def test_none_fields_within_sections() -> None:
|
||||
result: dict[str, Any] = {
|
||||
"egrn": {"address": None, "area_m2": None, "land_category": None},
|
||||
"nspd_zoning": {"zone_code": None, "max_far": None, "main_vri": None},
|
||||
"utilities": {"summary": None, "nearest_substation_m": None},
|
||||
"metro": {"nearest_top3": None},
|
||||
"hydrology": {"nearest": None},
|
||||
"neighbors_summary": {"neighbors": None},
|
||||
}
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
# Не должно быть исключений; секции присутствуют, «нет данных» где пусто.
|
||||
assert f'id="{_ANCHOR_S2}"' in html
|
||||
assert _NO_DATA in html
|
||||
|
||||
|
||||
# ── 7. HTML-escape динамических строк payload (XSS-вектор) ───────────────────────
|
||||
|
||||
|
||||
def test_dynamic_strings_are_escaped() -> None:
|
||||
result: dict[str, Any] = {
|
||||
"egrn": {"address": "<script>alert('xss')</script>"},
|
||||
"nspd_zoning": {
|
||||
"zone_code": "<b>zone</b>",
|
||||
"main_vri": ["<img src=x onerror=alert(1)>"],
|
||||
},
|
||||
"gate_verdict": {
|
||||
"can_build_mkd": True,
|
||||
"verdict_label": "<i>ok</i>",
|
||||
"blockers": [{"code": "<code>", "detail": "<detail>"}],
|
||||
},
|
||||
}
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
# Сырые теги payload не должны попасть в разметку неэкранированными.
|
||||
assert "<script>alert" not in html
|
||||
assert "<script>alert" in html
|
||||
assert "<img src=x onerror" not in html
|
||||
assert "<img src=x onerror" in html
|
||||
assert "<b>zone</b>" in html
|
||||
|
||||
|
||||
# ── 8. Блок альтернатив: рисуется только при наличии program_alternatives ────────
|
||||
|
||||
|
||||
def test_alternatives_block_present_when_data() -> None:
|
||||
result = _synthetic_result()
|
||||
result["program_alternatives"] = _synthetic_alternatives()
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert f'id="{_ANCHOR_ALT}"' in html
|
||||
assert "Комфорт" in html # RU-метка housing_class
|
||||
assert "высотная" in html # RU-метка development_type
|
||||
assert "216 млн ₽" in html # NPV округлён до млн
|
||||
assert "наименее убыточные" in html # any_viable=False → эта формулировка
|
||||
assert "не инвест-рекомендация" in html # caveat
|
||||
|
||||
|
||||
def test_alternatives_block_absent_when_missing() -> None:
|
||||
# Нет ключа вовсе.
|
||||
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
|
||||
assert f'id="{_ANCHOR_ALT}"' not in html
|
||||
# None / пустые variants → тоже пропуск.
|
||||
for empty in (None, {}, {"variants": []}):
|
||||
result = _synthetic_result()
|
||||
result["program_alternatives"] = empty
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert f'id="{_ANCHOR_ALT}"' not in html
|
||||
|
||||
|
||||
def test_alternatives_any_viable_true_subtitle() -> None:
|
||||
alt = _synthetic_alternatives()
|
||||
alt["any_viable"] = True
|
||||
alt["variants"][0]["npv_rub"] = 42_000_000.0 # положительный → «+42 млн ₽»
|
||||
result = _synthetic_result()
|
||||
result["program_alternatives"] = alt
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert "сходится при другой программе" in html
|
||||
assert "+42 млн ₽" in html
|
||||
|
||||
|
||||
# ── 8b. connection-capacity в §3 (эпик #2259 PR-D — ресурсные резервы) ──────────
|
||||
|
||||
|
||||
def _synthetic_connection_capacity() -> dict[str, Any]:
|
||||
"""Синтетический connection-capacity (структура ConnectionCapacityResponse)."""
|
||||
return {
|
||||
"power_summary": {
|
||||
"total_power_points": 3,
|
||||
"by_load_index": {"open": 2, "closed": 1},
|
||||
"nearest_with_reserve": {
|
||||
"reserve_mva": 12.5,
|
||||
"reserve_unit": "МВА",
|
||||
"voltage_class": "110/10",
|
||||
"distance_m": 640.0,
|
||||
},
|
||||
},
|
||||
"power_points": [{"sc_name": "ПС Тестовая"}],
|
||||
"water": [{"system_name": "ЗФС, ГСВ", "reserve_thousand_m3_day": 5.4, "period": "2026-Q2"}],
|
||||
"gas": {
|
||||
"city_grs": [
|
||||
{"grs_name": "ГРС-1", "free_capacity_th_m3_h": 18.0, "free_capacity_pct": 22.3}
|
||||
]
|
||||
},
|
||||
"heat": {
|
||||
"systems": [
|
||||
{"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.1, "period": "2026-Q2"}
|
||||
]
|
||||
},
|
||||
"nearby_network_zones": [
|
||||
{
|
||||
"net_kind": "thermal",
|
||||
"human_label": "Тепловая сеть (по охранной зоне)",
|
||||
"distance_m": 24,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_connection_capacity_block_rendered_in_section_3() -> None:
|
||||
html = build_full_report_html_part_a(
|
||||
_synthetic_result(), cad="X", connection_capacity=_synthetic_connection_capacity()
|
||||
)
|
||||
assert "Электроснабжение — свободная мощность" in html
|
||||
assert "Водоснабжение/водоотведение — резервы" in html
|
||||
assert "Газоснабжение — свободная мощность ГРС" in html
|
||||
assert "Теплоснабжение — резервы систем" in html
|
||||
assert "Сети рядом (охранные зоны)" in html
|
||||
# Значения/подписи присутствуют.
|
||||
assert "110/10" in html # класс напряжения ближайшего ЦП
|
||||
assert "22.3%" in html # free_capacity_pct — уже проценты, НЕ домножены на 100
|
||||
assert "Тепловая сеть (по охранной зоне)" in html
|
||||
|
||||
|
||||
def test_connection_capacity_absent_when_none() -> None:
|
||||
"""connection_capacity=None (default) → блок резервов не рисуется (backward-compat)."""
|
||||
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
|
||||
assert "Электроснабжение — свободная мощность" not in html
|
||||
# Пустой dict → тоже пропуск.
|
||||
html2 = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity={})
|
||||
assert "Газоснабжение — свободная мощность ГРС" not in html2
|
||||
|
||||
|
||||
# ── 9. Каркас документа: титул / оглавление / опциональный Part B ────────────────
|
||||
|
||||
|
||||
def test_document_frame_with_part_a_only() -> None:
|
||||
part_a = build_full_report_html_part_a(_synthetic_result(), cad="00:00:0000000:0000")
|
||||
doc = build_full_report_html(
|
||||
part_a,
|
||||
None,
|
||||
cad="00:00:0000000:0000",
|
||||
address="улица Примерная, 1",
|
||||
generated_at="03.07.2026",
|
||||
)
|
||||
assert doc.startswith("<!DOCTYPE html>")
|
||||
assert "00:00:0000000:0000" in doc
|
||||
assert "улица Примерная, 1" in doc
|
||||
assert "03.07.2026" in doc
|
||||
# Оглавление §1–§3 есть, пункт Part B (§4–§7) скрыт.
|
||||
assert f'href="#{_ANCHOR_S1}"' in doc
|
||||
assert "section-4" not in doc
|
||||
# Part A встроен.
|
||||
assert f'id="{_ANCHOR_S1}"' in doc
|
||||
|
||||
|
||||
def test_document_frame_with_part_b_adds_toc_entry() -> None:
|
||||
part_a = build_full_report_html_part_a(_synthetic_result(), cad="X")
|
||||
part_b = '<div class="section" id="section-4"><h2>§4 test</h2></div>'
|
||||
doc = build_full_report_html(part_a, part_b, cad="X", address=None, generated_at="03.07.2026")
|
||||
assert "section-4" in doc
|
||||
assert part_b in doc
|
||||
|
||||
|
||||
def test_document_frame_escapes_cad_and_address() -> None:
|
||||
doc = build_full_report_html(
|
||||
"<div></div>",
|
||||
None,
|
||||
cad="<script>x</script>",
|
||||
address="<b>addr</b>",
|
||||
generated_at="03.07.2026",
|
||||
)
|
||||
assert "<script>x</script>" not in doc
|
||||
assert "<script>x</script>" in doc
|
||||
assert "<b>addr</b>" in doc
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue