Compare commits
244 commits
chore/desi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 698576e77b | |||
| 67b50bf713 | |||
| c61ed5a96e | |||
| e61c2debe5 | |||
| c904fbf94e | |||
| 9fd6396fbc | |||
| 8b7af92ee3 | |||
| 2c5b30f8c9 | |||
| d530602fbd | |||
| 32037af6de | |||
| 5647c2b40e | |||
| 749c96cd0c | |||
| 987dce11bc | |||
| b5477362f0 | |||
| 60c21ae2a5 | |||
| 3bd0873230 | |||
| 5e73b35206 | |||
| 69ca746673 | |||
| 2cd24ecfbc | |||
| 92371e7d9e | |||
| 7fa52bb3ad | |||
| 348be445e3 | |||
|
|
8fdcd8df62 | ||
| c98a879a79 | |||
|
|
2ef3e42da1 | ||
|
|
720cee2c8e | ||
| 73f932d60a | |||
| e7c388dde7 | |||
|
|
07275c3c97 | ||
|
|
d92ff5c3d3 | ||
|
|
d31b1e01ee | ||
| 51473fc841 | |||
| c3d0f86f37 | |||
|
|
219cdfdbcc | ||
|
|
bf4ce1ba36 | ||
| 519b482cdd | |||
| e4bb2fc2e3 | |||
|
|
85321d1d1a | ||
| b1ac551451 | |||
| 032fd95a7e | |||
|
|
0583fb0efd | ||
| a3dfa2a99f | |||
|
|
20bdc7dec8 | ||
|
|
6487813ec5 | ||
|
|
1c9f0e1161 | ||
| 0f4ed2f6e9 | |||
|
|
27d1b09c72 | ||
|
|
c1a01fdbf9 | ||
| fb123bd97e | |||
|
|
f9ae6f0c53 | ||
| 3293df8e14 | |||
| 4c56c5189a | |||
|
|
25a865f27b | ||
|
|
69f8b50934 | ||
|
|
7801ac6cc1 | ||
| 5d7c3e1c30 | |||
| 6936558f6f | |||
|
|
f631af4de3 | ||
|
|
4a579e74e0 | ||
|
|
b93d26990b | ||
| 90a6e4bfe9 | |||
|
|
cf91ac6b24 | ||
| 74017b39c4 | |||
|
|
e250869c9d | ||
| b0b2c94ece | |||
|
|
e2502a0134 | ||
| 51952ebd70 | |||
|
|
c0cbdc2f99 | ||
| fcc37c46ab | |||
| 95f0b0aa64 | |||
|
|
752cb1044b | ||
| 338ccbb78f | |||
| a88e755403 | |||
| 4f205bd7da | |||
| 8dd35c01d8 | |||
| ed504cbbd9 | |||
| cc231946c2 | |||
| d22e0e00ae | |||
| 346767ba90 | |||
|
|
f4c26d474a | ||
| 3fe1216317 | |||
|
|
3c4a33ca32 | ||
| f987e819b2 | |||
| 9a6b56016b | |||
| d3f3370bed | |||
| 01360e9c3d | |||
| 22f3c44dc2 | |||
| 82fdabccba | |||
| 39dd63333e | |||
| 1fa99812af | |||
| 53b160ff0a | |||
| 464519c69b | |||
| 7315d04396 | |||
| d1fa450f48 | |||
| be4b2ee1fa | |||
| f3988add0a | |||
| c0c666cccc | |||
| de5da6ec35 | |||
| 1bbd4c99e2 | |||
| 82e0b8b0a3 | |||
| b40995780b | |||
| c8c96a87ee | |||
| 9b6aba42dc | |||
| 5b3799a23a | |||
| eea9c99e73 | |||
| 9c46689da1 | |||
| 994eba0a16 | |||
| 7c1d3693e2 | |||
| f93ef5374d | |||
| b2d42a631a | |||
| ced616d150 | |||
| acc1e05357 | |||
| 102bdbda47 | |||
| 752c3656a3 | |||
| cfed042712 | |||
| 5cbf3485a0 | |||
| b102c334d8 | |||
|
|
b238d3ef50 | ||
| 3a2b4ba825 | |||
| 27868a4d2e | |||
| 9c6afcc876 | |||
| 921aba04b8 | |||
| 0658399f01 | |||
|
|
27bbd579c3 | ||
| a0c609caf4 | |||
| 8030f8ecef | |||
| 00a4dca5c0 | |||
| f0a9763e48 | |||
| b28aefec16 | |||
| c8343c8f3d | |||
| 2a3a93b138 | |||
|
|
5015109f31 | ||
|
|
6ace4ea4ce | ||
|
|
b6b7af6b42 | ||
|
|
f0c7ac4972 | ||
|
|
36c451e155 | ||
| eafa305ac8 | |||
|
|
575c233e51 | ||
| 84716e61de | |||
|
|
79a77200d7 | ||
| 2e345acb82 | |||
| ab250a132b | |||
| 828d691c0d | |||
|
|
c30581ae8d | ||
|
|
85059aeb1b | ||
| b3a59b7cf1 | |||
| 5d51eae705 | |||
|
|
d0b011d388 | ||
|
|
42b1ca6d7f | ||
| 27ba08b821 | |||
|
|
8ca529ece0 | ||
| 155d5ec3a9 | |||
| 480d4c2128 | |||
| a7e1391814 | |||
| 98df57827e | |||
| dd222a4eb3 | |||
|
|
a9b87aa311 | ||
| a5097c3a97 | |||
| 2538fc02e2 | |||
| 293184ebc6 | |||
| 4f0cae5c18 | |||
| 9850bbde14 | |||
| 2580158d8c | |||
| 9b92b595db | |||
|
|
7800f6014a | ||
| 758a4b06d4 | |||
| 53947015e9 | |||
|
|
b445c34863 | ||
|
|
96dd27c4ae | ||
| 53e7834f19 | |||
|
|
debe55b8a0 | ||
| ae3278f92f | |||
| 282c92747e | |||
| ae814706a2 | |||
| 57d156973e | |||
| 38f703c8ec | |||
| d31fabd102 | |||
|
|
51e2e545b7 | ||
|
|
9b4cdf8ec6 | ||
| 91c8365d91 | |||
| 6468bd6b37 | |||
| 1e5fc13635 | |||
| 758d7c6bf8 | |||
|
|
ccf716119c | ||
|
|
b89e0c3daf | ||
| c4201f7d2a | |||
| c778e4c85d | |||
|
|
ecec9ff6d7 | ||
|
|
fea054d24f | ||
| 7ebeddc2bc | |||
| 36fe33750f | |||
|
|
4a6820b8f7 | ||
| 5fa505b809 | |||
| 9289c3d69d | |||
| f872f5636b | |||
| aa2b33f264 | |||
| 17cf7344b3 | |||
|
|
53287da886 | ||
|
|
7546868535 | ||
|
|
7049717782 | ||
| 1d9e25ce07 | |||
| e7f97f1355 | |||
| e3d26f0aae | |||
| 49304a78bc | |||
| f41ca530c5 | |||
| 30e033d653 | |||
| 9feadc8458 | |||
|
|
116cd10046 | ||
|
|
eb564cfc01 | ||
| 793234cff1 | |||
| 308eeac214 | |||
| 01f2c5290d | |||
| 7fd80adfa7 | |||
| 86af5b78bf | |||
| 7a113273f4 | |||
| 24b773efe3 | |||
| 279e7de778 | |||
|
|
88b93435fc | ||
|
|
ea24289f47 | ||
|
|
db3afa3176 | ||
| a7677b0fba | |||
| 27e21f37d3 | |||
|
|
8c8289cdad | ||
| bc9549782b | |||
| 5db6a2bec3 | |||
|
|
d95c9e6e55 | ||
|
|
57963e3a06 | ||
|
|
90731da537 | ||
| a8b504c3de | |||
| 4fedfc5970 | |||
| 0e2b4998c8 | |||
| c129515ab8 | |||
| 3945060f0c | |||
| 64d3a925ef | |||
| 71b125cc13 | |||
|
|
a7253639cd | ||
| 698c1be4f3 | |||
|
|
6f170504ba | ||
| b902edd44c | |||
|
|
dafd12d9fa | ||
|
|
404d88ee34 | ||
|
|
c672e0322d | ||
| e9e3cc7dcf | |||
| d15ff3d99d |
522 changed files with 42096 additions and 50105 deletions
|
|
@ -78,6 +78,7 @@ Staff+ code reviewer в autonomous-merge режиме. Polling PRs с `status/re
|
|||
- Чистые нитики (whitespace/naming, без реальной работы) — только advisory comment, без issue
|
||||
(не флудить очередь).
|
||||
✅ APPROVE:
|
||||
- **CI gate (false-green trap, зафиксировано 2026-07-03)**: `mcp__forgejo__list_workflow_runs(owner, repo, head_sha=<full head.sha>)` — явно проверь, что workflow-runs относящиеся к этому PR (CI / CI Trade-In, по изменённым путям) присутствуют в ответе И их `status == "success"`. Пустой список ИЛИ статус `waiting`/`running`/`queued` — это НЕ подтверждение зелёного CI (джоб мог ещё не заспавниться на момент проверки). Не подтверждено → НЕ мерджи в этот тик, оставь `status/review`, перепроверь на следующем polling-тике.
|
||||
- `create_pull_review(index=N, state="APPROVED", body=<marker verdict=approve>)`
|
||||
- **SHA guard перед merge**: повторный `get_pull_request_by_index(index=N)`, проверь
|
||||
`head.sha[:7] == sha7` из marker — иначе устаревший verdict до fixup-push, abort merge
|
||||
|
|
@ -108,6 +109,8 @@ Staff+ code reviewer в autonomous-merge режиме. Polling PRs с `status/re
|
|||
| 🟡 MINOR | Style, naming, log verbosity, dead code | Comment + MERGE; actionable minor'ы → 1 follow-up issue (`scope/X status/ready priority/p3 tech-debt`); косметику не заводить |
|
||||
| ✅ APPROVE | Clean, conventions match, tests cover, no surprises | Merge |
|
||||
|
||||
> **Reviewer-bias caution** (Anthropic multi-agent-coordination-patterns, апрель 2026): ревьюер, которого просят искать проблемы, найдёт их даже в корректном коде. 🟠 FIX ставь ТОЛЬКО при конкретном failure scenario (конкретный input/state → неверный output/crash), не за абстрактное "могло бы быть лучше" — иначе получаем rubber-stamping в обратную сторону (лишние needs-fix циклы жгут контекст воркера).
|
||||
|
||||
## Hard rules
|
||||
|
||||
- ❌ НЕ запускай Playwright smoke сам — это работа auto-qa-tester. Передача через status/qa.
|
||||
|
|
|
|||
|
|
@ -108,10 +108,10 @@ mcp__obsidian__obsidian_simple_search "<keyword>"
|
|||
- Time spent: ~3 min
|
||||
|
||||
### Critical issues (BLOCK push)
|
||||
- [ ] `file.py:42` — описание проблемы + fix suggestion
|
||||
- [ ] `file.py:42` — конкретный failure scenario (input/state → неверный output/crash) + fix suggestion. Без repro-сценария — не критикал, переквалифицируй в Minor или Positive observation.
|
||||
|
||||
### Minor issues (можно fix потом)
|
||||
- [ ] `file.py:84` — улучшение
|
||||
- [ ] `file.py:84` — улучшение (не rubber-stamp: если это не влияет на поведение — Positive observations или пропусти)
|
||||
|
||||
### Positive observations
|
||||
- ✅ Что сделано хорошо
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod
|
|||
|
||||
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack)
|
||||
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
|
||||
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh` → `.github/workflows/deploy-obsidian.yml`
|
||||
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md` → `.forgejo/workflows/deploy-obsidian.yml`
|
||||
- `docs/**` alone → НЕ триггерит деплой
|
||||
|
||||
## После изменения .env на VPS
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ name: Deploy Obsidian
|
|||
#
|
||||
# Не пересобирает никаких Docker-образов (CouchDB официальный с DockerHub).
|
||||
# Не трогает main-стек (backend / frontend / postgres / worker / beat / caddy).
|
||||
#
|
||||
# ИСТОРИЯ (2026-07-05): жил в .github/workflows/ с момента миграции с GitHub
|
||||
# (16.05.2026), помечен в README как «остался на GitHub» — но живого зеркала
|
||||
# на github.com с настроенными секретами не оказалось: 0 запусков за всю
|
||||
# историю Forgejo Actions (12000+ прогонов остальных workflow), контейнер
|
||||
# не пересоздавался с 17.05 до ручного SSH-фикса 04.07. Перенесён сюда —
|
||||
# единственная директория, которую реально исполняет этот инстанс.
|
||||
# См. issue #2416.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -17,7 +25,7 @@ on:
|
|||
- "docker-compose.obsidian.yml"
|
||||
- "scripts/setup-couchdb.sh"
|
||||
- "docs/obsidian-livesync.md"
|
||||
- ".github/workflows/deploy-obsidian.yml"
|
||||
- ".forgejo/workflows/deploy-obsidian.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -451,17 +451,103 @@ jobs:
|
|||
fi
|
||||
|
||||
# (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
|
||||
#
|
||||
# #1951: раньше scraper пересоздавался ВТОРОЙ отдельной командой `up -d`,
|
||||
# уже ПОСЛЕ browser/backend/frontend. Если это происходило посреди
|
||||
# in-flight sweep'а (avito/cian/rosreestr full-load), процесс убивался на
|
||||
# лету, а осиротевшая scrape_runs-строка сидела status='running' с
|
||||
# замёрзшим heartbeat до периодического 6h zombie-reaper'а — false "hang"
|
||||
# investigation вместо честного deploy-артефакта (см. cian_full_load #404).
|
||||
# Три меры ниже — все devops-only (shell в deploy-скрипте), БЕЗ правок
|
||||
# Python в scraper-стартап-пути (scheduler_main.py / scraper-kit /
|
||||
# app/services/scrapers — намеренно не тронуты, см. PR-описание):
|
||||
#
|
||||
# 1) Атомарный recreate — browser/backend/frontend[+scraper] поднимаются
|
||||
# ОДНИМ `docker compose up -d` инвокейшном (список сервисов собирается
|
||||
# заранее в $SERVICES), а не двумя последовательными командами.
|
||||
# 2) Graceful drain — если scraper будет пересоздан, ждём (до 5 мин, poll
|
||||
# каждые 10s) пока scrape_runs.status='running' не станет 0, ПРЕЖДЕ чем
|
||||
# инициировать recreate. Таймаут не блокирует деплой навсегда —
|
||||
# SIGTERM-drain (#1182 Phase 2/3a) + stop_grace_period 120s остаются
|
||||
# финальной страховкой для того, что не успело дойти до checkpoint'а.
|
||||
# 3) Startup-reap — сразу после recreate помечаем 'cancelled' любые
|
||||
# 'running'-строки, чей heartbeat не обновлялся с МОМЕНТА (по часам
|
||||
# самой БД — SELECT NOW(), без risk clock-skew раннера), взятого
|
||||
# непосредственно перед stop. Такие строки заведомо осиротели ЭТИМ
|
||||
# recreate'ом (старый контейнер физически не может писать heartbeat
|
||||
# после своей остановки) — не ждём 6h периодического reap_zombies().
|
||||
# 'cancelled' (не 'zombie') — честно отличает «убит деплоем» от
|
||||
# «непонятно завис» (последнее по-прежнему ловит только 6h-reaper).
|
||||
# Порог — по метке времени конкретного recreate, а не по фиксированному
|
||||
# интервалу: не зависит от heartbeat-каденса разных источников и не
|
||||
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
|
||||
# admin-triggered scrape внутри backend, если backend в этом деплое
|
||||
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
|
||||
SERVICES="browser backend frontend"
|
||||
SCRAPER_STOP_TS=""
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — recreating tradein-scraper"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
|
||||
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
|
||||
drained=""
|
||||
for i in $(seq 1 30); do
|
||||
# NB: не сливать "psql не ответил" с "0 running" — иначе неудачный
|
||||
# прогон психgl молча читается как «слито», и graceful drain
|
||||
# становится no-op именно в момент проблем с БД во время деплоя.
|
||||
running_count=""
|
||||
psql_out="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
|
||||
"SELECT COUNT(*) FROM scrape_runs WHERE status='running';" 2>/dev/null)" \
|
||||
&& running_count="$(printf '%s' "$psql_out" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
if [ -n "$running_count" ] && [ "$running_count" = "0" ]; then
|
||||
drained="yes"; break
|
||||
fi
|
||||
if [ -z "$running_count" ]; then
|
||||
echo " ...не удалось прочитать running_count (psql failed) — считаем как «ещё активен», жду 10s (попытка $i/30)"
|
||||
else
|
||||
echo " ...${running_count} активных run(ов) ещё бегут, жду 10s (попытка $i/30)"
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
if [ -n "$drained" ]; then
|
||||
echo "→ Активных run'ов нет — recreate scraper безопасен."
|
||||
else
|
||||
echo "WARNING: активные scrape_runs остались после 5 мин ожидания — recreate продолжится."
|
||||
echo " SIGTERM-drain (#1182) + stop_grace_period 120s постараются сохранить checkpoint;"
|
||||
echo " startup-reap ниже подчистит то, что не успеет."
|
||||
fi
|
||||
|
||||
# Checkpoint по часам БД (не раннера) прямо перед recreate.
|
||||
# NB: tr -d '[:space:]' сломан для timestamptz-литерала — убирает и
|
||||
# внутренний пробел между датой и временем ("2026-07-04 06:43" →
|
||||
# "2026-07-0406:43"), CAST(...AS timestamptz) на такое падает молча
|
||||
# (см. WARNING-фолбэк ниже). sed убирает только leading/trailing.
|
||||
SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS=""
|
||||
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
|
||||
|
||||
SERVICES="$SERVICES scraper"
|
||||
else
|
||||
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
|
||||
fi
|
||||
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
|
||||
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
|
||||
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
|
||||
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
|
||||
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
|
||||
# — поэтому подставляем bash-значением напрямую. SCRAPER_STOP_TS сгенерирован
|
||||
# самим Postgres (SELECT NOW()), не внешний ввод → безопасно.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c "
|
||||
UPDATE scrape_runs
|
||||
SET status = 'cancelled',
|
||||
finished_at = NOW(),
|
||||
error = 'deploy #1951: tradein-scraper recreated mid-run (startup-reap, checkpoint ${SCRAPER_STOP_TS})'
|
||||
WHERE status = 'running'
|
||||
AND heartbeat_at < CAST('${SCRAPER_STOP_TS}' AS timestamptz);
|
||||
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
|
||||
fi
|
||||
|
||||
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
|
||||
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
|
||||
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru
|
|||
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
|
||||
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
|
||||
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
|
||||
- [`.github/workflows/deploy-obsidian.yml`](.github/workflows/deploy-obsidian.yml) — obsidian (**остался на GitHub**): триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты).
|
||||
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*
|
||||
|
||||
**Forgejo Secrets / Variables:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе).
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ users:
|
|||
admin: admin
|
||||
kopylov: pilot
|
||||
user1: pilot
|
||||
user2: pilot
|
||||
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
|
||||
user3: pilot
|
||||
user4: pilot
|
||||
user5: pilot
|
||||
|
|
|
|||
|
|
@ -497,6 +497,21 @@ def trigger_poi_sync() -> dict[str, Any]:
|
|||
return {"task_id": result.id, "queued_at": "now"}
|
||||
|
||||
|
||||
@router.post("/gisogd-permits-sync")
|
||||
def trigger_gisogd_permits_sync() -> dict[str, Any]:
|
||||
"""Manual trigger инкрементальной загрузки РНС/РВЭ ГИСОГД-СО → gisogd_permits (#2367).
|
||||
|
||||
Обычно запускается еженедельно через beat (вторник 06:30 МСК). Этот endpoint —
|
||||
для ad-hoc запуска (например после деплоя миграции 187_gisogd_permits.sql или для
|
||||
внеочередного обновления). Инкрементально: карточки тянутся только для новых/
|
||||
изменившихся документов, повторный запуск дёшев.
|
||||
"""
|
||||
from app.workers.tasks.gisogd_permits_sync import sync_gisogd_permits
|
||||
|
||||
result = sync_gisogd_permits.apply_async()
|
||||
return {"task_id": result.id, "queued_at": "now"}
|
||||
|
||||
|
||||
class TriggerObjectiveEtlRequest(BaseModel):
|
||||
sqlite_path: str | None = Field(
|
||||
default=None,
|
||||
|
|
@ -1137,7 +1152,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
|
|||
)
|
||||
from app.workers.tasks.etl_newbuilding_crossload import etl_newbuilding_crossload
|
||||
|
||||
result = etl_newbuilding_crossload.apply_async()
|
||||
result = etl_newbuilding_crossload.apply_async(kwargs={"triggered_by": "manual"})
|
||||
return {"task_id": result.id, "queued_at": "now"}
|
||||
|
||||
|
||||
|
|
@ -1404,8 +1419,12 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
|
|||
table="nspd_geo_jobs",
|
||||
work_col="targets_done",
|
||||
attempt_fallback_col="created_at",
|
||||
fresh_days=7.0,
|
||||
stale_days=30.0,
|
||||
# On-demand источник БЕЗ cron (admin UI / CLI / lazy из analyze) — штучные
|
||||
# фетчи по активности пользователя. fresh_days=7 флагал каждую неделю
|
||||
# тишины ложным stale-алертом (расследование 2026-07-04); пороги — под
|
||||
# реальную каденцию, critical=False и так не трогает overall.
|
||||
fresh_days=30.0,
|
||||
stale_days=90.0,
|
||||
),
|
||||
FreshnessSource(
|
||||
source="cadastre",
|
||||
|
|
@ -1416,6 +1435,20 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
|
|||
fresh_days=14.0,
|
||||
stale_days=45.0,
|
||||
),
|
||||
FreshnessSource(
|
||||
# #2367: реестр РНС/РВЭ ГИСОГД-СО. Data-table режим (плоская таблица без run-
|
||||
# ledger): свежесть = MAX(fetched_at), upd_24h/_7d = COUNT(*) по окну. Источник
|
||||
# обновляется ежедневно, тянем еженедельно (beat вторник) → fresh<14d, stale<45d
|
||||
# (широкий запас на пропуск одного-двух вторников, как cadastre). critical=False.
|
||||
source="gisogd_permits",
|
||||
label="ГИСОГД-СО РНС/РВЭ (реестр разрешений)",
|
||||
table="gisogd_permits",
|
||||
timestamp_col="fetched_at",
|
||||
# В timestamp-режиме work_col не используется — валидное имя колонки.
|
||||
work_col="id",
|
||||
fresh_days=14.0,
|
||||
stale_days=45.0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -1756,6 +1789,20 @@ def trigger_ekburg_permits(
|
|||
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
||||
|
||||
|
||||
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
|
||||
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
|
||||
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
|
||||
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
|
||||
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
|
||||
_WAF_COOLDOWN_GUARD_MSG = (
|
||||
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
|
||||
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
|
||||
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
|
||||
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
|
||||
"передай i_understand_waf_risk=true в теле запроса."
|
||||
)
|
||||
|
||||
|
||||
class TriggerKnCatalogObjectsRequest(BaseModel):
|
||||
region_code: int = Field(default=66, ge=1, le=99)
|
||||
max_objects: int | None = Field(default=None, ge=1, le=2000)
|
||||
|
|
@ -1767,6 +1814,14 @@ class TriggerKnCatalogObjectsRequest(BaseModel):
|
|||
"что уже скраплено сегодня."
|
||||
),
|
||||
)
|
||||
i_understand_waf_risk: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
|
||||
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper бьёт по "
|
||||
"тому же /сервисы/* BrowserSession path family, что вызвал бан."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/kn-catalog-objects")
|
||||
|
|
@ -1783,7 +1838,14 @@ def trigger_kn_catalog_objects(
|
|||
- max_objects=None → дефолтный лимит таска (300).
|
||||
- max_objects=3 → smoke-тест.
|
||||
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||
|
||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
||||
re-trigger без явного подтверждения оператора запрещён.
|
||||
"""
|
||||
if not payload.i_understand_waf_risk:
|
||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||
|
||||
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
|
|
@ -1801,3 +1863,65 @@ def trigger_kn_catalog_objects(
|
|||
"force": payload.force,
|
||||
"queued_at": "now",
|
||||
}
|
||||
|
||||
|
||||
class TriggerKnCatalogFlatsRequest(BaseModel):
|
||||
region_code: int = Field(default=66, ge=1, le=99)
|
||||
max_flats: int | None = Field(default=None, ge=1, le=5000)
|
||||
force: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"True — игнорировать фильтр свежести ('catalog_updated_at свежий') и "
|
||||
"грузить ВСЕ квартиры последнего snapshot с непустым catalog_url_hash "
|
||||
"('Загрузить все'). По умолчанию пропускает то, что скраплено < 30 дней назад."
|
||||
),
|
||||
)
|
||||
i_understand_waf_risk: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
|
||||
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper ездит по "
|
||||
"тому же /сервисы/* BrowserSession path family, что и catalog-objects."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/kn-catalog-flats")
|
||||
def trigger_kn_catalog_flats(
|
||||
payload: TriggerKnCatalogFlatsRequest,
|
||||
) -> dict[str, Any]:
|
||||
"""Manual trigger для catalog-FLAT scraper (#2442): цена/статус/отделка/потолки/
|
||||
дата обновления + plan-изображения квартир из SSR-страницы каталога.
|
||||
|
||||
Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL. До тех пор пока
|
||||
#2442 Task 1 (elemId → catalog_url_hash) не задеплоен и свежий kn-sweep не
|
||||
наполнил hash — вернёт 0 обработанных строк (ожидаемо, не баг).
|
||||
|
||||
- max_flats=None → дефолтный лимит таска (300).
|
||||
- max_flats=3 → smoke-тест.
|
||||
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||
|
||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
||||
того же WAF-бана.
|
||||
"""
|
||||
if not payload.i_understand_waf_risk:
|
||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||
|
||||
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"region_code": payload.region_code,
|
||||
"force": payload.force,
|
||||
}
|
||||
if payload.max_flats is not None:
|
||||
kwargs["max_flats"] = payload.max_flats
|
||||
result = scrape_kn_catalog_flats.apply_async(kwargs=kwargs)
|
||||
|
||||
return {
|
||||
"task_id": result.id,
|
||||
"region_code": payload.region_code,
|
||||
"max_flats": payload.max_flats,
|
||||
"force": payload.force,
|
||||
"queued_at": "now",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ from app.core.db import get_db
|
|||
from app.schemas.chat import ChatAskRequest, ChatAskResponse, ChatIntent, GroundedIn
|
||||
from app.services.chat.intents import render_answer, route_intent
|
||||
from app.services.chat.orchestrator import orchestrate_chat
|
||||
from app.services.chat.retrieval import _FORECAST_SCHEMA_VERSION, get_report_for_chat
|
||||
from app.services.chat.retrieval import (
|
||||
_FORECAST_SCHEMA_VERSION,
|
||||
get_parcel_context_for_chat,
|
||||
get_report_for_chat,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -92,11 +96,46 @@ async def ask(
|
|||
report_status="pending",
|
||||
)
|
||||
|
||||
# Курируемый паспорт участка + градрегламент (§1 analyze-рана) — отдельный read-only
|
||||
# seam. §22-отчёт (форсайт) НЕ несёт тер.зону/ЗОУИТ/ЕГРН, поэтому дотягиваем их из
|
||||
# analyze-1.0 и вливаем в КОПИЮ report_dict под ключ "parcel_context" (tool
|
||||
# get_parcel_info режет именно его). Analyze-рана нет/сбой чтения → работаем как
|
||||
# раньше (только форсайт); НЕ меняем pending-поведение (оно завязано на §22-ран выше).
|
||||
report = await _with_parcel_context(db, payload.cad_num, report)
|
||||
|
||||
if settings.llm_enabled:
|
||||
return await _answer_via_llm(db, payload, report, run_id)
|
||||
return _answer_deterministic(payload, report, run_id)
|
||||
|
||||
|
||||
async def _with_parcel_context(
|
||||
db: Session,
|
||||
cad_num: str,
|
||||
report: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Дотянуть курируемый паспорт участка и влить его в КОПИЮ report_dict.
|
||||
|
||||
Read-only: sync-чтение analyze-рана мостим через run_in_threadpool (как §22-отчёт).
|
||||
None (рана нет) → возвращаем report без изменений. Сбой БД глотаем в pending-стиле
|
||||
эндпоинта: паспорт участка — обогащение, его отсутствие не должно ронять чат.
|
||||
"""
|
||||
try:
|
||||
parcel_context = await run_in_threadpool(get_parcel_context_for_chat, db, cad_num)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"chat: parcel context read failed for cad=%s — continuing without it",
|
||||
cad_num,
|
||||
exc_info=True,
|
||||
)
|
||||
return report
|
||||
if not parcel_context:
|
||||
return report
|
||||
# Копия: не мутируем report_dict, пришедший из get_report_for_chat.
|
||||
merged = dict(report)
|
||||
merged["parcel_context"] = parcel_context
|
||||
return merged
|
||||
|
||||
|
||||
def _answer_deterministic(
|
||||
payload: ChatAskRequest,
|
||||
report: dict[str, Any],
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ from app.services.site_finder.parcel_financial import (
|
|||
select_calibrated_price,
|
||||
synthesize_parcel_financial,
|
||||
)
|
||||
from app.services.site_finder.permits_nearby import get_permits_nearby
|
||||
from app.services.site_finder.poi_score import (
|
||||
PoiScoreResponse,
|
||||
compute_poi_routing_decay,
|
||||
|
|
@ -856,6 +857,20 @@ _NEIGHBORS_SUMMARY_SQL = text("""
|
|||
ORDER BY distance_m ASC
|
||||
LIMIT 30
|
||||
),
|
||||
neighbors_total AS (
|
||||
-- #2464 cluster B: честный COUNT(*) по ВСЕЙ 100м-выборке — БЕЗ LIMIT 30
|
||||
-- (тот же WHERE, что у `neighbors` выше). count_buildings_100m раньше
|
||||
-- считался как len(neighbors), тихо капаясь на 30 даже когда соседей
|
||||
-- в радиусе больше.
|
||||
SELECT COUNT(*) AS n
|
||||
FROM cad_buildings b
|
||||
WHERE ST_DWithin(
|
||||
b.geom::geography,
|
||||
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
|
||||
100
|
||||
)
|
||||
AND b.cad_num != CAST(:our_cad AS text)
|
||||
),
|
||||
overlap_rows AS (
|
||||
SELECT cad_num,
|
||||
building_name,
|
||||
|
|
@ -883,7 +898,8 @@ _NEIGHBORS_SUMMARY_SQL = text("""
|
|||
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
|
||||
FROM overlap_rows o),
|
||||
'[]'::json
|
||||
) AS overlap_rows
|
||||
) AS overlap_rows,
|
||||
(SELECT n FROM neighbors_total) AS neighbors_total_count
|
||||
""")
|
||||
|
||||
|
||||
|
|
@ -900,6 +916,11 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
один сетевой round-trip (~47ms) на каждый analyze — сами вычисления не
|
||||
меняются. Формат возвращаемого dict идентичен прежнему.
|
||||
|
||||
#2464 cluster B: `count_buildings_100m` — честный COUNT(*) по всей 100м-выборке
|
||||
(CTE `neighbors_total`, БЕЗ LIMIT), а не len(neighbors) (капалось на LIMIT 30).
|
||||
`neighbors_truncated` — True если в радиусе больше соседей, чем показано в
|
||||
списке `neighbors`.
|
||||
|
||||
SQL — module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
|
||||
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
|
||||
"""
|
||||
|
|
@ -914,6 +935,9 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
)
|
||||
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
|
||||
overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
|
||||
# #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) —
|
||||
# НЕ len(neighbor_rows), которое капалось на 30 даже когда соседей больше.
|
||||
neighbors_total_count = int(row["neighbors_total_count"]) if row else 0
|
||||
except Exception as e:
|
||||
logger.warning("neighbors query failed: %s", e)
|
||||
return {"data_available": False, "note": f"neighbors query failed: {e}"}
|
||||
|
|
@ -958,14 +982,12 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
]
|
||||
has_existing = len(overlap_buildings) > 0
|
||||
|
||||
return {
|
||||
"data_available": True,
|
||||
"radius_m": 100,
|
||||
"count_buildings_100m": len(neighbor_rows),
|
||||
"avg_floors_100m": avg_floors,
|
||||
"max_floors_100m": max_floors,
|
||||
"median_cost_per_m2_100m": median_cost,
|
||||
"neighbors": [
|
||||
# neighbor_rows — до 30 (SQL CTE `neighbors` LIMIT), список ниже дополнительно
|
||||
# режется до 20 для payload. `neighbors_truncated` сравнивает честный total с
|
||||
# тем, что РЕАЛЬНО показано (len(neighbors_list)) — а не с промежуточным
|
||||
# neighbor_rows (до 30) — иначе флаг мог соврать False на 21..30 соседях,
|
||||
# где список уже обрезан до 20, а total ещё не превысил len(neighbor_rows).
|
||||
neighbors_list = [
|
||||
{
|
||||
"cad_num": r["cad_num"],
|
||||
"building_name": r.get("building_name"),
|
||||
|
|
@ -987,7 +1009,19 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
"status": r.get("status"),
|
||||
}
|
||||
for r in neighbor_rows[:20]
|
||||
],
|
||||
]
|
||||
|
||||
return {
|
||||
"data_available": True,
|
||||
"radius_m": 100,
|
||||
# #2464 cluster B: честный total (neighbors_total CTE, без LIMIT) — НЕ
|
||||
# len(neighbor_rows) (капалось на LIMIT 30 у CTE `neighbors`).
|
||||
"count_buildings_100m": neighbors_total_count,
|
||||
"neighbors_truncated": neighbors_total_count > len(neighbors_list),
|
||||
"avg_floors_100m": avg_floors,
|
||||
"max_floors_100m": max_floors,
|
||||
"median_cost_per_m2_100m": median_cost,
|
||||
"neighbors": neighbors_list,
|
||||
"has_existing_buildings": has_existing,
|
||||
"overlap_buildings": overlap_buildings,
|
||||
"note": (
|
||||
|
|
@ -1156,6 +1190,70 @@ def _compute_confidence(
|
|||
}
|
||||
|
||||
|
||||
def _build_market_pulse(
|
||||
competitor_rows: list[dict[str, Any]],
|
||||
competitors_total: int,
|
||||
velocity_data: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""OBJ-3 aggregate: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
|
||||
|
||||
Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
|
||||
в `competitor_rows` (детальный список для карты/карточек), но исключаются
|
||||
из расчётов рыночных метрик (market_avg_price_per_m2, top_sellers).
|
||||
|
||||
#2464 cluster B: `competitors_total` — честный total (передаётся вызывающим
|
||||
кодом из отдельного COUNT(*) БЕЗ LIMIT, см. `analyze_parcel` 5a), НЕ
|
||||
len(competitor_rows) (которое капалось на LIMIT 20 запроса конкурентов, и
|
||||
заодно раздувало `coverage_pct` — competitors_priced/20 вместо
|
||||
competitors_priced/true_total). PURE — вызывающий код передаёт честный total
|
||||
отдельно, эта функция его не пересчитывает.
|
||||
"""
|
||||
competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
|
||||
competitors_priced = len(competitors_with_price)
|
||||
if competitors_with_price:
|
||||
prices = [float(c["avg_price_per_m2_rub"]) for c in competitors_with_price]
|
||||
market_avg_price = round(sum(prices) / len(prices))
|
||||
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
|
||||
with_sales = [
|
||||
c
|
||||
for c in competitors_with_price
|
||||
if c["units_sold"] is not None and int(c["units_sold"]) > 0
|
||||
]
|
||||
top_sellers = sorted(
|
||||
with_sales,
|
||||
key=lambda c: int(c["units_sold"]),
|
||||
reverse=True,
|
||||
)[:5]
|
||||
top_sellers_list = [
|
||||
{
|
||||
"obj_id": c["obj_id"],
|
||||
"comm_name": c["comm_name"],
|
||||
"dev_name": c["dev_name"],
|
||||
"units_sold": int(c["units_sold"]),
|
||||
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
|
||||
}
|
||||
for c in top_sellers
|
||||
]
|
||||
else:
|
||||
market_avg_price = None
|
||||
top_sellers_list = []
|
||||
|
||||
coverage_pct = (
|
||||
round(competitors_priced * 100.0 / competitors_total, 1) if competitors_total > 0 else 0.0
|
||||
)
|
||||
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
|
||||
# ЖК с objective_corpus_room_month данными (non-null by construction).
|
||||
avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
|
||||
return {
|
||||
"avg_velocity_m2": avg_velocity,
|
||||
"market_avg_price_per_m2": market_avg_price,
|
||||
"competitors_total": competitors_total,
|
||||
"competitors_with_price": competitors_priced,
|
||||
"coverage_pct": coverage_pct,
|
||||
"top_sellers": top_sellers_list,
|
||||
}
|
||||
|
||||
|
||||
# #93 — on-demand cadastre fetch tuning constants.
|
||||
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
|
||||
#
|
||||
|
|
@ -1626,10 +1724,15 @@ def build_parcel_report(
|
|||
analyze_at = _iso_or_none(getattr(analyze_run, "created_at", None))
|
||||
forecast_at = _iso_or_none(getattr(forecast_run, "created_at", None))
|
||||
|
||||
# Готовый кэш → 200 ready (не enqueue'им повторно).
|
||||
if _cached_report_result(db, cad_num) is not None:
|
||||
# Готовый кэш → 200 ready (не enqueue'им повторно). Отдаём и дату генерации PDF из
|
||||
# метадаты рана — чтобы fast-path кнопка показала дату без отдельного GET /status.
|
||||
cached = _cached_report_result(db, cad_num)
|
||||
if cached is not None:
|
||||
return ReportBuildResponse(
|
||||
status="ready", analyze_run_at=analyze_at, forecast_run_at=forecast_at
|
||||
status="ready",
|
||||
analyze_run_at=analyze_at,
|
||||
forecast_run_at=forecast_at,
|
||||
report_generated_at=cached.get("generated_at"),
|
||||
)
|
||||
|
||||
# Enqueue фоновой сборки. Lazy import таски (как forecast в analyze_parcel) — атрибут
|
||||
|
|
@ -1695,31 +1798,65 @@ def parcel_report_status(
|
|||
)
|
||||
|
||||
|
||||
# Медиа-типы + расширения по формату скачивания полного отчёта (PR-D pdf + PR-F docx).
|
||||
_REPORT_DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|
||||
|
||||
# HEAD нужен фронтовому preflight'у (#2338): FastAPI НЕ добавляет HEAD к @router.get
|
||||
# автоматически (прод отдавал 405 → UI не скачивал DOCX вовсе). FileResponse при
|
||||
# HEAD нативно отдаёт только заголовки (Starlette send_header_only).
|
||||
@router.head("/{cad_num}/report/download", include_in_schema=False)
|
||||
@router.get(
|
||||
"/{cad_num}/report/download",
|
||||
summary="Скачать готовый полный PDF-отчёт участка (#2259 PR-D)",
|
||||
summary="Скачать готовый полный отчёт участка — PDF или DOCX (#2259 PR-D/PR-F)",
|
||||
)
|
||||
def download_parcel_report(
|
||||
cad_num: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
format: Annotated[
|
||||
Literal["pdf", "docx"],
|
||||
Query(description="Формат файла: pdf (default) | docx (Word-документ, #2259 PR-F)"),
|
||||
] = "pdf",
|
||||
) -> FileResponse:
|
||||
"""Отдать готовый PDF-файл полного отчёта (application/pdf).
|
||||
"""Отдать готовый файл полного отчёта в выбранном формате (PDF / DOCX).
|
||||
|
||||
Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) → FileResponse с
|
||||
Content-Disposition attachment (`gendesign_report_<cad>_<date>.pdf`). Отчёт не готов /
|
||||
Content-Disposition attachment (`gendesign_report_<cad>_<date>.<ext>`). Отчёт не готов /
|
||||
файл не найден → 404 (сначала POST /report, дождаться status=ready).
|
||||
|
||||
`format=docx`: старые раны (собранные до PR-F) не несут `docx_path` в метадате →
|
||||
честный 404 с подсказкой «пересоберите отчёт» (POST /report пере-соберёт с DOCX, т.к.
|
||||
ключ кэша не изменился, но файла docx нет — пере-рендер запишет оба).
|
||||
|
||||
Args:
|
||||
cad_num: кадастровый номер участка.
|
||||
format: "pdf" (default) | "docx".
|
||||
"""
|
||||
cached = _cached_report_result(db, cad_num)
|
||||
if cached is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="отчёт ещё не готов — запустите POST /report и дождитесь ready"
|
||||
)
|
||||
pdf_path = cached["pdf_path"]
|
||||
|
||||
if format == "docx":
|
||||
file_path = cached.get("docx_path")
|
||||
media_type = _REPORT_DOCX_MEDIA_TYPE
|
||||
ext = "docx"
|
||||
# Старый ран без docx_path (собран до PR-F) → пере-соберите POST /report.
|
||||
if not isinstance(file_path, str) or not Path(file_path).exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
"DOCX-версия недоступна для этого отчёта — пересоберите отчёт (POST /report)"
|
||||
),
|
||||
)
|
||||
else:
|
||||
file_path = cached["pdf_path"]
|
||||
media_type = "application/pdf"
|
||||
ext = "pdf"
|
||||
|
||||
cad_safe = cad_num.replace(":", "_")
|
||||
# Дата в имени файла — из МЕТАДАТЫ отчёта (когда PDF реально собран), НЕ today: на
|
||||
# Дата в имени файла — из МЕТАДАТЫ отчёта (когда файл реально собран), НЕ today: на
|
||||
# cache-hit со вчерашнего отчёта today соврал бы. generated_at — ISO-строка из
|
||||
# build_full_report; битую/отсутствующую парсим best-effort → fallback на today.
|
||||
date_str = _dt.date.today().strftime("%Y-%m-%d")
|
||||
|
|
@ -1729,10 +1866,10 @@ def download_parcel_report(
|
|||
date_str = _dt.datetime.fromisoformat(generated_at).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.warning("report download: битый generated_at %r для %s", generated_at, cad_num)
|
||||
filename = f"gendesign_report_{cad_safe}_{date_str}.pdf"
|
||||
filename = f"gendesign_report_{cad_safe}_{date_str}.{ext}"
|
||||
return FileResponse(
|
||||
pdf_path,
|
||||
media_type="application/pdf",
|
||||
file_path,
|
||||
media_type=media_type,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
|
@ -2216,6 +2353,34 @@ def analyze_parcel(
|
|||
.all()
|
||||
)
|
||||
|
||||
# 5a) #2464 cluster B: честный COUNT(*) конкурентов в радиусе 3км — БЕЗ LIMIT.
|
||||
# competitor_rows выше капнут `LIMIT 20` (топ-20 ближайших/строящихся для карты и
|
||||
# детального списка) — market_pulse.competitors_total ниже раньше = len(competitor_rows),
|
||||
# тихо капаясь на 20 даже когда в радиусе ЖК больше. Лёгкий COUNT переиспользует
|
||||
# ТОТ ЖЕ latest_obj + ST_DWithin фильтр, но БЕЗ obj_lots_latest/obj_pricing джойнов
|
||||
# (нужны только для цен детального списка, не для total) — на порядок дешевле
|
||||
# полного запроса конкурентов (см. #1964 EXPLAIN про стоимость lots-дедупа).
|
||||
_competitors_total_true = (
|
||||
db.execute(
|
||||
text("""
|
||||
WITH latest_obj AS (
|
||||
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude
|
||||
FROM domrf_kn_objects
|
||||
WHERE latitude IS NOT NULL
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
)
|
||||
SELECT COUNT(*) FROM latest_obj o
|
||||
WHERE ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
3000
|
||||
)
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес
|
||||
# в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
|
||||
# + по кварталам сдачи. Константы — см. PIPELINE_* выше.
|
||||
|
|
@ -3464,6 +3629,25 @@ def analyze_parcel(
|
|||
except Exception as e:
|
||||
logger.warning("ekburg permits query failed for %s: %s", cad_num, e)
|
||||
|
||||
# 10d-pre2b) #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (gisogd_permits,
|
||||
# ST_DWithin 500м). НОВЫЙ ключ payload — заменяет TODO-прокси quarter-prefix выше,
|
||||
# НЕ трогая старый ekburg-путь (recent_permits_in_quarter / permits_summary остаются).
|
||||
# Non-fatal: сбой → honest-ноль вместо падения всего analyze.
|
||||
permits_nearby_data: dict[str, Any] = {
|
||||
"radius_m": 500,
|
||||
"total_count": 0,
|
||||
"rs_count": 0,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": None,
|
||||
"items": [],
|
||||
"items_truncated": False,
|
||||
"source": "gisogd66",
|
||||
}
|
||||
try:
|
||||
permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500)
|
||||
except Exception as e:
|
||||
logger.warning("gisogd permits_nearby query failed for %s: %s", cad_num, e)
|
||||
|
||||
# 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS
|
||||
karpinsky_url = (
|
||||
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
|
||||
|
|
@ -3621,56 +3805,12 @@ def analyze_parcel(
|
|||
except Exception as _ve:
|
||||
logger.warning("velocity compute failed for %s: %s", cad_num, _ve)
|
||||
|
||||
# OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
|
||||
# Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
|
||||
# в competitor list для карты, но исключаются из расчётов рыночных метрик.
|
||||
_competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
|
||||
_competitors_total = len(competitor_rows)
|
||||
_competitors_priced = len(_competitors_with_price)
|
||||
if _competitors_with_price:
|
||||
_prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price]
|
||||
_market_avg_price = round(sum(_prices) / len(_prices))
|
||||
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
|
||||
_with_sales = [
|
||||
c
|
||||
for c in _competitors_with_price
|
||||
if c["units_sold"] is not None and int(c["units_sold"]) > 0
|
||||
]
|
||||
_top_sellers = sorted(
|
||||
_with_sales,
|
||||
key=lambda c: int(c["units_sold"]),
|
||||
reverse=True,
|
||||
)[:5]
|
||||
_top_sellers_list = [
|
||||
{
|
||||
"obj_id": c["obj_id"],
|
||||
"comm_name": c["comm_name"],
|
||||
"dev_name": c["dev_name"],
|
||||
"units_sold": int(c["units_sold"]),
|
||||
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
|
||||
}
|
||||
for c in _top_sellers
|
||||
]
|
||||
else:
|
||||
_market_avg_price = None
|
||||
_top_sellers_list = []
|
||||
|
||||
_coverage_pct = (
|
||||
round(_competitors_priced * 100.0 / _competitors_total, 1)
|
||||
if _competitors_total > 0
|
||||
else 0.0
|
||||
# #2464 cluster B: competitors_total — честный total из отдельного COUNT(*)
|
||||
# (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса
|
||||
# конкурентов. См. _build_market_pulse docstring для полной логики агрегата.
|
||||
market_pulse: dict[str, Any] = _build_market_pulse(
|
||||
competitor_rows, int(_competitors_total_true), velocity_data
|
||||
)
|
||||
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
|
||||
# ЖК с objective_corpus_room_month данными (non-null by construction).
|
||||
_avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
|
||||
market_pulse: dict[str, Any] = {
|
||||
"avg_velocity_m2": _avg_velocity,
|
||||
"market_avg_price_per_m2": _market_avg_price,
|
||||
"competitors_total": _competitors_total,
|
||||
"competitors_with_price": _competitors_priced,
|
||||
"coverage_pct": _coverage_pct,
|
||||
"top_sellers": _top_sellers_list,
|
||||
}
|
||||
|
||||
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
|
||||
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается
|
||||
|
|
@ -3906,6 +4046,8 @@ def analyze_parcel(
|
|||
# #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m)
|
||||
"recent_permits_in_quarter": recent_permits,
|
||||
"permits_summary": permits_summary,
|
||||
# #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (ГИСОГД-66, ST_DWithin 500м)
|
||||
"permits_nearby": permits_nearby_data,
|
||||
"zoning": zoning,
|
||||
"success_recommendation": success_recommendation,
|
||||
"isochrones_available": bool(settings.openrouteservice_api_key),
|
||||
|
|
|
|||
|
|
@ -120,13 +120,18 @@ class Settings(BaseSettings):
|
|||
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
|
||||
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
|
||||
#
|
||||
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch()
|
||||
# ТОЛЬКО для KN-sweep BrowserSession (другие скраперы — nspd/catalog —
|
||||
# продолжают использовать модульный дефолт _BROWSER_CONCURRENCY=8 без
|
||||
# изменений). 2 — эмпирически безопасный потолок против volume-бана.
|
||||
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch().
|
||||
# Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2
|
||||
# (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat /
|
||||
# catalog-object scrapers) тоже переиспользуют эти же значения — они бьют
|
||||
# по тому же /сервисы/* path family, что и вызвало WAF hard-ban 2026-05-24
|
||||
# (#2443). nspd и прочие скраперы вне этого path family продолжают
|
||||
# использовать модульный дефолт _BROWSER_CONCURRENCY=8 без изменений.
|
||||
# 2 — эмпирически безопасный потолок против volume-бана.
|
||||
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
|
||||
scrape_kn_browser_concurrency: int = 2
|
||||
# Окно случайной паузы (мс) между запросами KN-sweep. Шире дефолта
|
||||
# Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog-
|
||||
# flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта
|
||||
# (600–1500), чтобы размазать запросы во времени и не триггерить rate-ban.
|
||||
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
|
||||
# ширим до 1200–3000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ class ConnectionPointsSummary(BaseModel):
|
|||
in_protection_zone: bool
|
||||
protection_zones_intersecting: int
|
||||
total_structures_in_radius: int
|
||||
# Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без
|
||||
# LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL).
|
||||
# ADDITIVE (default False) — старые клиенты не ломаются.
|
||||
protection_zones_truncated: bool = False
|
||||
structures_truncated: bool = False
|
||||
|
||||
|
||||
class ConnectionPointsResponse(BaseModel):
|
||||
|
|
@ -100,6 +105,10 @@ class UtilityInfrastructureSummary(BaseModel):
|
|||
nearest_distance_m: float | None
|
||||
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
|
||||
nearest_by_kind: dict[str, float | None]
|
||||
# Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же
|
||||
# ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200).
|
||||
# ADDITIVE (default False) — старые клиенты не ломаются.
|
||||
features_truncated: bool = False
|
||||
|
||||
|
||||
class UtilityInfrastructureResponse(BaseModel):
|
||||
|
|
@ -270,6 +279,9 @@ class ReportBuildResponse(BaseModel):
|
|||
# Даты базовых ранов (ISO) — контекст, по каким данным собирается/собран отчёт.
|
||||
analyze_run_at: str | None = None
|
||||
forecast_run_at: str | None = None
|
||||
# Дата генерации готового PDF (ISO) — только при status="ready" (кэш-хит); на
|
||||
# building-ветке None (файла ещё нет). Даёт fast-path кнопке дату без GET /status.
|
||||
report_generated_at: str | None = None
|
||||
|
||||
|
||||
class ReportStatusResponse(BaseModel):
|
||||
|
|
@ -901,6 +913,7 @@ class AnalyzeResponse(BaseModel):
|
|||
parcel_meta: dict[str, Any] | None = None
|
||||
recent_permits_in_quarter: list[dict[str, Any]] | None = None
|
||||
permits_summary: dict[str, Any] | None = None
|
||||
permits_nearby: dict[str, Any] | None = None
|
||||
zoning: dict[str, Any] | None = None
|
||||
success_recommendation: dict[str, Any] | None = None
|
||||
isochrones_available: bool | None = None
|
||||
|
|
|
|||
|
|
@ -504,6 +504,15 @@ def developer_history(
|
|||
|
||||
|
||||
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
|
||||
# #2464 cluster E: без дедупа каждый ЖК возвращался ×N раз (одна строка на
|
||||
# snapshot_date, ретенции нет) — портфель девелопера раздувался в разы (прод-замер
|
||||
# ~7.6-9.0× per-developer). DISTINCT ON (obj_id) + snapshot_date DESC NULLS LAST —
|
||||
# latest-snapshot-per-obj_id (тот же паттерн, что cmp_rows/latest_obj CTE в
|
||||
# recommend_mix ниже в этом файле). dev_id — стабильный идентификатор
|
||||
# (не меняется между снапшотами одного ЖК) → фильтруется ДО DISTINCT ON, как
|
||||
# region_cd/district_name в сиблингах. Итоговый ORDER BY ready_dt вынесен во внешний
|
||||
# SELECT: DISTINCT ON требует, чтобы его собственный ORDER BY начинался с ключа
|
||||
# дедупа (obj_id, snapshot_date), не с ready_dt.
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -511,8 +520,15 @@ def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
|
|||
SELECT obj_id, comm_name, addr, region_cd, flat_count,
|
||||
square_living, ready_dt, obj_class, escrow,
|
||||
problem_flag, latitude, longitude, is_ekb
|
||||
FROM (
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, comm_name, addr, region_cd, flat_count,
|
||||
square_living, ready_dt, obj_class, escrow,
|
||||
problem_flag, latitude, longitude, is_ekb
|
||||
FROM domrf_kn_objects
|
||||
WHERE dev_id = :dev
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
) latest
|
||||
ORDER BY ready_dt DESC NULLS LAST
|
||||
"""
|
||||
),
|
||||
|
|
@ -1797,12 +1813,38 @@ def _active_competitors_count(
|
|||
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
|
||||
|
||||
def _q(where_extras: str, params: dict[str, Any]) -> int:
|
||||
# #2464 cluster E: domrf_kn_objects хранит МНОЖЕСТВО snapshot_date на obj_id
|
||||
# (UNIQUE(obj_id, snapshot_date), ретенции нет) — прод-замер 4090 строк / 482
|
||||
# distinct obj_id для site_status='Строящиеся' (8.49× инфляция). Наивный
|
||||
# COUNT(*) считал каждый исторический снапшот отдельно. Fix: DISTINCT ON
|
||||
# (obj_id) + snapshot_date DESC NULLS LAST даёт latest-snapshot-per-obj_id
|
||||
# (сиблинг-паттерн: _L3_FUTURE_SQL #1212 в site_finder/supply_layers.py).
|
||||
#
|
||||
# ВСЕ волатильные предикаты применяются ПОСЛЕ DISTINCT ON (во внешнем WHERE),
|
||||
# а не внутри CTE — иначе DISTINCT ON вернул бы «последний снапшот, ПРОШЕДШИЙ
|
||||
# фильтр», а не истинно последний снапшот объекта. Прод-замер per-obj_id
|
||||
# variance across snapshots (authoritative, coordinator 2026-07-08):
|
||||
# dev_id=0, region_cd=0 (СТАБИЛЬНЫ) · district_name=1, obj_class=29,
|
||||
# site_status=11 (ВОЛАТИЛЬНЫ).
|
||||
# → В CTE остаётся ТОЛЬКО region_cd (стабилен + ограничивает стоимость дедупа
|
||||
# как partition-scope). {where_extras} (district_name/obj_class) и
|
||||
# site_status='Строящиеся' — во внешнем WHERE, на истинно-последней строке.
|
||||
# Пример бага, который это чинит: ЖК сменил класс Комфорт→Бизнес; при
|
||||
# фильтре obj_class внутри CTE взяли бы старый Комфорт-снапшот и посчитали
|
||||
# его активным Комфортом, хотя его актуальный класс уже Бизнес.
|
||||
n = db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM domrf_kn_objects
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, site_status, district_name,
|
||||
obj_class, obj_class_fallback
|
||||
FROM domrf_kn_objects
|
||||
WHERE region_cd = :rc
|
||||
AND site_status = 'Строящиеся'
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
)
|
||||
SELECT COUNT(*) FROM latest
|
||||
WHERE site_status = 'Строящиеся'
|
||||
{where_extras}
|
||||
"""
|
||||
),
|
||||
|
|
@ -2233,14 +2275,28 @@ def _competitors_two_dim(
|
|||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
WITH active AS (
|
||||
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name
|
||||
WITH latest AS (
|
||||
-- #2464 cluster E: сначала истинно-последний снапшот на obj_id,
|
||||
-- ТОЛЬКО по стабильному region_cd (variance=0). Волатильные
|
||||
-- предикаты (site_status=11, district_name=1, obj_class=29 per
|
||||
-- прод-замер) — НЕ внутри DISTINCT ON, иначе взяли бы «последний
|
||||
-- снапшот, прошедший фильтр», а не истинно последний (ЖК,
|
||||
-- сменивший класс/район/статус, засчитался бы по устаревшему
|
||||
-- снапшоту). Зеркалит _q() в _active_competitors_count выше.
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, latitude, longitude, district_name,
|
||||
site_status, obj_class, obj_class_fallback
|
||||
FROM domrf_kn_objects
|
||||
WHERE region_cd = :rc
|
||||
AND site_status = 'Строящиеся'
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
),
|
||||
active AS (
|
||||
-- Волатильные фильтры на истинно-последней строке.
|
||||
SELECT obj_id, latitude, longitude, district_name
|
||||
FROM latest
|
||||
WHERE site_status = 'Строящиеся'
|
||||
AND district_name = :dn
|
||||
{class_filter}
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
),
|
||||
centroid AS (
|
||||
SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.analysis_runs.repository import latest_run_for
|
||||
from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION, latest_run_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
|
|||
# схеме явный и устойчивый.
|
||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||
|
||||
# Максимум названий ЗОУИТ-зон в курируемом срезе (§19: ограничиваем объём, чтобы наружу
|
||||
# не ушёл длинный «хвост» — сводки хватает списка из первых нескольких зон).
|
||||
_MAX_ZOUIT_NAMES = 15
|
||||
|
||||
|
||||
def get_report_for_chat(
|
||||
db: Session,
|
||||
|
|
@ -57,3 +61,116 @@ def get_report_for_chat(
|
|||
return None, None
|
||||
|
||||
return result, run.id
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any]:
|
||||
"""Секция payload как dict или {} (graceful — как _as_dict в full_report_html)."""
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _put(target: dict[str, Any], key: str, value: Any) -> None:
|
||||
"""Положить ТОЛЬКО скаляр в курируемый срез (§19 data-residency).
|
||||
|
||||
Гейт двойной: и по КЛЮЧУ (вызывающий передаёт фиксированный whitelist), и по ТИПУ
|
||||
значения. Пропускаем лишь str|int|float|bool — dict/list/любая вложенная структура
|
||||
ОТБРАСЫВАЕТСЯ (иначе сырой под-словарь ЕГРН/зонирования с PII мог бы утечь наружу;
|
||||
redaction-слой ловит regex-PII в строках, но НЕ структуры). Пустую строку опускаем.
|
||||
|
||||
UP038: union-форма isinstance (не кортеж) — pre-commit пинит старый ruff.
|
||||
"""
|
||||
if not isinstance(value, str | int | float | bool):
|
||||
return
|
||||
if value == "":
|
||||
return
|
||||
target[key] = value
|
||||
|
||||
|
||||
def _zouit_names(overlaps: list[Any]) -> list[str]:
|
||||
"""Собрать dedup-список названий ЗОУИТ-зон (type_zone|name), обрезанный до лимита.
|
||||
|
||||
§19: ТОЛЬКО строки-названия — никакой геометрии/reg_numb/coverage сырых пересечений.
|
||||
Не-строковые значения (dict/list/число) ОТБРАСЫВАЕМ — не приводим str()'ом, иначе
|
||||
вложенная структура утекла бы как её repr. dict.fromkeys держит порядок и dedup
|
||||
(зеркало _build_zouit в §1-рендере).
|
||||
"""
|
||||
# Фильтруем до str ДО dict.fromkeys: не-str (dict/list) не только небезопасны для
|
||||
# §19, но и unhashable → уронили бы dict.fromkeys TypeError'ом.
|
||||
raw = (ov.get("type_zone") or ov.get("name") for ov in overlaps if isinstance(ov, dict))
|
||||
names = list(dict.fromkeys(n for n in raw if isinstance(n, str) and n))
|
||||
return names[:_MAX_ZOUIT_NAMES]
|
||||
|
||||
|
||||
def get_parcel_context_for_chat(db: Session, cad_num: str) -> dict[str, Any] | None:
|
||||
"""Курируемый паспорт участка + градрегламент для чата (§19 data-residency).
|
||||
|
||||
READ-ONLY: последний analyze-ран (schema_version='analyze-1.0', НЕ §22-форсайт) →
|
||||
ПЛОСКИЙ whitelist-дикт из ФИКСИРОВАННОГО набора публичных градо-полей. Сырой
|
||||
analyze-blob во внешний LLM ЗАПРЕЩЁН (см. safe_payload.py) — поэтому собираем только
|
||||
скаляры и список строк по явным ключам, НИКОГДА не протаскивая под-словари/геометрию.
|
||||
|
||||
Ключи payload — те же, что читает §1-рендер отчёта (full_report_html:_build_parcel_facts
|
||||
/ _build_zoning / _build_zouit): egrn, nspd_zoning, encumbrance, nspd_zouit_overlaps.
|
||||
|
||||
None если analyze-рана/результата нет (graceful, как get_report_for_chat) — вызывающий
|
||||
работает как раньше (только §22-отчёт, без паспорта участка).
|
||||
"""
|
||||
run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
|
||||
if run is None:
|
||||
return None
|
||||
|
||||
result = run.result
|
||||
if not isinstance(result, dict):
|
||||
logger.warning(
|
||||
"chat: analyze run %s for cad=%s has non-dict result (%s) — no parcel context",
|
||||
run.id,
|
||||
cad_num,
|
||||
type(result).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
egrn = _as_dict(result.get("egrn"))
|
||||
nspd_zoning = _as_dict(result.get("nspd_zoning"))
|
||||
encumbrance = _as_dict(result.get("encumbrance"))
|
||||
overlaps = [ov for ov in (result.get("nspd_zouit_overlaps") or []) if isinstance(ov, dict)]
|
||||
|
||||
context: dict[str, Any] = {}
|
||||
|
||||
# ── Кадастровые факты (ЕГРН) — скаляры, без геометрии ────────────────────────
|
||||
_put(context, "address", egrn.get("address"))
|
||||
_put(context, "area_m2", egrn.get("area_m2"))
|
||||
_put(context, "land_category", egrn.get("land_category"))
|
||||
_put(context, "permitted_use_text", egrn.get("permitted_use_text"))
|
||||
_put(context, "cadastral_value_rub", egrn.get("cadastral_value_rub"))
|
||||
_put(context, "parcel_status", egrn.get("parcel_status"))
|
||||
_put(context, "ownership_type", egrn.get("ownership_type"))
|
||||
|
||||
# ── Территориальная зона ПЗЗ + лимиты регламента (те же ключи, что §1-рендер) ─
|
||||
_put(
|
||||
context,
|
||||
"zone_code",
|
||||
nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index"),
|
||||
)
|
||||
_put(context, "zone_name", nspd_zoning.get("zone_name"))
|
||||
_put(context, "max_far", nspd_zoning.get("max_far"))
|
||||
_put(context, "max_floors", nspd_zoning.get("max_floors"))
|
||||
_put(context, "max_height_m", nspd_zoning.get("max_height_m"))
|
||||
_put(context, "max_building_pct", nspd_zoning.get("max_building_pct"))
|
||||
_put(context, "min_parcel_area_m2", nspd_zoning.get("min_parcel_area_m2"))
|
||||
_put(context, "regulation_source", nspd_zoning.get("regulation_source"))
|
||||
|
||||
# ── ЗОУИТ-обременения: сводка + список названий (dedup, capped) ───────────────
|
||||
has_zouit = encumbrance.get("has_zouit")
|
||||
zouit_count = encumbrance.get("zouit_count")
|
||||
zouit_names = _zouit_names(overlaps)
|
||||
# Противоречие «encumbrance=нет, но пересечения ЕСТЬ» — доверяем фактам НСПД (тот же
|
||||
# приём, что _build_zouit): свежий геослой перекрывает устаревшую сводку.
|
||||
if not has_zouit and zouit_names:
|
||||
has_zouit = True
|
||||
if zouit_count in (None, 0):
|
||||
zouit_count = len(overlaps)
|
||||
_put(context, "has_zouit", has_zouit)
|
||||
_put(context, "zouit_count", zouit_count)
|
||||
if zouit_names:
|
||||
context["zouit_zone_names"] = zouit_names
|
||||
|
||||
return context or None
|
||||
|
|
|
|||
|
|
@ -14,9 +14,14 @@ tool'ами (см. tools.py). ЭТО ЕДИНСТВЕННОЕ место, чер
|
|||
РАЗРЕШЕНО в ``section_data`` / ``fields``:
|
||||
• срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
|
||||
scoring / confidence / scenarios) — это посчитанные advisory-агрегаты.
|
||||
• ``parcel_context`` — КУРИРУЕМЫЙ срез analyze-рана (retrieval.get_parcel_context_for_chat):
|
||||
публичные градоданные участка (адрес, площадь, категория, ВРИ, тер.зона ПЗЗ + лимиты
|
||||
застройки, ЗОУИТ-сводка + названия зон). Строится по фиксированному whitelist'у из
|
||||
скаляров и списка строк — БЕЗ геометрии/координат, без сырых под-словарей, без PII.
|
||||
|
||||
ЗАПРЕЩЕНО (НИКОГДА не должно сюда дойти):
|
||||
• сырой ``analyze``-blob / сырые строки БД (rosreestr_deals, parcels, …);
|
||||
• сырой ``analyze``-blob целиком / сырые строки БД (rosreestr_deals, parcels, …);
|
||||
• геометрия/координаты участка или ЗОУИТ-пересечений;
|
||||
• свободный insight-текст / лиды / любой контент с PII;
|
||||
• любой источник, помеченный confidential.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
LLM в tool-loop'е (см. orchestrator.py) просит секции отчёта через function-calling.
|
||||
Здесь — две вещи и НИЧЕГО больше:
|
||||
|
||||
1. OpenAI tool-спеки (JSON-schema) пяти read-only секционных tool'ов
|
||||
1. OpenAI tool-спеки (JSON-schema) шести read-only секционных tool'ов
|
||||
(`get_exec_summary` / `get_product_recommendation` / `get_forecast` /
|
||||
`get_risks` / `get_scenarios`). Без параметров — каждый отдаёт фиксированную
|
||||
секцию(и) отчёта (модель не управляет вычислениями, только запрашивает данные).
|
||||
`get_risks` / `get_scenarios` / `get_parcel_info`). Без параметров — каждый отдаёт
|
||||
фиксированную секцию(и) отчёта (модель не управляет вычислениями, только запрашивает
|
||||
данные).
|
||||
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
|
||||
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
|
||||
НИКАКОЙ движковой математики — только срез готового dict'а.
|
||||
|
|
@ -73,6 +74,16 @@ def get_scenarios(report: dict[str, Any]) -> dict[str, Any]:
|
|||
return _section(report, "scenarios")
|
||||
|
||||
|
||||
def get_parcel_info(report: dict[str, Any]) -> dict[str, Any]:
|
||||
"""§1 parcel_context — паспорт участка + градрегламент ПЗЗ + ЗОУИТ.
|
||||
|
||||
Курируемый срез analyze-рана (тер.зона ПЗЗ, ВРИ, лимиты застройки, ЗОУИТ), влитый
|
||||
эндпоинтом под ключ "parcel_context" (retrieval.get_parcel_context_for_chat). Секции
|
||||
в отчёте нет (analyze-рана не было) → маркер «недоступно», как у остальных tool'ов.
|
||||
"""
|
||||
return _section(report, "parcel_context")
|
||||
|
||||
|
||||
# ── Реестр имя→executor + имя→секции отчёта (для provenance grounded_in.sections) ─
|
||||
# ЕДИНЫЙ источник истины: и спеки, и orchestrator берут имена/маппинг отсюда.
|
||||
|
||||
|
|
@ -84,6 +95,7 @@ _TOOLS: dict[str, tuple[Callable[[dict[str, Any]], dict[str, Any]], tuple[str, .
|
|||
"get_forecast": (get_forecast, ("future_market",)),
|
||||
"get_risks": (get_risks, ("scoring", "confidence")),
|
||||
"get_scenarios": (get_scenarios, ("scenarios",)),
|
||||
"get_parcel_info": (get_parcel_info, ("parcel_context",)),
|
||||
}
|
||||
|
||||
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
|
||||
|
|
@ -103,8 +115,11 @@ _TOOL_DESCRIPTIONS: dict[str, str] = {
|
|||
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
|
||||
"и уровень/факторы уверенности отчёта."
|
||||
),
|
||||
"get_scenarios": (
|
||||
"Сценарии развития: разброс по консервативному / базовому / агрессивному."
|
||||
"get_scenarios": ("Сценарии развития: разброс по консервативному / базовому / агрессивному."),
|
||||
"get_parcel_info": (
|
||||
"Паспорт участка и градостроительный регламент: адрес, площадь, категория земель, "
|
||||
"ВРИ, территориальная зона ПЗЗ (код и название), лимиты застройки, "
|
||||
"ЗОУИТ-обременения."
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +143,7 @@ def _spec(name: str) -> dict[str, Any]:
|
|||
|
||||
|
||||
def tool_specs() -> list[dict[str, Any]]:
|
||||
"""Все 5 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
|
||||
"""Все 6 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
|
||||
return [_spec(name) for name in _TOOLS]
|
||||
|
||||
|
||||
|
|
@ -161,6 +176,7 @@ __all__ = [
|
|||
"execute_tool",
|
||||
"get_exec_summary",
|
||||
"get_forecast",
|
||||
"get_parcel_info",
|
||||
"get_product_recommendation",
|
||||
"get_risks",
|
||||
"get_scenarios",
|
||||
|
|
|
|||
1277
backend/app/services/exporters/full_report_docx.py
Normal file
1277
backend/app/services/exporters/full_report_docx.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -57,6 +57,50 @@ MAP_CONCEPT_PLACEHOLDER = "{{MAP_CONCEPT}}"
|
|||
_DASH = "—"
|
||||
_NO_DATA = "нет данных"
|
||||
|
||||
# ── §3 connection-capacity: кап-строки + фильтр «шума» области ───────────────────
|
||||
# Печатный отчёт по участку ЕКБ, а heat_system_reserves несёт ВСЕ ~56 систем области
|
||||
# (Ирбит/Тавда/Красноуфимск/Первоуральск/Лесной/Нижняя Тура…). Не хардкодим список
|
||||
# городов — отсекаем по ЯВНЫМ не-ЕКБ маркерам в имени организации/системы; остальное
|
||||
# капим топ-N по резерву. Дефицит (отрицательный резерв) под кап НЕ прячем — честность.
|
||||
_NON_EKB_MARKERS: tuple[str, ...] = (
|
||||
"ирбит",
|
||||
"тавда",
|
||||
"красноуфимск",
|
||||
"первоуральск",
|
||||
"лесной",
|
||||
"нижняя тура",
|
||||
"нижний тагил",
|
||||
"каменск",
|
||||
"серов",
|
||||
"асбест",
|
||||
"ревда",
|
||||
"полевск",
|
||||
"березовск", # покрывает «Березовский» / «Берёзовский» (ё нормализуем ниже)
|
||||
"верхняя пышма",
|
||||
"среднеуральск",
|
||||
"заречный",
|
||||
"новоуральск",
|
||||
"качканар",
|
||||
"краснотурьинск",
|
||||
)
|
||||
_EKB_MARKER = "екатеринбург"
|
||||
# Generic-маркеры областных админ-единиц: «СТ: Нижнетуринский муниципальный округ» и т.п.
|
||||
# проходят мимо городского списка выше. Упоминание Екатеринбурга ПЕРЕВЕШИВАЕТ (см. _is_non_ekb).
|
||||
_NON_EKB_GENERIC: tuple[str, ...] = (
|
||||
"муниципальный округ",
|
||||
"городской округ",
|
||||
"муниципальный район",
|
||||
"городское поселение",
|
||||
"муниципальное образование",
|
||||
)
|
||||
# Кап видимых строк тепло/вода-таблиц (сверх — «и ещё K систем …»).
|
||||
_HEAT_ROW_CAP = 15
|
||||
_WATER_ROW_CAP = 25
|
||||
# Кап видимых строк таблицы разрешений §6 (сверх — «и ещё K записей …»).
|
||||
_PERMITS_ROW_CAP = 10
|
||||
# Усечение длинных бюрократических имён систем.
|
||||
_SYSTEM_NAME_MAX = 120
|
||||
|
||||
# Заголовки секций (якоря — id внутри) и титул документа.
|
||||
_TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА"
|
||||
_TITLE_S1 = "§1. Участок"
|
||||
|
|
@ -149,7 +193,9 @@ p { margin: 4pt 0; }
|
|||
не рвётся внутри заголовка (WeasyPrint поддерживает break-before/inside). */
|
||||
.section { margin-bottom: 16pt; break-inside: avoid-page; }
|
||||
.section + .section { break-before: page; }
|
||||
h2, h3 { break-after: avoid-page; }
|
||||
/* Заголовок НЕ должен осиротеть в конце страницы, оторвавшись от своей таблицы:
|
||||
и логический break-after (WeasyPrint 60+), и легаси page-break-after (fallback). */
|
||||
h2, h3 { page-break-after: avoid; break-after: avoid-page; }
|
||||
table { break-inside: avoid-page; }
|
||||
|
||||
/* Титул */
|
||||
|
|
@ -285,6 +331,17 @@ def _fmt_money_signed(value: Any) -> str:
|
|||
return f"{sign}{f'{round(magnitude):,}'.replace(',', ' ')} ₽"
|
||||
|
||||
|
||||
def _fmt_money(value: Any) -> str:
|
||||
"""Деньги округлением до млн БЕЗ знака: «8 млн ₽» / «216 млн ₽». PURE.
|
||||
|
||||
Для абсолютных величин-ЦЕН (средний чек, справочная цена), где «+» неуместен — это
|
||||
не дельта. Отрицательные (маловероятны для цены) — с типографским минусом. Тонкая
|
||||
обёртка над `_fmt_money_signed`: срезаем ведущий «+».
|
||||
"""
|
||||
signed = _fmt_money_signed(value)
|
||||
return signed[1:] if signed.startswith("+") else signed
|
||||
|
||||
|
||||
def _fmt_pct(fraction: Any) -> str:
|
||||
"""Доля 0.184 → «18.4%». Не-число → «—». PURE."""
|
||||
if isinstance(fraction, bool) or not isinstance(fraction, int | float):
|
||||
|
|
@ -440,20 +497,35 @@ def _build_zoning(result: dict[str, Any]) -> str:
|
|||
def _build_zouit(result: dict[str, Any]) -> str:
|
||||
"""ЗОУИТ-ограничения: сводка + список пересечений (тип / № границы / покрытие)."""
|
||||
encumbrance = _as_dict(result.get("encumbrance"))
|
||||
overlaps = _as_list(result.get("nspd_zouit_overlaps"))
|
||||
overlaps = [ov for ov in _as_list(result.get("nspd_zouit_overlaps")) if isinstance(ov, dict)]
|
||||
|
||||
has_zouit = encumbrance.get("has_zouit")
|
||||
zouit_count = encumbrance.get("zouit_count")
|
||||
zouit_types = _as_list(encumbrance.get("zouit_types"))
|
||||
|
||||
# Противоречие: encumbrance говорит «нет ЗОУИТ», но НСПД-пересечения ЕСТЬ. Доверяем
|
||||
# фактическим пересечениям (более свежий геослой) — иначе сводка «нет / 0» врёт под
|
||||
# таблицей с реальными строками. Типы/кол-во достаём из самих overlaps.
|
||||
if not has_zouit and overlaps:
|
||||
has_zouit = "да (по данным НСПД)"
|
||||
if zouit_count in (None, 0):
|
||||
zouit_count = len(overlaps)
|
||||
if not zouit_types:
|
||||
zouit_types = [
|
||||
str(t)
|
||||
for t in dict.fromkeys(ov.get("type_zone") or ov.get("name") for ov in overlaps)
|
||||
if t not in (None, "")
|
||||
]
|
||||
|
||||
summary_pairs: list[tuple[str, Any]] = [
|
||||
("Есть ЗОУИТ", encumbrance.get("has_zouit")),
|
||||
("Кол-во типов ЗОУИТ", encumbrance.get("zouit_count")),
|
||||
("Есть ЗОУИТ", has_zouit),
|
||||
("Кол-во типов ЗОУИТ", zouit_count),
|
||||
]
|
||||
zouit_types = _as_list(encumbrance.get("zouit_types"))
|
||||
if zouit_types:
|
||||
summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types)))
|
||||
|
||||
rows: list[list[Any]] = []
|
||||
for ov in overlaps:
|
||||
if not isinstance(ov, dict):
|
||||
continue
|
||||
for ov in overlaps: # already filtered to dicts above
|
||||
coverage = ov.get("coverage_pct")
|
||||
coverage_str = _fmt_pct(coverage) if isinstance(coverage, int | float) else _DASH
|
||||
rows.append(
|
||||
|
|
@ -674,19 +746,24 @@ def _build_engineering_nearby(result: dict[str, Any]) -> str:
|
|||
"""Инженерные сооружения НСПД рядом (name / назначение / расстояние из raw_props)."""
|
||||
items = _as_list(result.get("nspd_engineering_nearby"))
|
||||
rows: list[list[Any]] = []
|
||||
any_item = False
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
any_item = True
|
||||
raw = _as_dict(it.get("raw_props"))
|
||||
name = it.get("name") or raw.get("params_name") or raw.get("cad_number")
|
||||
purpose = it.get("type") or raw.get("params_purpose")
|
||||
# И имя, И назначение пустые → строка-призрак («— — 7»), скипаем.
|
||||
if name in (None, "") and purpose in (None, ""):
|
||||
continue
|
||||
rows.append([name, purpose, _fmt_int_ru(it.get("distance_m"))])
|
||||
if not rows:
|
||||
# Секции вообще нет в payload → не рисуем заголовок. Есть записи, но все пустые →
|
||||
# честное «нет данных» под заголовком (а не пустой невидимый блок).
|
||||
if not any_item:
|
||||
return ""
|
||||
return (
|
||||
f"<h3>Инженерные сооружения рядом (НСПД)</h3>"
|
||||
f"{_data_table(['Сооружение', 'Назначение', 'Расстояние, м'], rows)}"
|
||||
)
|
||||
table = _data_table(["Сооружение", "Назначение", "Расстояние, м"], rows)
|
||||
return f"<h3>Инженерные сооружения рядом (НСПД)</h3>{table}"
|
||||
|
||||
|
||||
def _build_alternatives(result: dict[str, Any]) -> str:
|
||||
|
|
@ -749,6 +826,96 @@ def _build_alternatives(result: dict[str, Any]) -> str:
|
|||
"""
|
||||
|
||||
|
||||
def _truncate_name(value: Any, limit: int = _SYSTEM_NAME_MAX) -> Any:
|
||||
"""Усечь длинное бюрократическое имя системы до `limit` символов с «…». PURE.
|
||||
|
||||
Не-строка / короткая строка → как есть (форматтер `_fmt`/`_esc` доработает). Режем по
|
||||
границе символов, добавляя одноточечное многоточие (U+2026), чтобы длинные имена вроде
|
||||
«Централизованная система теплоснабжения муниципального образования …» не разваливали
|
||||
вёрстку печатной таблицы.
|
||||
"""
|
||||
if not isinstance(value, str) or len(value) <= limit:
|
||||
return value
|
||||
return value[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _is_non_ekb(*fields: Any) -> bool:
|
||||
"""True, если В ЛЮБОМ из полей есть ЯВНЫЙ не-ЕКБ городской маркер (Ирбит/Тавда/…). PURE.
|
||||
|
||||
Организация/система из другого города области — «шум» для отчёта по участку ЕКБ.
|
||||
Нормализуем ё→е и регистр; НЕ хардкодим полный справочник городов (только явные
|
||||
крупные маркеры) — прочее схлопывается капом, а не этим фильтром.
|
||||
"""
|
||||
haystack = " ".join(str(f) for f in fields if f).lower().replace("ё", "е")
|
||||
if not haystack:
|
||||
return False
|
||||
# Явное упоминание Екатеринбурга перевешивает любые маркеры — один предикат
|
||||
# и для видимости, и для агрегата «Суммарно (ЕКБ)» (иначе они расходятся).
|
||||
if _EKB_MARKER in haystack:
|
||||
return False
|
||||
if any(marker in haystack for marker in _NON_EKB_MARKERS):
|
||||
return True
|
||||
return any(marker in haystack for marker in _NON_EKB_GENERIC)
|
||||
|
||||
|
||||
def _reserve_num(value: Any) -> float:
|
||||
"""Резерв как float для сортировки/суммы; не-число → 0.0 (нейтрально). PURE."""
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return 0.0
|
||||
return float(value)
|
||||
|
||||
|
||||
def _capacity_rows_capped(
|
||||
items: list[Any],
|
||||
*,
|
||||
reserve_key: str,
|
||||
name_fields: tuple[str, ...],
|
||||
row_cap: int,
|
||||
row_builder: Any,
|
||||
) -> tuple[list[list[Any]], int]:
|
||||
"""Отфильтровать областной шум + капнуть строки резерв-таблицы (тепло/вода). PURE.
|
||||
|
||||
Порядок честности:
|
||||
1. Дефицитные (резерв < 0) ЕКБ-системы — ВСЕГДА видимы, под кап не попадают.
|
||||
2. Остальные ЕКБ (или неопределённые по городу) сортируются по резерву ↓, берётся
|
||||
топ до заполнения `row_cap` (с учётом уже показанных дефицитных).
|
||||
3. Явно не-ЕКБ (Ирбит/Тавда/…) и хвост сверх капа схлопываются в счётчик `hidden`.
|
||||
|
||||
Args:
|
||||
items: сырые dict-строки (`heat.systems` / `cap.water`).
|
||||
reserve_key: ключ резерва в dict (`reserve_gcal_h` / `reserve_thousand_m3_day`).
|
||||
name_fields: ключи, по которым определяем город (org / system_name).
|
||||
row_cap: максимум видимых строк.
|
||||
row_builder: `dict -> list[cell]` — построитель ячеек строки данных.
|
||||
|
||||
Returns:
|
||||
`(visible_rows, hidden_count)` — строки для таблицы + сколько схлопнуто.
|
||||
"""
|
||||
dicts = [it for it in items if isinstance(it, dict)]
|
||||
deficit: list[dict[str, Any]] = []
|
||||
surplus_ekb: list[dict[str, Any]] = []
|
||||
hidden = 0
|
||||
for it in dicts:
|
||||
names = tuple(it.get(f) for f in name_fields)
|
||||
reserve = _reserve_num(it.get(reserve_key))
|
||||
# Дефицит ЕКБ/неопределённых — всегда показываем (даже если город не-ЕКБ:
|
||||
# отрицательный резерв рядом — сигнал, честнее показать, чем спрятать).
|
||||
if reserve < 0 and not _is_non_ekb(*names):
|
||||
deficit.append(it)
|
||||
continue
|
||||
if _is_non_ekb(*names):
|
||||
hidden += 1
|
||||
continue
|
||||
surplus_ekb.append(it)
|
||||
|
||||
surplus_ekb.sort(key=lambda d: _reserve_num(d.get(reserve_key)), reverse=True)
|
||||
remaining = max(row_cap - len(deficit), 0)
|
||||
shown_surplus = surplus_ekb[:remaining]
|
||||
hidden += len(surplus_ekb) - len(shown_surplus)
|
||||
visible = [row_builder(it) for it in (*deficit, *shown_surplus)]
|
||||
return visible, hidden
|
||||
|
||||
|
||||
def _build_connection_capacity(cap: dict[str, Any] | None) -> str:
|
||||
"""Ресурсные резервы (ЦП/вода/газ/тепло) + сети рядом из connection-capacity (#2259 PR-D).
|
||||
|
||||
|
|
@ -781,12 +948,22 @@ def _build_connection_capacity(cap: dict[str, Any] | None) -> str:
|
|||
if pairs:
|
||||
blocks.append("<h3>Электроснабжение — свободная мощность</h3>" + _kv_table(pairs))
|
||||
|
||||
# Вода: строки резервов ЦСВ/ЦСК за последний период.
|
||||
water_rows = [
|
||||
[w.get("system_name"), _fmt(w.get("reserve_thousand_m3_day")), w.get("period")]
|
||||
for w in _as_list(cap.get("water"))
|
||||
if isinstance(w, dict)
|
||||
]
|
||||
# Вода: строки резервов ЦСВ/ЦСК за последний период (ЕКБ-системы + кап ~25).
|
||||
water_rows, water_hidden = _capacity_rows_capped(
|
||||
_as_list(cap.get("water")),
|
||||
reserve_key="reserve_thousand_m3_day",
|
||||
name_fields=("system_name", "org"),
|
||||
row_cap=_WATER_ROW_CAP,
|
||||
row_builder=lambda w: [
|
||||
_truncate_name(w.get("system_name")),
|
||||
_fmt(w.get("reserve_thousand_m3_day")),
|
||||
w.get("period"),
|
||||
],
|
||||
)
|
||||
if water_hidden:
|
||||
water_rows.append(
|
||||
[f"… и ещё {water_hidden} систем (полный список в веб-версии §3)", _DASH, _DASH]
|
||||
)
|
||||
if water_rows:
|
||||
blocks.append(
|
||||
"<h3>Водоснабжение/водоотведение — резервы</h3>"
|
||||
|
|
@ -810,17 +987,47 @@ def _build_connection_capacity(cap: dict[str, Any] | None) -> str:
|
|||
+ _data_table(["ГРС", "Свободно, тыс. м³/ч", "Свободно, %"], gas_rows)
|
||||
)
|
||||
|
||||
# Тепло: резервы систем теплоснабжения.
|
||||
# Тепло: резервы систем теплоснабжения. heat_system_reserves несёт ВСЕ ~56 систем
|
||||
# области — сначала агрегат по ЕКБ (сумма резервов ЕКБ-систем), затем топ-15 + кап.
|
||||
heat = _as_dict(cap.get("heat"))
|
||||
heat_rows = [
|
||||
[h.get("org"), h.get("system_name"), _fmt(h.get("reserve_gcal_h")), h.get("period")]
|
||||
for h in _as_list(heat.get("systems"))
|
||||
if isinstance(h, dict)
|
||||
heat_systems = [h for h in _as_list(heat.get("systems")) if isinstance(h, dict)]
|
||||
if heat_systems:
|
||||
ekb_systems = [
|
||||
h for h in heat_systems if not _is_non_ekb(h.get("org"), h.get("system_name"))
|
||||
]
|
||||
if heat_rows:
|
||||
ekb_total = sum(_reserve_num(h.get("reserve_gcal_h")) for h in ekb_systems)
|
||||
heat_rows, heat_hidden = _capacity_rows_capped(
|
||||
heat_systems,
|
||||
reserve_key="reserve_gcal_h",
|
||||
name_fields=("org", "system_name"),
|
||||
row_cap=_HEAT_ROW_CAP,
|
||||
row_builder=lambda h: [
|
||||
_truncate_name(h.get("org")),
|
||||
_truncate_name(h.get("system_name")),
|
||||
_fmt(h.get("reserve_gcal_h")),
|
||||
h.get("period"),
|
||||
],
|
||||
)
|
||||
# Агрегатная строка «Суммарно (ЕКБ)» сверху.
|
||||
agg_row = [
|
||||
"Суммарно (ЕКБ)",
|
||||
f"{len(ekb_systems)} систем",
|
||||
_fmt(round(ekb_total, 1)),
|
||||
_DASH,
|
||||
]
|
||||
rows: list[list[Any]] = [agg_row, *heat_rows]
|
||||
if heat_hidden:
|
||||
rows.append(
|
||||
[
|
||||
f"… и ещё {heat_hidden} систем (полный список в веб-версии §3)",
|
||||
_DASH,
|
||||
_DASH,
|
||||
_DASH,
|
||||
]
|
||||
)
|
||||
blocks.append(
|
||||
"<h3>Теплоснабжение — резервы систем</h3>"
|
||||
+ _data_table(["Организация", "Система", "Резерв, Гкал/ч", "Период"], heat_rows)
|
||||
+ _data_table(["Организация", "Система", "Резерв, Гкал/ч", "Период"], rows)
|
||||
)
|
||||
|
||||
# Позитив-разрез: сетевые охранные зоны рядом (где физически проходит сеть).
|
||||
|
|
@ -914,7 +1121,9 @@ def _build_market_metrics(forecast: dict[str, Any]) -> str:
|
|||
("Темп продаж (velocity), ед./мес", metrics.get("unit_velocity")),
|
||||
("Темп продаж (площадь), м²/мес", metrics.get("area_velocity")),
|
||||
("Окно расчёта, мес", metrics.get("window_months")),
|
||||
("Ставка абсорбции", metrics.get("absorption_rate")),
|
||||
# absorption_rate — доля стока, продаваемая в месяц (0.0112). Как «0.01» это
|
||||
# нечитаемо → проценты («1.1%»). Темп в штуках уже есть в unit_velocity выше.
|
||||
("Ставка абсорбции (в мес.)", _fmt_pct(metrics.get("absorption_rate"))),
|
||||
("Месяцев запаса (months of supply)", metrics.get("months_of_supply")),
|
||||
("Индекс затоварки (overstock)", metrics.get("overstock_index")),
|
||||
("Sell-through, %", metrics.get("sell_through_pct")),
|
||||
|
|
@ -923,22 +1132,70 @@ def _build_market_metrics(forecast: dict[str, Any]) -> str:
|
|||
return _kv_table(pairs)
|
||||
|
||||
|
||||
def _competitor_lots(c: dict[str, Any]) -> int:
|
||||
"""Число лотов конкурента как int; не-число / None → 0. PURE."""
|
||||
n = c.get("flat_count")
|
||||
if isinstance(n, bool) or not isinstance(n, int | float):
|
||||
return 0
|
||||
return int(n)
|
||||
|
||||
|
||||
def _build_market_competitors(forecast: dict[str, Any]) -> str:
|
||||
"""Конкуренты рынка сейчас: ЖК / девелопер / класс / расстояние / лотов."""
|
||||
"""Конкуренты рынка сейчас: ЖК / девелопер / класс / расстояние / лотов.
|
||||
|
||||
Прод-payload несёт по строке НА КОРПУС: один ЖК с 6 корпусами → 6 строк, а безымянные
|
||||
записи с 0 лотов — мусор. Схлопываем: (а) строки без имени И с 0 лотов скипаем;
|
||||
(б) группируем по (имя, девелопер) → ближайшая дистанция, лоты суммой, класс/девелопер
|
||||
от первого, «(K корпусов)» в имени при K>1. Порядок групп — по первому появлению.
|
||||
"""
|
||||
market_now = _fc_as_dict(forecast.get("market_now"))
|
||||
competitors = _as_list(market_now.get("competitors"))
|
||||
competitors = [c for c in _as_list(market_now.get("competitors")) if isinstance(c, dict)]
|
||||
|
||||
groups: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
order: list[tuple[str, str]] = []
|
||||
for c in competitors:
|
||||
name = c.get("comm_name")
|
||||
lots = _competitor_lots(c)
|
||||
# Безымянная запись без лотов — мусор (пустые «—» корпуса-призраки).
|
||||
if name in (None, "") and lots == 0:
|
||||
continue
|
||||
key = (str(name or ""), str(c.get("dev_name") or ""))
|
||||
dist = c.get("distance_m")
|
||||
dist_val = (
|
||||
float(dist) if isinstance(dist, int | float) and not isinstance(dist, bool) else None
|
||||
)
|
||||
if key not in groups:
|
||||
order.append(key)
|
||||
groups[key] = {
|
||||
"name": name,
|
||||
"dev_name": c.get("dev_name"),
|
||||
"obj_class": c.get("obj_class"),
|
||||
"distance_m": dist_val,
|
||||
"lots": lots,
|
||||
"corpus": 1,
|
||||
}
|
||||
else:
|
||||
g = groups[key]
|
||||
g["lots"] += lots
|
||||
g["corpus"] += 1
|
||||
if dist_val is not None and (g["distance_m"] is None or dist_val < g["distance_m"]):
|
||||
g["distance_m"] = dist_val
|
||||
if g["obj_class"] in (None, "") and c.get("obj_class"):
|
||||
g["obj_class"] = c.get("obj_class")
|
||||
|
||||
rows: list[list[Any]] = []
|
||||
for c in competitors:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
for key in order:
|
||||
g = groups[key]
|
||||
name = g["name"]
|
||||
if g["corpus"] > 1:
|
||||
name = f"{_fmt(name)} ({g['corpus']} корпусов)"
|
||||
rows.append(
|
||||
[
|
||||
c.get("comm_name"),
|
||||
c.get("dev_name"),
|
||||
c.get("obj_class"),
|
||||
_fmt_int_ru(c.get("distance_m")),
|
||||
_fmt_int_ru(c.get("flat_count")),
|
||||
name,
|
||||
g["dev_name"],
|
||||
g["obj_class"],
|
||||
_fmt_int_ru(g["distance_m"]),
|
||||
_fmt_int_ru(g["lots"]),
|
||||
]
|
||||
)
|
||||
return _data_table(["ЖК", "Девелопер", "Класс", "Расстояние, м", "Лотов"], rows)
|
||||
|
|
@ -949,19 +1206,33 @@ def _build_market_coverage(forecast: dict[str, Any]) -> str:
|
|||
confidence = _fc_as_dict(forecast.get("confidence"))
|
||||
factors = _fc_as_dict(confidence.get("factors"))
|
||||
|
||||
rows: list[list[Any]] = []
|
||||
# «Комментарий» почти всегда дублирует «Фактор» (label==note, либо note ⊃ label) —
|
||||
# тогда две колонки = визуальный шум. Показываем 3-ю колонку ТОЛЬКО если хоть у одного
|
||||
# фактора комментарий несёт что-то сверх метки; иначе схлопываем в «Фактор/уровень».
|
||||
parsed: list[tuple[Any, Any, Any]] = []
|
||||
for _key, payload in factors.items():
|
||||
data = _fc_as_dict(payload)
|
||||
if not data:
|
||||
continue
|
||||
rows.append(
|
||||
[
|
||||
data.get("label") or data.get("note"),
|
||||
_fc_level_ru(data.get("level")),
|
||||
data.get("note"),
|
||||
]
|
||||
)
|
||||
label = data.get("label") or data.get("note")
|
||||
note = data.get("note")
|
||||
parsed.append((label, _fc_level_ru(data.get("level")), note))
|
||||
|
||||
def _note_adds_info(label: Any, note: Any) -> bool:
|
||||
if not isinstance(note, str) or note == "":
|
||||
return False
|
||||
if not isinstance(label, str):
|
||||
return True
|
||||
return note.strip() != label.strip() and label.strip() not in note
|
||||
|
||||
show_note = any(_note_adds_info(label, note) for label, _level, note in parsed)
|
||||
if show_note:
|
||||
rows = [[label, level, note] for label, level, note in parsed]
|
||||
coverage_table = _data_table(["Фактор", "Уровень", "Комментарий"], rows)
|
||||
else:
|
||||
# Комментарий ничего не добавляет → две колонки «Фактор» + «Уровень».
|
||||
rows = [[label, level] for label, level, _note in parsed]
|
||||
coverage_table = _data_table(["Фактор", "Уровень"], rows)
|
||||
|
||||
level_pairs: list[tuple[str, Any]] = [
|
||||
("Итоговая уверенность отчёта", _fc_level_ru(confidence.get("level"))),
|
||||
|
|
@ -1098,7 +1369,7 @@ def _build_market_affordability(forecast: dict[str, Any]) -> str:
|
|||
return ""
|
||||
pairs: list[tuple[str, Any]] = [
|
||||
("Рыночная цена, ₽/м²", _fmt_int_ru(detail.get("price_per_m2"))),
|
||||
("Средний чек лота, ₽", _fmt_money_signed(detail.get("avg_ticket_rub"))),
|
||||
("Средний чек лота, ₽", _fmt_money(detail.get("avg_ticket_rub"))),
|
||||
("Референс-площадь, м²", detail.get("ref_area_m2")),
|
||||
("Индекс избытка предложения", detail.get("oversupply_risk")),
|
||||
]
|
||||
|
|
@ -1220,8 +1491,68 @@ def _build_scenarios_honesty(forecast: dict[str, Any]) -> str:
|
|||
return _kv_table(pairs)
|
||||
|
||||
|
||||
def _build_section_6(forecast: dict[str, Any]) -> str:
|
||||
"""§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы."""
|
||||
def _build_permits_nearby(result: dict[str, Any]) -> str:
|
||||
"""РНС/РВЭ в радиусе 500 м участка (ГИСОГД-66) — короткая сводка + список до 10.
|
||||
|
||||
`result` — analyze-payload (НЕ forecast): читает `permits_nearby` (см.
|
||||
`permits_nearby.get_permits_nearby`). Пусто / total_count=0 → честная плашка-фраза
|
||||
(полное предложение, не аббревиатура). Все динамические строки через `html.escape`.
|
||||
|
||||
#2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby
|
||||
считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30
|
||||
(`items_truncated`). Здесь список дополнительно режется до `_PERMITS_ROW_CAP`
|
||||
(10) для компактности PDF — раньше это резалось МОЛЧА. Дисклоузим разницу
|
||||
`total_count - показано` строкой «и ещё N …», как тепло/вода-таблицы выше
|
||||
(`_build_connection_capacity`).
|
||||
"""
|
||||
nearby = _as_dict(result.get("permits_nearby"))
|
||||
total = nearby.get("total_count")
|
||||
if not isinstance(total, int) or total <= 0:
|
||||
return (
|
||||
'<div class="caveat">В радиусе 500 м участка новых разрешений на '
|
||||
"строительство не найдено (по данным ГИСОГД Свердловской области).</div>"
|
||||
)
|
||||
|
||||
rs_count = nearby.get("rs_count") or 0
|
||||
rv_count = nearby.get("rv_count") or 0
|
||||
nearest = nearby.get("nearest_distance_m")
|
||||
kv = [
|
||||
("Разрешений на строительство (РНС)", _fmt_int_ru(rs_count)),
|
||||
("Разрешений на ввод (РВЭ)", _fmt_int_ru(rv_count)),
|
||||
("Ближайшее, м", _fmt_int_ru(nearest) if nearest is not None else _DASH),
|
||||
]
|
||||
|
||||
rows: list[list[Any]] = []
|
||||
for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]:
|
||||
data = _as_dict(item)
|
||||
rows.append(
|
||||
[
|
||||
data.get("doc_name"),
|
||||
data.get("date_doc"),
|
||||
data.get("approved_organization"),
|
||||
data.get("distance_m"),
|
||||
]
|
||||
)
|
||||
hidden = total - len(rows)
|
||||
if hidden > 0:
|
||||
rows.append(
|
||||
[
|
||||
f"… и ещё {hidden} записей (полный список в веб-версии §6)",
|
||||
_DASH,
|
||||
_DASH,
|
||||
_DASH,
|
||||
]
|
||||
)
|
||||
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
|
||||
return _kv_table(kv) + _data_table(headers, rows)
|
||||
|
||||
|
||||
def _build_section_6(forecast: dict[str, Any], result: dict[str, Any]) -> str:
|
||||
"""§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы.
|
||||
|
||||
`result` — analyze-payload (для блока разрешений рядом, `permits_nearby`); `forecast` —
|
||||
форсайт-ран (дефицит/сценарии). Разные источники — §6 читает оба.
|
||||
"""
|
||||
future = _fc_as_dict(forecast.get("future_market"))
|
||||
summary = future.get("summary")
|
||||
summary_html = f'<p class="verdict">{_esc(summary)}</p>' if summary else ""
|
||||
|
|
@ -1241,6 +1572,9 @@ def _build_section_6(forecast: dict[str, Any]) -> str:
|
|||
|
||||
<h3>Сценарии</h3>
|
||||
{_build_scenarios_honesty(forecast)}
|
||||
|
||||
<h3>Разрешения на строительство рядом (500 м)</h3>
|
||||
{_build_permits_nearby(result)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
@ -1276,9 +1610,7 @@ def _build_concept_program(variant: dict[str, Any]) -> str:
|
|||
if not order:
|
||||
return ""
|
||||
rows = [[stype, floors, groups[(stype, floors)]] for stype, floors in order]
|
||||
return (
|
||||
"<h3>Программа застройки</h3>" f"{_data_table(['Тип дома', 'Этажность', 'Секций'], rows)}"
|
||||
)
|
||||
return f"<h3>Программа застройки</h3>{_data_table(['Тип дома', 'Этажность', 'Секций'], rows)}"
|
||||
|
||||
|
||||
def _build_concept_variant(variant: dict[str, Any]) -> str:
|
||||
|
|
@ -1374,6 +1706,7 @@ def build_full_report_html_part_b(
|
|||
concept_result: dict[str, Any] | None,
|
||||
*,
|
||||
cad: str,
|
||||
analyze_result: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Собрать HTML Part B полного отчёта: §4 «Рынок» + §5 «Финмодель» + §6 «Риски» + §7.
|
||||
|
||||
|
|
@ -1392,6 +1725,9 @@ def build_full_report_html_part_b(
|
|||
§7 рисует честную заметку «концепция не рассчитана» (§5 — только рыночный
|
||||
контекст цены).
|
||||
cad: кадастровый номер участка (для логов; в HTML приходит через каркас).
|
||||
analyze_result: analyze-payload (`analysis_runs.result` analyze-рана) — источник
|
||||
блока «разрешения рядом» §6 (`permits_nearby`). None / не-dict → блок рисует
|
||||
честную плашку «в радиусе 500 м разрешений не найдено».
|
||||
|
||||
Returns:
|
||||
HTML-фрагмент Part B (четыре `<div class="section">`), готовый как `part_b_html`
|
||||
|
|
@ -1401,7 +1737,7 @@ def build_full_report_html_part_b(
|
|||
part_b = (
|
||||
_build_section_4(forecast)
|
||||
+ _build_section_5(forecast, _as_dict(concept_result))
|
||||
+ _build_section_6(forecast)
|
||||
+ _build_section_6(forecast, _as_dict(analyze_result))
|
||||
+ _build_section_7(concept_result)
|
||||
)
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ volume `/app/reports/` с метадата-строкой в `analysis_runs` (sc
|
|||
ПОТОК (: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).
|
||||
2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) — нет → best-effort
|
||||
СИНХРОННО считаем его тут же (`_ensure_forecast_run`, зеркало Celery-таски форсайта,
|
||||
~20–30с) и перечитываем; всё ещё нет (сбой/тонкие данные) → Part B (§4–§6) деградирует
|
||||
«нет данных», отчёт всё равно валиден (передаём {} в part_b).
|
||||
3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с
|
||||
теми же id уже есть И файл на месте → cache-hit, PDF не пере-рендерим.
|
||||
4. connection-capacity (`get_connection_capacity`) — best-effort, для §3-резервов.
|
||||
|
|
@ -17,8 +19,11 @@ volume `/app/reports/` с метадата-строкой в `analysis_runs` (sc
|
|||
→ отчёт без §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).
|
||||
6b. DOCX-вариант (PR-F, `build_full_report_docx`) из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ
|
||||
карт-PNG (НЕ рендерим карты дважды) — рядом `.docx`-файл.
|
||||
7. Запись файлов (PDF + DOCX, атомарно tmp+os.replace) + метадата-ран `report-pdf-1.0`
|
||||
(result = pdf_path/docx_path/analyze_run_id/forecast_run_id/generated_at/size_bytes/
|
||||
docx_size_bytes). Старые раны без docx_path → download?format=docx отдаёт 404.
|
||||
|
||||
WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native —
|
||||
ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`).
|
||||
|
|
@ -41,6 +46,7 @@ from app.services.analysis_runs.repository import (
|
|||
latest_run_for,
|
||||
persist_analysis_run,
|
||||
)
|
||||
from app.services.exporters.full_report_docx import build_full_report_docx
|
||||
from app.services.exporters.full_report_html import (
|
||||
MAP_CONCEPT_PLACEHOLDER,
|
||||
MAP_PARCEL_PLACEHOLDER,
|
||||
|
|
@ -64,6 +70,12 @@ REPORT_SCHEMA_VERSION = "report-pdf-1.0"
|
|||
# "forecast-1.0", а именно "1.0", это SiteFinderReport._SCHEMA_VERSION).
|
||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||
|
||||
# Горизонты best-effort синхронного форсайта (мес). Зеркало Celery-таски
|
||||
# `forecast_site_finder_report(horizon=12)`: `_horizons_for(12)` = sorted({6,12,18,24}|{12})
|
||||
# = [6,12,18,24] (forecast.py) = orchestrator._DEFAULT_HORIZONS. Держим тот же набор,
|
||||
# чтобы «холодный» участок получил §4–§6 идентичные ленивому GET /forecast-пути.
|
||||
_FORECAST_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
|
||||
|
||||
# Верхняя граница длинной стороны карт-PNG (px) — печатный A4, 1400 достаточно для
|
||||
# ~150 dpi на ширину колонки, но не раздувает PDF гигабайтными растрами.
|
||||
_MAP_MAX_PX = 1400
|
||||
|
|
@ -93,6 +105,43 @@ def render_full_report_pdf(html: str) -> bytes:
|
|||
return pdf_bytes
|
||||
|
||||
|
||||
def _largest_polygon_geojson(geom: dict[str, Any]) -> dict[str, Any]:
|
||||
"""MultiPolygon → GeoJSON крупнейшего полигона-контура; Polygon и прочее — как есть.
|
||||
|
||||
Многоконтурный участок приходит как MultiPolygon, а concept-стек (`parse_parcel` +
|
||||
`_parcel_centroid_wkt`) принимает только Polygon → `ParcelGeometryError`. Берём
|
||||
контур с максимальной площадью (тот же приём, что generative-геометрия применяет к
|
||||
buildable-мультиполигону после буфера, geometry.py:263-265). Любой сбой парсинга /
|
||||
не-MultiPolygon → возвращаем geom без изменений (best-effort, не роняем концепцию).
|
||||
|
||||
Args:
|
||||
geom: GeoJSON-геометрия участка (Polygon / MultiPolygon / Feature-обёртка).
|
||||
|
||||
Returns:
|
||||
GeoJSON Polygon крупнейшего контура (если вход был MultiPolygon), иначе `geom`.
|
||||
"""
|
||||
geom_dict = geom.get("geometry") if geom.get("type") == "Feature" else geom
|
||||
if not isinstance(geom_dict, dict) or geom_dict.get("type") != "MultiPolygon":
|
||||
return geom
|
||||
|
||||
try:
|
||||
from shapely.geometry import mapping, shape
|
||||
|
||||
multi = shape(geom_dict)
|
||||
if multi.is_empty or not hasattr(multi, "geoms"):
|
||||
return geom
|
||||
largest = max(multi.geoms, key=lambda g: g.area)
|
||||
logger.info(
|
||||
"build_full_report: multi-contour участок — взят крупнейший контур из %d",
|
||||
len(list(multi.geoms)),
|
||||
)
|
||||
return dict(mapping(largest))
|
||||
except Exception:
|
||||
# Вырожденная/битая геометрия — отдаём как есть, concept-стек сам решит (best-effort).
|
||||
logger.exception("build_full_report: не удалось выделить крупнейший контур MultiPolygon")
|
||||
return geom
|
||||
|
||||
|
||||
def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Сгенерировать концепцию server-side как это делает POST /concepts (best-effort).
|
||||
|
||||
|
|
@ -115,6 +164,13 @@ def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str,
|
|||
logger.info("build_full_report: analyze-payload без geom_geojson → §7-концепция пропущена")
|
||||
return None
|
||||
|
||||
# Многоконтурный участок → geom = MultiPolygon, а concept-стек (parse_parcel +
|
||||
# _parcel_centroid_wkt через _parse_polygon) принимает ТОЛЬКО Polygon и роняет
|
||||
# ParcelGeometryError("expected Polygon, got MultiPolygon"). Берём крупнейший контур —
|
||||
# ровно как generative-геометрия после буфера (geometry.py:263-265). Иначе §7 и
|
||||
# market-price молча деградируют на любом мультиконтуре.
|
||||
geom = _largest_polygon_geojson(geom)
|
||||
|
||||
try:
|
||||
# Lazy import — тяжёлый generative-стек не нужен на module-load; concepts-хелперы
|
||||
# цены живут в API-слое (он знает БД), переиспользуем ИМЕННО их (single source).
|
||||
|
|
@ -163,6 +219,82 @@ def _get_connection_capacity(db: Session, cad: str) -> dict[str, Any] | None:
|
|||
return None
|
||||
|
||||
|
||||
def _ensure_forecast_run(
|
||||
db: Session,
|
||||
cad: str,
|
||||
analyze_row: Any,
|
||||
analyze: dict[str, Any],
|
||||
) -> None:
|
||||
"""Best-effort синхронно посчитать §22-форсайт для «холодного» участка (#2259 gap).
|
||||
|
||||
§22-форсайт-ран ("1.0") существует ТОЛЬКО если пользователь открывал страницу участка
|
||||
(§22 строится лениво GET /forecast-поллингом Celery-таской `forecast_site_finder_report`).
|
||||
На холодном участке полный отчёт выходил БЕЗ §4–§6. Здесь — тот же compute+persist, что
|
||||
делает Celery-таска, но СИНХРОННО и ПРЯМЫМ вызовом: мы УЖЕ внутри worker'а
|
||||
(build_full_report_task), поэтому НЕ .delay — считаем inline (~20–30с) и персистим тем же
|
||||
контрактом, чтобы последующий `latest_run_for("1.0")` в build_full_report поймал свежий ран
|
||||
(его новый id корректно войдёт в кэш-ключ отчёта → cache-miss → §4–§6 попадут в PDF).
|
||||
|
||||
Зеркало `forecast_site_finder_report(horizon=12)` (workers/tasks/forecast.py):
|
||||
• horizons = `_FORECAST_HORIZONS` (= `_horizons_for(12)` = [6,12,18,24]);
|
||||
• district = денорм-колонка рана → fallback analyze["district"]["district_name"];
|
||||
• `build_site_finder_report(...)` → `report.as_dict()` → `persist_analysis_run(...,
|
||||
schema_version=d["schema_version"], status="done", ...)`.
|
||||
|
||||
Best-effort: ЛЮБОЙ сбой/долгий compute → logger.warning + return (НЕ exception — форсайт
|
||||
может честно не собраться на тонких данных, GlitchTip-шум не нужен). Тогда отчёт, как и
|
||||
раньше, выйдет без §4–§6 (part_b деградирует «нет данных»).
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session (та же, что у build_full_report — свою НЕ открываем).
|
||||
cad: кадастровый номер участка.
|
||||
analyze_row: Row analyze-рана (несёт денорм `district`).
|
||||
analyze: persist-payload analyze-рана (district-fallback + competitors для сегмента).
|
||||
"""
|
||||
try:
|
||||
# Lazy import — тяжёлый forecasting-стек не нужен на module-load (как concept-стек).
|
||||
from app.services.forecasting.orchestrator import build_site_finder_report
|
||||
|
||||
district = analyze_row.district or (analyze.get("district") or {}).get("district_name")
|
||||
logger.info(
|
||||
"build_full_report: холодный участок cad=%s — best-effort синхронный §22-форсайт "
|
||||
"(district=%s horizons=%s)",
|
||||
cad,
|
||||
district,
|
||||
_FORECAST_HORIZONS,
|
||||
)
|
||||
report = build_site_finder_report(
|
||||
db,
|
||||
analyze=analyze,
|
||||
cad_num=cad,
|
||||
district=district,
|
||||
horizons=_FORECAST_HORIZONS,
|
||||
)
|
||||
d = report.as_dict()
|
||||
new_id = persist_analysis_run(
|
||||
db,
|
||||
cad_num=cad,
|
||||
result=d,
|
||||
params={"horizon": 12, "source": "full-report-inline-forecast"},
|
||||
district=district,
|
||||
confidence=(d.get("confidence") or {}).get("level"),
|
||||
status="done",
|
||||
schema_version=d["schema_version"], # "1.0" (SiteFinderReport._SCHEMA_VERSION)
|
||||
created_by=None,
|
||||
segment=(d.get("meta") or {}).get("segment"),
|
||||
)
|
||||
logger.info("build_full_report: §22-форсайт посчитан inline cad=%s run_id=%s", cad, new_id)
|
||||
except Exception:
|
||||
# Форсайт может честно не собраться (тонкие данные / сбой §9.x-шва) или занять
|
||||
# слишком долго — деградируем в отчёт без §4–§6 (warning, НЕ exception: держим
|
||||
# GlitchTip-шум в узде, ведёт себя как ленивая Celery-таска, которая тоже best-effort).
|
||||
logger.warning(
|
||||
"build_full_report: inline §22-форсайт не собрался cad=%s → отчёт без §4–§6",
|
||||
cad,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _find_cached_report(
|
||||
db: Session,
|
||||
cad: str,
|
||||
|
|
@ -199,6 +331,19 @@ def _cad_safe(cad: str) -> str:
|
|||
return re.sub(r"[^0-9:]", "", cad).replace(":", "_")
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: bytes) -> None:
|
||||
"""Атомарно записать байты в `path`: `.<pid>.tmp` рядом → `os.replace`.
|
||||
|
||||
Два конкурентных POST в один день целятся в ОДИН путь (имя несёт только дату) —
|
||||
прямой `write_bytes` мог бы interleave-писать байты обоих рендеров в один файл
|
||||
(битый вывод). `os.replace` атомарен в пределах одной FS → download всегда видит
|
||||
целый файл (свой или чужой). Общий для PDF и DOCX (тот же приём).
|
||||
"""
|
||||
tmp_path = path.with_suffix(f".{os.getpid()}.tmp")
|
||||
tmp_path.write_bytes(data)
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
|
||||
def build_full_report(db: Session, cad: str) -> dict[str, Any]:
|
||||
"""Собрать (или вернуть из кэша) полный PDF-отчёт участка + метадата-ран. #2259 PR-D.
|
||||
|
||||
|
|
@ -225,6 +370,14 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
|
|||
analyze_run_id = int(analyze_row.id)
|
||||
|
||||
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||
if forecast_row is None:
|
||||
# Холодный участок: §22-форсайт-ран ("1.0") строится лениво GET /forecast-поллингом
|
||||
# и на не-открытом участке отсутствует → отчёт выходил без §4–§6. Best-effort
|
||||
# считаем его СИНХРОННО прямо тут (мы в worker'е) и перечитываем — самодостаточность
|
||||
# отчёта важнее +20–30с (как §7-концепция генерится в оркестраторе). Сбой/долго →
|
||||
# forecast_row остаётся None, part_b деградирует «нет данных» (как раньше).
|
||||
_ensure_forecast_run(db, cad, analyze_row, analyze)
|
||||
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||
forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {}
|
||||
forecast_run_id = int(forecast_row.id) if forecast_row is not None else None
|
||||
|
||||
|
|
@ -254,7 +407,7 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
|
|||
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)
|
||||
part_b = build_full_report_html_part_b(forecast, concept, cad=cad, analyze_result=analyze)
|
||||
html = build_full_report_html(
|
||||
part_a,
|
||||
part_b,
|
||||
|
|
@ -279,30 +432,44 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
|
|||
|
||||
pdf_bytes = render_full_report_pdf(html)
|
||||
|
||||
# Запись файла на volume + метадата-ран. Каталог создаём (parents, exist_ok).
|
||||
# DOCX-вариант (PR-F): из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ карт-PNG (parcel_png /
|
||||
# concept_png уже отрендерены выше — НЕ рендерим карты дважды). Зеркалит §1–§7 PDF.
|
||||
docx_bytes = build_full_report_docx(
|
||||
analyze,
|
||||
forecast,
|
||||
concept,
|
||||
connection_capacity,
|
||||
cad=cad,
|
||||
address=address if isinstance(address, str) else None,
|
||||
generated_at=generated_at_ru,
|
||||
parcel_map_png=parcel_png,
|
||||
concept_map_png=concept_png,
|
||||
)
|
||||
|
||||
# Запись файлов на volume + метадата-ран. Каталог создаём (parents, exist_ok).
|
||||
reports_dir = Path(settings.reports_dir)
|
||||
reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
base_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}"
|
||||
pdf_path = reports_dir / f"{base_name}.pdf"
|
||||
docx_path = reports_dir / f"{base_name}.docx"
|
||||
# Атомарная запись обоих файлов (tmp+os.replace — см. `_atomic_write`).
|
||||
_atomic_write(pdf_path, pdf_bytes)
|
||||
_atomic_write(docx_path, docx_bytes)
|
||||
size_bytes = len(pdf_bytes)
|
||||
docx_size_bytes = len(docx_bytes)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pdf_path": str(pdf_path),
|
||||
"docx_path": str(docx_path),
|
||||
"analyze_run_id": analyze_run_id,
|
||||
"forecast_run_id": forecast_run_id,
|
||||
"generated_at": generated_at.isoformat(),
|
||||
"size_bytes": size_bytes,
|
||||
"docx_size_bytes": docx_size_bytes,
|
||||
}
|
||||
|
||||
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — PDF
|
||||
# уже записан, просто следующий вызов не поймает cache-hit и пере-рендерит).
|
||||
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — файлы
|
||||
# уже записаны, просто следующий вызов не поймает cache-hit и пере-рендерит).
|
||||
persist_analysis_run(
|
||||
db,
|
||||
cad_num=cad,
|
||||
|
|
@ -315,10 +482,12 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
|
|||
created_by=None,
|
||||
)
|
||||
logger.info(
|
||||
"build_full_report: cad=%s written path=%s size=%d analyze=%s forecast=%s",
|
||||
"build_full_report: cad=%s pdf=%s (%d B) docx=%s (%d B) analyze=%s forecast=%s",
|
||||
cad,
|
||||
pdf_path,
|
||||
size_bytes,
|
||||
docx_path,
|
||||
docx_size_bytes,
|
||||
analyze_run_id,
|
||||
forecast_run_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ from typing import Any
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
|
||||
# Тот же набор из шести содержательных секций, что рисует excel.py (#991).
|
||||
# Шесть секций зеркалят excel.py (#991); §13.7 «Уверенность» — доп. секция, портирована
|
||||
# из report_docx/report_md для parity между экспортёрами (audit epic #2445 item C2;
|
||||
# excel.py её пока не несёт — отдельный gap, вне scope этой правки).
|
||||
|
||||
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
|
||||
_TITLE_SUMMARY: str = "Сводка"
|
||||
|
|
@ -41,6 +43,7 @@ _TITLE_FUTURE_MARKET: str = "Будущий рынок"
|
|||
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
|
||||
_TITLE_SCENARIOS: str = "Сценарии"
|
||||
_TITLE_SCORING: str = "Скоринг"
|
||||
_TITLE_CONFIDENCE: str = "Уверенность"
|
||||
|
||||
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
|
||||
|
||||
|
|
@ -308,15 +311,21 @@ def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _build_summary(report: dict[str, Any]) -> str:
|
||||
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст."""
|
||||
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст.
|
||||
|
||||
Уровень уверенности здесь — только сводный badge (`overall_confidence`), зеркало
|
||||
report_docx/report_md._build_summary. Полный разбор (rationale + факторы-драйверы)
|
||||
живёт в отдельной секции §13.7 «Уверенность» (`_build_confidence`) — раньше
|
||||
(до parity-фикса #2445 C2) он дублировался здесь тонкой 2-строчной таблицей, что
|
||||
расходилось с docx/md и не переживало dict-значный фактор; убрано, чтобы не было
|
||||
двух версий одних и тех же данных в одном документе.
|
||||
"""
|
||||
exec_summary = _as_dict(report.get("exec_summary"))
|
||||
meta = _as_dict(report.get("meta"))
|
||||
confidence = _as_dict(report.get("confidence"))
|
||||
|
||||
headline = exec_summary.get("headline")
|
||||
verdict = exec_summary.get("verdict")
|
||||
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
||||
factors = _as_dict(confidence.get("factors"))
|
||||
|
||||
cad = _esc(meta.get("cad_num"))
|
||||
district = _esc(meta.get("district"))
|
||||
|
|
@ -329,10 +338,6 @@ def _build_summary(report: dict[str, Any]) -> str:
|
|||
("Сформировано", meta.get("generated_at")),
|
||||
("Версия схемы", meta.get("schema_version")),
|
||||
]
|
||||
confidence_pairs: list[tuple[str, Any]] = [
|
||||
("Уровень", _level_ru(confidence.get("level"))),
|
||||
("Обоснование", confidence.get("rationale")),
|
||||
]
|
||||
overall_conf = _esc(_level_ru(exec_summary.get("overall_confidence")))
|
||||
|
||||
return f"""
|
||||
|
|
@ -351,12 +356,6 @@ def _build_summary(report: dict[str, Any]) -> str:
|
|||
<h3>Ключевые числа</h3>
|
||||
{_dict_kv_table(key_numbers)}
|
||||
|
||||
<h3>Уверенность отчёта</h3>
|
||||
{_kv_table(confidence_pairs)}
|
||||
|
||||
<h3>Факторы уверенности</h3>
|
||||
{_dict_kv_table(factors)}
|
||||
|
||||
<h3>Контекст</h3>
|
||||
{_kv_table(context_pairs)}
|
||||
</div>
|
||||
|
|
@ -624,6 +623,43 @@ def _build_scoring(report: dict[str, Any]) -> str:
|
|||
|
||||
<h3>Специальные индексы</h3>
|
||||
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _build_confidence(report: dict[str, Any]) -> str:
|
||||
"""§13.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица).
|
||||
|
||||
Parity fix (audit epic #2445 item C2): report_docx/report_md уже несли эту секцию
|
||||
(§22.7/§13.7) — PDF был единственным экспортёром без нее. Портировано 1-в-1 (та же
|
||||
4-колоночная таблица «Фактор/Значение/Уровень/Комментарий»), но через HTML-примитивы
|
||||
report_pdf (`_data_table`), а не python-docx/Markdown API.
|
||||
"""
|
||||
confidence = _as_dict(report.get("confidence"))
|
||||
level = _level_ru(confidence.get("level"))
|
||||
rationale = confidence.get("rationale")
|
||||
factors = _as_dict(confidence.get("factors"))
|
||||
|
||||
# Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive:
|
||||
# если значение — dict, раскладываем на value/level/note; иначе кладём как есть
|
||||
# (зеркало report_docx._build_confidence / report_md._build_confidence).
|
||||
factor_rows: list[list[Any]] = []
|
||||
for name, payload in factors.items():
|
||||
if isinstance(payload, dict):
|
||||
factor_rows.append(
|
||||
[name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")]
|
||||
)
|
||||
else:
|
||||
factor_rows.append([name, payload, _DASH, _DASH])
|
||||
|
||||
return f"""
|
||||
<div class="section" id="confidence">
|
||||
<h2>{html.escape(_TITLE_CONFIDENCE)}</h2>
|
||||
<span class="badge">Уровень: {_esc(level)}</span>
|
||||
<p class="verdict">{_esc(rationale)}</p>
|
||||
|
||||
<h3>Факторы уверенности</h3>
|
||||
{_data_table(["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)}
|
||||
|
||||
<div class="footer">{html.escape(_FOOTER_NOTE)}</div>
|
||||
</div>
|
||||
|
|
@ -631,7 +667,8 @@ def _build_scoring(report: dict[str, Any]) -> str:
|
|||
|
||||
|
||||
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
|
||||
# `_SHEET_BUILDERS` у excel.py — тот же набор из шести содержательных секций §13).
|
||||
# `_SHEET_BUILDERS` у excel.py + report_docx/report_md — Сводка → Рынок сейчас →
|
||||
# Будущий рынок → Продукт ТЗ → Сценарии → Скоринг → Уверенность §13.7, #2445 C2).
|
||||
_SECTION_BUILDERS: tuple[Any, ...] = (
|
||||
_build_summary,
|
||||
_build_market_now,
|
||||
|
|
@ -639,11 +676,12 @@ _SECTION_BUILDERS: tuple[Any, ...] = (
|
|||
_build_product_tz,
|
||||
_build_scenarios,
|
||||
_build_scoring,
|
||||
_build_confidence,
|
||||
)
|
||||
|
||||
|
||||
def _build_html(report: dict[str, Any]) -> str:
|
||||
"""Склеить HTML-документ из шести секций §13. PURE (только строки). Graceful."""
|
||||
"""Склеить HTML-документ из семи секций §13 (+ §13.7 Уверенность). PURE. Graceful."""
|
||||
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
|
@ -666,10 +704,10 @@ def export_report_pdf(report: Any) -> bytes:
|
|||
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
|
||||
|
||||
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
|
||||
рынок / Продукт ТЗ / Сценарии / Скоринг — тот же набор, что и `export_report_xlsx`).
|
||||
Шапки таблиц с заливкой, RU-метки, числа округлены, None → "—". На блоке «Сводка» —
|
||||
заметный ADVISORY-маркер (отчёт советующий). ВСЕ динамические строки экранируются
|
||||
`html.escape`.
|
||||
рынок / Продукт ТЗ / Сценарии / Скоринг / Уверенность §13.7 — parity с
|
||||
report_docx/report_md, #2445 C2). Шапки таблиц с заливкой, RU-метки, числа
|
||||
округлены, None → "—". На блоке «Сводка» — заметный ADVISORY-маркер (отчёт
|
||||
советующий). ВСЕ динамические строки экранируются `html.escape`.
|
||||
|
||||
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
|
||||
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/
|
||||
|
|
|
|||
|
|
@ -371,8 +371,16 @@ def _month_grid(start: date, end: date) -> list[date]:
|
|||
|
||||
|
||||
def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
|
||||
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}."""
|
||||
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #2): `db` — общая §22-сессия отчёта; при сбое
|
||||
этого запроса БЕЗ SAVEPOINT транзакция Postgres остаётся aborted и следующие
|
||||
запросы get_monthly_macro (inflation, mortgage) + все ПОСЛЕДУЮЩИЕ §9.x-слои на
|
||||
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
|
||||
SAVEPOINT (ROLLBACK TO SAVEPOINT), внешняя транзакция остаётся рабочей.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
_KEY_RATE_MONTHLY_SQL,
|
||||
{"itype": "key_rate", "region": "rf", "since": since},
|
||||
|
|
@ -389,8 +397,13 @@ def _query_inflation_monthly(db: Session, *, since: date) -> dict[date, float]:
|
|||
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) — берём как есть
|
||||
через reuse get_macro_series (свой SQL не пишем). _month_start — страховка.
|
||||
Сбой/пустой ряд → {} (НЕ crash), inflation_yoy тогда None по всей сетке.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #2): см. `_query_key_rate_monthly` — та же
|
||||
общая §22-сессия, тот же риск отравления транзакции для последующих запросов
|
||||
(mortgage-поля + §9.x-слои). `with db.begin_nested():` изолирует сбой в SAVEPOINT.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
|
||||
except Exception:
|
||||
logger.exception("get_monthly_macro: inflation_yoy query failed")
|
||||
|
|
@ -404,10 +417,19 @@ def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date,
|
|||
Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу
|
||||
в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда
|
||||
не валит остальные (graceful: пустой подсловарь).
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #2): все 5 полей читаются на ОДНОЙ `db`-Session
|
||||
(get_monthly_macro вызывается внутри общей §22-сессии отчёта). Без SAVEPOINT сбой
|
||||
ОДНОГО поля оставляет транзакцию Postgres aborted — каждое СЛЕДУЮЩЕЕ поле в этом
|
||||
же цикле тоже падает (хотя его данные были бы доступны), а `except` здесь молча
|
||||
отдаёт [] по каждому, маскируя каскад под «нормальную» построчную деградацию.
|
||||
`with db.begin_nested():` — SAVEPOINT на КАЖДОЕ поле: сбой откатывает только его
|
||||
SAVEPOINT (ROLLBACK TO SAVEPOINT), сессия остаётся рабочей для следующего поля.
|
||||
"""
|
||||
out: dict[str, dict[date, float]] = {}
|
||||
for indicator_type, field in _MORTGAGE_FIELDS:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
|
||||
except Exception:
|
||||
logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type)
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ def _primary_horizon(horizons: Sequence[int]) -> int:
|
|||
return horizons[0] if horizons else _PREFERRED_PRIMARY_HORIZON
|
||||
|
||||
|
||||
def _safe_call(label: str, fn: Any) -> Any:
|
||||
def _safe_call(label: str, db: Session, fn: Any) -> Any:
|
||||
"""Вызвать §9.x-сервис graceful: сбой → None + logger.exception (не crash отчёта).
|
||||
|
||||
Зеркало product_scoring._safe_call: любой §9.x-слой может бросить (тонкие данные / нет
|
||||
|
|
@ -133,14 +133,28 @@ def _safe_call(label: str, fn: Any) -> Any:
|
|||
широкий Exception (изоляция одного слоя от отчёта) с ОБЯЗАТЕЛЬНЫМ logger.exception —
|
||||
НЕ молчаливое глотание. §9.x уже graceful внутри; это belt-and-suspenders на шве.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #1): все §9.x-слои шарят ОДИН `db`-Session на
|
||||
отчёт (module docstring `forecast_request_cache.py`). Без обёртки сбойный
|
||||
`db.execute` внутри слоя оставляет транзакцию Postgres в состоянии aborted
|
||||
(«current transaction is aborted, commands ignored until end of transaction
|
||||
block») — КАЖДЫЙ последующий слой на той же сессии тоже падает, хотя его данные
|
||||
были бы доступны. `with db.begin_nested():` заводит SAVEPOINT НА ВЕСЬ вызов слоя
|
||||
(слой может делать несколько `db.execute` внутри себя — например §9.6 внутри
|
||||
§9.8/§11); при исключении SAVEPOINT откатывается автоматически (ROLLBACK TO
|
||||
SAVEPOINT), внешняя транзакция остаётся рабочей для следующего слоя. НЕ
|
||||
`db.rollback()` — тот откатил бы ВСЮ внешнюю транзакцию (см. `backend.md` §
|
||||
SAVEPOINT pattern, established anti-pattern).
|
||||
|
||||
Args:
|
||||
label: имя слоя для лога (диагностика какой §9.x-вызов деградировал).
|
||||
db: общая §22-сессия (для SAVEPOINT вокруг вызова слоя).
|
||||
fn: нулевой-аргумент thunk вокруг §9.x-вызова.
|
||||
|
||||
Returns:
|
||||
Результат `fn()` или None при исключении.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
return fn()
|
||||
except Exception:
|
||||
logger.exception("orchestrator: §9.x layer %s failed → section degraded", label)
|
||||
|
|
@ -334,21 +348,25 @@ def _build_site_finder_report_impl(
|
|||
# ── 2. §9.x-слои, каждый graceful через _safe_call (ГЕТЕРОГЕННЫЕ сигнатуры) ──
|
||||
market_metrics = _safe_call(
|
||||
"market_metrics",
|
||||
db,
|
||||
lambda: compute_market_metrics(db, district=district, premise_kind=_PREMISE_KIND),
|
||||
)
|
||||
supply_rows = _safe_call(
|
||||
"supply_layers",
|
||||
db,
|
||||
lambda: compute_all_layers(db, district=district, premise_kind=_PREMISE_KIND),
|
||||
)
|
||||
supply_layers = _summarize_supply_layers(supply_rows)
|
||||
future_supply = _safe_call(
|
||||
"future_supply",
|
||||
db,
|
||||
lambda: compute_future_supply_pressure(
|
||||
db, district=district, horizon_months=primary, premise_kind=_PREMISE_KIND
|
||||
),
|
||||
)
|
||||
forecasts = _safe_call(
|
||||
"demand_supply_forecast",
|
||||
db,
|
||||
lambda: compute_demand_supply_forecast(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
|
|
@ -359,6 +377,7 @@ def _build_site_finder_report_impl(
|
|||
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
|
||||
scenarios_result = _safe_call(
|
||||
"scenarios",
|
||||
db,
|
||||
lambda: compute_scenarios(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
|
|
@ -371,18 +390,21 @@ def _build_site_finder_report_impl(
|
|||
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
|
||||
product_scores = _safe_call(
|
||||
"score_card",
|
||||
db,
|
||||
lambda: compute_score_card(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizon_months=primary
|
||||
),
|
||||
)
|
||||
special_indices = _safe_call(
|
||||
"special_indices",
|
||||
db,
|
||||
lambda: compute_special_indices(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
)
|
||||
recommendation_overlay = _safe_call(
|
||||
"forecast_overlay",
|
||||
db,
|
||||
lambda: build_forecast_overlay(
|
||||
db,
|
||||
district=district,
|
||||
|
|
@ -393,7 +415,7 @@ def _build_site_finder_report_impl(
|
|||
)
|
||||
|
||||
# ── Макро-свежесть (audit MEDIUM): только лог; проводка в отчёт — 3b ─────────
|
||||
macro = _safe_call("monthly_macro", lambda: get_monthly_macro(db))
|
||||
macro = _safe_call("monthly_macro", db, lambda: get_monthly_macro(db))
|
||||
macro_as_of = _macro_as_of(macro)
|
||||
if macro_as_of is not None:
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -835,9 +835,11 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
|||
compute_poi_weighted_top7. Нет геометрии / нет POI / сбой → None (infra_fit unavailable).
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
coords = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL, {"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)}
|
||||
_PARCEL_CENTROID_SQL,
|
||||
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
|
|
@ -850,6 +852,7 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
|||
if not coords or coords.get("lat") is None or coords.get("lon") is None:
|
||||
return None
|
||||
try:
|
||||
with db.begin_nested():
|
||||
response = compute_poi_weighted_top7(
|
||||
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
|
||||
)
|
||||
|
|
|
|||
|
|
@ -549,6 +549,12 @@ def _query_source_a(
|
|||
|
||||
Graceful → {} при сбое/пустых данных. price_bucket в spec для Source A
|
||||
игнорируется (агрегат не несёт per-lot цены) — фиксируется логом.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #4): `db` — общая §22-сессия отчёта (может
|
||||
переиспользоваться другими §9.x-слоями после этого вызова). Без SAVEPOINT сбойный
|
||||
`db.execute` оставляет транзакцию Postgres aborted — все ПОСЛЕДУЮЩИЕ запросы на
|
||||
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
|
||||
SAVEPOINT при сбое, оставляя внешнюю транзакцию рабочей.
|
||||
"""
|
||||
if spec.price_bucket is not None:
|
||||
logger.info(
|
||||
|
|
@ -567,6 +573,7 @@ def _query_source_a(
|
|||
"room_bucket": spec.room_bucket,
|
||||
}
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("build_sales_series: source A query failed")
|
||||
|
|
@ -582,6 +589,9 @@ def _query_source_b(
|
|||
Graceful → {} при сбое/пустых данных. Передаёт bucket-пороги/-метки в SQL
|
||||
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
|
||||
правилом и в БД, и в Python.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #4): см. `_query_source_a` — та же общая
|
||||
§22-сессия, тот же риск отравления транзакции для последующих слоёв/запросов.
|
||||
"""
|
||||
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
|
||||
# None → EKB-wide (без district-фильтра).
|
||||
|
|
@ -613,6 +623,7 @@ def _query_source_b(
|
|||
"p_unknown": PRICE_BUCKET_UNKNOWN,
|
||||
}
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("build_sales_series: source B query failed")
|
||||
|
|
|
|||
|
|
@ -994,8 +994,21 @@ def _query_parcel_centroid(db: Session, *, cad_num: str) -> tuple[float, float]
|
|||
|
||||
Нет геометрии / сбой → None (гео-веса упадут на floor; overlap считается по остальным
|
||||
осям, индекс НЕ деградирует целиком). Параметризовано (psycopg v3). Детерминированно.
|
||||
|
||||
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): этот helper
|
||||
зовётся из `_build_cannibalization` — builder, обёрнутый внешним SAVEPOINT в
|
||||
`compute_special_indices._run`. БЕЗ собственного `db.begin_nested():` сбойный
|
||||
`db.execute` тут проглатывается локально (→ None), оставляя транзакцию Postgres
|
||||
aborted; тогда ВНЕШНИЙ `_run`-savepoint выходит «нормально» и делает RELEASE
|
||||
SAVEPOINT, а RELEASE в aborted-tx САМ падает (Postgres в aborted допускает только
|
||||
ROLLBACK / ROLLBACK TO SAVEPOINT) — внешний except ловит уже RELEASE-ошибку, но
|
||||
транзакция так и не откачена → отравление каскадит в следующий §25-индекс. Savepoint
|
||||
ИМЕННО ЗДЕСЬ (в точке перехвата) откатывает сбой (ROLLBACK TO SAVEPOINT), поэтому
|
||||
внешний RELEASE проходит. Тот же принцип, что saturation.py: savepoint у db.execute
|
||||
в точке, где ошибка ловится.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
row = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL,
|
||||
|
|
@ -1657,8 +1670,19 @@ def compute_special_indices(
|
|||
segment = spec.as_dict()
|
||||
|
||||
def _run(key: str, builder: Any) -> SpecialIndex:
|
||||
"""Выполнить builder одного индекса в собственном try/except (graceful)."""
|
||||
"""Выполнить builder одного индекса в собственном try/except (graceful).
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #3): все шесть builder'ов делят ОДНУ `db`-
|
||||
Session (общая сессия §22-отчёта). Без SAVEPOINT сбойный `db.execute` внутри
|
||||
builder'а оставляет транзакцию Postgres aborted — КАЖДЫЙ следующий индекс на
|
||||
той же сессии тоже падает (хотя его данные были бы доступны), что маскируется
|
||||
под шесть независимых деградаций. `with db.begin_nested():` заводит SAVEPOINT
|
||||
на ВЕСЬ builder() (некоторые builder'ы делают несколько db.execute внутри
|
||||
себя) — сбой откатывает только его SAVEPOINT, сессия остаётся рабочей для
|
||||
следующего индекса.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
return builder() # type: ignore[no-any-return]
|
||||
except Exception:
|
||||
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ import logging
|
|||
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.filemanagement import new as ezdxf_new
|
||||
|
||||
# Modelspace определён в ezdxf.layouts.layout и не в __all__ пакета ezdxf.layouts;
|
||||
# импорт из модуля-определителя удовлетворяет strict no-implicit-reexport (как ezdxf_new).
|
||||
from ezdxf.layouts.layout import Modelspace
|
||||
from shapely.coords import CoordinateSequence
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from app.schemas.concept import ConceptVariant
|
||||
|
|
@ -44,7 +49,7 @@ _LAYER_BUILDINGS = "BUILDINGS"
|
|||
_LABEL_HEIGHT_M = 2.0
|
||||
|
||||
|
||||
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||||
def _ring_points(coords: CoordinateSequence) -> list[tuple[float, float]]:
|
||||
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||
pts = list(coords)
|
||||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||||
|
|
@ -53,7 +58,7 @@ def _ring_points(coords: object) -> list[tuple[float, float]]:
|
|||
return [(float(x), float(y)) for x, y in pts]
|
||||
|
||||
|
||||
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
|
||||
def _add_polygon(msp: Modelspace, poly: Polygon, layer: str) -> None:
|
||||
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
|
||||
|
||||
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
|
||||
|
|
@ -151,15 +156,15 @@ def _feature_to_metric_polygon(parcel: Parcel, feature: object) -> Polygon | Non
|
|||
return None
|
||||
coords = geometry.get("coordinates")
|
||||
# Shapely mapping() emits nested tuples; accept both tuple and list.
|
||||
if not isinstance(coords, (list, tuple)) or not coords:
|
||||
if not isinstance(coords, list | tuple) or not coords:
|
||||
return None
|
||||
ring = coords[0]
|
||||
if not isinstance(ring, (list, tuple)) or len(ring) < 4:
|
||||
if not isinstance(ring, list | tuple) or len(ring) < 4:
|
||||
return None
|
||||
|
||||
metric_pts: list[tuple[float, float]] = []
|
||||
for pt in ring:
|
||||
if not isinstance(pt, (list, tuple)) or len(pt) < 2:
|
||||
if not isinstance(pt, list | tuple) or len(pt) < 2:
|
||||
return None
|
||||
lon, lat = float(pt[0]), float(pt[1])
|
||||
x, y = parcel.wgs84_to_metric(lon, lat)
|
||||
|
|
|
|||
|
|
@ -66,9 +66,7 @@ def _strategy_label(strategy: str) -> str:
|
|||
|
||||
def _teap_table(variants: Sequence[ConceptVariant]) -> str:
|
||||
"""HTML-таблица ТЭП по всем вариантам (строки — показатели, колонки — стратегии)."""
|
||||
headers = "".join(
|
||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
||||
)
|
||||
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
|
||||
rows: list[tuple[str, list[str]]] = [
|
||||
("Пятно застройки, кв.м", [_fmt_int(v.teap.built_area_sqm) for v in variants]),
|
||||
("Общая площадь (GFA), кв.м", [_fmt_int(v.teap.total_floor_area_sqm) for v in variants]),
|
||||
|
|
@ -107,9 +105,7 @@ def _fmt_irr(financial: FinancialModel) -> str:
|
|||
|
||||
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
|
||||
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF)."""
|
||||
headers = "".join(
|
||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
||||
)
|
||||
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
|
||||
rows: list[tuple[str, list[str]]] = [
|
||||
(
|
||||
"Выручка — жильё, млн руб",
|
||||
|
|
@ -188,9 +184,13 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
|
|||
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
|
||||
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
||||
"НДС — реализация жилья и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ), "
|
||||
"входной НДС по СМР встроен в себестоимость; НДС начисляется только на нежилую часть "
|
||||
"(машиноместа). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
|
||||
"— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
|
||||
"входной НДС по СМР встроен в себестоимость; НДС начисляется на нежилые части — "
|
||||
"машиноместа и коммерцию/офисы 1-го этажа (встроенный НДС в добавленной стоимости "
|
||||
"каждой части). Налог на прибыль — 25% (с 2025). Цены и себестоимость — рыночные "
|
||||
"ориентиры. Коммерция/офисы 1-го этажа учтены по нормативной доле от общей площади "
|
||||
"и продаются с умеренной наценкой к цене жилья того же класса; себестоимость СМР "
|
||||
"нежилого — по той же ставке, что и жильё (отдельной строки затрат нет, повторного "
|
||||
"учёта в затратах нет).</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ from dataclasses import dataclass
|
|||
from typing import Any
|
||||
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely.geometry import Polygon, mapping, shape
|
||||
from pyproj.exceptions import CRSError
|
||||
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
from shapely.ops import transform as shapely_transform
|
||||
|
||||
|
|
@ -53,6 +54,12 @@ MIN_BUILDABLE_AREA_SQM: float = 50.0
|
|||
# удержать время в бюджете (<=10 c/вариант). MVP-упрощение.
|
||||
MAX_GRID_CELLS: int = 20_000
|
||||
|
||||
# Валидные диапазоны WGS84 lon/lat (градусы). Используются, чтобы отловить участок,
|
||||
# присланный в проекции (метры, напр. МСК-66/UTM), ДО того как _metric_transformers
|
||||
# соберёт AEQD с lat_0/lon_0 далеко за пределами градусов -> pyproj.CRSError.
|
||||
_LON_RANGE: tuple[float, float] = (-180.0, 180.0)
|
||||
_LAT_RANGE: tuple[float, float] = (-90.0, 90.0)
|
||||
|
||||
# WGS84 (вход контракта).
|
||||
_WGS84 = CRS.from_epsg(4326)
|
||||
|
||||
|
|
@ -142,6 +149,19 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
|
|||
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
||||
raise ParcelGeometryError(f"cannot parse GeoJSON geometry: {exc}") from exc
|
||||
|
||||
# Многоконтурный участок (MultiPolygon): берём крупнейший контур. У некоторых
|
||||
# КН-участков в Росреестре несколько разрозненных полигонов — концепцию строим
|
||||
# по основному (наибольшему по площади) контуру; остальные обычно вкрапления.
|
||||
if isinstance(geom, MultiPolygon):
|
||||
if geom.is_empty or not geom.geoms:
|
||||
raise ParcelGeometryError("parcel polygon is empty")
|
||||
n_contours = len(geom.geoms)
|
||||
geom = max(geom.geoms, key=lambda g: g.area)
|
||||
logger.info(
|
||||
"parse_parcel: MultiPolygon → крупнейший контур из %d",
|
||||
n_contours,
|
||||
)
|
||||
|
||||
if geom.geom_type != "Polygon":
|
||||
raise ParcelGeometryError(f"expected Polygon, got {geom.geom_type}")
|
||||
if geom.is_empty:
|
||||
|
|
@ -157,6 +177,27 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
|
|||
return polygon
|
||||
|
||||
|
||||
def _assert_wgs84_bounds(polygon: Polygon) -> None:
|
||||
"""Проверить, что bbox полигона лежит в валидных диапазонах WGS84 lon/lat.
|
||||
|
||||
Root-cause фикс: фронтовый баг / плохой геокодер / demo-payload иногда шлёт
|
||||
``parcel_geojson`` в проекции (метры МСК-66/UTM, напр. [500000, 6200000]) вместо
|
||||
WGS84 lon/lat. Такой полигон синтаксически валиден (это просто Polygon), но
|
||||
дальше он бы дошёл до :func:`_metric_transformers`, где centroid.y (~6_200_000)
|
||||
подставится в ``+lat_0=`` -> pyproj.CRS.from_proj4 упадёт CRSError (opaque 500).
|
||||
Ловим здесь, ДО построения проекции, с понятным сообщением (422).
|
||||
"""
|
||||
minx, miny, maxx, maxy = polygon.bounds
|
||||
lon_ok = _LON_RANGE[0] <= minx and maxx <= _LON_RANGE[1]
|
||||
lat_ok = _LAT_RANGE[0] <= miny and maxy <= _LAT_RANGE[1]
|
||||
if not (lon_ok and lat_ok):
|
||||
raise ParcelGeometryError(
|
||||
"parcel_geojson coordinates out of WGS84 lon/lat range "
|
||||
f"(lon∈{_LON_RANGE}, lat∈{_LAT_RANGE}, got bounds={polygon.bounds}) — "
|
||||
"coordinates appear to be projected (e.g. МСК-66/UTM metres), not WGS84 lon/lat"
|
||||
)
|
||||
|
||||
|
||||
def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transformer]:
|
||||
"""Построить пару трансформеров WGS84<->метрический AEQD вокруг центроида участка.
|
||||
|
||||
|
|
@ -164,10 +205,16 @@ def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transform
|
|||
координат и точен на масштабе квартала — не нужен выбор UTM-зоны.
|
||||
"""
|
||||
centroid = polygon_wgs84.centroid
|
||||
try:
|
||||
metric_crs = CRS.from_proj4(
|
||||
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
|
||||
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
|
||||
)
|
||||
except CRSError as exc:
|
||||
# Belt-and-suspenders: _assert_wgs84_bounds должен был отловить это раньше,
|
||||
# но на случай иной координатной патологии не даём CRSError утечь наружу
|
||||
# непойманным 500 — это невалидная геометрия участка, т.е. 422.
|
||||
raise ParcelGeometryError(f"cannot build metric CRS for parcel centroid: {exc}") from exc
|
||||
to_metric = Transformer.from_crs(_WGS84, metric_crs, always_xy=True)
|
||||
to_wgs84 = Transformer.from_crs(metric_crs, _WGS84, always_xy=True)
|
||||
return to_metric, to_wgs84
|
||||
|
|
@ -240,9 +287,11 @@ def parse_parcel(
|
|||
"""Stage 1a: ConceptInput -> :class:`Parcel` (метрика + buildable + grid).
|
||||
|
||||
Raises:
|
||||
ParcelGeometryError: полигон невалиден или пятно застройки вырождается.
|
||||
ParcelGeometryError: полигон невалиден, координаты не в WGS84 lon/lat, или
|
||||
пятно застройки вырождается.
|
||||
"""
|
||||
polygon_wgs84 = _parse_polygon(payload.parcel_geojson)
|
||||
_assert_wgs84_bounds(polygon_wgs84)
|
||||
to_metric, to_wgs84 = _metric_transformers(polygon_wgs84)
|
||||
|
||||
def _fwd(xs: Any, ys: Any) -> tuple[Any, Any]:
|
||||
|
|
|
|||
|
|
@ -48,22 +48,24 @@ _SYSTEM_BASE = PromptTemplate(
|
|||
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
|
||||
_CHAT_SYSTEM = PromptTemplate(
|
||||
name="chat_system",
|
||||
version=2,
|
||||
version=3,
|
||||
template=(
|
||||
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
|
||||
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
|
||||
"ЖЁСТКИЕ ПРАВИЛА:\n"
|
||||
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
|
||||
"Чтобы получить нужные числа, вызови подходящий инструмент.\n"
|
||||
"Чтобы получить нужные числа, вызови подходящий инструмент. Для вопросов про сам "
|
||||
"участок — адрес, площадь, категорию земель, ВРИ, территориальную зону ПЗЗ и её "
|
||||
"код/название, лимиты застройки, ЗОУИТ-обременения — вызови get_parcel_info.\n"
|
||||
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
|
||||
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
|
||||
"отчёте нет. Не подставляй правдоподобные значения.\n"
|
||||
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
|
||||
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
|
||||
"инвестиционного решения. Не давай гарантий доходности.\n"
|
||||
"5. Вопросы вне отчёта по участку (градостроительная документация / ПЗЗ-разрешения, "
|
||||
"сравнение с другими участками, юридические заключения) — вежливо скажи, что это вне "
|
||||
"области отчёта, и предложи вопросы по самому форсайту.\n"
|
||||
"5. Вопросы, выходящие за рамки данных отчёта и паспорта участка (сравнение с другими "
|
||||
"участками, юридические заключения, получение разрешений/согласований) — вежливо "
|
||||
"скажи, что это вне области отчёта, и предложи вопросы по участку и его форсайту.\n"
|
||||
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
|
||||
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
|
||||
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
||||
|
|
|
|||
|
|
@ -33,15 +33,19 @@ Wiring (отдельный PR):
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.scrapers.flat_plans import download_plan_image, upsert_flat_plans
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -87,6 +91,51 @@ _STATUS_KW_RE = re.compile(
|
|||
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
|
||||
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
|
||||
|
||||
# ── plan-image extraction ─────────────────────────────────────────────────────
|
||||
#
|
||||
# Страница квартиры каталога DOM.РФ — Next.js SSR (тот же стек что и страница
|
||||
# объекта, см. domrf_catalog_object.py). План квартиры рендерится через next/image,
|
||||
# поэтому реальный URL картинки живёт в JSON-блоке <script id="__NEXT_DATA__">
|
||||
# внутри pageProps, а НЕ в готовом <img src>. Стратегия extraction (в порядке
|
||||
# приоритета, best-effort — DOM.РФ может переименовать ключи без уведомления):
|
||||
#
|
||||
# 1. __NEXT_DATA__ pageProps: ищем значение под одним из кандидат-ключей
|
||||
# (planImage / planImageUrl / layoutImage / floorPlan / scheme / image / photo).
|
||||
# 2. Fallback: bare <img>, чей src/alt/class намекают на планировку
|
||||
# (plan / layout / scheme / планировк), либо <img> рядом с текстом «Планировка».
|
||||
#
|
||||
# URL нормализуется в абсолютный через urljoin(BASE_URL, ...): DOM.РФ отдаёт файлы
|
||||
# и абсолютными (https://xn--80az8a.../api/ext/file/... — см. documents.py), и
|
||||
# относительными (/_next/image?url=..., /api/ext/file/...). Мусорные картинки
|
||||
# (data:-inline base64, captcha, лого застройщика, миниатюры) отсекаются.
|
||||
|
||||
# Ключи в pageProps, под которыми может лежать URL планировки. Проверяются по
|
||||
# порядку; первое непустое строковое значение с image-like URL выигрывает.
|
||||
_NEXT_PLAN_KEY_RE = re.compile(
|
||||
r"(?:plan|layout|scheme|планировк|floor)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Признак «это картинка» в значении: расширение файла ИЛИ file-API DOM.РФ.
|
||||
_IMAGE_URL_RE = re.compile(
|
||||
r"(?:\.(?:png|jpe?g|webp|svg)(?:\?|$)|/api/ext/file/|/_next/image)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Токены, по которым отсекаем НЕ-планировочные картинки (лого, аватары, иконки,
|
||||
# captcha, миниатюры, банеры). Проверяются по src/alt/class.
|
||||
_IMAGE_NOISE_RE = re.compile(
|
||||
r"(?:logo|avatar|icon|captcha|robot|sprite|banner|placeholder|thumb|"
|
||||
r"favicon|badge|marker|pin)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Признак «это планировка» в атрибутах <img> (src / alt / class).
|
||||
_IMAGE_PLAN_HINT_RE = re.compile(
|
||||
r"(?:plan|layout|scheme|планировк)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _classify_status_kw(matched_text: str) -> str | None:
|
||||
"""Классифицировать совпавшее ключевое слово в STATUS_*.
|
||||
|
|
@ -217,8 +266,19 @@ class _TextCollector(HTMLParser):
|
|||
# Список записей: (class_hint, full_text)
|
||||
self.blocks: list[tuple[str, str]] = []
|
||||
self._buf: list[str] = []
|
||||
# Отдельный, аддитивный сбор <img>-атрибутов (issue #2440): нужен для
|
||||
# extraction plan_image_url как fallback к __NEXT_DATA__. НЕ влияет на
|
||||
# text-extraction — img остаётся void-тегом, фрейм в стек не пушится.
|
||||
# Формат: (attrs_dict, index_в_blocks) — index привязывает картинку к
|
||||
# позиции в потоке текстовых блоков для proximity-поиска «Планировка».
|
||||
self.images: list[tuple[dict[str, str], int]] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag == "img":
|
||||
# Собираем атрибуты img ДО early-return (void-тег): index = текущая
|
||||
# длина blocks, т.е. картинка «привязана» к ближайшему предыдущему
|
||||
# закрытому текстовому блоку. Text-extraction ниже не меняется.
|
||||
self.images.append(({k: (v or "") for k, v in attrs}, len(self.blocks)))
|
||||
if tag in self._VOID_TAGS:
|
||||
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
||||
attr_dict = {k: (v or "") for k, v in attrs}
|
||||
|
|
@ -266,6 +326,134 @@ def _find_text_near(
|
|||
return None
|
||||
|
||||
|
||||
def _normalize_plan_url(url: str | None) -> str | None:
|
||||
"""Нормализовать URL картинки планировки в абсолютный, отсеять мусор.
|
||||
|
||||
- data:-inline (base64) отбрасываем (это не удалённый файл, часто captcha/robot).
|
||||
- Относительный путь ('/api/ext/file/...', '/_next/image?url=...') → absolute
|
||||
через urljoin(BASE_URL, ...).
|
||||
- Пустое/None → None.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
u = url.strip()
|
||||
if not u or u.lower().startswith("data:"):
|
||||
return None
|
||||
if u.startswith("//"):
|
||||
# protocol-relative → https
|
||||
return "https:" + u
|
||||
if u.startswith("http://") or u.startswith("https://"):
|
||||
return u
|
||||
return urljoin(BASE_URL + "/", u.lstrip("/"))
|
||||
|
||||
|
||||
def _extract_plan_from_next_data(html: str) -> str | None:
|
||||
"""Извлечь URL планировки из <script id="__NEXT_DATA__"> pageProps.
|
||||
|
||||
Страница квартиры каталога — Next.js SSR: план рендерится через next/image,
|
||||
URL картинки живёт в JSON-блоке. Рекурсивно обходим pageProps и ищем строковое
|
||||
значение под ключом, матчащим _NEXT_PLAN_KEY_RE, чьё значение выглядит как
|
||||
image-URL (_IMAGE_URL_RE) и НЕ является шумом (_IMAGE_NOISE_RE).
|
||||
|
||||
Возвращает первый подходящий URL (нормализованный) или None.
|
||||
"""
|
||||
match = re.search(
|
||||
r'<script\s+id=["\']__NEXT_DATA__["\'][^>]*>(.+?)</script>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
blob = json.loads(match.group(1).strip())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
page_props = blob.get("props", {}).get("pageProps")
|
||||
root: Any = page_props if isinstance(page_props, dict) else blob
|
||||
|
||||
# BFS по вложенному dict/list; ключ+значение проверяем на plan-hint.
|
||||
stack: list[Any] = [root]
|
||||
seen = 0
|
||||
while stack and seen < 20_000: # cap: защита от патологически глубокого JSON
|
||||
node = stack.pop()
|
||||
seen += 1
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and _NEXT_PLAN_KEY_RE.search(str(key))
|
||||
and _IMAGE_URL_RE.search(value)
|
||||
and not _IMAGE_NOISE_RE.search(value)
|
||||
):
|
||||
normalized = _normalize_plan_url(value)
|
||||
if normalized:
|
||||
return normalized
|
||||
if isinstance(value, dict | list):
|
||||
stack.append(value)
|
||||
elif isinstance(node, list):
|
||||
stack.extend(node)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_plan_from_imgs(
|
||||
images: list[tuple[dict[str, str], int]], blocks: list[tuple[str, str]]
|
||||
) -> str | None:
|
||||
"""Fallback: выбрать <img> планировки из собранных _TextCollector.images.
|
||||
|
||||
Приоритет:
|
||||
1. <img>, чей src/alt/class содержит plan/layout/scheme/планировк — прямое
|
||||
совпадение, самый надёжный сигнал.
|
||||
2. <img> рядом (в потоке DOM) с текстовым блоком «Планировка».
|
||||
Шумовые картинки (лого/иконки/captcha/миниатюры) отсекаются по _IMAGE_NOISE_RE,
|
||||
inline data:-картинки и пустые src — через _normalize_plan_url.
|
||||
"""
|
||||
# Уровень 1: явный plan-hint в атрибутах.
|
||||
for attrs, _idx in images:
|
||||
src = attrs.get("src", "")
|
||||
alt = attrs.get("alt", "")
|
||||
cls = attrs.get("class", "")
|
||||
haystack = f"{src} {alt} {cls}"
|
||||
if _IMAGE_NOISE_RE.search(haystack):
|
||||
continue
|
||||
if _IMAGE_PLAN_HINT_RE.search(haystack):
|
||||
normalized = _normalize_plan_url(src)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
# Уровень 2: <img>, чей index стоит сразу после блока «Планировка».
|
||||
plan_block_indices = [
|
||||
i for i, (_cls, text) in enumerate(blocks) if re.search(r"планировк", text, re.IGNORECASE)
|
||||
]
|
||||
if plan_block_indices:
|
||||
for attrs, idx in images:
|
||||
src = attrs.get("src", "")
|
||||
if _IMAGE_NOISE_RE.search(f"{src} {attrs.get('alt', '')} {attrs.get('class', '')}"):
|
||||
continue
|
||||
# картинка «принадлежит» блоку «Планировка», если её index в пределах
|
||||
# ±2 текстовых блоков от лейбла.
|
||||
if any(abs(idx - pbi) <= 2 for pbi in plan_block_indices):
|
||||
normalized = _normalize_plan_url(src)
|
||||
if normalized:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def extract_plan_image_url(html: str, collector: _TextCollector) -> str | None:
|
||||
"""Извлечь URL картинки планировки из SSR-HTML страницы квартиры.
|
||||
|
||||
Two-strategy (issue #2440):
|
||||
1. __NEXT_DATA__ pageProps (primary — DOM.РФ Next.js SSR).
|
||||
2. bare <img> fallback (по plan-hint атрибутам либо proximity к «Планировка»).
|
||||
|
||||
Возвращает абсолютный URL или None. Best-effort — структура DOM.РФ хрупкая.
|
||||
"""
|
||||
url = _extract_plan_from_next_data(html)
|
||||
if url:
|
||||
return url
|
||||
return _extract_plan_from_imgs(collector.images, collector.blocks)
|
||||
|
||||
|
||||
def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||
"""Извлечь поля из SSR-HTML страницы квартиры DOM.РФ.
|
||||
|
||||
|
|
@ -277,6 +465,7 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
- ceiling_height_m (float) Высота потолков в метрах
|
||||
- section_no (int) Номер подъезда / секции
|
||||
- catalog_updated_at (date) Дата обновления информации на странице
|
||||
- plan_image_url (str) URL картинки планировки (issue #2440)
|
||||
|
||||
Парсинг хрупкий по природе (SSR HTML DOM.РФ меняется без уведомлений).
|
||||
Все extraction best-effort — KeyError/AttributeError обёрнуты внутри.
|
||||
|
|
@ -468,6 +657,14 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
except ValueError:
|
||||
pass
|
||||
|
||||
# ── Шаг 4: plan_image_url (issue #2440) ──────────────────────────────────
|
||||
# __NEXT_DATA__ pageProps (primary) → bare <img> fallback. collector.images
|
||||
# собран на Шаге 1 тем же feed(). None если план не найден (напр. проданная
|
||||
# квартира без планировки на странице).
|
||||
plan_url = extract_plan_image_url(html, collector)
|
||||
if plan_url:
|
||||
result["plan_image_url"] = plan_url
|
||||
|
||||
logger.debug(
|
||||
"parse_catalog_flat: extracted fields=%s",
|
||||
list(result.keys()),
|
||||
|
|
@ -544,16 +741,63 @@ def upsert_catalog_data(
|
|||
# ── Per-flat scrape orchestration ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _handle_plan_image(
|
||||
db: Session,
|
||||
session: BrowserSession,
|
||||
ods_id: str,
|
||||
obj_id: int,
|
||||
plan_url: str,
|
||||
snapshot_date: date,
|
||||
outcome: dict[str, Any],
|
||||
) -> None:
|
||||
"""Записать URL планировки в flat_plans, скачать бинарник, дописать метаданные.
|
||||
|
||||
Порядок (issue #2440):
|
||||
1. upsert_flat_plans с одним только plan_image_url — URL зафиксирован даже
|
||||
если скачивание упадёт (частичный прогресс не теряется).
|
||||
2. download_plan_image — reuse BrowserSession (cookies/TLS/throttle).
|
||||
3. повторный upsert_flat_plans с local_path/размерами — COALESCE внутри
|
||||
upsert защищает уже скачанные файлы от перезаписи.
|
||||
|
||||
Мутирует outcome['plan_downloaded']. Ошибки логируются внутри download/upsert,
|
||||
наружу не пробрасываются — планировка не должна ронять catalog-scrape квартиры.
|
||||
"""
|
||||
plan_row: dict[str, Any] = {
|
||||
"ods_id": ods_id,
|
||||
"obj_id": obj_id,
|
||||
"plan_image_url": plan_url,
|
||||
}
|
||||
upsert_flat_plans(db, obj_id, [plan_row], snapshot_date)
|
||||
|
||||
meta = await download_plan_image(session, plan_url, ods_id, obj_id)
|
||||
if meta:
|
||||
outcome["plan_downloaded"] = True
|
||||
upsert_flat_plans(db, obj_id, [{**plan_row, **meta}], snapshot_date)
|
||||
|
||||
|
||||
async def scrape_one_flat(
|
||||
session: BrowserSession,
|
||||
db: Session,
|
||||
ods_id: str,
|
||||
catalog_url_hash: str,
|
||||
obj_id: int | None = None,
|
||||
snapshot_date: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Scrape одной квартиры: fetch HTML → parse → upsert.
|
||||
"""Scrape одной квартиры: fetch HTML → parse → upsert (+ планировка).
|
||||
|
||||
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated}.
|
||||
Ошибки fetch/parse логируются, не бросаются — caller обрабатывает результат.
|
||||
Помимо catalog-полей (price/status/finishing/...) извлекает plan_image_url
|
||||
(issue #2440): пишет URL в domrf_kn_flat_plans через upsert_flat_plans, затем
|
||||
скачивает бинарник (download_plan_image) и повторным upsert'ом дописывает
|
||||
local_path/размеры (COALESCE-защита уже скачанных файлов внутри upsert).
|
||||
|
||||
Скачивание — inline в рамках того же BrowserSession: reuse cookies/TLS,
|
||||
download_binary несёт свой лёгкий throttle (jitter 200–500 мс), отдельная
|
||||
beat-задача не заводится (требование #2440). obj_id/snapshot_date нужны для
|
||||
записи в flat_plans; если не переданы — plan-upsert пропускается с warning.
|
||||
|
||||
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated,
|
||||
plan_found, plan_downloaded}. Ошибки fetch/parse/download логируются, не
|
||||
бросаются — caller обрабатывает результат.
|
||||
"""
|
||||
outcome: dict[str, Any] = {
|
||||
"ods_id": ods_id,
|
||||
|
|
@ -561,6 +805,8 @@ async def scrape_one_flat(
|
|||
"success": False,
|
||||
"fields_extracted": 0,
|
||||
"updated": False,
|
||||
"plan_found": False,
|
||||
"plan_downloaded": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
|
@ -590,6 +836,19 @@ async def scrape_one_flat(
|
|||
|
||||
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
|
||||
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
|
||||
|
||||
# ── plan_image_url → domrf_kn_flat_plans (issue #2440) ───────────────────
|
||||
plan_url = data.get("plan_image_url")
|
||||
if plan_url:
|
||||
outcome["plan_found"] = True
|
||||
if obj_id is not None and snapshot_date is not None:
|
||||
await _handle_plan_image(db, session, ods_id, obj_id, plan_url, snapshot_date, outcome)
|
||||
else:
|
||||
logger.warning(
|
||||
"catalog scrape ods_id=%s: plan_image_url found but obj_id/snapshot_date "
|
||||
"missing — flat_plans upsert skipped",
|
||||
ods_id,
|
||||
)
|
||||
# success отражает прохождение пайплайна fetch+parse без исключения; реально
|
||||
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
||||
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
||||
|
|
@ -617,11 +876,12 @@ async def scrape_catalog_batch(
|
|||
region_code: int = 66,
|
||||
headed: bool = False,
|
||||
load_state: str | None = None,
|
||||
snapshot_date: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Scrape пачки квартир каталога DOM.РФ.
|
||||
|
||||
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash}.
|
||||
Типовой источник: SELECT ods_id, catalog_url_hash FROM domrf_kn_flats
|
||||
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash [, obj_id]}.
|
||||
Типовой источник: SELECT ods_id, obj_id, catalog_url_hash FROM domrf_kn_flats
|
||||
WHERE catalog_url_hash IS NOT NULL
|
||||
AND (catalog_updated_at IS NULL OR catalog_updated_at < NOW() - INTERVAL '30 days')
|
||||
LIMIT :batch_size.
|
||||
|
|
@ -629,18 +889,25 @@ async def scrape_catalog_batch(
|
|||
Использует один BrowserSession на весь пакет (bootstrapped 1 раз).
|
||||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||
|
||||
obj_id/snapshot_date нужны для записи планировки в domrf_kn_flat_plans (issue
|
||||
#2440). snapshot_date по умолчанию = сегодня (дата первичного обнаружения URL).
|
||||
|
||||
Returns:
|
||||
{total, success, updated, failed, fields_total}
|
||||
{total, success, updated, failed, fields_total, plans_found, plans_downloaded}
|
||||
- success: прошли fetch+parse без исключения
|
||||
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||
фактическое число записанных квартир, в отличие от success
|
||||
- plans_found / plans_downloaded: планировки найдены / скачаны (#2440)
|
||||
"""
|
||||
snap = snapshot_date or date.today()
|
||||
stats: dict[str, Any] = {
|
||||
"total": len(flats),
|
||||
"success": 0,
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"fields_total": 0,
|
||||
"plans_found": 0,
|
||||
"plans_downloaded": 0,
|
||||
}
|
||||
|
||||
if not flats:
|
||||
|
|
@ -648,9 +915,10 @@ async def scrape_catalog_batch(
|
|||
return stats
|
||||
|
||||
logger.info(
|
||||
"scrape_catalog_batch: starting %d flats region=%d",
|
||||
"scrape_catalog_batch: starting %d flats region=%d snapshot_date=%s",
|
||||
len(flats),
|
||||
region_code,
|
||||
snap,
|
||||
)
|
||||
|
||||
async with BrowserSession(
|
||||
|
|
@ -659,21 +927,42 @@ async def scrape_catalog_batch(
|
|||
load_state=load_state,
|
||||
# auth=None — страницы каталога публичные, Basic auth не нужен
|
||||
auth=None,
|
||||
# #2445 D2 anti-ban: тот же /сервисы/* path family, что вызвал DOM.РФ WAF
|
||||
# hard-ban 2026-05-24 (#2443). Без явного override BrowserSession тихо
|
||||
# наследует модульный дефолт stealth.py (concurrency=8, jitter 600-1500ms).
|
||||
# Переиспользуем throttled настройки, принятые scrape_kn.py после #1945
|
||||
# (settings.scrape_kn_* — общий anti-ban рычаг, НЕ специфичный для KN-sweep,
|
||||
# несмотря на имя).
|
||||
concurrency=settings.scrape_kn_browser_concurrency,
|
||||
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||||
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||||
) as session:
|
||||
# Warm-up: WAF DOM.РФ (2026-05-24) требует session cookies для SSR-страниц.
|
||||
# Зеркалит scrape_catalog_objects. Idempotent на весь batch.
|
||||
await session.warm_up()
|
||||
|
||||
for flat in flats:
|
||||
ods_id = flat.get("ods_id", "")
|
||||
catalog_url_hash = flat.get("catalog_url_hash", "")
|
||||
obj_id_raw = flat.get("obj_id")
|
||||
obj_id = int(obj_id_raw) if obj_id_raw is not None else None
|
||||
if not ods_id or not catalog_url_hash:
|
||||
logger.warning("scrape_catalog_batch: skip flat with missing ods_id/hash: %r", flat)
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
|
||||
outcome = await scrape_one_flat(session, db, ods_id, catalog_url_hash)
|
||||
outcome = await scrape_one_flat(
|
||||
session, db, ods_id, catalog_url_hash, obj_id=obj_id, snapshot_date=snap
|
||||
)
|
||||
if outcome["success"]:
|
||||
stats["success"] += 1
|
||||
stats["fields_total"] += outcome["fields_extracted"]
|
||||
if outcome["updated"]:
|
||||
stats["updated"] += 1
|
||||
if outcome.get("plan_found"):
|
||||
stats["plans_found"] += 1
|
||||
if outcome.get("plan_downloaded"):
|
||||
stats["plans_downloaded"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
|
|
@ -689,11 +978,14 @@ async def scrape_catalog_batch(
|
|||
raise
|
||||
|
||||
logger.info(
|
||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
|
||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d "
|
||||
"fields_total=%d plans_found=%d plans_downloaded=%d",
|
||||
stats["total"],
|
||||
stats["success"],
|
||||
stats["updated"],
|
||||
stats["failed"],
|
||||
stats["fields_total"],
|
||||
stats["plans_found"],
|
||||
stats["plans_downloaded"],
|
||||
)
|
||||
return stats
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from typing import Any
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -440,6 +441,16 @@ async def scrape_catalog_objects(
|
|||
region_code=region_code,
|
||||
# Страницы каталога публичные — Basic auth не нужен
|
||||
auth=None,
|
||||
# #2445 D2 anti-ban: этот scraper ходит по тому же /сервисы/* path family,
|
||||
# что вызвал DOM.РФ WAF hard-ban 2026-05-24 (#2443). Без явного override
|
||||
# BrowserSession тихо наследует модульный дефолт stealth.py (concurrency=8,
|
||||
# jitter 600-1500ms) — тот самый throttle-less профиль, что и раньше
|
||||
# триггерил volume-ban. Переиспользуем throttled настройки, принятые
|
||||
# scrape_kn.py после #1945 (settings.scrape_kn_* — общий anti-ban рычаг,
|
||||
# НЕ специфичный для KN-sweep, несмотря на имя).
|
||||
concurrency=settings.scrape_kn_browser_concurrency,
|
||||
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||||
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||||
) as session:
|
||||
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies.
|
||||
# DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих cookies.
|
||||
|
|
|
|||
|
|
@ -432,6 +432,14 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
|||
digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8]
|
||||
flat_id = int.from_bytes(digest, "big") % (2**63 - 1)
|
||||
|
||||
# catalog_url_hash — тот же elemId (hash-часть URL квартиры в каталоге DOM.РФ):
|
||||
# https://наш.дом.рф/сервисы/каталог-квартир/квартира/{catalog_url_hash}.
|
||||
# Читаем ЗДЕСЬ (а не только в fallback-ветке flat_id выше) — при наличии flatId
|
||||
# ветка fallback не выполняется и elem остаётся непрочитанным. Пустую строку
|
||||
# нормализуем в None: `or None` зеркалит трактовку `if elem:` в fallback-ветке
|
||||
# и общий null-handling соседних optional-полей (_g отдаёт None при отсутствии).
|
||||
catalog_url_hash = _g(row, "elemId") or None
|
||||
|
||||
price_rub = _g(row, "price")
|
||||
price_per_m2 = _g(row, "pricePerSquareMeter")
|
||||
total_area = _g(row, "totalArea")
|
||||
|
|
@ -476,7 +484,10 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
|||
"ceiling_height_m": None, # заполнит catalog scraper 22d
|
||||
"key_handover_dt": None, # заполнит catalog scraper 22d
|
||||
"catalog_updated_at": None, # заполнит catalog scraper 22d
|
||||
"catalog_url_hash": None, # заполнит catalog scraper 22d
|
||||
# catalog_url_hash приходит из kn-API (elemId) уже здесь — нужен catalog
|
||||
# scraper'у (22d) для построения URL страницы квартиры. Ранее хардкодился
|
||||
# в None, из-за чего flat-catalog scraper не мог выбрать ни одной строки.
|
||||
"catalog_url_hash": catalog_url_hash,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -524,14 +535,29 @@ UPSERT_OBJECT_SQL = text(
|
|||
:snapshot_date
|
||||
)
|
||||
ON CONFLICT (obj_id, snapshot_date) DO UPDATE SET
|
||||
-- #2464 cluster E: hobj_id/short_addr/dev_inn/region_cd/floor_min/floor_max/
|
||||
-- problem_flag/green_house были в INSERT (populated every call by
|
||||
-- _norm_object) но пропущены в DO UPDATE SET → re-run скрапера для
|
||||
-- уже существующего (obj_id, snapshot_date) оставлял эти 8 колонок stale.
|
||||
-- Все 8 — direct EXCLUDED (не COALESCE): freshly computed каждый вызов,
|
||||
-- в т.ч. problem_flag должен ОБНУЛЯТЬСЯ, если проблема на объекте снята
|
||||
-- (COALESCE зафиксировал бы устаревший "проблемный" статус навсегда).
|
||||
hobj_id = EXCLUDED.hobj_id,
|
||||
comm_name = EXCLUDED.comm_name,
|
||||
addr = EXCLUDED.addr,
|
||||
short_addr = EXCLUDED.short_addr,
|
||||
region_cd = EXCLUDED.region_cd,
|
||||
dev_id = EXCLUDED.dev_id,
|
||||
dev_name = EXCLUDED.dev_name,
|
||||
dev_inn = EXCLUDED.dev_inn,
|
||||
floor_min = EXCLUDED.floor_min,
|
||||
floor_max = EXCLUDED.floor_max,
|
||||
flat_count = EXCLUDED.flat_count,
|
||||
square_living = EXCLUDED.square_living,
|
||||
ready_dt = EXCLUDED.ready_dt,
|
||||
problem_flag = EXCLUDED.problem_flag,
|
||||
site_status = EXCLUDED.site_status,
|
||||
green_house = EXCLUDED.green_house,
|
||||
escrow = EXCLUDED.escrow,
|
||||
obj_class = COALESCE(EXCLUDED.obj_class, domrf_kn_objects.obj_class),
|
||||
wall_type = COALESCE(EXCLUDED.wall_type, domrf_kn_objects.wall_type),
|
||||
|
|
@ -910,6 +936,9 @@ UPSERT_PHOTO_SQL = text(
|
|||
:local_path, :thumb_path, :downloaded_at
|
||||
)
|
||||
ON CONFLICT (obj_id, obj_file_id) DO UPDATE SET
|
||||
-- #2464 cluster F: build_type в INSERT column list (populated every call)
|
||||
-- но пропущен здесь → re-run скрапера оставлял build_type stale.
|
||||
build_type = EXCLUDED.build_type,
|
||||
ord_num = EXCLUDED.ord_num,
|
||||
photo_url = EXCLUDED.photo_url,
|
||||
photo_dttm = EXCLUDED.photo_dttm,
|
||||
|
|
|
|||
|
|
@ -33,15 +33,36 @@ Issue #297 sub-task 22c.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from app.services.scrapers.stealth import BrowserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Базовая директория для скачанных бинарников планировок. Зеркалит конвенцию
|
||||
# domrf_kn.py:PHOTOS_DIR_DEFAULT (data/raw/domrf_photos) — тот же raw-корень,
|
||||
# отдельная папка. Файлы: {base}/{obj_id}/{safe_ods_id}.{ext}.
|
||||
FLAT_PLANS_DIR_DEFAULT = Path("data/raw/domrf_flat_plans")
|
||||
|
||||
# Разрешённые расширения картинок планировок. png — дефолт (DOM.РФ отдаёт планы
|
||||
# растром), svg на всякий случай (векторные схемы встречаются).
|
||||
_ALLOWED_PLAN_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".svg")
|
||||
|
||||
# Замена небезопасных для имени файла символов в ods_id (напр. "65136/1/1.4.3").
|
||||
_ODS_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
# ── payload parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -212,30 +233,131 @@ def upsert_flat_plans(
|
|||
return inserted
|
||||
|
||||
|
||||
# ── download stub ─────────────────────────────────────────────────────────────
|
||||
# ── image download ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def download_plan_image_stub(
|
||||
def _safe_ods_filename(ods_id: str) -> str:
|
||||
"""Превратить ods_id (напр. '65136/1/1.4.3') в безопасное имя файла.
|
||||
|
||||
Слэши и прочие небезопасные символы → '_'. Результат используется как
|
||||
basename в {base_dir}/{obj_id}/{safe}.{ext}.
|
||||
"""
|
||||
return _ODS_UNSAFE_RE.sub("_", ods_id).strip("_") or "plan"
|
||||
|
||||
|
||||
def _ext_from_url(url: str) -> str:
|
||||
"""Определить расширение файла по URL планировки. Default .png.
|
||||
|
||||
DOM.РФ отдаёт планы преимущественно PNG; расширение берём из path, если
|
||||
оно в белом списке, иначе .png (file-API URL /api/ext/file/... часто без
|
||||
расширения — контент-тип уточнится по факту, но png — безопасный дефолт).
|
||||
"""
|
||||
path = urlsplit(url).path.lower()
|
||||
for ext in _ALLOWED_PLAN_EXTS:
|
||||
if path.endswith(ext):
|
||||
return ".jpg" if ext == ".jpeg" else ext
|
||||
return ".png"
|
||||
|
||||
|
||||
def _image_dims(data: bytes) -> tuple[int | None, int | None]:
|
||||
"""Получить (width_px, height_px) картинки через Pillow. None при ошибке.
|
||||
|
||||
Pillow (PIL) уже зависимость репо (pyproject: pillow>=10.4.0, используется в
|
||||
app.services.photos.thumbs). SVG Pillow не парсит — вернёт (None, None),
|
||||
это ОК (размеры для векторных планов необязательны).
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
return int(im.width), int(im.height)
|
||||
except Exception as exc:
|
||||
logger.debug("flat plan image dims parse failed: %s", exc)
|
||||
return None, None
|
||||
|
||||
|
||||
async def download_plan_image(
|
||||
session: BrowserSession,
|
||||
plan_image_url: str,
|
||||
ods_id: str,
|
||||
dest_dir: str | None = None,
|
||||
) -> str:
|
||||
"""Заглушка для скачивания бинарника планировки.
|
||||
obj_id: int,
|
||||
base_dir: Path | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Скачать бинарник планировки квартиры и вернуть метаданные для upsert.
|
||||
|
||||
Реальная реализация — отдельный Celery task (22d-track, issue #299).
|
||||
До реализации бросает NotImplementedError, чтобы случайный вызов
|
||||
не прошёл незаметно.
|
||||
Реиспользует BrowserSession.download_binary — тот же паттерн что download_photos
|
||||
в domrf_kn.py: тянет файл через browser context (те же cookies / TLS-fingerprint,
|
||||
что прошли WAF), с лёгким throttle (jitter 200–500 мс встроен в download_binary).
|
||||
|
||||
Файл сохраняется в {base_dir}/{obj_id}/{safe_ods_id}.{ext}. Если файл уже есть
|
||||
на диске — повторно НЕ качаем (idempotent, экономит WAF-бюджет), но всё равно
|
||||
возвращаем метаданные (размеры/размер читаются с диска).
|
||||
|
||||
Args:
|
||||
plan_image_url: URL картинки планировки.
|
||||
session: Активный BrowserSession (bootstrapped + warm_up).
|
||||
plan_image_url: Абсолютный URL картинки планировки.
|
||||
ods_id: Идентификатор квартиры (для имени файла).
|
||||
dest_dir: Директория для сохранения (None = MEDIA_ROOT/flat_plans/).
|
||||
obj_id: ID объекта DOM.РФ (подпапка).
|
||||
base_dir: Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT).
|
||||
|
||||
Raises:
|
||||
NotImplementedError: всегда — до реализации.
|
||||
Returns:
|
||||
dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at —
|
||||
для передачи в upsert_flat_plans. None при ошибке скачивания.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"download_plan_image_stub: скачивание планировок не реализовано. "
|
||||
"Реальный downloader — отдельный Celery task (issue #299, 22d-track). "
|
||||
f"URL={plan_image_url!r} ods_id={ods_id!r} dest_dir={dest_dir!r}"
|
||||
root = base_dir or FLAT_PLANS_DIR_DEFAULT
|
||||
obj_dir = root / str(obj_id)
|
||||
ext = _ext_from_url(plan_image_url)
|
||||
local = obj_dir / f"{_safe_ods_filename(ods_id)}{ext}"
|
||||
|
||||
# Idempotent: уже скачан — читаем метаданные с диска, не жжём WAF-запрос.
|
||||
if local.exists() and local.stat().st_size > 0:
|
||||
data = local.read_bytes()
|
||||
width_px, height_px = _image_dims(data)
|
||||
logger.debug("flat plan already on disk ods_id=%s path=%s", ods_id, local)
|
||||
return {
|
||||
"local_path": str(local),
|
||||
"width_px": width_px,
|
||||
"height_px": height_px,
|
||||
"size_bytes": local.stat().st_size,
|
||||
"downloaded_at": datetime.now(UTC),
|
||||
}
|
||||
|
||||
try:
|
||||
data = await session.download_binary(plan_image_url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"download_plan_image failed ods_id=%s obj_id=%s url=%s: %s",
|
||||
ods_id,
|
||||
obj_id,
|
||||
plan_image_url,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
if not data:
|
||||
logger.warning("download_plan_image empty body ods_id=%s url=%s", ods_id, plan_image_url)
|
||||
return None
|
||||
|
||||
obj_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
local.write_bytes(data)
|
||||
except OSError as exc:
|
||||
logger.warning("download_plan_image write failed ods_id=%s path=%s: %s", ods_id, local, exc)
|
||||
return None
|
||||
|
||||
width_px, height_px = _image_dims(data)
|
||||
logger.info(
|
||||
"download_plan_image ok ods_id=%s obj_id=%s bytes=%d dims=%sx%s",
|
||||
ods_id,
|
||||
obj_id,
|
||||
len(data),
|
||||
width_px,
|
||||
height_px,
|
||||
)
|
||||
return {
|
||||
"local_path": str(local),
|
||||
"width_px": width_px,
|
||||
"height_px": height_px,
|
||||
"size_bytes": len(data),
|
||||
"downloaded_at": datetime.now(UTC),
|
||||
}
|
||||
|
|
|
|||
501
backend/app/services/scrapers/gisogd66.py
Normal file
501
backend/app/services/scrapers/gisogd66.py
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
"""Загрузчик реестра РНС/РВЭ Свердловской области из открытого API ГИСОГД-СО (#2367).
|
||||
|
||||
Источник: ``gisogd66.midural.ru`` — региональная ГИС органов гос. власти Свердловской
|
||||
области. Открытый JSON-API раздела 13 («Сведения о документах в деле о земельном
|
||||
участке»), БЕЗ авторизации (bare curl 200, разведка 2026-07-04).
|
||||
|
||||
Три эндпоинта (все GET JSON, base ``https://gisogd66.midural.ru/api/v1/{schema}``):
|
||||
* ``/gisogddocgroups/razdel13`` — группы документов раздела (DocRS/DocRV/…);
|
||||
* ``/gisogddocgroupdocs/razdel13/{group}`` — весь список группы одним ответом (без
|
||||
пагинации): key, name, docNum, dateDoc, numReg, dateReg;
|
||||
* ``/gisogddocs/razdel13/{key}`` — карточка документа: spatialUnit (кад.№,
|
||||
может быть несколько через запятую / отсутствовать), infosetGeoJson (GeoJSON Polygon
|
||||
WGS84 lon/lat, может отсутствовать — hasGeometry=false), approvedOrganization, docNum,
|
||||
dateDoc, dateReg.
|
||||
|
||||
Две схемы (тянем обе, sverdregion первым): ``agate_sverdregion`` (регион-агрегат,
|
||||
стабильные карточки, покрывает и ЕКБ) и ``agate_ekbgo`` (ЕКБ, свежее на ~неделю, но
|
||||
часть старых ключей битая на портале). Один документ может встретиться в обеих →
|
||||
бизнес-ключ ``(doc_group, doc_num)``; при конфликте предпочитаем запись с более
|
||||
поздним ``date_reg``.
|
||||
|
||||
Инкрементальность: список группы сверяется с БД по (source_schema, source_key, date_reg);
|
||||
карточки (второй запрос — дорогой) тянутся ТОЛЬКО для новых / изменившихся документов.
|
||||
|
||||
Politeness: единый httpx-клиент с limits(max_connections=5), timeout=20с, 2 ретрая на
|
||||
сетевых ошибках. Карточки (5xx-битые записи портала) НЕ ретраятся — deterministically
|
||||
broken, не транзиент. Circuit breaker: 50 карточек подряд с ошибкой → прерываем группу
|
||||
(битый сегмент). UPSERT — per-row SAVEPOINT (битая строка не валит батч). Периодический
|
||||
db.commit() (каждые 200 записей + после каждой schema/group), чтобы партиал пережил
|
||||
soft_time_limit-kill.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Константы источника ───────────────────────────────────────────────────────
|
||||
|
||||
GISOGD_BASE = "https://gisogd66.midural.ru/api/v1"
|
||||
_SECTION = "razdel13"
|
||||
|
||||
# Схемы источника — обе тянем. Порядок ВАЖЕН: sverdregion первым.
|
||||
# sverdregion (регион-агрегат) отдаёт карточки стабильно и покрывает в т.ч. ЕКБ-доки
|
||||
# через агрегацию, поэтому он — базовый источник. ekbgo вторым как freshness-энхансер
|
||||
# (свежее на ~неделю), НО у него большой сегмент старых ключей (префикс ~100001007xxx)
|
||||
# детерминированно отдаёт 500 на карточках (сломано на стороне портала, 2026-07-04) —
|
||||
# гоняем его после того, как регион уже покрыл основную массу (см. circuit breaker ниже).
|
||||
SCHEMAS: tuple[str, ...] = ("agate_sverdregion", "agate_ekbgo")
|
||||
|
||||
# Группа источника (doc group key) → наш doc_group-код в БД (CHECK IN ('RS','RV')).
|
||||
GROUP_CODE: dict[str, str] = {"DocRS": "RS", "DocRV": "RV"}
|
||||
|
||||
_HTTP_TIMEOUT = 20.0
|
||||
_MAX_CONNECTIONS = 5
|
||||
_MAX_RETRIES = 2 # ретраи (helper делает суммарно до 1 + _MAX_RETRIES попыток)
|
||||
_RETRY_BACKOFF_S = 1.5
|
||||
_CARD_SLEEP_S = 0.05 # вежливая пауза между карточками (второй, «дорогой» запрос)
|
||||
_COMMIT_EVERY = 200 # периодический commit внутри группы (партиал переживает time-limit)
|
||||
# Circuit breaker: столько карточек ПОДРЯД с ошибкой fetch → прерываем группу (портал
|
||||
# отдаёт битый сегмент старых ключей детерминированным 5xx; остаток догоним будущими ранами).
|
||||
_CARD_FAIL_BREAKER = 50
|
||||
|
||||
USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) Site Finder gisogd permits loader"
|
||||
|
||||
# Валидация кадастрового номера: NN:NN:NNNNNN(N):N (6-7 знаков в третьем блоке).
|
||||
_CAD_RE = re.compile(r"^\d{2}:\d{2}:\d{6,7}:\d+$")
|
||||
|
||||
|
||||
# ── HTTP helpers (module-level — мокаются в тестах) ───────────────────────────
|
||||
|
||||
|
||||
def _make_client() -> httpx.Client:
|
||||
"""httpx.Client с пулом (max_connections=5), таймаутом и UA. Закрывать вызывающим."""
|
||||
return httpx.Client(
|
||||
base_url=GISOGD_BASE,
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
limits=httpx.Limits(max_connections=_MAX_CONNECTIONS),
|
||||
)
|
||||
|
||||
|
||||
def _get_json(client: httpx.Client, path: str, *, retries_5xx: int = _MAX_RETRIES) -> Any:
|
||||
"""GET path → JSON с ретраями на 5xx / сетевых ошибках.
|
||||
|
||||
Сетевые Timeout/TransportError ретраятся всегда (до 1 + _MAX_RETRIES попыток) —
|
||||
это транзиент. 5xx ретраятся ``retries_5xx`` раз: списки группы — с дефолтом
|
||||
(транзиентная нагрузка портала), карточки — с ``retries_5xx=0`` (5xx карточки
|
||||
детерминированно битые записи сегмента портала, не транзиент). 4xx → сразу raise.
|
||||
"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(_MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = client.get(path)
|
||||
if resp.status_code >= 500:
|
||||
last_exc = httpx.HTTPStatusError(
|
||||
f"{resp.status_code} on {path}", request=resp.request, response=resp
|
||||
)
|
||||
# 5xx: ретраим только пока attempt в пределах retries_5xx (0 → сразу raise).
|
||||
if attempt >= retries_5xx:
|
||||
raise last_exc
|
||||
logger.warning(
|
||||
"gisogd66: %s (попытка %d) на %s", resp.status_code, attempt + 1, path
|
||||
)
|
||||
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
last_exc = exc
|
||||
logger.warning(
|
||||
"gisogd66: сетевая ошибка (попытка %d) на %s: %s", attempt + 1, path, exc
|
||||
)
|
||||
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
def fetch_group_list(client: httpx.Client, schema: str, group: str) -> list[dict[str, Any]]:
|
||||
"""Весь список документов группы (без пагинации) → list of dict.
|
||||
|
||||
Поля элемента: key, name, docNum, dateDoc, numReg, dateReg. Пустой список при
|
||||
неожиданном (не-list) ответе.
|
||||
"""
|
||||
data = _get_json(client, f"/{schema}/gisogddocgroupdocs/{_SECTION}/{group}")
|
||||
if not isinstance(data, list):
|
||||
logger.warning("gisogd66: %s/%s список не-list (%s)", schema, group, type(data).__name__)
|
||||
return []
|
||||
logger.info("gisogd66: %s/%s список документов=%d", schema, group, len(data))
|
||||
return data
|
||||
|
||||
|
||||
def fetch_doc_card(client: httpx.Client, schema: str, key: str | int) -> dict[str, Any] | None:
|
||||
"""Карточка документа → dict (или None при не-dict / ошибке карточки).
|
||||
|
||||
Поля: spatialUnit, infosetGeoJson, hasGeometry, approvedOrganization, docNum,
|
||||
dateDoc, dateReg, name.
|
||||
|
||||
5xx карточки НЕ ретраятся (retries_5xx=0): битый сегмент старых ключей портала
|
||||
отдаёт детерминированный 500 — 3 попытки с backoff = ~12с потерянного времени/карточку.
|
||||
"""
|
||||
data = _get_json(client, f"/{schema}/gisogddocs/{_SECTION}/{key}", retries_5xx=0)
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("gisogd66: карточка %s/%s не-dict (%s)", schema, key, type(data).__name__)
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
# ── Парсинг полей ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_date(value: Any) -> date | None:
|
||||
"""ISO-datetime / date-строка источника → date. None если пусто / не парсится.
|
||||
|
||||
Источник отдаёт «2026-06-02T00:00:00» (dateDoc) и «2026-06-19T15:55:11» (dateReg).
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
# Обрезаем таймзону-суффикс 'Z' если вдруг придёт, парсим ISO.
|
||||
try:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).date()
|
||||
except ValueError:
|
||||
pass
|
||||
for fmt in ("%Y-%m-%d", "%d.%m.%Y"):
|
||||
try:
|
||||
return datetime.strptime(s[:10], fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
logger.debug("gisogd66: не распарсил дату %r", value)
|
||||
return None
|
||||
|
||||
|
||||
def parse_cad_nums(spatial_unit: Any) -> list[str]:
|
||||
"""spatialUnit → список валидных кадастровых номеров (может быть несколько через запятую).
|
||||
|
||||
Split по запятой, strip, валидация по _CAD_RE. Невалидные (мусор) отбрасываются с
|
||||
warning-логом. Отсутствие / пусто → []. Дубликаты схлопываются (сохраняя порядок).
|
||||
"""
|
||||
if not spatial_unit:
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for part in str(spatial_unit).split(","):
|
||||
cad = part.strip()
|
||||
if not cad:
|
||||
continue
|
||||
if not _CAD_RE.match(cad):
|
||||
logger.warning(
|
||||
"gisogd66: невалидный кадастровый номер %r в spatialUnit — отброшен", cad
|
||||
)
|
||||
continue
|
||||
if cad not in seen:
|
||||
seen.add(cad)
|
||||
out.append(cad)
|
||||
return out
|
||||
|
||||
|
||||
def extract_geojson(card: dict[str, Any]) -> str | None:
|
||||
"""infosetGeoJson карточки → GeoJSON-строка (для ST_GeomFromGeoJSON) или None.
|
||||
|
||||
Источник кладёт GeoJSON КАК СТРОКУ в поле infosetGeoJson. Пусто / hasGeometry=false
|
||||
/ невалидный JSON → None (лоадер запишет geom=NULL). Приведение Polygon→MultiPolygon
|
||||
делается в SQL через ST_Multi — здесь возвращаем сырой GeoJSON как есть.
|
||||
"""
|
||||
raw = card.get("infosetGeoJson")
|
||||
if not raw:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
# На случай если источник когда-нибудь отдаст объект, а не строку.
|
||||
return json.dumps(raw)
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
json.loads(s) # sanity-парс: битую строку не отправляем в PostGIS
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.warning("gisogd66: битый infosetGeoJson (%s) — geom=NULL", exc)
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
# ── Инкрементальность ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _existing_reg_dates(db: Session, schema: str) -> dict[str, date | None]:
|
||||
"""{source_key: date_reg} для уже загруженных из этой схемы записей.
|
||||
|
||||
Используется для инкрементальной сверки: карточку тянем только если key новый ИЛИ
|
||||
date_reg в списке отличается от сохранённого.
|
||||
"""
|
||||
rows = db.execute(
|
||||
text("SELECT source_key, date_reg FROM gisogd_permits WHERE source_schema = :schema"),
|
||||
{"schema": schema},
|
||||
).all()
|
||||
return {str(r[0]): r[1] for r in rows}
|
||||
|
||||
|
||||
# ── UPSERT ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _upsert_permit(db: Session, rec: dict[str, Any]) -> str:
|
||||
"""UPSERT одной записи в gisogd_permits по (doc_group, doc_num). Per-row SAVEPOINT.
|
||||
|
||||
Конфликт-резолв: при коллизии бизнес-ключа обновляем ТОЛЬКО если у новой записи
|
||||
date_reg НЕ старше сохранённой (EXCLUDED.date_reg >= existing) — предпочитаем
|
||||
более позднюю регистрацию (или запись без даты не затирает датированную).
|
||||
|
||||
Returns: 'inserted' | 'updated' | 'skipped_unchanged'.
|
||||
"""
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO gisogd_permits
|
||||
(doc_group, doc_num, doc_name, date_doc, date_reg,
|
||||
approved_organization, source_schema, source_key,
|
||||
cad_nums, geom, fetched_at, updated_at)
|
||||
VALUES (
|
||||
:doc_group, :doc_num, :doc_name, :date_doc, :date_reg,
|
||||
:approved_org, :source_schema, :source_key,
|
||||
CAST(:cad_nums AS text[]),
|
||||
-- CAST обязателен: без него ни одно вхождение :geojson не даёт Postgres
|
||||
-- тип параметра (ST_GeomFromGeoJSON перегружена text/json/jsonb) →
|
||||
-- psycopg.errors.AmbiguousParameter на КАЖДОЙ строке (прод-прогон #2367).
|
||||
CASE WHEN CAST(:geojson AS text) IS NULL THEN NULL
|
||||
ELSE ST_Multi(ST_SetSRID(
|
||||
ST_GeomFromGeoJSON(CAST(:geojson AS text)), 4326)) END,
|
||||
NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (doc_group, doc_num) DO UPDATE
|
||||
SET doc_name = EXCLUDED.doc_name,
|
||||
date_doc = EXCLUDED.date_doc,
|
||||
date_reg = EXCLUDED.date_reg,
|
||||
approved_organization = EXCLUDED.approved_organization,
|
||||
source_schema = EXCLUDED.source_schema,
|
||||
source_key = EXCLUDED.source_key,
|
||||
cad_nums = EXCLUDED.cad_nums,
|
||||
geom = EXCLUDED.geom,
|
||||
updated_at = NOW()
|
||||
WHERE EXCLUDED.date_reg IS NOT DISTINCT FROM gisogd_permits.date_reg
|
||||
OR EXCLUDED.date_reg > gisogd_permits.date_reg
|
||||
OR gisogd_permits.date_reg IS NULL
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
{
|
||||
"doc_group": rec["doc_group"],
|
||||
"doc_num": rec["doc_num"],
|
||||
"doc_name": rec["doc_name"],
|
||||
"date_doc": rec["date_doc"],
|
||||
"date_reg": rec["date_reg"],
|
||||
"approved_org": rec["approved_organization"],
|
||||
"source_schema": rec["source_schema"],
|
||||
"source_key": rec["source_key"],
|
||||
"cad_nums": rec["cad_nums"],
|
||||
"geojson": rec["geojson"],
|
||||
},
|
||||
).scalar()
|
||||
if result is None:
|
||||
# WHERE-гейт не пропустил апдейт (у нас более старый date_reg) → без изменений.
|
||||
return "skipped_unchanged"
|
||||
return "inserted" if result else "updated"
|
||||
|
||||
|
||||
# ── Оркестрация ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def load_gisogd_permits(db: Session | None = None) -> dict[str, int]:
|
||||
"""Инкрементально загрузить РНС/РВЭ ГИСОГД-СО (обе схемы, обе группы) в gisogd_permits.
|
||||
|
||||
Для каждой (schema, group): список → сверка с БД по (source_key, date_reg) →
|
||||
карточки только для новых/изменённых → UPSERT по (doc_group, doc_num) с
|
||||
предпочтением позднего date_reg. ``db`` для совместимости сигнатуры; None → своя
|
||||
SessionLocal.
|
||||
|
||||
Returns: счётчики {listed, fetched_details, inserted, updated, skipped_unchanged,
|
||||
no_cad, no_geom, card_failed, group_aborted}.
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
counts = {
|
||||
"listed": 0,
|
||||
"fetched_details": 0,
|
||||
"inserted": 0,
|
||||
"updated": 0,
|
||||
"skipped_unchanged": 0,
|
||||
"no_cad": 0,
|
||||
"no_geom": 0,
|
||||
"card_failed": 0, # каждая упавшая карточка (5xx / сеть / не-dict)
|
||||
"group_aborted": 0, # групп прервано circuit breaker'ом (битый сегмент портала)
|
||||
}
|
||||
client = _make_client()
|
||||
try:
|
||||
for schema in SCHEMAS:
|
||||
existing = _existing_reg_dates(db, schema)
|
||||
for group_key, doc_group in GROUP_CODE.items():
|
||||
try:
|
||||
items = fetch_group_list(client, schema, group_key)
|
||||
except Exception as exc:
|
||||
logger.exception("gisogd66: список %s/%s упал: %s", schema, group_key, exc)
|
||||
continue
|
||||
counts["listed"] += len(items)
|
||||
_sync_group(db, client, schema, doc_group, items, existing, counts)
|
||||
# Commit после КАЖДОЙ (schema, group), а не один раз в конце: партиал
|
||||
# должен пережить soft_time_limit-kill (иначе теряем весь прогон).
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.exception("gisogd66: outer tx rolled back: %s", exc)
|
||||
raise
|
||||
finally:
|
||||
client.close()
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_gisogd_permits done: %s", counts)
|
||||
return counts
|
||||
|
||||
|
||||
def _sync_group(
|
||||
db: Session,
|
||||
client: httpx.Client,
|
||||
schema: str,
|
||||
doc_group: str,
|
||||
items: list[dict[str, Any]],
|
||||
existing: dict[str, date | None],
|
||||
counts: dict[str, int],
|
||||
) -> None:
|
||||
"""Обработать одну (schema, group): инкрементальная сверка + карточки + UPSERT.
|
||||
|
||||
Периодический commit каждые ``_COMMIT_EVERY`` обработанных записей (партиал переживёт
|
||||
time-limit kill). Circuit breaker: ``_CARD_FAIL_BREAKER`` карточек ПОДРЯД с ошибкой
|
||||
fetch → прерываем группу (битый сегмент портала — остаток догоним будущими ранами).
|
||||
Любая успешная карточка сбрасывает счётчик подряд-фейлов.
|
||||
"""
|
||||
processed = 0 # успешно обработанных (upsert/skip) с последнего commit
|
||||
consecutive_fail = 0 # карточек подряд с ошибкой fetch (для circuit breaker)
|
||||
for item in items:
|
||||
key = item.get("key")
|
||||
if key is None:
|
||||
continue
|
||||
source_key = str(key)
|
||||
list_reg = _parse_date(item.get("dateReg"))
|
||||
|
||||
# Инкрементальность: пропускаем карточку, если key уже есть И date_reg совпал.
|
||||
if source_key in existing and existing[source_key] == list_reg:
|
||||
counts["skipped_unchanged"] += 1
|
||||
consecutive_fail = 0 # skip — не фейл карточки, сбрасываем breaker
|
||||
processed += 1
|
||||
if processed % _COMMIT_EVERY == 0:
|
||||
db.commit()
|
||||
continue
|
||||
|
||||
card: dict[str, Any] | None
|
||||
try:
|
||||
card = fetch_doc_card(client, schema, source_key)
|
||||
except Exception as exc:
|
||||
logger.warning("gisogd66: карточка %s/%s упала: %s", schema, source_key, exc)
|
||||
card = None
|
||||
finally:
|
||||
time.sleep(_CARD_SLEEP_S)
|
||||
if card is None:
|
||||
# Неуспех fetch (5xx / сеть / не-dict): считаем и двигаем circuit breaker.
|
||||
counts["card_failed"] += 1
|
||||
consecutive_fail += 1
|
||||
if consecutive_fail >= _CARD_FAIL_BREAKER:
|
||||
logger.error(
|
||||
"gisogd66: %s/%s: %d карточек подряд 5xx — прерываю группу "
|
||||
"(битый сегмент портала)",
|
||||
schema,
|
||||
doc_group,
|
||||
_CARD_FAIL_BREAKER,
|
||||
)
|
||||
counts["group_aborted"] += 1
|
||||
return
|
||||
continue
|
||||
consecutive_fail = 0 # успешная карточка сбрасывает breaker
|
||||
counts["fetched_details"] += 1
|
||||
|
||||
rec = _build_record(item, card, schema, doc_group, source_key, list_reg)
|
||||
if rec is None:
|
||||
continue
|
||||
if not rec["cad_nums"]:
|
||||
counts["no_cad"] += 1
|
||||
if rec["geojson"] is None:
|
||||
counts["no_geom"] += 1
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — откат только этой строки
|
||||
outcome = _upsert_permit(db, rec)
|
||||
except Exception as exc:
|
||||
logger.warning("gisogd66: upsert %s %r упал: %s", doc_group, rec["doc_num"], exc)
|
||||
continue
|
||||
counts[outcome] += 1
|
||||
processed += 1
|
||||
if processed % _COMMIT_EVERY == 0:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _build_record(
|
||||
item: dict[str, Any],
|
||||
card: dict[str, Any],
|
||||
schema: str,
|
||||
doc_group: str,
|
||||
source_key: str,
|
||||
list_reg: date | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Список-элемент + карточка → нормализованная запись для UPSERT. None если нет doc_num.
|
||||
|
||||
doc_num берём из карточки, fallback на список; strip обязателен (в списке ~25%
|
||||
номеров с ведущими/хвостовыми пробелами). Даты предпочитаем из карточки, fallback
|
||||
на список. cad_nums из spatialUnit карточки, geojson из infosetGeoJson.
|
||||
"""
|
||||
raw_num = card.get("docNum") or item.get("docNum")
|
||||
doc_num = str(raw_num).strip() if raw_num else ""
|
||||
if not doc_num:
|
||||
logger.warning("gisogd66: карточка %s/%s без docNum — пропуск", schema, source_key)
|
||||
return None
|
||||
|
||||
return {
|
||||
"doc_group": doc_group,
|
||||
"doc_num": doc_num,
|
||||
"doc_name": card.get("name") or item.get("name"),
|
||||
"date_doc": _parse_date(card.get("dateDoc")) or _parse_date(item.get("dateDoc")),
|
||||
"date_reg": _parse_date(card.get("dateReg")) or list_reg,
|
||||
"approved_organization": card.get("approvedOrganization"),
|
||||
"source_schema": schema,
|
||||
"source_key": source_key,
|
||||
"cad_nums": parse_cad_nums(card.get("spatialUnit")),
|
||||
"geojson": extract_geojson(card),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GROUP_CODE",
|
||||
"SCHEMAS",
|
||||
"extract_geojson",
|
||||
"fetch_doc_card",
|
||||
"fetch_group_list",
|
||||
"load_gisogd_permits",
|
||||
"parse_cad_nums",
|
||||
]
|
||||
|
|
@ -19,12 +19,21 @@ searchMap отдаёт в properties **только {id}** — название/
|
|||
|
||||
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
|
||||
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
|
||||
|
||||
WAF-hygiene (#2445 item D3): sibling-скрапер без backoff словил бан IP по WAF
|
||||
DOM.РФ 24.05.2026 (#2443) — okn-mk.mkrf.ru имеет тот же risk shape (гос. CMS,
|
||||
десятки последовательных запросов без паузы). Идиома пэйсинга/backoff/ретраев
|
||||
портирована из ``gisogd66.py`` (``_RETRY_BACKOFF_S`` + сон между попытками) и
|
||||
``objective.py`` (явная обработка 429/Retry-After): rate-limit между запросами
|
||||
(``_wait_rate_limit``), экспоненциальный backoff на 429/5xx с капом
|
||||
(``_do_request``/``_do_detail_request``), уважение заголовка Retry-After.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -42,6 +51,47 @@ _DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show"
|
|||
_REQUEST_TIMEOUT = 30.0
|
||||
_DETAIL_TIMEOUT = 10.0
|
||||
|
||||
# ── WAF-hygiene: пэйсинг + backoff (#2445 item D3) ────────────────────────────
|
||||
# Вежливая пауза между последовательными запросами (мс) — тот же порядок величины,
|
||||
# что и у objective.py (_DEFAULT_RATE_MS=500) / nspd_client.py (DEFAULT_RATE_MS=600).
|
||||
_RATE_LIMIT_MS = 400
|
||||
# Ретраи на 429/5xx (helper делает суммарно до 1 + _MAX_RETRIES попыток).
|
||||
_MAX_RETRIES = 3
|
||||
# Экспоненциальный backoff на 429/5xx: base * 2**attempt, капается _MAX_BACKOFF_S
|
||||
# (аналогично objective.py: 30 * 2**attempt, cap 300s — здесь эндпоинт легче,
|
||||
# поэтому база/потолок меньше, но идиома та же).
|
||||
_RETRY_BACKOFF_BASE_S = 2.0
|
||||
_MAX_BACKOFF_S = 60.0
|
||||
|
||||
# process-локальная метка времени последнего запроса (обеих эндпоинтов) —
|
||||
# rate-limit общий, чтобы 33 list-запроса и до 1500 detail-запросов не долбили
|
||||
# WAF без пауз даже при чередовании вызовов.
|
||||
_last_request_ts: float = 0.0
|
||||
|
||||
|
||||
def _wait_rate_limit() -> None:
|
||||
"""Выдержать паузу ``_RATE_LIMIT_MS`` с последнего запроса к okn-mk.mkrf.ru.
|
||||
|
||||
Общий для searchMap и detail — обе цели один и тот же WAF-хост.
|
||||
"""
|
||||
global _last_request_ts
|
||||
elapsed_ms = (time.time() - _last_request_ts) * 1000.0
|
||||
if elapsed_ms < _RATE_LIMIT_MS:
|
||||
time.sleep((_RATE_LIMIT_MS - elapsed_ms) / 1000.0)
|
||||
_last_request_ts = time.time()
|
||||
|
||||
|
||||
def _backoff_seconds(attempt: int, retry_after_hdr: str | None) -> float:
|
||||
"""Секунды ожидания перед повтором после 429/5xx.
|
||||
|
||||
Retry-After (если валиден) уважается первым, но капается _MAX_BACKOFF_S —
|
||||
аналогично objective.py. Иначе экспоненциальный backoff от attempt (0-based).
|
||||
"""
|
||||
if retry_after_hdr and retry_after_hdr.isdigit():
|
||||
return min(float(retry_after_hdr), _MAX_BACKOFF_S)
|
||||
return min(_RETRY_BACKOFF_BASE_S * (2**attempt), _MAX_BACKOFF_S)
|
||||
|
||||
|
||||
# Browser headers -- required: without User-Agent the server returns 403.
|
||||
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его
|
||||
# автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется.
|
||||
|
|
@ -99,7 +149,7 @@ def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]:
|
|||
Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0].
|
||||
"""
|
||||
form_data = _build_form_data(address, category_type)
|
||||
features = _do_request(form_data, verify=True)
|
||||
features = _do_request(form_data, verify=True, attempt=0)
|
||||
return features
|
||||
|
||||
|
||||
|
|
@ -123,19 +173,37 @@ def _do_request(
|
|||
form_data: list[tuple[str, str]],
|
||||
*,
|
||||
verify: bool,
|
||||
attempt: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False."""
|
||||
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False.
|
||||
|
||||
WAF-hygiene: rate-limit перед каждой попыткой (``_wait_rate_limit``), на 429/5xx —
|
||||
экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз (аналогично objective.py).
|
||||
"""
|
||||
# urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами
|
||||
# (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple]
|
||||
# конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает
|
||||
# «expected bytes, tuple found». content= передаёт тело как raw str.
|
||||
body = urllib.parse.urlencode(form_data)
|
||||
_wait_rate_limit()
|
||||
try:
|
||||
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client:
|
||||
resp = client.post(
|
||||
_SEARCHMAP_URL,
|
||||
content=body,
|
||||
)
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
|
||||
logger.warning(
|
||||
"okn_egrkn_client: HTTP %d от ЕГРКН (попытка %d/%d), retry через %.1fs",
|
||||
resp.status_code,
|
||||
attempt + 1,
|
||||
_MAX_RETRIES,
|
||||
wait,
|
||||
)
|
||||
time.sleep(wait)
|
||||
return _do_request(form_data, verify=verify, attempt=attempt + 1)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc)
|
||||
|
|
@ -145,7 +213,7 @@ def _do_request(
|
|||
logger.warning(
|
||||
"okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc
|
||||
)
|
||||
return _do_request(form_data, verify=False)
|
||||
return _do_request(form_data, verify=False, attempt=attempt)
|
||||
logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc)
|
||||
return []
|
||||
except Exception as exc:
|
||||
|
|
@ -275,17 +343,36 @@ def fetch_okn_detail(source_id: str) -> dict[str, Any]:
|
|||
"""
|
||||
if not source_id:
|
||||
return {}
|
||||
return _do_detail_request(source_id, verify=True)
|
||||
return _do_detail_request(source_id, verify=True, attempt=0)
|
||||
|
||||
|
||||
def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
|
||||
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap."""
|
||||
def _do_detail_request(source_id: str, *, verify: bool, attempt: int = 0) -> dict[str, Any]:
|
||||
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap.
|
||||
|
||||
WAF-hygiene: rate-limit перед каждой попыткой (общий с searchMap — один WAF-хост),
|
||||
на 429/5xx — экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз.
|
||||
"""
|
||||
params = {"id": source_id}
|
||||
_wait_rate_limit()
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
|
||||
) as client:
|
||||
resp = client.get(_DETAIL_URL, params=params)
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
|
||||
logger.warning(
|
||||
"okn_egrkn_client: detail HTTP %d source_id=%s "
|
||||
"(попытка %d/%d), retry через %.1fs",
|
||||
resp.status_code,
|
||||
source_id,
|
||||
attempt + 1,
|
||||
_MAX_RETRIES,
|
||||
wait,
|
||||
)
|
||||
time.sleep(wait)
|
||||
return _do_detail_request(source_id, verify=verify, attempt=attempt + 1)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
|
|
@ -299,7 +386,7 @@ def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
|
|||
logger.warning(
|
||||
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
|
||||
)
|
||||
return _do_detail_request(source_id, verify=False)
|
||||
return _do_detail_request(source_id, verify=False, attempt=attempt)
|
||||
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
|
||||
return {}
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ HTML server-rendered (нет JS-рендеринга), httpx достаточн
|
|||
|
||||
TLS: сайт использует российский УЦ → verify=False (аналогично ekburg_permits #242).
|
||||
|
||||
Politeness (#2445 D4): пауза ``_RATE_S`` перед каждым запросом (search / карточка
|
||||
документа / PDF) — тот же идиом, что nspd_client/objective/gisogd66 (300-500мс). Этот
|
||||
VPS уже словил WAF-бан от DOM.РФ 24.05.2026 (#2443) за непейсованные последовательные
|
||||
запросы; pravo.gov66.ru — сайт той же формы (гос-портал region, строгий WAF). Ретрай с
|
||||
backoff на 429/5xx — идиом gisogd66._get_json.
|
||||
|
||||
Публичный API:
|
||||
search_documents(query, law_type, since) → list[dict]
|
||||
get_document_pdf_url(doc_id) → str | None
|
||||
|
|
@ -15,6 +21,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -28,6 +35,14 @@ _SEARCH_URL = f"{_BASE_URL}/search/"
|
|||
# Тайм-аут щедрый: сервер иногда медленный.
|
||||
_TIMEOUT = httpx.Timeout(60.0, connect=15.0)
|
||||
|
||||
# Politeness pacing (#2445 D4): пауза ПЕРЕД каждым запросом к pravo.gov66.ru — тот же
|
||||
# идиом (300-500мс), что nspd_client/objective/gisogd66. Не непейсованный WAF-риск,
|
||||
# аналогичный DOM.РФ-бану #2443.
|
||||
_RATE_S = 0.4
|
||||
# Ретраи на 429/5xx: экспоненциальный backoff, суммарно до 1 + _MAX_RETRIES попыток.
|
||||
_MAX_RETRIES = 2
|
||||
_RETRY_BACKOFF_S = 2.0
|
||||
|
||||
# Числовые коды типов документов (select#id_law_type на сайте).
|
||||
# Текстовое значение «Постановление» — неверный параметр, сервер не матчит → 0 хитов.
|
||||
_LAW_TYPE_POSTANOVLENIE = "1" # Постановление Правительства Свердловской области
|
||||
|
|
@ -41,6 +56,43 @@ _RE_PDF_PATH = re.compile(r"/media/pravo/(?!ecp/)[^\s\"']+\.pdf", re.IGNORECASE)
|
|||
_RE_PDF_ECP_PATH = re.compile(r"/media/pravo/ecp/[^\s\"']+\.pdf", re.IGNORECASE)
|
||||
|
||||
|
||||
def _get_with_backoff(
|
||||
client: httpx.Client,
|
||||
url: str,
|
||||
*,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""GET с politeness-паузой перед запросом + backoff-ретраем на 429/5xx.
|
||||
|
||||
Пауза ``_RATE_S`` — ПЕРЕД каждой попыткой (в т.ч. первой): пейсинг
|
||||
последовательных запросов к pravo.gov66.ru, не только ретраев (#2445 D4).
|
||||
429/5xx ретраятся до ``_MAX_RETRIES`` раз с экспоненциальным backoff;
|
||||
прочие статусы (включая 4xx кроме 429) — сразу ``raise_for_status()``.
|
||||
"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(_MAX_RETRIES + 1):
|
||||
time.sleep(_RATE_S)
|
||||
resp = client.get(url, params=params)
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
last_exc = httpx.HTTPStatusError(
|
||||
f"{resp.status_code} on {url}", request=resp.request, response=resp
|
||||
)
|
||||
if attempt >= _MAX_RETRIES:
|
||||
raise last_exc
|
||||
logger.warning(
|
||||
"pravo_gov66_client: %s (попытка %d) на %s — backoff",
|
||||
resp.status_code,
|
||||
attempt + 1,
|
||||
url,
|
||||
)
|
||||
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
def search_documents(
|
||||
query: str,
|
||||
law_type: str = _LAW_TYPE_POSTANOVLENIE,
|
||||
|
|
@ -65,8 +117,7 @@ def search_documents(
|
|||
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||
resp = client.get(_SEARCH_URL, params=params)
|
||||
resp.raise_for_status()
|
||||
resp = _get_with_backoff(client, _SEARCH_URL, params=params)
|
||||
html = resp.text
|
||||
except Exception as exc:
|
||||
logger.error("pravo_gov66_client.search_documents: ошибка сети q=%r: %s", query, exc)
|
||||
|
|
@ -117,8 +168,7 @@ def get_document_pdf_url(doc_id: str) -> str | None:
|
|||
doc_url = f"{_BASE_URL}/{doc_id}/"
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||
resp = client.get(doc_url)
|
||||
resp.raise_for_status()
|
||||
resp = _get_with_backoff(client, doc_url)
|
||||
html = resp.text
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
|
|
@ -151,6 +201,10 @@ def _extract_pdf_url(html: str) -> str | None:
|
|||
def fetch_pdf(url: str) -> bytes:
|
||||
"""Загружает PDF по URL.
|
||||
|
||||
Пейсинг + backoff на 429/5xx через ``_get_with_backoff`` (#2445 D4) — caller
|
||||
(reservation_ingest) НЕ должен добавлять свой sleep перед этим вызовом, пауза
|
||||
уже внутри клиента.
|
||||
|
||||
Args:
|
||||
url: абсолютный URL PDF-файла.
|
||||
|
||||
|
|
@ -161,8 +215,7 @@ def fetch_pdf(url: str) -> bytes:
|
|||
httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully).
|
||||
"""
|
||||
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
resp = _get_with_backoff(client, url)
|
||||
return resp.content
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,11 @@ USER_AGENT = (
|
|||
|
||||
# Максимум одновременных in-page fetch() на одну BrowserSession;
|
||||
# поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant.
|
||||
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется catalog/nspd/прочими скраперами. KN-sweep с
|
||||
# 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
|
||||
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2.
|
||||
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется nspd/прочими скраперами вне /сервисы/* path
|
||||
# family. KN-sweep с 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
|
||||
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2; после
|
||||
# WAF hard-ban 2026-05-24 (#2443) domrf_catalog.py / domrf_catalog_object.py — тот же
|
||||
# /сервисы/* path family — тоже переопределяют на throttled настройки (#2445 D2).
|
||||
_BROWSER_CONCURRENCY = 8
|
||||
|
||||
# Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через
|
||||
|
|
|
|||
|
|
@ -971,11 +971,17 @@ def _fetch_supply_only_layouts(
|
|||
|
||||
Returns:
|
||||
Список SupplyOnlyLayoutRow (может быть пустым). Ошибка SQL не роняет ответ —
|
||||
логируется, откатывается транзакция, возвращается [] (блок аддитивен).
|
||||
логируется, savepoint откатывается, возвращается [] (блок аддитивен).
|
||||
"""
|
||||
if not project_names:
|
||||
return []
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (GET .../best-layouts / §22-отчёт,
|
||||
# Depends(get_db)). Голый db.rollback() тут НЕЛЬЗЯ: он откатил бы ВСЮ outer
|
||||
# tx (всю несохранённую работу этого запроса) и orphan'ит outer
|
||||
# SessionTransaction (см. velocity.py:170). begin_nested откатывает ТОЛЬКО
|
||||
# этот savepoint — outer tx остаётся usable, блок аддитивно деградирует в [].
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
_SUPPLY_ONLY_LOTS_SQL,
|
||||
|
|
@ -990,9 +996,6 @@ def _fetch_supply_only_layouts(
|
|||
.all()
|
||||
)
|
||||
except Exception:
|
||||
# Аддитивный блок — не роняем весь ответ. rollback: следующего execute нет,
|
||||
# но транзакция в aborted-state сломала бы commit/дальнейшую работу caller'а.
|
||||
db.rollback()
|
||||
logger.exception(
|
||||
"best_layouts: supply-only lots query failed for %d projects — empty block",
|
||||
len(project_names),
|
||||
|
|
@ -1120,8 +1123,19 @@ def get_best_layouts(
|
|||
# больше не теряет сделки, привязанные к 1-й через mapping).
|
||||
core_group_to_project: dict[str, str] = {}
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (GET .../best-layouts / §22-отчёт,
|
||||
# Depends(get_db)). Три db.execute здесь перемежаются с ЧИСТО-Python
|
||||
# match_groups_to_projects / _merged_project_name_pool (без db) → оборачиваем
|
||||
# весь блок одним savepoint. Голый db.rollback() НЕЛЬЗЯ: он откатил бы ВСЮ
|
||||
# outer tx + orphan'ит outer SessionTransaction (см. velocity.py:170).
|
||||
# begin_nested откатывает только этот savepoint → следующий execute (velocity
|
||||
# SQL, Step 3) не упадёт InFailedSqlTransaction, «fallback на mapping-only»
|
||||
# состоится.
|
||||
with db.begin_nested():
|
||||
corpus_names = [str(row[0]) for row in db.execute(_OBJECTIVE_PROJECT_NAMES_SQL).all()]
|
||||
lots_names = [str(row[0]) for row in db.execute(_OBJECTIVE_LOTS_PROJECT_NAMES_SQL).all()]
|
||||
lots_names = [
|
||||
str(row[0]) for row in db.execute(_OBJECTIVE_LOTS_PROJECT_NAMES_SQL).all()
|
||||
]
|
||||
project_names = _merged_project_name_pool(corpus_names, lots_names)
|
||||
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
|
||||
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
|
||||
|
|
@ -1131,17 +1145,15 @@ def get_best_layouts(
|
|||
if candidate_names:
|
||||
developers_by_project = {
|
||||
str(row[0]): [str(d) for d in (row[1] or []) if d]
|
||||
for row in db.execute(_OBJECTIVE_DEVELOPERS_SQL, {"names": candidate_names}).all()
|
||||
for row in db.execute(
|
||||
_OBJECTIVE_DEVELOPERS_SQL, {"names": candidate_names}
|
||||
).all()
|
||||
}
|
||||
core_group_to_project = match_groups_to_projects(
|
||||
complex_groups, project_names, developers_by_project
|
||||
)
|
||||
except Exception:
|
||||
# Не роняем весь ответ — core-attribution аддитивна к mapping-источнику.
|
||||
# rollback: при реальной DB-ошибке транзакция в aborted-state — без отката
|
||||
# следующий execute (velocity SQL) упал бы InFailedSqlTransaction, и
|
||||
# «fallback на mapping-only» не состоялся бы.
|
||||
db.rollback()
|
||||
logger.exception(
|
||||
"best_layouts: core-attribution failed for cad_num=%s — fallback to mapping-only",
|
||||
cad_num,
|
||||
|
|
@ -1204,6 +1216,12 @@ def get_best_layouts(
|
|||
# 0 мес» / «% продано —». Per-object latest snapshot встроен в _SUPPLY_BATCH_SQL
|
||||
# (flats_latest CTE) → отдельный pre-compute MAX больше не нужен.
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (GET .../best-layouts, Depends(get_db), sync
|
||||
# get_best_layouts мостится через run_in_threadpool). Голый db.rollback() тут
|
||||
# НЕЛЬЗЯ — orphan'ит outer SessionTransaction (см. velocity.py:170); begin_nested
|
||||
# откатывает только эту savepoint, оставляя db usable для Step 9b
|
||||
# (_fetch_supply_only_layouts) на этой же сессии.
|
||||
with db.begin_nested():
|
||||
supply_rows = (
|
||||
db.execute(
|
||||
_SUPPLY_BATCH_SQL,
|
||||
|
|
|
|||
|
|
@ -449,16 +449,42 @@ _COMPETITORS_SQL = text("""
|
|||
# scraper UPSERT per snapshot). Без фильтра snapshot_date AVG усреднял
|
||||
# ИСТОРИЮ цен (stale на растущем рынке). Берём ТОЛЬКО latest snapshot — зеркало
|
||||
# паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON.
|
||||
#
|
||||
# === PER-OBJECT snapshot fix (#2445 A5, byte-identical bug к #1956 в best_layouts) ===
|
||||
# domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в СВОЙ день,
|
||||
# UNIQUE-снимок (obj_id, snapshot_date), см. migration 174_domrf_kn_flats_obj_
|
||||
# snapshot_idx.sql. Прежний фильтр брал ОДНУ глобальную дату
|
||||
# `f.snapshot_date = MAX(snapshot_date по всей таблице)` — на этой единственной
|
||||
# глобал-max дате присутствует обычно лишь 1 объект (скрейпнутый последним) →
|
||||
# у ВСЕХ остальных конкурентов в радиусе 0 строк flats → avg_price_per_m2=NULL
|
||||
# → _price_similarity(None, ...) молча возвращает нейтраль 0.5, тихо деградируя
|
||||
# relevance-ранжирование конкурентов без единого флага.
|
||||
# Фикс (зеркало best_layouts.flats_latest, #1956): flats_latest берёт для КАЖДОГО
|
||||
# obj_id его СОБСТВЕННЫЙ последний снимок (DISTINCT ON (f.obj_id) ... ORDER BY
|
||||
# f.obj_id, snapshot_date DESC, id DESC), затем self-join к domrf_kn_flats по
|
||||
# (obj_id, snapshot_date) достаёт квартиры именно этого снимка. AVG считается
|
||||
# per-object на СВЕЖЕЙ дате объекта, а не на единственной глобал-max дате.
|
||||
# Слоение с OBJ-3 (#307) не меняется: этот запрос остаётся ПЕРВЫМ источником
|
||||
# (domrf), _OBJECTIVE_PRICE_FALLBACK_SQL — additive fallback ТОЛЬКО для obj_id,
|
||||
# у которых и после фикса нет domrf-цены (см. get_competitors шаг 4a). Ожидаемый
|
||||
# эффект: domrf возвращает реальные цены для БОЛЬШЕ конкурентов → fallback нужен
|
||||
# реже, но композиция (missing_price_ids / price_source) не меняется.
|
||||
_AVG_PRICE_SQL = text("""
|
||||
WITH flats_latest AS (
|
||||
SELECT DISTINCT ON (f.obj_id)
|
||||
f.obj_id, f.snapshot_date
|
||||
FROM domrf_kn_flats f
|
||||
WHERE f.obj_id = ANY(:obj_ids)
|
||||
ORDER BY f.obj_id, f.snapshot_date DESC, f.id DESC
|
||||
)
|
||||
SELECT
|
||||
f.obj_id,
|
||||
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
|
||||
AS avg_price_per_m2
|
||||
FROM domrf_kn_flats f
|
||||
WHERE f.obj_id = ANY(:obj_ids)
|
||||
AND f.snapshot_date = (
|
||||
SELECT MAX(snapshot_date) FROM domrf_kn_flats
|
||||
)
|
||||
FROM flats_latest fl
|
||||
JOIN domrf_kn_flats f
|
||||
ON f.obj_id = fl.obj_id
|
||||
AND f.snapshot_date = fl.snapshot_date
|
||||
GROUP BY f.obj_id
|
||||
""")
|
||||
|
||||
|
|
@ -773,6 +799,11 @@ def get_competitors(
|
|||
avg_price_map: dict[int, float] = {}
|
||||
price_source_map: dict[int, str] = {}
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (POST .../competitors, Depends(get_db)). Голый
|
||||
# db.rollback() orphan'ит outer SessionTransaction (velocity.py:170) — тем более
|
||||
# здесь ЕЩЁ ДВА db.execute идут следом в этой же функции (sold-count,
|
||||
# objective-fallback) на том же db; без savepoint сбой здесь отравил бы их тоже.
|
||||
with db.begin_nested():
|
||||
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
||||
for r in price_rows:
|
||||
oid = int(r["obj_id"])
|
||||
|
|
@ -792,8 +823,13 @@ def get_competitors(
|
|||
# flats_sold → None → нейтральный stage.
|
||||
sold_count_map: dict[int, int] = {}
|
||||
try:
|
||||
# SAVEPOINT — db shared, см. avg_price блок выше (иначе отравляет objective
|
||||
# price fallback ниже на том же db).
|
||||
with db.begin_nested():
|
||||
sold_rows = (
|
||||
db.execute(_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND})
|
||||
db.execute(
|
||||
_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND}
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
|
@ -812,6 +848,10 @@ def get_competitors(
|
|||
missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map]
|
||||
if missing_price_ids:
|
||||
try:
|
||||
# SAVEPOINT — db shared, см. avg_price блок выше. Последний из трёх
|
||||
# db.execute в get_competitors — тоже под savepoint, чтобы дальнейшие
|
||||
# запросы analyze на этом же db (persist_analysis_run и т.п.) не отравились.
|
||||
with db.begin_nested():
|
||||
obj_price_rows = (
|
||||
db.execute(
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
|
|
|
|||
|
|
@ -260,6 +260,10 @@ def _query_gas_city_grs(db: Session) -> dict:
|
|||
"""
|
||||
outlet_counts = _query_gas_outlet_counts(db)
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)).
|
||||
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||||
# begin_nested откатывает только эту savepoint, outer tx остаётся usable.
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
|
|
@ -333,6 +337,8 @@ def _query_gas_outlet_counts(db: Session) -> dict:
|
|||
Таблицы может не быть (миграция 184 не применена) → все нули (graceful, аддитивно).
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||||
with db.begin_nested():
|
||||
row = (
|
||||
db.execute(
|
||||
text("""
|
||||
|
|
@ -384,6 +390,8 @@ def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
|
|||
distance_m, lat, lon}] — отсортировано по distance ASC.
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
|
|
@ -451,6 +459,8 @@ def _query_heat_latest(db: Session) -> dict:
|
|||
"total_reserve_gcal_h": float|None}.
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
|
|
|
|||
|
|
@ -251,6 +251,11 @@ def get_overlaps_for_scoring(
|
|||
"""
|
||||
_parcel_cad = parcel_cad or ""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (POST .../analyze, Depends(get_db)). Голый
|
||||
# db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||||
# begin_nested откатывает только эту savepoint, оставляя db usable для
|
||||
# остальных запросов analyze_parcel на этой же сессии.
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
text(_OVERLAPS_SQL),
|
||||
|
|
|
|||
|
|
@ -142,6 +142,14 @@ def get_developer_attribution(
|
|||
- БД-ошибке, включая ещё не задеплоенную миграцию 149 (analyze не падает).
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db получена от caller (shared request-scoped Session,
|
||||
# analyze_parcel в parcels.py). Голый db.rollback() здесь НЕЛЬЗЯ — он
|
||||
# orphan'ит outer SessionTransaction и роняет persist_analysis_run сразу
|
||||
# после (см. velocity.py:170 / connection_capacity_lookup._query_nearby_
|
||||
# network_zones). Ошибка внутри begin_nested() откатывает ТОЛЬКО эту
|
||||
# savepoint до propagate — outer-транзакция остаётся usable для
|
||||
# последующих db.execute (#2464 cluster A finding 1).
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
_DEVELOPER_FOR_PARCEL_SQL,
|
||||
|
|
|
|||
|
|
@ -68,11 +68,21 @@ def _admin_names(db: Session) -> frozenset[str]:
|
|||
|
||||
На ошибке БД возвращает пустой set (logged) — резолвер тогда трактует ЛЮБОЙ
|
||||
вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.
|
||||
|
||||
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): резолвер
|
||||
зовётся §22-форсайт-билдерами (sales_series `_query_source_a/_b`,
|
||||
`_query_artificial_demand`) на ОБЩЕЙ сессии отчёта, которые сами обёрнуты во
|
||||
внешние SAVEPOINT'ы. БЕЗ `db.begin_nested():` здесь сбойный `db.execute`
|
||||
проглатывается (→ frozenset()), оставляя транзакцию aborted; тогда внешний
|
||||
SAVEPOINT при RELEASE падает (RELEASE запрещён в aborted-tx) и отравление
|
||||
каскадит. Savepoint в точке перехвата откатывает сбой (ROLLBACK TO SAVEPOINT),
|
||||
сессия остаётся рабочей.
|
||||
"""
|
||||
global _ADMIN_NAMES_CACHE
|
||||
if _ADMIN_NAMES_CACHE is not None:
|
||||
return _ADMIN_NAMES_CACHE
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
|
||||
_ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
|
||||
except Exception:
|
||||
|
|
@ -122,7 +132,12 @@ def resolve_objective_districts(db: Session, district: str | None) -> list[str]
|
|||
return [district]
|
||||
|
||||
# district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
|
||||
# SAVEPOINT на внутреннем swallow-сайте (#2464 cluster A, RELEASE-trap): см.
|
||||
# `_admin_names` — тот же общий-сессионный вызов из §22-билдеров под внешним
|
||||
# SAVEPOINT. `db.begin_nested():` откатывает сбой в точке перехвата, чтобы
|
||||
# внешний RELEASE не упал в aborted-tx (RELEASE запрещён в aborted).
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
|
||||
micros = [str(m) for m in rows]
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ def _future_supply_key(
|
|||
"""
|
||||
return (district, horizon_months, premise_kind)
|
||||
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
|
||||
# Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД),
|
||||
|
|
@ -439,6 +440,11 @@ def _query_supply_rows(db: Session, params: Mapping[str, Any]) -> Sequence[Mappi
|
|||
(worker ещё не наполнил м.125) или сбой БД не должны валить расчёт индекса.
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (compute_future_supply_pressure в цепочке
|
||||
# §22-форсайта / analyze). Голый db.rollback() orphan'ит outer SessionTransaction
|
||||
# (см. velocity.py:170) — begin_nested откатывает только эту savepoint, оставляя
|
||||
# db usable для последующих db.execute на этой же сессии.
|
||||
with db.begin_nested():
|
||||
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
|
|
|||
|
|
@ -81,7 +81,12 @@ def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
|
|||
[] при отсутствии пересечений или если таблица пуста/не существует.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
|
||||
except Exception as exc: # таблица не существует / DB-сбой → graceful
|
||||
logger.warning("ird_analyze: _krt_at DB query failed: %s", exc)
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"num_oks_krt": row.num_oks_krt,
|
||||
|
|
@ -91,9 +96,6 @@ def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
|
|||
}
|
||||
for row in rows
|
||||
]
|
||||
except Exception as exc: # таблица не существует / DB-сбой → graceful
|
||||
logger.warning("ird_analyze: _krt_at DB query failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _zone_regulation_at(
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
|
|||
return []
|
||||
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("parcel_krt_requisites: таблица недоступна, skip: %s", exc)
|
||||
|
|
@ -173,6 +174,7 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
|
|||
|
||||
# Нет exact-матча — geom-only поиск по КРТ-топонимам
|
||||
try:
|
||||
with db.begin_nested():
|
||||
no_match_rows = (
|
||||
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -263,9 +263,7 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
|
|||
|
||||
# ── competition + raw demand-velocity ← market_metrics (ОДИН вызов, два сигнала) ─
|
||||
try:
|
||||
metrics = compute_market_metrics(
|
||||
db, district=district, window_months=_MARKET_WINDOW_MONTHS
|
||||
)
|
||||
metrics = compute_market_metrics(db, district=district, window_months=_MARKET_WINDOW_MONTHS)
|
||||
# competition ← overstock_index (доступное/затоваренное конкурирующее
|
||||
# предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с
|
||||
# velocity и инвертирован относительно «свободной конкуренции»).
|
||||
|
|
@ -300,6 +298,15 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
|
|||
logger.exception(
|
||||
"location_indices: infra (POI) failed (district=%s) → infra None", district
|
||||
)
|
||||
# Plain rollback OK here (unlike begin_nested at the pass-2 upsert loop below):
|
||||
# compute_location_indices только вызывается из refresh_locations, единственный
|
||||
# caller которой — Celery-таск location_refresh.py, открывающий СВОЙ SessionLocal()
|
||||
# (owned session, не shared request/report-scoped). На момент этого блока (pass 1,
|
||||
# read-only) в транзакции ещё нет незакоммиченных write'ов — откатывать нечего,
|
||||
# rollback() не сиротит внешний SessionTransaction (в отличие от velocity.py:170).
|
||||
# Без rollback аборченная Postgres-транзакция валит СЛЕДУЮЩИЙ район в цикле pass 1
|
||||
# (см. #2464) — как и все db.execute() в pass 2 upsert.
|
||||
db.rollback()
|
||||
|
||||
logger.info(
|
||||
"location_indices: district=%s infra=%s competition=%s raw_velocity=%s future_supply=%s",
|
||||
|
|
|
|||
|
|
@ -613,6 +613,12 @@ def compute_market_metrics(
|
|||
def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
|
||||
"""Текущий сток. На ошибке/пустых данных → все счётчики 0 (graceful)."""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
|
||||
# / analyze). Голый db.rollback() orphan'ит outer SessionTransaction (см.
|
||||
# velocity.py:170) — begin_nested откатывает только эту savepoint, outer tx
|
||||
# остаётся usable для последующих db.execute (_query_sales_window/_price_sensitivity)
|
||||
# на этой же сессии.
|
||||
with db.begin_nested():
|
||||
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
|
@ -650,6 +656,11 @@ def _query_sales_window(
|
|||
затирать прежние счётчики. На ошибке/пусто → (0, 0.0, {}).
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (см. _query_stock выше). CRITICAL: без него
|
||||
# сбой здесь оставляет транзакцию aborted, и следующий db.execute в этой же
|
||||
# цепочке (_price_sensitivity → _elasticity_coef) падает "current transaction
|
||||
# is aborted" вместо честного graceful-fallback.
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
|
@ -700,6 +711,11 @@ def _price_sensitivity(
|
|||
micros = resolve_objective_districts(db, district)
|
||||
elasticity_window = max(window_months, 24)
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
|
||||
# / analyze). _elasticity_coef делает свой db.execute внутри без try/except —
|
||||
# оборачиваем ВЕСЬ вызов, чтобы сбой откатывал только эту savepoint (не outer
|
||||
# SessionTransaction, см. velocity.py:170), и db оставалась usable дальше.
|
||||
with db.begin_nested():
|
||||
elast = _elasticity_coef(
|
||||
db,
|
||||
region_code=_EKB_REGION_CODE,
|
||||
|
|
@ -810,6 +826,9 @@ _OFFER_TREND_SQL = text(
|
|||
|
||||
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k–500k):
|
||||
# предложение включает премиум/апартаменты; отсекаем только явный ETL-мусор.
|
||||
# ⚠ Значения зеркалированы ЛИТЕРАЛАМИ в partial-предикате покрывающего индекса
|
||||
# data/sql/186_objective_lots_history_offer_trend_covering_idx.sql — при изменении
|
||||
# констант индекс надо пересоздать, иначе offer-trend молча теряет Index-Only Scan.
|
||||
_OFFER_PRICE_MIN: int = 10000
|
||||
_OFFER_PRICE_MAX: int = 1000000
|
||||
|
||||
|
|
@ -874,13 +893,22 @@ def compute_offer_price_trend(
|
|||
"price_max": _OFFER_PRICE_MAX,
|
||||
}
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (POST .../analyze / §22-отчёт, Depends(get_db)).
|
||||
# 8с-гвард (parcels.py) может отменить этот запрос (QueryCanceled) → без savepoint
|
||||
# транзакция остаётся aborted и следующий db.execute падает "current transaction
|
||||
# is aborted". begin_nested откатывает только этот savepoint, db остаётся usable.
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s)",
|
||||
except Exception as exc:
|
||||
# Ожидаемый путь: 8с-гвард (parcels.py) отменяет запрос на холодном кэше
|
||||
# (~2/сутки, профилирование 2026-07-04) — блок аддитивный, analyze успешен.
|
||||
# WARNING, не exception: ERROR-уровень поднимал ложные GlitchTip-события.
|
||||
logger.warning(
|
||||
"market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s): %s",
|
||||
center_lat,
|
||||
center_lon,
|
||||
radius_m,
|
||||
exc,
|
||||
)
|
||||
rows = []
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ def parcel_okn_objects(
|
|||
if not parcel_wkt:
|
||||
return []
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
_OKN_DWITHIN_SQL,
|
||||
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
|
||||
|
|
|
|||
|
|
@ -169,6 +169,11 @@ def _query_current(db: Session) -> list[OwnProject]:
|
|||
)
|
||||
return []
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (get_own_portfolio в цепочке §25.3 / analyze).
|
||||
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||||
# begin_nested откатывает только эту savepoint, оставляя db usable для
|
||||
# _query_future (следующий вызов на этой же сессии в get_own_portfolio).
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("own_portfolio: current (domrf) query failed → []")
|
||||
|
|
@ -200,6 +205,10 @@ def _query_future(db: Session) -> list[OwnProject]:
|
|||
Сбой/отсутствие таблицы (cold-start до миграции 148) → [] (НЕ crash).
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (см. _query_current выше). begin_nested
|
||||
# откатывает только эту savepoint (НЕ голый db.rollback(), см. velocity.py:170),
|
||||
# оставляя db usable для остальных потребителей той же сессии (§25.3 / analyze).
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_FUTURE_SQL).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("own_portfolio: future (own_planned_project) query failed → []")
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ def parcel_pat_subzones(db: Session, parcel_wkt: str | None) -> list[dict[str, A
|
|||
return []
|
||||
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (analyze_parcel, shared request-scoped Session).
|
||||
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||||
# begin_nested откатывает только эту savepoint, не мешая persist_analysis_run
|
||||
# и прочим db.execute дальше по /analyze (#2464 cluster A finding 7).
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_PAT_INTERSECTS_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
# Таблица ещё не задеплоена / структура изменилась — graceful degrade.
|
||||
|
|
|
|||
135
backend/app/services/site_finder/permits_nearby.py
Normal file
135
backend/app/services/site_finder/permits_nearby.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Точный радиус-запрос разрешений на строительство/ввод рядом с участком (ГИСОГД-66).
|
||||
|
||||
Заменяет TODO-прокси quarter-prefix из analyze_parcel (#105 Phase 5): после того как
|
||||
Phase 3 (geocoding → geom) наполнила `gisogd_permits.geom`, соседние РНС/РВЭ ищутся
|
||||
точным `ST_DWithin(..., radius_m)` по геометрии, а не грубым совпадением префикса
|
||||
кадастрового квартала. Старый quarter-prefix путь (`ekburg_construction_permits` →
|
||||
`recent_permits_in_quarter` / `permits_summary`) НЕ трогается — он остаётся параллельно
|
||||
как отдельный, более богатый полями источник; этот модуль лишь добавляет второй,
|
||||
геометрически честный ключ payload.
|
||||
|
||||
`gisogd_permits`: id / doc_group ('RS' — разрешение на строительство, 'RV' — ввод в
|
||||
эксплуатацию) / doc_num / doc_name / date_doc / date_reg / approved_organization /
|
||||
cad_nums TEXT[] / geom geometry(MultiPolygon, 4326), GIST-индекс на geom.
|
||||
|
||||
PURE-по-данным (никаких выдуманных чисел): пустой радиус — честный ноль
|
||||
(total_count=0, items=[]), НЕ ошибка и НЕ None. Вызывается из синхронного analyze_parcel
|
||||
с той же non-fatal семантикой.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Максимум записей в списке items. Счётчики (total_count/rs_count/rv_count/
|
||||
# nearest_distance_m) считаются по ПОЛНОЙ выборке радиуса — БЕЗ LIMIT в SQL, чтобы не было
|
||||
# тихого капа (проектная конвенция «без тихих капов»: тот же паттерн чинили в дедупе
|
||||
# объявлений и фрешнес-мониторе). LIMIT капает лишь выдачу items → items_truncated=True.
|
||||
_ITEMS_LIMIT = 30
|
||||
|
||||
# Радиус-запрос по образцу POI-в-радиусе (osm_poi_ekb) из analyze_parcel: ST_DWithin по
|
||||
# geography-центроиду участка. `::geography` приклеено к `)`, НЕ к bind-name → psycopg v3
|
||||
# его не съедает (backend.md: исключение из :name::type-запрета). БЕЗ LIMIT: агрегаты честны
|
||||
# по всей выборке; в радиусе 500м GIST-индекс делает выборку дешёвой, тысяч строк не будет.
|
||||
_PERMITS_NEARBY_SQL = text("""
|
||||
SELECT
|
||||
doc_group,
|
||||
doc_name,
|
||||
doc_num,
|
||||
date_doc,
|
||||
date_reg,
|
||||
approved_organization,
|
||||
ST_Distance(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
) AS distance_m
|
||||
FROM gisogd_permits
|
||||
WHERE geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
:radius_m
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
""")
|
||||
|
||||
|
||||
def _empty_result(radius_m: int) -> dict[str, Any]:
|
||||
"""Честный нулевой агрегат: 0 разрешений в радиусе (НЕ ошибка, НЕ None)."""
|
||||
return {
|
||||
"radius_m": radius_m,
|
||||
"total_count": 0,
|
||||
"rs_count": 0,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": None,
|
||||
"items": [],
|
||||
"items_truncated": False,
|
||||
"source": "gisogd66",
|
||||
}
|
||||
|
||||
|
||||
def get_permits_nearby(db: Session, geom_wkt: str, radius_m: int = 500) -> dict[str, Any]:
|
||||
"""РНС/РВЭ из ГИСОГД-66 в радиусе `radius_m` метров от участка (точный ST_DWithin).
|
||||
|
||||
Args:
|
||||
db: активная сессия SQLAlchemy.
|
||||
geom_wkt: WKT-геометрия анализируемого участка (та же переменная, что для POI).
|
||||
radius_m: радиус поиска в метрах (по умолчанию 500).
|
||||
|
||||
Returns:
|
||||
Агрегат с ЧЕСТНЫМИ счётчиками (см. `_empty_result` для пустой формы):
|
||||
radius_m, total_count / rs_count ('RS') / rv_count ('RV') — по ВСЕЙ выборке радиуса
|
||||
(без тихого капа), nearest_distance_m (round 1, None если пусто),
|
||||
items (≤30 ближайших), items_truncated (True если total_count > len(items)),
|
||||
source="gisogd66". Пустой радиус → честный ноль.
|
||||
"""
|
||||
rows = [
|
||||
dict(r)
|
||||
for r in db.execute(
|
||||
_PERMITS_NEARBY_SQL,
|
||||
{"wkt": geom_wkt, "radius_m": radius_m},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
]
|
||||
|
||||
if not rows:
|
||||
return _empty_result(radius_m)
|
||||
|
||||
# Счётчики — по ПОЛНОЙ выборке (все rows), список items — только первые _ITEMS_LIMIT.
|
||||
rs_count = sum(1 for r in rows if r["doc_group"] == "RS")
|
||||
rv_count = sum(1 for r in rows if r["doc_group"] == "RV")
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for r in rows[:_ITEMS_LIMIT]:
|
||||
date_doc = r["date_doc"]
|
||||
distance_m = r["distance_m"]
|
||||
items.append(
|
||||
{
|
||||
"doc_group": r["doc_group"],
|
||||
"doc_name": r["doc_name"],
|
||||
"doc_num": r["doc_num"],
|
||||
"date_doc": date_doc.isoformat() if date_doc is not None else None,
|
||||
"approved_organization": r["approved_organization"],
|
||||
"distance_m": round(distance_m, 1) if distance_m is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
nearest = items[0]["distance_m"] if items else None
|
||||
total_count = len(rows)
|
||||
return {
|
||||
"radius_m": radius_m,
|
||||
"total_count": total_count,
|
||||
"rs_count": rs_count,
|
||||
"rv_count": rv_count,
|
||||
"nearest_distance_m": nearest,
|
||||
"items": items,
|
||||
"items_truncated": total_count > len(items),
|
||||
"source": "gisogd66",
|
||||
}
|
||||
|
|
@ -109,6 +109,7 @@ def parcel_ppt_tep(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
|
|||
if not parcel_wkt:
|
||||
return []
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ def resolve_cad_for_domrf(
|
|||
cad_num search).
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
row = (
|
||||
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
|
||||
.mappings()
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ def _get_zoning(
|
|||
if layer_counts is not None and layer_counts.get("territorial_zones_count", 1) == 0:
|
||||
return None
|
||||
try:
|
||||
with db.begin_nested():
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -453,6 +454,7 @@ def _get_zouit_overlaps(
|
|||
# geography для корректных м². Делитель — площадь участка; guard от деления на
|
||||
# ноль через NULLIF(...,0) → NULL → coverage_pct None в Python. Zone-4326
|
||||
# выражение совпадает с ST_Intersects ниже (transform 3857→4326).
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -547,6 +549,7 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
|
|||
# схлопывает в одну, оставляя наименьший id (ORDER BY reg_numb_border, id).
|
||||
# reg_numb_border — TEXT NOT NULL (0 NULL на проде), так что рег-номер есть у
|
||||
# каждой строки и дедуп идёт по реальному идентификатору зоны.
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -634,6 +637,7 @@ def _get_engineering_nearby(
|
|||
if layer_counts is not None and layer_counts.get("engineering_count", 1) == 0:
|
||||
return []
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -750,6 +754,7 @@ def _get_risk_zones(
|
|||
if layer_counts is not None and layer_counts.get("risks_count", 1) == 0:
|
||||
return []
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -870,6 +875,7 @@ def _get_opportunity_parcels(
|
|||
if layer_counts is not None and layer_counts.get("opportunity_count", 1) == 0:
|
||||
return []
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -974,6 +980,7 @@ def _get_red_lines(
|
|||
if layer_counts is not None and layer_counts.get("red_lines_count", 1) == 0:
|
||||
return []
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -1138,6 +1145,8 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
|
|||
"in_protection_zone": False,
|
||||
"protection_zones_intersecting": 0,
|
||||
"total_structures_in_radius": 0,
|
||||
"protection_zones_truncated": False,
|
||||
"structures_truncated": False,
|
||||
},
|
||||
"dump_available": False,
|
||||
"dump_fetched_at": fetched_at_iso,
|
||||
|
|
@ -1166,20 +1175,25 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
|
|||
_trigger_harvest(quarter)
|
||||
return _empty_unavailable(dump_fetched_at)
|
||||
|
||||
structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m)
|
||||
zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
|
||||
structures, structures_total = _get_engineering_structures_by_boundary(
|
||||
db, quarter, parcel_wkt, radius_m
|
||||
)
|
||||
zouit_overlaps, zouit_total = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
|
||||
|
||||
nearest_dist: float | None = structures[0]["distance_to_boundary_m"] if structures else None
|
||||
protection_count = len(zouit_overlaps)
|
||||
|
||||
return {
|
||||
"engineering_structures": structures,
|
||||
"zouit_engineering_overlaps": zouit_overlaps,
|
||||
"summary": {
|
||||
"nearest_structure_distance_m": nearest_dist,
|
||||
"in_protection_zone": protection_count > 0,
|
||||
"protection_zones_intersecting": protection_count,
|
||||
"total_structures_in_radius": len(structures),
|
||||
"in_protection_zone": zouit_total > 0,
|
||||
# Epic #2445 A1: истинные счётчики по ПОЛНОЙ выборке (COUNT(*) без LIMIT),
|
||||
# НЕ len(list) — раньше тихо капались на 100/50 (LIMIT в SQL row-fetch).
|
||||
"protection_zones_intersecting": zouit_total,
|
||||
"total_structures_in_radius": structures_total,
|
||||
"protection_zones_truncated": zouit_total > len(zouit_overlaps),
|
||||
"structures_truncated": structures_total > len(structures),
|
||||
},
|
||||
"dump_available": True,
|
||||
"dump_fetched_at": dump_fetched_at,
|
||||
|
|
@ -1227,18 +1241,61 @@ def _get_parcel_wkt(db: Session, cad_num: str) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
_ENGINEERING_STRUCTURES_ITEMS_LIMIT = 50
|
||||
|
||||
# Условие радиус-выборки engineering_structures — используется И в row-fetch (с ORDER BY +
|
||||
# LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на 50, но
|
||||
# summary.total_structures_in_radius обязан считаться по ВСЕЙ выборке, не по len(list)).
|
||||
_ENGINEERING_STRUCTURES_WHERE = """
|
||||
AND feat.value->>'layer' = 'engineering_structures'
|
||||
AND (feat.value->'geometry') IS NOT NULL
|
||||
AND feat.value->>'geometry' != 'null'
|
||||
AND ST_DWithin(
|
||||
ST_Transform(
|
||||
ST_SetSRID(
|
||||
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||
3857
|
||||
),
|
||||
4326
|
||||
)::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography,
|
||||
:radius_m
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _get_engineering_structures_by_boundary(
|
||||
db: Session,
|
||||
quarter: str,
|
||||
parcel_wkt: str,
|
||||
radius_m: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Engineering structures из dump в radius_m от boundary участка.
|
||||
|
||||
Использует ST_Distance к boundary (не centroid) для корректного расстояния.
|
||||
Geometry трансформируется из EPSG:3857 (хранение dump) → 4326.
|
||||
|
||||
Returns:
|
||||
(items, total_count) — items ограничен _ENGINEERING_STRUCTURES_ITEMS_LIMIT
|
||||
(ближайшие), total_count — ЧЕСТНЫЙ COUNT(*) по всей радиус-выборке (epic #2445
|
||||
A1: раньше summary.total_structures_in_radius = len(items) тихо капался на 50).
|
||||
"""
|
||||
params = {"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m}
|
||||
try:
|
||||
with db.begin_nested():
|
||||
total_count = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT COUNT(*)"
|
||||
" FROM nspd_quarter_dumps d,"
|
||||
" jsonb_array_elements(d.features_json) AS feat(value)"
|
||||
" WHERE d.quarter_cad = :q" + _ENGINEERING_STRUCTURES_WHERE
|
||||
),
|
||||
params,
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -1256,26 +1313,14 @@ def _get_engineering_structures_by_boundary(
|
|||
) AS distance_m
|
||||
FROM nspd_quarter_dumps d,
|
||||
jsonb_array_elements(d.features_json) AS feat(value)
|
||||
WHERE d.quarter_cad = :q
|
||||
AND feat.value->>'layer' = 'engineering_structures'
|
||||
AND (feat.value->'geometry') IS NOT NULL
|
||||
AND feat.value->>'geometry' != 'null'
|
||||
AND ST_DWithin(
|
||||
ST_Transform(
|
||||
ST_SetSRID(
|
||||
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||
3857
|
||||
),
|
||||
4326
|
||||
)::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography,
|
||||
:radius_m
|
||||
)
|
||||
WHERE d.quarter_cad = :q"""
|
||||
+ _ENGINEERING_STRUCTURES_WHERE
|
||||
+ """
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT 50
|
||||
LIMIT :items_limit
|
||||
"""
|
||||
),
|
||||
{"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m},
|
||||
{**params, "items_limit": _ENGINEERING_STRUCTURES_ITEMS_LIMIT},
|
||||
).fetchall()
|
||||
except (OperationalError, ProgrammingError, DataError) as e:
|
||||
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
|
||||
|
|
@ -1284,7 +1329,7 @@ def _get_engineering_structures_by_boundary(
|
|||
quarter,
|
||||
e,
|
||||
)
|
||||
return []
|
||||
return [], 0
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
|
|
@ -1349,7 +1394,7 @@ def _get_engineering_structures_by_boundary(
|
|||
"source": "nspd_36328",
|
||||
}
|
||||
)
|
||||
return result
|
||||
return result, int(total_count)
|
||||
|
||||
|
||||
def _clean_struct_str(value: Any) -> str | None:
|
||||
|
|
@ -1390,24 +1435,12 @@ def _build_structure_characteristics(opts: dict[str, Any]) -> str | None:
|
|||
return " · ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _get_zouit_engineering_overlaps(
|
||||
db: Session,
|
||||
quarter: str,
|
||||
parcel_wkt: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок.
|
||||
_ZOUIT_ENGINEERING_ITEMS_LIMIT = 100
|
||||
|
||||
Использует слой 'zouit_engineering' из dump.
|
||||
"""
|
||||
try:
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT feat.value->'properties' AS props,
|
||||
feat.value->>'geometry' AS geom_json
|
||||
FROM nspd_quarter_dumps d,
|
||||
jsonb_array_elements(d.features_json) AS feat(value)
|
||||
WHERE d.quarter_cad = :q
|
||||
# Условие пересечения zouit_engineering с участком — используется И в row-fetch (с
|
||||
# ORDER BY + LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на
|
||||
# 100, но summary.protection_zones_intersecting обязан считаться по ВСЕЙ выборке).
|
||||
_ZOUIT_ENGINEERING_WHERE = """
|
||||
AND feat.value->>'layer' = 'zouit_engineering'
|
||||
AND (feat.value->'geometry') IS NOT NULL
|
||||
AND feat.value->>'geometry' != 'null'
|
||||
|
|
@ -1421,10 +1454,57 @@ def _get_zouit_engineering_overlaps(
|
|||
),
|
||||
ST_GeomFromText(:wkt, 4326)
|
||||
)
|
||||
LIMIT 100
|
||||
"""
|
||||
|
||||
|
||||
def _get_zouit_engineering_overlaps(
|
||||
db: Session,
|
||||
quarter: str,
|
||||
parcel_wkt: str,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок.
|
||||
|
||||
Использует слой 'zouit_engineering' из dump.
|
||||
|
||||
Returns:
|
||||
(items, total_count) — items ограничен _ZOUIT_ENGINEERING_ITEMS_LIMIT,
|
||||
детерминированно отсортирован (epic #2445 A1: раньше не было ORDER BY перед
|
||||
LIMIT 100 → недетерминированные 100 строк на каждый запрос). total_count —
|
||||
ЧЕСТНЫЙ COUNT(*) по всей intersects-выборке (раньше summary.
|
||||
protection_zones_intersecting = len(items) тихо капался на 100).
|
||||
"""
|
||||
params = {"q": quarter, "wkt": parcel_wkt}
|
||||
try:
|
||||
with db.begin_nested():
|
||||
total_count = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT COUNT(*)"
|
||||
" FROM nspd_quarter_dumps d,"
|
||||
" jsonb_array_elements(d.features_json) AS feat(value)"
|
||||
" WHERE d.quarter_cad = :q" + _ZOUIT_ENGINEERING_WHERE
|
||||
),
|
||||
params,
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT feat.value->'properties' AS props,
|
||||
feat.value->>'geometry' AS geom_json
|
||||
FROM nspd_quarter_dumps d,
|
||||
jsonb_array_elements(d.features_json) AS feat(value)
|
||||
WHERE d.quarter_cad = :q"""
|
||||
+ _ZOUIT_ENGINEERING_WHERE
|
||||
+ """
|
||||
ORDER BY feat.value->'properties'->>'reg_numb_border' NULLS LAST,
|
||||
feat.value->>'geometry'
|
||||
LIMIT :items_limit
|
||||
"""
|
||||
),
|
||||
{"q": quarter, "wkt": parcel_wkt},
|
||||
{**params, "items_limit": _ZOUIT_ENGINEERING_ITEMS_LIMIT},
|
||||
).fetchall()
|
||||
except (OperationalError, ProgrammingError, DataError) as e:
|
||||
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
|
||||
|
|
@ -1433,7 +1513,7 @@ def _get_zouit_engineering_overlaps(
|
|||
quarter,
|
||||
e,
|
||||
)
|
||||
return []
|
||||
return [], 0
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
|
|
@ -1466,7 +1546,7 @@ def _get_zouit_engineering_overlaps(
|
|||
"source": "nspd_37578",
|
||||
}
|
||||
)
|
||||
return result
|
||||
return result, int(total_count)
|
||||
|
||||
|
||||
# ── Harvest trigger ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ def parcel_riasurt_gate(
|
|||
return dict(RIASURT_EMPTY_GATE)
|
||||
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_RIASURT_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||
except (OperationalError, ProgrammingError, DataError) as exc:
|
||||
logger.warning("parcel_riasurt_gate: таблица/запрос недоступны, skip: %s", exc)
|
||||
|
|
|
|||
|
|
@ -237,6 +237,14 @@ def compute_district_saturation(db: Session, district_name: str) -> dict[str, An
|
|||
psycopg v3: bind через CAST(:dn AS text). Read-only.
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (analyze_parcel, parcels.py). Caller УЖЕ
|
||||
# оборачивает вызов этой функции в свой db.begin_nested(), но это НЕ спасает:
|
||||
# если db.execute здесь падает и мы её же тут глотаем (return None, не re-raise),
|
||||
# исключение никогда не долетает до caller-savepoint → её ROLLBACK TO SAVEPOINT
|
||||
# никогда не срабатывает, а сам Postgres-connection уже aborted. Нужна СОБСТВЕННАЯ
|
||||
# savepoint здесь — откатывается на выходе из `with` ДО того, как исключение
|
||||
# поймано ниже, оставляя outer-транзакцию (caller'а) usable.
|
||||
with db.begin_nested():
|
||||
row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first()
|
||||
except Exception:
|
||||
logger.exception("saturation: query failed (district=%s)", district_name)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics): пус
|
|||
м.108-audit). Поэтому при free_flats IS NULL hidden НЕ вычислим (flat_count в одиночку
|
||||
бессмыслен). Такие объекты ИСКЛЮЧАЮТСЯ из L2 (preferred path из ТЗ — чище, чем
|
||||
выдуманный sold-proxy) → coverage снижается → confidence падает с medium до low.
|
||||
Тот же exclusion — И при flat_count IS NULL (#2445-A3: заполняется независимо от
|
||||
free_flats, м.124) — иначе GREATEST(0, COALESCE(flat_count,0) − free_flats) молча
|
||||
давал 0 «скрытых» для объекта с генуинно неизвестным flat_count, раздувая coverage.
|
||||
• L2 district-resolution: используем domrf_kn_objects.district_name НАПРЯМУЮ (м.57
|
||||
spatial-join по lat/long в полигоны ekb_districts_geom). Это самый прямой путь —
|
||||
БЕЗ complexes/complex_sources join (которые сами строятся из этой же колонки, м.73).
|
||||
|
|
@ -244,6 +247,21 @@ _L1_OPEN_SQL = text(
|
|||
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
|
||||
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
|
||||
#
|
||||
# #2445-A3 (audit): flat_count ТОЖЕ nullable и заполняется НЕЗАВИСИМО от free_flats
|
||||
# (м.124 — "NULL = не заполнено"). Раньше фильтр гейтил ТОЛЬКО free_flats IS NOT NULL:
|
||||
# при flat_count IS NULL (частично забэкфилленный снапшот) GREATEST(0, COALESCE(flat_count,
|
||||
# 0) - free_flats) = GREATEST(0, -free_flats) = 0 — объект молча давал 0 hidden units,
|
||||
# ХОТЯ его истинный hidden НЕИЗВЕСТЕН (не 0), И при этом всё равно попадал в
|
||||
# n_with_free_flats → раздувал coverage-деноминатор (denominator inflation). Зеркалит баг,
|
||||
# которого уже избегает pure-хелпер _hidden_units() (возвращает None при flat_count IS
|
||||
# NULL) — но тот хелпер не вызывается из этого SQL-пути (только из тестов), поэтому баг
|
||||
# жил в проде. Fix: гейтим ОБА (COUNT-деноминатор и SUM) на
|
||||
# flat_count IS NOT NULL AND free_flats IS NOT NULL — объект с любым из двух NULL
|
||||
# полностью исключается (как из числителя coverage, так и из hidden_units), а не
|
||||
# засчитывается как "0 скрытых". Сиблинг-конвенция: 60_v_zk_rosreestr_velocity.sql /
|
||||
# 64_v_zk_rosreestr_velocity.sql уже гейтят `o.flat_count IS NOT NULL AND o.flat_count > 0`
|
||||
# на этой же таблице.
|
||||
#
|
||||
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
|
||||
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
|
||||
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
|
||||
|
|
@ -268,10 +286,14 @@ _L2_HIDDEN_SQL = text(
|
|||
SELECT
|
||||
district_name,
|
||||
COUNT(*) AS n_objects_total,
|
||||
COUNT(*) FILTER (WHERE free_flats IS NOT NULL) AS n_with_free_flats,
|
||||
COUNT(*) FILTER (
|
||||
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
|
||||
) AS n_with_free_flats,
|
||||
COALESCE(SUM(
|
||||
GREATEST(0, COALESCE(flat_count, 0) - free_flats)
|
||||
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
|
||||
GREATEST(0, flat_count - free_flats)
|
||||
) FILTER (
|
||||
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
|
||||
), 0) AS hidden_units
|
||||
FROM latest
|
||||
WHERE ready_dt IS NULL
|
||||
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
|
||||
|
|
@ -397,8 +419,11 @@ def compute_layer2_hidden(
|
|||
|
||||
Per dom.рф-объект hidden = max(0, flat_count − free_flats); Σ к району. ОБЕ колонки
|
||||
на одной строке domrf_kn_objects → НЕ нужен domrf↔objective маппинг (см. docstring
|
||||
модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки → не вычислить;
|
||||
documented limitation) — снижают coverage → confidence medium→low.
|
||||
модуля). Объекты с free_flats IS NULL ИЛИ flat_count IS NULL полностью исключаются
|
||||
(#2445-A3: обе колонки nullable и заполняются независимо — объект с известным
|
||||
free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой
|
||||
объект НЕ считается «покрытым» и не входит в coverage-числитель) — снижают coverage
|
||||
→ confidence medium→low.
|
||||
|
||||
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
|
||||
units_estimate всегда ≥ 0 (clamp). Пустые данные → []. Не бросает.
|
||||
|
|
@ -413,11 +438,16 @@ def compute_layer2_hidden(
|
|||
out: list[SupplyLayerRow] = []
|
||||
for r in rows:
|
||||
n_total = int(r["n_objects_total"] or 0)
|
||||
# n_with_free_flats (SQL) = объекты, где ОБЕ free_flats и flat_count известны
|
||||
# (#2445-A3) — это единственные объекты, для которых hidden вообще вычислим.
|
||||
# Имя колонки оставлено как есть (ripple в SQL/тестах/logger); семантика теперь
|
||||
# "покрыто и вычислимо", не только "free_flats заполнен".
|
||||
n_with = int(r["n_with_free_flats"] or 0)
|
||||
# hidden_units приходит из SQL уже клампленным (GREATEST(0, ...)); страхуемся.
|
||||
hidden = max(0, int(r["hidden_units"] or 0))
|
||||
if n_with <= 0:
|
||||
# Нет ни одного объекта с free_flats → нечего объявлять скрытым (а не «0»).
|
||||
# Нет ни одного объекта с ВЫЧИСЛИМЫМ hidden (оба flat_count и free_flats
|
||||
# известны) → нечего объявлять скрытым (а не «0»).
|
||||
continue
|
||||
confidence = _l2_confidence(n_total, n_with)
|
||||
out.append(
|
||||
|
|
@ -434,7 +464,7 @@ def compute_layer2_hidden(
|
|||
confidence=confidence,
|
||||
method=(
|
||||
f"domrf_kn_objects: Σ max(0, flat_count − free_flats); "
|
||||
f"coverage {n_with}/{n_total} objects with free_flats"
|
||||
f"coverage {n_with}/{n_total} objects with flat_count & free_flats known"
|
||||
),
|
||||
snapshot_date=snapshot_date,
|
||||
)
|
||||
|
|
@ -540,8 +570,17 @@ def _safe_rows(
|
|||
|
||||
Зеркало market_metrics._query_* — тонкие данные/сбой БД не должны валить расчёт
|
||||
остальных слоёв или worker'а целиком.
|
||||
|
||||
SAVEPOINT (``db.begin_nested()``): ``compute_all_layers`` вызывает эту функцию 3×
|
||||
(L1/L2/L3) на ОДНОЙ Session — без savepoint сбой L1 отравил бы транзакцию и L2/L3
|
||||
молча получили бы [] тоже (плюс любой db.execute ПОСЛЕ compute_all_layers на этом же
|
||||
db, включая caller'а — worker owns SessionLocal(), но при вызове из shared
|
||||
request-scoped db, напр. forecasting/orchestrator.build_site_finder_report,
|
||||
отравился бы весь оставшийся запрос). Голый db.rollback() здесь НЕЛЬЗЯ — см.
|
||||
velocity.py:170.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
return db.execute(sql, dict(params)).mappings().all()
|
||||
except Exception:
|
||||
# L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
|
||||
|
|
|
|||
|
|
@ -657,9 +657,30 @@ def get_nearby_utility_infrastructure(
|
|||
if parcel_wkt is None:
|
||||
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
||||
|
||||
# Условие радиус-выборки — общее для честного COUNT(*) (без LIMIT) и row-fetch
|
||||
# (с ORDER BY + LIMIT). Epic #2445 A2: summary.total_features раньше = len(features),
|
||||
# тихо капался на `limit` (default 200) даже когда в радиусе фич больше.
|
||||
where_clause = """
|
||||
WHERE ST_DWithin(
|
||||
u.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
"""
|
||||
params = {"wkt": parcel_wkt, "radius_m": radius_m}
|
||||
|
||||
total_features = (
|
||||
db.execute(
|
||||
text("SELECT COUNT(*) FROM osm_utility_infrastructure_ekb u" + where_clause),
|
||||
params,
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
text(
|
||||
"""
|
||||
SELECT osm_id, osm_type, infrastructure_kind, name, source_tag,
|
||||
u.tags AS tags,
|
||||
ST_Distance(
|
||||
|
|
@ -667,16 +688,14 @@ def get_nearby_utility_infrastructure(
|
|||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
) AS distance_m,
|
||||
ST_AsGeoJSON(u.geom) AS geojson
|
||||
FROM osm_utility_infrastructure_ekb u
|
||||
WHERE ST_DWithin(
|
||||
u.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
FROM osm_utility_infrastructure_ekb u"""
|
||||
+ where_clause
|
||||
+ """
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT :lim
|
||||
"""),
|
||||
{"wkt": parcel_wkt, "radius_m": radius_m, "lim": limit},
|
||||
"""
|
||||
),
|
||||
{**params, "lim": limit},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
@ -706,11 +725,15 @@ def get_nearby_utility_infrastructure(
|
|||
}
|
||||
)
|
||||
|
||||
total_features_int = int(total_features)
|
||||
return {
|
||||
"features": features,
|
||||
"summary": {
|
||||
"total_features": len(features),
|
||||
# Epic #2445 A2: честный COUNT(*) по всей радиус-выборке — НЕ len(features)
|
||||
# (которое капалось на `limit`, default 200).
|
||||
"total_features": total_features_int,
|
||||
"nearest_distance_m": nearest_overall,
|
||||
"nearest_by_kind": nearest_by_kind,
|
||||
"features_truncated": total_features_int > len(features),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ class VelocityResult:
|
|||
proxy_used: bool = False
|
||||
# proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none.
|
||||
proxy_sqm_per_deal: float | None = None
|
||||
# Audit #2445 A4: доля report_month-строк ЕКБ-бенчмарка (_get_ekb_median), где
|
||||
# deals_total_vol_m2 IS NULL и объём фабриковался через count×45.0 (тот же proxy,
|
||||
# что и rosreestr_fallback, но внутри city-wide нормализующей медианы). 0.0 —
|
||||
# медиана целиком из реальных deals_total_vol_m2 (или бенчмарк не считался/fallback-константа).
|
||||
ekb_median_proxy_fraction: float = 0.0
|
||||
# RANK 1 (DCF velocity schedule): абсорбция ОДНОГО типового конкурента района,
|
||||
# м²/мес = monthly_velocity_sqm / max(n_with_sales, 1). Это НЕ суммарная скорость
|
||||
# района (monthly_velocity_sqm), а темп выбытия одного участника рынка — им и
|
||||
|
|
@ -103,6 +108,14 @@ class VelocityResult:
|
|||
# per-project absorption там ill-defined и НЕ должен драйвить график) и на
|
||||
# degenerate/zero путях.
|
||||
project_absorption_sqm_per_month: float | None = None
|
||||
# #2464 cluster B: True когда запрос конкурентов (Step 1, `LIMIT 200`) упёрся
|
||||
# в лимит — competitors_count тогда "минимум 200" (реальных ЖК в радиусе может
|
||||
# быть больше), а не честный total. Тот же silent-cap класс, что чинили в
|
||||
# utility_infrastructure_loader (`features_truncated`, epic #2445 A2); здесь —
|
||||
# флаг, а не второй COUNT(*): 200 конкурентов-ЖК в 3км радиусе — крайне
|
||||
# редкий edge (плотная застройка), доп. geo-запрос на каждый analyze того не
|
||||
# стоит — честная disclosure флагом дешевле.
|
||||
competitors_truncated: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
@ -120,11 +133,13 @@ class VelocityResult:
|
|||
"objective_coverage_pct": self.objective_coverage_pct,
|
||||
"proxy_used": self.proxy_used,
|
||||
"proxy_sqm_per_deal": self.proxy_sqm_per_deal,
|
||||
"ekb_median_proxy_fraction": round(self.ekb_median_proxy_fraction, 3),
|
||||
"project_absorption_sqm_per_month": (
|
||||
round(self.project_absorption_sqm_per_month, 1)
|
||||
if self.project_absorption_sqm_per_month is not None
|
||||
else None
|
||||
),
|
||||
"competitors_truncated": self.competitors_truncated,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -307,10 +322,20 @@ def compute_velocity(
|
|||
# SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными).
|
||||
coverage_pct = round(mapped_ratio * 100, 1)
|
||||
|
||||
ekb_median = (
|
||||
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
||||
)
|
||||
# Audit #2445 A4: _get_ekb_median возвращает (median, proxy_row_fraction) — доля
|
||||
# строк бенчмарка, где объём фабриковался через count*45.0. При DB-fallback
|
||||
# (None — нет данных) proxy_fraction неизвестна → 0.0 (используем calibrated
|
||||
# константу, а не contaminated медиану, так что disclosure не нужен).
|
||||
_ekb_median_result = _get_ekb_median(db, months_window=months_window)
|
||||
if _ekb_median_result is not None:
|
||||
ekb_median, ekb_median_proxy_fraction = _ekb_median_result
|
||||
else:
|
||||
ekb_median = _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
||||
ekb_median_proxy_fraction = 0.0
|
||||
n_comps = len(comp_rows)
|
||||
# #2464 cluster B: Step 1 query капнут `LIMIT 200` (см. выше) — если выборка
|
||||
# уперлась в лимит, реальных конкурентов в радиусе может быть больше.
|
||||
n_comps_truncated = n_comps >= 200
|
||||
sample_no_data = sorted(
|
||||
[
|
||||
{
|
||||
|
|
@ -342,6 +367,8 @@ def compute_velocity(
|
|||
ekb_median=ekb_median,
|
||||
sample_competitors=sample_no_data,
|
||||
objective_coverage_pct=coverage_pct,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
competitors_truncated=n_comps_truncated,
|
||||
)
|
||||
if rr_result is not None:
|
||||
return rr_result
|
||||
|
|
@ -363,7 +390,9 @@ def compute_velocity(
|
|||
objective_coverage_pct=coverage_pct,
|
||||
proxy_used=False,
|
||||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
|
||||
competitors_truncated=n_comps_truncated,
|
||||
)
|
||||
|
||||
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
|
||||
|
|
@ -470,6 +499,8 @@ def compute_velocity(
|
|||
ekb_median=ekb_median,
|
||||
sample_competitors=sample_no_data,
|
||||
objective_coverage_pct=coverage_pct,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
competitors_truncated=n_comps_truncated,
|
||||
)
|
||||
if rr_result is not None:
|
||||
return rr_result
|
||||
|
|
@ -503,7 +534,9 @@ def compute_velocity(
|
|||
objective_coverage_pct=coverage_pct,
|
||||
proxy_used=False,
|
||||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
|
||||
competitors_truncated=n_comps_truncated,
|
||||
)
|
||||
|
||||
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
|
||||
|
|
@ -580,7 +613,9 @@ def compute_velocity(
|
|||
objective_coverage_pct=coverage_pct,
|
||||
proxy_used=False,
|
||||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
project_absorption_sqm_per_month=project_absorption,
|
||||
competitors_truncated=n_comps_truncated,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -592,6 +627,8 @@ def _compute_rosreestr_fallback(
|
|||
ekb_median: float,
|
||||
sample_competitors: list[dict[str, Any]],
|
||||
objective_coverage_pct: float = 0.0,
|
||||
ekb_median_proxy_fraction: float = 0.0,
|
||||
competitors_truncated: bool = False,
|
||||
) -> VelocityResult | None:
|
||||
"""Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.
|
||||
|
||||
|
|
@ -681,13 +718,18 @@ def _compute_rosreestr_fallback(
|
|||
objective_coverage_pct=objective_coverage_pct,
|
||||
proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется)
|
||||
proxy_sqm_per_deal=avg_area_per_deal,
|
||||
# Audit #2445 A4: пробрасываем proxy-долю бенчмарка (ekb_median посчитан
|
||||
# ДО вызова этого fallback) — даже здесь denominator может быть частично
|
||||
# contaminated count*45.0-строками.
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
# RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции —
|
||||
# per-project absorption ill-defined → None, НЕ драйвит DCF-график.
|
||||
project_absorption_sqm_per_month=None,
|
||||
competitors_truncated=competitors_truncated,
|
||||
)
|
||||
|
||||
|
||||
def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
|
||||
def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float] | None:
|
||||
"""ЕКБ-wide медиана monthly velocity (м²/мес) per ЖК — benchmark.
|
||||
|
||||
Источник: objective_corpus_room_month (актуальные данные, обновляется еженедельно).
|
||||
|
|
@ -697,6 +739,24 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
|
|||
|
||||
Учитываются только ЖК с ≥3 месяцами данных за окно (стабильный сигнал).
|
||||
Fallback к _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH если нет данных в БД.
|
||||
|
||||
Audit #2445 A4: report_month-строки с deals_total_count>0, но
|
||||
deals_total_vol_m2 IS NULL, фабрикуют объём через COALESCE(..., count*45.0) —
|
||||
тот же proxy-приём, что и в rosreestr_fallback (avg_area_per_deal), только
|
||||
здесь он был необъявленным и контаминировал city-wide бенчмарк, к которому
|
||||
нормализуется velocity_score КАЖДОГО участка. Строки не исключаются (иначе
|
||||
продающийся, но без м²-данных ЖК просто выпадает из бенчмарка — тоже
|
||||
искажение), но доля таких строк считается и возвращается вызывающему коду
|
||||
для disclosure (см. VelocityResult.ekb_median_proxy_fraction), а не
|
||||
проглатывается молча как чистые данные.
|
||||
|
||||
Возвращает (median, proxy_row_fraction) либо None если данных нет.
|
||||
proxy_row_fraction — доля (project, room, month)-строк (среди вошедших в
|
||||
медиану project'ов, ≥3 отчётных месяца), где объём взят из count*45.0,
|
||||
а не из реального deals_total_vol_m2. Считается на уровне строк
|
||||
objective_corpus_room_month (таблица хранит строку per project×room×month —
|
||||
см. data/sql/100_fix_mv_layout_velocity_weighted_avg.sql), НЕ на уровне
|
||||
report_month, чтобы не менять исходный HAVING-гейт «≥3 месяца данных».
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
|
|
@ -704,23 +764,38 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
WITH per_project AS (
|
||||
WITH source_rows AS (
|
||||
SELECT
|
||||
project_name,
|
||||
SUM(COALESCE(deals_total_vol_m2,
|
||||
deals_total_count * 45.0)) AS total_sqm,
|
||||
COUNT(DISTINCT report_month) AS months_data
|
||||
report_month,
|
||||
COALESCE(deals_total_vol_m2,
|
||||
deals_total_count * 45.0) AS row_sqm,
|
||||
(deals_total_vol_m2 IS NULL) AS is_proxy_row
|
||||
FROM objective_corpus_room_month
|
||||
WHERE report_month >= (CURRENT_DATE - CAST(:window_interval AS interval))
|
||||
AND deals_total_count > 0
|
||||
),
|
||||
per_project AS (
|
||||
SELECT
|
||||
project_name,
|
||||
SUM(row_sqm) AS total_sqm,
|
||||
COUNT(DISTINCT report_month) AS months_data,
|
||||
COUNT(*) AS rows_data,
|
||||
COUNT(*) FILTER (WHERE is_proxy_row) AS proxy_rows
|
||||
FROM source_rows
|
||||
GROUP BY project_name
|
||||
HAVING COUNT(DISTINCT report_month) >= 3
|
||||
),
|
||||
per_project_velocity AS (
|
||||
SELECT total_sqm / months_data AS velocity
|
||||
SELECT total_sqm / months_data AS velocity,
|
||||
rows_data,
|
||||
proxy_rows
|
||||
FROM per_project
|
||||
)
|
||||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median
|
||||
SELECT
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median,
|
||||
COALESCE(SUM(proxy_rows), 0) AS proxy_rows_total,
|
||||
COALESCE(SUM(rows_data), 0) AS rows_total
|
||||
FROM per_project_velocity
|
||||
"""
|
||||
),
|
||||
|
|
@ -734,6 +809,10 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
|
|||
# SAVEPOINT auto-rollback'нут
|
||||
return None
|
||||
|
||||
if row and row["median"] is not None:
|
||||
return float(row["median"])
|
||||
if row is None or row["median"] is None:
|
||||
return None
|
||||
|
||||
rows_total = int(row["rows_total"] or 0)
|
||||
proxy_rows_total = int(row["proxy_rows_total"] or 0)
|
||||
proxy_fraction = (proxy_rows_total / rows_total) if rows_total > 0 else 0.0
|
||||
return float(row["median"]), proxy_fraction
|
||||
|
|
|
|||
|
|
@ -470,9 +470,15 @@ def backfill_ekb_zone_regulations(
|
|||
upserted += 1
|
||||
except Exception as exc:
|
||||
# Per-zone изоляция: один сбойный фетч/апсёрт не должен ронять весь прогон.
|
||||
logger.warning(
|
||||
"backfill_ekb_zone_regulations: зона idx=%s failed: %s", zone_index, exc
|
||||
)
|
||||
# rollback() (не begin_nested): db здесь — СОБСТВЕННАЯ сессия таска
|
||||
# backfill_zone_regulations.py (SessionLocal() + try/finally close), не
|
||||
# shared request-scoped Session — orphan'ить нечего, следующая итерация
|
||||
# цикла (get_cached_zone_regulation / upsert_zone_regulation) на этом же db
|
||||
# иначе поймает "current transaction is aborted" после сбойного db.commit()
|
||||
# выше (#2464 cluster A finding 6; сиблинг — write_db.rollback() в
|
||||
# get_or_fetch_zone_regulation, тоже owned-сессия).
|
||||
db.rollback()
|
||||
logger.warning("backfill_ekb_zone_regulations: зона idx=%s failed: %s", zone_index, exc)
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -361,6 +361,18 @@ def build_beat_schedule() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# #2367: реестр РНС/РВЭ Свердловской области из открытого API ГИСОГД-СО
|
||||
# (gisogd66.midural.ru, раздел 13). Источник обновляется ежедневно, но лоадер
|
||||
# инкрементален (карточки тянутся только для новых/изменившихся) → еженедельно с
|
||||
# запасом. Источник БЕЗ гео-блока и БЕЗ авторизации — не привязан к прод-IP. 06:30
|
||||
# МСК вторник — в том же вторник-кластере, что и capacity-sync (последний — heat в
|
||||
# 06:00), сразу после него, вне monday-нагрузки. Celery conf.timezone=Europe/Moscow.
|
||||
schedule["gisogd-permits-weekly"] = {
|
||||
"task": "tasks.gisogd_permits_sync.sync_gisogd_permits",
|
||||
"schedule": _parse_cron("30 6 * * tue"), # 06:30 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# Developer registry refresh (#1088 «GG-форсайт») — ежемесячно 1-го числа в 05:30 МСК,
|
||||
# через 30 мин ПОСЛЕ ekburg-permits-monthly (05:00), чтобы permits-сторона реестра
|
||||
# обновлялась на свежих РНС/РВЭ. fn_refresh_developer_registry() → REFRESH MATERIALIZED
|
||||
|
|
@ -408,6 +420,24 @@ def build_beat_schedule() -> dict:
|
|||
# "options": {"queue": "celery"},
|
||||
# }
|
||||
|
||||
# Catalog-FLAT scrape (#2442 Task 2) — наполняет per-flat catalog-поля
|
||||
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
|
||||
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
|
||||
#
|
||||
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
|
||||
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
|
||||
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
|
||||
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
|
||||
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
|
||||
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
|
||||
# напр. четверг 04:00 UTC.
|
||||
# schedule["scrape-kn-catalog-flats-weekly"] = {
|
||||
# "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
|
||||
# "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC
|
||||
# "kwargs": {"region_code": 66, "max_flats": 300},
|
||||
# "options": {"queue": "celery"},
|
||||
# }
|
||||
|
||||
# Quarter price index refresh (#762) — monthly on the 5th at 05:00 MSK.
|
||||
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
|
||||
# Chains: mv_quarter_price_per_m2 → mv_quarter_price_index (in order, both CONCURRENTLY).
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ celery_app = Celery(
|
|||
include=[
|
||||
"app.workers.tasks.scrape_kn",
|
||||
"app.workers.tasks.scrape_kn_catalog_objects",
|
||||
"app.workers.tasks.scrape_kn_catalog_flats",
|
||||
"app.workers.tasks.refresh_analytics",
|
||||
"app.workers.tasks.scrape_objective",
|
||||
"app.workers.tasks.objective_etl",
|
||||
|
|
@ -89,6 +90,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.mv_sales_tracker_refresh",
|
||||
"app.workers.tasks.scrape_freshness_check",
|
||||
"app.workers.tasks.connection_capacity_sync",
|
||||
"app.workers.tasks.gisogd_permits_sync",
|
||||
],
|
||||
)
|
||||
celery_app.conf.timezone = "Europe/Moscow"
|
||||
|
|
|
|||
|
|
@ -3,25 +3,116 @@
|
|||
Расписание: 03:30 МСК (hardcoded в beat_schedule, как cbr/poi/supply-layers).
|
||||
Идемпотентен: UPSERT ON CONFLICT (source, ext_house_id).
|
||||
Если TRADEIN_DATABASE_URL не задан → warn-log, возвращает {"disabled": True}.
|
||||
|
||||
Breadcrumb (issue #2445 D6): прогон логируется в objective_scrape_runs (тот же
|
||||
журнал, что используют соседние nightly/weekly-таски — см.
|
||||
app/workers/tasks/objective_etl.py) с group_name='newbuilding-crossload', чтобы
|
||||
сбой был виден оператору в admin-таблице логов, а не только в Celery FAILURE +
|
||||
GlitchTip. Таблица переиспользуется как есть — без новой миграции; поля
|
||||
rows_lots/rows_history/requests_count не про Objective-домен здесь, а про
|
||||
source_rows/upserted/skipped cross-load'а (см. комментарии у _finish_run).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.etl.newbuilding_crossload import run_crossload
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_GROUP_NAME = "newbuilding-crossload"
|
||||
|
||||
|
||||
def _start_run(db: Session, triggered_by: str) -> int:
|
||||
"""Создать breadcrumb-строку в objective_scrape_runs, статус 'running'."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO objective_scrape_runs (group_name, triggered_by, status)
|
||||
VALUES (:gn, :tb, 'running')
|
||||
RETURNING run_id
|
||||
"""
|
||||
),
|
||||
{"gn": _GROUP_NAME, "tb": triggered_by},
|
||||
).scalar_one()
|
||||
db.commit()
|
||||
return int(row)
|
||||
|
||||
|
||||
def _finish_run(
|
||||
db: Session, run_id: int, *, status: str, error: str | None = None, **counts: int
|
||||
) -> None:
|
||||
"""Обновить breadcrumb-строку финальным статусом ('done' | 'failed') + счётчиками."""
|
||||
sets = ["finished_at = NOW()", "status = :status", "error = :error"]
|
||||
params: dict[str, Any] = {"rid": run_id, "status": status, "error": error}
|
||||
for k, v in counts.items():
|
||||
sets.append(f"{k} = :{k}")
|
||||
params[k] = v
|
||||
db.execute(
|
||||
text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
|
||||
params,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@celery_app.task(name="tasks.etl_newbuilding_crossload.etl_newbuilding_crossload")
|
||||
def etl_newbuilding_crossload() -> dict:
|
||||
def etl_newbuilding_crossload(triggered_by: str = "beat") -> dict:
|
||||
"""Cross-load tradein.houses → gendesign.newbuilding_listings (#976).
|
||||
|
||||
Вызывается beat'ом в 03:30 МСК и вручную через POST /api/v1/admin/scrape/newbuilding-crossload.
|
||||
|
||||
Args:
|
||||
triggered_by: 'beat' | 'manual' | 'api' — для журнала objective_scrape_runs.
|
||||
|
||||
Breadcrumb-строка в objective_scrape_runs логирует start/success/failure —
|
||||
на исключении из run_crossload() помечает run 'failed' и ре-рейзит, чтобы
|
||||
Celery ушёл в FAILURE и GlitchTip продолжил ловить сбой (issue #2445 D6).
|
||||
"""
|
||||
logger.info("task etl_newbuilding_crossload: start")
|
||||
result = run_crossload()
|
||||
logger.info("task etl_newbuilding_crossload: done — %s", result)
|
||||
return result
|
||||
db = SessionLocal()
|
||||
run_id: int | None = None
|
||||
try:
|
||||
run_id = _start_run(db, triggered_by)
|
||||
logger.info("task etl_newbuilding_crossload: start run_id=%s", run_id)
|
||||
|
||||
result = run_crossload(db=db)
|
||||
|
||||
if result.get("disabled"):
|
||||
_finish_run(db, run_id, status="done", error="TRADEIN_DATABASE_URL not set")
|
||||
logger.info("task etl_newbuilding_crossload: done (disabled) run_id=%s", run_id)
|
||||
return {"run_id": run_id, **result}
|
||||
|
||||
_finish_run(
|
||||
db,
|
||||
run_id,
|
||||
status="done",
|
||||
error=None,
|
||||
rows_history=result.get("source_rows", 0),
|
||||
rows_lots=result.get("upserted", 0),
|
||||
requests_count=result.get("skipped", 0),
|
||||
)
|
||||
logger.info("task etl_newbuilding_crossload: done run_id=%s — %s", run_id, result)
|
||||
return {"run_id": run_id, **result}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("task etl_newbuilding_crossload: failed run_id=%s: %s", run_id, e)
|
||||
if run_id is not None:
|
||||
try:
|
||||
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
|
||||
except Exception:
|
||||
# Breadcrumb-flush не должен скрыть исходное исключение — Celery
|
||||
# всё равно должен уйти в FAILURE + GlitchTip его поймает.
|
||||
logger.exception(
|
||||
"task etl_newbuilding_crossload: failed to write failure breadcrumb "
|
||||
"for run_id=%s",
|
||||
run_id,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
69
backend/app/workers/tasks/gisogd_permits_sync.py
Normal file
69
backend/app/workers/tasks/gisogd_permits_sync.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Celery task: инкрементальная синхронизация реестра РНС/РВЭ ГИСОГД-СО (#2367).
|
||||
|
||||
Источник: открытый JSON-API gisogd66.midural.ru (раздел 13), БЕЗ авторизации и БЕЗ
|
||||
гео-блока — задача может выполняться и локально, и на проде. Держим weekly (обновляется
|
||||
ежедневно, но лоадер инкрементален и дёшев при отсутствии изменений). Persistent
|
||||
breadcrumbs в nspd_geo_log (как connection_capacity_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("gisogd_permits breadcrumb failed: %s", e)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.gisogd_permits_sync.sync_gisogd_permits",
|
||||
queue="celery",
|
||||
# Холодный прогон ~6k карточек (2 схемы × RS+RV) при politeness-sleep укладывается
|
||||
# в ~25 мин; щедрый лимит по конвенции full_report/forecast, чтобы beat-ран не убили
|
||||
# mid-upsert. Инкрементальные прогоны — минуты.
|
||||
time_limit=3600,
|
||||
soft_time_limit=3300,
|
||||
)
|
||||
def sync_gisogd_permits() -> dict:
|
||||
"""Инкрементально загрузить РНС/РВЭ ГИСОГД-СО (обе схемы, обе группы) → gisogd_permits.
|
||||
|
||||
Список группы → сверка с БД по (source_key, date_reg) → карточки только для
|
||||
новых/изменившихся → UPSERT по (doc_group, doc_num) с предпочтением позднего date_reg.
|
||||
|
||||
Returns: счётчики {listed, fetched_details, inserted, updated, skipped_unchanged,
|
||||
no_cad, no_geom}.
|
||||
"""
|
||||
_log_breadcrumb("gisogd_permits_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.scrapers.gisogd66 import load_gisogd_permits
|
||||
|
||||
result = load_gisogd_permits()
|
||||
logger.info("sync_gisogd_permits done: %s", result)
|
||||
_log_breadcrumb("gisogd_permits_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_gisogd_permits failed: %s", e)
|
||||
_log_breadcrumb("gisogd_permits_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
|
@ -151,15 +151,15 @@ def ingest_izyatie_ocr() -> dict[str, int]:
|
|||
|
||||
logger.info("ingest_izyatie_ocr: загружаем %r → %s", doc_title[:60], doc_url)
|
||||
|
||||
# Весь per-document body (fetch → OCR → extract → upsert → commit) — в
|
||||
# одном try/except. Сбой ЛЮБОГО шага (битый PDF на fetch, Tesseract-crash
|
||||
# на OCR, баг парсера на неожиданном OCR-тексте, ошибка upsert/commit)
|
||||
# логируется и переходит к следующему документу вместо падения всей
|
||||
# еженедельной пачки (issue #2445 D5). db.rollback() откатывает
|
||||
# незакоммиченные изменения текущей итерации, чтобы они не отравили
|
||||
# commit следующего документа.
|
||||
try:
|
||||
pdf_bytes = fetch_pdf(doc_url)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"ingest_izyatie_ocr: не удалось загрузить PDF %s: %s",
|
||||
doc_url,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
ocr_text = ocr_pdf_text(pdf_bytes)
|
||||
if not ocr_text.strip():
|
||||
|
|
@ -187,6 +187,23 @@ def ingest_izyatie_ocr() -> dict[str, int]:
|
|||
total_parcels += count
|
||||
processed_docs += 1
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ingest_izyatie_ocr: пропуск документа %r (%s): %s",
|
||||
doc_title[:60],
|
||||
doc_url,
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception as rollback_exc:
|
||||
logger.warning(
|
||||
"ingest_izyatie_ocr: rollback после сбоя документа %s тоже упал: %s",
|
||||
doc_url,
|
||||
rollback_exc,
|
||||
)
|
||||
continue
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, raw_props, fe
|
|||
``db.begin_nested()``.
|
||||
|
||||
Beat: еженедельно в воскресенье в 04:30 МСК — до ird-harvest (понедельник 05:00).
|
||||
|
||||
WAF-hygiene (#2445 item D3): пэйсинг и 429/5xx backoff между HTTP-запросами живут
|
||||
в ``okn_egrkn_client`` (``_wait_rate_limit`` — общий для 33 list-проходов и до
|
||||
``_DETAIL_FETCH_LIMIT`` detail-запросов, один WAF-хост). Здесь, в
|
||||
``_enrich_with_detail``, — circuit breaker: ``_DETAIL_FAIL_BREAKER`` детейл-фейлов
|
||||
ПОДРЯД → прерываем detail-loop (не долбим WAF до конца лимита при устойчивом бане),
|
||||
аналогично ``gisogd66.py`` (``_CARD_FAIL_BREAKER``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -89,6 +96,12 @@ _DETAIL_FETCH_LIMIT = 1500
|
|||
# Логгировать прогресс detail-fetch каждые N объектов.
|
||||
_DETAIL_PROGRESS_EVERY = 100
|
||||
|
||||
# Circuit breaker (#2445 item D3, аналогично gisogd66._CARD_FAIL_BREAKER): столько
|
||||
# detail-фейлов ПОДРЯД (raise ИЛИ пустой detail считается "не подряд", см. ниже)
|
||||
# → прерываем detail-loop. WAF-бан обычно даёт устойчивую серию ошибок; долбить
|
||||
# оставшиеся до ~1500 detail-запросов бессмысленно и рискованно для IP-репутации.
|
||||
_DETAIL_FAIL_BREAKER = 20
|
||||
|
||||
_UPSERT_SQL = text("""
|
||||
INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at)
|
||||
VALUES (
|
||||
|
|
@ -145,6 +158,7 @@ def _enrich_with_detail(
|
|||
*,
|
||||
detail_fetcher: Any = fetch_okn_detail,
|
||||
limit: int = _DETAIL_FETCH_LIMIT,
|
||||
fail_breaker: int = _DETAIL_FAIL_BREAKER,
|
||||
) -> int:
|
||||
"""Обогатить raw_props detail-полями (name, address, …).
|
||||
|
||||
|
|
@ -154,15 +168,25 @@ def _enrich_with_detail(
|
|||
Записи без detail-полей сохраняются с тем же ``raw_props={id}`` — детали
|
||||
могут быть недоступны (объект удалён из реестра / detail-endpoint лежит).
|
||||
|
||||
Пэйсинг и 429/5xx backoff — на уровне HTTP-клиента (``okn_egrkn_client``),
|
||||
общий rate-limit между списком и detail-запросами. Circuit breaker здесь
|
||||
(#2445 item D3): ``fail_breaker`` вызовов ``detail_fetcher`` ПОДРЯД,
|
||||
завершившихся исключением (сетевая ошибка / устойчивый WAF-бан после
|
||||
исчерпания ретраев клиента) → прерываем цикл, не долбим оставшийся лимит.
|
||||
Успешный вызов (даже с пустым detail — объект без карточки) сбрасывает
|
||||
счётчик подряд-фейлов.
|
||||
|
||||
Args:
|
||||
to_upsert: список row-dict для UPSERT (мутируется in-place).
|
||||
detail_fetcher: callable source_id → dict (для DI в тестах).
|
||||
limit: максимум detail-вызовов за прогон.
|
||||
fail_breaker: детейл-фейлов ПОДРЯД до прерывания цикла.
|
||||
|
||||
Returns:
|
||||
Число фич, для которых detail-поля были успешно подмёрджены.
|
||||
"""
|
||||
enriched = 0
|
||||
consecutive_fail = 0
|
||||
total = min(len(to_upsert), limit)
|
||||
for idx, row in enumerate(to_upsert[:limit], 1):
|
||||
try:
|
||||
|
|
@ -174,6 +198,19 @@ def _enrich_with_detail(
|
|||
exc,
|
||||
)
|
||||
detail = {}
|
||||
consecutive_fail += 1
|
||||
if consecutive_fail >= fail_breaker:
|
||||
logger.error(
|
||||
"okn_objects_sync: %d detail-фейлов подряд — прерываю "
|
||||
"detail-loop (устойчивый бан/сбой WAF), обработано %d/%d",
|
||||
fail_breaker,
|
||||
idx,
|
||||
total,
|
||||
)
|
||||
break
|
||||
continue
|
||||
else:
|
||||
consecutive_fail = 0
|
||||
|
||||
if not detail:
|
||||
continue
|
||||
|
|
@ -224,6 +261,10 @@ def sync_okn_objects() -> dict[str, int]:
|
|||
# Очередь для UPSERT: row-dict для исполнения _UPSERT_SQL.
|
||||
to_upsert: list[dict[str, Any]] = []
|
||||
|
||||
# Пэйсинг между 33 list-запросами (#2445 item D3): fetch_okn_points → _do_request
|
||||
# → okn_egrkn_client._wait_rate_limit перед КАЖДЫМ POST (общий rate-limit с
|
||||
# detail-запросами, один WAF-хост okn-mk.mkrf.ru). Ничего дополнительно спать
|
||||
# здесь не нужно — пэйсинг/backoff инкапсулированы в клиенте.
|
||||
for address, category_type in _PASSES:
|
||||
category = _CATEGORY_MAP[category_type]
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -10,12 +10,17 @@ Beat НЕ настроен: постановления выходят редко
|
|||
celery call tasks.reservation_ingest.ingest_reservations
|
||||
|
||||
Conventions (зеркало ird_harvest.py / backend.md):
|
||||
• SessionLocal() + try/finally close.
|
||||
• Session lifecycle (#2445 D4): PDF fetch происходит БЕЗ открытой сессии — pravo.gov66
|
||||
иногда медленный (таймаут до 60с), держать connection-pool slot на всё время внешнего
|
||||
запроса нельзя. Короткоживущий ``SessionLocal()`` открывается ТОЛЬКО на запись строк
|
||||
одного документа (open → upsert → commit → close per item), не на весь цикл.
|
||||
• SAVEPOINT per-row (``with db.begin_nested():``) — битая строка не валит батч.
|
||||
• CAST(:x AS type) — НИКОГДА :x::text (psycopg v3).
|
||||
• ON CONFLICT (cad_num, act_number) → обновляет метаданные + fetched_at.
|
||||
• httpx.Client (не requests). Сбой одного дока не валит прогон.
|
||||
• q-запросы — 2-3 слова (движок pravo.gov66 строгий AND/фраза, 1 или 4+ → 0 хитов).
|
||||
• Pacing/backoff к pravo.gov66.ru — внутри pravo_gov66_client (_get_with_backoff),
|
||||
НЕ здесь: каждый search/page/pdf запрос уже пейсится на уровне клиента.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -97,8 +102,11 @@ def _fetch_pdf_safe(pdf_url: str) -> bytes | None:
|
|||
return None
|
||||
|
||||
|
||||
def _upsert_records(db: Session, records_meta: list[dict[str, Any]]) -> int:
|
||||
"""UPSERT записей в land_reservation. Возвращает число обработанных строк."""
|
||||
def _upsert_records_in_session(db: Session, records_meta: list[dict[str, Any]]) -> int:
|
||||
"""UPSERT записей в land_reservation на уже открытой сессии.
|
||||
|
||||
Возвращает число upsert'нутых строк.
|
||||
"""
|
||||
count = 0
|
||||
for row in records_meta:
|
||||
try:
|
||||
|
|
@ -114,6 +122,27 @@ def _upsert_records(db: Session, records_meta: list[dict[str, Any]]) -> int:
|
|||
return count
|
||||
|
||||
|
||||
def _upsert_records(records_meta: list[dict[str, Any]]) -> int:
|
||||
"""Открывает короткоживущую сессию ТОЛЬКО на запись одного документа → commit → close.
|
||||
|
||||
Session lifecycle (#2445 D4): вызывается ПОСЛЕ внешнего PDF-fetch, никогда во время
|
||||
него — держать открытую транзакцию/connection-pool slot на время сетевого запроса
|
||||
к pravo.gov66.ru (таймаут до 60с) недопустимо (см. WAF-инцидент #2443).
|
||||
"""
|
||||
if not records_meta:
|
||||
return 0
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
count = _upsert_records_in_session(db, records_meta)
|
||||
db.commit()
|
||||
return count
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _enumerate_documents(since: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Обходит все поисковые запросы и возвращает дедуплицированный список документов.
|
||||
|
||||
|
|
@ -182,10 +211,13 @@ def ingest_reservations(
|
|||
|
||||
|
||||
def _ingest_explicit_docs(docs: list[dict[str, Any]]) -> dict[str, int]:
|
||||
"""Ингестирует явно переданный список документов (back-compat с ручным вызовом)."""
|
||||
"""Ингестирует явно переданный список документов (back-compat с ручным вызовом).
|
||||
|
||||
Session lifecycle (#2445 D4): PDF fetch (сетевой, может занять до 60с) выполняется
|
||||
БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою короткоживущую сессию
|
||||
только на запись строк одного документа (commit+close сразу после).
|
||||
"""
|
||||
total_parcels = 0
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
for doc in docs:
|
||||
url = doc.get("url", "")
|
||||
act_number = doc.get("act_number")
|
||||
|
|
@ -195,7 +227,7 @@ def _ingest_explicit_docs(docs: list[dict[str, Any]]) -> dict[str, int]:
|
|||
|
||||
logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)
|
||||
|
||||
pdf_bytes = _fetch_pdf_safe(url)
|
||||
pdf_bytes = _fetch_pdf_safe(url) # без открытой сессии — только сетевой запрос
|
||||
if pdf_bytes is None:
|
||||
logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
|
||||
continue
|
||||
|
|
@ -219,27 +251,25 @@ def _ingest_explicit_docs(docs: list[dict[str, Any]]) -> dict[str, int]:
|
|||
}
|
||||
for rec in records
|
||||
]
|
||||
count = _upsert_records(db, rows)
|
||||
db.commit()
|
||||
total_parcels += count
|
||||
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
|
||||
|
||||
logger.info(
|
||||
"ingest_reservations(explicit): готово docs=%d parcels=%d",
|
||||
len(docs),
|
||||
total_parcels,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {"docs": len(docs), "parcels": total_parcels}
|
||||
|
||||
|
||||
def _ingest_enumerated_docs(enumerated: list[dict[str, Any]]) -> dict[str, int]:
|
||||
"""Ингестирует документы, найденные через enumerate с pravo.gov66."""
|
||||
"""Ингестирует документы, найденные через enumerate с pravo.gov66.
|
||||
|
||||
Session lifecycle (#2445 D4): оба сетевых вызова на документ (get_document_pdf_url,
|
||||
fetch_pdf) выполняются БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою
|
||||
короткоживущую сессию только на запись строк одного документа.
|
||||
"""
|
||||
total_parcels = 0
|
||||
fetched_docs = 0
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
for hit in enumerated:
|
||||
doc_id: str = hit["doc_id"]
|
||||
title: str = hit.get("title", "")
|
||||
|
|
@ -247,18 +277,16 @@ def _ingest_enumerated_docs(enumerated: list[dict[str, Any]]) -> dict[str, int]:
|
|||
|
||||
logger.info("ingest_reservations(enum): doc_id=%s title=%r", doc_id, title)
|
||||
|
||||
pdf_url = get_document_pdf_url(doc_id)
|
||||
pdf_url = get_document_pdf_url(doc_id) # сеть, без открытой сессии
|
||||
if pdf_url is None:
|
||||
logger.warning(
|
||||
"ingest_reservations(enum): doc_id=%s — PDF не найден на странице", doc_id
|
||||
)
|
||||
continue
|
||||
|
||||
pdf_bytes = _fetch_pdf_safe(pdf_url)
|
||||
pdf_bytes = _fetch_pdf_safe(pdf_url) # сеть, без открытой сессии
|
||||
if pdf_bytes is None:
|
||||
logger.warning(
|
||||
"ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id
|
||||
)
|
||||
logger.warning("ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id)
|
||||
continue
|
||||
|
||||
fetched_docs += 1
|
||||
|
|
@ -281,18 +309,13 @@ def _ingest_enumerated_docs(enumerated: list[dict[str, Any]]) -> dict[str, int]:
|
|||
}
|
||||
for rec in records
|
||||
]
|
||||
count = _upsert_records(db, rows)
|
||||
db.commit()
|
||||
total_parcels += count
|
||||
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
|
||||
|
||||
logger.info(
|
||||
"ingest_reservations(enum): готово fetched=%d parcels=%d",
|
||||
fetched_docs,
|
||||
total_parcels,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {"docs": fetched_docs, "parcels": total_parcels}
|
||||
|
||||
|
||||
|
|
|
|||
214
backend/app/workers/tasks/scrape_kn_catalog_flats.py
Normal file
214
backend/app/workers/tasks/scrape_kn_catalog_flats.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Celery task: periodic catalog-FLAT scrape для DOM.РФ (issue #2442 Task 2).
|
||||
|
||||
Дополняет per-flat catalog-поля (price_rub, price_per_m2, status, finishing_type,
|
||||
ceiling_height_m, section_no, catalog_updated_at) + plan-изображения квартир в
|
||||
domrf_kn_flats из SSR-страниц каталога:
|
||||
https://наш.дом.рф/сервисы/каталог-квартир/квартира/{catalog_url_hash}
|
||||
|
||||
kn-API отдаёт цену для меньшинства квартир (~8.5% NULL-free); остальные поля
|
||||
(отделка, потолки, дата обновления) живут только на публичной странице квартиры.
|
||||
Оркестрация здесь: выборка batch'а из БД + вызов `scrape_catalog_batch`, которая
|
||||
сама держит один BrowserSession + встроенный jitter_sleep (rate-limit/stealth).
|
||||
|
||||
Selector logic (аналог object-level scraper):
|
||||
- catalog_url_hash IS NOT NULL — берём только строки, где kn-sweep уже записал
|
||||
реальный hash. До #2442 Task 1 + свежего kn-sweep это 0 строк (hash хардкодился
|
||||
в NULL) — пустой batch ожидаем/не баг.
|
||||
- catalog_updated_at IS NULL — квартира ещё не скрапилась из каталога, грузим.
|
||||
- catalog_updated_at < CURRENT_DATE - INTERVAL '30 days' — данные протухли, refresh.
|
||||
- force=True — игнорирует «уже свежий» фильтр, грузит все строки последнего
|
||||
snapshot с непустым hash.
|
||||
|
||||
Sequencing dependency (важно): эта задача НЕ дублирует object-level scrape и
|
||||
начнёт возвращать непустые batch'и ТОЛЬКО после того, как:
|
||||
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
|
||||
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
|
||||
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
|
||||
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) — WAF-cooldown на
|
||||
VPS IP; включается вручную после проверки targeted-тестом.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Выбор квартир на catalog-скрап одним запросом (obj-scraper-паттерн: obj_id+snapshot
|
||||
# атомарно, здесь ods_id+catalog_url_hash). snapshot_date = MAX(...) для того же
|
||||
# региона фиксирует последний snapshot и не тянет исторические строки — UPSERT в
|
||||
# scrape_catalog_batch пишет `WHERE ods_id = :ods_id` (без snapshot-скоупа), поэтому
|
||||
# отбираем только актуальный snapshot, чтобы не скрапить устаревшие ods_id.
|
||||
#
|
||||
# catalog_url_hash IS NOT NULL — жёсткое условие: без hash URL страницы не собрать.
|
||||
# Пока Task 1 не задеплоен и kn-sweep не наполнил hash — 0 строк (ожидаемо).
|
||||
#
|
||||
# Фильтр свежести (:force = false): catalog_updated_at IS NULL (никогда не скрапили)
|
||||
# ИЛИ старше 30 дней. При :force = true фильтр снимается — грузим все строки
|
||||
# последнего snapshot с непустым hash.
|
||||
#
|
||||
# LIMIT :max_flats: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
|
||||
# max_flats=None (force «Загрузить все» без явного потолка) грузим ВСЕ подходящие
|
||||
# строки, а не молча режем до _DEFAULT_MAX_FLATS.
|
||||
_SELECT_TARGETS_SQL = text(
|
||||
"""
|
||||
SELECT ods_id, catalog_url_hash, snapshot_date
|
||||
FROM domrf_kn_flats
|
||||
WHERE region_cd = :region_code
|
||||
AND catalog_url_hash IS NOT NULL
|
||||
AND ods_id IS NOT NULL
|
||||
AND (
|
||||
CAST(:force AS boolean)
|
||||
OR catalog_updated_at IS NULL
|
||||
OR catalog_updated_at < CURRENT_DATE - CAST(:stale_days AS integer)
|
||||
)
|
||||
AND snapshot_date = (
|
||||
SELECT MAX(snapshot_date)
|
||||
FROM domrf_kn_flats
|
||||
WHERE region_cd = :region_code
|
||||
)
|
||||
ORDER BY catalog_updated_at NULLS FIRST
|
||||
LIMIT :max_flats
|
||||
"""
|
||||
)
|
||||
|
||||
# Лимит по умолчанию если max_flats не задан явно (обычный beat/ad-hoc pass).
|
||||
_DEFAULT_MAX_FLATS = 300
|
||||
|
||||
# Считаем данные протухшими старше этого числа дней (refresh-порог).
|
||||
_STALE_DAYS = 30
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
|
||||
time_limit=3600,
|
||||
)
|
||||
def scrape_kn_catalog_flats(
|
||||
self: Any,
|
||||
region_code: int = 66,
|
||||
max_flats: int | None = None,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Periodic catalog-flat scrape (цены/отделка/планировки квартир из SSR каталога).
|
||||
|
||||
Args:
|
||||
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
|
||||
max_flats: Максимум квартир за один run. Если не задан: при force=True
|
||||
лимита нет (грузим все), при force=False — _DEFAULT_MAX_FLATS (300).
|
||||
force: Если True — игнорирует фильтр свежести («catalog_updated_at свежий»)
|
||||
и (при не заданном max_flats) снимает лимит, грузя ВСЕ квартиры
|
||||
последнего snapshot с непустым catalog_url_hash (admin «Загрузить все»).
|
||||
По умолчанию False — пропускает то, что скраплено < 30 дней назад, и
|
||||
режет batch до 300.
|
||||
|
||||
Returns:
|
||||
dict с ключами: region_code, force, snapshot_date, flats_count + поля из
|
||||
scrape_catalog_batch (total, success, updated, failed, fields_total).
|
||||
|
||||
Concurrency:
|
||||
No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
|
||||
Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
|
||||
маловероятно. Если случится:
|
||||
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
|
||||
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
|
||||
Add Redis lock если WAF блокирует наблюдается или beat включается на overlap.
|
||||
"""
|
||||
from app.services.scrapers.domrf_catalog import scrape_catalog_batch
|
||||
|
||||
# Явный max_flats всегда уважается. Без него:
|
||||
# force=True («Загрузить все») → лимита нет (limit=None → SQL LIMIT NULL = все строки);
|
||||
# force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_FLATS.
|
||||
if max_flats is not None:
|
||||
limit: int | None = max_flats
|
||||
elif force:
|
||||
limit = None
|
||||
else:
|
||||
limit = _DEFAULT_MAX_FLATS
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
_SELECT_TARGETS_SQL,
|
||||
{
|
||||
"region_code": region_code,
|
||||
"max_flats": limit,
|
||||
"force": force,
|
||||
"stale_days": _STALE_DAYS,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
flats: list[dict[str, Any]] = [
|
||||
{"ods_id": r["ods_id"], "catalog_url_hash": r["catalog_url_hash"]} for r in rows
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.error("scrape_kn_catalog_flats: failed to fetch flats: %s", exc)
|
||||
db.close()
|
||||
raise
|
||||
|
||||
if not flats:
|
||||
logger.info(
|
||||
"scrape_kn_catalog_flats: nothing to do for region=%d (force=%s) — "
|
||||
"likely no catalog_url_hash populated yet (see #2442 sequencing)",
|
||||
region_code,
|
||||
force,
|
||||
)
|
||||
db.close()
|
||||
return {
|
||||
"region_code": region_code,
|
||||
"force": force,
|
||||
"flats_count": 0,
|
||||
"total": 0,
|
||||
"success": 0,
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"fields_total": 0,
|
||||
}
|
||||
|
||||
# snapshot_date из первой строки — все строки одинаковые (WHERE snapshot_date =
|
||||
# MAX(snapshot_date)). Атомарно: один SELECT, без race с kn-scraper.
|
||||
snapshot_date_val: date = rows[0]["snapshot_date"]
|
||||
|
||||
logger.info(
|
||||
"scrape_kn_catalog_flats: region=%d snapshot_date=%s flats=%d limit=%s force=%s",
|
||||
region_code,
|
||||
snapshot_date_val,
|
||||
len(flats),
|
||||
"ALL" if limit is None else limit,
|
||||
force,
|
||||
)
|
||||
|
||||
try:
|
||||
stats = asyncio.run(
|
||||
scrape_catalog_batch(
|
||||
db=db,
|
||||
flats=flats,
|
||||
region_code=region_code,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("scrape_kn_catalog_flats: scrape failed: %s", exc)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"region_code": region_code,
|
||||
"force": force,
|
||||
"snapshot_date": str(snapshot_date_val),
|
||||
"flats_count": len(flats),
|
||||
**stats,
|
||||
}
|
||||
logger.info("scrape_kn_catalog_flats done: %s", result)
|
||||
return result
|
||||
|
|
@ -89,8 +89,8 @@ def test_freshness_mixed_statuses() -> None:
|
|||
"last_status": None,
|
||||
},
|
||||
"nspd_geo_jobs": {
|
||||
"last_success_at": _ago(40.0), # >= stale_days=30 → critical → failed
|
||||
"last_attempt_at": _ago(40.0),
|
||||
"last_success_at": _ago(100.0), # >= stale_days=90 → critical → failed
|
||||
"last_attempt_at": _ago(100.0),
|
||||
"upd_24h": 0,
|
||||
"upd_7d": 0,
|
||||
"last_status": "done",
|
||||
|
|
@ -230,9 +230,7 @@ def test_freshness_all_healthy() -> None:
|
|||
"last_status": "done",
|
||||
"recent_output": 100000, # выше любого floor → flagged-источники ok
|
||||
}
|
||||
rows = {
|
||||
s.table: dict(fresh_row) for s in admin_scrape._FRESHNESS_SOURCES
|
||||
}
|
||||
rows = {s.table: dict(fresh_row) for s in admin_scrape._FRESHNESS_SOURCES}
|
||||
_override_db(_make_db(rows))
|
||||
try:
|
||||
resp = TestClient(app).get(ENDPOINT)
|
||||
|
|
|
|||
108
backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py
Normal file
108
backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
|
||||
|
||||
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
|
||||
/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
|
||||
(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
|
||||
должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
|
||||
smoke-тест/re-trigger не углубил бан.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects"
|
||||
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
|
||||
|
||||
|
||||
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.id = task_id
|
||||
return result
|
||||
|
||||
|
||||
def test_kn_catalog_objects_refuses_without_override() -> None:
|
||||
"""Без i_understand_waf_risk (default False) — 400, task НЕ dispatched."""
|
||||
with patch(
|
||||
"app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async"
|
||||
) as mock_apply_async:
|
||||
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
|
||||
|
||||
assert resp.status_code == 400
|
||||
detail = resp.json()["detail"]
|
||||
assert "2443" in detail
|
||||
assert "i_understand_waf_risk" in detail
|
||||
mock_apply_async.assert_not_called()
|
||||
|
||||
|
||||
def test_kn_catalog_objects_dispatches_with_override() -> None:
|
||||
"""С i_understand_waf_risk=true — таск dispatched как раньше."""
|
||||
with patch(
|
||||
"app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async",
|
||||
return_value=_mock_apply_async_result("obj-task-1"),
|
||||
) as mock_apply_async:
|
||||
resp = TestClient(app).post(
|
||||
OBJECTS_ENDPOINT,
|
||||
json={"region_code": 66, "max_objects": 3, "i_understand_waf_risk": True},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["task_id"] == "obj-task-1"
|
||||
assert body["max_objects"] == 3
|
||||
mock_apply_async.assert_called_once()
|
||||
call_kwargs = mock_apply_async.call_args[1]["kwargs"]
|
||||
assert call_kwargs["region_code"] == 66
|
||||
assert call_kwargs["max_objects"] == 3
|
||||
|
||||
|
||||
def test_kn_catalog_flats_refuses_without_override() -> None:
|
||||
"""Без i_understand_waf_risk (default False) — 400, task НЕ dispatched."""
|
||||
with patch(
|
||||
"app.workers.tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats.apply_async"
|
||||
) as mock_apply_async:
|
||||
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
|
||||
|
||||
assert resp.status_code == 400
|
||||
detail = resp.json()["detail"]
|
||||
assert "2443" in detail
|
||||
assert "i_understand_waf_risk" in detail
|
||||
mock_apply_async.assert_not_called()
|
||||
|
||||
|
||||
def test_kn_catalog_flats_dispatches_with_override() -> None:
|
||||
"""С i_understand_waf_risk=true — таск dispatched как раньше."""
|
||||
with patch(
|
||||
"app.workers.tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats.apply_async",
|
||||
return_value=_mock_apply_async_result("flat-task-1"),
|
||||
) as mock_apply_async:
|
||||
resp = TestClient(app).post(
|
||||
FLATS_ENDPOINT,
|
||||
json={"region_code": 66, "max_flats": 3, "i_understand_waf_risk": True},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["task_id"] == "flat-task-1"
|
||||
assert body["max_flats"] == 3
|
||||
mock_apply_async.assert_called_once()
|
||||
call_kwargs = mock_apply_async.call_args[1]["kwargs"]
|
||||
assert call_kwargs["region_code"] == 66
|
||||
assert call_kwargs["max_flats"] == 3
|
||||
|
||||
|
||||
def test_kn_catalog_objects_explicit_false_still_refuses() -> None:
|
||||
"""i_understand_waf_risk=false явно (не только default) — тоже 400."""
|
||||
with patch(
|
||||
"app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async"
|
||||
) as mock_apply_async:
|
||||
resp = TestClient(app).post(
|
||||
OBJECTS_ENDPOINT, json={"region_code": 66, "i_understand_waf_risk": False}
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
mock_apply_async.assert_not_called()
|
||||
|
|
@ -7,26 +7,26 @@
|
|||
Стратегия mock: аналогична test_analyze_market_price.py — DB mock через
|
||||
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
|
||||
|
||||
Порядок db.execute calls в analyze_parcel (с #29 G2):
|
||||
Порядок db.execute calls в analyze_parcel (с #29 G2, #2464 cluster B):
|
||||
0. UNION ALL geom + source → .mappings().first()
|
||||
1. WKT query → .mappings().first()
|
||||
2. District → .mappings().first()
|
||||
3. POI rows → .mappings().all()
|
||||
4. Competitor rows → .mappings().all()
|
||||
5. Pipeline rows → .mappings().all()
|
||||
6. Centroid lat/lon → .mappings().first()
|
||||
7. Noise rows → .mappings().all()
|
||||
8. Hydrology → .mappings().all()
|
||||
9. Utilities → .mappings().all()
|
||||
10. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2
|
||||
11. Market trend → .mappings().first()
|
||||
12. Zoning (begin_nested) → .mappings().first()
|
||||
13. Success recommendation (begin_nested) → .mappings().all()
|
||||
14. Market price (begin_nested) → .mappings().first()
|
||||
15. Recent permits (begin_nested) → .mappings().all()
|
||||
16. _geotech_risk (industrial count) → .scalar()
|
||||
17. _neighbors_summary (neighbor_rows) → .mappings().all()
|
||||
18. _neighbors_summary (overlap_row) → .mappings().first()
|
||||
5. competitors_total honest COUNT(*) → .scalar() ← NEW #2464 cluster B
|
||||
6. Pipeline rows → .mappings().all()
|
||||
7. Centroid lat/lon → .mappings().first()
|
||||
8. Noise rows → .mappings().all()
|
||||
9. Hydrology → .mappings().all()
|
||||
10. Utilities → .mappings().all()
|
||||
11. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2
|
||||
12. Market trend → .mappings().first()
|
||||
13. Zoning (begin_nested) → .mappings().first()
|
||||
14. Success recommendation (begin_nested) → .mappings().all()
|
||||
15. Market price (begin_nested) → .mappings().first()
|
||||
16. Recent permits (begin_nested) → .mappings().all()
|
||||
17. _geotech_risk (industrial count) → .scalar()
|
||||
18. _neighbors_summary (single statement, neighbors+overlap+total) → .mappings().first()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -82,20 +82,23 @@ def _make_db_for_analyze(
|
|||
("first", district_row), # 2: district
|
||||
("all", []), # 3: POI rows
|
||||
("all", []), # 4: competitor rows
|
||||
("all", []), # 5: pipeline rows
|
||||
("first", centroid_row), # 6: centroid
|
||||
("all", []), # 7: noise rows
|
||||
("all", []), # 8: hydrology rows
|
||||
("all", []), # 9: utilities rows
|
||||
("first", pm_mock), # 10: parcel_meta ← #29 G2
|
||||
("first", None), # 11: market trend
|
||||
("first", None), # 12: zoning (begin_nested)
|
||||
("all", []), # 13: success recommendation (begin_nested)
|
||||
("first", None), # 14: market price (begin_nested)
|
||||
("all", []), # 15: recent permits (begin_nested)
|
||||
("scalar", 0), # 16: geotech_risk
|
||||
("all", []), # 17: neighbors
|
||||
("first", None), # 18: overlap
|
||||
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
|
||||
("all", []), # 6: pipeline rows
|
||||
("first", centroid_row), # 7: centroid
|
||||
("all", []), # 8: noise rows
|
||||
("all", []), # 9: hydrology rows
|
||||
("all", []), # 10: utilities rows
|
||||
("first", pm_mock), # 11: parcel_meta ← #29 G2
|
||||
("first", None), # 12: market trend
|
||||
("first", None), # 13: zoning (begin_nested)
|
||||
("all", []), # 14: success recommendation (begin_nested)
|
||||
("first", None), # 15: market price (begin_nested)
|
||||
("all", []), # 16: recent permits (begin_nested)
|
||||
("scalar", 0), # 17: geotech_risk
|
||||
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
|
||||
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
|
||||
# возвращает ОДНУ строку с этими тремя ключами.
|
||||
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
|
||||
]
|
||||
|
||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||
|
|
|
|||
|
|
@ -125,21 +125,24 @@ def _make_db_for_analyze() -> MagicMock:
|
|||
("first", district_row), # 2: district
|
||||
("all", []), # 3: POI rows
|
||||
("all", []), # 4: competitor rows
|
||||
("all", []), # 5: pipeline rows
|
||||
("first", centroid_row), # 6: centroid
|
||||
("all", []), # 7: noise rows
|
||||
("all", []), # 8: hydrology rows
|
||||
("all", []), # 9: utilities rows
|
||||
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
|
||||
("all", []), # 6: pipeline rows
|
||||
("first", centroid_row), # 7: centroid
|
||||
("all", []), # 8: noise rows
|
||||
("all", []), # 9: hydrology rows
|
||||
("all", []), # 10: utilities rows
|
||||
# (далее nspd_zoning merge не ходит в db.execute — резолвер замокан)
|
||||
("first", None), # 10: parcel_meta
|
||||
("first", None), # 11: market trend
|
||||
("first", None), # 12: zoning (begin_nested)
|
||||
("all", []), # 13: success recommendation
|
||||
("first", None), # 14: market price
|
||||
("all", []), # 15: recent permits
|
||||
("scalar", 0), # 16: geotech_risk
|
||||
("all", []), # 17: neighbors
|
||||
("first", None), # 18: overlap
|
||||
("first", None), # 11: parcel_meta
|
||||
("first", None), # 12: market trend
|
||||
("first", None), # 13: zoning (begin_nested)
|
||||
("all", []), # 14: success recommendation
|
||||
("first", None), # 15: market price
|
||||
("all", []), # 16: recent permits
|
||||
("scalar", 0), # 17: geotech_risk
|
||||
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
|
||||
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
|
||||
# возвращает ОДНУ строку с этими тремя ключами.
|
||||
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
|
||||
]
|
||||
|
||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||
|
|
|
|||
|
|
@ -68,9 +68,7 @@ def _report() -> dict[str, Any]:
|
|||
"summary": "Разброс умеренный.",
|
||||
},
|
||||
"scoring": {
|
||||
"special_indices": {
|
||||
"indices": {"cannibalization": {"value": 0.31, "label": None}}
|
||||
},
|
||||
"special_indices": {"indices": {"cannibalization": {"value": 0.31, "label": None}}},
|
||||
},
|
||||
"confidence": {
|
||||
"level": "medium",
|
||||
|
|
@ -454,6 +452,130 @@ def test_ask_llm_enabled_degrades_to_deterministic_on_failure(
|
|||
assert f"250{_NBSP}000" in body["answer"]
|
||||
|
||||
|
||||
# ── parcel_context: паспорт участка вливается в report_dict и доступен tool'у ─────
|
||||
|
||||
|
||||
class _ParcelInfoProvider(LLMProvider):
|
||||
"""Fake: 1-й вызов — tool-call get_parcel_info, 2-й — финальная проза (grounding)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
self.captured_fields: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def is_external(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
return "gpt-4o-mini"
|
||||
|
||||
def complete(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
max_output_tokens: int,
|
||||
) -> ProviderResponse:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return ProviderResponse(
|
||||
content=None,
|
||||
tool_calls=[ToolCall(id="p1", name="get_parcel_info", arguments="{}")],
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
# Второй ход: срез паспорта участка уже в user-сообщении (fields) — фиксируем факт,
|
||||
# что зона ПЗЗ реально долетела до модели.
|
||||
self.captured_fields = {"seen": "\n".join(m.get("content") or "" for m in messages)}
|
||||
return ProviderResponse(content="Зона ПЗЗ участка: Ж-4.", model="gpt-4o-mini")
|
||||
|
||||
|
||||
def test_ask_merges_parcel_context_and_tool_can_read_it(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""§22-отчёт есть → эндпоинт дотягивает parcel_context; get_parcel_info его отдаёт LLM."""
|
||||
monkeypatch.setattr(settings, "llm_enabled", True)
|
||||
monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real")
|
||||
fake = _ParcelInfoProvider()
|
||||
monkeypatch.setattr("app.services.llm.client._build_default_provider", lambda: fake)
|
||||
parcel_ctx = {"zone_code": "Ж-4", "zone_name": "Зона многоэтажной застройки"}
|
||||
client = _client_with_db()
|
||||
with (
|
||||
patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(_report()),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.chat.get_parcel_context_for_chat",
|
||||
return_value=parcel_ctx,
|
||||
) as mock_ctx,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "какая зона ПЗЗ?"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["llm_used"] is True
|
||||
assert body["answer"] == "Зона ПЗЗ участка: Ж-4."
|
||||
# provenance содержит parcel_context (tool get_parcel_info реально отработал).
|
||||
assert body["grounded_in"]["sections"] == ["parcel_context"]
|
||||
# Паспорт участка дотягивался ровно для этого cad_num.
|
||||
mock_ctx.assert_called_once()
|
||||
assert mock_ctx.call_args.args[1] == _CAD
|
||||
# Зона ПЗЗ реально попала в контекст, отправленный модели.
|
||||
assert "Ж-4" in fake.captured_fields["seen"]
|
||||
|
||||
|
||||
def test_ask_no_parcel_context_falls_back_to_report_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Analyze-рана нет (parcel_context=None) → чат работает как раньше (только форсайт)."""
|
||||
monkeypatch.setattr(settings, "llm_enabled", False)
|
||||
client = _client_with_db()
|
||||
with (
|
||||
patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(_report()),
|
||||
),
|
||||
patch("app.api.v1.chat.get_parcel_context_for_chat", return_value=None),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "дай резюме"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["report_status"] == "ready"
|
||||
assert f"250{_NBSP}000" in body["answer"]
|
||||
|
||||
|
||||
def test_ask_parcel_context_error_does_not_break_chat(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Сбой чтения паспорта участка → чат не падает, отдаёт отчёт (обогащение опционально)."""
|
||||
monkeypatch.setattr(settings, "llm_enabled", False)
|
||||
client = _client_with_db()
|
||||
with (
|
||||
patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(_report()),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.chat.get_parcel_context_for_chat",
|
||||
side_effect=RuntimeError("analyze read down"),
|
||||
),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/chat/ask",
|
||||
json={"cad_num": _CAD, "message": "дай резюме"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["report_status"] == "ready"
|
||||
assert f"250{_NBSP}000" in body["answer"]
|
||||
|
||||
|
||||
def test_ask_llm_disabled_no_provider_constructed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""llm_enabled=False (дефолт) → детерм. путь; провайдер НЕ конструируется (tripwire)."""
|
||||
monkeypatch.setattr(settings, "llm_enabled", False)
|
||||
|
|
|
|||
|
|
@ -277,20 +277,23 @@ def _make_db_for_analyze() -> MagicMock:
|
|||
("first", district_row), # 2: district
|
||||
("all", []), # 3: POI rows
|
||||
("all", []), # 4: competitor rows
|
||||
("all", []), # 5: pipeline rows
|
||||
("first", centroid_row), # 6: centroid
|
||||
("all", []), # 7: noise rows
|
||||
("all", []), # 8: hydrology rows
|
||||
("all", []), # 9: utilities rows
|
||||
("first", None), # 10: parcel_meta
|
||||
("first", None), # 11: market trend
|
||||
("first", None), # 12: zoning (begin_nested)
|
||||
("all", []), # 13: success recommendation (begin_nested)
|
||||
("first", None), # 14: market price (begin_nested)
|
||||
("all", []), # 15: recent permits (begin_nested)
|
||||
("scalar", 0), # 16: geotech_risk
|
||||
("all", []), # 17: neighbors
|
||||
("first", None), # 18: overlap
|
||||
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
|
||||
("all", []), # 6: pipeline rows
|
||||
("first", centroid_row), # 7: centroid
|
||||
("all", []), # 8: noise rows
|
||||
("all", []), # 9: hydrology rows
|
||||
("all", []), # 10: utilities rows
|
||||
("first", None), # 11: parcel_meta
|
||||
("first", None), # 12: market trend
|
||||
("first", None), # 13: zoning (begin_nested)
|
||||
("all", []), # 14: success recommendation (begin_nested)
|
||||
("first", None), # 15: market price (begin_nested)
|
||||
("all", []), # 16: recent permits (begin_nested)
|
||||
("scalar", 0), # 17: geotech_risk
|
||||
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
|
||||
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
|
||||
# возвращает ОДНУ строку с этими тремя ключами.
|
||||
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
|
||||
]
|
||||
|
||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||
|
|
@ -599,3 +602,94 @@ def test_service_update_custom_poi_no_commit_when_not_found() -> None:
|
|||
|
||||
db.commit.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
|
||||
#
|
||||
# get_overlaps_for_scoring: db shared с caller (POST .../analyze, Depends(get_db)).
|
||||
# Раньше db.execute-сбой ловился в bare ``except Exception as e`` без
|
||||
# SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
|
||||
# следующий db.execute на этой же сессии (остальные шаги analyze_parcel) падает
|
||||
# "current transaction is aborted". Фикс — db.execute обёрнут в
|
||||
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
|
||||
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
|
||||
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
|
||||
# на той же mock-сессии.
|
||||
|
||||
|
||||
def _overlap_row(data: dict[str, Any]) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.__getitem__ = lambda self, k: data[k]
|
||||
return m
|
||||
|
||||
|
||||
def test_get_overlaps_for_scoring_returns_shaped_rows() -> None:
|
||||
"""Happy-path: db.execute успешен → список dict с ожидаемыми ключами."""
|
||||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||||
|
||||
row = _overlap_row(
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Мой парк",
|
||||
"category": "park",
|
||||
"weight": 2.0,
|
||||
"lon": 60.607,
|
||||
"lat": 56.840,
|
||||
"distance_m": 500.0,
|
||||
}
|
||||
)
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = [row]
|
||||
|
||||
out = get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION, parcel_cad="66:41:0:1")
|
||||
|
||||
assert out == [
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Мой парк",
|
||||
"category": "park",
|
||||
"weight": 2.0,
|
||||
"lon": 60.607,
|
||||
"lat": 56.840,
|
||||
"distance_m": 500.0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_get_overlaps_for_scoring_uses_begin_nested_savepoint() -> None:
|
||||
"""Фикс: db.execute обёрнут в db.begin_nested(), не голый вызов."""
|
||||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||||
|
||||
get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION)
|
||||
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
|
||||
def test_get_overlaps_for_scoring_query_failure_degrades_to_empty_list() -> None:
|
||||
"""Сбой db.execute → [] (graceful), не крашит analyze_parcel."""
|
||||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||||
|
||||
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
|
||||
|
||||
|
||||
def test_get_overlaps_for_scoring_session_usable_after_failure() -> None:
|
||||
"""db.execute сбоит один раз, но db остаётся usable для следующего вызова на
|
||||
этой же сессии (имитирует custom-POI overlaps → остальные шаги analyze_parcel
|
||||
на одной shared Session)."""
|
||||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
|
|
|||
226
backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py
Normal file
226
backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"""Тесты: epic #2464 cluster B — silent-cap honesty fixes в analyze_parcel.
|
||||
|
||||
Mock-based — не требуют живой БД.
|
||||
|
||||
Покрывает:
|
||||
1. `_build_market_pulse` (market_pulse.competitors_total / coverage_pct) —
|
||||
раньше competitors_total = len(competitor_rows) (LIMIT 20 в SQL), теперь
|
||||
принимает честный total отдельным параметром (см. analyze_parcel 5a —
|
||||
COUNT(*) БЕЗ LIMIT) и считает coverage_pct против него.
|
||||
2. `_neighbors_summary` (count_buildings_100m / neighbors_truncated) —
|
||||
раньше count_buildings_100m = len(neighbor_rows) (LIMIT 30 в CTE
|
||||
`neighbors`), теперь честный total из CTE `neighbors_total` (БЕЗ LIMIT).
|
||||
|
||||
Reference fix pattern: `features_truncated` в
|
||||
`app/services/site_finder/utility_infrastructure_loader.py` (epic #2445 A2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.api.v1 import parcels as parcels_module
|
||||
|
||||
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _competitor_row(
|
||||
obj_id: int,
|
||||
avg_price_per_m2_rub: float | None,
|
||||
units_sold: int | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"obj_id": obj_id,
|
||||
"comm_name": f"ЖК-{obj_id}",
|
||||
"dev_name": "TestDev",
|
||||
"avg_price_per_m2_rub": avg_price_per_m2_rub,
|
||||
"units_sold": units_sold,
|
||||
}
|
||||
|
||||
|
||||
# ── _build_market_pulse: competitors_total / coverage_pct honesty ────────────
|
||||
|
||||
|
||||
class TestBuildMarketPulseHonesty:
|
||||
"""market_pulse.competitors_total / coverage_pct — честный total (#2464 cluster B)."""
|
||||
|
||||
def test_below_cap_total_matches_len(self) -> None:
|
||||
"""Ниже LIMIT (мало конкурентов в радиусе) — true total == len(rows), zero
|
||||
behavior change: старое поведение (len(competitor_rows)) совпадает с новым."""
|
||||
rows = [_competitor_row(i, 150_000.0) for i in range(1, 6)] # 5 конкурентов
|
||||
pulse = parcels_module._build_market_pulse(rows, competitors_total=5, velocity_data=None)
|
||||
|
||||
assert pulse["competitors_total"] == 5
|
||||
assert pulse["competitors_with_price"] == 5
|
||||
assert pulse["coverage_pct"] == 100.0
|
||||
|
||||
def test_at_cap_total_is_honest_not_len_of_rows(self) -> None:
|
||||
"""#2464 cluster B core regression: competitor_rows капнут LIMIT 20 (детальный
|
||||
список), но реальных ЖК в радиусе 45 (честный COUNT). competitors_total ДОЛЖЕН
|
||||
отражать 45, а НЕ len(rows)=20 (старый баг — silent cap репортился как total)."""
|
||||
rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)] # ровно LIMIT 20
|
||||
true_total = 45
|
||||
pulse = parcels_module._build_market_pulse(
|
||||
rows, competitors_total=true_total, velocity_data=None
|
||||
)
|
||||
|
||||
assert pulse["competitors_total"] == true_total
|
||||
assert pulse["competitors_total"] != len(
|
||||
rows
|
||||
), "regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)"
|
||||
|
||||
def test_coverage_pct_computed_against_true_total_not_capped_list(self) -> None:
|
||||
"""coverage_pct = priced / TRUE total — раньше делилось на len(rows) (капнутый
|
||||
список), что давало ложные 100% покрытия даже когда реальное покрытие ниже."""
|
||||
# Все 20 показанных строк имеют цену → раньше coverage_pct считался бы 100%.
|
||||
rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)]
|
||||
true_total = 40 # реально в радиусе 40 ЖК, из них с ценой только эти 20.
|
||||
pulse = parcels_module._build_market_pulse(
|
||||
rows, competitors_total=true_total, velocity_data=None
|
||||
)
|
||||
|
||||
assert pulse["competitors_with_price"] == 20
|
||||
# 20/40*100 = 50.0, НЕ 100.0 (старая формула len(rows)-денутый would дать 100%).
|
||||
assert pulse["coverage_pct"] == 50.0
|
||||
|
||||
def test_zero_competitors_honest_zero_coverage(self) -> None:
|
||||
"""Нет конкурентов в радиусе → coverage_pct=0.0 (не деление на ноль)."""
|
||||
pulse = parcels_module._build_market_pulse([], competitors_total=0, velocity_data=None)
|
||||
|
||||
assert pulse["competitors_total"] == 0
|
||||
assert pulse["competitors_with_price"] == 0
|
||||
assert pulse["coverage_pct"] == 0.0
|
||||
assert pulse["market_avg_price_per_m2"] is None
|
||||
assert pulse["top_sellers"] == []
|
||||
|
||||
def test_unpriced_competitors_excluded_from_market_avg_but_counted_in_total(self) -> None:
|
||||
"""ЖК без маппинга (avg_price_per_m2_rub=None) остаются в competitor_rows
|
||||
(детальный список для карты), но не влияют на market_avg_price_per_m2."""
|
||||
rows = [
|
||||
_competitor_row(1, 100_000.0),
|
||||
_competitor_row(2, None),
|
||||
_competitor_row(3, 200_000.0),
|
||||
]
|
||||
pulse = parcels_module._build_market_pulse(rows, competitors_total=3, velocity_data=None)
|
||||
|
||||
assert pulse["competitors_with_price"] == 2
|
||||
assert pulse["market_avg_price_per_m2"] == 150_000
|
||||
|
||||
def test_velocity_data_feeds_avg_velocity_m2(self) -> None:
|
||||
"""avg_velocity_m2 берётся из velocity_data.monthly_velocity_sqm, если есть."""
|
||||
rows = [_competitor_row(1, 100_000.0)]
|
||||
pulse = parcels_module._build_market_pulse(
|
||||
rows, competitors_total=1, velocity_data={"monthly_velocity_sqm": 750.5}
|
||||
)
|
||||
assert pulse["avg_velocity_m2"] == 750.5
|
||||
|
||||
def test_top_sellers_top5_sorted_by_units_sold(self) -> None:
|
||||
"""top_sellers — топ-5 по units_sold среди приценённых конкурентов."""
|
||||
rows = [_competitor_row(i, 100_000.0, units_sold=i * 10) for i in range(1, 8)]
|
||||
pulse = parcels_module._build_market_pulse(rows, competitors_total=7, velocity_data=None)
|
||||
|
||||
assert len(pulse["top_sellers"]) == 5
|
||||
sold = [s["units_sold"] for s in pulse["top_sellers"]]
|
||||
assert sold == sorted(sold, reverse=True)
|
||||
assert sold[0] == 70 # obj_id=7 продал больше всех
|
||||
|
||||
|
||||
# ── _neighbors_summary: count_buildings_100m / neighbors_truncated honesty ───
|
||||
|
||||
|
||||
def _neighbor_row(cad_num: str, distance_m: float = 10.0) -> dict:
|
||||
return {
|
||||
"cad_num": cad_num,
|
||||
"building_name": None,
|
||||
"floors": None,
|
||||
"year_built": None,
|
||||
"cost_value": None,
|
||||
"area": None,
|
||||
"readable_address": None,
|
||||
"purpose": None,
|
||||
"status": None,
|
||||
"distance_m": distance_m,
|
||||
}
|
||||
|
||||
|
||||
def _make_neighbors_db(neighbors_json: list, neighbors_total_count: int, overlap_json=None):
|
||||
row = {
|
||||
"neighbors": neighbors_json,
|
||||
"overlap_rows": overlap_json or [],
|
||||
"neighbors_total_count": neighbors_total_count,
|
||||
}
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.first.return_value = row
|
||||
db = MagicMock()
|
||||
db.execute.return_value = result
|
||||
return db
|
||||
|
||||
|
||||
class TestNeighborsSummaryHonesty:
|
||||
"""count_buildings_100m / neighbors_truncated — честный total (#2464 cluster B)."""
|
||||
|
||||
def test_below_cap_count_matches_len_no_truncation(self) -> None:
|
||||
"""Ниже LIMIT 30 — true total == len(neighbors), neighbors_truncated=False
|
||||
(zero behavior change для типичного случая)."""
|
||||
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(5)]
|
||||
db = _make_neighbors_db(neighbors, neighbors_total_count=5)
|
||||
|
||||
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
|
||||
|
||||
assert summary["data_available"] is True
|
||||
assert summary["count_buildings_100m"] == 5
|
||||
assert summary["neighbors_truncated"] is False
|
||||
|
||||
def test_at_cap_count_is_honest_total_not_len_of_list(self) -> None:
|
||||
"""#2464 cluster B core regression: `neighbors` CTE капнут LIMIT 30, но
|
||||
реальных соседей в радиусе 47 (честный COUNT из `neighbors_total`).
|
||||
count_buildings_100m ДОЛЖЕН отражать 47, а НЕ len(neighbors)=30."""
|
||||
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)] # ровно LIMIT 30
|
||||
true_total = 47
|
||||
db = _make_neighbors_db(neighbors, neighbors_total_count=true_total)
|
||||
|
||||
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
|
||||
|
||||
assert summary["count_buildings_100m"] == true_total
|
||||
assert summary["count_buildings_100m"] != len(
|
||||
neighbors
|
||||
), "regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)"
|
||||
assert summary["neighbors_truncated"] is True
|
||||
|
||||
def test_neighbors_list_itself_unaffected_by_count_fix(self) -> None:
|
||||
"""Constraint: детальный список `neighbors` (payload для UI, уже капнут до 20
|
||||
в существующем коде — отдельно от SQL LIMIT 30) остаётся прежним по составу/
|
||||
порядку — фикс трогает только count/флаг, НЕ список."""
|
||||
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)]
|
||||
db = _make_neighbors_db(neighbors, neighbors_total_count=47)
|
||||
|
||||
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
|
||||
|
||||
assert len(summary["neighbors"]) == 20
|
||||
assert summary["neighbors"][0]["cad_num"] == "66:41:0000000:0"
|
||||
|
||||
def test_neighbors_truncated_reflects_actual_shown_list_not_intermediate_30(self) -> None:
|
||||
"""neighbors_truncated должен сравнивать честный total с тем, что РЕАЛЬНО
|
||||
показано в `neighbors` (капнуто до 20), а НЕ с промежуточным neighbor_rows
|
||||
(до 30 из SQL) — иначе флаг может ложно молчать (False) на 21..30-м соседе,
|
||||
где список уже обрезан до 20, а SQL ещё не упёрся в свой LIMIT 30."""
|
||||
# SQL вернул 25 строк (не уперся в LIMIT 30) — но список для UI режется до 20.
|
||||
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(25)]
|
||||
db = _make_neighbors_db(neighbors, neighbors_total_count=25)
|
||||
|
||||
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
|
||||
|
||||
assert len(summary["neighbors"]) == 20
|
||||
assert summary["count_buildings_100m"] == 25
|
||||
# 25 (total) > 20 (реально показано) → truncated=True, даже хотя SQL LIMIT 30
|
||||
# не был исчерпан.
|
||||
assert summary["neighbors_truncated"] is True
|
||||
|
||||
def test_query_failure_degrades_gracefully(self) -> None:
|
||||
"""DB-ошибка → data_available=False, note с текстом ошибки (unchanged behavior)."""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
|
||||
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
|
||||
|
||||
assert summary == {"data_available": False, "note": "neighbors query failed: boom"}
|
||||
|
|
@ -545,6 +545,109 @@ def test_competitors_no_price_anywhere_is_none() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── SAVEPOINT-регрессия (#2464 cluster A finding 3) ──────────────────────────
|
||||
|
||||
|
||||
def test_competitors_avg_price_failure_does_not_poison_later_queries() -> None:
|
||||
"""avg_price query падает → sold-count и objective-fallback (следом на ТОЙ ЖЕ
|
||||
Session) всё равно отрабатывают, а не ловят "current transaction is aborted".
|
||||
|
||||
Раньше avg_price/sold-count/objective-fallback ловили db.execute-сбой без
|
||||
SAVEPOINT (db.begin_nested()) — сбой ЛЮБОГО из трёх отравлял shared db для
|
||||
ОСТАЛЬНЫХ двух (и для persist_analysis_run дальше по /analyze). Фикс оборачивает
|
||||
каждый в свою begin_nested(); здесь ловим именно avg_price и проверяем, что
|
||||
objective-fallback (третий execute) всё равно наполняет цену.
|
||||
"""
|
||||
coord_result = MagicMock()
|
||||
coord_result.mappings.return_value.first.return_value = _coord_row()
|
||||
obj_result = MagicMock()
|
||||
obj_result.mappings.return_value.all.return_value = [_obj_row(obj_id=1)]
|
||||
sold_result = MagicMock()
|
||||
sold_result.mappings.return_value.all.return_value = []
|
||||
obj_price_result = MagicMock()
|
||||
obj_price_result.mappings.return_value.all.return_value = [
|
||||
_obj_price_row(obj_id=1, price=142_000.0)
|
||||
]
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = [
|
||||
coord_result,
|
||||
obj_result,
|
||||
RuntimeError("simulated avg_price DB failure"), # avg_price query сбоит
|
||||
sold_result,
|
||||
obj_price_result, # objective-fallback ДОЛЖЕН всё равно выполниться
|
||||
]
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
comp = resp.json()["competitors"][0]
|
||||
# avg_price сбойнул (без domrf-цены), но objective-fallback наполнил её —
|
||||
# это возможно ТОЛЬКО если сбой avg_price не отравил сессию для fallback.
|
||||
assert comp["avg_price_per_m2"] == pytest.approx(142_000.0)
|
||||
assert comp["price_source"] == "objective"
|
||||
# Все 5 запланированных execute реально дошли (sold-count и fallback не
|
||||
# были пропущены/не упали каскадом из-за отравленной сессии).
|
||||
assert db.execute.call_count == 5
|
||||
# avg_price/sold-count/objective-fallback — каждый под своим SAVEPOINT.
|
||||
assert db.begin_nested.call_count == 3
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_competitors_multi_object_radius_yields_per_object_prices() -> None:
|
||||
"""Регрессия #2445 A5 (byte-identical к best_layouts #1956): несколько
|
||||
конкурентов в радиусе → КАЖДЫЙ получает свою domrf-цену, а не «1 объект на
|
||||
global-max снимке, у остальных NULL».
|
||||
|
||||
Мок здесь не исполняет реальный SQL (_AVG_PRICE_SQL — строковые инварианты
|
||||
см. test_competitors_avg_price_sql.py), но фиксирует ОЖИДАЕМЫЙ КОНТРАКТ:
|
||||
_make_db может вернуть price_rows для ВСЕХ obj_id разом (per-object latest
|
||||
snapshot делает это возможным на живой БД) — раньше баг гарантировал, что
|
||||
price_rows содержал не более 1 строки на весь набор конкурентов.
|
||||
"""
|
||||
rows = [
|
||||
_obj_row(obj_id=1, distance_m=100.0),
|
||||
_obj_row(obj_id=2, distance_m=200.0),
|
||||
_obj_row(obj_id=3, distance_m=300.0),
|
||||
]
|
||||
price_rows = [
|
||||
_price_row(obj_id=1, price=140_000.0),
|
||||
_price_row(obj_id=2, price=155_000.0),
|
||||
_price_row(obj_id=3, price=162_000.0),
|
||||
]
|
||||
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows)
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
|
||||
assert len(comps) == 3
|
||||
expected = {1: 140_000.0, 2: 155_000.0, 3: 162_000.0}
|
||||
for obj_id, price in expected.items():
|
||||
assert comps[obj_id]["avg_price_per_m2"] == pytest.approx(price), (
|
||||
f"obj_id={obj_id}: все конкуренты должны получить СВОЮ domrf-цену, "
|
||||
"не only-one-object-on-global-max-snapshot регрессия"
|
||||
)
|
||||
assert comps[obj_id]["price_source"] == "domrf"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_competitors_is_active_flag() -> None:
|
||||
"""is_active=True для русского 'Строящиеся' (реальная prod-форма #1213),
|
||||
False для 'Сданные'/null/прочих."""
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ def test_post_report_enqueues_and_returns_202() -> None:
|
|||
body = resp.json()
|
||||
assert body["status"] == "building"
|
||||
assert body["analyze_run_at"] is not None
|
||||
# Отчёт ещё не собран → даты генерации PDF нет.
|
||||
assert body["report_generated_at"] is None
|
||||
delay_mock.assert_called_once_with(_CAD)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
|
@ -127,7 +129,10 @@ def test_post_report_cache_hit_returns_200_ready(tmp_path: Path) -> None:
|
|||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "ready"
|
||||
body = resp.json()
|
||||
assert body["status"] == "ready"
|
||||
# Fast-path: POST-ответ несёт дату генерации PDF из метадаты рана (без GET /status).
|
||||
assert body["report_generated_at"] == "2026-07-03T09:00:00+00:00"
|
||||
delay_mock.assert_not_called() # кэш есть → не enqueue'им
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
|
@ -273,3 +278,205 @@ def test_download_404_when_not_ready() -> None:
|
|||
assert "готов" in resp.json()["detail"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── GET /{cad}/report/download?format=docx (#2259 PR-F) ─────────────────────────
|
||||
|
||||
|
||||
def test_download_docx_returns_word_when_ready(tmp_path: Path) -> None:
|
||||
"""format=docx + docx_path на диске → 200 + Word media-type + .docx filename."""
|
||||
from app.core.db import get_db
|
||||
|
||||
pdf = tmp_path / "report.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.7 payload")
|
||||
docx = tmp_path / "report.docx"
|
||||
docx.write_bytes(b"PK\x03\x04 fake-docx")
|
||||
report_row = _run(
|
||||
500,
|
||||
{
|
||||
"pdf_path": str(pdf),
|
||||
"docx_path": str(docx),
|
||||
"analyze_run_id": 101,
|
||||
"forecast_run_id": 202,
|
||||
"generated_at": "2026-07-01T09:00:00+00:00",
|
||||
"size_bytes": 16,
|
||||
"docx_size_bytes": 12,
|
||||
},
|
||||
)
|
||||
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?format=docx")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == (
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
cd = resp.headers["content-disposition"]
|
||||
assert "attachment;" in cd
|
||||
assert cd.endswith('.docx"')
|
||||
assert "2026-07-01" in cd
|
||||
assert resp.content == b"PK\x03\x04 fake-docx"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_download_default_format_is_pdf(tmp_path: Path) -> None:
|
||||
"""format не задан → default pdf (даже если docx_path тоже есть)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
pdf = tmp_path / "report.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.7 payload")
|
||||
docx = tmp_path / "report.docx"
|
||||
docx.write_bytes(b"PK\x03\x04 fake-docx")
|
||||
report_row = _run(
|
||||
500,
|
||||
{
|
||||
"pdf_path": str(pdf),
|
||||
"docx_path": str(docx),
|
||||
"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"
|
||||
assert resp.content == b"%PDF-1.7 payload"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_download_docx_404_when_legacy_run_has_no_docx(tmp_path: Path) -> None:
|
||||
"""Старый ран (собран до PR-F) без docx_path → format=docx 404 «пересоберите»."""
|
||||
from app.core.db import get_db
|
||||
|
||||
pdf = tmp_path / "report.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.7 payload")
|
||||
# Метадата без docx_path — legacy отчёт, собранный до PR-F.
|
||||
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?format=docx")
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "пересоберите" in resp.json()["detail"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_download_docx_404_when_file_missing(tmp_path: Path) -> None:
|
||||
"""docx_path в метадате есть, но файл на диске исчез → format=docx 404."""
|
||||
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),
|
||||
"docx_path": str(tmp_path / "gone.docx"), # файла нет
|
||||
"analyze_run_id": 101,
|
||||
"forecast_run_id": 202,
|
||||
"generated_at": "2026-07-01T09:00:00+00:00",
|
||||
"size_bytes": 4,
|
||||
},
|
||||
)
|
||||
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?format=docx")
|
||||
assert resp.status_code == 404, resp.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_download_invalid_format_returns_422() -> None:
|
||||
"""Неизвестный format (не pdf/docx) → 422 (Literal-валидация FastAPI)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download?format=xlsx")
|
||||
assert resp.status_code == 422, resp.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_download_head_returns_headers_only(tmp_path: Path) -> None:
|
||||
"""HEAD на download — 200 с заголовками БЕЗ тела (preflight фронта, #2338).
|
||||
|
||||
FastAPI не добавляет HEAD к @router.get автоматически — прод отдавал 405,
|
||||
HEAD-preflight абортился и UI молча не скачивал DOCX. Явный @router.head чинит.
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
|
||||
pdf = tmp_path / "report.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.7 payload")
|
||||
docx = tmp_path / "report.docx"
|
||||
docx.write_bytes(b"PK\x03\x04 fake-docx")
|
||||
report_row = _run(
|
||||
510,
|
||||
{
|
||||
"pdf_path": str(pdf),
|
||||
"docx_path": str(docx),
|
||||
"analyze_run_id": 101,
|
||||
"forecast_run_id": 202,
|
||||
"generated_at": "2026-07-01T09:00:00+00:00",
|
||||
},
|
||||
)
|
||||
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.head(f"/api/v1/parcels/{_CAD}/report/download?format=docx")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == (
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
assert resp.content == b""
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_download_head_404_when_not_ready() -> None:
|
||||
"""HEAD для участка без готового отчёта — 404 (симметрично GET)."""
|
||||
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({})):
|
||||
client = TestClient(app)
|
||||
resp = client.head(f"/api/v1/parcels/{_CAD}/report/download")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
|
|
|||
|
|
@ -165,6 +165,36 @@ def test_analyze_response_preserves_unmodeled_extra_key() -> None:
|
|||
_stop_heavy()
|
||||
|
||||
|
||||
def test_analyze_response_includes_permits_nearby_honest_zero() -> None:
|
||||
"""#105 Phase 3: новый ключ permits_nearby присутствует в 200-ответе.
|
||||
|
||||
Mock-БД отдаёт [] на радиус-запрос gisogd_permits → честный ноль (total_count=0,
|
||||
items=[]), НЕ отсутствие ключа. Доказывает wiring get_permits_nearby → result_payload
|
||||
→ AnalyzeResponse (рядом со старым permits_summary, который тоже остаётся).
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = _make_db_for_analyze()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_heavy()
|
||||
try:
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "permits_nearby" in body, "permits_nearby выкинут из ответа"
|
||||
assert "permits_summary" in body, "старый permits_summary не должен исчезнуть"
|
||||
nearby = body["permits_nearby"]
|
||||
assert nearby["total_count"] == 0
|
||||
assert nearby["items"] == []
|
||||
assert nearby["source"] == "gisogd66"
|
||||
assert nearby["radius_m"] == 500
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_heavy()
|
||||
|
||||
|
||||
def test_analyze_response_does_not_drop_keys_vs_raw_payload() -> None:
|
||||
"""Жёсткая гарантия: key-set 200-ответа ⊇ key-set собранного result_payload.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
"""Тесты pravo_gov66_client — offline, без живой сети.
|
||||
|
||||
Покрывают: парсинг search-HTML (doc_id/title/url), парсинг страницы документа
|
||||
(PDF-ссылка), fallback на ecp-вариант, отсутствие PDF.
|
||||
(PDF-ссылка), fallback на ecp-вариант, отсутствие PDF, pacing/backoff (#2445 D4)
|
||||
между последовательными запросами к pravo.gov66.ru.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.scrapers.pravo_gov66_client import (
|
||||
_LAW_TYPE_POSTANOVLENIE,
|
||||
_extract_pdf_url,
|
||||
_get_with_backoff,
|
||||
_parse_search_html,
|
||||
)
|
||||
|
||||
|
|
@ -169,3 +174,96 @@ class TestExtractPdfUrl:
|
|||
url = _extract_pdf_url(_DOC_HTML_ECP_ONLY)
|
||||
assert url is not None
|
||||
assert url.startswith("https://www.pravo.gov66.ru/")
|
||||
|
||||
|
||||
# ── Тесты pacing/backoff (#2445 D4) — offline через httpx.MockTransport ───────
|
||||
|
||||
|
||||
def _client_with_responses(responses: list[httpx.Response]) -> httpx.Client:
|
||||
"""httpx.Client на MockTransport, отдающий responses по очереди (без реальной сети)."""
|
||||
calls = iter(responses)
|
||||
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
return next(calls)
|
||||
|
||||
return httpx.Client(transport=httpx.MockTransport(_handler))
|
||||
|
||||
|
||||
class TestGetWithBackoff:
|
||||
def test_sleeps_before_request(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Пейсинг: time.sleep вызывается ПЕРЕД запросом, даже на первой/единственной попытке."""
|
||||
sleeps: list[float] = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.scrapers.pravo_gov66_client.time.sleep",
|
||||
lambda s: sleeps.append(s),
|
||||
)
|
||||
client = _client_with_responses([httpx.Response(200, text="ok")])
|
||||
_get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
|
||||
assert len(sleeps) == 1
|
||||
|
||||
def test_paces_between_successive_fetches(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Несколько последовательных fetch → time.sleep между КАЖДЫМ (не только ретраи)."""
|
||||
sleeps: list[float] = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.scrapers.pravo_gov66_client.time.sleep",
|
||||
lambda s: sleeps.append(s),
|
||||
)
|
||||
client = _client_with_responses(
|
||||
[
|
||||
httpx.Response(200, text="a"),
|
||||
httpx.Response(200, text="b"),
|
||||
httpx.Response(200, text="c"),
|
||||
]
|
||||
)
|
||||
for i in range(3):
|
||||
_get_with_backoff(client, f"https://www.pravo.gov66.ru/{i}/")
|
||||
assert len(sleeps) == 3
|
||||
|
||||
def test_retries_on_429_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""429 → backoff sleep + ретрай → 200 успешно, без исключения."""
|
||||
sleeps: list[float] = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.scrapers.pravo_gov66_client.time.sleep",
|
||||
lambda s: sleeps.append(s),
|
||||
)
|
||||
client = _client_with_responses(
|
||||
[httpx.Response(429, text="rate limited"), httpx.Response(200, text="ok")]
|
||||
)
|
||||
resp = _get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
|
||||
assert resp.status_code == 200
|
||||
# 1 pacing-sleep перед 1й попыткой + 1 backoff-sleep перед ретраем + 1 pacing перед 2й.
|
||||
assert len(sleeps) >= 2
|
||||
|
||||
def test_retries_on_5xx_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
sleeps: list[float] = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.scrapers.pravo_gov66_client.time.sleep",
|
||||
lambda s: sleeps.append(s),
|
||||
)
|
||||
client = _client_with_responses(
|
||||
[httpx.Response(503, text="unavailable"), httpx.Response(200, text="ok")]
|
||||
)
|
||||
resp = _get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_raises_after_exhausting_retries_on_429(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("app.services.scrapers.pravo_gov66_client.time.sleep", lambda s: None)
|
||||
# _MAX_RETRIES=2 → до 3 попыток, все 429.
|
||||
client = _client_with_responses([httpx.Response(429, text="x") for _ in range(3)])
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
|
||||
|
||||
def test_4xx_other_than_429_raises_immediately_without_retry(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""404 не ретраится — сразу raise_for_status(), задача не тратит время на 3 попытки."""
|
||||
sleeps: list[float] = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.scrapers.pravo_gov66_client.time.sleep",
|
||||
lambda s: sleeps.append(s),
|
||||
)
|
||||
client = _client_with_responses([httpx.Response(404, text="not found")])
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
|
||||
# Только 1 pacing-sleep перед единственной попыткой, никаких backoff-ретраев.
|
||||
assert len(sleeps) == 1
|
||||
|
|
|
|||
244
backend/tests/services/chat/test_retrieval.py
Normal file
244
backend/tests/services/chat/test_retrieval.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""Тесты read-only retrieval чата (#957): §22-отчёт + курируемый паспорт участка.
|
||||
|
||||
Покрываем `get_parcel_context_for_chat` (§19 data-residency):
|
||||
• строит ПЛОСКИЙ whitelist-дикт из фиксированных градо-полей analyze-рана;
|
||||
• НЕ протаскивает наружу сырые под-словари / геометрию / любой лишний ключ payload;
|
||||
• ЗОУИТ: dedup названий, cap, разрешение противоречия encumbrance↔overlaps;
|
||||
• None, если analyze-рана/результата нет (эндпоинт работает как раньше).
|
||||
|
||||
Стратегия: latest_run_for патчится (никакой прод-БД); db — MagicMock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.services.chat import retrieval
|
||||
|
||||
_CAD = "66:41:0204016:10"
|
||||
|
||||
|
||||
def _analyze_payload() -> dict[str, Any]:
|
||||
"""analyze-1.0 result с курируемыми полями И «шумом», который наружу уходить НЕ должен."""
|
||||
return {
|
||||
"egrn": {
|
||||
"address": "Екатеринбург, ул. Ленина, 1",
|
||||
"area_m2": 5400,
|
||||
"land_category": "земли населённых пунктов",
|
||||
"permitted_use_text": "многоэтажная жилая застройка",
|
||||
"cadastral_value_rub": 123456789,
|
||||
"parcel_status": "учтённый",
|
||||
"ownership_type": "муниципальная",
|
||||
# Шум: сырой под-словарь + PII-подобные поля — НЕ должны попасть в срез.
|
||||
"raw": {"owner_name": "Иванов И.И.", "internal_id": 42},
|
||||
},
|
||||
"nspd_zoning": {
|
||||
"zone_code": "Ж-4",
|
||||
"zone_name": "Зона многоэтажной жилой застройки",
|
||||
"max_far": 2.5,
|
||||
"max_floors": 25,
|
||||
"max_height_m": 75,
|
||||
"max_building_pct": 40,
|
||||
"min_parcel_area_m2": 1500,
|
||||
"regulation_source": "ПЗЗ Екатеринбурга",
|
||||
"main_vri": ["2.6", "3.1"], # список ВРИ — в срез НЕ тянем (не в whitelist)
|
||||
},
|
||||
"encumbrance": {"has_zouit": True, "zouit_count": 2},
|
||||
"nspd_zouit_overlaps": [
|
||||
{"type_zone": "Приаэродромная территория", "reg_numb_border": "66:00-6.1"},
|
||||
{"name": "Охранная зона ЛЭП", "coverage_pct": 12.3},
|
||||
{"type_zone": "Приаэродромная территория"}, # дубль → dedup
|
||||
],
|
||||
# Тяжёлый сырой блоб + геометрия — НЕ курируемые, наружу НЕЛЬЗЯ.
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[1, 2], [3, 4]]]},
|
||||
"market_now": {"deals": [{"price": 1}]},
|
||||
}
|
||||
|
||||
|
||||
def _make_run(result: Any, run_id: int = 7) -> MagicMock:
|
||||
run = MagicMock()
|
||||
run.result = result
|
||||
run.id = run_id
|
||||
return run
|
||||
|
||||
|
||||
def _get_context(result: Any) -> dict[str, Any] | None:
|
||||
with patch(
|
||||
"app.services.chat.retrieval.latest_run_for",
|
||||
return_value=_make_run(result) if result is not None else None,
|
||||
):
|
||||
return retrieval.get_parcel_context_for_chat(MagicMock(), _CAD)
|
||||
|
||||
|
||||
# ── whitelist: нужные поля есть, лишних нет ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_builds_curated_whitelist_dict() -> None:
|
||||
ctx = _get_context(_analyze_payload())
|
||||
assert ctx is not None
|
||||
# ЕГРН-скаляры.
|
||||
assert ctx["address"] == "Екатеринбург, ул. Ленина, 1"
|
||||
assert ctx["area_m2"] == 5400
|
||||
assert ctx["land_category"] == "земли населённых пунктов"
|
||||
assert ctx["permitted_use_text"] == "многоэтажная жилая застройка"
|
||||
assert ctx["cadastral_value_rub"] == 123456789
|
||||
assert ctx["parcel_status"] == "учтённый"
|
||||
assert ctx["ownership_type"] == "муниципальная"
|
||||
# Градрегламент ПЗЗ.
|
||||
assert ctx["zone_code"] == "Ж-4"
|
||||
assert ctx["zone_name"] == "Зона многоэтажной жилой застройки"
|
||||
assert ctx["max_far"] == 2.5
|
||||
assert ctx["max_floors"] == 25
|
||||
assert ctx["max_height_m"] == 75
|
||||
assert ctx["max_building_pct"] == 40
|
||||
assert ctx["min_parcel_area_m2"] == 1500
|
||||
assert ctx["regulation_source"] == "ПЗЗ Екатеринбурга"
|
||||
# ЗОУИТ-сводка + dedup названий.
|
||||
assert ctx["has_zouit"] is True
|
||||
assert ctx["zouit_count"] == 2
|
||||
assert ctx["zouit_zone_names"] == ["Приаэродромная территория", "Охранная зона ЛЭП"]
|
||||
|
||||
|
||||
def test_does_not_leak_geometry_raw_or_extra_keys() -> None:
|
||||
"""§19: срез — ТОЛЬКО фиксированный whitelist; geometry/raw/market и т.п. НЕ протекают."""
|
||||
ctx = _get_context(_analyze_payload())
|
||||
assert ctx is not None
|
||||
allowed = {
|
||||
"address",
|
||||
"area_m2",
|
||||
"land_category",
|
||||
"permitted_use_text",
|
||||
"cadastral_value_rub",
|
||||
"parcel_status",
|
||||
"ownership_type",
|
||||
"zone_code",
|
||||
"zone_name",
|
||||
"max_far",
|
||||
"max_floors",
|
||||
"max_height_m",
|
||||
"max_building_pct",
|
||||
"min_parcel_area_m2",
|
||||
"regulation_source",
|
||||
"has_zouit",
|
||||
"zouit_count",
|
||||
"zouit_zone_names",
|
||||
}
|
||||
assert set(ctx) <= allowed
|
||||
# Явно: ни сырых под-словарей, ни геометрии, ни PII/ВРИ-списка.
|
||||
assert "geometry" not in ctx
|
||||
assert "raw" not in ctx
|
||||
assert "market_now" not in ctx
|
||||
assert "main_vri" not in ctx
|
||||
# Все значения — скаляры или список строк (никаких вложенных dict/геометрии).
|
||||
for key, value in ctx.items():
|
||||
if key == "zouit_zone_names":
|
||||
assert isinstance(value, list)
|
||||
assert all(isinstance(n, str) for n in value)
|
||||
else:
|
||||
assert isinstance(value, str | int | float | bool)
|
||||
|
||||
|
||||
# ── §19 type-guard: не-скаляры отбрасываются, а не протаскиваются ─────────────────
|
||||
|
||||
|
||||
def test_dict_value_in_egrn_field_is_dropped() -> None:
|
||||
"""Под-словарь в egrn.address (напр. с PII) НЕ должен утечь в срез — ключ отсутствует."""
|
||||
payload = {
|
||||
"egrn": {
|
||||
"address": {"owner_pii": "Иванов И.И."}, # вложенный dict — §19-угроза
|
||||
"area_m2": 500,
|
||||
}
|
||||
}
|
||||
ctx = _get_context(payload)
|
||||
assert ctx is not None
|
||||
assert "address" not in ctx # dict-значение отброшено гейтом типа
|
||||
assert ctx["area_m2"] == 500 # скаляр рядом при этом сохраняется
|
||||
|
||||
|
||||
def test_list_with_nested_dict_in_zone_code_is_dropped() -> None:
|
||||
"""Список (тем более с вложенным dict) в zone_code НЕ протаскивается — ключа нет."""
|
||||
payload = {"nspd_zoning": {"zone_code": ["Ж-4", {"nested": "leak"}], "max_floors": 25}}
|
||||
ctx = _get_context(payload)
|
||||
assert ctx is not None
|
||||
assert "zone_code" not in ctx # list-значение отброшено
|
||||
assert ctx["max_floors"] == 25 # соседний скаляр цел
|
||||
|
||||
|
||||
def test_zouit_names_drops_non_string_elements() -> None:
|
||||
"""В списке ЗОУИТ не-строковые элементы (dict/число) отбрасываются, строки остаются."""
|
||||
payload = {
|
||||
"nspd_zouit_overlaps": [
|
||||
{"type_zone": "Санитарно-защитная зона"},
|
||||
{"type_zone": {"leak": "нельзя"}}, # dict вместо строки — отбросить
|
||||
{"name": 12345}, # число вместо строки — отбросить
|
||||
{"name": "Водоохранная зона"},
|
||||
],
|
||||
}
|
||||
ctx = _get_context(payload)
|
||||
assert ctx is not None
|
||||
assert ctx["zouit_zone_names"] == ["Санитарно-защитная зона", "Водоохранная зона"]
|
||||
# Ни одного не-строкового элемента / repr'а вложенной структуры.
|
||||
assert all(isinstance(n, str) for n in ctx["zouit_zone_names"])
|
||||
assert "leak" not in " ".join(ctx["zouit_zone_names"])
|
||||
|
||||
|
||||
# ── зона: fallback zone_code → regulation_zone_index ─────────────────────────────
|
||||
|
||||
|
||||
def test_zone_code_falls_back_to_regulation_zone_index() -> None:
|
||||
payload = {"nspd_zoning": {"regulation_zone_index": "ОД-1"}}
|
||||
ctx = _get_context(payload)
|
||||
assert ctx is not None
|
||||
assert ctx["zone_code"] == "ОД-1"
|
||||
|
||||
|
||||
# ── ЗОУИТ: противоречие encumbrance=нет, но пересечения есть ──────────────────────
|
||||
|
||||
|
||||
def test_zouit_contradiction_resolved_from_overlaps() -> None:
|
||||
payload = {
|
||||
"encumbrance": {"has_zouit": False, "zouit_count": 0},
|
||||
"nspd_zouit_overlaps": [
|
||||
{"type_zone": "Санитарно-защитная зона"},
|
||||
{"name": "Водоохранная зона"},
|
||||
],
|
||||
}
|
||||
ctx = _get_context(payload)
|
||||
assert ctx is not None
|
||||
assert ctx["has_zouit"] is True
|
||||
assert ctx["zouit_count"] == 2
|
||||
assert ctx["zouit_zone_names"] == ["Санитарно-защитная зона", "Водоохранная зона"]
|
||||
|
||||
|
||||
def test_zouit_names_capped() -> None:
|
||||
payload = {
|
||||
"nspd_zouit_overlaps": [{"type_zone": f"Зона {i}"} for i in range(30)],
|
||||
}
|
||||
ctx = _get_context(payload)
|
||||
assert ctx is not None
|
||||
assert len(ctx["zouit_zone_names"]) == retrieval._MAX_ZOUIT_NAMES
|
||||
|
||||
|
||||
# ── graceful: отсутствие полей / рана ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_missing_fields_are_omitted_not_none() -> None:
|
||||
# Пустой ЕГРН/зонирование → эти ключи просто отсутствуют (не None).
|
||||
ctx = _get_context({"egrn": {"address": "Тест"}})
|
||||
assert ctx == {"address": "Тест"}
|
||||
assert "zone_code" not in ctx
|
||||
assert "area_m2" not in ctx
|
||||
|
||||
|
||||
def test_returns_none_when_no_run() -> None:
|
||||
assert _get_context(None) is None
|
||||
|
||||
|
||||
def test_returns_none_when_result_not_dict() -> None:
|
||||
assert _get_context(["not", "a", "dict"]) is None
|
||||
|
||||
|
||||
def test_returns_none_when_no_curated_fields() -> None:
|
||||
# Ран есть, но ни одного курируемого поля → None (нечего вливать в отчёт).
|
||||
assert _get_context({"market_now": {"x": 1}, "geometry": {}}) is None
|
||||
|
|
@ -20,6 +20,7 @@ _EXPECTED_TOOLS = {
|
|||
"get_forecast",
|
||||
"get_risks",
|
||||
"get_scenarios",
|
||||
"get_parcel_info",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -39,15 +40,23 @@ def _report() -> dict[str, Any]:
|
|||
"scoring": {"special_indices": {"indices": {"cannibalization": {"value": 0.31}}}},
|
||||
"confidence": {"level": "medium", "factors": {"deals": "ok"}},
|
||||
"scenarios": {"by_scenario": {"base": {}, "conservative": {}}},
|
||||
"parcel_context": {
|
||||
"address": "Екатеринбург, ул. Ленина, 1",
|
||||
"zone_code": "Ж-4",
|
||||
"zone_name": "Зона многоэтажной застройки",
|
||||
"max_floors": 25,
|
||||
"has_zouit": True,
|
||||
"zouit_zone_names": ["Приаэродромная территория"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Спеки ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tool_specs_cover_all_five() -> None:
|
||||
def test_tool_specs_cover_all_six() -> None:
|
||||
specs = tools.tool_specs()
|
||||
assert len(specs) == 5
|
||||
assert len(specs) == 6
|
||||
assert {s["function"]["name"] for s in specs} == _EXPECTED_TOOLS
|
||||
|
||||
|
||||
|
|
@ -99,6 +108,20 @@ def test_scenarios_slice() -> None:
|
|||
assert set(out["by_scenario"]) == {"base", "conservative"}
|
||||
|
||||
|
||||
def test_parcel_info_slice_is_verbatim() -> None:
|
||||
out = tools.get_parcel_info(_report())
|
||||
assert out["zone_code"] == "Ж-4"
|
||||
assert out["zone_name"] == "Зона многоэтажной застройки"
|
||||
assert out["max_floors"] == 25
|
||||
assert out["zouit_zone_names"] == ["Приаэродромная территория"]
|
||||
|
||||
|
||||
def test_parcel_info_missing_returns_marker() -> None:
|
||||
# Паспорт участка не влит (analyze-рана не было) → маркер «недоступно», не KeyError.
|
||||
assert tools.get_parcel_info({}) == {"available": False}
|
||||
assert tools.get_parcel_info({"parcel_context": {}}) == {"available": False}
|
||||
|
||||
|
||||
# ── Robust: отсутствующая/пустая секция → маркер, НЕ KeyError ─────────────────────
|
||||
|
||||
|
||||
|
|
@ -136,6 +159,7 @@ def test_execute_unknown_tool_returns_marker_not_raise() -> None:
|
|||
def test_sections_for_tool_provenance() -> None:
|
||||
assert tools.sections_for_tool("get_exec_summary") == ("exec_summary",)
|
||||
assert tools.sections_for_tool("get_risks") == ("scoring", "confidence")
|
||||
assert tools.sections_for_tool("get_parcel_info") == ("parcel_context",)
|
||||
assert tools.sections_for_tool("get_nonexistent") == ()
|
||||
|
||||
|
||||
|
|
|
|||
615
backend/tests/services/exporters/test_full_report_docx.py
Normal file
615
backend/tests/services/exporters/test_full_report_docx.py
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
"""Unit-тесты DOCX-варианта полного отчёта ПТИЦА (эпик #2259 PR-F) — `build_full_report_docx`.
|
||||
|
||||
Чистые тесты БЕЗ БД / сети / native-libs (python-docx — чистый Python на lxml): билдер
|
||||
только ПОТРЕБЛЯЕТ уже-собранные словари (analyze / forecast / concept / connection-
|
||||
capacity) + карт-PNG. Проверяем:
|
||||
• полный payload → НЕПУСТЫЕ байты, начинаются с `b"PK"` (OOXML/.docx — zip);
|
||||
• документ открывается python-docx и несёт заголовки §1–§7 + альтернативы + титул;
|
||||
• деградации: нет forecast → §4–§6 «нет данных»; нет концепции → §7 плашка + §5 контекст;
|
||||
нет connection-capacity → §3 без резервов; пустой payload → валидный минимальный .docx;
|
||||
• карты вставлены (inline_shapes) при наличии PNG; None → плашка «Карта недоступна».
|
||||
|
||||
DATABASE_URL до импорта app-модулей (зеркало test_full_report_pdf.py) — на случай
|
||||
side-effect'ов импорта пакета.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from docx import Document
|
||||
|
||||
from app.services.exporters.full_report_docx import (
|
||||
_build_permits_nearby,
|
||||
_build_section_6,
|
||||
build_full_report_docx,
|
||||
)
|
||||
|
||||
_CAD = "66:41:0204016:10"
|
||||
|
||||
|
||||
def _png_1x1() -> bytes:
|
||||
"""Минимальный валидный 1×1 PNG (белый пиксель) для проверки вставки карт.
|
||||
|
||||
Собираем вручную (chunk = len + type + data + CRC32) — не тянем PIL в фикстуру.
|
||||
python-docx декодирует размеры прямо из IHDR, реальный растр ему не важен.
|
||||
"""
|
||||
|
||||
def _chunk(tag: bytes, data: bytes) -> bytes:
|
||||
body = tag + data
|
||||
crc = struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
return struct.pack(">I", len(data)) + body + crc
|
||||
|
||||
sig = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) # 1×1, 8-bit, truecolor
|
||||
idat = zlib.compress(b"\x00\xff\xff\xff") # one filtered scanline, white
|
||||
return sig + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def png() -> bytes:
|
||||
return _png_1x1()
|
||||
|
||||
|
||||
def _analyze() -> dict[str, Any]:
|
||||
return {
|
||||
"cad_num": _CAD,
|
||||
"geom_geojson": {"type": "Polygon", "coordinates": [[[60.6, 56.8], [60.61, 56.8]]]},
|
||||
"egrn": {
|
||||
"address": "ЕКБ, ул. Примерная, 1",
|
||||
"area_m2": 7800,
|
||||
"land_category": "Земли населённых пунктов",
|
||||
},
|
||||
"nspd_zoning": {
|
||||
"zone_code": "Ж-4",
|
||||
"max_far": 2.5,
|
||||
"max_floors": 16,
|
||||
"main_vri": ["МКД", "Детсад"],
|
||||
},
|
||||
"encumbrance": {"has_zouit": True, "zouit_count": 1, "zouit_types": ["Приаэродромная"]},
|
||||
"gate_verdict": {"can_build_mkd": True, "verdict_label": "Можно"},
|
||||
"location": {"distance_to_center_km": 4.2},
|
||||
"metro": {"nearest_top3": [{"name": "Пл.1905", "distance_m": 1200}]},
|
||||
"utilities": {
|
||||
"nearest_substation_m": 300,
|
||||
"summary": [{"subtype": "power", "nearest_m": 300, "count_within_2km": 5}],
|
||||
},
|
||||
"neighbors_summary": {
|
||||
"neighbors": [
|
||||
{"building_name": "ЖК А", "purpose": "жилое", "floors": 12, "distance_m": 80}
|
||||
]
|
||||
},
|
||||
"program_alternatives": {
|
||||
"any_viable": True,
|
||||
"grid_size": 9,
|
||||
"variants": [
|
||||
{
|
||||
"housing_class": "comfort",
|
||||
"floors": 16,
|
||||
"development_type": "high_rise",
|
||||
"npv_rub": 42_000_000,
|
||||
"roi": 0.18,
|
||||
"irr": 0.22,
|
||||
"residential_sqm": 12_000,
|
||||
"apartments_count": 240,
|
||||
"price_per_sqm_used": 120_000,
|
||||
"price_source": "objective_geo_radius",
|
||||
}
|
||||
],
|
||||
"caveat": "Оценка по макс. застройке зоны.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _forecast() -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"market_now": {
|
||||
"summary": "Рынок активен.",
|
||||
"market_metrics": {"district": "Кировский", "n_lots": 540, "unit_velocity": 12.3},
|
||||
"competitors": [
|
||||
{
|
||||
"comm_name": "ЖК Б",
|
||||
"dev_name": "Дев1",
|
||||
"obj_class": "comfort",
|
||||
"distance_m": 500,
|
||||
"flat_count": 120,
|
||||
}
|
||||
],
|
||||
},
|
||||
"confidence": {
|
||||
"level": "high",
|
||||
"rationale": "много данных",
|
||||
"factors": {"deals": {"label": "Сделки", "level": "high", "note": "2000 сделок"}},
|
||||
},
|
||||
"future_market": {
|
||||
"summary": "Дефицит нарастает.",
|
||||
"forecasts_by_horizon": [
|
||||
{
|
||||
"horizon_months": 12,
|
||||
"projected_demand_units": 300,
|
||||
"projected_supply_units": 200,
|
||||
"deficit_index": 1.5,
|
||||
"months_of_inventory": 8,
|
||||
"confidence": "medium",
|
||||
}
|
||||
],
|
||||
},
|
||||
"scoring": {
|
||||
"special_indices": {
|
||||
"indices": {
|
||||
"cost_of_error": {
|
||||
"key": "coe",
|
||||
"label": "Цена ошибки",
|
||||
"value": 0.3,
|
||||
"detail": {"price_per_m2": 125_000, "avg_ticket_rub": 7_500_000},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenarios": {"scenarios_summary": {"base": 1.4, "optimistic": 1.8}},
|
||||
}
|
||||
|
||||
|
||||
def _concept() -> dict[str, Any]:
|
||||
return {
|
||||
"variants": [
|
||||
{
|
||||
"strategy": "balanced",
|
||||
"teap": {
|
||||
"built_area_sqm": 2000,
|
||||
"total_floor_area_sqm": 24_000,
|
||||
"residential_area_sqm": 18_000,
|
||||
"apartments_count": 300,
|
||||
"parking_spaces": 150,
|
||||
"density": 2.4,
|
||||
},
|
||||
"financial": {
|
||||
"revenue_rub": 3_600_000_000,
|
||||
"cost_rub": 2_800_000_000,
|
||||
"net_profit_rub": 800_000_000,
|
||||
"npv_rub": 420_000_000,
|
||||
"roi": 0.28,
|
||||
"irr": 0.19,
|
||||
"price_per_sqm_used": 125_000,
|
||||
"price_source": "objective_geo_radius",
|
||||
"payback_months": 36,
|
||||
"construction_rub": 2_000_000_000,
|
||||
},
|
||||
"buildings_geojson": {
|
||||
"features": [
|
||||
{"properties": {"section_type": "tower", "floors": 16}},
|
||||
{"properties": {"section_type": "tower", "floors": 16}},
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _capacity() -> dict[str, Any]:
|
||||
return {
|
||||
"power_summary": {
|
||||
"total_power_points": 3,
|
||||
"nearest_with_reserve": {
|
||||
"reserve_mva": 12.5,
|
||||
"voltage_class": "10кВ",
|
||||
"distance_m": 400,
|
||||
"reserve_unit": "МВА",
|
||||
},
|
||||
},
|
||||
"gas": {
|
||||
"city_grs": [
|
||||
{"grs_name": "ГРС-1", "free_capacity_th_m3_h": 50, "free_capacity_pct": 18.4}
|
||||
]
|
||||
},
|
||||
"heat": {
|
||||
"systems": [
|
||||
{
|
||||
"org": "ЕКБ ТЭЦ",
|
||||
"system_name": "Система 1",
|
||||
"reserve_gcal_h": 120,
|
||||
"period": "2024",
|
||||
}
|
||||
]
|
||||
},
|
||||
"water": [{"system_name": "ЦСВ ЕКБ", "reserve_thousand_m3_day": 30, "period": "2024"}],
|
||||
"nearby_network_zones": [{"human_label": "ЛЭП 10кВ", "distance_m": 50}],
|
||||
}
|
||||
|
||||
|
||||
def _all_text(doc: Document) -> str:
|
||||
"""Весь текст документа: абзацы + ячейки всех таблиц (для проверки содержимого)."""
|
||||
parts = [p.text for p in doc.paragraphs]
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
parts.extend(cell.text for cell in row.cells)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _render(**overrides: Any) -> bytes:
|
||||
kwargs: dict[str, Any] = {
|
||||
"analyze_result": _analyze(),
|
||||
"forecast_result": _forecast(),
|
||||
"concept_result": _concept(),
|
||||
"connection_capacity": _capacity(),
|
||||
"cad": _CAD,
|
||||
"address": "ЕКБ, ул. Примерная, 1",
|
||||
"generated_at": "03.07.2026",
|
||||
"parcel_map_png": None,
|
||||
"concept_map_png": None,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
analyze = kwargs.pop("analyze_result")
|
||||
forecast = kwargs.pop("forecast_result")
|
||||
concept = kwargs.pop("concept_result")
|
||||
cap = kwargs.pop("connection_capacity")
|
||||
return build_full_report_docx(analyze, forecast, concept, cap, **kwargs)
|
||||
|
||||
|
||||
# ── Happy path ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_full_payload_returns_docx_bytes(png: bytes) -> None:
|
||||
"""Полный payload → непустые байты, начинающиеся с b"PK" (OOXML zip)."""
|
||||
out = _render(parcel_map_png=png, concept_map_png=png)
|
||||
assert isinstance(out, bytes)
|
||||
assert len(out) > 0
|
||||
assert out[:2] == b"PK" # zip-контейнер
|
||||
|
||||
|
||||
def test_all_sections_present(png: bytes) -> None:
|
||||
"""Документ несёт заголовки §1–§7 + альтернативы + титул (зеркало PDF-структуры)."""
|
||||
out = _render(parcel_map_png=png, concept_map_png=png)
|
||||
doc = Document(io.BytesIO(out))
|
||||
headings = {p.text for p in doc.paragraphs}
|
||||
for section in (
|
||||
"§1. Участок",
|
||||
"§2. Окружение",
|
||||
"§3. Инженерные сети",
|
||||
"§4. Рынок",
|
||||
"§5. Финансовая модель",
|
||||
"§6. Риски и дефицит",
|
||||
"§7. Концепция застройки",
|
||||
):
|
||||
assert section in headings, f"нет секции {section}"
|
||||
assert "Как участок сходится (альтернативы программы)" in headings
|
||||
assert "Отчёт по участку — Site Finder ПТИЦА" in headings
|
||||
|
||||
|
||||
def test_title_carries_cad_and_address(png: bytes) -> None:
|
||||
"""Титул несёт кад.номер, адрес и дату формирования."""
|
||||
out = _render(parcel_map_png=png, concept_map_png=png)
|
||||
text = _all_text(Document(io.BytesIO(out)))
|
||||
assert _CAD in text
|
||||
assert "ЕКБ, ул. Примерная, 1" in text
|
||||
assert "03.07.2026" in text
|
||||
|
||||
|
||||
def test_key_data_rendered(png: bytes) -> None:
|
||||
"""Данные из исходных словарей попадают в таблицы (площадь / зона / NPV / метро)."""
|
||||
out = _render(parcel_map_png=png, concept_map_png=png)
|
||||
text = _all_text(Document(io.BytesIO(out)))
|
||||
assert "7 800" in text # площадь через _fmt_int_ru
|
||||
assert "Ж-4" in text # зона ПЗЗ
|
||||
assert "Пл.1905" in text # станция метро
|
||||
assert "ЖК Б" in text # конкурент рынка
|
||||
|
||||
|
||||
# ── Карты ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_maps_inserted_when_png_present(png: bytes) -> None:
|
||||
"""Обе карты (участок §1 + концепция §7) вставлены как inline-изображения."""
|
||||
out = _render(parcel_map_png=png, concept_map_png=png)
|
||||
doc = Document(io.BytesIO(out))
|
||||
assert len(doc.inline_shapes) == 2
|
||||
|
||||
|
||||
def test_map_placeholder_when_png_none() -> None:
|
||||
"""Карты None → плашка «Карта недоступна» вместо изображения (graceful)."""
|
||||
out = _render(parcel_map_png=None, concept_map_png=None)
|
||||
doc = Document(io.BytesIO(out))
|
||||
assert len(doc.inline_shapes) == 0
|
||||
assert "Карта недоступна" in _all_text(doc)
|
||||
|
||||
|
||||
def test_broken_png_falls_back_to_placeholder() -> None:
|
||||
"""Битый PNG → плашка «недоступна», отчёт не падает (best-effort вставка)."""
|
||||
out = _render(parcel_map_png=b"not-a-png", concept_map_png=b"also-broken")
|
||||
doc = Document(io.BytesIO(out))
|
||||
assert len(doc.inline_shapes) == 0
|
||||
assert "Карта недоступна" in _all_text(doc)
|
||||
|
||||
|
||||
# ── Деградации ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_forecast_degrades_part_b() -> None:
|
||||
"""Нет forecast → §4–§6 «нет данных», документ валиден (не падает)."""
|
||||
out = _render(forecast_result=None)
|
||||
doc = Document(io.BytesIO(out))
|
||||
headings = {p.text for p in doc.paragraphs}
|
||||
assert "§4. Рынок" in headings
|
||||
assert "§6. Риски и дефицит" in headings
|
||||
assert "нет данных" in _all_text(doc)
|
||||
|
||||
|
||||
def test_no_concept_shows_plashka_and_market_context() -> None:
|
||||
"""Нет концепции → §7 честная плашка + §5 только рыночный контекст цены."""
|
||||
out = _render(concept_result=None)
|
||||
text = _all_text(Document(io.BytesIO(out)))
|
||||
assert "Концепция застройки не рассчитана" in text
|
||||
# §5 деградирует в рыночный контекст (cost_of_error detail в forecast).
|
||||
assert "Рыночный контекст цены" in text
|
||||
|
||||
|
||||
def test_no_connection_capacity_omits_reserves() -> None:
|
||||
"""Нет connection-capacity → §3 без блоков резервов (только OSM-сети)."""
|
||||
out = _render(connection_capacity=None)
|
||||
text = _all_text(Document(io.BytesIO(out)))
|
||||
# OSM-сети остаются, а connection-capacity-заголовки не появляются.
|
||||
assert "Инженерные сети рядом" in text
|
||||
assert "Электроснабжение — свободная мощность" not in text
|
||||
assert "Газоснабжение — свободная мощность ГРС" not in text
|
||||
|
||||
|
||||
def test_no_alternatives_when_field_absent() -> None:
|
||||
"""program_alternatives отсутствует → блок альтернатив не рисуется вовсе."""
|
||||
analyze = _analyze()
|
||||
analyze.pop("program_alternatives")
|
||||
out = _render(analyze_result=analyze)
|
||||
headings = {p.text for p in Document(io.BytesIO(out)).paragraphs}
|
||||
assert "Как участок сходится (альтернативы программы)" not in headings
|
||||
|
||||
|
||||
def test_empty_payload_produces_valid_docx() -> None:
|
||||
"""Пустой payload (всё {}) → валидный минимальный .docx БЕЗ падения (graceful)."""
|
||||
out = build_full_report_docx(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
cad="x",
|
||||
address=None,
|
||||
generated_at="03.07.2026",
|
||||
parcel_map_png=None,
|
||||
concept_map_png=None,
|
||||
)
|
||||
assert out[:2] == b"PK"
|
||||
doc = Document(io.BytesIO(out))
|
||||
headings = {p.text for p in doc.paragraphs}
|
||||
# Все секции присутствуют даже на пустом payload.
|
||||
assert "§1. Участок" in headings
|
||||
assert "§7. Концепция застройки" in headings
|
||||
assert "нет данных" in _all_text(doc)
|
||||
|
||||
|
||||
def test_garbage_payload_does_not_crash() -> None:
|
||||
"""Мусорный payload (не-dict) нормализуется к пустому → валидный .docx."""
|
||||
out = build_full_report_docx(
|
||||
"не словарь", # type: ignore[arg-type]
|
||||
"тоже не словарь", # type: ignore[arg-type]
|
||||
None,
|
||||
None,
|
||||
cad=_CAD,
|
||||
address=None,
|
||||
generated_at="03.07.2026",
|
||||
parcel_map_png=None,
|
||||
concept_map_png=None,
|
||||
)
|
||||
assert out[:2] == b"PK"
|
||||
|
||||
|
||||
# ── §6 «Разрешения на строительство рядом (500 м)» (ГИСОГД-66, зеркало HTML) ──────
|
||||
|
||||
|
||||
def _permits_result() -> dict[str, Any]:
|
||||
"""analyze-payload с непустым permits_nearby (2 документа: РНС + РВЭ)."""
|
||||
return {
|
||||
"permits_nearby": {
|
||||
"total_count": 2,
|
||||
"rs_count": 1,
|
||||
"rv_count": 1,
|
||||
"nearest_distance_m": 123.5,
|
||||
"items": [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": "Разрешение на строительство МКД",
|
||||
"date_doc": "2025-03-14",
|
||||
"approved_organization": "Администрация ЕКБ",
|
||||
"distance_m": 123.5,
|
||||
},
|
||||
{
|
||||
"doc_group": "RV",
|
||||
"doc_name": "Разрешение на ввод",
|
||||
"date_doc": "2024-11-01",
|
||||
"approved_organization": "Минстрой СО",
|
||||
"distance_m": 340.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _new_doc() -> Document:
|
||||
"""Пустой python-docx Document для юнит-теста билдера в изоляции."""
|
||||
return Document()
|
||||
|
||||
|
||||
def test_permits_nearby_empty_honest_full_sentence() -> None:
|
||||
"""Нет permits_nearby → честная плашка-параграф полным предложением, БЕЗ таблицы."""
|
||||
doc = _new_doc()
|
||||
_build_permits_nearby(doc, {})
|
||||
text = _all_text(doc)
|
||||
assert "новых разрешений на строительство не найдено" in text
|
||||
assert "ГИСОГД Свердловской области" in text
|
||||
assert len(doc.tables) == 0 # плашка — параграф, не таблица
|
||||
|
||||
|
||||
def test_permits_nearby_zero_total_honest_note() -> None:
|
||||
"""total_count=0 → та же честная плашка (не аббревиатура), таблицы нет."""
|
||||
doc = _new_doc()
|
||||
_build_permits_nearby(doc, {"permits_nearby": {"total_count": 0, "items": []}})
|
||||
text = _all_text(doc)
|
||||
assert "новых разрешений на строительство не найдено" in text
|
||||
assert len(doc.tables) == 0
|
||||
|
||||
|
||||
def test_permits_nearby_non_empty_summary_and_list() -> None:
|
||||
"""Есть данные → сводка РНС/РВЭ/ближайшее (kv-таблица) + таблица документов."""
|
||||
doc = _new_doc()
|
||||
_build_permits_nearby(doc, _permits_result())
|
||||
text = _all_text(doc)
|
||||
assert "не найдено" not in text
|
||||
assert "Разрешений на строительство (РНС)" in text
|
||||
assert "Разрешений на ввод (РВЭ)" in text
|
||||
assert "Разрешение на строительство МКД" in text
|
||||
assert "Администрация ЕКБ" in text
|
||||
assert "123.5" in text
|
||||
# kv-сводка + таблица items = две таблицы.
|
||||
assert len(doc.tables) == 2
|
||||
|
||||
|
||||
def test_permits_nearby_items_capped_at_ten() -> None:
|
||||
"""items[:10] — показываются первые 10 строк документов (зеркало HTML).
|
||||
|
||||
#2464 cluster B: сверх 10 добавляется honest-строка «и ещё N записей», поэтому
|
||||
таблица = шапка + 10 документов + 1 disclosure = 12 строк.
|
||||
"""
|
||||
items = [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": f"РНС-{i}",
|
||||
"date_doc": "2025-01-01",
|
||||
"approved_organization": "Орган",
|
||||
"distance_m": float(i),
|
||||
}
|
||||
for i in range(15)
|
||||
]
|
||||
result = {
|
||||
"permits_nearby": {
|
||||
"total_count": 15,
|
||||
"rs_count": 15,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": 0.0,
|
||||
"items": items,
|
||||
}
|
||||
}
|
||||
doc = _new_doc()
|
||||
_build_permits_nearby(doc, result)
|
||||
# Вторая таблица (документы): шапка + 10 показанных + 1 disclosure-строка.
|
||||
docs_table = doc.tables[1]
|
||||
assert len(docs_table.rows) == 12
|
||||
text = _all_text(doc)
|
||||
assert "РНС-0" in text
|
||||
assert "РНС-9" in text
|
||||
assert "РНС-10" not in text
|
||||
|
||||
|
||||
def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
|
||||
"""#2464 cluster B: total_count (честный COUNT апстрима, БЕЗ SQL LIMIT) > показанных
|
||||
10 → honest-строка «и ещё N записей (полный список в веб-версии §6)», а не молчаливый
|
||||
срез — как тепло/вода-таблицы §3 (`_build_connection_capacity`)."""
|
||||
items = [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": f"РНС-{i}",
|
||||
"date_doc": "2025-01-01",
|
||||
"approved_organization": "Орган",
|
||||
"distance_m": float(i),
|
||||
}
|
||||
for i in range(12)
|
||||
]
|
||||
result = {
|
||||
"permits_nearby": {
|
||||
# total_count=15 > показанных 10 → hidden = 5 (апстрим уже капнул items на 30,
|
||||
# здесь режем до 10; disclosure считается от честного total_count).
|
||||
"total_count": 15,
|
||||
"rs_count": 15,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": 0.0,
|
||||
"items": items,
|
||||
}
|
||||
}
|
||||
doc = _new_doc()
|
||||
_build_permits_nearby(doc, result)
|
||||
text = _all_text(doc)
|
||||
assert "… и ещё 5 записей (полный список в веб-версии §6)" in text
|
||||
|
||||
|
||||
def test_permits_nearby_no_disclosure_when_all_shown() -> None:
|
||||
"""total_count <= 10 (всё влезло) → БЕЗ строки «и ещё» (zero behavior change для
|
||||
маленьких выборок): таблица = шапка + N документов, без disclosure-строки."""
|
||||
items = [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": f"РНС-{i}",
|
||||
"date_doc": "2025-01-01",
|
||||
"approved_organization": "Орган",
|
||||
"distance_m": float(i),
|
||||
}
|
||||
for i in range(7)
|
||||
]
|
||||
result = {
|
||||
"permits_nearby": {
|
||||
"total_count": 7,
|
||||
"rs_count": 7,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": 0.0,
|
||||
"items": items,
|
||||
}
|
||||
}
|
||||
doc = _new_doc()
|
||||
_build_permits_nearby(doc, result)
|
||||
docs_table = doc.tables[1]
|
||||
assert len(docs_table.rows) == 8 # шапка + 7, без disclosure
|
||||
text = _all_text(doc)
|
||||
assert "и ещё" not in text
|
||||
|
||||
|
||||
def test_section_6_wires_permits_nearby_from_analyze() -> None:
|
||||
"""_build_section_6(doc, forecast, result) прокидывает result → блок разрешений виден."""
|
||||
doc = _new_doc()
|
||||
_build_section_6(doc, {}, _permits_result())
|
||||
text = _all_text(doc)
|
||||
headings = {p.text for p in doc.paragraphs}
|
||||
assert "Разрешения на строительство рядом (500 м)" in headings
|
||||
assert "Разрешение на строительство МКД" in text
|
||||
|
||||
|
||||
def test_section_6_permits_nearby_absent_analyze_shows_honest_note() -> None:
|
||||
"""§6 без permits_nearby в result всё равно рисует честную плашку (не падает)."""
|
||||
doc = _new_doc()
|
||||
_build_section_6(doc, {}, {})
|
||||
text = _all_text(doc)
|
||||
assert "Разрешения на строительство рядом (500 м)" in {p.text for p in doc.paragraphs}
|
||||
assert "новых разрешений на строительство не найдено" in text
|
||||
|
||||
|
||||
def test_full_report_wires_permits_nearby_end_to_end() -> None:
|
||||
"""build_full_report_docx прокидывает analyze.permits_nearby в §6 (интеграция)."""
|
||||
analyze = _analyze()
|
||||
analyze["permits_nearby"] = _permits_result()["permits_nearby"]
|
||||
out = _render(analyze_result=analyze)
|
||||
doc = Document(io.BytesIO(out))
|
||||
headings = {p.text for p in doc.paragraphs}
|
||||
assert "Разрешения на строительство рядом (500 м)" in headings
|
||||
assert "Разрешение на строительство МКД" in _all_text(doc)
|
||||
|
||||
|
||||
def test_full_report_permits_nearby_honest_note_when_absent() -> None:
|
||||
"""Дефолтный _analyze() без permits_nearby → честная плашка «не найдено» в §6."""
|
||||
out = _render()
|
||||
text = _all_text(Document(io.BytesIO(out)))
|
||||
assert "Разрешения на строительство рядом (500 м)" in {
|
||||
p.text for p in Document(io.BytesIO(out)).paragraphs
|
||||
}
|
||||
assert "новых разрешений на строительство не найдено" in text
|
||||
|
|
@ -386,6 +386,150 @@ def test_connection_capacity_absent_when_none() -> None:
|
|||
assert "Газоснабжение — свободная мощность ГРС" not in html2
|
||||
|
||||
|
||||
# ── FIX 5: противоречие ЗОУИТ-блоков (has_zouit falsy, но overlaps есть) ─────────
|
||||
|
||||
|
||||
def test_zouit_reconciles_falsy_flag_with_overlaps() -> None:
|
||||
"""has_zouit=нет + zouit_count=0, но nspd_zouit_overlaps непуст → «да (по данным НСПД)»."""
|
||||
result = {
|
||||
"encumbrance": {"has_zouit": False, "zouit_count": 0},
|
||||
"nspd_zouit_overlaps": [
|
||||
{"type_zone": "Охранная зона ЛЭП", "reg_numb_border": "66:41-1", "coverage_pct": 0.3},
|
||||
{"type_zone": "Санитарная зона", "reg_numb_border": "66:41-2", "coverage_pct": 0.1},
|
||||
],
|
||||
}
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert "да (по данным НСПД)" in html
|
||||
# Кол-во типов = число пересечений; типы восстановлены из overlaps.
|
||||
assert "Охранная зона ЛЭП" in html
|
||||
assert "Санитарная зона" in html
|
||||
|
||||
|
||||
def test_zouit_flag_true_unchanged() -> None:
|
||||
"""has_zouit=да → сводка не переписывается (FIX 5 трогает только противоречие)."""
|
||||
result = {
|
||||
"encumbrance": {"has_zouit": True, "zouit_count": 1, "zouit_types": ["Охранная"]},
|
||||
"nspd_zouit_overlaps": [{"type_zone": "Охранная", "coverage_pct": 0.5}],
|
||||
}
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert "да (по данным НСПД)" not in html
|
||||
|
||||
|
||||
# ── FIX 6a: НСПД-сооружения — пропуск строк-призраков ────────────────────────────
|
||||
|
||||
|
||||
def test_engineering_nearby_skips_ghost_rows() -> None:
|
||||
"""Строки где И имя, И назначение пустые («— — 7») скипаются (FIX 6a)."""
|
||||
result = {
|
||||
"nspd_engineering_nearby": [
|
||||
{"name": None, "type": None, "distance_m": 7, "raw_props": {}}, # призрак
|
||||
{"name": "Трубопровод", "type": "тепло", "distance_m": 40, "raw_props": {}},
|
||||
]
|
||||
}
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert "Трубопровод" in html
|
||||
# Заголовок есть (записи были), призрак-строки нет.
|
||||
assert "Инженерные сооружения рядом (НСПД)" in html
|
||||
|
||||
|
||||
def test_engineering_nearby_all_empty_shows_no_data() -> None:
|
||||
"""Все записи пустые → заголовок + «нет данных» (не пустой невидимый блок, FIX 6a)."""
|
||||
result = {
|
||||
"nspd_engineering_nearby": [
|
||||
{"name": None, "type": None, "distance_m": 7, "raw_props": {}},
|
||||
{"name": "", "type": "", "distance_m": 9, "raw_props": {}},
|
||||
]
|
||||
}
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert "Инженерные сооружения рядом (НСПД)" in html
|
||||
assert _NO_DATA in html
|
||||
|
||||
|
||||
# ── FIX 3: тепло/вода — фильтр областного шума + агрегат ЕКБ + кап ───────────────
|
||||
|
||||
|
||||
def _heat_systems_province() -> dict[str, Any]:
|
||||
"""56-строчная тепло-выборка: горсть ЕКБ + много не-ЕКБ (Ирбит/Тавда/…)."""
|
||||
systems: list[dict[str, Any]] = [
|
||||
{"org": "ЕТК", "system_name": "СТ Екатеринбург №54", "reserve_gcal_h": 3.1},
|
||||
{"org": "Т Плюс", "system_name": "СТ Екатеринбург ТЭЦ", "reserve_gcal_h": 12.0},
|
||||
# Дефицит ЕКБ — под кап НЕ прячем.
|
||||
{"org": "ЕТК", "system_name": "СТ Екатеринбург №7", "reserve_gcal_h": -2.5},
|
||||
]
|
||||
# Много областного шума (не-ЕКБ).
|
||||
for city in ("Ирбит", "Тавда", "Красноуфимск", "Первоуральск", "Лесной", "Нижняя Тура"):
|
||||
systems.append(
|
||||
{"org": "Регионгаз-инвест", "system_name": f"СТ {city} №1", "reserve_gcal_h": 1.0}
|
||||
)
|
||||
return {"systems": systems, "total_reserve_gcal_h": 100.0}
|
||||
|
||||
|
||||
def test_heat_table_aggregates_ekb_and_hides_province_noise() -> None:
|
||||
"""Тепло: агрегат «Суммарно (ЕКБ)» + не-ЕКБ схлопнуты в «… и ещё K систем» (FIX 3)."""
|
||||
cap = {"heat": _heat_systems_province()}
|
||||
html = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity=cap)
|
||||
assert "Суммарно (ЕКБ)" in html
|
||||
# ЕКБ-системы видны (в т.ч. дефицит), не-ЕКБ схлопнуты.
|
||||
assert "Екатеринбург №54" in html
|
||||
assert "Екатеринбург №7" in html # дефицит НЕ спрятан
|
||||
assert "и ещё" in html # областной шум схлопнут
|
||||
# Не-ЕКБ город не должен фигурировать строкой.
|
||||
assert "Ирбит" not in html
|
||||
|
||||
|
||||
def test_heat_generic_province_units_hidden_ekb_mention_wins() -> None:
|
||||
"""Generic-маркеры областных админ-единиц скрыты; упоминание ЕКБ перевешивает (FIX 3+)."""
|
||||
systems = [
|
||||
{
|
||||
"org": "Т Плюс",
|
||||
"system_name": "СТ: Нижнетуринский муниципальный округ",
|
||||
"reserve_gcal_h": 239.0,
|
||||
},
|
||||
{
|
||||
"org": "Регионгаз-инвест",
|
||||
"system_name": "Невьянский муниципальный округ",
|
||||
"reserve_gcal_h": 51.0,
|
||||
},
|
||||
# Упоминание Екатеринбурга перевешивает generic-маркер — строка ЕКБ.
|
||||
{
|
||||
"org": "ЕТК",
|
||||
"system_name": "муниципальное образование город Екатеринбург",
|
||||
"reserve_gcal_h": 10.0,
|
||||
},
|
||||
]
|
||||
html = build_full_report_html_part_a(
|
||||
_synthetic_result(), cad="X", connection_capacity={"heat": {"systems": systems}}
|
||||
)
|
||||
assert "Нижнетуринский" not in html
|
||||
assert "Невьянский" not in html
|
||||
assert "город Екатеринбург" in html
|
||||
# Агрегат считает только ЕКБ-строку (10.0), а не областные 239+51.
|
||||
assert "239" not in html
|
||||
|
||||
|
||||
def test_heat_deficit_not_hidden_under_cap() -> None:
|
||||
"""Честность: отрицательный резерв ЕКБ виден даже при переполнении капа (FIX 3)."""
|
||||
systems = [{"org": "ЕТК", "system_name": "СТ Екатеринбург дефицит", "reserve_gcal_h": -5.0}]
|
||||
# Забиваем кап 20 профицитными ЕКБ-системами.
|
||||
for i in range(20):
|
||||
systems.append(
|
||||
{"org": "ЕТК", "system_name": f"СТ Екатеринбург плюс {i}", "reserve_gcal_h": float(i)}
|
||||
)
|
||||
html = build_full_report_html_part_a(
|
||||
_synthetic_result(), cad="X", connection_capacity={"heat": {"systems": systems}}
|
||||
)
|
||||
assert "Екатеринбург дефицит" in html
|
||||
|
||||
|
||||
def test_long_system_name_truncated() -> None:
|
||||
"""Длинное бюрократическое имя системы усечено до ~120 символов с «…» (FIX 3)."""
|
||||
long_name = "Централизованная система теплоснабжения " * 6 # >200 симв.
|
||||
cap = {"heat": {"systems": [{"org": "ЕТК Екатеринбург", "system_name": long_name}]}}
|
||||
html = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity=cap)
|
||||
assert "…" in html
|
||||
assert long_name not in html # полное имя не попало целиком
|
||||
|
||||
|
||||
# ── 9. Каркас документа: титул / оглавление / опциональный Part B ────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from app.services.exporters.full_report_html import (
|
|||
_ANCHOR_S7,
|
||||
_NO_DATA,
|
||||
MAP_CONCEPT_PLACEHOLDER,
|
||||
_build_permits_nearby,
|
||||
build_full_report_html,
|
||||
build_full_report_html_part_a,
|
||||
build_full_report_html_part_b,
|
||||
|
|
@ -399,6 +400,167 @@ def test_section_6_scenarios_collapsed_honest_note() -> None:
|
|||
assert "Чувствительность спроса к ставке не оценена" in html
|
||||
|
||||
|
||||
def test_permits_nearby_empty_honest_full_sentence() -> None:
|
||||
"""Нет permits_nearby / total_count=0 → честная плашка полным предложением (не аббр.)."""
|
||||
assert "новых разрешений на строительство не найдено" in _build_permits_nearby({})
|
||||
zero = {"permits_nearby": {"total_count": 0, "items": []}}
|
||||
out = _build_permits_nearby(zero)
|
||||
assert "ГИСОГД Свердловской области" in out
|
||||
assert "не найдено" in out
|
||||
|
||||
|
||||
def test_permits_nearby_non_empty_summary_and_list() -> None:
|
||||
"""Есть данные → сводка РНС/РВЭ/ближайшее + список документов."""
|
||||
result = {
|
||||
"permits_nearby": {
|
||||
"total_count": 2,
|
||||
"rs_count": 1,
|
||||
"rv_count": 1,
|
||||
"nearest_distance_m": 123.5,
|
||||
"items": [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": "Разрешение на строительство МКД",
|
||||
"date_doc": "2025-03-14",
|
||||
"approved_organization": "Администрация ЕКБ",
|
||||
"distance_m": 123.5,
|
||||
},
|
||||
{
|
||||
"doc_group": "RV",
|
||||
"doc_name": "Разрешение на ввод",
|
||||
"date_doc": "2024-11-01",
|
||||
"approved_organization": "Минстрой СО",
|
||||
"distance_m": 340.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
out = _build_permits_nearby(result)
|
||||
assert "не найдено" not in out
|
||||
assert "Разрешение на строительство МКД" in out
|
||||
assert "Администрация ЕКБ" in out
|
||||
assert "123.5" in out
|
||||
|
||||
|
||||
def test_permits_nearby_org_name_escaped() -> None:
|
||||
"""org-имя проходит html.escape (недоверенный вход)."""
|
||||
result = {
|
||||
"permits_nearby": {
|
||||
"total_count": 1,
|
||||
"rs_count": 1,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": 10.0,
|
||||
"items": [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": "<b>hack</b>",
|
||||
"date_doc": "2025-01-01",
|
||||
"approved_organization": "A&B <script>",
|
||||
"distance_m": 10.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
out = _build_permits_nearby(result)
|
||||
assert "<script>" not in out
|
||||
assert "<script>" in out
|
||||
assert "&" in out
|
||||
assert "<b>hack</b>" not in out
|
||||
|
||||
|
||||
def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
|
||||
"""#2464 cluster B: >10 items (upstream cap) → «и ещё N записей» дисклоузится,
|
||||
вместо молчаливого среза до 10 (total_count — честный total апстрима,
|
||||
permits_nearby.get_permits_nearby считает его COUNT'ом БЕЗ SQL LIMIT)."""
|
||||
items = [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": f"РНС №{i}",
|
||||
"date_doc": "2025-01-01",
|
||||
"approved_organization": "Администрация",
|
||||
"distance_m": float(i * 10),
|
||||
}
|
||||
for i in range(12)
|
||||
]
|
||||
result = {
|
||||
"permits_nearby": {
|
||||
"total_count": 15, # > len(items)=12: апстрим уже капнул items на 30,
|
||||
# здесь ещё раз режем до 10 — итоговый hidden = 15-10 = 5.
|
||||
"rs_count": 15,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": 0.0,
|
||||
"items": items,
|
||||
"items_truncated": True,
|
||||
}
|
||||
}
|
||||
out = _build_permits_nearby(result)
|
||||
assert "РНС №9" in out # 10-й показанный (index 9, row cap=10)
|
||||
assert "РНС №10" not in out # 11-й — за кэпом, НЕ показан напрямую
|
||||
assert "и ещё 5 записей" in out
|
||||
assert "веб-версии §6" in out
|
||||
|
||||
|
||||
def test_permits_nearby_no_truncation_note_when_all_shown() -> None:
|
||||
"""total_count совпадает с показанными (<=10) → БЕЗ приписки «и ещё» (zero behavior
|
||||
change для маленьких выборок, как раньше)."""
|
||||
items = [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": f"РНС №{i}",
|
||||
"date_doc": "2025-01-01",
|
||||
"approved_organization": "Администрация",
|
||||
"distance_m": float(i * 10),
|
||||
}
|
||||
for i in range(7)
|
||||
]
|
||||
result = {
|
||||
"permits_nearby": {
|
||||
"total_count": 7,
|
||||
"rs_count": 7,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": 0.0,
|
||||
"items": items,
|
||||
"items_truncated": False,
|
||||
}
|
||||
}
|
||||
out = _build_permits_nearby(result)
|
||||
assert "и ещё" not in out
|
||||
for i in range(7):
|
||||
assert f"РНС №{i}" in out
|
||||
|
||||
|
||||
def test_section_6_wires_permits_nearby_from_analyze() -> None:
|
||||
"""analyze_result прокинут в §6 → блок разрешений рядом виден в part_b HTML."""
|
||||
analyze = {
|
||||
"permits_nearby": {
|
||||
"total_count": 1,
|
||||
"rs_count": 1,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": 42.0,
|
||||
"items": [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": "РНС уникальный-маркер-XYZ",
|
||||
"date_doc": "2025-05-01",
|
||||
"approved_organization": "Администрация",
|
||||
"distance_m": 42.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
html = build_full_report_html_part_b(
|
||||
_synthetic_forecast(), None, cad="X", analyze_result=analyze
|
||||
)
|
||||
assert "Разрешения на строительство рядом" in html
|
||||
assert "РНС уникальный-маркер-XYZ" in html
|
||||
|
||||
|
||||
def test_section_6_permits_nearby_absent_analyze_shows_honest_note() -> None:
|
||||
"""Без analyze_result §6 всё равно рисует честную плашку «не найдено» (не падает)."""
|
||||
html = build_full_report_html_part_b(_synthetic_forecast(), None, cad="X")
|
||||
assert "новых разрешений на строительство не найдено" in html
|
||||
|
||||
|
||||
# ── 5. §7 Концепция: ТЭП/финитог/программа + {{MAP_CONCEPT}} ─────────────────────
|
||||
|
||||
|
||||
|
|
@ -567,3 +729,113 @@ def test_document_frame_with_both_parts() -> None:
|
|||
assert f'href="#{_ANCHOR_S7}"' in doc
|
||||
# Ровно один общий <style> (не дублируется на Part B).
|
||||
assert doc.count("<style>") == 1
|
||||
|
||||
|
||||
# ── FIX 4: конкуренты — дубли-корпуса + безымянные с 0 лотов ─────────────────────
|
||||
|
||||
|
||||
def _forecast_with_competitors(competitors: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Форсайт только с блоком конкурентов (остальное пустое → graceful)."""
|
||||
return {"market_now": {"competitors": competitors}}
|
||||
|
||||
|
||||
def test_competitors_group_corpuses_and_sum_lots() -> None:
|
||||
"""6 корпусов «ЖК ОК: Премиум» → одна строка «(6 корпусов)», лоты суммой (FIX 4)."""
|
||||
corpuses = [
|
||||
{
|
||||
"comm_name": "ЖК ОК: Премиум",
|
||||
"dev_name": "Девелопер X",
|
||||
"obj_class": "Премиум",
|
||||
"distance_m": 1000 + i * 10,
|
||||
"flat_count": 50,
|
||||
}
|
||||
for i in range(6)
|
||||
]
|
||||
html = build_full_report_html_part_b(_forecast_with_competitors(corpuses), None, cad="X")
|
||||
assert "ЖК ОК: Премиум (6 корпусов)" in html
|
||||
assert "300" in html # 6 × 50 лотов суммой
|
||||
# Ближайшая дистанция группы — минимум (1000), с пробелом-разрядом «1 000».
|
||||
assert "1 000" in html
|
||||
|
||||
|
||||
def test_competitors_skip_nameless_zero_lots() -> None:
|
||||
"""Безымянные строки с 0 лотов («—» Эфес) скипаются (FIX 4)."""
|
||||
competitors = [
|
||||
{"comm_name": None, "dev_name": "Эфес", "flat_count": 0, "distance_m": 500},
|
||||
{"comm_name": None, "dev_name": "Эфес", "flat_count": 0, "distance_m": 600},
|
||||
{"comm_name": "ЖК Реальный", "dev_name": "Дев", "flat_count": 120, "distance_m": 800},
|
||||
]
|
||||
html = build_full_report_html_part_b(_forecast_with_competitors(competitors), None, cad="X")
|
||||
assert "ЖК Реальный" in html
|
||||
assert "Эфес" not in html # безымянные с 0 лотов выброшены
|
||||
|
||||
|
||||
def test_competitors_single_corpus_no_suffix() -> None:
|
||||
"""Один корпус → без суффикса «(K корпусов)» (FIX 4)."""
|
||||
competitors = [
|
||||
{"comm_name": "ЖК Одиночка", "dev_name": "Дев", "flat_count": 40, "distance_m": 300}
|
||||
]
|
||||
html = build_full_report_html_part_b(_forecast_with_competitors(competitors), None, cad="X")
|
||||
assert "ЖК Одиночка" in html
|
||||
assert "корпусов" not in html
|
||||
|
||||
|
||||
# ── FIX 6b: confidence-факторы — схлопывание дублирующей колонки «Комментарий» ────
|
||||
|
||||
|
||||
def test_confidence_factors_collapse_duplicate_note_column() -> None:
|
||||
"""note дублирует label у всех факторов → колонка «Комментарий» скрыта (FIX 6b)."""
|
||||
forecast = {
|
||||
"confidence": {
|
||||
"level": "medium",
|
||||
"factors": {
|
||||
"a": {"label": "Сделки", "level": "high", "note": "Сделки"},
|
||||
"b": {"label": "История", "level": "low", "note": "История"},
|
||||
},
|
||||
}
|
||||
}
|
||||
html = build_full_report_html_part_b(forecast, None, cad="X")
|
||||
assert "Сделки" in html
|
||||
assert "Комментарий" not in html # дублирующая колонка схлопнута
|
||||
|
||||
|
||||
def test_confidence_factors_keep_note_when_informative() -> None:
|
||||
"""note несёт данные сверх label → колонка «Комментарий» остаётся (FIX 6b)."""
|
||||
forecast = {
|
||||
"confidence": {
|
||||
"level": "low",
|
||||
"factors": {
|
||||
"a": {"label": "Сделки", "level": "high", "note": "1388 сделок за 6 мес"},
|
||||
},
|
||||
}
|
||||
}
|
||||
html = build_full_report_html_part_b(forecast, None, cad="X")
|
||||
assert "Комментарий" in html
|
||||
assert "1388 сделок за 6 мес" in html
|
||||
|
||||
|
||||
# ── FIX 6c/6d: абсорбция как %, средний чек без «+» ──────────────────────────────
|
||||
|
||||
|
||||
def test_absorption_rate_shown_as_percent() -> None:
|
||||
"""absorption_rate 0.0112 → «1.1%», не сырое «0.01» (FIX 6c)."""
|
||||
forecast = {"market_now": {"market_metrics": {"absorption_rate": 0.0112}}}
|
||||
html = build_full_report_html_part_b(forecast, None, cad="X")
|
||||
assert "1.1%" in html
|
||||
assert ">0.01<" not in html
|
||||
|
||||
|
||||
def test_avg_ticket_has_no_leading_plus() -> None:
|
||||
"""Средний чек лота — цена, не дельта: «8 млн ₽» без ведущего «+» (FIX 6d)."""
|
||||
forecast = {
|
||||
"scoring": {
|
||||
"special_indices": {
|
||||
"indices": {
|
||||
"cost_of_error": {"detail": {"avg_ticket_rub": 8_000_000.0}},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
html = build_full_report_html_part_b(forecast, None, cad="X")
|
||||
assert "8 млн ₽" in html
|
||||
assert "+8 млн ₽" not in html
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ def _patch_renders(monkeypatch: pytest.MonkeyPatch, pdf_mock: MagicMock) -> None
|
|||
# connection-capacity + концепция — заглушаем на None (best-effort, отдельные тесты ниже).
|
||||
monkeypatch.setattr(mod, "_get_connection_capacity", lambda db, cad: None)
|
||||
monkeypatch.setattr(mod, "_generate_concept_result", lambda db, analyze: None)
|
||||
# inline §22-форсайт (best-effort synchronous compute) — по умолчанию no-op, чтобы
|
||||
# тяжёлый orchestrator не тянулся; поведение «холодного» участка тестируем отдельно ниже.
|
||||
monkeypatch.setattr(mod, "_ensure_forecast_run", lambda db, cad, row, analyze: None)
|
||||
|
||||
|
||||
def test_no_analyze_run_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -126,13 +129,55 @@ def test_happy_path_writes_file_and_persists(
|
|||
assert kwargs["result"]["forecast_run_id"] == 202
|
||||
|
||||
|
||||
def test_no_forecast_run_degrades(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Нет forecast-рана → forecast_run_id=None, Part B из пустого форсайта (не падает)."""
|
||||
def test_no_forecast_run_triggers_inline_generation(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Холодный участок: forecast-рана нет → _ensure_forecast_run вызван → ран перечитан.
|
||||
|
||||
Генератор (mock) «персистит» §22-ран, поэтому повторный latest_run_for("1.0") ловит
|
||||
свежий forecast_row → его id попадает в отчёт и кэш-ключ (самодостаточность §4–§6).
|
||||
"""
|
||||
analyze_row = _make_row(101, _analyze_result())
|
||||
forecast_row = _make_row(777, _forecast_result()) # «появляется» после генерации
|
||||
rows: dict[str | None, MagicMock | None] = {
|
||||
"analyze-1.0": analyze_row,
|
||||
"1.0": None, # первый read: рана ещё нет
|
||||
mod.REPORT_SCHEMA_VERSION: None,
|
||||
}
|
||||
table = _RunTable(rows)
|
||||
monkeypatch.setattr(mod, "latest_run_for", table)
|
||||
monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
|
||||
_patch_renders(monkeypatch, MagicMock(return_value=b"%PDF"))
|
||||
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
|
||||
|
||||
def _appear(db: Any, cad: str, row: Any, analyze: Any) -> None:
|
||||
rows["1.0"] = forecast_row # генератор «персистит» § 22-ран
|
||||
|
||||
ensure_mock = MagicMock(side_effect=_appear)
|
||||
monkeypatch.setattr(mod, "_ensure_forecast_run", ensure_mock)
|
||||
|
||||
result = mod.build_full_report(MagicMock(), _CAD)
|
||||
|
||||
# Генератор вызван ровно раз с cad + analyze-row (для district-fallback); ран перечитан.
|
||||
ensure_mock.assert_called_once()
|
||||
call_args, _ = ensure_mock.call_args
|
||||
assert call_args[1] == _CAD # cad
|
||||
assert call_args[2] is analyze_row # analyze_row
|
||||
assert result["forecast_run_id"] == 777
|
||||
assert result["analyze_run_id"] == 101
|
||||
|
||||
|
||||
def test_inline_forecast_failure_degrades(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Inline-форсайт ничего не собрал (сбой/тонкие данные) → отчёт без §4–§6.
|
||||
|
||||
_ensure_forecast_run best-effort (сам глотает исключение → warning), поэтому здесь он
|
||||
просто no-op: повторный latest_run_for("1.0") снова None → forecast_run_id=None,
|
||||
Part B из пустого форсайта (отчёт валиден, как до фикса на холодном участке)."""
|
||||
analyze_row = _make_row(101, _analyze_result())
|
||||
table = _RunTable({"analyze-1.0": analyze_row, "1.0": None, mod.REPORT_SCHEMA_VERSION: None})
|
||||
monkeypatch.setattr(mod, "latest_run_for", table)
|
||||
monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
|
||||
_patch_renders(monkeypatch, MagicMock(return_value=b"%PDF"))
|
||||
_patch_renders(monkeypatch, MagicMock(return_value=b"%PDF")) # ставит _ensure no-op
|
||||
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
|
||||
|
||||
result = mod.build_full_report(MagicMock(), _CAD)
|
||||
|
|
@ -258,6 +303,146 @@ def test_connection_capacity_failure_is_best_effort(monkeypatch: pytest.MonkeyPa
|
|||
assert mod._get_connection_capacity(MagicMock(), _CAD) is None
|
||||
|
||||
|
||||
def test_ensure_forecast_run_computes_and_persists(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""_ensure_forecast_run: считает §22 через orchestrator и персистит как Celery-таска.
|
||||
|
||||
Сверка с forecast_site_finder_report: build_site_finder_report(horizons=[6,12,18,24]) →
|
||||
report.as_dict() → persist_analysis_run(schema_version="1.0", status="done", ...).
|
||||
"""
|
||||
db = MagicMock()
|
||||
analyze_row = _make_row(101, _analyze_result(), district="Кировский")
|
||||
report = MagicMock()
|
||||
report.as_dict.return_value = {
|
||||
"schema_version": "1.0",
|
||||
"confidence": {"level": "medium"},
|
||||
"meta": {"segment": {"obj_class": "comfort"}},
|
||||
}
|
||||
build_mock = MagicMock(return_value=report)
|
||||
persist_mock = MagicMock(return_value=888)
|
||||
monkeypatch.setattr(
|
||||
"app.services.forecasting.orchestrator.build_site_finder_report", build_mock
|
||||
)
|
||||
monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)
|
||||
|
||||
mod._ensure_forecast_run(db, _CAD, analyze_row, _analyze_result())
|
||||
|
||||
# build_site_finder_report вызван с дефолт-горизонтами таски и district из денорм-колонки.
|
||||
build_mock.assert_called_once()
|
||||
_, bkwargs = build_mock.call_args
|
||||
assert bkwargs["cad_num"] == _CAD
|
||||
assert bkwargs["district"] == "Кировский"
|
||||
assert list(bkwargs["horizons"]) == [6, 12, 18, 24]
|
||||
# persist той же схемой "1.0" + confidence/segment из as_dict.
|
||||
persist_mock.assert_called_once()
|
||||
_, pkwargs = persist_mock.call_args
|
||||
assert pkwargs["schema_version"] == "1.0"
|
||||
assert pkwargs["status"] == "done"
|
||||
assert pkwargs["confidence"] == "medium"
|
||||
assert pkwargs["segment"] == {"obj_class": "comfort"}
|
||||
|
||||
|
||||
def test_ensure_forecast_run_district_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""district=None в денорм-колонке → fallback на analyze['district']['district_name']."""
|
||||
db = MagicMock()
|
||||
analyze_row = _make_row(101, _analyze_result(), district=None)
|
||||
analyze = {**_analyze_result(), "district": {"district_name": "Ленинский"}}
|
||||
report = MagicMock()
|
||||
report.as_dict.return_value = {"schema_version": "1.0"}
|
||||
build_mock = MagicMock(return_value=report)
|
||||
monkeypatch.setattr(
|
||||
"app.services.forecasting.orchestrator.build_site_finder_report", build_mock
|
||||
)
|
||||
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
|
||||
|
||||
mod._ensure_forecast_run(db, _CAD, analyze_row, analyze)
|
||||
|
||||
_, bkwargs = build_mock.call_args
|
||||
assert bkwargs["district"] == "Ленинский"
|
||||
|
||||
|
||||
def test_ensure_forecast_run_failure_is_best_effort(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""orchestrator бросает → _ensure_forecast_run глотает (warning, НЕ пробрасывает)."""
|
||||
db = MagicMock()
|
||||
analyze_row = _make_row(101, _analyze_result())
|
||||
monkeypatch.setattr(
|
||||
"app.services.forecasting.orchestrator.build_site_finder_report",
|
||||
MagicMock(side_effect=RuntimeError("§9.x seam failed")),
|
||||
)
|
||||
persist_mock = MagicMock()
|
||||
monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)
|
||||
|
||||
# Не должно бросить наружу — best-effort деградация в отчёт без §4–§6.
|
||||
mod._ensure_forecast_run(db, _CAD, analyze_row, _analyze_result())
|
||||
persist_mock.assert_not_called() # compute упал ДО персиста
|
||||
|
||||
|
||||
_MULTIPOLYGON = {
|
||||
"type": "MultiPolygon",
|
||||
"coordinates": [
|
||||
# Мелкий контур (площадь ~0.0001 в градусах²).
|
||||
[[[60.60, 56.838], [60.61, 56.838], [60.61, 56.839], [60.60, 56.839], [60.60, 56.838]]],
|
||||
# Крупный контур (площадь ~0.00007*0.007 — намеренно шире по обеим осям).
|
||||
[[[60.62, 56.840], [60.70, 56.840], [60.70, 56.850], [60.62, 56.850], [60.62, 56.840]]],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_largest_polygon_passthrough_for_polygon() -> None:
|
||||
"""Обычный Polygon → возвращается без изменений (FIX 1: только MultiPolygon трогаем)."""
|
||||
assert mod._largest_polygon_geojson(_GEOJSON) is _GEOJSON
|
||||
|
||||
|
||||
def test_largest_polygon_extracts_biggest_contour() -> None:
|
||||
"""MultiPolygon → GeoJSON Polygon КРУПНЕЙШЕГО контура (FIX 1, эпик #2259)."""
|
||||
out = mod._largest_polygon_geojson(_MULTIPOLYGON)
|
||||
assert out["type"] == "Polygon"
|
||||
# Крупнейший контур — второй (60.62..60.70 × 56.840..56.850): его первая вершина x≈60.62.
|
||||
xs = [pt[0] for pt in out["coordinates"][0]]
|
||||
assert min(xs) >= 60.62
|
||||
|
||||
|
||||
def test_largest_polygon_unwraps_feature() -> None:
|
||||
"""Feature-обёртка над MultiPolygon → крупнейший Polygon (FIX 1)."""
|
||||
feature = {"type": "Feature", "geometry": _MULTIPOLYGON, "properties": {}}
|
||||
out = mod._largest_polygon_geojson(feature)
|
||||
assert out["type"] == "Polygon"
|
||||
|
||||
|
||||
def test_largest_polygon_bad_geometry_returns_input() -> None:
|
||||
"""Битая MultiPolygon-геометрия → best-effort вернуть вход как есть (FIX 1)."""
|
||||
broken = {"type": "MultiPolygon", "coordinates": "не координаты"}
|
||||
assert mod._largest_polygon_geojson(broken) is broken
|
||||
|
||||
|
||||
def test_concept_multipolygon_does_not_crash(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Многоконтурный участок (MultiPolygon) НЕ роняет генерацию концепции (FIX 1).
|
||||
|
||||
Корень прод-бага: concept-стек (_parse_polygon / parse_parcel) принимал только
|
||||
Polygon и падал `ParcelGeometryError: expected Polygon, got MultiPolygon`. После
|
||||
FIX 1 geom нормализуется к крупнейшему контуру ДО передачи в стек — generate
|
||||
получает Polygon-payload и отрабатывает.
|
||||
"""
|
||||
analyze = {"geom_geojson": _MULTIPOLYGON, "egrn": {}}
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake_generate(payload: Any, **kwargs: Any) -> list[Any]:
|
||||
captured["geom_type"] = payload.parcel_geojson.get("type")
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("app.services.generative.geometry.generate", _fake_generate)
|
||||
monkeypatch.setattr(
|
||||
"app.api.v1.concepts._parcel_centroid_wkt", lambda payload: "POINT(60.66 56.845)"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.api.v1.concepts._lookup_market_price", lambda db, wkt: (None, "class_norm")
|
||||
)
|
||||
|
||||
result = mod._generate_concept_result(MagicMock(), analyze)
|
||||
# generate вызвана с уже-нормализованным Polygon (не MultiPolygon) → не упала.
|
||||
assert captured["geom_type"] == "Polygon"
|
||||
assert result == {"variants": []}
|
||||
|
||||
|
||||
def test_cad_safe_whitelist() -> None:
|
||||
"""_cad_safe: только `[0-9:]`, `:`→`_`; path-traversal/спецсимволы вырезаны (п.4)."""
|
||||
assert mod._cad_safe("66:41:0204016:10") == "66_41_0204016_10"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import pytest
|
|||
|
||||
from app.services.exporters.report_pdf import (
|
||||
_ADVISORY_MARKER,
|
||||
_TITLE_CONFIDENCE,
|
||||
_TITLE_FUTURE_MARKET,
|
||||
_TITLE_MARKET_NOW,
|
||||
_TITLE_PRODUCT_TZ,
|
||||
|
|
@ -141,7 +142,8 @@ _skip_if_no_weasyprint = pytest.mark.skipif(
|
|||
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
|
||||
)
|
||||
|
||||
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
|
||||
# Семь ожидаемых заголовков блоков (по одному на содержательную секцию §13, включая
|
||||
# §13.7 «Уверенность» — parity fix #2445 C2, зеркало _EXPECTED_TITLES в test_report_docx/md).
|
||||
_EXPECTED_TITLES: tuple[str, ...] = (
|
||||
_TITLE_SUMMARY,
|
||||
_TITLE_MARKET_NOW,
|
||||
|
|
@ -149,6 +151,7 @@ _EXPECTED_TITLES: tuple[str, ...] = (
|
|||
_TITLE_PRODUCT_TZ,
|
||||
_TITLE_SCENARIOS,
|
||||
_TITLE_SCORING,
|
||||
_TITLE_CONFIDENCE,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -357,12 +360,101 @@ class TestContractKeysRendered:
|
|||
assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
|
||||
|
||||
def test_confidence_factors_rendered(self) -> None:
|
||||
# confidence.factors должны попасть в HTML (ключ→значение).
|
||||
# confidence.factors должны попасть в HTML (ключ→значение) через §13.7-секцию.
|
||||
html_str = _build_html(_normalize(_full_report()))
|
||||
assert "Факторы уверенности" in html_str
|
||||
assert "data_coverage" in html_str
|
||||
|
||||
|
||||
# ── Parity fix #2445 C2: §13.7 «Уверенность» — дедикейтед секция (было только в docx/md) ─
|
||||
# report_docx.py / report_md.py уже несли полную секцию с 4-колоночной таблицей
|
||||
# (Фактор/Значение/Уровень/Комментарий) и defensive dict-vs-flat разбором факторов;
|
||||
# PDF был единственным экспортёром без неё (только тонкий 2-строчный уровень/rationale
|
||||
# в «Сводке» + плоский dict-дамп факторов). Эти тесты ловят регресс парности.
|
||||
|
||||
|
||||
class TestConfidenceSectionParity:
|
||||
def test_confidence_section_title_present(self) -> None:
|
||||
# Секция §13.7 присутствует как отдельный заголовок (не только внутри «Сводки»).
|
||||
html_str = _build_html(_normalize(_full_report()))
|
||||
assert _TITLE_CONFIDENCE in html_str
|
||||
assert 'id="confidence"' in html_str
|
||||
|
||||
def test_confidence_section_has_four_column_table(self) -> None:
|
||||
# 4-колоночная таблица факторов — зеркало report_docx/report_md._build_confidence.
|
||||
html_str = _build_html(_normalize(_full_report()))
|
||||
assert "Фактор" in html_str
|
||||
assert "Значение" in html_str
|
||||
assert "Уровень" in html_str
|
||||
assert "Комментарий" in html_str
|
||||
|
||||
def test_confidence_level_and_rationale_rendered(self) -> None:
|
||||
report = SiteFinderReport(
|
||||
confidence=ReportConfidence(
|
||||
level="medium",
|
||||
rationale="Источники advisory-capped; данные средней плотности.",
|
||||
factors={"data_coverage": 0.6},
|
||||
),
|
||||
)
|
||||
html_str = _build_html(_normalize(report))
|
||||
assert "средняя" in html_str # _level_ru("medium")
|
||||
assert "Источники advisory-capped" in html_str
|
||||
|
||||
def test_dict_shaped_factor_renders_without_crashing(self) -> None:
|
||||
# Ровно баг, от которого защищает docx isinstance(payload, dict)-ветка: значение
|
||||
# фактора — САМО dict {value, level, note} (реальная форма §15/#990, см. frontend
|
||||
# mock parcel-forecast.json), а не плоский скаляр. Плоский _dict_kv_table в старой
|
||||
# версии PDF-«Сводки» такое не ожидал — падал/рендерил мусор. Новая §13.7-секция
|
||||
# (_build_confidence) разбирает dict-payload как в report_docx/report_md.
|
||||
report = SiteFinderReport(
|
||||
confidence=ReportConfidence(
|
||||
level="low",
|
||||
rationale="Low потому что мало истории.",
|
||||
factors={
|
||||
"history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
|
||||
"analog_count": {"value": 32, "level": "high", "note": "32 ЖК — достаточно"},
|
||||
"advisory_capped": False, # плоский (не-dict) фактор — тоже должен уцелеть
|
||||
},
|
||||
),
|
||||
)
|
||||
# Не должно бросить исключение (регресс-guard); плюс проверяем реальный рендер.
|
||||
html_str = _build_html(_normalize(report))
|
||||
assert "history_months" in html_str
|
||||
assert "6 мес — мало" in html_str
|
||||
assert "analog_count" in html_str
|
||||
assert "advisory_capped" in html_str
|
||||
|
||||
@_skip_if_no_weasyprint
|
||||
def test_dict_shaped_factor_produces_valid_pdf(self) -> None:
|
||||
# То же самое, но конец-в-конец через WeasyPrint (не только HTML-строка).
|
||||
report = SiteFinderReport(
|
||||
confidence=ReportConfidence(
|
||||
level="low",
|
||||
rationale="Low потому что мало истории.",
|
||||
factors={
|
||||
"history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
|
||||
},
|
||||
),
|
||||
)
|
||||
payload = export_report_pdf(report)
|
||||
assert payload.startswith(b"%PDF")
|
||||
assert len(payload) > 0
|
||||
|
||||
def test_summary_no_longer_duplicates_confidence_rationale(self) -> None:
|
||||
# Тонкий 2-строчный «уровень+rationale» блок убран из «Сводки» — теперь живёт
|
||||
# только в §13.7, чтобы не было двух версий одних и тех же данных в документе.
|
||||
# Rationale-текст должен встретиться РОВНО один раз (в секции confidence).
|
||||
report = SiteFinderReport(
|
||||
confidence=ReportConfidence(
|
||||
level="medium",
|
||||
rationale="Уникальная-строка-обоснования-для-проверки-дублей",
|
||||
factors={"data_coverage": 0.6},
|
||||
),
|
||||
)
|
||||
html_str = _build_html(_normalize(report))
|
||||
assert html_str.count("Уникальная-строка-обоснования-для-проверки-дублей") == 1
|
||||
|
||||
|
||||
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,18 +13,25 @@ psycopg v3 правило проверяется явно: bind-параметр
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import datetime as dt
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.forecasting.macro_series import (
|
||||
_CONFOUNDER_DATES,
|
||||
_MORTGAGE_FIELDS,
|
||||
_REGIME_EPS_PP,
|
||||
MonthlyMacro,
|
||||
_carry_forward,
|
||||
_month_grid,
|
||||
_query_inflation_monthly,
|
||||
_query_key_rate_monthly,
|
||||
_query_mortgage_monthly,
|
||||
_shift_months,
|
||||
classify_regime,
|
||||
get_monthly_macro,
|
||||
|
|
@ -476,3 +483,110 @@ class TestGetMonthlyMacroGraceful:
|
|||
out = get_monthly_macro(db, months_back=1)
|
||||
assert len(out) == 2
|
||||
assert all(m.mortgage_rate_weighted is None for m in out)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# SAVEPOINT (#2464 cluster A finding #2, `_query_mortgage_monthly`): сбой ОДНОГО
|
||||
# mortgage-поля НЕ должен «отравить» общую §22-сессию для остальных 4 полей / для
|
||||
# последующих §9.x-слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()`
|
||||
# сбойный `db.execute` (внутри `get_macro_series`) оставляет транзакцию Postgres
|
||||
# aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeMortgageResult:
|
||||
"""Мини-результат db.execute() — покрывает `.all()` (см. `get_macro_series`)."""
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
class _PoisonableSession:
|
||||
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
|
||||
|
||||
`execute()` бросает РОВНО один раз (на первое поле) и, как реальный Postgres,
|
||||
«отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже
|
||||
несвязанный) тоже бросает — `current transaction is aborted`. `begin_nested()`
|
||||
зеркалит `Session.begin_nested()`: при исключении откатывает ТОЛЬКО SAVEPOINT
|
||||
(сбрасывает poison-флаг) и пробрасывает исключение дальше — как настоящий
|
||||
`ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (откатил бы всю внешнюю
|
||||
транзакцию).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.poisoned = False
|
||||
self._raised = False
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, *args: Any, **kwargs: Any) -> _FakeMortgageResult:
|
||||
self.calls += 1
|
||||
if self.poisoned:
|
||||
raise RuntimeError(
|
||||
"current transaction is aborted, commands ignored until end of transaction block"
|
||||
)
|
||||
if not self._raised:
|
||||
self._raised = True
|
||||
self.poisoned = True
|
||||
raise RuntimeError("simulated mortgage field query failure")
|
||||
return _FakeMortgageResult()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def begin_nested(self) -> Iterator[None]:
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
|
||||
# сессия остаётся рабочей для следующего поля.
|
||||
self.poisoned = False
|
||||
raise
|
||||
|
||||
|
||||
class TestQueryMortgageMonthlySavepoint:
|
||||
"""`_query_mortgage_monthly` оборачивает КАЖДОЕ поле в `db.begin_nested()`."""
|
||||
|
||||
def test_first_field_failure_leaves_session_usable_for_later_fields(self) -> None:
|
||||
db = _PoisonableSession()
|
||||
out = _query_mortgage_monthly(db, since=dt.date(2024, 1, 1))
|
||||
# все 5 полей присутствуют в результате (контракт функции не изменён).
|
||||
assert set(out.keys()) == {field for _itype, field in _MORTGAGE_FIELDS}
|
||||
# сбойное (первое) поле → пустой fallback-словарь, НЕ crash.
|
||||
first_field = _MORTGAGE_FIELDS[0][1]
|
||||
assert out[first_field] == {}
|
||||
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
|
||||
# сбойного поля проходит без 'current transaction aborted' (не poisoned).
|
||||
db.execute("SELECT 1")
|
||||
|
||||
|
||||
class TestQueryKeyRateMonthlySavepoint:
|
||||
"""`_query_key_rate_monthly` оборачивает db.execute в `db.begin_nested()`.
|
||||
|
||||
Тот же poisoning-класс, что и mortgage-цикл: key_rate — ПЕРВЫЙ запрос
|
||||
get_monthly_macro; без SAVEPOINT его сбой отравил бы транзакцию для inflation +
|
||||
mortgage + всех последующих §9.x-слоёв на общей §22-сессии.
|
||||
"""
|
||||
|
||||
def test_db_failure_leaves_session_usable(self) -> None:
|
||||
db = _PoisonableSession()
|
||||
out = _query_key_rate_monthly(db, since=dt.date(2024, 1, 1))
|
||||
# сбой запроса → пустой ресэмпл (fallback, НЕ crash).
|
||||
assert out == {}
|
||||
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
|
||||
# сбоя проходит без 'current transaction aborted' (не poisoned).
|
||||
db.execute("SELECT 1")
|
||||
|
||||
|
||||
class TestQueryInflationMonthlySavepoint:
|
||||
"""`_query_inflation_monthly` оборачивает db.execute (через get_macro_series) в SAVEPOINT.
|
||||
|
||||
Тот же poisoning-класс: inflation — ВТОРОЙ запрос get_monthly_macro; без SAVEPOINT
|
||||
его сбой отравил бы транзакцию для mortgage-полей + последующих §9.x-слоёв.
|
||||
"""
|
||||
|
||||
def test_db_failure_leaves_session_usable(self) -> None:
|
||||
db = _PoisonableSession()
|
||||
out = _query_inflation_monthly(db, since=dt.date(2024, 1, 1))
|
||||
# сбой запроса → пустой ряд (fallback, НЕ crash).
|
||||
assert out == {}
|
||||
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
|
||||
# сбоя проходит без 'current transaction aborted' (не poisoned).
|
||||
db.execute("SELECT 1")
|
||||
|
|
|
|||
|
|
@ -371,3 +371,101 @@ class TestNormalizeDistrict:
|
|||
# list / int / прочие неподдерживаемые типы → None + warning.
|
||||
assert _normalize_district([1, 2, 3]) is None
|
||||
assert _normalize_district(42) is None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# SAVEPOINT (#2464 cluster A finding #1, orchestrator.py `_safe_call`): один сбойный
|
||||
# §9.x-слой НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ слоёв. Без
|
||||
# `db.begin_nested()` сбойный `db.execute` внутри слоя оставляет транзакцию Postgres
|
||||
# aborted («current transaction is aborted, commands ignored until end of
|
||||
# transaction block») — каждый ПОСЛЕДУЮЩИЙ слой на той же сессии тоже падал бы.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
"""Мини-результат db.execute() — покрывает `.mappings().first()`/`.all()`."""
|
||||
|
||||
def mappings(self) -> _FakeResult:
|
||||
return self
|
||||
|
||||
def first(self) -> None:
|
||||
return None
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
class _PoisonableSession:
|
||||
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
|
||||
|
||||
`execute()` бросает РОВНО один раз (на первый вызов) и, как реальный Postgres,
|
||||
«отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже
|
||||
совершенно несвязанный) тоже бросает — `current transaction is aborted`.
|
||||
`begin_nested()` зеркалит `Session.begin_nested()`: при исключении откатывает
|
||||
ТОЛЬКО SAVEPOINT (сбрасывает poison-флаг) и пробрасывает исключение дальше — как
|
||||
настоящий `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (который откатил бы
|
||||
всю внешнюю транзакцию — established anti-pattern, `backend.md`).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.poisoned = False
|
||||
self._raised = False
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, *args: Any, **kwargs: Any) -> _FakeResult:
|
||||
self.calls += 1
|
||||
if self.poisoned:
|
||||
raise RuntimeError(
|
||||
"current transaction is aborted, commands ignored until end of transaction block"
|
||||
)
|
||||
if not self._raised:
|
||||
self._raised = True
|
||||
self.poisoned = True
|
||||
raise RuntimeError("simulated §9.x layer query failure")
|
||||
return _FakeResult()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def begin_nested(self) -> Iterator[None]:
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
|
||||
# сессия остаётся рабочей для следующего слоя.
|
||||
self.poisoned = False
|
||||
raise
|
||||
|
||||
|
||||
class TestSafeCallSavepointDoesNotPoisonSession:
|
||||
"""`_safe_call` оборачивает вызов слоя в `db.begin_nested()` (SAVEPOINT)."""
|
||||
|
||||
def test_layer_db_failure_leaves_session_usable_for_later_layers(self) -> None:
|
||||
db = _PoisonableSession()
|
||||
mocks = _patched_layers()
|
||||
# market_metrics — единственный слой в этом тесте, реально трогающий `db`:
|
||||
# его мок сам вызывает db.execute (через _PoisonableSession бросает один раз).
|
||||
# Остальные слои остаются sentinel-моками из _patched_layers() (db не трогают).
|
||||
mocks["compute_market_metrics"] = MagicMock(
|
||||
side_effect=lambda *a, **kw: db.execute("SELECT 1")
|
||||
)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
|
||||
captured.update(kwargs)
|
||||
return SiteFinderReport()
|
||||
|
||||
with _patch_layers(mocks, _fake_assemble):
|
||||
report = build_site_finder_report(
|
||||
db,
|
||||
analyze=_analyze([{"obj_class": "Комфорт"}]),
|
||||
cad_num="66:41:0702048:27",
|
||||
district="Ленинский",
|
||||
horizons=[6, 12, 18],
|
||||
)
|
||||
assert isinstance(report, SiteFinderReport)
|
||||
# упавший слой → None в слоте (fallback), остальные слои — sentinel'ы, целы.
|
||||
assert captured["market_metrics"] is None
|
||||
assert captured["scenarios"] == ["SC"]
|
||||
assert captured["forecasts"] == ["F6", "F12"]
|
||||
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
|
||||
# сбойного слоя проходит без 'current transaction aborted' (не poisoned).
|
||||
db.execute("SELECT 1")
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from app.services.forecasting.product_scoring import (
|
|||
_count_positive_usp,
|
||||
_min_confidence,
|
||||
_overlay_confidence,
|
||||
_poi_weight_sum,
|
||||
_quarter_from_cad,
|
||||
_saturating,
|
||||
_score_commercial,
|
||||
|
|
@ -991,3 +992,98 @@ class TestMortgageSensitivityViaRegimeAdapter:
|
|||
reg_mock.assert_not_called()
|
||||
assert card.scores["mortgage_sensitivity"].value is None
|
||||
assert card.scores["mortgage_sensitivity"].confidence == "low"
|
||||
|
||||
|
||||
# ── #2464 cluster A: _poi_weight_sum SAVEPOINT-регрессия (session-poisoning) ────
|
||||
# ОБА db.execute-сайта внутри _poi_weight_sum раньше ловили сбой БЕЗ SAVEPOINT/rollback:
|
||||
# 1) центроид-lookup (db.execute(_PARCEL_CENTROID_SQL, ...));
|
||||
# 2) compute_poi_weighted_top7(...) — poi_score.py сам делает raw db.execute БЕЗ своего
|
||||
# try/except, поэтому сбой ПРОБРАСЫВАЕТСЯ наверх и глотается вторым except здесь.
|
||||
# На реальном Postgres любой из них отравляет §22-report session (compute_score_card делит
|
||||
# одну db-сессию с остальными §22-скорерами): следующий db.execute на ТОЙ ЖЕ сессии падал
|
||||
# "current transaction is aborted". Фикс: ОБА сайта обёрнуты в db.begin_nested() (swallow
|
||||
# site для poi — это второй except _poi_weight_sum, там и ставим savepoint).
|
||||
|
||||
|
||||
class TestPoiWeightSumSavepoint:
|
||||
def test_centroid_query_failure_returns_none(self) -> None:
|
||||
"""DB-сбой на центроид-lookup → None (fallback → infra_fit unavailable)."""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("connection refused")
|
||||
assert _poi_weight_sum(db, cad_num=_CAD) is None
|
||||
|
||||
def test_uses_begin_nested_savepoint(self) -> None:
|
||||
"""Фикс: центроид-запрос обёрнут в db.begin_nested(), не голый db.execute."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = None
|
||||
_poi_weight_sum(db, cad_num=_CAD)
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_session_usable_after_centroid_failure(self) -> None:
|
||||
"""execute сбоит на 1-м вызове (центроид), db остаётся usable для следующего
|
||||
(не связанного) вызова на этой же сессии — имитирует §22 shared report-session."""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
assert _poi_weight_sum(db, cad_num=_CAD) is None
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
||||
def test_poi_call_failure_returns_none(self) -> None:
|
||||
"""Второй сайт: центроид резолвится, но compute_poi_weighted_top7 бросает →
|
||||
None (документированный fallback, infra_fit unavailable)."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||
"lat": 56.8,
|
||||
"lon": 60.6,
|
||||
}
|
||||
with patch(_P_POI, side_effect=RuntimeError("poi_score raw db.execute failed")):
|
||||
assert _poi_weight_sum(db, cad_num=_CAD) is None
|
||||
|
||||
def test_uses_begin_nested_for_poi_call(self) -> None:
|
||||
"""Фикс второго сайта: вызов compute_poi_weighted_top7 обёрнут в db.begin_nested().
|
||||
При успешном центроиде оба сайта под savepoint → begin_nested вызван дважды."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||
"lat": 56.8,
|
||||
"lon": 60.6,
|
||||
}
|
||||
with patch(_P_POI, return_value=_poi_response_stub([0.01])):
|
||||
_poi_weight_sum(db, cad_num=_CAD)
|
||||
assert db.begin_nested.call_count == 2
|
||||
|
||||
def test_session_usable_after_poi_failure(self) -> None:
|
||||
"""poi_score (второй сайт) бросает → db остаётся usable для следующего
|
||||
(не связанного) вызова на этой же сессии — имитирует §22 shared report-session
|
||||
(без savepoint здесь была бы 'current transaction is aborted' на следующем скоре)."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||
"lat": 56.8,
|
||||
"lon": 60.6,
|
||||
}
|
||||
with patch(_P_POI, side_effect=RuntimeError("poi boom")):
|
||||
assert _poi_weight_sum(db, cad_num=_CAD) is None
|
||||
# begin_nested вызван для обоих сайтов (центроид успешен, poi сбоит внутри savepoint).
|
||||
assert db.begin_nested.call_count == 2
|
||||
|
||||
# Следующий (не связанный) вызов на той же сессии отрабатывает — не отравлена.
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
||||
def test_centroid_success_then_poi_computed(self) -> None:
|
||||
"""Happy path: центроид резолвится → compute_poi_weighted_top7 вызван, Σ weight отдан.
|
||||
Оба db-сайта под savepoint → begin_nested вызван дважды."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = {
|
||||
"lat": 56.8,
|
||||
"lon": 60.6,
|
||||
}
|
||||
with patch(_P_POI, return_value=_poi_response_stub([0.03, 0.02])):
|
||||
out = _poi_weight_sum(db, cad_num=_CAD)
|
||||
assert out == pytest.approx(0.05)
|
||||
assert db.begin_nested.call_count == 2
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ psycopg v3 правило проверяется явно: bind-параметр
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import datetime as dt
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -667,6 +669,85 @@ class TestBuildSalesSeriesGraceful:
|
|||
assert out.units == [0]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# SAVEPOINT (#2464 cluster A finding #4, `_query_source_a`/`_query_source_b`): сбой
|
||||
# db.execute НЕ должен «отравить» общую §22-сессию для ПОСЛЕДУЮЩИХ слоёв/запросов,
|
||||
# переиспользующих ту же `db`-Session. Без `db.begin_nested()` сбойный `db.execute`
|
||||
# оставляет транзакцию Postgres aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии
|
||||
# тоже падал бы.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeSalesResult:
|
||||
"""Мини-результат db.execute() — покрывает `.mappings().all()`."""
|
||||
|
||||
def mappings(self) -> _FakeSalesResult:
|
||||
return self
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
class _PoisonableSession:
|
||||
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
|
||||
|
||||
`execute()` бросает РОВНО один раз и, как реальный Postgres, «отравляет» сессию:
|
||||
БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже несвязанный) тоже
|
||||
бросает — `current transaction is aborted`. `begin_nested()` зеркалит
|
||||
`Session.begin_nested()`: при исключении откатывает ТОЛЬКО SAVEPOINT (сбрасывает
|
||||
poison-флаг) и пробрасывает исключение дальше — как настоящий `ROLLBACK TO
|
||||
SAVEPOINT`, НЕ общий `db.rollback()` (откатил бы всю внешнюю транзакцию).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.poisoned = False
|
||||
self._raised = False
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, *args: Any, **kwargs: Any) -> _FakeSalesResult:
|
||||
self.calls += 1
|
||||
if self.poisoned:
|
||||
raise RuntimeError(
|
||||
"current transaction is aborted, commands ignored until end of transaction block"
|
||||
)
|
||||
if not self._raised:
|
||||
self._raised = True
|
||||
self.poisoned = True
|
||||
raise RuntimeError("simulated sales query failure")
|
||||
return _FakeSalesResult()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def begin_nested(self) -> Iterator[None]:
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
|
||||
# сессия остаётся рабочей для последующих запросов.
|
||||
self.poisoned = False
|
||||
raise
|
||||
|
||||
|
||||
class TestSourceQuerySavepointDoesNotPoisonSession:
|
||||
"""`_query_source_a`/`_query_source_b` оборачивают db.execute в SAVEPOINT."""
|
||||
|
||||
def test_source_a_failure_leaves_session_usable(self) -> None:
|
||||
db = _PoisonableSession()
|
||||
out = build_sales_series(db, spec=SegmentSpec(), source="corpus_room_month", months_back=1)
|
||||
# сбой запроса → zero-filled сетка, low confidence (fallback, НЕ crash).
|
||||
assert out.confidence == "low"
|
||||
assert out.units == [0, 0]
|
||||
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
|
||||
# сбоя проходит без 'current transaction aborted' (не poisoned).
|
||||
db.execute("SELECT 1")
|
||||
|
||||
def test_source_b_failure_leaves_session_usable(self) -> None:
|
||||
db = _PoisonableSession()
|
||||
out = build_sales_series(db, spec=SegmentSpec(), source="objective_lots", months_back=1)
|
||||
assert out.confidence == "low"
|
||||
assert out.units == [0, 0]
|
||||
db.execute("SELECT 1")
|
||||
|
||||
|
||||
class TestDistrictResolution:
|
||||
"""Step 2: spec.district (админ-имя ЕКБ) резолвится в МИКРО-набор в SQL-фильтре.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ test_market_metrics.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
|
@ -65,6 +66,7 @@ from app.services.forecasting.special_indices import (
|
|||
_price_bucket_to_band,
|
||||
_price_overlap,
|
||||
_query_artificial_demand,
|
||||
_query_parcel_centroid,
|
||||
_timing_overlap,
|
||||
_unit_mix_similarity,
|
||||
_void_index,
|
||||
|
|
@ -1570,6 +1572,178 @@ class TestComputeSpecialIndicesGraceful:
|
|||
assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.25)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# SAVEPOINT (#2464 cluster A finding #3, `_run` внутри `compute_special_indices`):
|
||||
# сбой ОДНОГО §25-индекса НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ
|
||||
# индексов/слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()`
|
||||
# сбойный `db.execute` (внутри `_query_artificial_demand`) оставляет транзакцию
|
||||
# Postgres aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeIndicesResult:
|
||||
"""Мини-результат db.execute() — покрывает `.mappings().first()`."""
|
||||
|
||||
def mappings(self) -> _FakeIndicesResult:
|
||||
return self
|
||||
|
||||
def first(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _PoisonableSession:
|
||||
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
|
||||
|
||||
`execute()` бросает РОВНО один раз (Artificial Demand SQL) и, как реальный
|
||||
Postgres, «отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()`
|
||||
(даже несвязанный) тоже бросает — `current transaction is aborted`.
|
||||
`begin_nested()` зеркалит `Session.begin_nested()`: при исключении откатывает
|
||||
ТОЛЬКО SAVEPOINT (сбрасывает poison-флаг) и пробрасывает исключение дальше — как
|
||||
настоящий `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.poisoned = False
|
||||
self._raised = False
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, *args: Any, **kwargs: Any) -> _FakeIndicesResult:
|
||||
self.calls += 1
|
||||
if self.poisoned:
|
||||
raise RuntimeError(
|
||||
"current transaction is aborted, commands ignored until end of transaction block"
|
||||
)
|
||||
if not self._raised:
|
||||
self._raised = True
|
||||
self.poisoned = True
|
||||
raise RuntimeError("simulated artificial_demand query failure")
|
||||
return _FakeIndicesResult()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
|
||||
# сессия остаётся рабочей для следующего индекса.
|
||||
self.poisoned = False
|
||||
raise
|
||||
|
||||
|
||||
class TestRunSavepointDoesNotPoisonSession:
|
||||
"""`_run` (внутренний gate compute_special_indices) оборачивает builder в SAVEPOINT."""
|
||||
|
||||
def test_artificial_demand_db_failure_leaves_session_usable(self) -> None:
|
||||
db = _PoisonableSession()
|
||||
# district=None/cad_num=None → Cannibalization/Competitor Strength деградируют
|
||||
# БЕЗ обращения к db (см. test_no_cad_num_degrades_competitor_indices); own
|
||||
# portfolio патчим в [] (own_developer_ids-гейт не задействуем) — единственный
|
||||
# builder, реально трогающий db в этом тесте, это Artificial Demand.
|
||||
with _full_stack_patch(), patch(f"{_MOD}.get_own_portfolio", return_value=[]):
|
||||
card = compute_special_indices(db, spec=_SPEC, district=None, cad_num=None)
|
||||
# упавший индекс деградировал в fallback (НЕ крашнул карточку).
|
||||
assert card.indices[KEY_ARTIFICIAL_DEMAND].method == _METHOD_UNAVAILABLE
|
||||
assert card.indices[KEY_ARTIFICIAL_DEMAND].value is None
|
||||
assert len(card.indices) == 6
|
||||
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
|
||||
# сбойного индекса проходит без 'current transaction aborted' (не poisoned).
|
||||
db.execute("SELECT 1")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# RELEASE-SAVEPOINT trap (#2464 cluster A, second-order): `_query_parcel_centroid` —
|
||||
# ВНУТРЕННИЙ helper, зовётся из `_build_cannibalization` ПОД внешним SAVEPOINT
|
||||
# `_run`. Если он глотает db-сбой БЕЗ своего savepoint, tx остаётся aborted; внешний
|
||||
# `_run`-savepoint выходит «нормально» → RELEASE SAVEPOINT → RELEASE в aborted-tx САМ
|
||||
# падает (Postgres допускает лишь ROLLBACK / ROLLBACK TO SAVEPOINT) → внешний except
|
||||
# ловит RELEASE-ошибку, но tx не откачена → отравление каскадит в следующий индекс.
|
||||
# Тест воспроизводит ИМЕННО RELEASE-trap: `_ReleaseTrapSession` моделирует, что
|
||||
# RELEASE в aborted-tx падает (без внутреннего savepoint тест краснеет).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeReleaseResult:
|
||||
"""Мини-результат db.execute() — покрывает `.mappings().first()`."""
|
||||
|
||||
def mappings(self) -> _FakeReleaseResult:
|
||||
return self
|
||||
|
||||
def first(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _ReleaseTrapSession:
|
||||
"""Fake Session, моделирующая Postgres SAVEPOINT/RELEASE/ROLLBACK в aborted-tx.
|
||||
|
||||
`execute()` бросает РОВНО раз (помечает tx aborted). `begin_nested()`:
|
||||
• ВХОД в poisoned-tx → SAVEPOINT запрещён → бросает;
|
||||
• ВЫХОД с исключением → ROLLBACK TO SAVEPOINT (снимает poison), пробрасывает;
|
||||
• ВЫХОД нормально → RELEASE SAVEPOINT, и если poisoned — RELEASE ПАДАЕТ (trap).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.poisoned = False
|
||||
self._raised = False
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, *args: Any, **kwargs: Any) -> _FakeReleaseResult:
|
||||
self.calls += 1
|
||||
if self.poisoned:
|
||||
raise RuntimeError(
|
||||
"current transaction is aborted, commands ignored until end of transaction block"
|
||||
)
|
||||
if not self._raised:
|
||||
self._raised = True
|
||||
self.poisoned = True
|
||||
raise RuntimeError("simulated centroid query failure")
|
||||
return _FakeReleaseResult()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||||
if self.poisoned:
|
||||
raise RuntimeError(
|
||||
"current transaction is aborted, commands ignored until end of transaction block"
|
||||
)
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
self.poisoned = False # ROLLBACK TO SAVEPOINT
|
||||
raise
|
||||
else:
|
||||
if self.poisoned:
|
||||
# RELEASE SAVEPOINT в aborted-tx ПАДАЕТ — тот самый trap.
|
||||
raise RuntimeError(
|
||||
"current transaction is aborted, "
|
||||
"commands ignored until end of transaction block"
|
||||
)
|
||||
|
||||
|
||||
def _outer_savepoint_call(db: Any, fn: Any, *, fallback: Any) -> Any:
|
||||
"""Зеркало `_run`/`_safe_call`: fn() под ВНЕШНИМ savepoint (RELEASE на норм-выходе)."""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
return fn()
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
class TestReleaseSavepointTrapParcelCentroid:
|
||||
"""`_query_parcel_centroid` НЕ отравляет внешний SAVEPOINT (RELEASE-trap)."""
|
||||
|
||||
def test_centroid_failure_survives_outer_release(self) -> None:
|
||||
db = _ReleaseTrapSession() # первый execute (centroid) падает
|
||||
result = _outer_savepoint_call(
|
||||
db,
|
||||
lambda: _query_parcel_centroid(db, cad_num="66:41:0303161:123"),
|
||||
fallback="OUTER_FALLBACK",
|
||||
)
|
||||
# centroid проглочен → его fallback (None), НЕ внешний OUTER_FALLBACK: значит
|
||||
# внутренний savepoint откатил сбой и внешний RELEASE прошёл чисто.
|
||||
assert result is None
|
||||
# КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
|
||||
db.execute("SELECT 1")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# §25.3 Cannibalization dispatch — TRUE own-portfolio vs PROXY fallback
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -116,6 +116,33 @@ def test_concepts_degenerate_parcel_returns_422() -> None:
|
|||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_concepts_projected_coordinates_returns_422_not_500() -> None:
|
||||
# Регресс эпик #2445 B1: parcel_geojson в проекции (МСК-66/UTM-like метры) вместо
|
||||
# WGS84 lon/lat раньше падал неперехваченным pyproj.CRSError -> opaque 500. Должен
|
||||
# быть чистый 422 (ParcelGeometryError), как и для прочих невалидных геометрий.
|
||||
projected = {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[500_000.0, 6_200_000.0],
|
||||
[500_450.0, 6_200_000.0],
|
||||
[500_450.0, 6_200_310.0],
|
||||
[500_000.0, 6_200_310.0],
|
||||
[500_000.0, 6_200_000.0],
|
||||
]
|
||||
],
|
||||
}
|
||||
response = _post(
|
||||
{
|
||||
"parcel_geojson": projected,
|
||||
"housing_class": "comfort",
|
||||
"target_floors": 9,
|
||||
"development_type": "mid_rise",
|
||||
}
|
||||
)
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
def test_concepts_response_matches_contract_keys() -> None:
|
||||
response = _post(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -119,6 +119,25 @@ def test_pdf_html_build_graceful_on_empty() -> None:
|
|||
assert "нет вариантов" in html
|
||||
|
||||
|
||||
def test_pdf_caveat_does_not_contradict_office_revenue_row() -> None:
|
||||
"""Эпик #2445 C1: каскад включает нежилую выручку/НДС — caveat не должен это отрицать.
|
||||
|
||||
Payload — ``comfort`` (office_share=0.05 в teap.py), поэтому строка "Выручка —
|
||||
нежилое (1-й этаж)" в таблице ненулевая: caveat ниже НЕ может утверждать, что
|
||||
коммерция/офисы "не учитываются" или что НДС — "только машиноместа".
|
||||
"""
|
||||
variants = geometry.generate(_payload())
|
||||
html = pdf._build_html(variants)
|
||||
assert "Выручка — нежилое (1-й этаж), млн руб" in html
|
||||
assert "НДС (нежилое: паркинг + коммерция), млн руб" in html
|
||||
# Старые (стейл) формулировки, отрицающие модель нежилого, — не должны вернуться.
|
||||
assert "не учитываются" not in html
|
||||
assert "только на нежилую часть" not in html
|
||||
assert "только машиноместа" not in html
|
||||
# Caveat честно описывает нежилое как учтённое (не «нет в ТЭП»).
|
||||
assert "нет в ТЭП" not in html
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _weasyprint_available(),
|
||||
reason="WeasyPrint native libs unavailable on this host",
|
||||
|
|
|
|||
|
|
@ -100,13 +100,94 @@ def test_tiny_parcel_rejected_after_setback() -> None:
|
|||
|
||||
|
||||
def test_non_polygon_rejected() -> None:
|
||||
payload = _payload(parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]})
|
||||
with pytest.raises(ParcelGeometryError):
|
||||
geometry.parse_parcel(payload)
|
||||
|
||||
|
||||
def test_linestring_rejected() -> None:
|
||||
# LineString не имеет площади — прежняя ошибка сохраняется.
|
||||
payload = _payload(
|
||||
parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]}
|
||||
parcel_geojson={
|
||||
"type": "LineString",
|
||||
"coordinates": [[60.60, 56.830], [60.6045, 56.8328]],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ParcelGeometryError):
|
||||
geometry.parse_parcel(payload)
|
||||
|
||||
|
||||
def test_projected_coordinates_rejected_with_clear_message() -> None:
|
||||
# Регресс эпик #2445 B1: координаты в проекции (МСК-66/UTM-like метры, напр.
|
||||
# [500000, 6200000]) вместо WGS84 lon/lat — синтаксически валидный Polygon, но
|
||||
# centroid.y (~6_200_000) далеко за пределами ±90° -> раньше падало неперехваченным
|
||||
# pyproj.exceptions.CRSError внутри _metric_transformers (opaque 500). Теперь
|
||||
# ловится ДО построения проекции с понятным ParcelGeometryError (-> 422).
|
||||
projected = [
|
||||
[500_000.0, 6_200_000.0],
|
||||
[500_450.0, 6_200_000.0],
|
||||
[500_450.0, 6_200_310.0],
|
||||
[500_000.0, 6_200_310.0],
|
||||
[500_000.0, 6_200_000.0],
|
||||
]
|
||||
payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [projected]})
|
||||
with pytest.raises(ParcelGeometryError, match="WGS84"):
|
||||
geometry.parse_parcel(payload)
|
||||
|
||||
|
||||
# Второй, заведомо меньший контур (~90 m x 45 m) поодаль от основного участка.
|
||||
_SMALL_CONTOUR = [
|
||||
[60.610, 56.840],
|
||||
[60.6112, 56.840],
|
||||
[60.6112, 56.8404],
|
||||
[60.610, 56.8404],
|
||||
[60.610, 56.840],
|
||||
]
|
||||
|
||||
|
||||
def test_multipolygon_picks_largest_contour() -> None:
|
||||
# Многоконтурный участок: основной прямоугольник + мелкое вкрапление поодаль.
|
||||
# Берётся крупнейший контур → площадь == площади одиночного основного полигона.
|
||||
multi = _payload(
|
||||
parcel_geojson={
|
||||
"type": "MultiPolygon",
|
||||
"coordinates": [[_PARCEL_COORDS], [_SMALL_CONTOUR]],
|
||||
}
|
||||
)
|
||||
single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]})
|
||||
parcel_multi = geometry.parse_parcel(multi)
|
||||
parcel_single = geometry.parse_parcel(single)
|
||||
# Результат многоконтурного == основному контуру (крупнейший выбран).
|
||||
assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-6)
|
||||
# Крупнейший контур на масштабе квартала, мелкое вкрапление отброшено.
|
||||
assert parcel_multi.site_area_sqm > 50_000
|
||||
|
||||
|
||||
def test_multipolygon_single_contour_equals_polygon() -> None:
|
||||
# MultiPolygon с одним контуром эквивалентен голому Polygon.
|
||||
multi = _payload(
|
||||
parcel_geojson={
|
||||
"type": "MultiPolygon",
|
||||
"coordinates": [[_PARCEL_COORDS]],
|
||||
}
|
||||
)
|
||||
single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]})
|
||||
parcel_multi = geometry.parse_parcel(multi)
|
||||
parcel_single = geometry.parse_parcel(single)
|
||||
assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-9)
|
||||
assert math.isclose(
|
||||
parcel_multi.buildable_area_sqm,
|
||||
parcel_single.buildable_area_sqm,
|
||||
rel_tol=1e-9,
|
||||
)
|
||||
|
||||
|
||||
def test_empty_multipolygon_rejected() -> None:
|
||||
payload = _payload(parcel_geojson={"type": "MultiPolygon", "coordinates": []})
|
||||
with pytest.raises(ParcelGeometryError):
|
||||
geometry.parse_parcel(payload)
|
||||
|
||||
|
||||
def test_build_placement_grid_anchors_are_step_aligned() -> None:
|
||||
# Простой метрический квадрат 30x30 м, шаг 10 -> 3x3 = 9 ячеек.
|
||||
square = Polygon([(0, 0), (30, 0), (30, 30), (0, 30)])
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ def test_render_known_template() -> None:
|
|||
assert "ассистент" in out
|
||||
|
||||
|
||||
def test_chat_system_v2_has_unit_mix_formatting_rule() -> None:
|
||||
"""chat_system@v2 (#957 fix): чистый рендер квартирографии, без «N-тип» склейки."""
|
||||
def test_chat_system_v3_has_unit_mix_formatting_rule() -> None:
|
||||
"""chat_system@v3: чистый рендер квартирографии, без «N-тип» склейки (сохранено с v2)."""
|
||||
tpl = get_template("chat_system")
|
||||
assert tpl.version == 2
|
||||
assert tpl.key == "chat_system@v2"
|
||||
assert tpl.version == 3
|
||||
assert tpl.key == "chat_system@v3"
|
||||
out = render("chat_system")
|
||||
assert "квартирограф" in out.lower() # правило про перечисление сегментов
|
||||
assert "«2-1-к»" in out # явный анти-пример (склейка номера с типом)
|
||||
|
|
@ -42,6 +42,13 @@ def test_chat_system_v2_has_unit_mix_formatting_rule() -> None:
|
|||
assert "ВЕРБАТИМ" in out
|
||||
|
||||
|
||||
def test_chat_system_v3_mentions_parcel_info_tool() -> None:
|
||||
"""chat_system@v3: паспорт участка/ПЗЗ теперь в области ответа через get_parcel_info."""
|
||||
out = render("chat_system")
|
||||
assert "get_parcel_info" in out # tool паспорта участка/градрегламента
|
||||
assert "зон" in out.lower() # тер.зона ПЗЗ упомянута как отвечаемая
|
||||
|
||||
|
||||
def test_render_with_required_vars() -> None:
|
||||
"""render подставляет переменные через str.format и валидирует required_vars."""
|
||||
tpl = PromptTemplate(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
"""Тест для #2445 D2: scrape_catalog_batch() (per-flat catalog scraper, #2442)
|
||||
должен конструировать BrowserSession с throttled concurrency/jitter
|
||||
(settings.scrape_kn_*), а НЕ с модульным дефолтом stealth.py (concurrency=8,
|
||||
jitter 600-1500ms).
|
||||
|
||||
Этот scraper ходит по тому же /сервисы/* path family, что вызвал DOM.РФ WAF
|
||||
hard-ban 2026-05-24 (#2443) — без явного override BrowserSession тихо унаследовал
|
||||
бы дефолт, углубляя риск повторного бана.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class _FakeBrowserSession:
|
||||
"""Async context manager stub — избегаем реального Playwright/network."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.init_kwargs = kwargs
|
||||
self.warm_up = AsyncMock()
|
||||
|
||||
async def __aenter__(self) -> _FakeBrowserSession:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scrape_catalog_batch_passes_throttled_browsersession_kwargs() -> None:
|
||||
from app.services.scrapers import domrf_catalog as mod
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _factory(*args: object, **kwargs: object) -> _FakeBrowserSession:
|
||||
captured.update(kwargs)
|
||||
return _FakeBrowserSession(*args, **kwargs)
|
||||
|
||||
db = MagicMock()
|
||||
flats = [{"ods_id": "abc123", "catalog_url_hash": "hash1", "obj_id": 1}]
|
||||
fake_outcome = {
|
||||
"success": True,
|
||||
"fields_extracted": 3,
|
||||
"updated": True,
|
||||
"plan_found": False,
|
||||
"plan_downloaded": False,
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(mod, "BrowserSession", side_effect=_factory) as mock_cls,
|
||||
patch.object(mod, "scrape_one_flat", new=AsyncMock(return_value=fake_outcome)),
|
||||
):
|
||||
stats = await mod.scrape_catalog_batch(
|
||||
db=db,
|
||||
flats=flats,
|
||||
region_code=66,
|
||||
snapshot_date=date(2026, 5, 17),
|
||||
)
|
||||
|
||||
mock_cls.assert_called_once()
|
||||
assert captured["concurrency"] == settings.scrape_kn_browser_concurrency
|
||||
assert captured["jitter_min_ms"] == settings.scrape_kn_request_jitter_min_ms
|
||||
assert captured["jitter_max_ms"] == settings.scrape_kn_request_jitter_max_ms
|
||||
# Sanity: эти throttled значения — НЕ модульный дефолт stealth.py.
|
||||
assert captured["concurrency"] != 8
|
||||
assert stats["success"] == 1
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"""Тест для #2445 D2: scrape_catalog_objects() должен конструировать BrowserSession
|
||||
с throttled concurrency/jitter (settings.scrape_kn_*), а НЕ с модульным дефолтом
|
||||
stealth.py (concurrency=8, jitter 600-1500ms).
|
||||
|
||||
Этот scraper ходит по тому же /сервисы/* path family, что вызвал DOM.РФ WAF
|
||||
hard-ban 2026-05-24 (#2443) — без явного override BrowserSession тихо унаследовал
|
||||
бы дефолт, углубляя риск повторного бана.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class _FakeBrowserSession:
|
||||
"""Async context manager stub — избегаем реального Playwright/network."""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.init_kwargs = kwargs
|
||||
self.warm_up = AsyncMock()
|
||||
|
||||
async def __aenter__(self) -> _FakeBrowserSession:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scrape_catalog_objects_passes_throttled_browsersession_kwargs() -> None:
|
||||
from app.services.scrapers import domrf_catalog_object as mod
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _factory(*args: object, **kwargs: object) -> _FakeBrowserSession:
|
||||
captured.update(kwargs)
|
||||
return _FakeBrowserSession(*args, **kwargs)
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(mod, "BrowserSession", side_effect=_factory) as mock_cls,
|
||||
patch.object(mod, "scrape_catalog_object", new=AsyncMock(return_value=True)),
|
||||
):
|
||||
stats = await mod.scrape_catalog_objects(
|
||||
db=db,
|
||||
obj_ids=[65136],
|
||||
snapshot_date=date(2026, 5, 17),
|
||||
region_code=66,
|
||||
)
|
||||
|
||||
mock_cls.assert_called_once()
|
||||
assert captured["concurrency"] == settings.scrape_kn_browser_concurrency
|
||||
assert captured["jitter_min_ms"] == settings.scrape_kn_request_jitter_min_ms
|
||||
assert captured["jitter_max_ms"] == settings.scrape_kn_request_jitter_max_ms
|
||||
# Sanity: эти throttled значения — НЕ модульный дефолт stealth.py.
|
||||
assert captured["concurrency"] != 8
|
||||
assert stats["succeeded"] == 1
|
||||
211
backend/tests/services/scrapers/test_domrf_flat_plan_url.py
Normal file
211
backend/tests/services/scrapers/test_domrf_flat_plan_url.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""Тесты для extraction plan_image_url из страницы квартиры DOM.РФ (issue #2440).
|
||||
|
||||
Покрывает:
|
||||
- __NEXT_DATA__ pageProps (primary): URL плана под plan-hint ключом.
|
||||
- bare <img> fallback: plan-hint в атрибутах / proximity к «Планировка».
|
||||
- нормализация URL (относительный → absolute; data:-inline / шум отбрасываются).
|
||||
- интеграция с parse_catalog_flat: ключ plan_image_url в результате.
|
||||
- _TextCollector НЕ ломает text-extraction при наличии <img> (регрессия #1608).
|
||||
|
||||
Фикстуры инлайновые (минимальный реалистичный HTML), без live-fetch — DOM.РФ
|
||||
каталог-квартир это Next.js SSR (тот же стек что страница объекта в
|
||||
domrf_catalog_object.py, где план живёт в __NEXT_DATA__, а не в <img src>).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.scrapers.domrf_catalog import (
|
||||
_normalize_plan_url,
|
||||
_TextCollector,
|
||||
extract_plan_image_url,
|
||||
parse_catalog_flat,
|
||||
)
|
||||
from app.services.scrapers.stealth import BASE_URL
|
||||
|
||||
# Абсолютный file-API URL DOM.РФ (реальный паттерн, см. documents.py:17).
|
||||
PLAN_ABS_URL = f"{BASE_URL}/api/ext/file/plan-12345.png"
|
||||
# Относительный next/image URL (Next.js image optimizer).
|
||||
PLAN_REL_NEXT = "/_next/image?url=%2Fapi%2Fext%2Ffile%2Fplan-777.png&w=1200&q=75"
|
||||
|
||||
|
||||
def _html_with_next_data(page_props: dict) -> str:
|
||||
"""SSR HTML с __NEXT_DATA__ блоком, содержащим pageProps."""
|
||||
blob = json.dumps({"props": {"pageProps": page_props}})
|
||||
return (
|
||||
"<!doctype html><html><head>"
|
||||
f'<script id="__NEXT_DATA__" type="application/json">{blob}</script>'
|
||||
"</head><body>"
|
||||
"<h1>Квартира 2-комнатная</h1>"
|
||||
"<div>4 500 000 ₽</div>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _collector_for(html: str) -> _TextCollector:
|
||||
c = _TextCollector()
|
||||
c.feed(html)
|
||||
return c
|
||||
|
||||
|
||||
# ── __NEXT_DATA__ primary path ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_plan_from_next_data_plan_image_key() -> None:
|
||||
"""URL под ключом planImage в pageProps → извлекается (absolute)."""
|
||||
html = _html_with_next_data({"planImage": PLAN_ABS_URL, "buildingClass": "Комфорт"})
|
||||
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||
|
||||
|
||||
def test_plan_from_next_data_layout_key_nested() -> None:
|
||||
"""URL под вложенным ключом layoutImageUrl → извлекается (BFS обход)."""
|
||||
html = _html_with_next_data(
|
||||
{"flat": {"info": {"layoutImageUrl": PLAN_ABS_URL}}, "price": 4_500_000}
|
||||
)
|
||||
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||
|
||||
|
||||
def test_plan_from_next_data_relative_url_normalized() -> None:
|
||||
"""Относительный next/image URL → абсолютный через BASE_URL."""
|
||||
html = _html_with_next_data({"floorPlanUrl": PLAN_REL_NEXT})
|
||||
got = extract_plan_image_url(html, _collector_for(html))
|
||||
assert got is not None
|
||||
assert got.startswith(BASE_URL)
|
||||
assert "plan-777" in got
|
||||
|
||||
|
||||
def test_plan_from_next_data_ignores_non_plan_keys() -> None:
|
||||
"""Картинка под НЕ-plan ключом (developerLogo) не должна ловиться."""
|
||||
html = _html_with_next_data(
|
||||
{"developerLogo": f"{BASE_URL}/api/ext/file/logo.png", "buildingClass": "Бизнес"}
|
||||
)
|
||||
assert extract_plan_image_url(html, _collector_for(html)) is None
|
||||
|
||||
|
||||
def test_plan_from_next_data_ignores_noise_url() -> None:
|
||||
"""plan-ключ, но URL содержит logo/thumb (шум) → отбрасывается."""
|
||||
html = _html_with_next_data({"planThumb": f"{BASE_URL}/api/ext/file/plan-thumb.png"})
|
||||
# 'thumb' в URL → _IMAGE_NOISE_RE → None
|
||||
assert extract_plan_image_url(html, _collector_for(html)) is None
|
||||
|
||||
|
||||
# ── bare <img> fallback ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_plan_from_img_class_hint() -> None:
|
||||
"""Нет __NEXT_DATA__: <img class='flat-plan__image'> → извлекается по class."""
|
||||
html = (
|
||||
"<!doctype html><html><body>"
|
||||
f'<img class="flat-plan__image" alt="" src="{PLAN_ABS_URL}">'
|
||||
"</body></html>"
|
||||
)
|
||||
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||
|
||||
|
||||
def test_plan_from_img_src_hint() -> None:
|
||||
"""<img> без plan-класса, но src содержит 'plan' → извлекается."""
|
||||
html = f'<!doctype html><html><body><img src="{PLAN_ABS_URL}"></body></html>'
|
||||
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||
|
||||
|
||||
def test_plan_from_img_proximity_to_label() -> None:
|
||||
"""<img> без plan-hint в атрибутах, но рядом с блоком «Планировка»."""
|
||||
plain = f"{BASE_URL}/api/ext/file/imgabc.png"
|
||||
html = (
|
||||
"<!doctype html><html><body>"
|
||||
"<div>Планировка</div>"
|
||||
f'<img alt="" src="{plain}">'
|
||||
"</body></html>"
|
||||
)
|
||||
got = extract_plan_image_url(html, _collector_for(html))
|
||||
assert got == plain
|
||||
|
||||
|
||||
def test_plan_from_img_skips_logo_and_captcha() -> None:
|
||||
"""Лого/captcha <img> игнорируются, даже если стоят первыми."""
|
||||
html = (
|
||||
"<!doctype html><html><body>"
|
||||
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
|
||||
'<img class="robot" src="data:image/jpeg;base64,AAAA">'
|
||||
"<div>Планировка квартиры</div>"
|
||||
f'<img class="plan-view" src="{PLAN_ABS_URL}">'
|
||||
"</body></html>"
|
||||
)
|
||||
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||||
|
||||
|
||||
def test_no_plan_returns_none() -> None:
|
||||
"""Нет ни __NEXT_DATA__ плана, ни plan-img → None."""
|
||||
html = (
|
||||
"<!doctype html><html><body>"
|
||||
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
|
||||
"<p>Площадь 45 м²</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
assert extract_plan_image_url(html, _collector_for(html)) is None
|
||||
|
||||
|
||||
# ── URL normalization ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected_prefix",
|
||||
[
|
||||
(PLAN_ABS_URL, PLAN_ABS_URL),
|
||||
("/api/ext/file/plan.png", f"{BASE_URL}/api/ext/file/plan.png"),
|
||||
("//cdn.example/plan.png", "https://cdn.example/plan.png"),
|
||||
],
|
||||
)
|
||||
def test_normalize_plan_url_variants(raw: str, expected_prefix: str) -> None:
|
||||
assert _normalize_plan_url(raw) == expected_prefix
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", None, "data:image/png;base64,AAAA", " "])
|
||||
def test_normalize_plan_url_rejects_junk(raw: str | None) -> None:
|
||||
assert _normalize_plan_url(raw) is None
|
||||
|
||||
|
||||
# ── integration: parse_catalog_flat ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_catalog_flat_includes_plan_image_url() -> None:
|
||||
"""parse_catalog_flat кладёт plan_image_url в результат при наличии плана."""
|
||||
html = _html_with_next_data({"planImage": PLAN_ABS_URL, "price": 4_500_000})
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("plan_image_url") == PLAN_ABS_URL
|
||||
|
||||
|
||||
def test_parse_catalog_flat_omits_plan_when_absent() -> None:
|
||||
"""Нет плана → ключ plan_image_url отсутствует (а не None)."""
|
||||
html = "<!doctype html><html><body><p>Площадь 45 м²</p></body></html>"
|
||||
result = parse_catalog_flat(html)
|
||||
assert "plan_image_url" not in result
|
||||
|
||||
|
||||
# ── regression: <img> capture must not break text-extraction (#1608) ──────────
|
||||
|
||||
|
||||
def test_img_capture_preserves_text_blocks() -> None:
|
||||
"""Наличие <img> (void-тег) не рассинхронизирует стек — текст блоков цел."""
|
||||
html = (
|
||||
"<!doctype html><html><body>"
|
||||
'<div class="price-block">4 500 000 ₽</div>'
|
||||
f'<img src="{PLAN_ABS_URL}">'
|
||||
'<div class="status-badge">Продана</div>'
|
||||
"</body></html>"
|
||||
)
|
||||
collector = _collector_for(html)
|
||||
texts = [t for _cls, t in collector.blocks]
|
||||
assert any("4 500 000" in t for t in texts)
|
||||
assert any("Продана" in t for t in texts)
|
||||
# и картинка захвачена в отдельный список
|
||||
assert len(collector.images) == 1
|
||||
assert collector.images[0][0].get("src") == PLAN_ABS_URL
|
||||
|
||||
# parse_catalog_flat всё ещё корректно извлекает status/price при наличии img
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == "sold"
|
||||
assert result.get("price_rub") == 4_500_000
|
||||
|
|
@ -55,3 +55,34 @@ class TestFlatIdFallbackDeterministic:
|
|||
out = _norm_flat({"elemId": "x" * 1000}, region_cd=66)
|
||||
assert out["id"] is not None
|
||||
assert 0 <= out["id"] < 2**63
|
||||
|
||||
|
||||
class TestCatalogUrlHash:
|
||||
"""catalog_url_hash ОБЯЗАН пробрасываться из elemId (#2442 Task 1).
|
||||
|
||||
Ранее хардкодился в None → flat-catalog scraper выбирал 0 строк
|
||||
(WHERE catalog_url_hash IS NOT NULL). Теперь берётся из того же elemId,
|
||||
что и fallback flat_id, и живёт независимо от того, пришёл ли flatId.
|
||||
"""
|
||||
|
||||
def test_hash_populated_from_elem_id(self) -> None:
|
||||
row = {"elemId": "abc-hash-123"}
|
||||
out = _norm_flat(row, region_cd=66)
|
||||
assert out["catalog_url_hash"] == "abc-hash-123"
|
||||
|
||||
def test_hash_populated_even_when_flat_id_present(self) -> None:
|
||||
# flatId присутствует → fallback-ветка flat_id НЕ выполняется, но hash
|
||||
# всё равно должен прочитаться (регрессия, которую чинит Task 1).
|
||||
row = {"flatId": 999, "elemId": "hash-with-flatid"}
|
||||
out = _norm_flat(row, region_cd=66)
|
||||
assert out["id"] == 999
|
||||
assert out["catalog_url_hash"] == "hash-with-flatid"
|
||||
|
||||
def test_hash_none_when_elem_id_absent(self) -> None:
|
||||
out = _norm_flat({"flatId": 1}, region_cd=66)
|
||||
assert out["catalog_url_hash"] is None
|
||||
|
||||
def test_hash_empty_string_normalized_to_none(self) -> None:
|
||||
# Пустую строку трактуем как отсутствие (не пишем "" в БД).
|
||||
out = _norm_flat({"flatId": 1, "elemId": ""}, region_cd=66)
|
||||
assert out["catalog_url_hash"] is None
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue