Compare commits

..

No commits in common. "main" and "chore/design-sync-tradein" have entirely different histories.

522 changed files with 50106 additions and 42097 deletions

View file

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

View file

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

View file

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

View file

@ -451,103 +451,17 @@ jobs:
fi
# (4) Теперь — новый app-код: схема уже актуальна.
#
# #1951: раньше scraper пересоздавался ВТОРОЙ отдельной командой `up -d`,
# уже ПОСЛЕ browser/backend/frontend. Если это происходило посреди
# in-flight sweep'а (avito/cian/rosreestr full-load), процесс убивался на
# лету, а осиротевшая scrape_runs-строка сидела status='running' с
# замёрзшим heartbeat до периодического 6h zombie-reaper'а — false "hang"
# investigation вместо честного deploy-артефакта (см. cian_full_load #404).
# Три меры ниже — все devops-only (shell в deploy-скрипте), БЕЗ правок
# Python в scraper-стартап-пути (scheduler_main.py / scraper-kit /
# app/services/scrapers — намеренно не тронуты, см. PR-описание):
#
# 1) Атомарный recreate — browser/backend/frontend[+scraper] поднимаются
# ОДНИМ `docker compose up -d` инвокейшном (список сервисов собирается
# заранее в $SERVICES), а не двумя последовательными командами.
# 2) Graceful drain — если scraper будет пересоздан, ждём (до 5 мин, poll
# каждые 10s) пока scrape_runs.status='running' не станет 0, ПРЕЖДЕ чем
# инициировать recreate. Таймаут не блокирует деплой навсегда —
# SIGTERM-drain (#1182 Phase 2/3a) + stop_grace_period 120s остаются
# финальной страховкой для того, что не успело дойти до checkpoint'а.
# 3) Startup-reap — сразу после recreate помечаем 'cancelled' любые
# 'running'-строки, чей heartbeat не обновлялся с МОМЕНТА (по часам
# самой БД — SELECT NOW(), без risk clock-skew раннера), взятого
# непосредственно перед stop. Такие строки заведомо осиротели ЭТИМ
# recreate'ом (старый контейнер физически не может писать heartbeat
# после своей остановки) — не ждём 6h периодического reap_zombies().
# 'cancelled' (не 'zombie') — честно отличает «убит деплоем» от
# «непонятно завис» (последнее по-прежнему ловит только 6h-reaper).
# Порог — по метке времени конкретного recreate, а не по фиксированному
# интервалу: не зависит от heartbeat-каденса разных источников и не
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
# admin-triggered scrape внутри backend, если backend в этом деплое
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
SERVICES="browser backend frontend"
SCRAPER_STOP_TS=""
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps browser backend frontend
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — 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"
echo "→ scraper paths changed — recreating tradein-scraper"
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
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) падал с

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -92,7 +92,6 @@ 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,
@ -857,20 +856,6 @@ _NEIGHBORS_SUMMARY_SQL = text("""
ORDER BY distance_m ASC
LIMIT 30
),
neighbors_total AS (
-- #2464 cluster B: честный COUNT(*) по ВСЕЙ 100м-выборке — БЕЗ LIMIT 30
-- (тот же WHERE, что у `neighbors` выше). count_buildings_100m раньше
-- считался как len(neighbors), тихо капаясь на 30 даже когда соседей
-- в радиусе больше.
SELECT COUNT(*) AS n
FROM cad_buildings b
WHERE ST_DWithin(
b.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
100
)
AND b.cad_num != CAST(:our_cad AS text)
),
overlap_rows AS (
SELECT cad_num,
building_name,
@ -898,8 +883,7 @@ _NEIGHBORS_SUMMARY_SQL = text("""
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
FROM overlap_rows o),
'[]'::json
) AS overlap_rows,
(SELECT n FROM neighbors_total) AS neighbors_total_count
) AS overlap_rows
""")
@ -916,11 +900,6 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
один сетевой round-trip (~47ms) на каждый analyze сами вычисления не
меняются. Формат возвращаемого dict идентичен прежнему.
#2464 cluster B: `count_buildings_100m` — честный COUNT(*) по всей 100м-выборке
(CTE `neighbors_total`, БЕЗ LIMIT), а не len(neighbors) (капалось на LIMIT 30).
`neighbors_truncated` True если в радиусе больше соседей, чем показано в
списке `neighbors`.
SQL module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
"""
@ -935,9 +914,6 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
)
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
# #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) —
# НЕ len(neighbor_rows), которое капалось на 30 даже когда соседей больше.
neighbors_total_count = int(row["neighbors_total_count"]) if row else 0
except Exception as e:
logger.warning("neighbors query failed: %s", e)
return {"data_available": False, "note": f"neighbors query failed: {e}"}
@ -982,46 +958,36 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
]
has_existing = len(overlap_buildings) > 0
# neighbor_rows — до 30 (SQL CTE `neighbors` LIMIT), список ниже дополнительно
# режется до 20 для payload. `neighbors_truncated` сравнивает честный total с
# тем, что РЕАЛЬНО показано (len(neighbors_list)) — а не с промежуточным
# neighbor_rows (до 30) — иначе флаг мог соврать False на 21..30 соседях,
# где список уже обрезан до 20, а total ещё не превысил len(neighbor_rows).
neighbors_list = [
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
]
return {
"data_available": True,
"radius_m": 100,
# #2464 cluster B: честный total (neighbors_total CTE, без LIMIT) — НЕ
# len(neighbor_rows) (капалось на LIMIT 30 у CTE `neighbors`).
"count_buildings_100m": neighbors_total_count,
"neighbors_truncated": neighbors_total_count > len(neighbors_list),
"count_buildings_100m": len(neighbor_rows),
"avg_floors_100m": avg_floors,
"max_floors_100m": max_floors,
"median_cost_per_m2_100m": median_cost,
"neighbors": neighbors_list,
"neighbors": [
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
],
"has_existing_buildings": has_existing,
"overlap_buildings": overlap_buildings,
"note": (
@ -1190,70 +1156,6 @@ def _compute_confidence(
}
def _build_market_pulse(
competitor_rows: list[dict[str, Any]],
competitors_total: int,
velocity_data: dict[str, Any] | None,
) -> dict[str, Any]:
"""OBJ-3 aggregate: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
в `competitor_rows` (детальный список для карты/карточек), но исключаются
из расчётов рыночных метрик (market_avg_price_per_m2, top_sellers).
#2464 cluster B: `competitors_total` — честный total (передаётся вызывающим
кодом из отдельного COUNT(*) БЕЗ LIMIT, см. `analyze_parcel` 5a), НЕ
len(competitor_rows) (которое капалось на LIMIT 20 запроса конкурентов, и
заодно раздувало `coverage_pct` competitors_priced/20 вместо
competitors_priced/true_total). PURE вызывающий код передаёт честный total
отдельно, эта функция его не пересчитывает.
"""
competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
competitors_priced = len(competitors_with_price)
if competitors_with_price:
prices = [float(c["avg_price_per_m2_rub"]) for c in competitors_with_price]
market_avg_price = round(sum(prices) / len(prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
with_sales = [
c
for c in competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
top_sellers = sorted(
with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in top_sellers
]
else:
market_avg_price = None
top_sellers_list = []
coverage_pct = (
round(competitors_priced * 100.0 / competitors_total, 1) if competitors_total > 0 else 0.0
)
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
return {
"avg_velocity_m2": avg_velocity,
"market_avg_price_per_m2": market_avg_price,
"competitors_total": competitors_total,
"competitors_with_price": competitors_priced,
"coverage_pct": coverage_pct,
"top_sellers": top_sellers_list,
}
# #93 — on-demand cadastre fetch tuning constants.
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
#
@ -1724,15 +1626,10 @@ 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'им повторно). Отдаём и дату генерации PDF из
# метадаты рана — чтобы fast-path кнопка показала дату без отдельного GET /status.
cached = _cached_report_result(db, cad_num)
if cached is not None:
# Готовый кэш → 200 ready (не enqueue'им повторно).
if _cached_report_result(db, cad_num) is not None:
return ReportBuildResponse(
status="ready",
analyze_run_at=analyze_at,
forecast_run_at=forecast_at,
report_generated_at=cached.get("generated_at"),
status="ready", analyze_run_at=analyze_at, forecast_run_at=forecast_at
)
# Enqueue фоновой сборки. Lazy import таски (как forecast в analyze_parcel) — атрибут
@ -1798,65 +1695,31 @@ 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 или DOCX (#2259 PR-D/PR-F)",
summary="Скачать готовый полный PDF-отчёт участка (#2259 PR-D)",
)
def download_parcel_report(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
format: Annotated[
Literal["pdf", "docx"],
Query(description="Формат файла: pdf (default) | docx (Word-документ, #2259 PR-F)"),
] = "pdf",
) -> FileResponse:
"""Отдать готовый файл полного отчёта в выбранном формате (PDF / DOCX).
"""Отдать готовый PDF-файл полного отчёта (application/pdf).
Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) FileResponse с
Content-Disposition attachment (`gendesign_report_<cad>_<date>.<ext>`). Отчёт не готов /
Content-Disposition attachment (`gendesign_report_<cad>_<date>.pdf`). Отчёт не готов /
файл не найден 404 (сначала POST /report, дождаться status=ready).
`format=docx`: старые раны (собранные до PR-F) не несут `docx_path` в метадате
честный 404 с подсказкой «пересоберите отчёт» (POST /report пере-соберёт с DOCX, т.к.
ключ кэша не изменился, но файла docx нет пере-рендер запишет оба).
Args:
cad_num: кадастровый номер участка.
format: "pdf" (default) | "docx".
"""
cached = _cached_report_result(db, cad_num)
if cached is None:
raise HTTPException(
status_code=404, detail="отчёт ещё не готов — запустите POST /report и дождитесь ready"
)
if format == "docx":
file_path = cached.get("docx_path")
media_type = _REPORT_DOCX_MEDIA_TYPE
ext = "docx"
# Старый ран без docx_path (собран до PR-F) → пере-соберите POST /report.
if not isinstance(file_path, str) or not Path(file_path).exists():
raise HTTPException(
status_code=404,
detail=(
"DOCX-версия недоступна для этого отчёта — пересоберите отчёт (POST /report)"
),
)
else:
file_path = cached["pdf_path"]
media_type = "application/pdf"
ext = "pdf"
pdf_path = cached["pdf_path"]
cad_safe = cad_num.replace(":", "_")
# Дата в имени файла — из МЕТАДАТЫ отчёта (когда файл реально собран), НЕ today: на
# Дата в имени файла — из МЕТАДАТЫ отчёта (когда PDF реально собран), НЕ today: на
# cache-hit со вчерашнего отчёта today соврал бы. generated_at — ISO-строка из
# build_full_report; битую/отсутствующую парсим best-effort → fallback на today.
date_str = _dt.date.today().strftime("%Y-%m-%d")
@ -1866,10 +1729,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}.{ext}"
filename = f"gendesign_report_{cad_safe}_{date_str}.pdf"
return FileResponse(
file_path,
media_type=media_type,
pdf_path,
media_type="application/pdf",
filename=filename,
)
@ -2353,34 +2216,6 @@ def analyze_parcel(
.all()
)
# 5a) #2464 cluster B: честный COUNT(*) конкурентов в радиусе 3км — БЕЗ LIMIT.
# competitor_rows выше капнут `LIMIT 20` (топ-20 ближайших/строящихся для карты и
# детального списка) — market_pulse.competitors_total ниже раньше = len(competitor_rows),
# тихо капаясь на 20 даже когда в радиусе ЖК больше. Лёгкий COUNT переиспользует
# ТОТ ЖЕ latest_obj + ST_DWithin фильтр, но БЕЗ obj_lots_latest/obj_pricing джойнов
# (нужны только для цен детального списка, не для total) — на порядок дешевле
# полного запроса конкурентов (см. #1964 EXPLAIN про стоимость lots-дедупа).
_competitors_total_true = (
db.execute(
text("""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT COUNT(*) FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
"""),
{"wkt": geom_wkt},
).scalar()
or 0
)
# 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес
# в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
# + по кварталам сдачи. Константы — см. PIPELINE_* выше.
@ -3629,25 +3464,6 @@ def analyze_parcel(
except Exception as e:
logger.warning("ekburg permits query failed for %s: %s", cad_num, e)
# 10d-pre2b) #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (gisogd_permits,
# ST_DWithin 500м). НОВЫЙ ключ payload — заменяет TODO-прокси quarter-prefix выше,
# НЕ трогая старый ekburg-путь (recent_permits_in_quarter / permits_summary остаются).
# Non-fatal: сбой → honest-ноль вместо падения всего analyze.
permits_nearby_data: dict[str, Any] = {
"radius_m": 500,
"total_count": 0,
"rs_count": 0,
"rv_count": 0,
"nearest_distance_m": None,
"items": [],
"items_truncated": False,
"source": "gisogd66",
}
try:
permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500)
except Exception as e:
logger.warning("gisogd permits_nearby query failed for %s: %s", cad_num, e)
# 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS
karpinsky_url = (
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
@ -3805,12 +3621,56 @@ def analyze_parcel(
except Exception as _ve:
logger.warning("velocity compute failed for %s: %s", cad_num, _ve)
# #2464 cluster B: competitors_total — честный total из отдельного COUNT(*)
# (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса
# конкурентов. См. _build_market_pulse docstring для полной логики агрегата.
market_pulse: dict[str, Any] = _build_market_pulse(
competitor_rows, int(_competitors_total_true), velocity_data
# OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
# Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
# в competitor list для карты, но исключаются из расчётов рыночных метрик.
_competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
_competitors_total = len(competitor_rows)
_competitors_priced = len(_competitors_with_price)
if _competitors_with_price:
_prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price]
_market_avg_price = round(sum(_prices) / len(_prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
_with_sales = [
c
for c in _competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
_top_sellers = sorted(
_with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
_top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in _top_sellers
]
else:
_market_avg_price = None
_top_sellers_list = []
_coverage_pct = (
round(_competitors_priced * 100.0 / _competitors_total, 1)
if _competitors_total > 0
else 0.0
)
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
_avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
market_pulse: dict[str, Any] = {
"avg_velocity_m2": _avg_velocity,
"market_avg_price_per_m2": _market_avg_price,
"competitors_total": _competitors_total,
"competitors_with_price": _competitors_priced,
"coverage_pct": _coverage_pct,
"top_sellers": _top_sellers_list,
}
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается
@ -4046,8 +3906,6 @@ def analyze_parcel(
# #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m)
"recent_permits_in_quarter": recent_permits,
"permits_summary": permits_summary,
# #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (ГИСОГД-66, ST_DWithin 500м)
"permits_nearby": permits_nearby_data,
"zoning": zoning,
"success_recommendation": success_recommendation,
"isochrones_available": bool(settings.openrouteservice_api_key),

View file

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

View file

@ -65,11 +65,6 @@ class ConnectionPointsSummary(BaseModel):
in_protection_zone: bool
protection_zones_intersecting: int
total_structures_in_radius: int
# Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без
# LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL).
# ADDITIVE (default False) — старые клиенты не ломаются.
protection_zones_truncated: bool = False
structures_truncated: bool = False
class ConnectionPointsResponse(BaseModel):
@ -105,10 +100,6 @@ class UtilityInfrastructureSummary(BaseModel):
nearest_distance_m: float | None
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
nearest_by_kind: dict[str, float | None]
# Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же
# ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200).
# ADDITIVE (default False) — старые клиенты не ломаются.
features_truncated: bool = False
class UtilityInfrastructureResponse(BaseModel):
@ -279,9 +270,6 @@ 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):
@ -913,7 +901,6 @@ class AnalyzeResponse(BaseModel):
parcel_meta: dict[str, Any] | None = None
recent_permits_in_quarter: list[dict[str, Any]] | None = None
permits_summary: dict[str, Any] | None = None
permits_nearby: dict[str, Any] | None = None
zoning: dict[str, Any] | None = None
success_recommendation: dict[str, Any] | None = None
isochrones_available: bool | None = None

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -57,50 +57,6 @@ 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. Участок"
@ -193,9 +149,7 @@ p { margin: 4pt 0; }
не рвётся внутри заголовка (WeasyPrint поддерживает break-before/inside). */
.section { margin-bottom: 16pt; break-inside: avoid-page; }
.section + .section { break-before: page; }
/* Заголовок НЕ должен осиротеть в конце страницы, оторвавшись от своей таблицы:
и логический break-after (WeasyPrint 60+), и легаси page-break-after (fallback). */
h2, h3 { page-break-after: avoid; break-after: avoid-page; }
h2, h3 { break-after: avoid-page; }
table { break-inside: avoid-page; }
/* Титул */
@ -331,17 +285,6 @@ 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):
@ -497,35 +440,20 @@ def _build_zoning(result: dict[str, Any]) -> str:
def _build_zouit(result: dict[str, Any]) -> str:
"""ЗОУИТ-ограничения: сводка + список пересечений (тип / № границы / покрытие)."""
encumbrance = _as_dict(result.get("encumbrance"))
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, "")
]
overlaps = _as_list(result.get("nspd_zouit_overlaps"))
summary_pairs: list[tuple[str, Any]] = [
("Есть ЗОУИТ", has_zouit),
("Кол-во типов ЗОУИТ", zouit_count),
("Есть ЗОУИТ", encumbrance.get("has_zouit")),
("Кол-во типов ЗОУИТ", encumbrance.get("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: # already filtered to dicts above
for ov in overlaps:
if not isinstance(ov, dict):
continue
coverage = ov.get("coverage_pct")
coverage_str = _fmt_pct(coverage) if isinstance(coverage, int | float) else _DASH
rows.append(
@ -746,24 +674,19 @@ 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"))])
# Секции вообще нет в payload → не рисуем заголовок. Есть записи, но все пустые →
# честное «нет данных» под заголовком (а не пустой невидимый блок).
if not any_item:
if not rows:
return ""
table = _data_table(["Сооружение", "Назначение", "Расстояние, м"], rows)
return f"<h3>Инженерные сооружения рядом (НСПД)</h3>{table}"
return (
f"<h3>Инженерные сооружения рядом (НСПД)</h3>"
f"{_data_table(['Сооружение', 'Назначение', 'Расстояние, м'], rows)}"
)
def _build_alternatives(result: dict[str, Any]) -> str:
@ -826,96 +749,6 @@ 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).
@ -948,22 +781,12 @@ def _build_connection_capacity(cap: dict[str, Any] | None) -> str:
if pairs:
blocks.append("<h3>Электроснабжение — свободная мощность</h3>" + _kv_table(pairs))
# Вода: строки резервов ЦСВ/ЦСК за последний период (ЕКБ-системы + кап ~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]
)
# Вода: строки резервов ЦСВ/ЦСК за последний период.
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)
]
if water_rows:
blocks.append(
"<h3>Водоснабжение/водоотведение — резервы</h3>"
@ -987,47 +810,17 @@ 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_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"))
]
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,
]
)
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)
]
if heat_rows:
blocks.append(
"<h3>Теплоснабжение — резервы систем</h3>"
+ _data_table(["Организация", "Система", "Резерв, Гкал/ч", "Период"], rows)
+ _data_table(["Организация", "Система", "Резерв, Гкал/ч", "Период"], heat_rows)
)
# Позитив-разрез: сетевые охранные зоны рядом (где физически проходит сеть).
@ -1121,9 +914,7 @@ def _build_market_metrics(forecast: dict[str, Any]) -> str:
("Темп продаж (velocity), ед./мес", metrics.get("unit_velocity")),
("Темп продаж (площадь), м²/мес", metrics.get("area_velocity")),
("Окно расчёта, мес", metrics.get("window_months")),
# absorption_rate — доля стока, продаваемая в месяц (0.0112). Как «0.01» это
# нечитаемо → проценты («1.1%»). Темп в штуках уже есть в unit_velocity выше.
("Ставка абсорбции (в мес.)", _fmt_pct(metrics.get("absorption_rate"))),
("Ставка абсорбции", metrics.get("absorption_rate")),
("Месяцев запаса (months of supply)", metrics.get("months_of_supply")),
("Индекс затоварки (overstock)", metrics.get("overstock_index")),
("Sell-through, %", metrics.get("sell_through_pct")),
@ -1132,70 +923,22 @@ 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 = [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")
competitors = _as_list(market_now.get("competitors"))
rows: list[list[Any]] = []
for key in order:
g = groups[key]
name = g["name"]
if g["corpus"] > 1:
name = f"{_fmt(name)} ({g['corpus']} корпусов)"
for c in competitors:
if not isinstance(c, dict):
continue
rows.append(
[
name,
g["dev_name"],
g["obj_class"],
_fmt_int_ru(g["distance_m"]),
_fmt_int_ru(g["lots"]),
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")),
]
)
return _data_table(["ЖК", "Девелопер", "Класс", "Расстояние, м", "Лотов"], rows)
@ -1206,33 +949,19 @@ def _build_market_coverage(forecast: dict[str, Any]) -> str:
confidence = _fc_as_dict(forecast.get("confidence"))
factors = _fc_as_dict(confidence.get("factors"))
# «Комментарий» почти всегда дублирует «Фактор» (label==note, либо note ⊃ label) —
# тогда две колонки = визуальный шум. Показываем 3-ю колонку ТОЛЬКО если хоть у одного
# фактора комментарий несёт что-то сверх метки; иначе схлопываем в «Фактор/уровень».
parsed: list[tuple[Any, Any, Any]] = []
rows: list[list[Any]] = []
for _key, payload in factors.items():
data = _fc_as_dict(payload)
if not data:
continue
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)
rows.append(
[
data.get("label") or data.get("note"),
_fc_level_ru(data.get("level")),
data.get("note"),
]
)
coverage_table = _data_table(["Фактор", "Уровень", "Комментарий"], rows)
level_pairs: list[tuple[str, Any]] = [
("Итоговая уверенность отчёта", _fc_level_ru(confidence.get("level"))),
@ -1369,7 +1098,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(detail.get("avg_ticket_rub"))),
("Средний чек лота, ₽", _fmt_money_signed(detail.get("avg_ticket_rub"))),
("Референс-площадь, м²", detail.get("ref_area_m2")),
("Индекс избытка предложения", detail.get("oversupply_risk")),
]
@ -1491,68 +1220,8 @@ def _build_scenarios_honesty(forecast: dict[str, Any]) -> str:
return _kv_table(pairs)
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 читает оба.
"""
def _build_section_6(forecast: dict[str, Any]) -> str:
"""§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 ""
@ -1572,9 +1241,6 @@ def _build_section_6(forecast: dict[str, Any], result: dict[str, Any]) -> str:
<h3>Сценарии</h3>
{_build_scenarios_honesty(forecast)}
<h3>Разрешения на строительство рядом (500 м)</h3>
{_build_permits_nearby(result)}
</div>
"""
@ -1610,7 +1276,9 @@ 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 f"<h3>Программа застройки</h3>{_data_table(['Тип дома', 'Этажность', 'Секций'], rows)}"
return (
"<h3>Программа застройки</h3>" f"{_data_table(['Тип дома', 'Этажность', 'Секций'], rows)}"
)
def _build_concept_variant(variant: dict[str, Any]) -> str:
@ -1706,7 +1374,6 @@ 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.
@ -1725,9 +1392,6 @@ 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`
@ -1737,7 +1401,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, _as_dict(analyze_result))
+ _build_section_6(forecast)
+ _build_section_7(concept_result)
)
logger.info(

View file

@ -7,10 +7,8 @@ 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")`) нет best-effort
СИНХРОННО считаем его тут же (`_ensure_forecast_run`, зеркало Celery-таски форсайта,
~2030с) и перечитываем; всё ещё нет (сбой/тонкие данные) Part B (§4§6) деградирует
«нет данных», отчёт всё равно валиден (передаём {} в part_b).
2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) нет Part B (§4§6)
деградирует «нет данных», отчёт всё равно валиден (передаём {} в part_b).
3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с
теми же id уже есть И файл на месте cache-hit, PDF не пере-рендерим.
4. connection-capacity (`get_connection_capacity`) best-effort, для §3-резервов.
@ -19,11 +17,8 @@ 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`).
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.
7. Запись файла + метадата-ран `report-pdf-1.0` (result = pdf_path/analyze_run_id/
forecast_run_id/generated_at/size_bytes).
WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native
ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`).
@ -46,7 +41,6 @@ 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,
@ -70,12 +64,6 @@ 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
@ -105,43 +93,6 @@ 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).
@ -164,13 +115,6 @@ 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).
@ -219,82 +163,6 @@ 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 (~2030с) и персистим тем же
контрактом, чтобы последующий `latest_run_for("1.0")` в build_full_report поймал свежий ран
(его новый id корректно войдёт в кэш-ключ отчёта cache-miss §4§6 попадут в PDF).
Зеркало `forecast_site_finder_report(horizon=12)` (workers/tasks/forecast.py):
horizons = `_FORECAST_HORIZONS` (= `_horizons_for(12)` = [6,12,18,24]);
district = денорм-колонка рана fallback analyze["district"]["district_name"];
`build_site_finder_report(...)` `report.as_dict()` `persist_analysis_run(...,
schema_version=d["schema_version"], status="done", ...)`.
Best-effort: ЛЮБОЙ сбой/долгий compute logger.warning + return (НЕ exception форсайт
может честно не собраться на тонких данных, GlitchTip-шум не нужен). Тогда отчёт, как и
раньше, выйдет без §4§6 (part_b деградирует «нет данных»).
Args:
db: SQLAlchemy session (та же, что у build_full_report свою НЕ открываем).
cad: кадастровый номер участка.
analyze_row: Row analyze-рана (несёт денорм `district`).
analyze: persist-payload analyze-рана (district-fallback + competitors для сегмента).
"""
try:
# Lazy import — тяжёлый forecasting-стек не нужен на module-load (как concept-стек).
from app.services.forecasting.orchestrator import build_site_finder_report
district = analyze_row.district or (analyze.get("district") or {}).get("district_name")
logger.info(
"build_full_report: холодный участок cad=%s — best-effort синхронный §22-форсайт "
"(district=%s horizons=%s)",
cad,
district,
_FORECAST_HORIZONS,
)
report = build_site_finder_report(
db,
analyze=analyze,
cad_num=cad,
district=district,
horizons=_FORECAST_HORIZONS,
)
d = report.as_dict()
new_id = persist_analysis_run(
db,
cad_num=cad,
result=d,
params={"horizon": 12, "source": "full-report-inline-forecast"},
district=district,
confidence=(d.get("confidence") or {}).get("level"),
status="done",
schema_version=d["schema_version"], # "1.0" (SiteFinderReport._SCHEMA_VERSION)
created_by=None,
segment=(d.get("meta") or {}).get("segment"),
)
logger.info("build_full_report: §22-форсайт посчитан inline cad=%s run_id=%s", cad, new_id)
except Exception:
# Форсайт может честно не собраться (тонкие данные / сбой §9.x-шва) или занять
# слишком долго — деградируем в отчёт без §4§6 (warning, НЕ exception: держим
# GlitchTip-шум в узде, ведёт себя как ленивая Celery-таска, которая тоже best-effort).
logger.warning(
"build_full_report: inline §22-форсайт не собрался cad=%s → отчёт без §4§6",
cad,
exc_info=True,
)
def _find_cached_report(
db: Session,
cad: str,
@ -331,19 +199,6 @@ 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.
@ -370,14 +225,6 @@ 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'е) и перечитываем — самодостаточность
# отчёта важнее +2030с (как §7-концепция генерится в оркестраторе). Сбой/долго →
# forecast_row остаётся None, part_b деградирует «нет данных» (как раньше).
_ensure_forecast_run(db, cad, analyze_row, analyze)
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {}
forecast_run_id = int(forecast_row.id) if forecast_row is not None else None
@ -407,7 +254,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, analyze_result=analyze)
part_b = build_full_report_html_part_b(forecast, concept, cad=cad)
html = build_full_report_html(
part_a,
part_b,
@ -432,44 +279,30 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
pdf_bytes = render_full_report_pdf(html)
# DOCX-вариант (PR-F): из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ карт-PNG (parcel_png /
# concept_png уже отрендерены выше — НЕ рендерим карты дважды). Зеркалит §1§7 PDF.
docx_bytes = build_full_report_docx(
analyze,
forecast,
concept,
connection_capacity,
cad=cad,
address=address if isinstance(address, str) else None,
generated_at=generated_at_ru,
parcel_map_png=parcel_png,
concept_map_png=concept_png,
)
# Запись файлов на volume + метадата-ран. Каталог создаём (parents, exist_ok).
# Запись файла на volume + метадата-ран. Каталог создаём (parents, exist_ok).
reports_dir = Path(settings.reports_dir)
reports_dir.mkdir(parents=True, exist_ok=True)
base_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}"
pdf_path = reports_dir / f"{base_name}.pdf"
docx_path = reports_dir / f"{base_name}.docx"
# Атомарная запись обоих файлов (tmp+os.replace — см. `_atomic_write`).
_atomic_write(pdf_path, pdf_bytes)
_atomic_write(docx_path, docx_bytes)
file_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}.pdf"
pdf_path = reports_dir / file_name
# АТОМАРНАЯ запись: пишем в .tmp рядом и os.replace → финальный путь. Два конкурентных
# POST в один день целятся в ОДИН pdf_path (имя несёт только дату) — прямой write_bytes
# мог бы interleave-писать байты обоих рендеров в один файл (битый PDF). os.replace
# атомарен в пределах одной FS → download всегда видит целый файл (свой или чужой).
tmp_path = pdf_path.with_suffix(f".{os.getpid()}.tmp")
tmp_path.write_bytes(pdf_bytes)
os.replace(tmp_path, pdf_path)
size_bytes = len(pdf_bytes)
docx_size_bytes = len(docx_bytes)
result: dict[str, Any] = {
"pdf_path": str(pdf_path),
"docx_path": str(docx_path),
"analyze_run_id": analyze_run_id,
"forecast_run_id": forecast_run_id,
"generated_at": generated_at.isoformat(),
"size_bytes": size_bytes,
"docx_size_bytes": docx_size_bytes,
}
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — файлы
# уже записаны, просто следующий вызов не поймает cache-hit и пере-рендерит).
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — PDF
# уже записан, просто следующий вызов не поймает cache-hit и пере-рендерит).
persist_analysis_run(
db,
cad_num=cad,
@ -482,12 +315,10 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
created_by=None,
)
logger.info(
"build_full_report: cad=%s pdf=%s (%d B) docx=%s (%d B) analyze=%s forecast=%s",
"build_full_report: cad=%s written path=%s size=%d analyze=%s forecast=%s",
cad,
pdf_path,
size_bytes,
docx_path,
docx_size_bytes,
analyze_run_id,
forecast_run_id,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -971,31 +971,28 @@ 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,
{
"names": project_names,
"premise_kind": _SUPPLY_ONLY_PREMISE_KIND,
"price_min": _SUPPLY_ONLY_PRICE_MIN,
"price_max": _SUPPLY_ONLY_PRICE_MAX,
},
)
.mappings()
.all()
rows = (
db.execute(
_SUPPLY_ONLY_LOTS_SQL,
{
"names": project_names,
"premise_kind": _SUPPLY_ONLY_PREMISE_KIND,
"price_min": _SUPPLY_ONLY_PRICE_MIN,
"price_max": _SUPPLY_ONLY_PRICE_MAX,
},
)
.mappings()
.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),
@ -1123,37 +1120,28 @@ 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()
]
project_names = _merged_project_name_pool(corpus_names, lots_names)
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
preliminary = match_groups_to_projects(complex_groups, project_names, None)
candidate_names = sorted(set(preliminary.values()))
developers_by_project: dict[str, list[str]] = {}
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()
}
core_group_to_project = match_groups_to_projects(
complex_groups, project_names, developers_by_project
)
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()]
project_names = _merged_project_name_pool(corpus_names, lots_names)
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
preliminary = match_groups_to_projects(complex_groups, project_names, None)
candidate_names = sorted(set(preliminary.values()))
developers_by_project: dict[str, list[str]] = {}
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()
}
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,
@ -1216,24 +1204,18 @@ 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,
{
"center_lon": center_lon,
"center_lat": center_lat,
"radius_m": radius_m,
},
)
.mappings()
.all()
supply_rows = (
db.execute(
_SUPPLY_BATCH_SQL,
{
"center_lon": center_lon,
"center_lat": center_lat,
"radius_m": radius_m,
},
)
.mappings()
.all()
)
except Exception:
logger.warning("best_layouts: supply query failed, supply=0 fallback")
supply_rows = []

View file

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

View file

@ -260,29 +260,25 @@ 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("""
SELECT grs_name,
SUM(design_capacity_th_m3_h) AS design_sum,
SUM(free_capacity_th_m3_h) AS free_sum,
AVG(free_capacity_pct) AS pct_avg,
MAX(upgrade_due) AS upgrade_due,
COUNT(*) AS outputs_count
FROM gas_grs_capacity
WHERE grs_name ILIKE '%свердловск%'
OR grs_name ILIKE '%екатеринбург%'
GROUP BY grs_name
ORDER BY grs_name
""")
)
.mappings()
.all()
rows = (
db.execute(
text("""
SELECT grs_name,
SUM(design_capacity_th_m3_h) AS design_sum,
SUM(free_capacity_th_m3_h) AS free_sum,
AVG(free_capacity_pct) AS pct_avg,
MAX(upgrade_due) AS upgrade_due,
COUNT(*) AS outputs_count
FROM gas_grs_capacity
WHERE grs_name ILIKE '%свердловск%'
OR grs_name ILIKE '%екатеринбург%'
GROUP BY grs_name
ORDER BY grs_name
""")
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 181 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
@ -337,27 +333,25 @@ 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("""
SELECT COUNT(*) AS outlets_total,
COUNT(*) FILTER (
WHERE free_capacity_mln_m3 < 0
) AS outlets_deficit,
COUNT(*) FILTER (
WHERE free_capacity_needs_calc
) AS outlets_needs_calc
FROM gas_grs_outlet_points
WHERE period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
""")
)
.mappings()
.first()
row = (
db.execute(
text("""
SELECT COUNT(*) AS outlets_total,
COUNT(*) FILTER (
WHERE free_capacity_mln_m3 < 0
) AS outlets_deficit,
COUNT(*) FILTER (
WHERE free_capacity_needs_calc
) AS outlets_needs_calc
FROM gas_grs_outlet_points
WHERE period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
""")
)
.mappings()
.first()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
@ -390,41 +384,39 @@ 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("""
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
free_capacity_needs_calc AS needs_calc,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM gas_grs_outlet_points
WHERE geom IS NOT NULL
AND period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT CAST(:limit AS int)
"""),
{
"wkt": parcel_wkt,
"radius_m": _GAS_OUTLET_RADIUS_M,
"limit": _GAS_OUTLET_LIMIT,
},
)
.mappings()
.all()
rows = (
db.execute(
text("""
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
free_capacity_needs_calc AS needs_calc,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM gas_grs_outlet_points
WHERE geom IS NOT NULL
AND period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT CAST(:limit AS int)
"""),
{
"wkt": parcel_wkt,
"radius_m": _GAS_OUTLET_RADIUS_M,
"limit": _GAS_OUTLET_LIMIT,
},
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
@ -459,25 +451,23 @@ def _query_heat_latest(db: Session) -> dict:
"total_reserve_gcal_h": float|None}.
"""
try:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
FROM heat_system_reserves h
WHERE h.period IS NOT NULL
AND h.period = (
SELECT MAX(h2.period) FROM heat_system_reserves h2
WHERE h2.period IS NOT NULL
AND h2.org = h.org
)
ORDER BY h.org, h.system_name
""")
)
.mappings()
.all()
rows = (
db.execute(
text("""
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
FROM heat_system_reserves h
WHERE h.period IS NOT NULL
AND h.period = (
SELECT MAX(h2.period) FROM heat_system_reserves h2
WHERE h2.period IS NOT NULL
AND h2.org = h.org
)
ORDER BY h.org, h.system_name
""")
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 182 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -169,12 +169,7 @@ 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()
rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
except Exception:
logger.exception("own_portfolio: current (domrf) query failed → []")
return []
@ -205,11 +200,7 @@ 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()
rows = db.execute(_FUTURE_SQL).mappings().all()
except Exception:
logger.exception("own_portfolio: future (own_planned_project) query failed → []")
return []

View file

@ -45,12 +45,7 @@ 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()
rows = db.execute(_PAT_INTERSECTS_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / структура изменилась — graceful degrade.
logger.warning("parcel_pat_subzones: pat_subzones недоступна, skip: %s", exc)

View file

@ -1,135 +0,0 @@
"""Точный радиус-запрос разрешений на строительство/ввод рядом с участком (ГИСОГД-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",
}

View file

@ -109,8 +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()
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning("parcel_ppt_tep: ekb_ppt_tep / planning_projects недоступна, skip: %s", exc)

View file

@ -137,12 +137,11 @@ 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()
.first()
)
row = (
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
.mappings()
.first()
)
except Exception:
# SQL/PostGIS-сбой — не наш профиль данных. Логируем и отдаём None
# (один конкурент без parking_ratio лучше, чем 500 на весь lazy-endpoint).

View file

@ -355,32 +355,31 @@ 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(
"""
SELECT feat.value->'properties' AS zone_props
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'territorial_zones'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
row = db.execute(
text(
"""
SELECT feat.value->'properties' AS zone_props
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'territorial_zones'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
ST_Centroid(ST_GeomFromText(:wkt, 4326))
)
LIMIT 1
"""
),
{"q": quarter, "wkt": parcel_wkt},
).first()
4326
),
ST_Centroid(ST_GeomFromText(:wkt, 4326))
)
LIMIT 1
"""
),
{"q": quarter, "wkt": parcel_wkt},
).first()
if row is None:
return None
@ -454,46 +453,45 @@ 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(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'zouit_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
4326
),
4326
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'zouit_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
ST_GeomFromText(:wkt, 4326)
)
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
4326
),
ST_GeomFromText(:wkt, 4326)
)
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -549,32 +547,31 @@ 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(
"""
SELECT DISTINCT ON (reg_numb_border)
type_zone,
category_name,
name_by_doc AS name,
reg_numb_border,
id AS zouit_id,
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson,
ST_Area(
ST_Intersection(
CAST(geom AS geometry),
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY reg_numb_border, id
"""
),
{"wkt": parcel_wkt},
).fetchall()
rows = db.execute(
text(
"""
SELECT DISTINCT ON (reg_numb_border)
type_zone,
category_name,
name_by_doc AS name,
reg_numb_border,
id AS zouit_id,
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson,
ST_Area(
ST_Intersection(
CAST(geom AS geometry),
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY reg_numb_border, id
"""
),
{"wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -637,28 +634,11 @@ 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(
"""
SELECT feat.value->'properties' AS props,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) 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(
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -666,15 +646,31 @@ def _get_engineering_nearby(
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 20
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _ENGINEERING_RADIUS_M},
).fetchall()
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) 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_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 20
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _ENGINEERING_RADIUS_M},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -754,54 +750,53 @@ 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(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
)
) AS intersection_area_sqm
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'risk_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
)
) AS intersection_area_sqm
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'risk_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
ST_GeomFromText(:wkt, 4326)
)
ORDER BY layer, intersection_area_sqm DESC NULLS LAST
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
4326
),
ST_GeomFromText(:wkt, 4326)
)
ORDER BY layer, intersection_area_sqm DESC NULLS LAST
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -875,38 +870,21 @@ 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(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) 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' LIKE 'opportunity_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -914,15 +892,31 @@ def _get_opportunity_parcels(
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 30
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M},
).fetchall()
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) 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' LIKE 'opportunity_%'
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_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 30
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -980,20 +974,30 @@ 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(
"""
SELECT ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Intersects(
rows = db.execute(
text(
"""
SELECT ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
ST_GeomFromText(:wkt, 4326)
) AS does_intersect,
ST_Length(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -1002,36 +1006,9 @@ def _get_red_lines(
4326
),
ST_GeomFromText(:wkt, 4326)
) AS does_intersect,
ST_Length(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
ST_GeomFromText(:wkt, 4326)
)::geography
) AS intersection_length_m,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) 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' = 'red_lines'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
)::geography
) AS intersection_length_m,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -1039,15 +1016,31 @@ def _get_red_lines(
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography,
:nearby_m
)
ORDER BY distance_m ASC
LIMIT 50
"""
),
{"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M},
).fetchall()
ST_GeomFromText(:wkt, 4326)::geography
) 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' = 'red_lines'
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,
:nearby_m
)
ORDER BY distance_m ASC
LIMIT 50
"""
),
{"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -1145,8 +1138,6 @@ 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,
@ -1175,25 +1166,20 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
_trigger_harvest(quarter)
return _empty_unavailable(dump_fetched_at)
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)
structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m)
zouit_overlaps = _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": 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),
"in_protection_zone": protection_count > 0,
"protection_zones_intersecting": protection_count,
"total_structures_in_radius": len(structures),
},
"dump_available": True,
"dump_fetched_at": dump_fetched_at,
@ -1241,12 +1227,36 @@ def _get_parcel_wkt(db: Session, cad_num: str) -> str | None:
return None
_ENGINEERING_STRUCTURES_ITEMS_LIMIT = 50
def _get_engineering_structures_by_boundary(
db: Session,
quarter: str,
parcel_wkt: str,
radius_m: int,
) -> list[dict[str, Any]]:
"""Engineering structures из dump в radius_m от boundary участка.
# Условие радиус-выборки engineering_structures — используется И в row-fetch (с ORDER BY +
# LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на 50, но
# summary.total_structures_in_radius обязан считаться по ВСЕЙ выборке, не по len(list)).
_ENGINEERING_STRUCTURES_WHERE = """
Использует ST_Distance к boundary (не centroid) для корректного расстояния.
Geometry трансформируется из EPSG:3857 (хранение dump) 4326.
"""
try:
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) 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'
@ -1261,67 +1271,12 @@ _ENGINEERING_STRUCTURES_WHERE = """
ST_GeomFromText(:wkt, 4326)::geography,
:radius_m
)
"""
def _get_engineering_structures_by_boundary(
db: Session,
quarter: str,
parcel_wkt: str,
radius_m: int,
) -> 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(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q"""
+ _ENGINEERING_STRUCTURES_WHERE
+ """
ORDER BY distance_m ASC
LIMIT :items_limit
"""
),
{**params, "items_limit": _ENGINEERING_STRUCTURES_ITEMS_LIMIT},
).fetchall()
ORDER BY distance_m ASC
LIMIT 50
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m},
).fetchall()
except (OperationalError, ProgrammingError, DataError) as e:
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
logger.warning(
@ -1329,7 +1284,7 @@ def _get_engineering_structures_by_boundary(
quarter,
e,
)
return [], 0
return []
result: list[dict[str, Any]] = []
for r in rows:
@ -1394,7 +1349,7 @@ def _get_engineering_structures_by_boundary(
"source": "nspd_36328",
}
)
return result, int(total_count)
return result
def _clean_struct_str(value: Any) -> str | None:
@ -1435,12 +1390,24 @@ def _build_structure_characteristics(opts: dict[str, Any]) -> str | None:
return " · ".join(parts) if parts else None
_ZOUIT_ENGINEERING_ITEMS_LIMIT = 100
def _get_zouit_engineering_overlaps(
db: Session,
quarter: str,
parcel_wkt: str,
) -> list[dict[str, Any]]:
"""ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок.
# Условие пересечения zouit_engineering с участком — используется И в row-fetch (с
# ORDER BY + LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на
# 100, но summary.protection_zones_intersecting обязан считаться по ВСЕЙ выборке).
_ZOUIT_ENGINEERING_WHERE = """
Использует слой '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
AND feat.value->>'layer' = 'zouit_engineering'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
@ -1454,58 +1421,11 @@ _ZOUIT_ENGINEERING_WHERE = """
),
ST_GeomFromText(:wkt, 4326)
)
"""
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
"""
),
{**params, "items_limit": _ZOUIT_ENGINEERING_ITEMS_LIMIT},
).fetchall()
LIMIT 100
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
except (OperationalError, ProgrammingError, DataError) as e:
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
logger.warning(
@ -1513,7 +1433,7 @@ def _get_zouit_engineering_overlaps(
quarter,
e,
)
return [], 0
return []
result: list[dict[str, Any]] = []
for r in rows:
@ -1546,7 +1466,7 @@ def _get_zouit_engineering_overlaps(
"source": "nspd_37578",
}
)
return result, int(total_count)
return result
# ── Harvest trigger ───────────────────────────────────────────────────────────

View file

@ -99,8 +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()
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)
return dict(RIASURT_EMPTY_GATE)

View file

@ -237,15 +237,7 @@ 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()
row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first()
except Exception:
logger.exception("saturation: query failed (district=%s)", district_name)
return None

View file

@ -46,9 +46,6 @@ 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).
@ -247,21 +244,6 @@ _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, ∞).
@ -286,14 +268,10 @@ _L2_HIDDEN_SQL = text(
SELECT
district_name,
COUNT(*) AS n_objects_total,
COUNT(*) FILTER (
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
) AS n_with_free_flats,
COUNT(*) FILTER (WHERE free_flats IS NOT NULL) AS n_with_free_flats,
COALESCE(SUM(
GREATEST(0, flat_count - free_flats)
) FILTER (
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
), 0) AS hidden_units
GREATEST(0, COALESCE(flat_count, 0) - free_flats)
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
FROM latest
WHERE ready_dt IS NULL
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
@ -419,11 +397,8 @@ def compute_layer2_hidden(
Per dom.рф-объект hidden = max(0, flat_count free_flats); Σ к району. ОБЕ колонки
на одной строке domrf_kn_objects НЕ нужен domrfobjective маппинг (см. docstring
модуля). Объекты с free_flats IS NULL ИЛИ flat_count IS NULL полностью исключаются
(#2445-A3: обе колонки nullable и заполняются независимо — объект с известным
free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой
объект НЕ считается «покрытым» и не входит в coverage-числитель) снижают coverage
confidence mediumlow.
модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки не вычислить;
documented limitation) снижают coverage confidence mediumlow.
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
units_estimate всегда 0 (clamp). Пустые данные []. Не бросает.
@ -438,16 +413,11 @@ 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:
# Нет ни одного объекта с ВЫЧИСЛИМЫМ hidden (оба flat_count и free_flats
# известны) → нечего объявлять скрытым (а не «0»).
# Нет ни одного объекта с free_flats → нечего объявлять скрытым (а не «0»).
continue
confidence = _l2_confidence(n_total, n_with)
out.append(
@ -464,7 +434,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 flat_count & free_flats known"
f"coverage {n_with}/{n_total} objects with free_flats"
),
snapshot_date=snapshot_date,
)
@ -570,18 +540,9 @@ 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()
return db.execute(sql, dict(params)).mappings().all()
except Exception:
# L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
district_dbg = params.get("district", params.get("districts"))

View file

@ -657,30 +657,9 @@ 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(
@ -688,14 +667,16 @@ 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_clause
+ """
FROM osm_utility_infrastructure_ekb u
WHERE ST_DWithin(
u.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT :lim
"""
),
{**params, "lim": limit},
"""),
{"wkt": parcel_wkt, "radius_m": radius_m, "lim": limit},
)
.mappings()
.all()
@ -725,15 +706,11 @@ def get_nearby_utility_infrastructure(
}
)
total_features_int = int(total_features)
return {
"features": features,
"summary": {
# Epic #2445 A2: честный COUNT(*) по всей радиус-выборке — НЕ len(features)
# (которое капалось на `limit`, default 200).
"total_features": total_features_int,
"total_features": len(features),
"nearest_distance_m": nearest_overall,
"nearest_by_kind": nearest_by_kind,
"features_truncated": total_features_int > len(features),
},
}

View file

@ -95,11 +95,6 @@ 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), а темп выбытия одного участника рынка — им и
@ -108,14 +103,6 @@ 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 {
@ -133,13 +120,11 @@ 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,
}
@ -322,20 +307,10 @@ def compute_velocity(
# SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными).
coverage_pct = round(mapped_ratio * 100, 1)
# 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
ekb_median = (
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
)
n_comps = len(comp_rows)
# #2464 cluster B: Step 1 query капнут `LIMIT 200` (см. выше) — если выборка
# уперлась в лимит, реальных конкурентов в радиусе может быть больше.
n_comps_truncated = n_comps >= 200
sample_no_data = sorted(
[
{
@ -367,8 +342,6 @@ 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
@ -390,9 +363,7 @@ 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) ───────────────────────
@ -499,8 +470,6 @@ 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
@ -534,9 +503,7 @@ 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).
@ -613,9 +580,7 @@ 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,
)
@ -627,8 +592,6 @@ 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 участка.
@ -718,18 +681,13 @@ 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) -> tuple[float, float] | None:
def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
"""ЕКБ-wide медиана monthly velocity (м²/мес) per ЖК — benchmark.
Источник: objective_corpus_room_month (актуальные данные, обновляется еженедельно).
@ -739,24 +697,6 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float]
Учитываются только ЖК с 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():
@ -764,38 +704,23 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float]
db.execute(
text(
"""
WITH source_rows AS (
WITH per_project AS (
SELECT
project_name,
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
SUM(COALESCE(deals_total_vol_m2,
deals_total_count * 45.0)) AS total_sqm,
COUNT(DISTINCT report_month) AS months_data
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,
rows_data,
proxy_rows
SELECT total_sqm / months_data AS velocity
FROM per_project
)
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
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median
FROM per_project_velocity
"""
),
@ -809,10 +734,6 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float]
# SAVEPOINT auto-rollback'нут
return None
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
if row and row["median"] is not None:
return float(row["median"])
return None

View file

@ -470,15 +470,9 @@ def backfill_ekb_zone_regulations(
upserted += 1
except Exception as exc:
# Per-zone изоляция: один сбойный фетч/апсёрт не должен ронять весь прогон.
# 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)
logger.warning(
"backfill_ekb_zone_regulations: зона idx=%s failed: %s", zone_index, exc
)
failed += 1
continue

View file

@ -361,18 +361,6 @@ 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
@ -420,24 +408,6 @@ 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).

View file

@ -50,7 +50,6 @@ 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",
@ -90,7 +89,6 @@ 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"

View file

@ -3,116 +3,25 @@
Расписание: 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(triggered_by: str = "beat") -> dict:
def etl_newbuilding_crossload() -> 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).
"""
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()
logger.info("task etl_newbuilding_crossload: start")
result = run_crossload()
logger.info("task etl_newbuilding_crossload: done — %s", result)
return result

View file

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

View file

@ -151,59 +151,42 @@ 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)
ocr_text = ocr_pdf_text(pdf_bytes)
if not ocr_text.strip():
logger.warning(
"ingest_izyatie_ocr: OCR вернул пустой текст для %s "
"(tesseract не установлен, битый PDF или чистый лист?)",
doc_url,
)
processed_docs += 1
continue
records = extract_izyatie_records(ocr_text, doc_title, doc_url)
logger.info(
"ingest_izyatie_ocr: %r%d кад-номеров",
doc_title[:60],
len(records),
)
if not records:
processed_docs += 1
continue
count = _upsert_records(db, records)
db.commit()
total_parcels += count
processed_docs += 1
except Exception as exc:
logger.warning(
"ingest_izyatie_ocr: пропуск документа %r (%s): %s",
doc_title[:60],
logger.error(
"ingest_izyatie_ocr: не удалось загрузить PDF %s: %s",
doc_url,
exc,
)
try:
db.rollback()
except Exception as rollback_exc:
logger.warning(
"ingest_izyatie_ocr: rollback после сбоя документа %s тоже упал: %s",
doc_url,
rollback_exc,
)
continue
ocr_text = ocr_pdf_text(pdf_bytes)
if not ocr_text.strip():
logger.warning(
"ingest_izyatie_ocr: OCR вернул пустой текст для %s "
"(tesseract не установлен, битый PDF или чистый лист?)",
doc_url,
)
processed_docs += 1
continue
records = extract_izyatie_records(ocr_text, doc_title, doc_url)
logger.info(
"ingest_izyatie_ocr: %r%d кад-номеров",
doc_title[:60],
len(records),
)
if not records:
processed_docs += 1
continue
count = _upsert_records(db, records)
db.commit()
total_parcels += count
processed_docs += 1
finally:
db.close()

View file

@ -25,13 +25,6 @@ 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
@ -96,12 +89,6 @@ _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 (
@ -158,7 +145,6 @@ 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, …).
@ -168,25 +154,15 @@ 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:
@ -198,19 +174,6 @@ 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
@ -261,10 +224,6 @@ 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(

View file

@ -10,17 +10,12 @@ Beat НЕ настроен: постановления выходят редко
celery call tasks.reservation_ingest.ingest_reservations
Conventions (зеркало ird_harvest.py / backend.md):
Session lifecycle (#2445 D4): PDF fetch происходит БЕЗ открытой сессии — pravo.gov66
иногда медленный (таймаут до 60с), держать connection-pool slot на всё время внешнего
запроса нельзя. Короткоживущий ``SessionLocal()`` открывается ТОЛЬКО на запись строк
одного документа (open upsert commit close per item), не на весь цикл.
SessionLocal() + try/finally close.
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
@ -102,11 +97,8 @@ def _fetch_pdf_safe(pdf_url: str) -> bytes | None:
return None
def _upsert_records_in_session(db: Session, records_meta: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation на уже открытой сессии.
Возвращает число upsert'нутых строк.
"""
def _upsert_records(db: Session, records_meta: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation. Возвращает число обработанных строк."""
count = 0
for row in records_meta:
try:
@ -122,27 +114,6 @@ def _upsert_records_in_session(db: Session, records_meta: list[dict[str, Any]])
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]]:
"""Обходит все поисковые запросы и возвращает дедуплицированный список документов.
@ -211,111 +182,117 @@ def ingest_reservations(
def _ingest_explicit_docs(docs: list[dict[str, Any]]) -> dict[str, int]:
"""Ингестирует явно переданный список документов (back-compat с ручным вызовом).
Session lifecycle (#2445 D4): PDF fetch (сетевой, может занять до 60с) выполняется
БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою короткоживущую сессию
только на запись строк одного документа (commit+close сразу после).
"""
"""Ингестирует явно переданный список документов (back-compat с ручным вызовом)."""
total_parcels = 0
for doc in docs:
url = doc.get("url", "")
act_number = doc.get("act_number")
basis_act = doc.get("basis_act", "")
doc_purpose = doc.get("purpose")
doc_kind = doc.get("kind", "изъятие")
db: Session = SessionLocal()
try:
for doc in docs:
url = doc.get("url", "")
act_number = doc.get("act_number")
basis_act = doc.get("basis_act", "")
doc_purpose = doc.get("purpose")
doc_kind = doc.get("kind", "изъятие")
logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)
logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)
pdf_bytes = _fetch_pdf_safe(url) # без открытой сессии — только сетевой запрос
if pdf_bytes is None:
logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
continue
pdf_bytes = _fetch_pdf_safe(url)
if pdf_bytes is None:
logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
continue
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(explicit): url=%s%d КН", url, len(records))
if not records:
continue
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(explicit): url=%s%d КН", url, len(records))
if not records:
continue
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind or doc_kind,
"basis_act": basis_act,
"act_number": rec.act_number or act_number,
"act_date": rec.act_date,
"purpose": rec.purpose or doc_purpose,
"doc_url": url,
"source": "page_pdf",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind or doc_kind,
"basis_act": basis_act,
"act_number": rec.act_number or act_number,
"act_date": rec.act_date,
"purpose": rec.purpose or doc_purpose,
"doc_url": url,
"source": "page_pdf",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
count = _upsert_records(db, rows)
db.commit()
total_parcels += count
logger.info(
"ingest_reservations(explicit): готово docs=%d parcels=%d",
len(docs),
total_parcels,
)
finally:
db.close()
logger.info(
"ingest_reservations(explicit): готово docs=%d parcels=%d",
len(docs),
total_parcels,
)
return {"docs": len(docs), "parcels": total_parcels}
def _ingest_enumerated_docs(enumerated: list[dict[str, Any]]) -> dict[str, int]:
"""Ингестирует документы, найденные через enumerate с pravo.gov66.
Session lifecycle (#2445 D4): оба сетевых вызова на документ (get_document_pdf_url,
fetch_pdf) выполняются БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою
короткоживущую сессию только на запись строк одного документа.
"""
"""Ингестирует документы, найденные через enumerate с pravo.gov66."""
total_parcels = 0
fetched_docs = 0
for hit in enumerated:
doc_id: str = hit["doc_id"]
title: str = hit.get("title", "")
doc_page_url: str = hit["url"]
db: Session = SessionLocal()
try:
for hit in enumerated:
doc_id: str = hit["doc_id"]
title: str = hit.get("title", "")
doc_page_url: str = hit["url"]
logger.info("ingest_reservations(enum): doc_id=%s title=%r", doc_id, title)
logger.info("ingest_reservations(enum): doc_id=%s title=%r", doc_id, title)
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_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) # сеть, без открытой сессии
if pdf_bytes is None:
logger.warning("ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id)
continue
pdf_bytes = _fetch_pdf_safe(pdf_url)
if pdf_bytes is None:
logger.warning(
"ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id
)
continue
fetched_docs += 1
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(enum): doc_id=%s%d КН", doc_id, len(records))
if not records:
continue
fetched_docs += 1
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(enum): doc_id=%s%d КН", doc_id, len(records))
if not records:
continue
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind,
"basis_act": title,
"act_number": rec.act_number,
"act_date": rec.act_date,
"purpose": rec.purpose,
"doc_url": doc_page_url,
"source": "pravo_gov66",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind,
"basis_act": title,
"act_number": rec.act_number,
"act_date": rec.act_date,
"purpose": rec.purpose,
"doc_url": doc_page_url,
"source": "pravo_gov66",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
count = _upsert_records(db, rows)
db.commit()
total_parcels += count
logger.info(
"ingest_reservations(enum): готово fetched=%d parcels=%d",
fetched_docs,
total_parcels,
)
finally:
db.close()
logger.info(
"ingest_reservations(enum): готово fetched=%d parcels=%d",
fetched_docs,
total_parcels,
)
return {"docs": fetched_docs, "parcels": total_parcels}

View file

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

View file

@ -89,8 +89,8 @@ def test_freshness_mixed_statuses() -> None:
"last_status": None,
},
"nspd_geo_jobs": {
"last_success_at": _ago(100.0), # >= stale_days=90 → critical → failed
"last_attempt_at": _ago(100.0),
"last_success_at": _ago(40.0), # >= stale_days=30 → critical → failed
"last_attempt_at": _ago(40.0),
"upd_24h": 0,
"upd_7d": 0,
"last_status": "done",
@ -230,7 +230,9 @@ 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)

View file

@ -1,108 +0,0 @@
"""Тесты для 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()

View file

@ -7,26 +7,26 @@
Стратегия mock: аналогична test_analyze_market_price.py DB mock через
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
Порядок db.execute calls в analyze_parcel (с #29 G2, #2464 cluster B):
Порядок db.execute calls в analyze_parcel (с #29 G2):
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. 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()
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()
"""
from __future__ import annotations
@ -82,23 +82,20 @@ def _make_db_for_analyze(
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor 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
("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}),
("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
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:

View file

@ -125,24 +125,21 @@ def _make_db_for_analyze() -> MagicMock:
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor 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
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
# (далее nspd_zoning merge не ходит в db.execute — резолвер замокан)
("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}),
("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
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:

View file

@ -68,7 +68,9 @@ 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",
@ -452,130 +454,6 @@ 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)

View file

@ -277,23 +277,20 @@ def _make_db_for_analyze() -> MagicMock:
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor 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
("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}),
("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
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
@ -602,94 +599,3 @@ 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"}]

View file

@ -1,226 +0,0 @@
"""Тесты: 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"}

View file

@ -545,109 +545,6 @@ 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/прочих."""

View file

@ -95,8 +95,6 @@ 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()
@ -129,10 +127,7 @@ 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
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"
assert resp.json()["status"] == "ready"
delay_mock.assert_not_called() # кэш есть → не enqueue'им
finally:
app.dependency_overrides.clear()
@ -278,205 +273,3 @@ 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()

View file

@ -165,36 +165,6 @@ 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.

View file

@ -1,19 +1,14 @@
"""Тесты pravo_gov66_client — offline, без живой сети.
Покрывают: парсинг search-HTML (doc_id/title/url), парсинг страницы документа
(PDF-ссылка), fallback на ecp-вариант, отсутствие PDF, pacing/backoff (#2445 D4)
между последовательными запросами к pravo.gov66.ru.
(PDF-ссылка), fallback на ecp-вариант, отсутствие PDF.
"""
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,
)
@ -174,96 +169,3 @@ 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

View file

@ -1,244 +0,0 @@
"""Тесты read-only retrieval чата (#957): §22-отчёт + курируемый паспорт участка.
Покрываем `get_parcel_context_for_chat` (§19 data-residency):
строит ПЛОСКИЙ whitelist-дикт из фиксированных градо-полей analyze-рана;
НЕ протаскивает наружу сырые под-словари / геометрию / любой лишний ключ payload;
ЗОУИТ: dedup названий, cap, разрешение противоречия encumbranceoverlaps;
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

View file

@ -20,7 +20,6 @@ _EXPECTED_TOOLS = {
"get_forecast",
"get_risks",
"get_scenarios",
"get_parcel_info",
}
@ -40,23 +39,15 @@ 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_six() -> None:
def test_tool_specs_cover_all_five() -> None:
specs = tools.tool_specs()
assert len(specs) == 6
assert len(specs) == 5
assert {s["function"]["name"] for s in specs} == _EXPECTED_TOOLS
@ -108,20 +99,6 @@ 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 ─────────────────────
@ -159,7 +136,6 @@ 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") == ()

View file

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

View file

@ -386,150 +386,6 @@ 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 ────────────────

View file

@ -34,7 +34,6 @@ 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,
@ -400,167 +399,6 @@ 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 "&lt;script&gt;" in out
assert "&amp;" 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}} ─────────────────────
@ -729,113 +567,3 @@ 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

View file

@ -84,9 +84,6 @@ 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:
@ -129,55 +126,13 @@ def test_happy_path_writes_file_and_persists(
assert kwargs["result"]["forecast_run_id"] == 202
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 из пустого форсайта (отчёт валиден, как до фикса на холодном участке)."""
def test_no_forecast_run_degrades(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Нет forecast-рана → 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")) # ставит _ensure no-op
_patch_renders(monkeypatch, MagicMock(return_value=b"%PDF"))
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
result = mod.build_full_report(MagicMock(), _CAD)
@ -303,146 +258,6 @@ 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"

View file

@ -24,7 +24,6 @@ import pytest
from app.services.exporters.report_pdf import (
_ADVISORY_MARKER,
_TITLE_CONFIDENCE,
_TITLE_FUTURE_MARKET,
_TITLE_MARKET_NOW,
_TITLE_PRODUCT_TZ,
@ -142,8 +141,7 @@ _skip_if_no_weasyprint = pytest.mark.skipif(
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
)
# Семь ожидаемых заголовков блоков (по одному на содержательную секцию §13, включая
# §13.7 «Уверенность» — parity fix #2445 C2, зеркало _EXPECTED_TITLES в test_report_docx/md).
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
_EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_SUMMARY,
_TITLE_MARKET_NOW,
@ -151,7 +149,6 @@ _EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_PRODUCT_TZ,
_TITLE_SCENARIOS,
_TITLE_SCORING,
_TITLE_CONFIDENCE,
)
@ -360,101 +357,12 @@ class TestContractKeysRendered:
assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
def test_confidence_factors_rendered(self) -> None:
# confidence.factors должны попасть в HTML (ключ→значение) через §13.7-секцию.
# confidence.factors должны попасть в HTML (ключ→значение).
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 без падения ─────

View file

@ -13,25 +13,18 @@ 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,
@ -483,110 +476,3 @@ 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")

View file

@ -371,101 +371,3 @@ 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")

View file

@ -40,7 +40,6 @@ from app.services.forecasting.product_scoring import (
_count_positive_usp,
_min_confidence,
_overlay_confidence,
_poi_weight_sum,
_quarter_from_cad,
_saturating,
_score_commercial,
@ -992,98 +991,3 @@ 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

View file

@ -15,12 +15,10 @@ 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
@ -669,85 +667,6 @@ 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-фильтре.

View file

@ -16,7 +16,6 @@ test_market_metrics.
from __future__ import annotations
import contextlib
from datetime import date
from typing import Any
from unittest.mock import MagicMock, patch
@ -66,7 +65,6 @@ 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,
@ -1572,178 +1570,6 @@ 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
# ──────────────────────────────────────────────────────────────────────────────

View file

@ -116,33 +116,6 @@ 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(
{

View file

@ -119,25 +119,6 @@ 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",

View file

@ -100,94 +100,13 @@ 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": "LineString",
"coordinates": [[60.60, 56.830], [60.6045, 56.8328]],
}
parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]}
)
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)])

View file

@ -29,11 +29,11 @@ def test_render_known_template() -> None:
assert "ассистент" in out
def test_chat_system_v3_has_unit_mix_formatting_rule() -> None:
"""chat_system@v3: чистый рендер квартирографии, без «N-тип» склейки (сохранено с v2)."""
def test_chat_system_v2_has_unit_mix_formatting_rule() -> None:
"""chat_system@v2 (#957 fix): чистый рендер квартирографии, без «N-тип» склейки."""
tpl = get_template("chat_system")
assert tpl.version == 3
assert tpl.key == "chat_system@v3"
assert tpl.version == 2
assert tpl.key == "chat_system@v2"
out = render("chat_system")
assert "квартирограф" in out.lower() # правило про перечисление сегментов
assert "«2-1-к»" in out # явный анти-пример (склейка номера с типом)
@ -42,13 +42,6 @@ def test_chat_system_v3_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(

View file

@ -1,72 +0,0 @@
"""Тест для #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

View file

@ -1,63 +0,0 @@
"""Тест для #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

View file

@ -1,211 +0,0 @@
"""Тесты для 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

View file

@ -55,34 +55,3 @@ 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