Merge remote-tracking branch 'forgejo-backend/main' into fix/sf-feedback-batch
This commit is contained in:
commit
bcd24923b8
190 changed files with 12669 additions and 3165 deletions
|
|
@ -15,23 +15,22 @@ name: CI
|
|||
# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см.
|
||||
# .github/workflows/ci.yml как образец service-блока.
|
||||
on:
|
||||
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709).
|
||||
# WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым
|
||||
# PR триггерил ДВА прогона на ОДИН SHA: push-событие (github.ref=refs/heads/<branch>)
|
||||
# и pull_request-событие (github.ref=refs/pull/<N>/merge). Разный github.ref →
|
||||
# разные concurrency-группы (см. ниже) → прогоны НЕ отменяют друг друга → 2× job
|
||||
# при и так дефицитных раннерах. В bot-пайплайне каждый коммит идёт через PR, так
|
||||
# что pull_request гейтит его полностью; push-прогон был чистым дублем.
|
||||
# Trade-off: push в feature-ветку БЕЗ открытого PR не получит CI до открытия PR
|
||||
# (бот открывает PR сразу после первого push) — приемлемо.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches:
|
||||
# Mirror the bot-PR / feature-branch flow в .claude/rules/git-pr.md:
|
||||
# PR получает gate, прямые пуши в feature-ветки — тоже.
|
||||
- "feat/**"
|
||||
- "fix/**"
|
||||
- "refactor/**"
|
||||
- "chore/**"
|
||||
- "docs/**"
|
||||
- "perf/**"
|
||||
- "test/**"
|
||||
- "ci/**"
|
||||
- "hotfix/**"
|
||||
|
||||
concurrency:
|
||||
# Теперь, когда остался только pull_request, github.ref стабилен на весь PR
|
||||
# (refs/pull/<N>/merge) → новый push в ветку PR отменяет предыдущий незавершённый
|
||||
# прогон ЭТОГО PR (cancel-in-progress) вместо накопления параллельных.
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
|
@ -89,6 +88,19 @@ jobs:
|
|||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Cache uv packages
|
||||
# Кросс-прогонный кэш скачанных/собранных wheel'ов (~/.cache/uv по умолч.).
|
||||
# `uv sync --frozen` без него каждый прогон тянет весь geo-стек заново —
|
||||
# доминирующая часть времени job (#1709). Ключ по uv.lock; continue-on-error
|
||||
# чтобы сбой cache-бэкенда раннера НИКОГДА не ронял gate.
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
|
||||
restore-keys: |
|
||||
uv-${{ runner.os }}-
|
||||
|
||||
- name: Install system deps for geo + WeasyPrint
|
||||
# libpq/gdal/proj/geos — geo-стек (geopandas/shapely/pyproj).
|
||||
# libcairo2/libpango* — нативные либы WeasyPrint: с ними PDF-тесты
|
||||
|
|
@ -230,6 +242,16 @@ jobs:
|
|||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Cache uv packages
|
||||
# См. backend-tests: кросс-прогонный кэш ~/.cache/uv, тот же ключ по uv.lock.
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
|
||||
restore-keys: |
|
||||
uv-${{ runner.os }}-
|
||||
|
||||
- name: Install system deps for geo + WeasyPrint
|
||||
# app.main транзитивно тянет geo/PDF-модули. На macOS-dev импорт схемы
|
||||
# проходит и без этих либ, но на ubuntu ставим как backend-tests
|
||||
|
|
@ -239,9 +261,12 @@ jobs:
|
|||
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
|
||||
libcairo2 libpango-1.0-0 libpangoft2-1.0-0
|
||||
|
||||
- name: Install backend deps (uv sync --frozen)
|
||||
- name: Install backend deps (uv sync --frozen --no-dev)
|
||||
working-directory: backend
|
||||
run: uv sync --frozen
|
||||
# --no-dev: этот job только дампит app.openapi() (нужен runtime app.main).
|
||||
# pytest/ruff/coverage не используются → не ставим dev-группу (быстрее).
|
||||
# Dockerfile тоже собирает с --no-dev → импорт app.main гарантированно ок.
|
||||
run: uv sync --frozen --no-dev
|
||||
|
||||
- name: Install frontend deps (npm ci)
|
||||
working-directory: frontend
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ jobs:
|
|||
- '.forgejo/workflows/deploy-tradein.yml'
|
||||
scraper:
|
||||
- 'tradein-mvp/backend/app/services/scrapers/**'
|
||||
- 'tradein-mvp/backend/app/services/scrape_pipeline.py'
|
||||
- 'tradein-mvp/backend/app/services/scheduler.py'
|
||||
- 'tradein-mvp/backend/app/scheduler_main.py'
|
||||
- 'tradein-mvp/backend/app/tasks/**'
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -81,3 +81,6 @@ sf_anton_snapshot.db-*
|
|||
*.csv
|
||||
*.csv.zip
|
||||
!backend/db/init/*.sql
|
||||
|
||||
# Log cruft at repo root
|
||||
debug.log
|
||||
|
|
|
|||
102
README.md
102
README.md
|
|
@ -1,6 +1,12 @@
|
|||
# Generative Design + Site Finder
|
||||
# GenDesign — Site Finder + Trade-In «Мера»
|
||||
|
||||
Две продуктовые линии в одной воронке: **Site Finder** (AI-подбор инвестиционных земельных участков) + **Generative Design** (автоматическая генерация концепций застройки) для девелоперов РФ. Целевой регион Discovery — Свердловская обл. (ЕКБ, ПЗЗ, МСК-66).
|
||||
Несколько продуктовых линий для девелоперов и риелторов РФ в одном монорепо:
|
||||
|
||||
- **Site Finder v2 «Foresight»** — AI-подбор инвестиционных земельных участков + прогноз продуктовой стратегии («GG-форсайт»: спрос/предложение, сценарии, что строить, gate-вердикт по ПЗЗ/ЗОУИТ/ОКН).
|
||||
- **Trade-In «Мера»** — оценка выкупной стоимости вторичного жилья (адрес → оценка по аналогам и реальным сделкам, PDF-отчёт, white-label). `tradein-mvp/`, отдельный пайплайн.
|
||||
- **Generative Design** — автоматическая генерация концепций застройки (на ранней стадии).
|
||||
|
||||
Целевой регион Discovery — Свердловская обл. (ЕКБ, ПЗЗ, МСК-66).
|
||||
|
||||
## North Star
|
||||
|
||||
|
|
@ -14,16 +20,23 @@
|
|||
|
||||
| Слой | Статус |
|
||||
|---|---|
|
||||
| **Site Finder v2 «Foresight»** (`/site-finder`) — анализ участка по cad_num, карта by-bbox, forecast-движок | ✅ live |
|
||||
| ↳ Forecast-движок (`/parcels/{cad}/forecast` + `/analyze`): спрос/предложение, сценарии, confidence, affordability, rate-sensitivity, продуктовое ТЗ | ✅ (`backend/app/services/forecasting/*`, фронт `Section6Forecast` + `ScenariosBlock` + `ForecastConfidenceBlock`) |
|
||||
| ↳ Gate-вердикт ПЗЗ/ЗОУИТ/ОКН (`gate_verdict`) + ИРД-overlay/analyze | ✅ backend (`gate_verdict.py`, `ird_overlay_lookup.py`, `ird_analyze.py`); фронт `GateVerdictBanner` |
|
||||
| ↳ Конкуренты / best-layouts / изохроны (`/parcels/{cad}/competitors`, `/best-layouts`, `/isochrones`) | ✅ live |
|
||||
| **Trade-In «Мера»** (`/trade-in`) — оценка вторички по аналогам + реальным сделкам, PDF, white-label | ✅ live (`tradein-mvp/`, отдельный стек/пайплайн) |
|
||||
| ↳ Скрейперы аналогов: Avito / Cian / Domclick / Yandex.Realty (+ детальные парсеры, browser-fetcher) | ✅ |
|
||||
| ↳ SberIndex time-adjust (city-level вторичный индекс цен) + Rosreestr ДКП-сделки + ДаДата/геокодер | ✅ |
|
||||
| Аналитический калькулятор `/analytics/recommend` (Tier 3 per-bucket эластичность, кадастр vs рынок) | ✅ |
|
||||
| DOM.РФ kn-API скрейпер (442 строящихся ЖК Свердл, sale_graph + infra + photos) | ✅ Celery beat + admin UI |
|
||||
| DOM.РФ kn-API скрейпер (строящиеся ЖК Свердл, sale_graph + infra + photos) | ✅ Celery beat + admin UI |
|
||||
| NSPD geo bulk-fetcher (rosreestr2coord lite, без Playwright) | ✅ admin UI с per-target логами |
|
||||
| Rosreestr CSV-сделки 2024Q1—2026Q1 (9 квартирных партиций, 7M строк) | ✅ |
|
||||
| NSPD кадастровые кварталы + здания (709 cad-кварталов, 10 590 cad_buildings ЕКБ) | ✅ Celery beat + admin UI |
|
||||
| OSM ekb_districts_geom (PostGIS-полигоны 8 районов ЕКБ) | ✅ |
|
||||
| Rosreestr CSV-сделки (квартирные партиции, ~7M строк, 27 мес истории) | ✅ |
|
||||
| NSPD кадастровые кварталы + здания ЕКБ | ✅ Celery beat + admin UI |
|
||||
| OSM ekb_districts_geom (PostGIS-полигоны районов ЕКБ) | ✅ |
|
||||
| Objective ETL (Anton SQLite → PG, ports на psycopg v3) | ✅ admin UI |
|
||||
| Obsidian Self-hosted LiveSync vault на `obsidian.gendsgn.ru` (268 entities из KG) | ✅ multi-device sync |
|
||||
| Concept page (`/concept`) — Stage 1 Generative | 🟡 skeleton |
|
||||
| Site Finder (`/site-finder`) — Stage 2 | 🔴 заглушка |
|
||||
| Uptime Kuma мониторинг (`status.gendsgn.ru`, отдельный стек) | ✅ |
|
||||
| Obsidian Self-hosted LiveSync vault на `obsidian.gendsgn.ru` | ✅ multi-device sync |
|
||||
| Concept page (`/concept`) — Generative Design | 🟡 skeleton |
|
||||
|
||||
**Backlog:** [Forgejo Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) (трекаемые задачи с PR-связью) + [Obsidian vault](https://obsidian.gendsgn.ru) (планы, ADR, research — см. ниже про разделение).
|
||||
|
||||
|
|
@ -56,8 +69,13 @@ curl http://localhost:8000/health # → {"status":"ok","environment":"dev"}
|
|||
|
||||
Структура монорепо:
|
||||
```
|
||||
backend/ FastAPI app, services (analytics_queries, scrapers, generative, site_finder), workers (Celery)
|
||||
frontend/ Next.js app router (analytics, admin, concept, site-finder)
|
||||
backend/ FastAPI app, services (analytics_queries, scrapers, generative, site_finder, forecasting), workers (Celery)
|
||||
frontend/ Next.js app router (analytics, admin, concept, site-finder + forecast/scenarios компоненты)
|
||||
tradein-mvp/ Trade-In «Мера» — standalone стек (FastAPI + Next.js + Postgres + Caddy), скрейперы вторички, PDF-оценка
|
||||
site-finder/ standalone pipeline-скрипты SF v1 (01–12_*.py загрузка/скоринг) + server.py (Leaflet UI на SQLite-кэше)
|
||||
auth/ roles.yaml — RBAC (single source of truth, bind-mount в main + tradein backend)
|
||||
caddy/ users.caddy.snippet — basic-auth snippet для Caddy
|
||||
preview/ статичные HTML-макеты (analytics / landing / monitoring / site-finder)
|
||||
data/sql/ ETL артефакты — миграции схемы, загрузчики rosreestr/anton/nspd
|
||||
ops/ backup.sh + deploy скрипты
|
||||
scripts/ утилиты (migrate_kg_to_obsidian.py, setup-couchdb.sh, cleanup_ghosts.py)
|
||||
|
|
@ -65,10 +83,11 @@ docs/ стратегические снапшоты (акту
|
|||
memory/ legacy JSONL knowledge graph (deprecated, не обновляется — данные в Obsidian)
|
||||
docker-compose.prod.yml main стек (backend, frontend, postgres, redis, worker, beat, caddy)
|
||||
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
|
||||
docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную
|
||||
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
|
||||
├── ci.yml lint (ruff) + mypy + pytest на PR
|
||||
├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy
|
||||
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн)
|
||||
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн + свой _schema_migrations)
|
||||
└── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне
|
||||
.github/workflows/ (остаточные — только obsidian-стек на GitHub)
|
||||
└── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap)
|
||||
|
|
@ -92,6 +111,35 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
|
|||
|
||||
---
|
||||
|
||||
## Site Finder v2 «Foresight» — что доступно
|
||||
|
||||
Анализ инвестиционного участка по кадастровому номеру: карта (`/parcels/by-bbox`), запуск анализа (`POST /parcels/{cad}/analyze`), прогнозный движок (`GET /parcels/{cad}/forecast` + экспорт). Бизнес-логика — `backend/app/services/site_finder/*` и `backend/app/services/forecasting/*`.
|
||||
|
||||
- **Forecast-движок** (`forecasting/orchestrator.py`): прогноз спроса/предложения (`demand_supply_forecast`), сценарии (`scenarios`), оценка уверенности (`confidence_engine`), доступность (`affordability`), чувствительность к ставке (`rate_sensitivity`), продуктовый скоринг и «что строить» (`product_scoring`, `what_to_build`). Фронт: `Section6Forecast`, `ScenariosBlock`, `ForecastChart`, `ForecastConfidenceBlock`, `ForecastHorizonsBlock`, `ForecastProductTzBlock`.
|
||||
- **Gate-вердикт**: проверка регуляторных ограничений ПЗЗ/ЗОУИТ/ОКН (`gate_verdict.py`, `pzz_loader`, `zone_regulation`, `okn_lookup`) → баннер вердикта на фронте (`GateVerdictBanner`).
|
||||
- **ИРД** (исходно-разрешительная документация): overlay-lookup + анализ на backend (`ird_overlay_lookup.py`, `ird_analyze.py`).
|
||||
- **Соседство/контекст**: конкуренты (`POST /parcels/{cad}/competitors`), лучшие планировки (`/best-layouts` + PDF), изохроны (`/isochrones`), POI-скоринг, КРТ/резервирование/network-обременения.
|
||||
|
||||
`site-finder/` (отдельная папка) — это **standalone-пайплайн SF v1**: скрипты `01_…12_*.py` (загрузка участков, fetch POI, скоринг v1/v2, отчёты) + `server.py` (Leaflet-UI поверх SQLite-кэша POI). Это исследовательский прекурсор прод-движка в `backend/`, не путать с прод-API.
|
||||
|
||||
---
|
||||
|
||||
## Trade-In «Мера» — оценка вторички
|
||||
|
||||
Отдельный продукт в `tradein-mvp/` (standalone стек: FastAPI + Next.js 15 + Postgres + Caddy, свой `deploy-tradein.yml`). Дефолтный бренд переименован «Trade-In» → **«Мера»** (white-label-бренды клиентов резолвятся по slug из таблицы `brands`; URL-пути `/trade-in/*` и code-идентификаторы сохранены).
|
||||
|
||||
**Поток:** адрес → геокодер (ДаДата) → подбор аналогов в радиусе (PostGIS `ST_DWithin`) → оценка → PDF-отчёт (WeasyPrint, layout по референсу «Брусника.Обмен»).
|
||||
|
||||
- **Источники аналогов**: скрейперы Avito / Cian / Domclick / Yandex.Realty (`backend/app/services/scrapers/*`, с детальными парсерами и `browser_fetcher`).
|
||||
- **Реальные сделки**: Rosreestr ДКП-поллинг (`rosreestr_poll.py`).
|
||||
- **Time-adjust**: SberIndex city-level вторичный индекс цен (`sber_index.py`) — приведение разновременных аналогов/сделок к текущему месяцу.
|
||||
- **Оценка** (`estimator.py`): SQL-агрегация поверх listings + deals, Tukey-фильтр выбросов (1.5×IQR), медиана/Q1/Q3 + confidence; больше **не** mock.
|
||||
- **API**: `POST /api/v1/trade-in/estimate`, `GET …/estimate/{id}`, `GET …/estimate/{id}/pdf`. Multi-tenant: `/api/v1/brand/{slug}`, RBAC через `auth/roles.yaml`.
|
||||
|
||||
Подробнее — [`tradein-mvp/README.md`](tradein-mvp/README.md) и [`tradein-mvp/DEPLOY.md`](tradein-mvp/DEPLOY.md).
|
||||
|
||||
---
|
||||
|
||||
## Production хостинг
|
||||
|
||||
**Beget Cloud, Москва, 2 vCPU / 4 GB / 40 GB NVMe.** Стоимость ~1 140 ₽/мес сервер + ~17 ₽/мес домен.
|
||||
|
|
@ -132,20 +180,16 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
|
|||
|
||||
---
|
||||
|
||||
## Roadmap (12 недель)
|
||||
## Текущий фокус (июнь 2026)
|
||||
|
||||
| Stage | Сроки | Что | Acceptance |
|
||||
|---|---|---|---|
|
||||
| **0** ✅ | нед. 1 | Скелет монорепо, CI, прод | curl /health = ok |
|
||||
| **Sprint 1** ✅ | конец апр 2026 | NSPD-скрейпер + per-bucket эластичность + кадастр cross-check + Sentry release tracking | recommend_mix Tier 3 в проде |
|
||||
| **1a** | нед. 2–3 | Shapely-сервис, отступы, API-контракт `/concept` ([#5](https://git.gendsgn.ru/lekss361/gendesign/issues/5)) | GeoJSON → 3 заглушки |
|
||||
| **1b** | нед. 4–5 | Greedy-алгоритм, 3 стратегии, STRtree ([#12](https://git.gendsgn.ru/lekss361/gendesign/issues/12)) | 3 варианта ≤15с на 1 га |
|
||||
| **1c** | нед. 6–7 | ТЭП + финмодель + PDF/Excel/DXF ([#6](https://git.gendsgn.ru/lekss361/gendesign/issues/6)) | Продаваемый артефакт |
|
||||
| **2a** | нед. 8–9 | ПКК-парсер + sales_tracker ([#7](https://git.gendsgn.ru/lekss361/gendesign/issues/7)) | ≥1000 участков ЕКБ |
|
||||
| **2b** | нед. 10–11 | Скоринг + API + UI Site Finder ([#8](https://git.gendsgn.ru/lekss361/gendesign/issues/8)) | Карта с цветовой градацией |
|
||||
| **2c** | нед. 12 | Site Finder ↔ Generative + реестр ПО ([#9](https://git.gendsgn.ru/lekss361/gendesign/issues/9)) | 3 платящих пилота |
|
||||
Исходный 12-недельный roadmap (Stage 0 → 2c, апрель–июнь) пройден: скелет/CI/прод, NSPD-скрейпер + per-bucket эластичность, ПКК-данные и скоринг участков — всё ✅. Site Finder вышел из стадии «заглушка» в боевой v2-движок, параллельно запущен Trade-In. Активные направления:
|
||||
|
||||
Аналитический backlog: [#17](https://git.gendsgn.ru/lekss361/gendesign/issues/17) velocity-anomaly, [#18](https://git.gendsgn.ru/lekss361/gendesign/issues/18) ekb_districts median refresh, [#19](https://git.gendsgn.ru/lekss361/gendesign/issues/19) competitor-360, [#20](https://git.gendsgn.ru/lekss361/gendesign/issues/20) mortgage-rate sensitivity, [#21](https://git.gendsgn.ru/lekss361/gendesign/issues/21) cad_buildings filters в Site Finder.
|
||||
- **Site Finder v2 «Foresight»** ([#944](https://git.gendsgn.ru/lekss361/gendesign/issues/944) GG-форсайт) — ИИ-прогноз продуктовой стратегии девелопера: спрос/предложение, сценарии, confidence, продуктовое ТЗ. Движок и фронт-секции отгружены, продолжается доведение точности и покрытия.
|
||||
- **ИРД** ([#1067](https://git.gendsgn.ru/lekss361/gendesign/issues/1067)) — исходно-разрешительная документация: overlay + analyze на участке.
|
||||
- **Trade-In «Мера»** — оценка вторички на реальных данных (скрейперы Avito/Cian/Domclick/Yandex + Rosreestr ДКП + SberIndex), расширение покрытия аналогов и detail-парсинга.
|
||||
- **Generative Design** (`/concept`) — на ранней стадии (skeleton), доводится после стабилизации SF/Trade-In.
|
||||
|
||||
Трекаемый backlog и приоритеты — [Forgejo Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) с лейблами `scope/*`, `вторичка`, `status/*`. North Star — см. секцию выше.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -212,6 +256,16 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
|
|||
|
||||
**Workflow:** тривиально (typo, 1-line) → main session; single-domain → профильный worker; cross-domain → `tech-analyst` сначала. Worker → `code-reviewer` → коммит → push → PR в Forgejo. Branch + PR обязательны, никаких direct push в main.
|
||||
|
||||
**Автономный bot-loop.** Помимо ручных subagent'ов есть набор автономных персон (`.claude/agents/auto-*.md`, status `draft`), которые крутятся каждая в отдельном Claude Code-окне на `/loop` и двигают задачи через лейблы `status/*` (ready → wip → review → qa → done):
|
||||
|
||||
- `auto-analyst` — декомпозирует work-items из vault/feedback в actionable Forgejo issues.
|
||||
- `auto-backend` / `auto-frontend` — claim issue `scope/*` → ветка + код + push + PR (`Refs #N`, не `Closes`).
|
||||
- `auto-code-reviewer` — читает diff, выносит verdict, мерджит при APPROVE (merge-authority).
|
||||
- `auto-qa-tester` — Playwright golden-path по `status/qa`, закрывает issue на `status/done`.
|
||||
- `auto-resolver` — снимает блокеры `needs-human`, используя capabilities, которых нет у headless-ботов (dev-IP, куки, SSH на прод, прямой доступ к БД).
|
||||
|
||||
`stale-claims.yml` авто-снимает протухшие claim-метки. Контракт claim/state-transition — `.claude/agents/_autonomous_pickup.md`.
|
||||
|
||||
---
|
||||
|
||||
## Полезные ссылки
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@ from logging.config import fileConfig
|
|||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# Import the models package so every ORM model registers on Base.metadata.
|
||||
# Add new model modules in app/models/__init__.py as they appear.
|
||||
import app.models # noqa: F401
|
||||
from alembic import context
|
||||
from app.core.config import settings
|
||||
from app.core.db import Base
|
||||
|
||||
# Import models so they register on Base.metadata.
|
||||
# Add new model modules here as they appear.
|
||||
from app.models import parcel # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
# Inject runtime DB URL.
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ def leads_stats(
|
|||
WITH window_leads AS (
|
||||
SELECT *
|
||||
FROM prinzip_leads
|
||||
WHERE created_at >= NOW() - (:m || ' months')::interval
|
||||
WHERE created_at >= NOW() - make_interval(months => :m)
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM prinzip_leads) AS leads_total,
|
||||
|
|
@ -149,8 +149,20 @@ def leads_stats(
|
|||
2
|
||||
) AS conv_pct_window,
|
||||
(SELECT COUNT(DISTINCT source) FROM prinzip_leads) AS sources_total,
|
||||
(SELECT SUM(deal_price) FROM prinzip_deals) AS revenue_total,
|
||||
(SELECT COUNT(*) FROM prinzip_deals) AS deals_total
|
||||
(
|
||||
SELECT SUM(d.deal_price)
|
||||
FROM prinzip_deals d
|
||||
WHERE d.deal_id IN (
|
||||
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
|
||||
)
|
||||
) AS revenue_total,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM prinzip_deals d
|
||||
WHERE d.deal_id IN (
|
||||
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
|
||||
)
|
||||
) AS deals_total
|
||||
FROM window_leads
|
||||
"""
|
||||
),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import logging
|
|||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -268,7 +268,7 @@ def revoke_task(
|
|||
def list_failures(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
run_id: int | None = None,
|
||||
limit: int = 50,
|
||||
limit: int = Query(default=50, ge=1),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Per-request failure log for manual browser verification."""
|
||||
where = ""
|
||||
|
|
@ -314,7 +314,7 @@ def list_logs(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
run_id: int | None = None,
|
||||
since_id: int | None = None,
|
||||
limit: int = 200,
|
||||
limit: int = Query(default=200, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Per-run progress events. Use since_id to poll incrementally:
|
||||
pass the highest log_id seen → returns only newer rows."""
|
||||
|
|
@ -483,7 +483,7 @@ def trigger_objective_etl(
|
|||
@router.get("/objective/runs")
|
||||
def list_objective_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 20,
|
||||
limit: int = Query(default=20, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
db.execute(
|
||||
|
|
@ -889,14 +889,20 @@ def bulk_enqueue_geo(
|
|||
|
||||
geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
|
||||
|
||||
# Валидируем ВСЕ thematic_ids ДО любых сайд-эффектов (создание jobs / apply_async),
|
||||
# иначе невалидный id в середине списка приводит к partial execution: для предыдущих
|
||||
# валидных id строки в nspd_geo_jobs уже созданы и задачи улетели в очередь geo,
|
||||
# а клиент получает 400 без идемпотентного отката (#1562).
|
||||
invalid_ids = [tid for tid in payload.thematic_ids if tid not in _THEMATIC_META]
|
||||
if invalid_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"thematic_id={invalid_ids} не поддерживается (допустимы: 1, 2, 5)",
|
||||
)
|
||||
|
||||
jobs_summary: list[dict[str, Any]] = []
|
||||
|
||||
for thematic_id in payload.thematic_ids:
|
||||
if thematic_id not in _THEMATIC_META:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"thematic_id={thematic_id} не поддерживается (допустимы: 1, 2, 5)",
|
||||
)
|
||||
meta = _THEMATIC_META[thematic_id]
|
||||
|
||||
# 1) Собрать cad-номера
|
||||
|
|
@ -967,7 +973,7 @@ def bulk_enqueue_geo(
|
|||
@router.get("/geo/jobs")
|
||||
def list_geo_jobs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 30,
|
||||
limit: int = Query(default=30, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Список последних geo-jobs (для UI dashboard)."""
|
||||
rows = (
|
||||
|
|
@ -1087,7 +1093,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
|
|||
def list_all_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
scraper_type: str | None = None,
|
||||
limit: int = 30,
|
||||
limit: int = Query(default=30, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Унифицированный список прогонов (kn + nspd + objective).
|
||||
|
||||
|
|
@ -1145,7 +1151,7 @@ def list_all_logs(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
scraper_type: str | None = None,
|
||||
run_id: int | None = None,
|
||||
limit: int = 200,
|
||||
limit: int = Query(default=200, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
|
||||
where: list[str] = []
|
||||
|
|
@ -1278,9 +1284,7 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
|
|||
]
|
||||
|
||||
|
||||
def _classify_freshness(
|
||||
age_days: float | None, fresh_days: float, stale_days: float
|
||||
) -> str:
|
||||
def _classify_freshness(age_days: float | None, fresh_days: float, stale_days: float) -> str:
|
||||
"""fresh / stale / critical / unknown по возрасту последнего успешного прогона."""
|
||||
if age_days is None:
|
||||
return "unknown"
|
||||
|
|
@ -1416,7 +1420,7 @@ def scrape_freshness(
|
|||
@router.get("/runs")
|
||||
def list_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: int = 20,
|
||||
limit: int = Query(default=20, ge=0),
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
db.execute(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
|
|||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from shapely import wkt as _shp_wkt
|
||||
from shapely.geometry import Polygon
|
||||
from sqlalchemy import text
|
||||
|
|
@ -77,6 +78,7 @@ from app.services.site_finder.quarter_dump_lookup import (
|
|||
get_quarter_dump_data,
|
||||
make_empty_result,
|
||||
)
|
||||
from app.services.site_finder.riasurt_lookup import parcel_riasurt_gate
|
||||
from app.services.site_finder.saturation import compute_district_saturation
|
||||
from app.services.site_finder.velocity import compute_velocity
|
||||
from app.services.site_finder.weight_profiles import (
|
||||
|
|
@ -145,10 +147,17 @@ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|||
|
||||
|
||||
def _score_label(s: float) -> str:
|
||||
"""Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ."""
|
||||
"""Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ.
|
||||
|
||||
Шкала: <5 плохо, 5-15 средне, 15-25 хорошо, >=25 отлично.
|
||||
"""
|
||||
if s < SCORE_THRESHOLDS["плохо"]:
|
||||
return "плохо"
|
||||
if s < SCORE_THRESHOLDS["средне"]:
|
||||
return "плохо" if s < SCORE_THRESHOLDS["плохо"] else "средне"
|
||||
return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично"
|
||||
return "средне"
|
||||
if s < SCORE_THRESHOLDS["хорошо"]:
|
||||
return "хорошо"
|
||||
return "отлично"
|
||||
|
||||
|
||||
def _confidence_label(c: float) -> str:
|
||||
|
|
@ -821,11 +830,20 @@ def _compute_confidence(
|
|||
market_trend: dict[str, Any] | None,
|
||||
zoning: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""X2 (#48) — composite confidence score 0..1 + caveats.
|
||||
"""X2 (#48) — composite confidence score 0..1 + caveats для site-finder analyze.
|
||||
|
||||
Stub-версия (до реализации G1/G2/D1/D2): использует сигналы которые уже
|
||||
доступны на main. Композитный балл = avg of subscore'ов; caveats — list
|
||||
конкретных проблем для UI ("Нет данных N, score K ненадёжен").
|
||||
Это НАДЁЖНОСТЬ САМОГО SITE-FINDER СКОРИНГА (coverage/свежесть входных
|
||||
сигналов: POI, район, рынок, конкуренты, источник геометрии) — отдельная
|
||||
by-design метрика, НЕ форсайтный §15 `forecasting.confidence_engine`
|
||||
(`compute_report_confidence`). §15 оценивает надёжность ПРОГНОЗА
|
||||
спроса/цены и работает над форсайт-входами (coverage/confounded/
|
||||
special_indices/sales_series), которых в analyze hot-path нет — поэтому
|
||||
analyze намеренно держит свою лёгкую coverage-метрику, а не зовёт §15.
|
||||
|
||||
Композитный балл = avg of subscore'ов; caveats — list конкретных проблем
|
||||
для UI ("Нет данных N, score K ненадёжен"). См. #1668 (решение: оставить
|
||||
раздельно by-design; «stub до G1/G2/D1/D2» — устаревшая формулировка,
|
||||
те эшелоны уже отгружены, их confidence сюда не вплетается намеренно).
|
||||
"""
|
||||
caveats: list[str] = []
|
||||
subscores: dict[str, float] = {}
|
||||
|
|
@ -2177,8 +2195,44 @@ def analyze_parcel(
|
|||
except Exception as e:
|
||||
logger.warning("red_lines_block query failed for %s: %s", cad_num, e)
|
||||
|
||||
# B5-4) Metro placeholder — заполнится после merge 22h metro scraper
|
||||
metro_block: dict[str, Any] = {"nearest_top3": None}
|
||||
# B5-4) Metro — ближайшие 3 станции метро к участку.
|
||||
# Данные уже в osm_poi_ekb (category='metro_stop', грузятся poi_loader.py:44),
|
||||
# скрапер не нужен — прямой KNN-запрос по geom <-> centroid участка.
|
||||
# nearest_top3=[] (НЕ None) когда метро не найдено: отличаем "посчитано, рядом
|
||||
# нет" от "не реализовано". None только при ошибке запроса.
|
||||
metro_block: dict[str, Any] = {"nearest_top3": []}
|
||||
try:
|
||||
with db.begin_nested():
|
||||
metro_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT name,
|
||||
ST_Distance(
|
||||
m.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
) AS dist_m
|
||||
FROM osm_poi_ekb m
|
||||
WHERE m.category = 'metro_stop'
|
||||
ORDER BY m.geom <-> ST_Centroid(ST_GeomFromText(:wkt, 4326))
|
||||
LIMIT 3
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
metro_block = {
|
||||
"nearest_top3": [
|
||||
{
|
||||
"name": mr["name"],
|
||||
"distance_m": round(float(mr["dist_m"])) if mr["dist_m"] is not None else None,
|
||||
}
|
||||
for mr in metro_rows
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("metro_block query failed for %s: %s", cad_num, e)
|
||||
metro_block = {"nearest_top3": None}
|
||||
|
||||
# B5-5) District price ranges из objective_lots (SF-B5)
|
||||
district_price_block: dict[str, Any] = {
|
||||
|
|
@ -2921,7 +2975,75 @@ def analyze_parcel(
|
|||
"risks": risks_block,
|
||||
}
|
||||
|
||||
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs.
|
||||
# #1561: forecast/ird/developer_attribution дописываются в result_payload ДО persist —
|
||||
# иначе persist_analysis_run сериализует jsonb-снимок (repository.py:124) и коммитит
|
||||
# (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
|
||||
# (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).
|
||||
|
||||
# §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы
|
||||
# jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический
|
||||
# enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть
|
||||
# ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не
|
||||
# посчитается, а ретраев у таски нет (regression #1561-followup). result_payload
|
||||
# передаётся в persist by-reference; если enqueue провалится после persist —
|
||||
# перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted
|
||||
# снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана).
|
||||
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
|
||||
|
||||
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
||||
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
||||
# источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful —
|
||||
# сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
|
||||
if settings.enable_ird_analyze:
|
||||
try:
|
||||
result_payload["ird"] = build_ird_analyze_block(
|
||||
db, geom_wkt, centroid_lon, centroid_lat, cad_num
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ird block failed for cad=%s — analyze response unaffected",
|
||||
cad_num,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# РИАСУРТ Свердл gate (#108, multi-city scaling): для участков АГЛОМЕРАЦИИ ЕКБ
|
||||
# (окраины — Берёзовский/В.Пышма/Среднеуральск/Арамиль/Сысерть), НЕ ЕКБ-сити (66:41).
|
||||
# Flag-gated (default off): таблица riasurt_sverdl наполняется post-deploy harvest'ом на
|
||||
# уточнённых bbox МО. Полностью graceful (parcel_riasurt_gate сам отдаёт пустой gate при
|
||||
# отсутствии таблицы / вне агломерации) — сбой не меняет успех/форму ответа (extra="allow").
|
||||
# Ключ добавляется только когда gate применим (applicable=True) — иначе не шумим.
|
||||
if settings.enable_riasurt_gate:
|
||||
try:
|
||||
_riasurt_gate = parcel_riasurt_gate(db, geom_wkt, cad_num)
|
||||
if _riasurt_gate.get("applicable"):
|
||||
result_payload.setdefault("gate", {})["riasurt"] = _riasurt_gate
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"riasurt gate failed for cad=%s — analyze response unaffected",
|
||||
cad_num,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
|
||||
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага
|
||||
# (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms,
|
||||
# резолвер ~30ms), без live-geoportal в hot-пути — низкий риск. get_developer_attribution
|
||||
# сам graceful (None при пустом матче / нет геометрии / pre-migration), но обёрнут в
|
||||
# try/except как остальные блоки — сбой не меняет успех/форму ответа (extra="allow"). Ключ
|
||||
# добавляется ТОЛЬКО при наличии застройщика (None → ключ отсутствует, не null-шум).
|
||||
try:
|
||||
_dev_attr = get_developer_attribution(db, cad_num)
|
||||
if _dev_attr is not None:
|
||||
result_payload["developer_attribution"] = _dev_attr.model_dump()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"developer_attribution block failed for cad=%s — analyze response unaffected",
|
||||
cad_num,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs — ПОСЛЕ дописывания
|
||||
# forecast/ird/developer_attribution, чтобы jsonb-снимок совпадал с live-ответом (#1561).
|
||||
# Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
|
||||
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
|
||||
# district денормализуем из result["district"]["district_name"] (для фильтрации
|
||||
|
|
@ -2952,20 +3074,22 @@ def analyze_parcel(
|
|||
created_by=x_authenticated_user,
|
||||
)
|
||||
|
||||
# §22-форсайт (3b-ii, #995): best-effort fire-and-forget enqueue после persist.
|
||||
# Таска `forecast_site_finder_report` читает только что сохранённый analyze-1.0
|
||||
# ран и в фоне (~30-180s) считает §22 SiteFinderReport ('1.0'). analyze НЕ ждёт
|
||||
# её — возвращаемся сразу. Celery/Redis down НЕ должен валить ответ (он уже успешен:
|
||||
# §22-форсайт enqueue — СТРОГО ПОСЛЕ persist_analysis_run. persist_analysis_run —
|
||||
# единственный commit analyze-рана (get_db() на success не коммитит), поэтому enqueue
|
||||
# должен случиться только после того, как ран закоммичен: иначе фоновая таска
|
||||
# `forecast_site_finder_report` (~30-180s) прочтёт latest_run_for и не найдёт свежий
|
||||
# ран (None/старый) → форсайт молча не посчитается, ретраев нет (#1561-followup).
|
||||
# Best-effort fire-and-forget: Celery/Redis down НЕ валит ответ (он уже успешен,
|
||||
# frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
|
||||
# Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
|
||||
try:
|
||||
from app.workers.tasks.forecast import forecast_site_finder_report
|
||||
|
||||
forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
|
||||
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
|
||||
except Exception:
|
||||
# Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
|
||||
# клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
|
||||
# persisted снимок остаётся "pending" (безвреден — poll читает live-статус рана).
|
||||
logger.warning(
|
||||
"forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
|
||||
cad_num,
|
||||
|
|
@ -2974,40 +3098,6 @@ def analyze_parcel(
|
|||
)
|
||||
result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
|
||||
|
||||
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
||||
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
||||
# источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful —
|
||||
# сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
|
||||
if settings.enable_ird_analyze:
|
||||
try:
|
||||
result_payload["ird"] = build_ird_analyze_block(
|
||||
db, geom_wkt, centroid_lon, centroid_lat, cad_num
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ird block failed for cad=%s — analyze response unaffected",
|
||||
cad_num,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
|
||||
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага
|
||||
# (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms,
|
||||
# резолвер ~30ms), без live-geoportal в hot-пути — низкий риск. get_developer_attribution
|
||||
# сам graceful (None при пустом матче / нет геометрии / pre-migration), но обёрнут в
|
||||
# try/except как остальные блоки — сбой не меняет успех/форму ответа (extra="allow"). Ключ
|
||||
# добавляется ТОЛЬКО при наличии застройщика (None → ключ отсутствует, не null-шум).
|
||||
try:
|
||||
_dev_attr = get_developer_attribution(db, cad_num)
|
||||
if _dev_attr is not None:
|
||||
result_payload["developer_attribution"] = _dev_attr.model_dump()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"developer_attribution block failed for cad=%s — analyze response unaffected",
|
||||
cad_num,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return result_payload
|
||||
|
||||
|
||||
|
|
@ -3249,8 +3339,10 @@ async def get_parcel_competitors(
|
|||
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
|
||||
участка с рассчитанным velocity_per_month за указанный time_window.
|
||||
"""
|
||||
# sync get_competitors (несколько db.execute, competitors.py:518) мостится через
|
||||
# run_in_threadpool — иначе sync DB-IO блокирует event loop (тот же приём, что в chat.py).
|
||||
try:
|
||||
return get_competitors(db=db, cad_num=cad_num, request=body)
|
||||
return await run_in_threadpool(get_competitors, db=db, cad_num=cad_num, request=body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
|
|
@ -3303,8 +3395,10 @@ async def get_parcel_best_layouts(
|
|||
Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
|
||||
× objective_complex_mapping).
|
||||
"""
|
||||
# sync get_best_layouts (db.execute, best_layouts.py:377) мостится через
|
||||
# run_in_threadpool — иначе sync DB-IO блокирует event loop.
|
||||
try:
|
||||
return get_best_layouts(db=db, cad_num=cad_num, request=body)
|
||||
return await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
|
|
@ -3322,9 +3416,12 @@ async def get_parcel_best_layouts_pdf(
|
|||
|
||||
Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера.
|
||||
"""
|
||||
# sync get_best_layouts (DB-IO) + render_layout_tz_pdf (CPU-bound WeasyPrint
|
||||
# write_pdf, сотни мс) мостятся через run_in_threadpool — иначе блокируют event loop.
|
||||
try:
|
||||
response = get_best_layouts(db=db, cad_num=cad_num, request=body)
|
||||
pdf_bytes = render_layout_tz_pdf(
|
||||
response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
|
||||
pdf_bytes = await run_in_threadpool(
|
||||
render_layout_tz_pdf,
|
||||
response,
|
||||
cad_num=cad_num,
|
||||
radius_km=body.radius_km,
|
||||
|
|
|
|||
|
|
@ -111,16 +111,25 @@ def get_photo(
|
|||
data = _fetch_upstream(upstream)
|
||||
if data:
|
||||
src = _persist_original(obj_id, file_id, photo_name, data)
|
||||
# Record the original immediately so a failed thumbnail does not
|
||||
# orphan the on-disk file and trigger an eternal re-fetch.
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE domrf_kn_photos"
|
||||
" SET local_path = :lp, downloaded_at = NOW()"
|
||||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||||
),
|
||||
{"lp": str(src), "o": obj_id, "f": file_id},
|
||||
)
|
||||
db.commit()
|
||||
generated = make_thumbnail(src)
|
||||
if generated and generated.exists():
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE domrf_kn_photos"
|
||||
" SET local_path = :lp, thumb_path = :tp,"
|
||||
" downloaded_at = NOW()"
|
||||
"UPDATE domrf_kn_photos SET thumb_path = :tp"
|
||||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||||
),
|
||||
{"lp": str(src), "tp": str(generated), "o": obj_id, "f": file_id},
|
||||
{"tp": str(generated), "o": obj_id, "f": file_id},
|
||||
)
|
||||
db.commit()
|
||||
return FileResponse(str(generated), media_type="image/webp", headers=headers)
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ def estimate_pdf(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="estimate not found")
|
||||
|
||||
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
|
||||
if row.expires_at.astimezone(UTC) < datetime.now(tz=UTC):
|
||||
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
|
||||
|
||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||
|
|
|
|||
|
|
@ -190,6 +190,23 @@ class Settings(BaseSettings):
|
|||
# возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
|
||||
enable_ird_analyze: bool = True
|
||||
|
||||
# РИАСУРТ Свердл gate в analyze (#108, multi-city scaling): поле `gate.riasurt` в ответе
|
||||
# analyze — пересечения участка с зонами РИАСУРТ Свердл (тер.зона/функц.зона/красные линии/
|
||||
# СЗЗ/ЗСО/затопление/КРТ). ТОЛЬКО для участков в агломерации ЕКБ, НЕ в самом ЕКБ-сити
|
||||
# (is_in_aglomeration_but_not_ekb). По умолчанию OFF: таблица riasurt_sverdl наполняется
|
||||
# post-deploy harvest'ом на уточнённых bbox МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO).
|
||||
# Override через env ENABLE_RIASURT_GATE=true.
|
||||
enable_riasurt_gate: bool = False
|
||||
|
||||
# РИАСУРТ Свердл harvest kill-switch (#108 review): ежеквартальный beat
|
||||
# `harvest_all_riasurt_sverdl` прогоняет grid-walk по MO_BBOXES. Эти bbox — ПЛЕЙСХОЛДЕРЫ
|
||||
# (грубые ±6 км вокруг центров МО), реальные административные границы резолвятся post-deploy.
|
||||
# По умолчанию OFF, иначе beat на следующем тике дёргает WMS с мусорными bbox.
|
||||
# Включать ТОЛЬКО после замены MO_BBOXES на реальные границы. Override через
|
||||
# env ENABLE_RIASURT_HARVEST=true. Single-MO harvest_riasurt_sverdl_for_mo не гейтится
|
||||
# (callable вручную для smoke-тестирования конкретного bbox).
|
||||
enable_riasurt_harvest: bool = False
|
||||
|
||||
# ── LLM infrastructure (#960) ────────────────────────────────────────────
|
||||
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
|
||||
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ if settings.glitchtip_dsn:
|
|||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||
profiles_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send=scrub_sensitive_query,
|
||||
before_send_transaction=scrub_sensitive_query,
|
||||
integrations=[
|
||||
StarletteIntegration(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
"""ORM model registry.
|
||||
|
||||
Importing this package loads every SQLAlchemy model module so the classes
|
||||
register on `Base.metadata`. Alembic (alembic/env.py) imports `app.models`
|
||||
to populate `target_metadata` for autogenerate / drift detection.
|
||||
|
||||
Add new ORM model modules to the imports below as they appear. Tables managed
|
||||
by raw SQL (data/sql/*.sql, e.g. cad_parcels / cad_parcels_geom) have no ORM
|
||||
model by design and intentionally stay out of Base.metadata.
|
||||
"""
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models import job_settings, parcel # noqa: F401
|
||||
|
||||
__all__ = ["Base"]
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text
|
||||
from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
|
@ -20,6 +20,8 @@ class JobSetting(Base):
|
|||
max_retries: Mapped[int] = mapped_column(SmallInteger, default=2, nullable=False)
|
||||
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
|
||||
extra_config: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=text("now()"), nullable=False
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,20 @@
|
|||
"""SQLAlchemy + GeoAlchemy2 ORM models.
|
||||
"""SQLAlchemy ORM models for parcel data.
|
||||
|
||||
Stage 2a: real Parcel model. Geometry stored in WGS84 (EPSG:4326);
|
||||
project to МСК-66 via pyproj when computing distances/areas.
|
||||
NB: реальные данные участков живут в таблицах ``cad_parcels`` /
|
||||
``cad_parcels_geom`` (см. ``data/sql/92_cad_bulk_layers.sql`` и
|
||||
``data/sql/83_cad_parcels_geom.sql``) и читаются через сырые PostGIS-запросы
|
||||
(``app.services.site_finder.filters``, ``app.api.v1.parcels``), не через ORM.
|
||||
|
||||
Прежняя ORM-модель ``Parcel`` (таблица ``parcels``) удалена: соответствующего
|
||||
DDL в ``data/sql/`` нет, ни один ORM-запрос её не использовал, а её колонки
|
||||
(в т.ч. ``geometry(POLYGON, 4326)``) расходились с реальной схемой
|
||||
``cad_parcels.geom`` — которую миграция 93 уже перевела на ``MultiPolygon``,
|
||||
т.к. НСПД отдаёт многоконтурные участки. Единственным её потребителем был
|
||||
alembic autogenerate (``alembic/env.py``), для которого она порождала
|
||||
фантомный ``CREATE TABLE parcels``.
|
||||
|
||||
Модуль сохранён (его импортирует ``alembic/env.py``), чтобы новые ORM-модели
|
||||
регистрировались на ``Base.metadata`` именно отсюда.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import JSON, DateTime, Float, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class Parcel(Base):
|
||||
__tablename__ = "parcels"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
cadastral_number: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
vri: Mapped[str] = mapped_column(String, index=True)
|
||||
area_sqm: Mapped[float] = mapped_column(Float)
|
||||
address: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
geometry: Mapped[object] = mapped_column(Geometry("POLYGON", srid=4326))
|
||||
enrichment: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
from app.core.db import Base # noqa: F401 (re-export для регистрации будущих моделей)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
|||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
|
||||
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
|
||||
|
|
@ -84,10 +84,21 @@ class ChatAskRequest(BaseModel):
|
|||
)
|
||||
history: list[ChatTurn] | None = Field(
|
||||
default=None,
|
||||
max_length=_HISTORY_MAX_TURNS,
|
||||
description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)",
|
||||
)
|
||||
|
||||
@field_validator("history")
|
||||
@classmethod
|
||||
def _cap_history(cls, v: list[ChatTurn] | None) -> list[ChatTurn] | None:
|
||||
"""Graceful-усечение: кэпируем хвост до последних _HISTORY_MAX_TURNS ходов.
|
||||
|
||||
НЕ отклоняем длинный диалог 422 (контракт обещает усечение, не отказ) —
|
||||
оставляем самые свежие ходы, отбрасывая старые с головы.
|
||||
"""
|
||||
if v is not None and len(v) > _HISTORY_MAX_TURNS:
|
||||
return v[-_HISTORY_MAX_TURNS:]
|
||||
return v
|
||||
|
||||
|
||||
class ChatAskResponse(BaseModel):
|
||||
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ class ConceptInput(BaseModel):
|
|||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||||
target_floors: int = Field(9, ge=1, le=30)
|
||||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
||||
land_cost_rub: float | None = Field(None, description="Optional land cost for financial model")
|
||||
land_cost_rub: float | None = Field(
|
||||
None, ge=0, description="Optional land cost for financial model"
|
||||
)
|
||||
|
||||
|
||||
class TEAP(BaseModel):
|
||||
|
|
|
|||
|
|
@ -302,10 +302,18 @@ class NSPDBulkClient:
|
|||
|
||||
try:
|
||||
data = await self._get_json(NSPD_SEARCH_URL, params=params)
|
||||
except (NspdBulkWafError, NspdBulkRateLimitError, NspdBulkServerError):
|
||||
# 403 WAF / 429 / 5xx+ServiceException — НЕ «квартал не найден».
|
||||
# Пробрасываем как есть: caller (autoretry) ретраит квартал, WAF
|
||||
# останавливает harvest. Раньше подстрочная классификация по str(e)
|
||||
# с подмешанным body_preview могла ложно проглотить 5xx как 404.
|
||||
raise
|
||||
except NspdBulkError as e:
|
||||
# 404 или пустой ответ → возвращаем пустой snapshot
|
||||
# Остаётся базовый NspdBulkError = прочие 4xx (см. _get_json:234).
|
||||
# Текст: f"HTTP {code}: {url} — {body_preview}" → классифицируем по
|
||||
# ПРЕФИКСУ (код до URL), а не по вхождению в произвольное тело ответа.
|
||||
err_str = str(e)
|
||||
if "HTTP 404" in err_str or "HTTP 400" in err_str:
|
||||
if err_str.startswith("HTTP 404:") or err_str.startswith("HTTP 400:"):
|
||||
logger.info(
|
||||
"search_by_quarter: quarter %s not found (404/400), returning empty"
|
||||
" (category_id=%s)",
|
||||
|
|
@ -342,8 +350,23 @@ class NSPDBulkClient:
|
|||
for m in raw_meta:
|
||||
cat_id = m.get("categoryId")
|
||||
total = m.get("totalCount")
|
||||
if cat_id is not None and total is not None:
|
||||
if cat_id is None or total is None:
|
||||
continue
|
||||
# NSPD изредка отдаёт categoryId/totalCount нечисловой/float-строкой
|
||||
# ('20.0') или иным типом → голый int() кинул бы ValueError/TypeError,
|
||||
# который НЕ подкласс NspdBulkError и завалил бы всю Phase 1 квартала.
|
||||
# Зеркалит защитный try/except в schemas/nspd_bulk.py и
|
||||
# list_objects_in_building. Битую meta-запись просто пропускаем.
|
||||
try:
|
||||
meta_counts[int(cat_id)] = int(total)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"search_by_quarter: non-numeric meta entry quarter=%s"
|
||||
" categoryId=%r totalCount=%r — skipping",
|
||||
quarter,
|
||||
cat_id,
|
||||
total,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
|
||||
|
|
@ -416,7 +439,19 @@ class NSPDBulkClient:
|
|||
}
|
||||
|
||||
data = await self._get_json(url, params=params)
|
||||
raw_features: list[dict[str, Any]] = (data or {}).get("features") or []
|
||||
# NSPD/GeoServer изредка отдаёт валидный JSON, но не объект (list/str —
|
||||
# Bug_Nspd_Geo_Str_Object). Тогда `(data or {})` вернул бы сам truthy
|
||||
# data, а .get("features") кинул бы AttributeError (не NspdBulkError →
|
||||
# уронил бы ячейку grid-walk без сигнала). Унифицируем guard с
|
||||
# search_by_quarter: не-dict трактуем как пустой ответ.
|
||||
if not isinstance(data, dict):
|
||||
logger.warning(
|
||||
"wms_feature_info: non-dict JSON response layer=%d type=%s — returning empty",
|
||||
layer_id,
|
||||
type(data).__name__,
|
||||
)
|
||||
return []
|
||||
raw_features: list[dict[str, Any]] = data.get("features") or []
|
||||
return [NSPDBulkFeature.model_validate(f) for f in raw_features]
|
||||
|
||||
# ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
|
||||
|
|
@ -473,9 +508,39 @@ class NSPDBulkClient:
|
|||
|
||||
seen_ids: set[str] = set()
|
||||
results: list[dict] = []
|
||||
# Issue #252-mirror: считаем server-side провалы и успешные ячейки, чтобы
|
||||
# отличить «слой реально пуст» (ok_cells>0, 0 features) от «слой/IP лёг»
|
||||
# (все ячейки 5xx/WAF). Раньше любой Exception молча падал на DEBUG и метод
|
||||
# отдавал [] → в БД писался ложный tz_count=0 без layer_failed-сигнала.
|
||||
server_errors = 0
|
||||
ok_cells = 0
|
||||
first_server_error: NspdBulkServerError | None = None
|
||||
|
||||
for idx, cell_result in enumerate(cell_results):
|
||||
if isinstance(cell_result, NspdBulkWafError):
|
||||
# 403 WAF (бан IP) — по docstring должен ОСТАНОВИТЬ harvest, не
|
||||
# маскироваться пустым результатом. Пробрасываем немедленно.
|
||||
logger.warning(
|
||||
"get_features_in_bbox_grid: layer=%d cell=%d WAF 403 — aborting grid-walk: %s",
|
||||
layer_id,
|
||||
idx,
|
||||
cell_result,
|
||||
)
|
||||
raise cell_result
|
||||
if isinstance(cell_result, NspdBulkServerError):
|
||||
server_errors += 1
|
||||
if first_server_error is None:
|
||||
first_server_error = cell_result
|
||||
logger.debug(
|
||||
"get_features_in_bbox_grid: layer=%d cell=%d server error: %s",
|
||||
layer_id,
|
||||
idx,
|
||||
cell_result,
|
||||
)
|
||||
continue
|
||||
if isinstance(cell_result, Exception):
|
||||
# Прочие (сетевые/parse) ошибки одной ячейки — не валим обход и НЕ
|
||||
# считаем server-side fail (иначе сеть ложно triggers layer_failed).
|
||||
logger.debug(
|
||||
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
|
||||
layer_id,
|
||||
|
|
@ -483,6 +548,7 @@ class NSPDBulkClient:
|
|||
cell_result,
|
||||
)
|
||||
continue
|
||||
ok_cells += 1
|
||||
for feature in cell_result:
|
||||
fid = str(feature.id) if feature.id is not None else ""
|
||||
if fid and fid in seen_ids:
|
||||
|
|
@ -497,6 +563,21 @@ class NSPDBulkClient:
|
|||
}
|
||||
)
|
||||
|
||||
# Если БЫЛИ server-side провалы И ни одна ячейка не прошла — слой/NSPD лёг
|
||||
# целиком. Возврат [] здесь означал бы ложный tz_count=0 («зонирование
|
||||
# отсутствует»). Пробрасываем server-error, чтобы caller отличил сбой от
|
||||
# реально пустого слоя (мирроринг _grid_walk_category.layer_failed).
|
||||
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
|
||||
logger.warning(
|
||||
"get_features_in_bbox_grid: layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
|
||||
"(%d server errors, 0 ok cells) — raising вместо ложного пустого результата",
|
||||
layer_id,
|
||||
grid_n,
|
||||
grid_n,
|
||||
server_errors,
|
||||
)
|
||||
raise first_server_error
|
||||
|
||||
logger.info(
|
||||
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
|
||||
layer_id,
|
||||
|
|
|
|||
|
|
@ -64,8 +64,15 @@ def _jsonb_param(value: Any) -> str:
|
|||
|
||||
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
|
||||
json.dumps(..., ensure_ascii=False) — кириллица как есть (зеркало pzz_loader).
|
||||
|
||||
allow_nan=False (#1580): дефолтный allow_nan=True выводит нестандартные литералы
|
||||
NaN/Infinity/-Infinity, которые JSONB-парсер PostgreSQL отвергает ("invalid input
|
||||
syntax for type json") → INSERT падает, а broad-except в persist_analysis_run
|
||||
проглатывает это и теряет ран молча. С allow_nan=False json.dumps бросает ValueError
|
||||
ДО SQL — провал детерминирован и виден в logger.exception (с traceback), а не
|
||||
маскируется под невнятную psycopg-ошибку синтаксиса.
|
||||
"""
|
||||
return json.dumps(jsonable_encoder(value), ensure_ascii=False)
|
||||
return json.dumps(jsonable_encoder(value), ensure_ascii=False, allow_nan=False)
|
||||
|
||||
|
||||
def persist_analysis_run(
|
||||
|
|
|
|||
|
|
@ -24,11 +24,12 @@ list rather than silently ignored.
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -54,6 +55,11 @@ _SUPPORTED_METHODS = (_CALC_BASIS, _CALC_PREVIOUS)
|
|||
# as unsupported.
|
||||
_SUPPORTED_SUBJECT = "66"
|
||||
|
||||
# ARN period_value format (matches mv_ddu_price_indicator.period_value, e.g.
|
||||
# '2026-Q1'). Used to reject malformed bounds whose lexicographic comparison
|
||||
# against well-formed period_value would silently drop rows.
|
||||
_PERIOD_RE = re.compile(r"^\d{4}-Q[1-4]$")
|
||||
|
||||
|
||||
def _f(value: Any) -> float | None:
|
||||
if value is None:
|
||||
|
|
@ -148,6 +154,50 @@ def get_ddu_indicator(
|
|||
if clean_buckets:
|
||||
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
|
||||
params["buckets"] = clean_buckets
|
||||
else:
|
||||
# Client narrowed by area buckets but every id is out of range (0..6)
|
||||
# → honour the explicit narrowing with an empty result, never silently
|
||||
# widen back to all buckets. Mirrors the `not subject_ok` branch above.
|
||||
notes.append(
|
||||
f"areaRanges={area_ranges} вне диапазона 0..6 "
|
||||
f"(0=все площади, 1..6=диапазоны м²) — нет подходящих площадей."
|
||||
)
|
||||
return {
|
||||
"meta": {
|
||||
"market": "primary_ddu",
|
||||
"region_code": int(_SUPPORTED_SUBJECT),
|
||||
"calculation_method": method,
|
||||
"period_type": "Q",
|
||||
},
|
||||
"table": [],
|
||||
"graph": [],
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
# Validate period bounds against the documented ARN 'YYYY-QN' format before
|
||||
# binding them. period_value is compared lexicographically (it is text); a
|
||||
# malformed bound ('foo', '2026') would silently drop rows, so drop the bad
|
||||
# bound and explain it in notes (this endpoint's convention is notes, not 422).
|
||||
if period_from and not _PERIOD_RE.match(period_from):
|
||||
notes.append(
|
||||
f"periodFrom={period_from!r} не в формате 'YYYY-QN' (напр. '2025-Q2') "
|
||||
f"— граница проигнорирована."
|
||||
)
|
||||
period_from = None
|
||||
if period_to and not _PERIOD_RE.match(period_to):
|
||||
notes.append(
|
||||
f"periodTo={period_to!r} не в формате 'YYYY-QN' (напр. '2026-Q1') "
|
||||
f"— граница проигнорирована."
|
||||
)
|
||||
period_to = None
|
||||
# Inverted range (from > to) yields an empty table with no signal otherwise.
|
||||
# Lexicographic comparison is correct here because the format is zero-padded
|
||||
# 'YYYY-QN'.
|
||||
if period_from and period_to and period_from > period_to:
|
||||
notes.append(
|
||||
f"periodFrom={period_from!r} > periodTo={period_to!r} — границы "
|
||||
f"перепутаны (диапазон инвертирован), результат пуст."
|
||||
)
|
||||
|
||||
period_filter = ""
|
||||
if period_from:
|
||||
|
|
@ -184,8 +234,10 @@ def get_ddu_indicator(
|
|||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except OperationalError:
|
||||
except (ProgrammingError, OperationalError):
|
||||
# Most likely the MV does not exist yet (migration 152 not applied).
|
||||
# A missing relation is SQLSTATE 42P01 (UndefinedTable) → ProgrammingError;
|
||||
# OperationalError is kept for connection-level failures.
|
||||
logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)")
|
||||
raise
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ def detect_velocity_anomalies(
|
|||
AVG(realised) FILTER (WHERE rn <= :recent_window) AS recent_mean,
|
||||
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
|
||||
STDDEV_SAMP(realised) FILTER (WHERE rn > :recent_window) AS prior_std,
|
||||
COUNT(*) FILTER (WHERE rn > :recent_window) AS prior_n
|
||||
COUNT(realised) FILTER (WHERE rn > :recent_window) AS prior_n
|
||||
FROM ranked
|
||||
GROUP BY obj_id, n_months
|
||||
),
|
||||
|
|
|
|||
|
|
@ -79,46 +79,83 @@ def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
|
|||
|
||||
|
||||
def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]:
|
||||
"""source: 'portfolio' (что строится) or 'deals' (реально покупают)."""
|
||||
"""source: 'portfolio' (что строится) or 'deals' (реально покупают).
|
||||
|
||||
Оба источника возвращают одни и те же 5 area-бакетов, выровненных с осью
|
||||
QuartirographyChart ("Студии 15-30" / "1-к 30-45" / "2-к 45-60" /
|
||||
"3-к 60-80" / "80+ м²").
|
||||
|
||||
Портфель: domrf_flat_area_distribution (RF-wide snapshot, region_id=0 т.к.
|
||||
API DOM.РФ игнорирует ?regionId на этом эндпоинте). Бакеты DOM.РФ (FROM_0_TO_25
|
||||
… FROM_100) маппируются на chart-бакеты по средней площади:
|
||||
FROM_0_TO_25 (~22 м²) → "Студии 15-30"
|
||||
FROM_25_TO_35 (~31 м², мелкие однушки) → "1-к 30-45"
|
||||
FROM_35_TO_45 (~40 м²) → "1-к 30-45"
|
||||
FROM_45_TO_55 (~50 м²) → "2-к 45-60"
|
||||
FROM_55_TO_70 (~62 м²) → "3-к 60-80"
|
||||
FROM_70_TO_85 (~77 м²) → "3-к 60-80"
|
||||
FROM_85_TO_100 (~92 м²) → "80+ м²"
|
||||
FROM_100 (100+ м²) → "80+ м²"
|
||||
"""
|
||||
if source == "portfolio":
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT room_count_type, flat_count, area_sqm, percent
|
||||
FROM domrf_region_aggregates
|
||||
WHERE region_id = :region_id
|
||||
AND snapshot_date = (
|
||||
SELECT MAX(snapshot_date)
|
||||
FROM domrf_region_aggregates
|
||||
WHERE region_id = :region_id
|
||||
)
|
||||
AND room_count_type <> 'TOTAL'
|
||||
ORDER BY CASE room_count_type
|
||||
WHEN 'ONE' THEN 1
|
||||
WHEN 'TWO' THEN 2
|
||||
WHEN 'THREE' THEN 3
|
||||
WHEN 'FOUR' THEN 4
|
||||
END
|
||||
WITH latest AS (
|
||||
SELECT MAX(snapshot_date) AS snap
|
||||
FROM domrf_flat_area_distribution
|
||||
WHERE region_id = 0
|
||||
),
|
||||
mapped AS (
|
||||
SELECT
|
||||
CASE area_bucket
|
||||
WHEN 'FROM_0_TO_25' THEN '1-Студия'
|
||||
WHEN 'FROM_25_TO_35' THEN '2-1-к'
|
||||
WHEN 'FROM_35_TO_45' THEN '2-1-к'
|
||||
WHEN 'FROM_45_TO_55' THEN '3-2-к'
|
||||
WHEN 'FROM_55_TO_70' THEN '4-3-к'
|
||||
WHEN 'FROM_70_TO_85' THEN '4-3-к'
|
||||
WHEN 'FROM_85_TO_100' THEN '5-80+ м²'
|
||||
WHEN 'FROM_100' THEN '5-80+ м²'
|
||||
END AS chart_bucket,
|
||||
flat_count,
|
||||
area_sqm
|
||||
FROM domrf_flat_area_distribution
|
||||
CROSS JOIN latest
|
||||
WHERE region_id = 0
|
||||
AND room_count_type = 'TOTAL'
|
||||
AND snapshot_date = latest.snap
|
||||
)
|
||||
SELECT chart_bucket,
|
||||
SUM(flat_count)::bigint AS flat_count,
|
||||
SUM(area_sqm) AS area_sqm
|
||||
FROM mapped
|
||||
WHERE chart_bucket IS NOT NULL
|
||||
GROUP BY chart_bucket
|
||||
ORDER BY chart_bucket
|
||||
"""
|
||||
),
|
||||
{"region_id": region_id},
|
||||
{},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
_pretty = {
|
||||
"1-Студия": "Студии 15-30",
|
||||
"2-1-к": "1-к 30-45",
|
||||
"3-2-к": "2-к 45-60",
|
||||
"4-3-к": "3-к 60-80",
|
||||
"5-80+ м²": "80+ м²",
|
||||
}
|
||||
total_flats = sum(r["flat_count"] or 0 for r in rows) or 1
|
||||
return [
|
||||
{
|
||||
"bucket": {
|
||||
"ONE": "1-к",
|
||||
"TWO": "2-к",
|
||||
"THREE": "3-к",
|
||||
"FOUR": "4+",
|
||||
}.get(r["room_count_type"], r["room_count_type"]),
|
||||
"flat_count": r["flat_count"],
|
||||
"bucket": _pretty[r["chart_bucket"]],
|
||||
"flat_count": int(r["flat_count"] or 0),
|
||||
"area_sqm": _f(r["area_sqm"]),
|
||||
"percent": r["percent"],
|
||||
"avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None,
|
||||
"percent": round((r["flat_count"] or 0) * 100 / total_flats, 1),
|
||||
"avg_area": (_f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
|
@ -147,8 +184,7 @@ def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[s
|
|||
-- ('2025-07-01' расширял «recent»-окно каждую неделю по мере
|
||||
-- доливки ETL новых report_months → перекос в сторону всё
|
||||
-- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL.
|
||||
AND period_start_date >= NOW()
|
||||
- (:months_window || ' months')::INTERVAL
|
||||
AND period_start_date >= NOW() - make_interval(months => :months_window)
|
||||
),
|
||||
bucketed AS (
|
||||
SELECT CASE
|
||||
|
|
@ -1132,7 +1168,7 @@ def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]
|
|||
"""
|
||||
SELECT month, source, leads, engaged, converted, conv_pct
|
||||
FROM prinzip_funnel_monthly
|
||||
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
|
||||
WHERE month >= (CURRENT_DATE - make_interval(months => :months))::date
|
||||
ORDER BY month DESC, leads DESC
|
||||
"""
|
||||
),
|
||||
|
|
@ -1166,7 +1202,7 @@ def prinzip_funnel_by_source(db: Session, months: int = 12) -> list[dict[str, An
|
|||
SUM(converted) AS converted,
|
||||
ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct
|
||||
FROM prinzip_funnel_monthly
|
||||
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
|
||||
WHERE month >= (CURRENT_DATE - make_interval(months => :months))::date
|
||||
GROUP BY source
|
||||
ORDER BY leads DESC
|
||||
"""
|
||||
|
|
@ -1327,8 +1363,7 @@ _BUCKET_SQL = text(
|
|||
AND deal_count > 0
|
||||
AND (area / deal_count) BETWEEN 15 AND 200
|
||||
AND price_per_sqm BETWEEN 30000 AND 1000000
|
||||
AND period_start_date >= NOW()
|
||||
- (:months_window || ' months')::INTERVAL
|
||||
AND period_start_date >= NOW() - make_interval(months => :months_window)
|
||||
),
|
||||
bucketed AS (
|
||||
SELECT CASE
|
||||
|
|
@ -1958,7 +1993,7 @@ def _elasticity_coef(
|
|||
{where_district}
|
||||
{where_class}
|
||||
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
|
||||
AND crm.report_month >= NOW() - (:ew || ' months')::interval
|
||||
AND crm.report_month >= NOW() - make_interval(months => :ew)
|
||||
)
|
||||
SELECT
|
||||
regr_slope(y, x) AS slope,
|
||||
|
|
@ -2049,7 +2084,7 @@ def _elasticity_per_bucket_coef(
|
|||
{where_class}
|
||||
AND crm.deals_total_count > 0
|
||||
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
|
||||
AND crm.report_month >= NOW() - (:ew || ' months')::interval
|
||||
AND crm.report_month >= NOW() - make_interval(months => :ew)
|
||||
)
|
||||
SELECT bucket,
|
||||
regr_slope(y, x) AS slope,
|
||||
|
|
@ -2632,9 +2667,7 @@ def recommend_mix(
|
|||
else:
|
||||
# Rosreestr fallback aggregate: district+class deals / months / n_comp =
|
||||
# среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
|
||||
market_vel_pm = (
|
||||
(total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
|
||||
)
|
||||
market_vel_pm = (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
|
||||
warnings.append(
|
||||
f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
|
||||
f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."
|
||||
|
|
@ -2786,6 +2819,25 @@ def recommend_mix(
|
|||
if b["bucket"] != top_bucket_name:
|
||||
b["share_pct"] = round(b["share_pct"] * scale, 1)
|
||||
|
||||
# #1576: success-boost изменил share_pct → средневзвешенная цена
|
||||
# должна пересчитаться под новые доли, иначе weighted_avg_price
|
||||
# остаётся от ДО-boost микса (пробел в фиксе #1359, который
|
||||
# обновлял только units/revenue). price_median_per_m2 уже включает
|
||||
# combined_price_factor (line 2743), поэтому домножать НЕ нужно —
|
||||
# это согласовано с per-bucket revenue ниже. Веса = area_avg×share,
|
||||
# независимо от area_total_m2, поэтому вне ветки area_total_m2.
|
||||
wnum = sum(
|
||||
b["_area_avg_raw"] * b["share_pct"] * b["price_median_per_m2"]
|
||||
for b in buckets
|
||||
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
|
||||
)
|
||||
wden = sum(
|
||||
b["_area_avg_raw"] * b["share_pct"]
|
||||
for b in buckets
|
||||
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
|
||||
)
|
||||
weighted_avg_price = round(wnum / wden, 2) if wden > 0 else None
|
||||
|
||||
# #1359: success-boost изменил share_pct → перераспределяем
|
||||
# units/revenue/months_to_sellout и агрегаты под новые доли,
|
||||
# иначе share_pct рассогласуется с units_planned/revenue/sellout
|
||||
|
|
|
|||
|
|
@ -247,21 +247,11 @@ async def harvest_quarter(
|
|||
db.commit()
|
||||
update_progress(done_progress)
|
||||
|
||||
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
|
||||
# Выполняем после основного grid-walk (Phase 2-3). Требует bbox квартала.
|
||||
quarter_bbox = quarter_bbox_3857(db, quarter)
|
||||
if quarter_bbox is not None:
|
||||
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
|
||||
try:
|
||||
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
|
||||
tz_count = _save_territorial_zones(db, quarter, tz_features)
|
||||
logger.info(
|
||||
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
|
||||
|
||||
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
|
||||
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
|
||||
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
|
||||
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
|
||||
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
|
||||
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
||||
if stats_features:
|
||||
upsert_quarter_stats(db, quarter, stats_features[0])
|
||||
|
|
@ -276,6 +266,22 @@ async def harvest_quarter(
|
|||
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
|
||||
db.commit()
|
||||
|
||||
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
|
||||
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
|
||||
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
|
||||
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
|
||||
quarter_bbox = quarter_bbox_3857(db, quarter)
|
||||
if quarter_bbox is not None:
|
||||
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
|
||||
try:
|
||||
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
|
||||
tz_count = _save_territorial_zones(db, quarter, tz_features)
|
||||
logger.info(
|
||||
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
|
||||
|
||||
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
|
||||
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
||||
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
||||
|
|
@ -344,6 +350,11 @@ async def _grid_walk_category(
|
|||
|
||||
grid_points = generate_grid_click_points(bbox, grid_size=grid_size, tile_size=tile_size)
|
||||
|
||||
# Bug #1584: считаем discovered ТОЛЬКО для таблицы запрошенного layer_id, а не
|
||||
# sum(stats.values()). Иначе skipped/чужекатегорийные features завышают счётчик,
|
||||
# ошибочно приписываясь к parcels/buildings вызывающим (harvest_quarter:230-233).
|
||||
layer_count_key = "parcels" if layer_id == CAT_PARCEL else "buildings"
|
||||
|
||||
discovered_cads: set[str] = set()
|
||||
upserted = 0
|
||||
requests = 0
|
||||
|
|
@ -411,7 +422,7 @@ async def _grid_walk_category(
|
|||
try:
|
||||
with db.begin_nested():
|
||||
stats = upsert_features(db, [feature], source="wms_grid_walk")
|
||||
upserted += sum(stats.values())
|
||||
upserted += stats[layer_count_key]
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"_grid_walk_category: upsert failed cad=%s layer=%d: %s",
|
||||
|
|
@ -544,9 +555,7 @@ async def backfill_parcel_geom(
|
|||
# Один сбойный квартал не валит весь backfill — лог + продолжаем.
|
||||
# (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
|
||||
# caller-task ловит и не ретраит, как в bulk_harvest.)
|
||||
logger.warning(
|
||||
"backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e
|
||||
)
|
||||
logger.warning("backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e)
|
||||
db.rollback()
|
||||
continue
|
||||
|
||||
|
|
@ -1423,7 +1432,7 @@ def upsert_quarter_stats(
|
|||
"cost_value_total_geom": _safe_numeric(raw_opts.get("cost_value_total_geom")),
|
||||
"sum_land_area": _safe_numeric(raw_opts.get("sum_land_area")),
|
||||
"sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_area")),
|
||||
"date_cr": raw_opts.get("date_cr"),
|
||||
"date_cr": _parse_nspd_date(raw_opts.get("date_cr")),
|
||||
"real_srid": _safe_int(raw_opts.get("real_srid")),
|
||||
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
||||
},
|
||||
|
|
@ -1466,6 +1475,21 @@ def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict])
|
|||
zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone")
|
||||
permitted_use = props.get("permitted_use") or props.get("vri")
|
||||
|
||||
# cad_territorial_zones.geom — geography(MultiPolygon, 4326).
|
||||
# Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString →
|
||||
# geography-INSERT fail → SAVEPOINT откат → строка дропается молча.
|
||||
# Зеркалит фильтр upsert_zouit (~1196).
|
||||
geom_type = geom.get("type") if isinstance(geom, dict) else None
|
||||
if geom_type not in ("Polygon", "MultiPolygon"):
|
||||
if geom_type:
|
||||
logger.info(
|
||||
"_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)"
|
||||
" — geom=NULL",
|
||||
zone_id,
|
||||
geom_type,
|
||||
)
|
||||
geom_geojson = None
|
||||
|
||||
try:
|
||||
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
|
||||
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
|
||||
|
|
@ -1484,9 +1508,11 @@ def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict])
|
|||
CAST(:permitted_use AS text),
|
||||
CAST(:raw_props AS jsonb),
|
||||
CASE WHEN CAST(:geom AS text) IS NOT NULL
|
||||
THEN ST_Transform(
|
||||
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
|
||||
4326
|
||||
THEN ST_Multi(
|
||||
ST_Transform(
|
||||
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
|
||||
4326
|
||||
)
|
||||
)::geography
|
||||
ELSE NULL
|
||||
END
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Радиус сферы Web Mercator (EPSG:3857), м. Нужен для инверсии Y → широта.
|
||||
_WEB_MERCATOR_R = 6378137.0
|
||||
|
||||
|
||||
def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None:
|
||||
"""Получить bbox квартала из cad_quarters_geom в EPSG:3857.
|
||||
|
|
@ -56,8 +60,19 @@ def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, f
|
|||
# geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors
|
||||
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
|
||||
# broken — snapshot phase даст 20 per cat, что норм для MVP.
|
||||
width = bbox[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
#
|
||||
# Bug #1629: EPSG:3857 (Web Mercator) — конформная, НЕ равнопротяжённая
|
||||
# проекция. Линейный масштаб = sec(lat); на широте ЕКБ (~56-60°N) фактор
|
||||
# ≈1.8-2.0, т.е. 1 наземный метр ≈ 1.8-2.0 единиц 3857. Пороги ниже заданы
|
||||
# в НАЗЕМНЫХ метрах, поэтому переводим протяжённость bbox из единиц 3857 в
|
||||
# наземные метры умножением на cos(lat) (lat берём из центра bbox через
|
||||
# инверсию Y). Иначе верхняя граница 10000 отсекала бы реальные крупные/
|
||||
# вытянутые кварталы (10000 единиц 3857 ≈ всего ~5050 м земли на широте ЕКБ).
|
||||
y_mid = (bbox[1] + bbox[3]) / 2.0
|
||||
lat_mid = 2.0 * math.atan(math.exp(y_mid / _WEB_MERCATOR_R)) - math.pi / 2.0
|
||||
ground_scale = math.cos(lat_mid) # ground_m = mercator_units * cos(lat)
|
||||
width = (bbox[2] - bbox[0]) * ground_scale
|
||||
height = (bbox[3] - bbox[1]) * ground_scale
|
||||
if width < 100 or height < 100 or width > 10000 or height > 10000:
|
||||
logger.warning(
|
||||
"quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm "
|
||||
|
|
|
|||
|
|
@ -151,8 +151,8 @@ def _fmt_number(value: Any) -> str | None:
|
|||
if isinstance(value, int):
|
||||
return _fmt_thousands(value)
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
|
||||
return str(value)
|
||||
if not math.isfinite(value): # NaN/Inf — не число: честно пропускаем (как None),
|
||||
return None # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585)
|
||||
if value == int(value):
|
||||
return _fmt_thousands(value)
|
||||
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
|
||||
|
|
@ -251,7 +251,7 @@ def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
|
|||
if summary:
|
||||
lines.append(str(summary))
|
||||
|
||||
if len(sections_used) == 1 and not any(
|
||||
if not any(
|
||||
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
|
||||
):
|
||||
lines.append("Раздел рекомендации продукта в отчёте пуст.")
|
||||
|
|
@ -266,16 +266,21 @@ def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
|
|||
|
||||
future = report.get("future_market")
|
||||
if isinstance(future, dict):
|
||||
sections_used.append("future_market")
|
||||
future_emitted = False # секцию в provenance кладём только если выведена строка (#1630)
|
||||
horizons = future.get("forecasts_by_horizon")
|
||||
if isinstance(horizons, list) and horizons:
|
||||
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
|
||||
future_emitted = True
|
||||
future_supply = future.get("future_supply")
|
||||
if isinstance(future_supply, dict) and future_supply:
|
||||
lines.append("Учтено давление будущего предложения (выходящие проекты).")
|
||||
future_emitted = True
|
||||
summary = future.get("summary")
|
||||
if summary:
|
||||
lines.append(str(summary))
|
||||
future_emitted = True
|
||||
if future_emitted:
|
||||
sections_used.append("future_market")
|
||||
else:
|
||||
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,18 @@ def orchestrate_chat(
|
|||
continue
|
||||
|
||||
# Нет tool_calls → финальная проза.
|
||||
# Проверяем finish_reason: 'length' / 'content_filter' означают обрезанный
|
||||
# или отфильтрованный ответ — неполная проза клиенту не отдаётся (LLMResult
|
||||
# docstring §64: «консьюмер ОБЯЗАН проверять даже при ok=True»).
|
||||
if result.finish_reason not in (None, "stop", "end_turn", "tool_calls"):
|
||||
logger.warning(
|
||||
"chat: non-stop finish_reason=%r for cad=%s run=%s → deterministic fallback",
|
||||
result.finish_reason,
|
||||
cad_num,
|
||||
run_id,
|
||||
)
|
||||
return _deterministic(report_dict, message, f"finish_reason:{result.finish_reason}")
|
||||
|
||||
answer = (result.content or "").strip()
|
||||
if not answer:
|
||||
# Модель вернула пустоту — пустой ответ клиенту не отдаём, деградируем.
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ def find_match_candidates(
|
|||
objective_distinct AS (
|
||||
SELECT DISTINCT project_name
|
||||
FROM objective_corpus_room_month
|
||||
WHERE group_name = 'Екатеринбург'
|
||||
)
|
||||
SELECT
|
||||
d.obj_id,
|
||||
|
|
@ -168,7 +169,7 @@ def auto_apply_matches(
|
|||
len(auto),
|
||||
len(review),
|
||||
)
|
||||
return {"auto_accepted": 0, "review_queue": len(review), "skipped": 0}
|
||||
return {"auto_accepted": len(auto), "review_queue": len(review), "skipped": 0}
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@ _FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
|
|||
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
|
||||
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
|
||||
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
|
||||
# основного горизонта как сводный показатель сценария.
|
||||
# основного горизонта как сводный показатель. При fallback на иной горизонт ячейка
|
||||
# несёт «(гор. N мес)» через _scenario_deficit_cell, чтобы шапка «(12 мес)» не лгала
|
||||
# (#1590).
|
||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||
|
||||
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
|
||||
|
|
@ -140,7 +142,7 @@ def _fmt(value: Any) -> Any:
|
|||
return _sanitize_formula(value)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, (dict, list)):
|
||||
if isinstance(value, dict | list):
|
||||
return _sanitize_formula(str(value))
|
||||
return value
|
||||
|
||||
|
|
@ -208,6 +210,48 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
|||
return None
|
||||
|
||||
|
||||
def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None:
|
||||
"""Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE.
|
||||
|
||||
Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес → первый с не-None
|
||||
дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала
|
||||
при fallback на чужой горизонт. Нет дефицита → None (#1590, зеркало report_pdf.py).
|
||||
"""
|
||||
forecasts = _as_list(payload.get("forecasts"))
|
||||
primary = next(
|
||||
(
|
||||
f
|
||||
for f in forecasts
|
||||
if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
|
||||
),
|
||||
None,
|
||||
)
|
||||
if primary is not None and primary.get("deficit_index") is not None:
|
||||
return _PRIMARY_HORIZON_MONTHS
|
||||
for f in forecasts:
|
||||
if isinstance(f, dict) and f.get("deficit_index") is not None:
|
||||
h = f.get("horizon_months")
|
||||
return h if isinstance(h, int) else None
|
||||
return None
|
||||
|
||||
|
||||
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
|
||||
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
|
||||
|
||||
PURE. При отсутствии 12-мес прогноза `_scenario_deficit_index` делает fallback на чужой
|
||||
горизонт — помечаем ячейку реальным горизонтом, чтобы число под общей шапкой
|
||||
«(12 мес)» не читалось как 12-месячное. Дефицит None → отдаём как есть (→ "—").
|
||||
Зеркалит report_pdf._scenario_deficit_cell (#1590).
|
||||
"""
|
||||
deficit = _scenario_deficit_index(payload)
|
||||
if deficit is None:
|
||||
return deficit
|
||||
horizon = _scenario_deficit_horizon(payload)
|
||||
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
|
||||
return f"{_fmt(deficit)} (гор. {horizon} мес)"
|
||||
return deficit
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка
|
||||
# (мутируют переданный лист). Не ходят в БД/сеть.
|
||||
|
|
@ -481,12 +525,14 @@ def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
|
|||
row += 1
|
||||
|
||||
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
|
||||
# _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт
|
||||
# «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590).
|
||||
row = _write_title(ws, row, "Сводка по сценариям")
|
||||
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
|
||||
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
|
||||
table_rows: list[list[Any]] = []
|
||||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
table_rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
|
||||
table_rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
|
||||
_write_table(ws, row, headers, table_rows)
|
||||
|
||||
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из
|
||||
`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря,
|
||||
`_fmt` — строковое форматирование ячейки/None→"—", `_level_ru`, `_as_dict`/`_as_list`,
|
||||
`_future_supply_pairs`, `_scenario_deficit_index`) + переиспользуем его named-константы
|
||||
`_future_supply_pairs`, `_scenario_deficit_cell`) + переиспользуем его named-константы
|
||||
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные
|
||||
микро-билдеры (заголовки/абзацы/таблицы python-docx).
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ from app.services.exporters.report_pdf import (
|
|||
_future_supply_pairs,
|
||||
_level_ru,
|
||||
_normalize,
|
||||
_scenario_deficit_index,
|
||||
_scenario_deficit_cell,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -76,7 +76,8 @@ _TABLE_STYLE: str = "Table Grid"
|
|||
_COMPETITORS_TOP_N: int = 5
|
||||
|
||||
# Основной продуктовый горизонт (мес) — подпись столбца сводного дефицита сценария
|
||||
# (зеркало report_md._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index).
|
||||
# (зеркало report_md._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell —
|
||||
# при fallback на чужой горизонт ячейка несёт «(гор. N мес)», #1590).
|
||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||
|
||||
|
||||
|
|
@ -272,7 +273,7 @@ def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
|||
rate_str = (
|
||||
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
||||
)
|
||||
rows.append([name, _scenario_deficit_index(data), rate_str, data.get("advisory")])
|
||||
rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
|
||||
|
||||
headers = [
|
||||
"Сценарий",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from app.services.exporters.report_pdf import (
|
|||
_future_supply_pairs,
|
||||
_level_ru,
|
||||
_normalize,
|
||||
_scenario_deficit_horizon,
|
||||
_scenario_deficit_index,
|
||||
)
|
||||
|
||||
|
|
@ -109,10 +110,7 @@ def _md_table(headers: list[str], rows: list[list[Any]]) -> str:
|
|||
if not rows:
|
||||
empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |"
|
||||
return "\n".join([head, sep, empty])
|
||||
body = [
|
||||
"| " + " | ".join(_md_cell(cell) for cell in row) + " |"
|
||||
for row in rows
|
||||
]
|
||||
body = ["| " + " | ".join(_md_cell(cell) for cell in row) + " |" for row in rows]
|
||||
return "\n".join([head, sep, *body])
|
||||
|
||||
|
||||
|
|
@ -123,10 +121,17 @@ def _md_kv_table(data: dict[str, Any]) -> str:
|
|||
|
||||
|
||||
def _md_kv_lines(pairs: list[tuple[str, Any]]) -> str:
|
||||
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE."""
|
||||
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE.
|
||||
|
||||
Переводы строк в значении сворачиваем в пробел (по аналогии с `_md_cell`):
|
||||
иначе многострочный value (напр. product_tz.summary) разорвал бы буллет.
|
||||
"""
|
||||
if not pairs:
|
||||
return _NO_DATA
|
||||
return "\n".join(f"- **{label}:** {_fmt(value)}" for label, value in pairs)
|
||||
return "\n".join(
|
||||
f"- **{label}:** {_fmt(value).replace(chr(10), ' ').replace(chr(13), ' ')}"
|
||||
for label, value in pairs
|
||||
)
|
||||
|
||||
|
||||
def _join_horizons(values: list[Any]) -> Any:
|
||||
|
|
@ -243,6 +248,22 @@ def _build_future_market(report: dict[str, Any]) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
|
||||
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
|
||||
|
||||
PURE. Зеркало report_pdf._scenario_deficit_cell: при fallback `_scenario_deficit_index`
|
||||
на чужой горизонт (нет 12-мес прогноза) помечаем число реальным горизонтом, чтобы под
|
||||
общей шапкой «(12 мес)» оно не читалось как 12-месячное. Дефицит None → как есть (→ "—").
|
||||
"""
|
||||
deficit = _scenario_deficit_index(payload)
|
||||
if deficit is None:
|
||||
return deficit
|
||||
horizon = _scenario_deficit_horizon(payload)
|
||||
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
|
||||
return f"{_fmt(deficit)} (гор. {horizon} мес)"
|
||||
return deficit
|
||||
|
||||
|
||||
def _build_scenarios(report: dict[str, Any]) -> str:
|
||||
"""§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
|
||||
scenarios = _as_dict(report.get("scenarios"))
|
||||
|
|
@ -252,12 +273,8 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
|||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
rate_path = _as_dict(data.get("rate_path"))
|
||||
rate_str = (
|
||||
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
||||
)
|
||||
rows.append(
|
||||
[name, _scenario_deficit_index(data), rate_str, data.get("advisory")]
|
||||
)
|
||||
rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
||||
rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
|
||||
|
||||
headers = [
|
||||
"Сценарий",
|
||||
|
|
@ -266,9 +283,7 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
|||
"Advisory",
|
||||
]
|
||||
return (
|
||||
f"## {_TITLE_SCENARIOS}\n\n"
|
||||
f"{_fmt(scenarios.get('summary'))}\n\n"
|
||||
f"{_md_table(headers, rows)}"
|
||||
f"## {_TITLE_SCENARIOS}\n\n{_fmt(scenarios.get('summary'))}\n\n{_md_table(headers, rows)}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -290,9 +305,7 @@ def _build_product_tz(report: dict[str, Any]) -> str:
|
|||
if isinstance(m, dict)
|
||||
]
|
||||
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
|
||||
reason_rows = [
|
||||
[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)
|
||||
]
|
||||
reason_rows = [[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)]
|
||||
|
||||
return (
|
||||
f"## {_TITLE_PRODUCT_TZ}\n\n"
|
||||
|
|
@ -444,7 +457,9 @@ def _tg_scenario_deficit_line(report: dict[str, Any]) -> str | None:
|
|||
parts: list[str] = []
|
||||
for name in ordered:
|
||||
label = _TG_SCENARIO_RU.get(name, str(name))
|
||||
deficit = _scenario_deficit_index(_as_dict(by_scenario.get(name)))
|
||||
# _scenario_deficit_cell сам помечает «(гор. N мес)» при fallback на чужой горизонт,
|
||||
# чтобы метка периода в чате не лгала (см. _build_scenarios).
|
||||
deficit = _scenario_deficit_cell(_as_dict(by_scenario.get(name)))
|
||||
parts.append(f"{label} {_fmt(deficit)}")
|
||||
return " · ".join(parts) if parts else None
|
||||
|
||||
|
|
@ -504,8 +519,20 @@ def render_report_telegram_summary(report: Any) -> str:
|
|||
# Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии).
|
||||
scenario_line = _tg_scenario_deficit_line(data)
|
||||
if scenario_line is not None:
|
||||
horizons = _as_list(meta.get("horizons"))
|
||||
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if horizons else ""
|
||||
# Метку «(гор. 12 мес)» ставим, только если ВСЕ выведенные дефициты реально взяты
|
||||
# с основного 12-мес горизонта; при fallback на чужой горизонт у сценария метка
|
||||
# «(гор. N мес)» уже стоит в самой строке (см. _scenario_deficit_cell), а общая
|
||||
# метка молчит, чтобы не лгать про период (баг #1590).
|
||||
by_scenario = _as_dict(_as_dict(data.get("scenarios")).get("by_scenario"))
|
||||
with_deficit = [
|
||||
_as_dict(payload)
|
||||
for payload in by_scenario.values()
|
||||
if _scenario_deficit_index(_as_dict(payload)) is not None
|
||||
]
|
||||
all_primary = bool(with_deficit) and all(
|
||||
_scenario_deficit_horizon(p) == _PRIMARY_HORIZON_MONTHS for p in with_deficit
|
||||
)
|
||||
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if all_primary else ""
|
||||
lines.append(f"• Дефицит по сценариям{horizon_note}: {scenario_line}")
|
||||
|
||||
# Рекомендованный класс продукта (если задан).
|
||||
|
|
|
|||
|
|
@ -458,6 +458,9 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
|||
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
|
||||
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
|
||||
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
|
||||
|
||||
NB: эту функцию импортируют report_md/docx/pptx — НЕ менять сигнатуру (вернёт скаляр).
|
||||
Для подписи фактического горизонта используй `_scenario_deficit_horizon`.
|
||||
"""
|
||||
forecasts = _as_list(payload.get("forecasts"))
|
||||
primary = next(
|
||||
|
|
@ -476,6 +479,49 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
|||
return None
|
||||
|
||||
|
||||
def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None:
|
||||
"""Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE.
|
||||
|
||||
Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес → первый с не-None
|
||||
дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала
|
||||
при fallback на чужой горизонт. Нет дефицита → None. NB: сигнатура
|
||||
`_scenario_deficit_index` (импортируется в report_md/docx/pptx) НЕ меняется — это
|
||||
отдельная чистая функция-зеркало.
|
||||
"""
|
||||
forecasts = _as_list(payload.get("forecasts"))
|
||||
primary = next(
|
||||
(
|
||||
f
|
||||
for f in forecasts
|
||||
if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
|
||||
),
|
||||
None,
|
||||
)
|
||||
if primary is not None and primary.get("deficit_index") is not None:
|
||||
return _PRIMARY_HORIZON_MONTHS
|
||||
for f in forecasts:
|
||||
if isinstance(f, dict) and f.get("deficit_index") is not None:
|
||||
h = f.get("horizon_months")
|
||||
return h if isinstance(h, int) else None
|
||||
return None
|
||||
|
||||
|
||||
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
|
||||
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
|
||||
|
||||
PURE. При отсутствии 12-мес прогноза `_scenario_deficit_index` делает fallback на чужой
|
||||
горизонт — помечаем ячейку реальным горизонтом, чтобы число под общей шапкой
|
||||
«(12 мес)» не читалось как 12-месячное. Дефицит None → отдаём как есть (→ "—").
|
||||
"""
|
||||
deficit = _scenario_deficit_index(payload)
|
||||
if deficit is None:
|
||||
return deficit
|
||||
horizon = _scenario_deficit_horizon(payload)
|
||||
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
|
||||
return f"{_fmt(deficit)} (гор. {horizon} мес)"
|
||||
return deficit
|
||||
|
||||
|
||||
def _build_scenarios(report: dict[str, Any]) -> str:
|
||||
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
||||
scenarios = _as_dict(report.get("scenarios"))
|
||||
|
|
@ -484,9 +530,9 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
|||
rows: list[list[Any]] = []
|
||||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
|
||||
rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
|
||||
|
||||
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
|
||||
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
|
||||
return f"""
|
||||
<div class="section" id="scenarios">
|
||||
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из
|
||||
`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря,
|
||||
`_fmt` — строковое форматирование значения/None→"—", `_level_ru`, `_as_dict`/`_as_list`,
|
||||
`_scenario_deficit_index`) + переиспользуем его named-константы
|
||||
`_scenario_deficit_cell`) + переиспользуем его named-константы
|
||||
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные
|
||||
микро-билдеры (слайды/буллеты/таблица python-pptx).
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ from app.services.exporters.report_pdf import (
|
|||
_fmt,
|
||||
_level_ru,
|
||||
_normalize,
|
||||
_scenario_deficit_index,
|
||||
_scenario_deficit_cell,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -78,7 +78,8 @@ _LAYOUT_TITLE_CONTENT: int = 1
|
|||
_LAYOUT_TITLE_ONLY: int = 5
|
||||
|
||||
# Основной продуктовый горизонт (мес) — подпись сводного дефицита сценария
|
||||
# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index).
|
||||
# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell —
|
||||
# при fallback на чужой горизонт буллет несёт «(гор. N мес)», #1590).
|
||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||
|
||||
# Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно).
|
||||
|
|
@ -235,7 +236,7 @@ def _build_scenarios(prs: _PptxPresentation, report: dict[str, Any]) -> None:
|
|||
rate_str = (
|
||||
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH
|
||||
)
|
||||
deficit = _fmt(_scenario_deficit_index(data))
|
||||
deficit = _fmt(_scenario_deficit_cell(data))
|
||||
bullets.append(
|
||||
(str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -166,12 +166,14 @@ def generate_snapshot_pdf(
|
|||
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else "—"
|
||||
poi_items = _build_poi_items(poi_rows, limit=7)
|
||||
|
||||
# Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC;
|
||||
# переупорядочиваем по distance_m для удобства чтения)
|
||||
# Конкуренты — берём топ N БЛИЖАЙШИХ. competitor_rows приходят отсортированными
|
||||
# по flat_count DESC (крупнейшие ЖК), поэтому сначала пересортировываем весь
|
||||
# список по distance_m ASC, и лишь затем срезаем N — иначе в блок попадали бы
|
||||
# 5 крупнейших из радиуса, а не непосредственное конкурентное окружение пятна.
|
||||
competitors_display = sorted(
|
||||
competitor_rows[:competitors_limit],
|
||||
competitor_rows,
|
||||
key=lambda r: float(r.get("distance_m") or 0),
|
||||
)
|
||||
)[:competitors_limit]
|
||||
competitors_ctx: list[dict[str, Any]] = [
|
||||
{
|
||||
"comm_name": r.get("comm_name"),
|
||||
|
|
|
|||
|
|
@ -58,7 +58,11 @@ def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str:
|
|||
for lot in lots:
|
||||
date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else "—"
|
||||
dom_val = str(lot.days_on_market) if lot.days_on_market is not None else "—"
|
||||
floor_val = f"{lot.floor}/{lot.total_floors}" if lot.floor and lot.total_floors else "—"
|
||||
floor_val = (
|
||||
f"{lot.floor}/{lot.total_floors}"
|
||||
if lot.floor is not None and lot.total_floors is not None
|
||||
else "—"
|
||||
)
|
||||
label = "Дата сделки" if is_deal else "В продаже"
|
||||
_ = label # used for header only
|
||||
rows.append(
|
||||
|
|
|
|||
|
|
@ -294,8 +294,10 @@ def compute_affordability(
|
|||
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
||||
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
||||
months=_ANNUITY_TERM_MONTHS).
|
||||
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h]) — платёж на
|
||||
каждом горизонте сценарной ставки (None rate_path → поле None).
|
||||
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h] + спред) —
|
||||
платёж на каждом горизонте сценарной КЛЮЧЕВОЙ ставки ЦБ, приведённой к той
|
||||
же рыночной базе, что и monthly_payment_rub (key_rate + калиброванный спред
|
||||
4.5 п.п.); None rate_path → поле None (#1639).
|
||||
|
||||
Graceful: нет ставки/цены → платёж None; ставка ≤0 → аннуитет деградирует к
|
||||
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
|
||||
|
|
@ -304,8 +306,10 @@ def compute_affordability(
|
|||
db: SQLAlchemy sync Session.
|
||||
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
|
||||
price_per_m2: цена ₽/м² (None → берём сегментную среднюю reuse-ом).
|
||||
rate_path: сценарный {horizon: годовая ставка %} для payment_at_scenario;
|
||||
None → payment_at_scenario None.
|
||||
rate_path: сценарный {horizon: КЛЮЧЕВАЯ ставка ЦБ %} для payment_at_scenario
|
||||
(контракт #952/#984: конверт key_rate, НЕ рыночная — к ней внутри
|
||||
добавляется тот же спред _KEY_RATE_MARKET_SPREAD_PP, что и в базовом
|
||||
monthly_payment_rub); None → payment_at_scenario None.
|
||||
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
|
||||
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
|
||||
|
||||
|
|
@ -337,7 +341,18 @@ def compute_affordability(
|
|||
if rate_path is not None:
|
||||
scenario: dict[int, float] = {}
|
||||
for horizon, scenario_rate in rate_path.items():
|
||||
payment = _annuity(principal, scenario_rate, _ANNUITY_TERM_MONTHS)
|
||||
# rate_path[h] = КЛЮЧЕВАЯ ставка ЦБ сценария (#952/#984 контракт:
|
||||
# demand_supply_forecast.py:586, scenarios.py — конверт key_rate), НЕ
|
||||
# рыночная. Приводим к той же рыночной базе, что и monthly_payment_rub:
|
||||
# key_rate + калиброванный спред (_current_market_rate, строка 251).
|
||||
# Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п.
|
||||
# ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639).
|
||||
market_scenario_rate = (
|
||||
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP
|
||||
if scenario_rate is not None
|
||||
else None
|
||||
)
|
||||
payment = _annuity(principal, market_scenario_rate, _ANNUITY_TERM_MONTHS)
|
||||
if payment is not None:
|
||||
scenario[horizon] = payment
|
||||
payment_at_scenario = scenario
|
||||
|
|
|
|||
|
|
@ -367,9 +367,12 @@ def _build_rationale(
|
|||
|
||||
if advisory_capped and level == _ADVISORY_CEILING:
|
||||
# Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
|
||||
# _F_ADVISORY_CAP-фактор уже проговорён в base — исключаем его ноту из «также»,
|
||||
# иначе advisory-cap-сообщение дублируется (частый all-high случай).
|
||||
other = [f.note for f in drag if f.name != _F_ADVISORY_CAP]
|
||||
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
|
||||
if notes:
|
||||
base += "; также " + _join_notes(notes)
|
||||
if other:
|
||||
base += "; также " + _join_notes(other)
|
||||
return base + "."
|
||||
|
||||
if not notes:
|
||||
|
|
@ -409,6 +412,7 @@ def compute_report_confidence(
|
|||
*,
|
||||
component_confidences: Sequence[ComponentConfidence] | None = None,
|
||||
deal_count: int | None = None,
|
||||
deal_count_months: int | None = None,
|
||||
analog_count: int | None = None,
|
||||
domrf_coverage: float | None = None,
|
||||
history_months: int | None = None,
|
||||
|
|
@ -439,6 +443,8 @@ def compute_report_confidence(
|
|||
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
|
||||
имя сервиса проносится сквозь стек в RU-метку фактора).
|
||||
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
||||
deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес»
|
||||
в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода.
|
||||
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
||||
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||
history_months: глубина ряда (мес).
|
||||
|
|
@ -453,6 +459,7 @@ def compute_report_confidence(
|
|||
|
||||
# ── 1. Сырые счётчики качества данных → факторы (только заданные) ──────────
|
||||
if deal_count is not None:
|
||||
_deal_suffix = f"за {deal_count_months} мес" if deal_count_months is not None else ""
|
||||
factors.append(
|
||||
_factor_from_count(
|
||||
_F_DEAL_COUNT,
|
||||
|
|
@ -460,6 +467,7 @@ def compute_report_confidence(
|
|||
high_at=_DEAL_COUNT_HIGH,
|
||||
low_below=_DEAL_COUNT_LOW,
|
||||
unit="сделок",
|
||||
suffix=_deal_suffix,
|
||||
)
|
||||
)
|
||||
if analog_count is not None:
|
||||
|
|
|
|||
|
|
@ -36,9 +36,10 @@
|
|||
МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard — graceful, без усиления шума на тонких данных):
|
||||
сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить
|
||||
случайным шумом одного-двух наблюдений:
|
||||
• нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — иначе у каждого месяца
|
||||
< 2 наблюдений, «сезонность» неотличима от шума → ВСЕ факторы = 1.0 (ряд
|
||||
возвращается без изменений);
|
||||
• нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — считаем только по
|
||||
NON-ZERO месяцам (fill_month_grid ставит units=0 для «нет сделок», но 0 не
|
||||
несёт сезонного сигнала); иначе у каждого месяца < 2 наблюдений, «сезонность»
|
||||
неотличима от шума → ВСЕ факторы = 1.0 (ряд возвращается без изменений);
|
||||
• месяц без наблюдений → его фактор = 1.0 (нет базы для оценки);
|
||||
• overall_mean ≤ 0 (пустой/нулевой ряд) → ВСЕ факторы = 1.0 (нет масштаба, и
|
||||
защита от деления на ноль);
|
||||
|
|
@ -114,7 +115,8 @@ def seasonal_factors(
|
|||
None-значения в values пропускаются (нет наблюдения — не подмешиваем 0).
|
||||
|
||||
GUARD (деградация к нейтрали 1.0, без деления на ноль / усиления шума):
|
||||
• полных лет < min_full_years → ВСЕ факторы 1.0, applied=False;
|
||||
• полных лет (по NON-ZERO месяцам) < min_full_years → ВСЕ факторы 1.0,
|
||||
applied=False (fix #1638: zero-filled месяцы не считаются за наблюдения);
|
||||
• overall_mean ≤ 0 → ВСЕ факторы 1.0 (нет масштаба / защита от /0);
|
||||
• месяц без наблюдений ИЛИ со средним ≤ 0 → его фактор 1.0.
|
||||
|
||||
|
|
@ -167,15 +169,20 @@ def seasonal_factors(
|
|||
|
||||
|
||||
def _count_full_years(months: list[date], values: list[float | int | None]) -> int:
|
||||
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 наблюдение) в ряду.
|
||||
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 NON-ZERO наблюдение).
|
||||
|
||||
Считаем по годам, где присутствует наблюдение в КАЖДОМ из 12 месяцев — это
|
||||
Считаем по годам, где есть НЕНУЛЕВОЕ наблюдение в КАЖДОМ из 12 месяцев — это
|
||||
«полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки
|
||||
наблюдением НЕ считаются. PURE.
|
||||
и нулевые значения (zero-filled месяцы из fill_month_grid) наблюдением НЕ
|
||||
считаются: у ряда с units=0 нет сезонного сигнала для оценки факторов. PURE.
|
||||
|
||||
Это исправляет баг #1638: fill_month_grid выставляет units=0 для отсутствующих
|
||||
месяцев (не None), поэтому старый guard, пропускавший только None, насчитывал
|
||||
«полные годы» на фактически пустом ряду.
|
||||
"""
|
||||
months_seen: dict[int, set[int]] = {}
|
||||
for d, v in zip(months, values, strict=False):
|
||||
if v is None:
|
||||
if v is None or float(v) == 0.0:
|
||||
continue
|
||||
months_seen.setdefault(d.year, set()).add(d.month)
|
||||
return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR)
|
||||
|
|
|
|||
|
|
@ -174,9 +174,10 @@ _MORTG_MAX_X_PCT: float = 25.0
|
|||
# дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш).
|
||||
_DIFF_TARGET_USP: float = 3.0
|
||||
|
||||
# commercial: commercial_share_pct ∈ [0,100] (§10.4 реализованная доля нежилого) → /100.
|
||||
# Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): overlay.commercial.available=False
|
||||
# → скор None (НЕ 0). available=True → share/100 как прокси силы спроса на коммерцию.
|
||||
# commercial: commercial_sell_through_pct ∈ [0,100] (§10.4 темп распродажи нежилого,
|
||||
# sell-through, прокси спроса) → /100. Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё):
|
||||
# overlay.commercial.available=False → скор None (НЕ 0). available=True → sell_through/100
|
||||
# как прокси силы спроса на коммерцию.
|
||||
|
||||
# confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] →
|
||||
# усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 —
|
||||
|
|
@ -463,21 +464,24 @@ def _score_differentiation(
|
|||
def _score_commercial(
|
||||
commercial: dict[str, Any] | None,
|
||||
) -> tuple[float | None, Confidence, str]:
|
||||
"""commercial ← recommendation §10.4 сигнал → share_pct/100. ДЕГРАДИРУЕТ в None. PURE.
|
||||
"""commercial ← recommendation §10.4 сигнал → sell_through_pct/100. ДЕГРАДИРУЕТ в None. PURE.
|
||||
|
||||
overlay.commercial.available=True → commercial_share_pct/100 (прокси спроса на
|
||||
нежилое). available=False / None (objective ~ жильё, тонко) → None (НЕ 0-как-заглушка).
|
||||
confidence наследуется из сигнала (или 'low').
|
||||
overlay.commercial.available=True → commercial_sell_through_pct/100 (темп распродажи
|
||||
нежилого, sell-through, прокси спроса). available=False / None (objective ~ жильё, тонко)
|
||||
→ None (НЕ 0-как-заглушка). confidence наследуется из сигнала (или 'low').
|
||||
"""
|
||||
if not commercial or not commercial.get("available"):
|
||||
return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)."
|
||||
share_pct = commercial.get("commercial_share_pct")
|
||||
if share_pct is None:
|
||||
return None, "low", "Коммерческий сигнал §10.4 недоступен (доля не измерена)."
|
||||
sell_through_pct = commercial.get("commercial_sell_through_pct")
|
||||
if sell_through_pct is None:
|
||||
return None, "low", "Коммерческий сигнал §10.4 недоступен (темп распродажи не измерен)."
|
||||
raw_conf = commercial.get("confidence", "low")
|
||||
conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low"
|
||||
value = _clamp01(float(share_pct) / 100.0)
|
||||
reason = f"Реализованная доля коммерции ~{round(float(share_pct), 1)}% (§10.4, прокси спроса)."
|
||||
value = _clamp01(float(sell_through_pct) / 100.0)
|
||||
reason = (
|
||||
f"Темп распродажи нежилого ~{round(float(sell_through_pct), 1)}% (§10.4, прокси "
|
||||
"ликвидности/спроса на нежилое; НЕ доля нежилого в объёме застройки)."
|
||||
)
|
||||
return value, conf, reason
|
||||
|
||||
|
||||
|
|
@ -818,7 +822,10 @@ def _competitor_signal(
|
|||
)
|
||||
return None, None
|
||||
weights = [c.relevance_weight for c in response.competitors if c.relevance_weight is not None]
|
||||
return weights, len(response.competitors)
|
||||
# #1595: count считаем по тем же конкурентам, у которых есть relevance_weight, иначе при
|
||||
# частичных данных (None-веса) count > len(weights) → density завышена, future_competition
|
||||
# занижен. В проде get_competitors всегда задаёт вес (число), но поле допускает None (мок).
|
||||
return weights, len(weights)
|
||||
|
||||
|
||||
def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,16 @@ LIVE-ENDPOINT-SAFE: НИКОГДА не бросает — на любом то
|
|||
• mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты
|
||||
НЕизмеримы → считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default
|
||||
room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace ∈
|
||||
(0,1] — это ПРОКСИ относительной силы спроса, НЕ supply-based дефицит;
|
||||
`balance_units=None`, confidence='low', обязательный warning что supply НЕ учтён.
|
||||
НИКОГДА не фабрикуем предложение/конкурентов из воздуха.
|
||||
(0,1]; `balance_units=None`, confidence='low', обязательный warning что supply
|
||||
НЕ учтён. НИКОГДА не фабрикуем предложение/конкурентов из воздуха.
|
||||
#1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через
|
||||
MarketMetrics.velocity_by_room (ед./мес из objective_lots per комнатность).
|
||||
Маппинг forecast_bucket → metric-ключи: _FORECAST_TO_METRIC_BUCKETS.
|
||||
"80+ м²" = сумма bucket'ов "4" + "5+" (оба крупные форматы). Если данных
|
||||
по конкретному bucket'у нет — fallback на агрегатный unit_velocity, что
|
||||
лучше чем фабрикация 0. §9.4 norm.coefficient по-прежнему без per-bucket
|
||||
фита (β фитится district×obj_class), поэтому он одинаков между bucket'ами
|
||||
и СОКРАЩАЕТСЯ в pace/max_pace — это честно задокументировано.
|
||||
|
||||
ИМПОРТЫ §9.x — ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет
|
||||
`forecasting/__init__` тянет `affordability`, который импортит
|
||||
|
|
@ -68,6 +75,18 @@ _FORECAST_TO_LIVE_BUCKET: dict[str, str] = {
|
|||
# дефолтной сетке room-bucket'ов §9.7 и для map_room_bucket_inverse).
|
||||
_LIVE_TO_FORECAST_BUCKET: dict[str, str] = {v: k for k, v in _FORECAST_TO_LIVE_BUCKET.items()}
|
||||
|
||||
# #1593: forecast room-bucket → список ключей MarketMetrics.velocity_by_room.
|
||||
# Ключи velocity_by_room — вокабуляр _room_bucket() из market_metrics
|
||||
# ("студия","1","2","3","4","5+"). "80+ м²" накрывает 4-комнатные и 5+
|
||||
# (большие квартиры), поэтому суммируем оба бакета.
|
||||
_FORECAST_TO_METRIC_BUCKETS: dict[str, list[str]] = {
|
||||
"Студии 15-30": ["студия"],
|
||||
"1-к 30-45": ["1"],
|
||||
"2-к 45-60": ["2"],
|
||||
"3-к 60-80": ["3"],
|
||||
"80+ м²": ["4", "5+"],
|
||||
}
|
||||
|
||||
# Live target_class (вокабуляр schemas.recommend ClassLiteral, «человеческий»
|
||||
# регистр) → forecast obj_class (3 массовых класса §9.7 _DEFAULT_CLASSES). None и
|
||||
# незнакомое → None (вызывающий тогда отдаёт движку дефолтную сетку классов).
|
||||
|
|
@ -565,9 +584,14 @@ def _demand_only_overlay(
|
|||
БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса
|
||||
pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient
|
||||
per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace ∈
|
||||
(0,1] — ПРОКСИ относительной силы спроса (НЕ supply-based дефицит).
|
||||
balance_units=None, confidence='low', обязательный warning. НИКОГДА не
|
||||
(0,1]. balance_units=None, confidence='low', обязательный warning. НИКОГДА не
|
||||
фабрикуем supply. Graceful → [].
|
||||
|
||||
#1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через
|
||||
MarketMetrics.velocity_by_room. §9.4 norm.coefficient одинаков между
|
||||
bucket'ами (β без room_bucket фита) и СОКРАЩАЕТСЯ в pace/max_pace — это
|
||||
задокументировано, не скрыто. Ранкинг теперь отражает реальные per-bucket
|
||||
темпы спроса из objective_lots, а не только §9.5 macro_coef.
|
||||
"""
|
||||
# Локальные импорты — избегаем import-cycle (см. module docstring).
|
||||
from app.services.forecasting.demand_normalization import compute_demand_normalization
|
||||
|
|
@ -581,9 +605,10 @@ def _demand_only_overlay(
|
|||
]
|
||||
mapped_class = map_class(target_class)
|
||||
|
||||
# Один раз на вызов: §9.2 наблюдаемый темп (base_pace) + hold-last-rate для §9.4.
|
||||
# Один раз на вызов: §9.2 наблюдаемый темп (aggregate + per-bucket) + hold-last-rate.
|
||||
metrics = compute_market_metrics(db, district=district)
|
||||
base_pace = metrics.unit_velocity
|
||||
base_pace = metrics.unit_velocity # агрегатный fallback (используется если нет per-bucket)
|
||||
vel_by_room = metrics.velocity_by_room # #1593: per-bucket из objective_lots
|
||||
macro = get_monthly_macro(db)
|
||||
rate_future = hold_last_rate(macro, [horizon_months]).get(horizon_months)
|
||||
|
||||
|
|
@ -609,17 +634,38 @@ def _demand_only_overlay(
|
|||
room_bucket=forecast_bucket,
|
||||
district=district,
|
||||
)
|
||||
# §9.4 нормализация под будущий режим ставки (β внутри; rate_future None →
|
||||
# деградирует к нейтрали внутри себя, передаём 0.0 как placeholder).
|
||||
norm = compute_demand_normalization(
|
||||
db, spec=spec, rate_future=rate_future if rate_future is not None else 0.0
|
||||
)
|
||||
# §9.2 #1593: per-bucket velocity из velocity_by_room (objective_lots
|
||||
# per комнатность). _FORECAST_TO_METRIC_BUCKETS даёт список metric-ключей
|
||||
# для данного forecast_bucket ("80+ м²" = "4"+"5+"). Суммируем ед./мес по
|
||||
# бакетам. Честный 0 при известных бакетах — НЕ fallback. Fallback на
|
||||
# агрегатный base_pace только когда velocity_by_room=None (пустая
|
||||
# выборка) или bucket неизвестен в _FORECAST_TO_METRIC_BUCKETS.
|
||||
metric_keys = _FORECAST_TO_METRIC_BUCKETS.get(forecast_bucket, [])
|
||||
if vel_by_room is not None and metric_keys:
|
||||
bucket_velocity: float = sum(vel_by_room.get(k, 0.0) for k in metric_keys)
|
||||
# Если нет продаж ни в одном из ключей — честный 0 (НЕ fallback к
|
||||
# aggregate: лучше показать реальный 0 чем маскировать его aggregate).
|
||||
bucket_base_pace: float = bucket_velocity
|
||||
else:
|
||||
# vel_by_room None (пустая выборка) или неизвестный forecast_bucket →
|
||||
# aggregate fallback сохраняет graceful поведение.
|
||||
bucket_base_pace = float(base_pace)
|
||||
# §9.4 нормализация под будущий режим ставки (β внутри). rate_future None
|
||||
# (hold_last_rate не дал ставку) → НЕ применяем §9.4: 0.0-placeholder дал бы
|
||||
# delta=−rate_window_avg → exp(β·delta) и клэмп к _NORM_MAX (макс. аплифт),
|
||||
# а НЕ нейтраль. Честная нейтраль при отсутствии будущей ставки = коэф. 1.0.
|
||||
if rate_future is not None:
|
||||
norm_coefficient = compute_demand_normalization(
|
||||
db, spec=spec, rate_future=rate_future
|
||||
).coefficient
|
||||
else:
|
||||
norm_coefficient = 1.0
|
||||
# §9.5 макро-коэффициент (ортогонален β); профиль — класс + room_bucket.
|
||||
profile: dict[str, Any] = {"room_bucket": forecast_bucket}
|
||||
if mapped_class is not None:
|
||||
profile["obj_class"] = mapped_class
|
||||
macro_coef = compute_macro_coefficient(db, segment_profile=profile)
|
||||
pace = base_pace * norm.coefficient * macro_coef.coefficient
|
||||
pace = bucket_base_pace * norm_coefficient * macro_coef.coefficient
|
||||
paces.append((live_bucket, mapped_class, pace))
|
||||
|
||||
max_pace = max((p for _, _, p in paces), default=0.0)
|
||||
|
|
@ -635,7 +681,10 @@ def _demand_only_overlay(
|
|||
demand_only=True,
|
||||
)
|
||||
|
||||
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1] (ПРОКСИ, НЕ supply-based).
|
||||
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1].
|
||||
# #1593: base_pace теперь per-bucket из velocity_by_room → ранкинг отражает
|
||||
# реальный спрос по комнатности. §9.4 norm одинаков между bucket'ами и
|
||||
# сокращается в pace/max_pace (β без per-bucket фита — честно задокументировано).
|
||||
paces.sort(key=lambda t: t[2], reverse=True)
|
||||
ranked_segments: list[dict[str, Any]] = [
|
||||
{
|
||||
|
|
@ -649,12 +698,13 @@ def _demand_only_overlay(
|
|||
]
|
||||
|
||||
logger.info(
|
||||
"forecast-overlay demand_only: district=%s horizon=%d ranked=%d base_pace=%.3f "
|
||||
"(ADVISORY, supply НЕ учтён)",
|
||||
"forecast-overlay demand_only: district=%s horizon=%d ranked=%d "
|
||||
"base_pace_agg=%.3f vel_by_room=%s (ADVISORY, supply НЕ учтён, #1593 per-bucket)",
|
||||
district,
|
||||
horizon_months,
|
||||
len(ranked_segments),
|
||||
base_pace,
|
||||
vel_by_room,
|
||||
)
|
||||
return _enrich_overlay(
|
||||
db,
|
||||
|
|
@ -698,13 +748,15 @@ def _overlay(
|
|||
def _commercial_signal(
|
||||
db: Session, district: str | None, horizon_months: int
|
||||
) -> dict[str, Any] | None:
|
||||
"""§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful.
|
||||
"""§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest.
|
||||
|
||||
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
|
||||
"нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда
|
||||
возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число.
|
||||
Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка доли
|
||||
коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason.
|
||||
Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка ТЕМПА
|
||||
РАСПРОДАЖИ нежилого: sell_through_pct = проданные ÷ (проданные+доступные)·100
|
||||
(market_metrics §-определение) — прокси ликвидности/спроса на нежилое, НЕ доля
|
||||
нежилого в объёме застройки + §16-подобный reason.
|
||||
НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал.
|
||||
|
||||
Args:
|
||||
|
|
@ -747,22 +799,26 @@ def _commercial_signal(
|
|||
)
|
||||
return {"available": False, "caveat": caveat, "advisory": True}
|
||||
|
||||
# Достаточно данных: советующая оценка реализованной доли коммерции (прокси).
|
||||
share_pct = round(sell_through, 1)
|
||||
# Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (прокси
|
||||
# ликвидности/спроса), НЕ доли нежилого в объёме застройки (#1635).
|
||||
sell_through_pct = round(sell_through, 1)
|
||||
confidence = confidence if confidence in ("high", "medium", "low") else "low"
|
||||
return {
|
||||
"available": True,
|
||||
"premise_kind": _COMMERCIAL_PREMISE_KIND,
|
||||
"commercial_share_pct": share_pct,
|
||||
# #1635: ключ = ТЕМП РАСПРОДАЖИ нежилого (sell_through, прокси
|
||||
# ликвидности/спроса), НЕ доля нежилого в объёме застройки.
|
||||
"commercial_sell_through_pct": sell_through_pct,
|
||||
"n_lots": n_lots,
|
||||
"confidence": confidence,
|
||||
"reason": {
|
||||
"why": (
|
||||
f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} "
|
||||
f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)."
|
||||
f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} "
|
||||
f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на "
|
||||
f"нежилые помещения, НЕ доля нежилого в объёме застройки)."
|
||||
),
|
||||
"drivers": [
|
||||
{"factor": "sell_through_pct", "value": share_pct, "direction": "+"},
|
||||
{"factor": "sell_through_pct", "value": sell_through_pct, "direction": "+"},
|
||||
{"factor": "n_lots", "value": n_lots, "direction": "+"},
|
||||
],
|
||||
"rejected": [],
|
||||
|
|
@ -780,14 +836,14 @@ def _as_int(value: Any) -> int | None:
|
|||
Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой
|
||||
нечисловой/bool/сбойный вход → None → degraded-honest путь, без падения.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _as_float(value: Any) -> float | None:
|
||||
"""Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import numpy as np
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.forecast_request_cache import cached
|
||||
from app.services.forecasting.macro_series import get_monthly_macro
|
||||
from app.services.forecasting.macro_series import get_monthly_macro, is_confounded_window
|
||||
from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta
|
||||
from app.services.forecasting.sales_series import (
|
||||
SegmentSpec,
|
||||
|
|
@ -672,7 +672,7 @@ def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity
|
|||
|
||||
|
||||
def _fit_to_sensitivity(
|
||||
fit: DistributedLagFit, *, segment: dict[str, str | None]
|
||||
fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
|
||||
) -> RateSensitivity:
|
||||
"""Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.
|
||||
|
||||
|
|
@ -684,9 +684,16 @@ def _fit_to_sensitivity(
|
|||
• r2 / n_obs ← fit.r2 / fit.n
|
||||
• confidence ← 'regression' → "medium" (gated-OK but advisory-grade) |
|
||||
'fallback' → "low"
|
||||
Source-B-only outputs (z_area_floor, most_sensitive_bucket, confounded,
|
||||
shrinkage_weight) have no analogue in a district×class distributed-lag fit
|
||||
(no room×area bucketing here) → None / sensible defaults. PURE.
|
||||
• confounded ← passed in by the caller (computed from the ACTUAL fit window
|
||||
via is_confounded_window — #1636). The §9.6 production path
|
||||
OR-aggregates this with §9.5 macro_coefficient.confounded in
|
||||
demand_supply_forecast._series_confounded → шок-фактор (#1222).
|
||||
The 48-мес regression window overlaps the 2024-07-01 shock long
|
||||
after the 12-мес macro window stops doing so, so hardcoding
|
||||
False here silently dropped the shock signal on this channel.
|
||||
Source-B-only outputs (z_area_floor, most_sensitive_bucket, shrinkage_weight) have
|
||||
no analogue in a district×class distributed-lag fit (no room×area bucketing here)
|
||||
→ None / sensible defaults. PURE.
|
||||
|
||||
BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier
|
||||
Σ_j β_j on Δln — the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT
|
||||
|
|
@ -705,7 +712,7 @@ def _fit_to_sensitivity(
|
|||
r2=fit.r2,
|
||||
n_obs=fit.n,
|
||||
shrinkage_weight=0.0,
|
||||
confounded=False,
|
||||
confounded=confounded,
|
||||
confidence=confidence,
|
||||
phrase=fit.phrase,
|
||||
)
|
||||
|
|
@ -778,4 +785,32 @@ def compute_rate_regime_sensitivity(
|
|||
)
|
||||
return _insufficient_sensitivity(segment)
|
||||
|
||||
return _fit_to_sensitivity(fit, segment=segment)
|
||||
# #1636: confounded must reflect the ACTUAL §9.6 fit window. The regression fits
|
||||
# over the same macro grid as compute_district_rate_regression (get_monthly_macro,
|
||||
# months_back); re-reading it here is a cache hit (same args). The 48-мес window
|
||||
# crosses the 2024-07-01 shock long after the §9.5 12-мес macro window stops → this
|
||||
# is exactly the channel that was silently never raising the shock flag (#1222).
|
||||
confounded = _macro_window_confounded(db, months_back=months_back)
|
||||
return _fit_to_sensitivity(fit, segment=segment, confounded=confounded)
|
||||
|
||||
|
||||
def _macro_window_confounded(db: Session, *, months_back: int) -> bool:
|
||||
"""True если §9.6 fit-окно [min..max] макро-сетки пересекает шок-дату (#1636).
|
||||
|
||||
Зеркалит macro_coefficient._series_confounded / rate_sensitivity._series_confounded
|
||||
(PR2 is_confounded_window). Окно = та же сетка get_monthly_macro(months_back), что
|
||||
использует compute_district_rate_regression → cache-hit, без лишнего запроса.
|
||||
Пустая сетка / сбой → False (нет окна — нечего конфаундить), НЕ crash.
|
||||
"""
|
||||
try:
|
||||
months = [m.month for m in get_monthly_macro(db, months_back=months_back)]
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"rate_regime_sensitivity: macro window read for confounded-flag failed "
|
||||
"(months_back=%d) → treating as not confounded",
|
||||
months_back,
|
||||
)
|
||||
return False
|
||||
if not months:
|
||||
return False
|
||||
return is_confounded_window(min(months), max(months))
|
||||
|
|
|
|||
|
|
@ -189,10 +189,10 @@ def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | Non
|
|||
"""
|
||||
if supply_layers is not None:
|
||||
coverage = supply_layers.get("domrf_coverage")
|
||||
if isinstance(coverage, (int, float)) and not isinstance(coverage, bool):
|
||||
if isinstance(coverage, int | float) and not isinstance(coverage, bool):
|
||||
return _clamp_fraction(float(coverage))
|
||||
pct = analyze.get("market_data_coverage_pct")
|
||||
if isinstance(pct, (int, float)) and not isinstance(pct, bool):
|
||||
if isinstance(pct, int | float) and not isinstance(pct, bool):
|
||||
return _clamp_fraction(float(pct) / 100.0)
|
||||
return None
|
||||
|
||||
|
|
@ -223,6 +223,19 @@ def _history_months(
|
|||
return None
|
||||
|
||||
|
||||
def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None:
|
||||
"""Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE.
|
||||
|
||||
Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months` —
|
||||
именно за это окно считается n_sold. Нет → None (#990 пропускает суффикс «за N мес»).
|
||||
"""
|
||||
if market_metrics is not None:
|
||||
window = market_metrics.get("window_months")
|
||||
if isinstance(window, int) and window > 0:
|
||||
return window
|
||||
return None
|
||||
|
||||
|
||||
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
|
||||
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
|
||||
|
||||
|
|
@ -303,10 +316,10 @@ def _primary_deficit_index(forecasts: Sequence[dict[str, Any]]) -> float | None:
|
|||
)
|
||||
if primary is not None and primary.get("deficit_index") is not None:
|
||||
di = primary["deficit_index"]
|
||||
return float(di) if isinstance(di, (int, float)) and not isinstance(di, bool) else None
|
||||
return float(di) if isinstance(di, int | float) and not isinstance(di, bool) else None
|
||||
for f in forecasts:
|
||||
di = f.get("deficit_index")
|
||||
if isinstance(di, (int, float)) and not isinstance(di, bool):
|
||||
if isinstance(di, int | float) and not isinstance(di, bool):
|
||||
return float(di)
|
||||
return None
|
||||
|
||||
|
|
@ -324,10 +337,10 @@ def _primary_months_of_inventory(forecasts: Sequence[dict[str, Any]]) -> float |
|
|||
)
|
||||
if primary is not None and primary.get("months_of_inventory") is not None:
|
||||
moi = primary["months_of_inventory"]
|
||||
return float(moi) if isinstance(moi, (int, float)) and not isinstance(moi, bool) else None
|
||||
return float(moi) if isinstance(moi, int | float) and not isinstance(moi, bool) else None
|
||||
for f in forecasts:
|
||||
moi = f.get("months_of_inventory")
|
||||
if isinstance(moi, (int, float)) and not isinstance(moi, bool):
|
||||
if isinstance(moi, int | float) and not isinstance(moi, bool):
|
||||
return float(moi)
|
||||
return None
|
||||
|
||||
|
|
@ -352,7 +365,7 @@ def _overall_score(product_scores: dict[str, Any] | None) -> float | None:
|
|||
if not isinstance(product_scores, dict):
|
||||
return None
|
||||
overall = product_scores.get("overall")
|
||||
if isinstance(overall, (int, float)) and not isinstance(overall, bool):
|
||||
if isinstance(overall, int | float) and not isinstance(overall, bool):
|
||||
return float(overall)
|
||||
return None
|
||||
|
||||
|
|
@ -398,14 +411,26 @@ def _market_now_summary(
|
|||
parts: list[str] = []
|
||||
if market_metrics is not None:
|
||||
velocity = market_metrics.get("unit_velocity")
|
||||
if isinstance(velocity, (int, float)) and not isinstance(velocity, bool):
|
||||
if isinstance(velocity, int | float) and not isinstance(velocity, bool):
|
||||
parts.append(f"абсорбция ~{round(float(velocity), 1)} ед./мес")
|
||||
avg_price = analyze.get("market_avg_price_per_m2")
|
||||
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool):
|
||||
if isinstance(avg_price, int | float) and not isinstance(avg_price, bool):
|
||||
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
|
||||
n_comp = _analog_count(analyze, market_metrics)
|
||||
if n_comp is not None:
|
||||
parts.append(f"{n_comp} ЖК-конкурентов рядом")
|
||||
# #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
|
||||
# всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
|
||||
# Метка честно следует источнику: obj_count → «в выборке района», локальный fallback
|
||||
# из analyze (competitors_total / len(competitors)) → «рядом».
|
||||
if market_metrics is not None and isinstance(market_metrics.get("obj_count"), int):
|
||||
parts.append(f"{market_metrics['obj_count']} ЖК в выборке района")
|
||||
else:
|
||||
n_local: int | None = None
|
||||
pulse = analyze.get("market_pulse")
|
||||
if isinstance(pulse, dict) and isinstance(pulse.get("competitors_total"), int):
|
||||
n_local = pulse["competitors_total"]
|
||||
elif isinstance(analyze.get("competitors"), list):
|
||||
n_local = len(analyze["competitors"])
|
||||
if n_local is not None:
|
||||
parts.append(f"{n_local} ЖК-конкурентов рядом")
|
||||
if not parts:
|
||||
return None
|
||||
return "Текущий рынок: " + ", ".join(parts) + "."
|
||||
|
|
@ -616,6 +641,7 @@ def _build_confidence(
|
|||
market_metrics, future_supply, forecasts, product_scores, special_indices
|
||||
),
|
||||
deal_count=_deal_count(analyze, market_metrics),
|
||||
deal_count_months=_deal_count_months(market_metrics),
|
||||
analog_count=_analog_count(analyze, market_metrics),
|
||||
domrf_coverage=_domrf_coverage(analyze, supply_layers),
|
||||
history_months=_history_months(market_metrics, forecasts),
|
||||
|
|
|
|||
|
|
@ -143,7 +143,12 @@ _GEO_WEIGHT_SCALE_KM: float = 3.0
|
|||
# НЕ исключаем проект и НЕ даём полный вес — низкий floor, чтобы он мог сигналить, но
|
||||
# не доминировал над проектом с подтверждённой близостью. None-not-0 дух (есть проект,
|
||||
# но геопривязка слабая → down-weight, а не выкидываем).
|
||||
_GEO_WEIGHT_UNKNOWN: float = 0.1
|
||||
#
|
||||
# ВАЖНО: значение НИЖЕ exp(−6.9/3)≈0.10 (вес подтверждённо дальнего ~6.9 км проекта),
|
||||
# иначе «координаты неизвестны» даёт бо́льший вес чем «подтверждённо далеко» — инверсия.
|
||||
# При scale=3 км: exp(−9/3)=exp(−3)≈0.050. Значение 0.05 ≈ «условные 9 км»: проект без
|
||||
# координат не перебивает подтверждённо дальний (>6.9 км), но сохраняет ненулевой сигнал.
|
||||
_GEO_WEIGHT_UNKNOWN: float = 0.05
|
||||
|
||||
# Минимум доступных осей пересечения для пары кандидат↔наш проект, чтобы считать оценку
|
||||
# надёжной. < этого (напр. только класс) → пара low-confidence (сигнал есть, но тонкий).
|
||||
|
|
@ -1023,9 +1028,7 @@ def _launch_window_horizon(launch_window: SpecialIndex) -> int | None:
|
|||
return horizon if isinstance(horizon, int) else None
|
||||
|
||||
|
||||
def _candidate_release_month(
|
||||
launch_window: SpecialIndex, *, as_of: date
|
||||
) -> date | None:
|
||||
def _candidate_release_month(launch_window: SpecialIndex, *, as_of: date) -> date | None:
|
||||
"""Когда рекомендованный проект реально выйдет на рынок (тайминг §25.3). PURE.
|
||||
|
||||
= as_of (дата отчёта) + горизонт окна запуска §25.1: на этот месяц #980 предсказывает
|
||||
|
|
@ -1288,9 +1291,7 @@ def _build_cannibalization_true(
|
|||
contributions: list[tuple[float, OwnProject, _PairOverlap]] = []
|
||||
for own in portfolio:
|
||||
distance_km = _own_distance_km(centroid, own)
|
||||
price_overlap = _price_overlap(
|
||||
candidate_band, own.price_min_per_m2, own.price_max_per_m2
|
||||
)
|
||||
price_overlap = _price_overlap(candidate_band, own.price_min_per_m2, own.price_max_per_m2)
|
||||
pair = _own_portfolio_overlap(
|
||||
class_overlap=_class_overlap(spec.obj_class, own.obj_class),
|
||||
price_overlap=price_overlap,
|
||||
|
|
@ -1315,7 +1316,7 @@ def _build_cannibalization_true(
|
|||
# Топ-вкладчики (сильнейшие каннибализаторы) — детерминированно по signal DESC,
|
||||
# tie-break по имени проекта (стабильно, без RNG/порядка set).
|
||||
contributions.sort(key=lambda t: (-t[0], t[1].name))
|
||||
top = contributions[: _COMPETITOR_TOP_N]
|
||||
top = contributions[:_COMPETITOR_TOP_N]
|
||||
confidence = _cap_confidence(_portfolio_confidence(portfolio))
|
||||
return SpecialIndex(
|
||||
key=KEY_CANNIBALIZATION,
|
||||
|
|
@ -1382,9 +1383,7 @@ def _axes_available_summary(pairs: Sequence[_PairOverlap]) -> dict[str, int]:
|
|||
return summary
|
||||
|
||||
|
||||
def _contribution_detail(
|
||||
signal: float, own: OwnProject, pair: _PairOverlap
|
||||
) -> dict[str, Any]:
|
||||
def _contribution_detail(signal: float, own: OwnProject, pair: _PairOverlap) -> dict[str, Any]:
|
||||
"""Explainability-карточка одного топ-каннибализатора. PURE."""
|
||||
return {
|
||||
"name": own.name,
|
||||
|
|
@ -1689,7 +1688,13 @@ def compute_special_indices(
|
|||
|
||||
indices: dict[str, SpecialIndex] = {key: _run(key, builders[key]) for key in _INDEX_KEYS}
|
||||
|
||||
confidence = _min_confidence([idx.confidence for idx in indices.values()])
|
||||
# confidence = MIN по ДОСТУПНЫМ индексам (контракт SpecialIndices / docstring выше).
|
||||
# Недоступный индекс (_unavailable → value=None, confidence='low') НЕ участвует: его
|
||||
# 'low' — артефакт деградации, а не сигнал низкой уверенности доступных индексов
|
||||
# (#1592: _min_confidence отбрасывает только None, поэтому фильтруем здесь по value).
|
||||
confidence = _min_confidence(
|
||||
[idx.confidence for idx in indices.values() if idx.value is not None]
|
||||
)
|
||||
|
||||
n_available = sum(1 for idx in indices.values() if idx.value is not None)
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import logging
|
|||
|
||||
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
|
||||
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.filemanagement import new as ezdxf_new
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
|
|
@ -43,13 +44,33 @@ _LAYER_BUILDINGS = "BUILDINGS"
|
|||
_LABEL_HEIGHT_M = 2.0
|
||||
|
||||
|
||||
def _polygon_points(poly: Polygon) -> list[tuple[float, float]]:
|
||||
"""Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||
coords = list(poly.exterior.coords)
|
||||
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||||
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||
pts = list(coords)
|
||||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||||
if len(coords) > 1 and coords[0] == coords[-1]:
|
||||
coords = coords[:-1]
|
||||
return [(float(x), float(y)) for x, y in coords]
|
||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||
pts = pts[:-1]
|
||||
return [(float(x), float(y)) for x, y in pts]
|
||||
|
||||
|
||||
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
|
||||
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
|
||||
|
||||
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
|
||||
замкнутой полилинией на том же слое — иначе легальные вырезы (двор, сервитут,
|
||||
охранная зона) терялись бы и заливались сплошняком.
|
||||
"""
|
||||
msp.add_lwpolyline(
|
||||
_ring_points(poly.exterior.coords),
|
||||
close=True,
|
||||
dxfattribs={"layer": layer},
|
||||
)
|
||||
for interior in poly.interiors:
|
||||
msp.add_lwpolyline(
|
||||
_ring_points(interior.coords),
|
||||
close=True,
|
||||
dxfattribs={"layer": layer},
|
||||
)
|
||||
|
||||
|
||||
def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
||||
|
|
@ -71,16 +92,8 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
|||
msp = doc.modelspace()
|
||||
|
||||
# Участок и пятно застройки — из метрической геометрии Parcel.
|
||||
msp.add_lwpolyline(
|
||||
_polygon_points(parcel.polygon_m),
|
||||
close=True,
|
||||
dxfattribs={"layer": _LAYER_PARCEL},
|
||||
)
|
||||
msp.add_lwpolyline(
|
||||
_polygon_points(parcel.buildable_m),
|
||||
close=True,
|
||||
dxfattribs={"layer": _LAYER_BUILDABLE},
|
||||
)
|
||||
_add_polygon(msp, parcel.polygon_m, _LAYER_PARCEL)
|
||||
_add_polygon(msp, parcel.buildable_m, _LAYER_BUILDABLE)
|
||||
|
||||
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
|
||||
features = variant.buildings_geojson.get("features", [])
|
||||
|
|
@ -91,18 +104,17 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
|||
if footprint is None:
|
||||
continue
|
||||
section_count += 1
|
||||
msp.add_lwpolyline(
|
||||
_polygon_points(footprint),
|
||||
close=True,
|
||||
dxfattribs={"layer": _LAYER_BUILDINGS},
|
||||
)
|
||||
_add_polygon(msp, footprint, _LAYER_BUILDINGS)
|
||||
centroid = footprint.centroid
|
||||
label = str(_feature_section_id(feature, section_count))
|
||||
text = msp.add_text(
|
||||
label,
|
||||
dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M},
|
||||
)
|
||||
text.set_placement((float(centroid.x), float(centroid.y)))
|
||||
text.set_placement(
|
||||
(float(centroid.x), float(centroid.y)),
|
||||
align=TextEntityAlignment.MIDDLE_CENTER,
|
||||
)
|
||||
|
||||
stream = io.BytesIO()
|
||||
doc.write(stream, fmt="bin")
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from shapely.strtree import STRtree
|
|||
|
||||
from app.schemas.concept import ConceptInput, ConceptVariant
|
||||
from app.services.generative import financial, teap
|
||||
from app.services.generative.geometry import Parcel
|
||||
from app.services.generative.geometry import Parcel, ParcelGeometryError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -191,11 +191,25 @@ def place_strategy(
|
|||
parcel: Parcel,
|
||||
payload: ConceptInput,
|
||||
spec: StrategySpec,
|
||||
) -> ConceptVariant:
|
||||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant."""
|
||||
) -> ConceptVariant | None:
|
||||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
|
||||
|
||||
Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
|
||||
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
|
||||
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный —
|
||||
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
|
||||
"""
|
||||
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
|
||||
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
|
||||
footprints = _greedy_place(parcel, spec, coverage_cap)
|
||||
if not footprints:
|
||||
logger.warning(
|
||||
"strategy=%s placed 0 sections (footprint %.0fx%.0f m not buildable) — отбраковка",
|
||||
spec.name,
|
||||
spec.section_w,
|
||||
spec.section_d,
|
||||
)
|
||||
return None
|
||||
|
||||
teap_result = teap.compute_teap(
|
||||
footprints=footprints,
|
||||
|
|
@ -220,8 +234,22 @@ def place_strategy(
|
|||
|
||||
|
||||
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
|
||||
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced)."""
|
||||
variants = [place_strategy(parcel, payload, spec) for spec in _STRATEGIES]
|
||||
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
|
||||
|
||||
Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают
|
||||
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
|
||||
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
|
||||
:class:`ParcelGeometryError` (API мапит в 422) — лучше отказ, чем пустой/лживый ответ.
|
||||
"""
|
||||
variants = [
|
||||
variant
|
||||
for spec in _STRATEGIES
|
||||
if (variant := place_strategy(parcel, payload, spec)) is not None
|
||||
]
|
||||
if not variants:
|
||||
raise ParcelGeometryError(
|
||||
"ни одна стратегия размещения не вместила секцию — участок слишком узкий/мелкий"
|
||||
)
|
||||
logger.info(
|
||||
"placed all strategies: %s",
|
||||
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ class LLMResult:
|
|||
fallback_used: True если результат — сигнал к детерминированному fallback.
|
||||
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
|
||||
redaction_refused/provider_error/call_cap/no_api_key). None при ok.
|
||||
finish_reason: причина завершения от провайдера (stop/length/content_filter/
|
||||
tool_calls/…). None при fallback. Консьюмер ОБЯЗАН проверять её даже при
|
||||
ok=True: 'length'/'content_filter' → ответ обрезан/отфильтрован (частичный
|
||||
или пустой content) и не является полноценным результатом.
|
||||
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
|
||||
model: модель, ответившая на запрос ("" при fallback).
|
||||
"""
|
||||
|
|
@ -67,6 +71,7 @@ class LLMResult:
|
|||
tool_calls: list[ToolCall] = field(default_factory=list)
|
||||
fallback_used: bool = False
|
||||
reason: str | None = None
|
||||
finish_reason: str | None = None
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
model: str = ""
|
||||
|
|
@ -81,6 +86,7 @@ class LLMResult:
|
|||
ok=True,
|
||||
content=resp.content,
|
||||
tool_calls=list(resp.tool_calls),
|
||||
finish_reason=resp.finish_reason,
|
||||
prompt_tokens=resp.prompt_tokens,
|
||||
completion_tokens=resp.completion_tokens,
|
||||
model=resp.model,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,21 @@ class LLMProvider(ABC):
|
|||
# ── OpenAI (external) ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _coerce_token_count(value: Any) -> int:
|
||||
"""usage-токены → int, толерантно к мусору (str "abc"/None/dict → 0).
|
||||
|
||||
OpenAI обычно отдаёт int, но через внешний прокси/нестандартный провайдер поле
|
||||
может прийти нечисловым. int() на таком значении бросил бы ValueError/TypeError
|
||||
мимо LLM*-контракта (его ловит client._call_with_retries) и пробил бы инвариант
|
||||
«complete никогда не падает наружу» (#1601). Невалидный токен-счётчик — не повод
|
||||
ронять ответ: деградируем до 0.
|
||||
"""
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
|
||||
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
|
||||
choices = data.get("choices") or []
|
||||
|
|
@ -114,8 +129,8 @@ def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> Prov
|
|||
content=message.get("content"),
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=choice.get("finish_reason"),
|
||||
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||
completion_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||
prompt_tokens=_coerce_token_count(usage.get("prompt_tokens", 0)),
|
||||
completion_tokens=_coerce_token_count(usage.get("completion_tokens", 0)),
|
||||
model=str(data.get("model") or fallback_model),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -77,10 +78,68 @@ _PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\
|
|||
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
|
||||
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
|
||||
_PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
|
||||
# Телефон РФ «локальный»: 10 значащих цифр БЕЗ кода страны, начинаются с мобильного
|
||||
# «9», сгруппированы 9XX XXX XX XX через пробел/дефис (#1641). _PHONE_RE требует
|
||||
# префикс +7/8, _PHONE_BARE_RE — ровно 11 слитных цифр → формат «922 123 45 67» /
|
||||
# «922-123-45-67» проходил мимо. Якорь «9» + фиксированная группировка 3-3-2-2 не
|
||||
# пересекается с паспортом (4+6) / СНИЛС (3-3-3 2). Ставится ПОСЛЕ префиксных форм,
|
||||
# чтобы local-часть «+7 912 …» сначала ушла как полноценный phone.
|
||||
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
|
||||
# Email.
|
||||
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
|
||||
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
|
||||
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
|
||||
# ИНН: требуется контекстный якорь — маркер «ИНН» / «inn» (регистронезависимо) не
|
||||
# далее ~20 символов перед 10/12-значным блоком цифр (#1640). Голые цифры без якоря
|
||||
# НЕ редактируются — это исключает легитимные суммы (1 200 000 000 = 10 цифр) и
|
||||
# прочие числа, которые случайно совпадают с длиной ИНН.
|
||||
# «Прокладка» (?:[^\d\n]{0,20}) позволяет «ИНН физлица 500100732259» (слово между
|
||||
# маркером и числом), но не перешагивает через строки или другие цифровые блоки.
|
||||
# Группа 1 захватывает только цифры — передаётся в _inn_checksum_valid.
|
||||
# Границы слова (#1640 follow-up): (?<!\w) / (?!\w) вокруг цифрового блока гарантируют,
|
||||
# что блок не является частью более длинного алфавитно-цифрового токена.
|
||||
# \b не подходит: Python \w включает цифры и буквы, поэтому \b между буквой и цифрой
|
||||
# отсутствует (оба — \w). Lookaround'ы (?<!\w)/(?!\w) дают нужную границу «не-\w перед/после».
|
||||
# (?<!\d) заменён на (?<!\w): закрывает случай «ref7707083893» (alpha-префикс).
|
||||
# (?!\d) заменён на (?!\w): закрывает случай «7707083893more» (alpha-суффикс).
|
||||
_INN_RE = re.compile(r"(?i:инн|inn)\s*[:№]?\s*(?:[^\d\n]{0,20})(?<!\w)(\d{12}|\d{10})(?!\w)")
|
||||
|
||||
|
||||
def _inn_checksum_valid(token: str) -> bool:
|
||||
"""Проверить контрольные цифры ИНН по алгоритму ФНС.
|
||||
|
||||
10-значный (юрлицо): одна контрольная цифра (последняя).
|
||||
12-значный (физлицо/ИП): две контрольные цифры (11-я и 12-я).
|
||||
Веса — стандартные коэффициенты ФНС.
|
||||
|
||||
Вызывается из _inn_repl с group(1) — только цифровой блок без keyword-префикса.
|
||||
Дополнительный gate поверх контекстного якоря (_INN_RE): отфильтровывает
|
||||
синтетические 10/12-значные числа, у которых первые цифры случайно дают верную
|
||||
контрольную сумму.
|
||||
"""
|
||||
digits = [int(c) for c in token]
|
||||
if len(digits) == 10:
|
||||
weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
|
||||
control = sum(w * d for w, d in zip(weights, digits[:9], strict=True)) % 11 % 10
|
||||
return control == digits[9]
|
||||
if len(digits) == 12:
|
||||
w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
|
||||
w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
|
||||
c1 = sum(w * d for w, d in zip(w1, digits[:10], strict=True)) % 11 % 10
|
||||
c2 = sum(w * d for w, d in zip(w2, digits[:11], strict=True)) % 11 % 10
|
||||
return c1 == digits[10] and c2 == digits[11]
|
||||
return False
|
||||
|
||||
|
||||
def _inn_repl(match: re.Match[str]) -> str:
|
||||
"""re.sub callback: редактировать ИНН только при валидной контрольной сумме.
|
||||
|
||||
_INN_RE захватывает цифры в group(1) — keyword-префикс в group(0). Для замены
|
||||
нужна только часть с цифрами (group 1). Если checksum не проходит — возвращаем
|
||||
исходную строку целиком (keyword + цифры), не удаляем ничего.
|
||||
"""
|
||||
digits = match.group(1)
|
||||
return "[REDACTED:inn]" if _inn_checksum_valid(digits) else match.group(0)
|
||||
|
||||
|
||||
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
|
||||
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
|
||||
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
|
||||
|
|
@ -97,18 +156,22 @@ _FULLNAME_RE = re.compile(
|
|||
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
|
||||
)
|
||||
|
||||
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
|
||||
# (regex, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
|
||||
# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
|
||||
# Применяются последовательно в этом порядке.
|
||||
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
|
||||
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
|
||||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(_SNILS_RE, "snils"),
|
||||
(_PASSPORT_RE, "passport"),
|
||||
(_PHONE_RE, "phone"),
|
||||
(_PHONE_BARE_RE, "phone"),
|
||||
(_EMAIL_RE, "email"),
|
||||
(_INN_RE, "inn"),
|
||||
(_SNILS_BARE_RE, "snils"),
|
||||
(_FULLNAME_RE, "name"),
|
||||
_Repl = str | Callable[[re.Match[str]], str]
|
||||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
|
||||
(_SNILS_RE, "snils", "[REDACTED:snils]"),
|
||||
(_PASSPORT_RE, "passport", "[REDACTED:passport]"),
|
||||
(_PHONE_RE, "phone", "[REDACTED:phone]"),
|
||||
(_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
|
||||
(_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
|
||||
(_EMAIL_RE, "email", "[REDACTED:email]"),
|
||||
(_INN_RE, "inn", _inn_repl),
|
||||
(_SNILS_BARE_RE, "snils", "[REDACTED:snils]"),
|
||||
(_FULLNAME_RE, "name", "[REDACTED:name]"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -121,9 +184,16 @@ def scrub_text(value: str) -> str:
|
|||
if not value:
|
||||
return value
|
||||
redacted = value
|
||||
for pattern, kind in _PII_PATTERNS:
|
||||
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
|
||||
if n:
|
||||
placeholder = "[REDACTED:%s]"
|
||||
for pattern, kind, repl in _PII_PATTERNS:
|
||||
# n из subn для callback-repl (ИНН) считает ВСЕ совпадения, включая кандидаты,
|
||||
# которые callback вернул без изменений (не прошли checksum). Поэтому реальное
|
||||
# число замен берём по приросту числа плейсхолдеров — корректно и для str, и
|
||||
# для callback, без утечки самого PII-значения в лог.
|
||||
before_count = redacted.count(placeholder % kind)
|
||||
redacted = pattern.sub(repl, redacted)
|
||||
n = redacted.count(placeholder % kind) - before_count
|
||||
if n > 0:
|
||||
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
|
||||
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
|
||||
return redacted
|
||||
|
|
@ -135,7 +205,7 @@ def _scrub_value(value: Any) -> Any:
|
|||
return scrub_text(value)
|
||||
if isinstance(value, dict):
|
||||
return {k: _scrub_value(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
if isinstance(value, list | tuple):
|
||||
scrubbed = [_scrub_value(v) for v in value]
|
||||
return type(value)(scrubbed)
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import logging
|
|||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from contextlib import closing
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -466,13 +467,12 @@ def get_sqlite_info(sqlite_path: str | Path) -> dict[str, Any]:
|
|||
info["size_bytes"] = st.st_size
|
||||
info["modified_at"] = st.st_mtime # epoch seconds
|
||||
try:
|
||||
c = sqlite3.connect(p)
|
||||
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
|
||||
info["corp_room_month"] = c.execute("SELECT COUNT(*) FROM objective_corp_month").fetchone()[
|
||||
0
|
||||
]
|
||||
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
|
||||
c.close()
|
||||
with closing(sqlite3.connect(p)) as c:
|
||||
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
|
||||
info["corp_room_month"] = c.execute(
|
||||
"SELECT COUNT(*) FROM objective_corp_month"
|
||||
).fetchone()[0]
|
||||
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
|
||||
except sqlite3.Error as e:
|
||||
info["error"] = str(e)
|
||||
return info
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ opens the original DOM.РФ URL (we don't need to mirror originals).
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
|
@ -36,21 +37,40 @@ def make_thumbnail(
|
|||
) -> Path | None:
|
||||
"""Generate a WebP cover-thumbnail next to src. Returns thumb path on success.
|
||||
|
||||
Skips if thumb already exists and overwrite=False. Returns None on any error
|
||||
Skips if thumb already exists, is not stale (dst mtime >= src mtime) and
|
||||
overwrite=False. A stale thumb (src rewritten after thumb, e.g. original
|
||||
re-downloaded on size mismatch) is regenerated. Returns None on any error
|
||||
(logged) so callers can keep going with the next file.
|
||||
"""
|
||||
if not src.exists():
|
||||
return None
|
||||
dst = thumb_path_for(src)
|
||||
if dst.exists() and not overwrite:
|
||||
return dst
|
||||
# Existence alone is not freshness: if src was rewritten after dst was
|
||||
# generated, the thumb is stale and must be regenerated. Only skip when
|
||||
# the thumb is at least as new as the source.
|
||||
try:
|
||||
if dst.stat().st_mtime >= src.stat().st_mtime:
|
||||
return dst
|
||||
except OSError as e:
|
||||
# stat failed (race / removed) — fall through and try to regenerate.
|
||||
logger.warning("thumbnail freshness check %s failed: %s", src, e)
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im = ImageOps.exif_transpose(im)
|
||||
im = im.convert("RGB")
|
||||
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
im.save(dst, format="WEBP", quality=quality, method=4)
|
||||
# Atomic write: encode to a sibling temp file, then os.replace() so a
|
||||
# crash/OOM mid-encode never leaves a truncated .webp at the canonical
|
||||
# path (which dst.exists() would later treat as a valid cached thumb).
|
||||
tmp = dst.with_name(f".{dst.name}.tmp")
|
||||
try:
|
||||
im.save(tmp, format="WEBP", quality=quality, method=4)
|
||||
os.replace(tmp, dst)
|
||||
except BaseException:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
return dst
|
||||
except Exception as e:
|
||||
logger.warning("thumbnail %s failed: %s", src, e)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,38 @@ STATUS_FREE = "free"
|
|||
STATUS_SOLD = "sold"
|
||||
STATUS_RESERVED = "reserved"
|
||||
|
||||
# Паттерны для извлечения статуса (issue #1609).
|
||||
# Все морфоварианты: продан/продана/продано, забронирован[аоы]?, реализован[аоы]?.
|
||||
_STATUS_KW_RE = re.compile(
|
||||
r"(в\s*продаже|свободн[аоы]?|free"
|
||||
r"|продан[аоы]?|реализован[аоы]?|sold"
|
||||
r"|забронирован[аоы]?|бронь|reserved)",
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
# CSS-классы, характерные для статус-бейджей в Next.js/Tailwind.
|
||||
# Требуем слово «status» как отдельный токен внутри hyphen-separated CSS-класса
|
||||
# (напр. «status-badge», «flat-status-tag», «object-status»).
|
||||
# Предыдущий паттерн «status|badge|tag|chip|label» был слишком широк —
|
||||
# срабатывал на любой generic элемент с классом «tag»/«chip»/«label»/«badge»,
|
||||
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
|
||||
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
|
||||
|
||||
|
||||
def _classify_status_kw(matched_text: str) -> str | None:
|
||||
"""Классифицировать совпавшее ключевое слово в STATUS_*.
|
||||
|
||||
sold/reserved проверяются ПЕРВЫМИ — они специфичнее "в продаже" и никогда
|
||||
не появляются в навигационных блоках страницы, в отличие от «в продаже».
|
||||
"""
|
||||
s = matched_text.lower()
|
||||
if any(kw in s for kw in ("продан", "реализован", "sold")):
|
||||
return STATUS_SOLD
|
||||
if any(kw in s for kw in ("бронь", "забронирован", "reserved")):
|
||||
return STATUS_RESERVED
|
||||
if any(kw in s for kw in ("продаже", "свободн", "free")):
|
||||
return STATUS_FREE
|
||||
return None
|
||||
|
||||
|
||||
# ── HTML fetching ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -157,6 +189,28 @@ class _TextCollector(HTMLParser):
|
|||
extraction известных структур страницы каталога.
|
||||
"""
|
||||
|
||||
# HTML5 void-элементы: не имеют закрывающего тега → handle_endtag не вызывается.
|
||||
# Если пушить их в стек/буфер, чужой handle_endtag поп'ает чужой фрейм →
|
||||
# рассинхрон стека, текст блоков теряется (issue #1608).
|
||||
_VOID_TAGS = frozenset(
|
||||
{
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._stack: list[tuple[str, dict[str, str]]] = []
|
||||
|
|
@ -165,11 +219,15 @@ class _TextCollector(HTMLParser):
|
|||
self._buf: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag in self._VOID_TAGS:
|
||||
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
||||
attr_dict = {k: (v or "") for k, v in attrs}
|
||||
self._stack.append((tag, attr_dict))
|
||||
self._buf.append("") # начало нового буфера для этого тега
|
||||
|
||||
def handle_endtag(self, _tag: str) -> None:
|
||||
if _tag in self._VOID_TAGS:
|
||||
return # void-теги фрейм не открывали — нечего поп'ать (issue #1608)
|
||||
if not self._stack:
|
||||
return
|
||||
tag, attr_dict = self._stack.pop()
|
||||
|
|
@ -239,8 +297,11 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
|
||||
|
||||
# Price: "7 890 000 ₽" или "7 890 000 руб"
|
||||
# Negative lookahead (?!\s*[/⁄]) исключает цену за м² ("217 835 ₽/м²"),
|
||||
# которая на странице обычно выше полной стоимости и иначе матчилась бы
|
||||
# первой → отбрасывалась санити-границей → price_rub=NULL (issue #1645).
|
||||
price_match = re.search(
|
||||
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)",
|
||||
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)(?!\s*[/⁄])",
|
||||
html,
|
||||
re.UNICODE,
|
||||
)
|
||||
|
|
@ -267,20 +328,66 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
except ValueError:
|
||||
pass
|
||||
|
||||
# Status: ищем характерные слова рядом с "статус" или в badge
|
||||
status_match = re.search(
|
||||
r"(в\s*продаже|свободна|free|продано|sold|забронирована|бронь|reserved)",
|
||||
html,
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
if status_match:
|
||||
s = status_match.group(1).lower()
|
||||
if any(kw in s for kw in ("продаже", "свободна", "free")):
|
||||
result["status"] = STATUS_FREE
|
||||
elif any(kw in s for kw in ("продано", "sold")):
|
||||
result["status"] = STATUS_SOLD
|
||||
elif any(kw in s for kw in ("бронь", "забронирована", "reserved")):
|
||||
result["status"] = STATUS_RESERVED
|
||||
# Status extraction (issue #1609 fix).
|
||||
#
|
||||
# Проблема: re.search по всему HTML находит "в продаже" из навигации
|
||||
# ("другие квартиры в продаже") РАНЬШЕ, чем реальный статус-бейдж →
|
||||
# проданная/забронированная квартира ошибочно маркируется как free.
|
||||
#
|
||||
# Стратегия (3 уровня, от точного к широкому):
|
||||
#
|
||||
# 1. Блоки с CSS-классом, содержащим "status"/"badge"/"tag"/"chip"/"label" —
|
||||
# типовые имена для статус-бейджей в Next.js/Tailwind.
|
||||
# 2. Ближайший блок ПОСЛЕ лейбла "Статус" (proximity-поиск в blocks).
|
||||
# 3. Полный перебор всех blocks: sold/reserved побеждают free
|
||||
# (они никогда не появляются в навигации, в отличие от "в продаже").
|
||||
#
|
||||
# Морфоварианты: продан / продана / продано / продан → продан[аоы]?
|
||||
# Аналогично: забронирован[аоы]? / реализован[аоы]? (синоним для sold).
|
||||
|
||||
# Уровень 1: блок с CSS-классом типа badge/status/tag
|
||||
status_from_badge: str | None = None
|
||||
for cls, block_text in blocks:
|
||||
if _STATUS_BADGE_CLS_RE.search(cls):
|
||||
m = _STATUS_KW_RE.search(block_text)
|
||||
if m:
|
||||
status_from_badge = _classify_status_kw(m.group(1))
|
||||
if status_from_badge:
|
||||
break
|
||||
|
||||
if status_from_badge:
|
||||
result["status"] = status_from_badge
|
||||
else:
|
||||
# Уровень 2: блок сразу после лейбла "Статус"
|
||||
status_label_value = _find_text_near(blocks, r"^статус$")
|
||||
status_from_label: str | None = None
|
||||
if status_label_value:
|
||||
m2 = _STATUS_KW_RE.search(status_label_value)
|
||||
if m2:
|
||||
status_from_label = _classify_status_kw(m2.group(1))
|
||||
|
||||
if status_from_label:
|
||||
result["status"] = status_from_label
|
||||
else:
|
||||
# Уровень 3: перебор всех blocks — sold/reserved побеждают free.
|
||||
# Если встретили sold/reserved — сразу break (не появляются в nav).
|
||||
# Если только free — запоминаем как кандидата (может быть перебит).
|
||||
free_candidate: bool = False
|
||||
sold_reserved_found: str | None = None
|
||||
for _cls, block_text in blocks:
|
||||
m3 = _STATUS_KW_RE.search(block_text)
|
||||
if not m3:
|
||||
continue
|
||||
classified = _classify_status_kw(m3.group(1))
|
||||
if classified in (STATUS_SOLD, STATUS_RESERVED):
|
||||
sold_reserved_found = classified
|
||||
break # точнее nav-текстов, дальше не ищем
|
||||
if classified == STATUS_FREE:
|
||||
free_candidate = True # продолжаем — вдруг sold встретится позже
|
||||
if sold_reserved_found:
|
||||
result["status"] = sold_reserved_found
|
||||
elif free_candidate:
|
||||
result["status"] = STATUS_FREE
|
||||
|
||||
# Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
|
||||
finishing_match = re.search(
|
||||
|
|
@ -483,7 +590,18 @@ 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)
|
||||
# success отражает прохождение пайплайна fetch+parse без исключения; реально
|
||||
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
||||
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
||||
# ненайденном ods_id / пустом парсе (fields_extracted==0) — issue #1610.
|
||||
outcome["success"] = True
|
||||
if not outcome["updated"]:
|
||||
logger.warning(
|
||||
"catalog scrape ods_id=%s: fetched+parsed but DB row NOT updated "
|
||||
"(fields=%d, ods_id missing or all-NULL parse)",
|
||||
ods_id,
|
||||
outcome["fields_extracted"],
|
||||
)
|
||||
logger.info(
|
||||
"catalog scrape ods_id=%s: fields=%d updated=%s",
|
||||
ods_id,
|
||||
|
|
@ -512,11 +630,15 @@ async def scrape_catalog_batch(
|
|||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||
|
||||
Returns:
|
||||
{total, success, failed, fields_total}
|
||||
{total, success, updated, failed, fields_total}
|
||||
- success: прошли fetch+parse без исключения
|
||||
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||
фактическое число записанных квартир, в отличие от success
|
||||
"""
|
||||
stats: dict[str, Any] = {
|
||||
"total": len(flats),
|
||||
"success": 0,
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"fields_total": 0,
|
||||
}
|
||||
|
|
@ -550,6 +672,8 @@ async def scrape_catalog_batch(
|
|||
if outcome["success"]:
|
||||
stats["success"] += 1
|
||||
stats["fields_total"] += outcome["fields_extracted"]
|
||||
if outcome["updated"]:
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
|
|
@ -565,9 +689,10 @@ async def scrape_catalog_batch(
|
|||
raise
|
||||
|
||||
logger.info(
|
||||
"scrape_catalog_batch done: total=%d success=%d failed=%d fields_total=%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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -115,6 +115,30 @@ def _to_int(v: Any) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _to_float(v: Any) -> float | None:
|
||||
"""Coerce a DOM.РФ numeric value into float. None / bool / non-numeric / empty → None.
|
||||
Accepts int, float, and numeric strings ('45.2'). Mirror of _to_int for area/price."""
|
||||
if v is None or isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, int | float):
|
||||
f = float(v)
|
||||
if f != f or f in (float("inf"), float("-inf")): # NaN / ±inf guard
|
||||
return None
|
||||
return f
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
f = float(s)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
if f != f or f in (float("inf"), float("-inf")):
|
||||
return None
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _to_date(v: Any) -> date | None:
|
||||
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
|
||||
'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'.
|
||||
|
|
@ -414,8 +438,13 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
|||
|
||||
# Derive price_per_m2 when API returns price_rub but omits pricePerSquareMeter.
|
||||
# Covers cases where the table endpoint has the flat price but no pre-computed m² rate.
|
||||
if price_per_m2 is None and price_rub is not None and total_area and total_area > 0:
|
||||
price_per_m2 = round(price_rub / total_area, 2)
|
||||
# Coerce оба операнда в float ДО сравнения/деления: API иногда отдаёт totalArea/price
|
||||
# строкой ('45.2'), и `total_area > 0` на str роняло бы _norm_flat с TypeError
|
||||
# → весь run падал бы в status='failed' (#1644).
|
||||
price_rub_num = _to_float(price_rub)
|
||||
total_area_num = _to_float(total_area)
|
||||
if price_per_m2 is None and price_rub_num is not None and total_area_num and total_area_num > 0:
|
||||
price_per_m2 = round(price_rub_num / total_area_num, 2)
|
||||
logger.info(
|
||||
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
|
||||
price_per_m2,
|
||||
|
|
@ -1432,34 +1461,78 @@ def _place_str(region_code: int) -> str:
|
|||
return str(region_code)
|
||||
|
||||
|
||||
OBJECTS_PAGE_SIZE = 500
|
||||
|
||||
|
||||
async def fetch_objects_for_status(
|
||||
sess: BrowserSession, place: str, status: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch ALL objects for a given (place, objStatus) — server returns up to limit=999999."""
|
||||
payload = await sess.get_json(
|
||||
PATH_OBJECTS,
|
||||
{
|
||||
"offset": 0,
|
||||
"limit": 999999,
|
||||
"sortField": "default",
|
||||
"sortType": "desc",
|
||||
"place": place,
|
||||
"objStatus": status,
|
||||
},
|
||||
"""Fetch ALL objects for a given (place, objStatus), пагинируя по страницам.
|
||||
|
||||
Раньше делали единственный запрос с limit=999999 и доверяли допущению, что сервер
|
||||
вернёт всё. Если WAF/прокси DOM.РФ обрезает гигантский limit до своего max, хвост
|
||||
объектов молча терялся (не скрейпился), а run всё равно рапортовал status='done' (#1605).
|
||||
Теперь идём страницами по OBJECTS_PAGE_SIZE и аккумулируем, пока не наберём total
|
||||
(из payload) либо страница не вернётся короче запрошенной / пустой.
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
total: int | None = None
|
||||
while True:
|
||||
payload = await sess.get_json(
|
||||
PATH_OBJECTS,
|
||||
{
|
||||
"offset": offset,
|
||||
"limit": OBJECTS_PAGE_SIZE,
|
||||
"sortField": "default",
|
||||
"sortType": "desc",
|
||||
"place": place,
|
||||
"objStatus": status,
|
||||
},
|
||||
)
|
||||
page = _extract_list(payload)
|
||||
if total is None:
|
||||
total = _extract_total(payload)
|
||||
rows.extend(page)
|
||||
# Стоп-условия: пустая страница, недобор до размера страницы (последняя),
|
||||
# либо набрали заявленный total. total может быть None (сервер его не отдал) —
|
||||
# тогда полагаемся на размер страницы как сигнал конца.
|
||||
if not page or len(page) < OBJECTS_PAGE_SIZE:
|
||||
break
|
||||
if total is not None and len(rows) >= total:
|
||||
break
|
||||
offset += OBJECTS_PAGE_SIZE
|
||||
|
||||
logger.info(
|
||||
"kn/object place=%s status=%d -> %d/%s rows (paginated, page=%d)",
|
||||
place,
|
||||
status,
|
||||
len(rows),
|
||||
total,
|
||||
OBJECTS_PAGE_SIZE,
|
||||
)
|
||||
rows = _extract_list(payload)
|
||||
total = _extract_total(payload)
|
||||
logger.info("kn/object place=%s status=%d -> %d/%s rows", place, status, len(rows), total)
|
||||
if total is not None and len(rows) < total:
|
||||
logger.warning(
|
||||
"kn/object place=%s status=%d: получено %d < total=%d — возможен недобор хвоста",
|
||||
place,
|
||||
status,
|
||||
len(rows),
|
||||
total,
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict[str, Any]]:
|
||||
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened)."""
|
||||
try:
|
||||
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
|
||||
except Exception as e:
|
||||
logger.warning("flats fetch obj=%s failed: %s", obj_id, e)
|
||||
return []
|
||||
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened).
|
||||
|
||||
On HTTP / WAF / non-JSON errors raises — caller (_fetch_flats_safe) ловит и кладёт
|
||||
Exception в result-tuple, который result-loop отдаёт в _classify_and_log →
|
||||
kn_scrape_failures. Раньше try/except здесь возвращал [] на ЛЮБУЮ ошибку, из-за чего
|
||||
провал /portal/table (429, 5xx, WAF-challenge) не попадал в журнал отказов, а run
|
||||
рапортовал ложную полноту по квартирам (#1643). Поведение теперь как у остальных
|
||||
fetch_* endpoint'ов, которые исключения не глотают.
|
||||
"""
|
||||
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
|
||||
# Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]}
|
||||
return _flatten_table(payload)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ _EKB_LAT_MAX = 57.5
|
|||
|
||||
# Паттерн для разбора первого числа из строки координат
|
||||
# (ячейки могут содержать несколько точек через пробелы или запятую-десятичный разделитель)
|
||||
_COORD_FIRST_RE = re.compile(r"[\d]+[.,][\d]+")
|
||||
_COORD_FIRST_RE = re.compile(r"[\d]+(?:[.,][\d]+)?")
|
||||
|
||||
|
||||
def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] | None:
|
||||
|
|
@ -375,26 +375,28 @@ class EkburgPermitsClient:
|
|||
Пропускает листы «Справочник», «Лист1» и неизвестные.
|
||||
"""
|
||||
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||||
try:
|
||||
for sheet_name in wb.sheetnames:
|
||||
if sheet_name.lower() in _SKIP_SHEETS:
|
||||
continue
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
if sheet_name.lower() in _SKIP_SHEETS:
|
||||
continue
|
||||
permit_type = _detect_permit_type(sheet_name)
|
||||
if permit_type is None:
|
||||
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
|
||||
continue
|
||||
|
||||
permit_type = _detect_permit_type(sheet_name)
|
||||
if permit_type is None:
|
||||
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
|
||||
continue
|
||||
|
||||
sheet = wb[sheet_name]
|
||||
data_start = _detect_header_row(sheet)
|
||||
logger.info(
|
||||
"Parsing sheet %r (%s) year=%d, data starts at row %d",
|
||||
sheet_name,
|
||||
permit_type,
|
||||
year,
|
||||
data_start,
|
||||
)
|
||||
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
|
||||
sheet = wb[sheet_name]
|
||||
data_start = _detect_header_row(sheet)
|
||||
logger.info(
|
||||
"Parsing sheet %r (%s) year=%d, data starts at row %d",
|
||||
sheet_name,
|
||||
permit_type,
|
||||
year,
|
||||
data_start,
|
||||
)
|
||||
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
def _parse_sheet(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -143,6 +143,45 @@ _GRID_WALK_LAYERS: frozenset[str] = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
# ── РИАСУРТ Свердл (folderId 1224) — агломерация ЕКБ, multi-city scaling (#108) ──
|
||||
# РИАСУРТ Свердл агрегирует данные от МАЛЫХ МО Свердл (Берёзовский, В.Пышма,
|
||||
# Среднеуральск, Арамиль, Сысерть) — НЕ от ЕКБ-сити (тот не интегрирован в ФГИС ТП).
|
||||
# Слои на той же NSPD aeggis/v4 WMS-инфраструктуре, что и федеральные LAYERS, но
|
||||
# layerId из регионального каталога (84xxxx/85xxxx). 14 ключевых слоёв (см. issue #108).
|
||||
#
|
||||
# layerId → (topic, человекочитаемое название). topic группирует слой для analyze-gate
|
||||
# (тер.зона/функц.зона/красные линии/СЗЗ/ЗСО/затопление/КРТ и т.п.).
|
||||
RIASURT_SVERDL_LAYERS: dict[int, tuple[str, str]] = {
|
||||
# ПЗЗ / Планирование
|
||||
845274: ("territorial_zone", "Территориальные зоны"),
|
||||
846381: ("functional_zone", "Функциональные зоны"),
|
||||
844759: ("red_lines", "Красные линии"),
|
||||
844774: ("setback_lines", "Линии отступа от красных линий"),
|
||||
845298: ("ppt_oks_boundary", "Границы ППТ ОКС"),
|
||||
# Инженерные ЗОУИТ
|
||||
846365: ("szz", "Санитарно-защитная зона (СЗЗ)"),
|
||||
846369: ("san_gap_engineering", "Санитарный разрыв инженерных коммуникаций"),
|
||||
845392: ("szo", "Зоны санитарной охраны питьевого водоснабжения (ЗСО)"),
|
||||
846373: ("water_protection", "Водоохранные зоны"),
|
||||
# Risk
|
||||
845425: ("flood_zone", "Зоны затопления / подтопления"),
|
||||
846378: ("emergency_alert", "Зона экстренного оповещения"),
|
||||
844795: ("emergency_risk", "Территории риска ЧС"),
|
||||
# Opportunity
|
||||
844478: ("krt", "Территории КРТ"),
|
||||
846379: ("oez", "Особые экономические зоны (ОЭЗ)"),
|
||||
}
|
||||
|
||||
|
||||
def riasurt_layer_topic(layer_id: int) -> str:
|
||||
"""Topic (бакет для analyze-gate) по layerId РИАСУРТ Свердл.
|
||||
|
||||
Неизвестный layerId → 'other' (graceful; не падаем на новых слоях каталога).
|
||||
"""
|
||||
entry = RIASURT_SVERDL_LAYERS.get(layer_id)
|
||||
return entry[0] if entry else "other"
|
||||
|
||||
|
||||
# Default rate limit (мс между запросами) — баланс между скоростью и WAF
|
||||
DEFAULT_RATE_MS = 600
|
||||
|
||||
|
|
@ -612,6 +651,58 @@ class NSPDClient:
|
|||
)
|
||||
return deduped
|
||||
|
||||
# ── 3c. get_riasurt_sverdl_in_bbox (РИАСУРТ Свердл bulk, #108) ───────────
|
||||
|
||||
def get_riasurt_sverdl_in_bbox(
|
||||
self,
|
||||
bbox_3857: tuple[float, float, float, float],
|
||||
layers: list[int] | None = None,
|
||||
*,
|
||||
grid_n: int = 7,
|
||||
step_m: float = 50.0,
|
||||
) -> dict[int, list[NSPDFeature]]:
|
||||
"""Bulk WMS GetFeatureInfo по слоям РИАСУРТ Свердл в пределах bbox.
|
||||
|
||||
Для каждого layerId делает grid-walk (``get_features_in_bbox_grid``) — area/
|
||||
linear слои (зоны, линии) под-возвращаются single-pixel probe, поэтому grid.
|
||||
Слой, который упал (WAF / сеть), логируется и пропускается (его ключ = []),
|
||||
остальные слои продолжают — частичный harvest лучше полного провала по агломерации.
|
||||
|
||||
Args:
|
||||
bbox_3857: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры) — bbox одного МО.
|
||||
layers: список layerId РИАСУРТ. None → все 14 ключевых
|
||||
(``RIASURT_SVERDL_LAYERS``). Неизвестный layerId всё равно
|
||||
запрашивается (topic='other' на стороне классификатора).
|
||||
grid_n / step_m: параметры grid-walk (см. get_features_in_bbox_grid).
|
||||
|
||||
Returns:
|
||||
dict[layerId, list[NSPDFeature]]. Ключи — все запрошенные layerId
|
||||
(пустой list если слой пуст / упал). Стабильная форма для caller'а.
|
||||
"""
|
||||
layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys())
|
||||
result: dict[int, list[NSPDFeature]] = {}
|
||||
for layer_id in layer_ids:
|
||||
try:
|
||||
feats = self.get_features_in_bbox_grid(
|
||||
layer_id, bbox_3857, grid_n=grid_n, step_m=step_m
|
||||
)
|
||||
except (NspdLiteError, NspdLiteWafError) as exc:
|
||||
logger.warning(
|
||||
"get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем",
|
||||
layer_id,
|
||||
exc,
|
||||
)
|
||||
result[layer_id] = []
|
||||
continue
|
||||
result[layer_id] = feats
|
||||
logger.info(
|
||||
"get_riasurt_sverdl_in_bbox: layer=%d topic=%s count=%d",
|
||||
layer_id,
|
||||
riasurt_layer_topic(layer_id),
|
||||
len(feats),
|
||||
)
|
||||
return result
|
||||
|
||||
# ── 4. list_layers ──────────────────────────────────────────────────────
|
||||
|
||||
def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]:
|
||||
|
|
@ -924,6 +1015,7 @@ __all__ = [
|
|||
"LAYERS",
|
||||
"NSPD_THEMES",
|
||||
"NSPD_WMS_BASE",
|
||||
"RIASURT_SVERDL_LAYERS",
|
||||
"THEME_ARN",
|
||||
"THEME_PKK",
|
||||
"NSPDClient",
|
||||
|
|
@ -935,4 +1027,5 @@ __all__ = [
|
|||
"QuarterDump",
|
||||
"bbox_around_point_m",
|
||||
"lonlat_to_3857",
|
||||
"riasurt_layer_topic",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -329,25 +329,37 @@ def denorm_dump(
|
|||
features: плоский list из features_json JSONB (уже декодированный Python list).
|
||||
|
||||
Returns:
|
||||
dict {"parcels": N, "buildings": M, "errors": K} — количество обработанных строк.
|
||||
dict {"parcels": N, "buildings": M, "errors": K, "skipped": S} —
|
||||
количество обработанных строк. ``errors`` — только реальные сбои UPSERT;
|
||||
``skipped`` — штатные пропуски feature без cad_num.
|
||||
"""
|
||||
snapshot_date = datetime.date.today().isoformat()
|
||||
parcels_n = 0
|
||||
buildings_n = 0
|
||||
errors_n = 0
|
||||
skipped_n = 0
|
||||
|
||||
for feat in features:
|
||||
layer = feat.get("layer", "")
|
||||
try:
|
||||
if layer == "parcels":
|
||||
if denorm_parcel_feature(
|
||||
# denorm_parcel_feature возвращает False и при штатном пропуске
|
||||
# (нет cad_num), и при реальном сбое UPSERT — различаем их здесь,
|
||||
# чтобы не завышать error-счётчик прогона.
|
||||
props = feat.get("properties") or {}
|
||||
if not (props.get("cad_num") or props.get("cadastral_number")):
|
||||
skipped_n += 1
|
||||
elif denorm_parcel_feature(
|
||||
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||
):
|
||||
parcels_n += 1
|
||||
else:
|
||||
errors_n += 1
|
||||
elif layer == "buildings":
|
||||
if denorm_building_feature(
|
||||
props = feat.get("properties") or {}
|
||||
if not (props.get("cad_num") or props.get("cadastral_number")):
|
||||
skipped_n += 1
|
||||
elif denorm_building_feature(
|
||||
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||
):
|
||||
buildings_n += 1
|
||||
|
|
@ -360,10 +372,16 @@ def denorm_dump(
|
|||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"denorm_dump quarter=%s parcels=%d buildings=%d errors=%d",
|
||||
"denorm_dump quarter=%s parcels=%d buildings=%d errors=%d skipped=%d",
|
||||
quarter_cad,
|
||||
parcels_n,
|
||||
buildings_n,
|
||||
errors_n,
|
||||
skipped_n,
|
||||
)
|
||||
return {"parcels": parcels_n, "buildings": buildings_n, "errors": errors_n}
|
||||
return {
|
||||
"parcels": parcels_n,
|
||||
"buildings": buildings_n,
|
||||
"errors": errors_n,
|
||||
"skipped": skipped_n,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,17 @@ def fetch_geoportal(
|
|||
try:
|
||||
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
|
||||
body = r.read().decode("utf-8", "ignore")
|
||||
return json.loads(body)
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
# HTTP 200, но тело — не JSON. Это WAF/прокси-челлендж (HTML или
|
||||
# пустое тело) с кодом 200 вместо 403/429. Та же transient-ситуация,
|
||||
# что и явный WAF → NspdLiteWafError, чтобы caller сделал backoff
|
||||
# (harvest_quarter autoretry_for=(NspdLiteWafError,)), а не пометил
|
||||
# строку постоянным harvest_error.
|
||||
raise NspdLiteWafError(
|
||||
f"HTTP 200 but non-JSON body (WAF challenge?): {body[:300]}"
|
||||
) from e
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
|
||||
if e.code in (403, 429):
|
||||
|
|
@ -162,6 +172,12 @@ def fetch_via_rosreestr2coord(
|
|||
_ = delay # silence unused — см. docstring выше
|
||||
try:
|
||||
from rosreestr2coord.parser import Area
|
||||
from rosreestr2coord.request.exceptions import (
|
||||
HTTPErrorException,
|
||||
HTTPForbiddenException,
|
||||
RequestException,
|
||||
TimeoutException,
|
||||
)
|
||||
except ImportError as e:
|
||||
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
|
||||
|
||||
|
|
@ -170,19 +186,33 @@ def fetch_via_rosreestr2coord(
|
|||
# denied при первом fetch.
|
||||
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
|
||||
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
|
||||
a = Area(
|
||||
code=cad_num,
|
||||
area_type=area_type,
|
||||
timeout=timeout,
|
||||
with_log=False,
|
||||
use_cache=False,
|
||||
media_path="/tmp/rosreestr2coord",
|
||||
)
|
||||
#
|
||||
# NB: with_log=False → конструктор Area() сразу делает HTTP-запрос
|
||||
# (get_geometry без try/except), поэтому WAF/сетевые исключения летят уже
|
||||
# отсюда, а не из to_geojson_poly(). Оба вызова под одним try.
|
||||
try:
|
||||
a = Area(
|
||||
code=cad_num,
|
||||
area_type=area_type,
|
||||
timeout=timeout,
|
||||
with_log=False,
|
||||
use_cache=False,
|
||||
media_path="/tmp/rosreestr2coord",
|
||||
)
|
||||
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
|
||||
# строку. Default в v5 = True → строка → крах в `_persist_target` который
|
||||
# ожидает dict с `.get("properties")` etc.
|
||||
return a.to_geojson_poly(dumps=False)
|
||||
except Exception as e:
|
||||
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
|
||||
return None
|
||||
except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
|
||||
# WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
|
||||
# HTTPErrorException) и таймауты — transient. Поднимаем NspdLiteWafError,
|
||||
# чтобы воркер process_nspd_geo_job сделал backoff (exponential, инкремент
|
||||
# waf_blocked_count, пауза после серии WAF), а не пометил цель 'done' с 0
|
||||
# features (false run-status). См. nspd_geo.py:464.
|
||||
raise NspdLiteWafError(f"rosreestr2coord WAF/transient for {cad_num}: {e}") from e
|
||||
except RequestException as e:
|
||||
# Прочие ошибки запроса (RequestException, в т.ч. is_error_response с
|
||||
# сообщением об ошибке от NSPD) — не отличить от transient WAF надёжно,
|
||||
# но это НЕ легитимное 'участок не найден' (то возвращает feature=None →
|
||||
# None без исключения). Классифицируем как ошибку, не как пустой результат.
|
||||
raise NspdLiteError(f"rosreestr2coord request failed for {cad_num}: {e}") from e
|
||||
|
|
|
|||
|
|
@ -69,6 +69,35 @@ _CHECK_TYPE_ALIASES: dict[str, list[str]] = {
|
|||
"declaration": ["declaration", "hasDeclaration", "declarationFlg"],
|
||||
}
|
||||
|
||||
# Строковые флаги, которые сторонний API может прислать вместо bool.
|
||||
# Схема payload не верифицирована (см. docstring), поэтому приводим явно.
|
||||
_TRUE_STRINGS = {"true", "1", "yes", "y", "да", "passed", "ok"}
|
||||
_FALSE_STRINGS = {"false", "0", "no", "n", "нет", "failed", "not_passed"}
|
||||
|
||||
|
||||
def _coerce_flag(value: Any) -> bool | None:
|
||||
"""Привести значение флага проверки к bool либо None (UNKNOWN).
|
||||
|
||||
bool(value) ломается на строках ('false'/'0'/'нет' → True) и не отличает
|
||||
отсутствие данных от False. Возвращаем None, если значение нераспознаваемо —
|
||||
вызывающий код НЕ должен фабриковать False для UNKNOWN.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
s = value.strip().lower()
|
||||
if s in _TRUE_STRINGS:
|
||||
return True
|
||||
if s in _FALSE_STRINGS:
|
||||
return False
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
_UPSERT_CHECKS_SQL = text(
|
||||
"""
|
||||
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
|
||||
|
|
@ -106,14 +135,21 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
|
|||
for field, value in data.items():
|
||||
ct = _CHECK_FIELD_MAP.get(field)
|
||||
if ct and ct not in found:
|
||||
found[ct] = bool(value)
|
||||
flag = _coerce_flag(value)
|
||||
if flag is not None:
|
||||
found[ct] = flag
|
||||
# Также проверить canonical names напрямую
|
||||
for ct in CHECK_TYPES:
|
||||
if ct not in found and ct in data:
|
||||
found[ct] = bool(data[ct])
|
||||
flag = _coerce_flag(data[ct])
|
||||
if flag is not None:
|
||||
found[ct] = flag
|
||||
if found:
|
||||
# Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
|
||||
# а не FAILED — не фабрикуем passed=False для непришедших проверок.
|
||||
for ct in CHECK_TYPES:
|
||||
results.append({"check_type": ct, "passed": found.get(ct, False)})
|
||||
if ct in found:
|
||||
results.append({"check_type": ct, "passed": found[ct]})
|
||||
return results
|
||||
# dict не содержит известных полей — попробуем как list-формат ниже
|
||||
logger.warning(
|
||||
|
|
@ -128,11 +164,24 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
|
|||
continue
|
||||
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
|
||||
if ct_raw and str(ct_raw) in CHECK_TYPES:
|
||||
passed_raw = item.get("passed") or item.get("value") or item.get("status")
|
||||
found_list[str(ct_raw)] = bool(passed_raw)
|
||||
# Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
|
||||
# Берём первый ключ, который реально присутствует в item.
|
||||
if "passed" in item:
|
||||
passed_raw = item["passed"]
|
||||
elif "value" in item:
|
||||
passed_raw = item["value"]
|
||||
elif "status" in item:
|
||||
passed_raw = item["status"]
|
||||
else:
|
||||
passed_raw = None
|
||||
flag = _coerce_flag(passed_raw)
|
||||
if flag is not None:
|
||||
found_list[str(ct_raw)] = flag
|
||||
if found_list:
|
||||
# См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
|
||||
for ct in CHECK_TYPES:
|
||||
results.append({"check_type": ct, "passed": found_list.get(ct, False)})
|
||||
if ct in found_list:
|
||||
results.append({"check_type": ct, "passed": found_list[ct]})
|
||||
return results
|
||||
logger.warning(
|
||||
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]
|
||||
|
|
|
|||
|
|
@ -298,9 +298,7 @@ def _parse_value(raw: str) -> Decimal | None:
|
|||
raw = (raw or "").strip()
|
||||
if not raw or raw in {"-", "—", "…", "..."}:
|
||||
return None
|
||||
cleaned = (
|
||||
raw.replace(" ", "").replace(" ", "").replace(" ", "").replace(",", ".")
|
||||
)
|
||||
cleaned = raw.replace(" ", "").replace(" ", "").replace(" ", "").replace(",", ".")
|
||||
try:
|
||||
return Decimal(cleaned)
|
||||
except (InvalidOperation, ValueError):
|
||||
|
|
@ -399,9 +397,7 @@ def parse_dataset_rows(
|
|||
# ── HTTP fetch (thin) ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _http_get(
|
||||
client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES
|
||||
) -> bytes:
|
||||
def _http_get(client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES) -> bytes:
|
||||
"""GET с ретраями (сеть / 5xx) → байты тела. Module-level для monkeypatch в тестах.
|
||||
|
||||
Raises:
|
||||
|
|
@ -622,9 +618,7 @@ _MONTH_NAME_TO_NUM: dict[str, int] = {
|
|||
"декабрь": 12,
|
||||
}
|
||||
# Годовой под-период (некоторые индикаторы): «значение показателя за год», «год».
|
||||
_YEAR_PERIODS: frozenset[str] = frozenset(
|
||||
{"год", "значение показателя за год", "за год"}
|
||||
)
|
||||
_YEAR_PERIODS: frozenset[str] = frozenset({"год", "значение показателя за год", "за год"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -673,7 +667,14 @@ class EmissRow:
|
|||
"""Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss').
|
||||
|
||||
Отдельно от ``MacroRow`` (open-data, source='rosstat', yearly): ЕМИСС-ряды несут
|
||||
свою frequency (quarterly/monthly) и source — контракт upsert'а у них иной.
|
||||
свою frequency (quarterly/monthly), source и period_type — контракт upsert'а у
|
||||
них иной.
|
||||
|
||||
period_type: гранулярность под-периода ('year' | 'quarter' | 'month' | 'unknown').
|
||||
Берётся из _emiss_period_granularity(PERIOD). Необходима как часть PK
|
||||
macro_indicator (migration 163), чтобы годовой агрегат ('год' → 'year') и
|
||||
Q1 ('I квартал' → 'quarter') за один год не перезаписывали друг друга при
|
||||
ON CONFLICT DO UPDATE (оба дают obs_date=YYYY-01-01) (#1606).
|
||||
"""
|
||||
|
||||
indicator_type: str
|
||||
|
|
@ -683,6 +684,7 @@ class EmissRow:
|
|||
unit: str
|
||||
frequency: str
|
||||
comment: str
|
||||
period_type: str = "unknown"
|
||||
|
||||
|
||||
def _emiss_period_to_month(period: str) -> int | None:
|
||||
|
|
@ -704,6 +706,25 @@ def _emiss_period_to_month(period: str) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _emiss_period_granularity(period: str) -> str:
|
||||
"""PERIOD-строку fedstat → гранулярность ('year' | 'quarter' | 'month').
|
||||
|
||||
Используется как третья компонента ключа дедупликации в parse_emiss_sdmx, чтобы
|
||||
годовой ряд ('год') и квартальный Q1 ('I квартал') не перезаписывали друг друга при
|
||||
одинаковых (region, obs_date=YYYY-01-01) (#1606).
|
||||
Возвращает 'unknown' для нераспознанных под-периодов (на практике такие ряды
|
||||
пропускаются ещё до этого вызова через _emiss_period_to_month → None).
|
||||
"""
|
||||
p = (period or "").strip().lower()
|
||||
if p in _YEAR_PERIODS:
|
||||
return "year"
|
||||
if p in _QUARTER_START_MONTH:
|
||||
return "quarter"
|
||||
if p in _MONTH_NAME_TO_NUM:
|
||||
return "month"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _series_concepts(series: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""Извлечь (SeriesKey, Attributes) concept→value словари из <generic:Series>.
|
||||
|
||||
|
|
@ -736,7 +757,10 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
|
|||
не среди spec.targets — ряд пропускается. PERIOD (Attributes) → стартовый месяц
|
||||
под-периода; год — из <Obs><Time>; obs_date = date(year, month, 1). Значение —
|
||||
из <Obs><ObsValue value=...> через _parse_value (запятая-десятичный, разделители).
|
||||
3. Дубли по (region, obs_date) схлопываются (последний выигрывает) — PK всё равно один.
|
||||
3. Дубли по (region, obs_date, granularity) схлопываются (последний выигрывает).
|
||||
Ключ трёхкомпонентный — гранулярность ('year'/'quarter'/'month') предотвращает
|
||||
перезапись годового ряда ('год' → obs_date YYYY-01-01) Q1-рядом ('I квартал' →
|
||||
тот же YYYY-01-01) при совместном их присутствии в одном SDMX-ответе (#1606).
|
||||
|
||||
Никогда не бросает на данных: битые ячейки/периоды/годы → наблюдение пропускается.
|
||||
"""
|
||||
|
|
@ -759,7 +783,10 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
|
|||
return []
|
||||
|
||||
target_by_okato = {t.oktmo: t for t in spec.targets}
|
||||
by_key: dict[tuple[str, date], EmissRow] = {}
|
||||
# Ключ: (region, obs_date, granularity) — три компоненты предотвращают коллизию
|
||||
# годового ряда ('год' → YYYY-01-01, granularity='year') и Q1 ('I квартал' →
|
||||
# тот же YYYY-01-01, granularity='quarter') при их совместном присутствии в SDMX (#1606).
|
||||
by_key: dict[tuple[str, date, str], EmissRow] = {}
|
||||
|
||||
for series in dataset.findall("g:Series", _SDMX_NS):
|
||||
key, attrs = _series_concepts(series)
|
||||
|
|
@ -767,15 +794,17 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
|
|||
target = target_by_okato.get(okato)
|
||||
if target is None:
|
||||
continue
|
||||
month = _emiss_period_to_month(attrs.get("PERIOD", ""))
|
||||
period_raw = attrs.get("PERIOD", "")
|
||||
month = _emiss_period_to_month(period_raw)
|
||||
if month is None:
|
||||
logger.warning(
|
||||
"EMISS %s: неизвестный PERIOD %r (ОКАТО %s) — пропуск",
|
||||
spec.indicator_id,
|
||||
attrs.get("PERIOD"),
|
||||
period_raw,
|
||||
okato,
|
||||
)
|
||||
continue
|
||||
granularity = _emiss_period_granularity(period_raw)
|
||||
for obs in series.findall("g:Obs", _SDMX_NS):
|
||||
time_el = obs.find("g:Time", _SDMX_NS)
|
||||
val_el = obs.find("g:ObsValue", _SDMX_NS)
|
||||
|
|
@ -788,7 +817,7 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
|
|||
if value is None:
|
||||
continue
|
||||
obs_date = date(int(yr), month, 1)
|
||||
by_key[(target.region, obs_date)] = EmissRow(
|
||||
by_key[(target.region, obs_date, granularity)] = EmissRow(
|
||||
indicator_type=spec.indicator_type,
|
||||
region=target.region,
|
||||
obs_date=obs_date,
|
||||
|
|
@ -796,6 +825,7 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
|
|||
unit=spec.unit,
|
||||
frequency=spec.frequency,
|
||||
comment=spec.comment,
|
||||
period_type=granularity,
|
||||
)
|
||||
|
||||
return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]
|
||||
|
|
@ -948,9 +978,7 @@ def _parse_year_cell(raw: Any) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def parse_construction_index_xlsx(
|
||||
content: bytes, spec: XlsxIndicatorSpec
|
||||
) -> list[MacroRow]:
|
||||
def parse_construction_index_xlsx(content: bytes, spec: XlsxIndicatorSpec) -> list[MacroRow]:
|
||||
"""Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow.
|
||||
|
||||
Алгоритм (форма листа — см. блок выше):
|
||||
|
|
|
|||
|
|
@ -227,13 +227,14 @@ class BrowserSession:
|
|||
"""
|
||||
if self._context is None:
|
||||
raise RuntimeError("BrowserSession not bootstrapped")
|
||||
await jitter_sleep(200, 500) # Lighter throttle for static assets.
|
||||
self._request_count += 1
|
||||
resp = await self._context.request.get(
|
||||
url,
|
||||
headers={"Authorization": self.auth} if self.auth else {},
|
||||
)
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
|
||||
return await resp.body()
|
||||
async with self._sem:
|
||||
await jitter_sleep(200, 500) # Lighter throttle for static assets.
|
||||
self._request_count += 1
|
||||
resp = await self._context.request.get(
|
||||
url,
|
||||
headers={"Authorization": self.auth} if self.auth else {},
|
||||
)
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
|
||||
return await resp.body()
|
||||
|
|
|
|||
|
|
@ -488,17 +488,82 @@ _AVG_PRICE_SQL = text("""
|
|||
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
|
||||
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
|
||||
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
|
||||
#
|
||||
# #1615: velocity обогащается из ДВУХ источников (см. _COMPETITORS_SQL mapped CTE) —
|
||||
# явного objective_complex_mapping И спатиально-именного nearest_cx gap-fill. Ценовой
|
||||
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
|
||||
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
|
||||
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
|
||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
|
||||
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
|
||||
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
|
||||
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
|
||||
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||
WITH primary_price AS (
|
||||
SELECT
|
||||
cm.domrf_obj_id AS obj_id,
|
||||
ol.price_per_m2_rub AS price_per_m2_rub
|
||||
FROM objective_complex_mapping cm
|
||||
JOIN objective_lots ol
|
||||
ON ol.project_name = cm.objective_complex_name
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
),
|
||||
nearest_cx AS (
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id,
|
||||
c.id AS complex_id
|
||||
FROM domrf_kn_objects o
|
||||
JOIN complexes c
|
||||
ON c.latitude IS NOT NULL
|
||||
AND c.longitude IS NOT NULL
|
||||
AND c.canonical_name IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
|
||||
)
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||
CAST(:velocity_match_radius_m AS float)
|
||||
)
|
||||
AND (
|
||||
lower(btrim(o.comm_name)) = lower(btrim(c.canonical_name))
|
||||
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
||||
)
|
||||
WHERE o.obj_id = ANY(:obj_ids)
|
||||
AND o.latitude IS NOT NULL
|
||||
AND o.longitude IS NOT NULL
|
||||
AND o.comm_name IS NOT NULL
|
||||
AND btrim(o.comm_name) <> ''
|
||||
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
|
||||
ORDER BY o.obj_id,
|
||||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
||||
) ASC
|
||||
),
|
||||
gapfill_price AS (
|
||||
SELECT
|
||||
nc.obj_id AS obj_id,
|
||||
ol.price_per_m2_rub AS price_per_m2_rub
|
||||
FROM nearest_cx nc
|
||||
JOIN objective_lots ol
|
||||
ON ol.complex_id = nc.complex_id
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
cm.domrf_obj_id AS obj_id,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
|
||||
p.obj_id,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_per_m2_rub)
|
||||
AS median_price_per_m2
|
||||
FROM objective_complex_mapping cm
|
||||
JOIN objective_lots ol
|
||||
ON ol.project_name = cm.objective_complex_name
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
GROUP BY cm.domrf_obj_id
|
||||
FROM (
|
||||
SELECT obj_id, price_per_m2_rub FROM primary_price
|
||||
UNION ALL
|
||||
SELECT obj_id, price_per_m2_rub FROM gapfill_price
|
||||
) p
|
||||
GROUP BY p.obj_id
|
||||
""")
|
||||
|
||||
|
||||
|
|
@ -648,7 +713,13 @@ def get_competitors(
|
|||
if missing_price_ids:
|
||||
try:
|
||||
obj_price_rows = (
|
||||
db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids})
|
||||
db.execute(
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
{
|
||||
"obj_ids": missing_price_ids,
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Pure function — no DB dependencies, consumes already-fetched data from analyze
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from app.services.site_finder.network_obremenenie import (
|
||||
|
|
@ -16,11 +17,28 @@ from app.services.site_finder.network_obremenenie import (
|
|||
|
||||
# ── Zone matching ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5)
|
||||
RESIDENTIAL_ZONE_PREFIXES = ("Ж-", "Ж1", "Ж2", "Ж3", "Ж4", "Ж5")
|
||||
# Fallback по zone_name (lowercase substring)
|
||||
# Regex для zone_code из PKK6 / pzz_zones_ekb: "Ж", "Ж-1", "Жс", "ЖС-2" и т.д.
|
||||
# Кейс-инсенситив для защиты от "ж-1" в нестандартных источниках.
|
||||
# NSPD quarter-dump в zone_code кладёт кадастровый рег.номер (напр. "66:41-7.14") —
|
||||
# он никогда не начинается на "Ж", поэтому этот regex не даст false positive.
|
||||
_RESIDENTIAL_CODE_RE = re.compile(r"^[Жж]", re.UNICODE)
|
||||
|
||||
# Fallback по zone_name (lowercase substring) — "жилая", "жилой", "жилая зона" и т.д.
|
||||
RESIDENTIAL_KEYWORDS = ("жил",)
|
||||
|
||||
# NSPD territorial zones layer 875838: subcategory (int) → тип зоны.
|
||||
# Значения получены из реальных данных nspd_quarter_dumps (ЕКБ, 2026-02):
|
||||
# subcategory=1 — 39 объектов — Жилые зоны малоэтажной застройки (ИЖС/таунхаусы)
|
||||
# subcategory=2 — 221 объект — Жилые зоны (МКД разрешён, крупнейшая группа ЕКБ)
|
||||
# subcategory=3 — 126 объектов — Зоны смешанного использования (МКД по ПЗЗ ЕКБ допустим)
|
||||
# subcategory=4 — 77 объектов — Общественно-деловые
|
||||
# subcategory=5 — 112 объектов — Производственные
|
||||
# subcategory=6 — 92 объекта — Рекреационные
|
||||
# subcategory=8 — 16 объектов — Специальные
|
||||
# subcategory=10 — 113 объектов — Промышленные/транспортные
|
||||
# subcategory=1 исключён намеренно: ИЖС-зона МКД не допускает.
|
||||
NSPD_RESIDENTIAL_SUBCATEGORIES: frozenset[int] = frozenset({2, 3})
|
||||
|
||||
# ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
|
||||
|
||||
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода).
|
||||
|
|
@ -45,6 +63,11 @@ WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-з
|
|||
# Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py
|
||||
ENGINEERING_NEARBY_THRESHOLD_M = 200
|
||||
|
||||
# Warning codes that are purely informational — they appear in warnings[] for the user
|
||||
# but do NOT downgrade the final verdict from «Можно» to «С ограничениями» (#1617).
|
||||
# A clean residential parcel with only these warnings still gets verdict_label="Можно".
|
||||
INFORMATIONAL_WARNING_CODES: frozenset[str] = frozenset({"NO_ENGINEERING_NEARBY"})
|
||||
|
||||
|
||||
# ── TypedDicts ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -71,19 +94,46 @@ class GateVerdict(TypedDict):
|
|||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def is_residential_zone(zone_code: str | None, zone_name: str | None) -> bool:
|
||||
def is_residential_zone(
|
||||
zone_code: str | None,
|
||||
zone_name: str | None,
|
||||
raw_props: dict | None = None,
|
||||
) -> bool:
|
||||
"""Check if ПЗЗ zone permits residential (МКД) building.
|
||||
|
||||
Checks zone_code prefix first (exact ПЗЗ codes), then zone_name lowercase
|
||||
substring match as fallback for non-standard zone labels.
|
||||
Detection strategy (in priority order):
|
||||
|
||||
1. zone_code regex «^Ж» — ПЗЗ коды PKK6/pzz_zones_ekb: "Ж-1", "Жс", "ЖС-2" и т.д.
|
||||
NSPD quarter-dump кладёт в zone_code кадастровый рег.номер ("66:41-7.14") —
|
||||
он не начинается на «Ж», ложных срабатываний нет.
|
||||
|
||||
2. NSPD subcategory из raw_props — единственный надёжный тип-дискриминатор в
|
||||
реальных данных nspd_quarter_dumps (layer 875838, ЕКБ 2026-02):
|
||||
subcategory=2 ("Жилые зоны") и subcategory=3 ("Смешанное использование")
|
||||
допускают МКД по ПЗЗ ЕКБ. subcategory=1 (ИЖС) — намеренно исключён.
|
||||
|
||||
3. zone_name substring «жил» — fallback для нестандартных источников.
|
||||
"""
|
||||
if zone_code:
|
||||
if any(zone_code.startswith(p) for p in RESIDENTIAL_ZONE_PREFIXES):
|
||||
return True
|
||||
# 1. PKK6-style zone_code: starts with Ж (case-insensitive)
|
||||
if zone_code and _RESIDENTIAL_CODE_RE.match(zone_code):
|
||||
return True
|
||||
|
||||
# 2. NSPD subcategory from raw_props (primary discriminator for quarter-dump data)
|
||||
if raw_props:
|
||||
sub_raw = raw_props.get("subcategory")
|
||||
if sub_raw is not None:
|
||||
try:
|
||||
if int(sub_raw) in NSPD_RESIDENTIAL_SUBCATEGORIES:
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 3. zone_name substring fallback
|
||||
if zone_name:
|
||||
zn_lower = zone_name.lower()
|
||||
if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -134,7 +184,11 @@ def compute_gate_verdict(
|
|||
# Check 1 — ПЗЗ residential zone
|
||||
checks.append("ПЗЗ зональность")
|
||||
if nspd_zoning:
|
||||
if not is_residential_zone(nspd_zoning.get("zone_code"), nspd_zoning.get("zone_name")):
|
||||
if not is_residential_zone(
|
||||
nspd_zoning.get("zone_code"),
|
||||
nspd_zoning.get("zone_name"),
|
||||
nspd_zoning.get("raw_props"),
|
||||
):
|
||||
zone_label = (
|
||||
nspd_zoning.get("zone_name") or nspd_zoning.get("zone_code") or "неизвестна"
|
||||
)
|
||||
|
|
@ -259,12 +313,17 @@ def compute_gate_verdict(
|
|||
)
|
||||
)
|
||||
|
||||
# Final verdict
|
||||
# Final verdict.
|
||||
# Informational warnings (e.g. NO_ENGINEERING_NEARBY) appear in warnings[] for display
|
||||
# but do NOT by themselves downgrade a clean parcel to «С ограничениями» (#1617).
|
||||
verdict_relevant_warnings = [
|
||||
w for w in warnings if w["code"] not in INFORMATIONAL_WARNING_CODES
|
||||
]
|
||||
can_build: bool | Literal["unknown"]
|
||||
if blockers:
|
||||
can_build = False
|
||||
label = "Нельзя"
|
||||
elif warnings:
|
||||
elif verdict_relevant_warnings:
|
||||
can_build = True
|
||||
label = "С ограничениями"
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
|||
Returns:
|
||||
list[{project_type, doc_status_name, is_active, paga_number, paga_date,
|
||||
subject, doc_full_name}].
|
||||
- ``is_active``: doc_status_name == 'Действующий'.
|
||||
- ``is_active``: doc_status_name == 'действующий' (регистронезависимо).
|
||||
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
|
||||
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
|
||||
Отсортировано: сначала действующие, внутри группы — по paga_date desc (свежие первыми).
|
||||
|
|
@ -97,7 +97,10 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
|||
for r in rows:
|
||||
full_name: str | None = r["full_name"]
|
||||
paga_number, paga_date, subject = _parse_paga(full_name)
|
||||
is_active = r["doc_status_name"] == "Действующий"
|
||||
status_name: str | None = r["doc_status_name"]
|
||||
# Регистронезависимо: harvest пишет doc_status_name из WFS без нормализации,
|
||||
# DDL/COMMENT документируют хранимое значение как lowercase ('действующий').
|
||||
is_active = (status_name or "").strip().lower() == "действующий"
|
||||
result.append(
|
||||
{
|
||||
"project_type": r["project_type"],
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ def _market_metrics_key(
|
|||
premise_kind,
|
||||
)
|
||||
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
|
||||
# Лот считается «зависшим» (overstock), если он в продаже дольше этого числа
|
||||
|
|
@ -111,6 +112,9 @@ class MarketMetrics:
|
|||
demand_concentration: float | None # Херфиндаль продаж по комнатности (0..1)
|
||||
price_sensitivity: float | None # эластичность цена↔спрос (slope, обычно < 0)
|
||||
price_sensitivity_source: str | None # 'regression' | 'fallback' | None
|
||||
# #1593: ед./мес per room-bucket (ключи — вокабуляр _room_bucket():
|
||||
# "студия","1","2","3","4","5+"). None если нет данных в окне.
|
||||
velocity_by_room: dict[str, float] | None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
@ -136,6 +140,8 @@ class MarketMetrics:
|
|||
"demand_concentration": _round_or_none(self.demand_concentration, 3),
|
||||
"price_sensitivity": _round_or_none(self.price_sensitivity, 4),
|
||||
"price_sensitivity_source": self.price_sensitivity_source,
|
||||
# velocity_by_room намеренно не сериализуется: это внутренний
|
||||
# pipeline-атрибут (потребляется recommendation.py напрямую).
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -409,6 +415,15 @@ def compute_market_metrics(
|
|||
liquidity = _liquidity_index(sold_by_room)
|
||||
overstock = _overstock_index(n_long_unsold, n_available)
|
||||
demand_conc = _demand_concentration(sold_by_room)
|
||||
# #1593: per-bucket velocity — ед./мес по каждой комнатности. Ключи зеркалят
|
||||
# _room_bucket() ("студия","1","2","3","4","5+"). При has_sample=False нет
|
||||
# смысла делить 0 лотов → None (graceful, зеркало unit_velocity поведения).
|
||||
# window_months > 0 защищает от ZeroDivisionError (зеркало _monthly_rate()).
|
||||
vel_by_room: dict[str, float] | None = (
|
||||
{bkt: float(cnt) / float(window_months) for bkt, cnt in sold_by_room.items()}
|
||||
if has_sample and sold_by_room and window_months > 0
|
||||
else None
|
||||
)
|
||||
|
||||
# ── price_sensitivity — reuse analytics_queries._elasticity_coef ───────────
|
||||
price_sensitivity, price_sensitivity_source = _price_sensitivity(
|
||||
|
|
@ -450,6 +465,7 @@ def compute_market_metrics(
|
|||
demand_concentration=demand_conc,
|
||||
price_sensitivity=price_sensitivity,
|
||||
price_sensitivity_source=price_sensitivity_source,
|
||||
velocity_by_room=vel_by_room,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,21 @@ CATEGORY_WEIGHTS: dict[str, float] = {
|
|||
"default": 1.0,
|
||||
}
|
||||
|
||||
# ── Нормировочные константы (для вывода score_contribution / poi_weighted_score в 0..100) ──
|
||||
#
|
||||
# Теоретический максимум суммы весов top-7 POI при идеальном расположении:
|
||||
# straight-line: w_i = cat_weight_i / (0+100) → max_sum = Σ(top7 cat_weights) / 100
|
||||
# routing-decay: w_i = cat_weight_i * decay(0) = cat_weight_i → max_sum = Σ(top7 cat_weights)
|
||||
#
|
||||
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5
|
||||
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
|
||||
|
||||
# straight-line mode: каждый POI берётся с множителем 1/(d+100); при d=0 → /100
|
||||
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
|
||||
|
||||
# routing-decay mode: decay ∈ [0,1], при t=0 decay=1.0 → max = cat_weight
|
||||
_MAX_ROUTING_SCORE: float = _TOP7_WEIGHT_SUM # = 31.5
|
||||
|
||||
|
||||
class PoiScoreItem(BaseModel):
|
||||
"""Один POI в ranked-ответе."""
|
||||
|
|
@ -52,12 +67,17 @@ class PoiScoreItem(BaseModel):
|
|||
category: str
|
||||
distance_m: float
|
||||
weight: float
|
||||
# Вклад данного POI в суммарный балл, в процентах от 0..100.
|
||||
# Позволяет фронтенду рендерить «долю» без знания формулы нормировки.
|
||||
score_contribution: float
|
||||
address: str | None
|
||||
|
||||
|
||||
class PoiScoreResponse(BaseModel):
|
||||
cad_num: str
|
||||
radius_m: int
|
||||
# Суммарный взвешенный балл инфраструктуры, нормированный в диапазон 0..100.
|
||||
poi_weighted_score: float
|
||||
top_poi: list[PoiScoreItem]
|
||||
|
||||
|
||||
|
|
@ -155,6 +175,7 @@ def compute_poi_weighted_top7(
|
|||
category=category,
|
||||
distance_m=round(distance_m, 1),
|
||||
weight=round(weight, 6),
|
||||
score_contribution=0.0, # заполним после нормировки
|
||||
address=address,
|
||||
)
|
||||
)
|
||||
|
|
@ -163,9 +184,18 @@ def compute_poi_weighted_top7(
|
|||
items.sort(key=lambda x: x.weight, reverse=True)
|
||||
top_items = items[:top_n]
|
||||
|
||||
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_STRAIGHT_SCORE) * 100, клэмп [0, 100]
|
||||
raw_sum = sum(i.weight for i in top_items)
|
||||
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0), 1)
|
||||
|
||||
# score_contribution — доля данного POI в общем балле (0..100)
|
||||
for item in top_items:
|
||||
item.score_contribution = round((item.weight / _MAX_STRAIGHT_SCORE) * 100.0, 1)
|
||||
|
||||
return PoiScoreResponse(
|
||||
cad_num=cad_num,
|
||||
radius_m=radius_m,
|
||||
poi_weighted_score=poi_weighted_score,
|
||||
top_poi=top_items,
|
||||
)
|
||||
|
||||
|
|
@ -303,7 +333,9 @@ def compute_poi_routing_decay(
|
|||
)
|
||||
|
||||
if not rows:
|
||||
return PoiScoreResponse(cad_num=cad_num, radius_m=radius_m, top_poi=[])
|
||||
return PoiScoreResponse(
|
||||
cad_num=cad_num, radius_m=radius_m, poi_weighted_score=0.0, top_poi=[]
|
||||
)
|
||||
|
||||
destinations = [(float(r["lon"]), float(r["lat"])) for r in rows]
|
||||
|
||||
|
|
@ -311,9 +343,7 @@ def compute_poi_routing_decay(
|
|||
times_min: list[float | None]
|
||||
routing_used = False
|
||||
try:
|
||||
times_min = ors_client.matrix_durations_min(
|
||||
lon, lat, destinations, profile=profile
|
||||
)
|
||||
times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile)
|
||||
routing_used = True
|
||||
except ors_client.OrsUnavailableError as exc:
|
||||
logger.info(
|
||||
|
|
@ -355,13 +385,25 @@ def compute_poi_routing_decay(
|
|||
category=category,
|
||||
distance_m=round(distance_m, 1),
|
||||
weight=round(weight, 6),
|
||||
score_contribution=0.0, # заполним после нормировки
|
||||
address=address,
|
||||
)
|
||||
)
|
||||
|
||||
items.sort(key=lambda x: x.weight, reverse=True)
|
||||
top_items = items[:top_n]
|
||||
|
||||
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_ROUTING_SCORE) * 100, клэмп [0, 100]
|
||||
raw_sum = sum(i.weight for i in top_items)
|
||||
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_ROUTING_SCORE) * 100.0), 1)
|
||||
|
||||
# score_contribution — доля данного POI в общем балле (0..100)
|
||||
for item in top_items:
|
||||
item.score_contribution = round((item.weight / _MAX_ROUTING_SCORE) * 100.0, 1)
|
||||
|
||||
return PoiScoreResponse(
|
||||
cad_num=cad_num,
|
||||
radius_m=radius_m,
|
||||
top_poi=items[:top_n],
|
||||
poi_weighted_score=poi_weighted_score,
|
||||
top_poi=top_items,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,14 @@
|
|||
|
||||
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
|
||||
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
|
||||
1) ``doc_ref = source_key::text`` (прямое равенство);
|
||||
2) ``doc_ref ILIKE '%doc_full_name%'`` (fallback для slug-ов вида ppt2018_22823).
|
||||
1) ``doc_ref = CAST(source_key AS text)`` (прямое равенство);
|
||||
2) ``doc_full_name ILIKE '%' || doc_ref || '%'`` (fallback для slug-ов вида
|
||||
ppt2018_22823: длинное WFS-описание содержит короткий slug парсера; LIKE-метасимволы
|
||||
в doc_ref экранируются ESCAPE '\').
|
||||
|
||||
Дедуп: best-effort OR-JOIN может зацепить несколько строк ``ekb_ppt_tep`` на один
|
||||
``planning_projects`` (точная ветка + ILIKE-fallback). ``DISTINCT ON (source_key)``
|
||||
держит контракт «одна строка = один ППТ/ПМТ-документ», отдавая приоритет точной ветке.
|
||||
|
||||
Граф вызова: build_ird_analyze_block → parcel_ppt_tep → JOIN planning_projects ∩ ekb_ppt_tep.
|
||||
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
|
||||
|
|
@ -33,26 +39,49 @@ logger = logging.getLogger(__name__)
|
|||
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
|
||||
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
|
||||
_PPT_TEP_OVERLAP_SQL = text(
|
||||
"""
|
||||
r"""
|
||||
SELECT
|
||||
pp.project_type,
|
||||
pp.doc_status_name,
|
||||
pp.full_name,
|
||||
pp.doc_full_name,
|
||||
pp.source_key,
|
||||
t.doc_ref,
|
||||
t.zone_balance,
|
||||
t.tep,
|
||||
t.phasing,
|
||||
t.source_pdf,
|
||||
t.fetched_at
|
||||
FROM planning_projects pp
|
||||
JOIN ekb_ppt_tep t ON (
|
||||
t.doc_ref = CAST(pp.source_key AS text)
|
||||
OR (pp.doc_full_name IS NOT NULL AND t.doc_ref ILIKE '%' || pp.doc_full_name || '%')
|
||||
)
|
||||
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
|
||||
ORDER BY pp.dmd_actual_year DESC NULLS LAST, pp.project_type
|
||||
project_type,
|
||||
doc_status_name,
|
||||
full_name,
|
||||
doc_full_name,
|
||||
source_key,
|
||||
doc_ref,
|
||||
zone_balance,
|
||||
tep,
|
||||
phasing,
|
||||
source_pdf,
|
||||
fetched_at
|
||||
FROM (
|
||||
-- DISTINCT ON (source_key): один ППТ/ПМТ-документ = одна строка (контракт docstring).
|
||||
-- Точная ветка (doc_ref = source_key) приоритетна над slug-fallback (ILIKE).
|
||||
SELECT DISTINCT ON (pp.source_key)
|
||||
pp.project_type,
|
||||
pp.doc_status_name,
|
||||
pp.full_name,
|
||||
pp.doc_full_name,
|
||||
pp.source_key,
|
||||
pp.dmd_actual_year,
|
||||
t.doc_ref,
|
||||
t.zone_balance,
|
||||
t.tep,
|
||||
t.phasing,
|
||||
t.source_pdf,
|
||||
t.fetched_at
|
||||
FROM planning_projects pp
|
||||
JOIN ekb_ppt_tep t ON (
|
||||
t.doc_ref = CAST(pp.source_key AS text)
|
||||
OR (
|
||||
pp.doc_full_name IS NOT NULL
|
||||
AND pp.doc_full_name ILIKE
|
||||
'%' || replace(replace(replace(t.doc_ref, '\', '\\'), '%', '\%'), '_', '\_')
|
||||
|| '%' ESCAPE '\'
|
||||
)
|
||||
)
|
||||
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
|
||||
ORDER BY pp.source_key, (t.doc_ref = CAST(pp.source_key AS text)) DESC
|
||||
) deduped
|
||||
ORDER BY dmd_actual_year DESC NULLS LAST, project_type
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -74,13 +74,16 @@ class BuildingMatch:
|
|||
|
||||
|
||||
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
|
||||
# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает
|
||||
# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по
|
||||
# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на
|
||||
# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно
|
||||
# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка
|
||||
# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см.
|
||||
# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
||||
# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
|
||||
# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
|
||||
# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
|
||||
# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
|
||||
# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
|
||||
# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
|
||||
# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
|
||||
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
|
||||
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
|
||||
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
||||
_RESOLVE_CAD_SQL = text("""
|
||||
SELECT
|
||||
b.cad_num,
|
||||
|
|
@ -97,13 +100,13 @@ _RESOLVE_CAD_SQL = text("""
|
|||
) AS distance_m
|
||||
FROM cad_buildings b
|
||||
WHERE b.geom IS NOT NULL
|
||||
ORDER BY b.geom <-> ST_SetSRID(
|
||||
ORDER BY b.geom::geography <-> ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)
|
||||
),
|
||||
4326
|
||||
)
|
||||
)::geography
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
|
|
@ -119,8 +122,9 @@ def resolve_cad_for_domrf(
|
|||
|
||||
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
|
||||
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
|
||||
GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до
|
||||
footprint ≤ max_dist_m. Точка внутри площадного footprint → distance 0.
|
||||
GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
|
||||
принимаем матч, если расстояние до footprint ≤ max_dist_m. Точка внутри
|
||||
площадного footprint → distance 0.
|
||||
|
||||
Returns:
|
||||
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import DatabaseError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -41,8 +41,11 @@ def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> None:
|
|||
else:
|
||||
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
|
||||
db.commit()
|
||||
except OperationalError as e:
|
||||
if concurrently and "cannot refresh materialized view" in str(e).lower():
|
||||
except DatabaseError as e:
|
||||
# PostgreSQL emits "CONCURRENTLY cannot be used when the materialized
|
||||
# view ... is not populated" (matview.c, SQLSTATE 55000), which psycopg3
|
||||
# surfaces as InternalError (a DatabaseError sibling of OperationalError).
|
||||
if concurrently and "concurrently cannot be used" in str(e).lower():
|
||||
logger.warning(
|
||||
"%s: CONCURRENTLY failed (MV likely not populated), "
|
||||
"falling back to non-concurrent refresh",
|
||||
|
|
|
|||
154
backend/app/services/site_finder/riasurt_lookup.py
Normal file
154
backend/app/services/site_finder/riasurt_lookup.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""РИАСУРТ Свердл gate-lookup для analyze_parcel (#108, multi-city scaling).
|
||||
|
||||
Читает ``riasurt_sverdl`` (наполняется harvest-task riasurt_sverdl_harvest.py) и отдаёт зоны
|
||||
РИАСУРТ Свердл, пересекающие участок: тер.зона / функц.зона / красные линии / СЗЗ / ЗСО /
|
||||
затопление / КРТ. Только для участков агломерации ЕКБ (ОКРАИНЫ — Берёзовский, В.Пышма,
|
||||
Среднеуральск, Арамиль, Сысерть), НЕ для ЕКБ-сити (тот не покрыт РИАСУРТ Свердл).
|
||||
|
||||
Gate-форма (``response["gate"]["riasurt"]``) собирается из topic-бакетов:
|
||||
tier / func_zone / red_lines / szz / szo / flood_zone / krt + raw-список overlaps.
|
||||
|
||||
Graceful: нет таблицы (pre-migration) / пустой WKT / БД-ошибка → пустой gate (analyze не падает).
|
||||
Зеркалит стиль ``ird_overlay_lookup.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Кадастровый район ЕКБ-сити — 66:41. Агломерация (окраины) — прочие районы 66:xx
|
||||
# (Берёзовский 66:35, В.Пышма/Среднеуральск 66:62, Арамиль/Сысерть 66:25, ...).
|
||||
_EKB_CITY_PREFIX = "66:41"
|
||||
|
||||
# Пустой gate-результат — sentinel (НЕ мутировать).
|
||||
RIASURT_EMPTY_GATE: dict[str, Any] = {
|
||||
"applicable": False,
|
||||
"tier": None,
|
||||
"func_zone": None,
|
||||
"red_lines": False,
|
||||
"szz": False,
|
||||
"szo": False,
|
||||
"flood_zone": False,
|
||||
"krt": False,
|
||||
"overlaps": [],
|
||||
}
|
||||
|
||||
# Пересечения участка с зонами РИАСУРТ. geom в БД — EPSG:3857; участок передаём WKT в 4326 и
|
||||
# трансформируем в 3857 для ST_Intersects по GIST idx_riasurt_sverdl_geom. CAST psycopg v3.
|
||||
_RIASURT_OVERLAP_SQL = text(
|
||||
"""
|
||||
SELECT source_layer_id, layer_topic, mo_name, obshnz, description
|
||||
FROM riasurt_sverdl
|
||||
WHERE ST_Intersects(
|
||||
geom,
|
||||
ST_Transform(ST_GeomFromText(CAST(:parcel_wkt AS text), 4326), 3857)
|
||||
)
|
||||
ORDER BY layer_topic, source_layer_id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def is_in_aglomeration_but_not_ekb(cad: str | None) -> bool:
|
||||
"""True если участок в агломерации ЕКБ, но НЕ в самом ЕКБ-сити (66:41).
|
||||
|
||||
РИАСУРТ Свердл покрывает только окраины агломерации (Берёзовский, В.Пышма,
|
||||
Среднеуральск, Арамиль, Сысерть) — кадастровые районы 66:xx, кроме 66:41.
|
||||
ЕКБ-сити (66:41) и участки вне Свердл (не 66:*) → False.
|
||||
|
||||
Args:
|
||||
cad: кадастровый номер участка ('66:35:...'). None/пусто → False.
|
||||
|
||||
Returns:
|
||||
bool.
|
||||
"""
|
||||
if not cad:
|
||||
return False
|
||||
cad = cad.strip()
|
||||
if not cad.startswith("66:"):
|
||||
return False
|
||||
return not cad.startswith(_EKB_CITY_PREFIX + ":") and cad != _EKB_CITY_PREFIX
|
||||
|
||||
|
||||
def parcel_riasurt_gate(
|
||||
db: Session,
|
||||
parcel_wkt: str | None,
|
||||
cad: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Gate-блок РИАСУРТ Свердл для участка агломерации ЕКБ.
|
||||
|
||||
Если участок не в агломерации (или в ЕКБ-сити) → RIASURT_EMPTY_GATE (applicable=False).
|
||||
Иначе ST_Intersects по riasurt_sverdl и сборка topic-бакетов.
|
||||
|
||||
Args:
|
||||
db: сессия.
|
||||
parcel_wkt: геометрия участка WKT (EPSG:4326). None → пустой gate.
|
||||
cad: кадастровый номер (для is_in_aglomeration_but_not_ekb).
|
||||
|
||||
Returns:
|
||||
dict с полями tier / func_zone / red_lines / szz / szo / flood_zone / krt + overlaps.
|
||||
Пусто при отсутствии участка / таблицы / пересечений / вне агломерации.
|
||||
"""
|
||||
if not parcel_wkt or not is_in_aglomeration_but_not_ekb(cad):
|
||||
return dict(RIASURT_EMPTY_GATE)
|
||||
|
||||
try:
|
||||
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)
|
||||
except Exception as exc:
|
||||
logger.warning("parcel_riasurt_gate: неожиданная ошибка, skip: %s", exc)
|
||||
return dict(RIASURT_EMPTY_GATE)
|
||||
|
||||
gate: dict[str, Any] = {
|
||||
"applicable": True,
|
||||
"tier": None, # description зоны topic='territorial_zone'
|
||||
"func_zone": None, # description зоны topic='functional_zone'
|
||||
"red_lines": False,
|
||||
"szz": False,
|
||||
"szo": False,
|
||||
"flood_zone": False,
|
||||
"krt": False,
|
||||
"overlaps": [],
|
||||
}
|
||||
for r in rows:
|
||||
topic = r["layer_topic"]
|
||||
gate["overlaps"].append(
|
||||
{
|
||||
"source_layer_id": r["source_layer_id"],
|
||||
"layer_topic": topic,
|
||||
"mo_name": r["mo_name"],
|
||||
"obshnz": r["obshnz"],
|
||||
"description": r["description"],
|
||||
}
|
||||
)
|
||||
if topic == "territorial_zone" and gate["tier"] is None:
|
||||
gate["tier"] = r["description"] or r["obshnz"]
|
||||
elif topic == "functional_zone" and gate["func_zone"] is None:
|
||||
gate["func_zone"] = r["description"] or r["obshnz"]
|
||||
elif topic == "red_lines":
|
||||
gate["red_lines"] = True
|
||||
elif topic == "szz":
|
||||
gate["szz"] = True
|
||||
elif topic == "szo":
|
||||
gate["szo"] = True
|
||||
elif topic == "flood_zone":
|
||||
gate["flood_zone"] = True
|
||||
elif topic == "krt":
|
||||
gate["krt"] = True
|
||||
|
||||
return gate
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RIASURT_EMPTY_GATE",
|
||||
"is_in_aglomeration_but_not_ekb",
|
||||
"parcel_riasurt_gate",
|
||||
]
|
||||
95
backend/app/services/site_finder/sales_tracker_mv_refresh.py
Normal file
95
backend/app/services/site_finder/sales_tracker_mv_refresh.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Refresh helper for the sales-tracker MVs (Issue #61).
|
||||
|
||||
Two independent materialized views built from the Объектив sales-tracker
|
||||
("шахматки") snapshots (objective_lots / objective_lots_history), created by
|
||||
data/sql/164_mv_sales_tracker_velocity_absorption.sql:
|
||||
|
||||
1. mv_sales_tracker_velocity_by_district — per (district, month) sold/total/
|
||||
avg-sold-price. Feeds the Site Finder Velocity Score (4th scoring criterion).
|
||||
2. mv_sales_tracker_absorption_curves — cumulative sold% as f(months from
|
||||
sales_start_date) per (rooms_int, area_bucket). Foundation for recommend_mix
|
||||
+ sellout forecast.
|
||||
|
||||
The two MVs do not depend on each other, so refresh order is irrelevant; both
|
||||
are refreshed in the same call.
|
||||
|
||||
Scheduled via Celery beat hardcoded entry in workers/beat_schedule.py
|
||||
('mv-sales-tracker-refresh-weekly', Mon 04:30 MSK).
|
||||
|
||||
Usage example (manual, via psql-connected shell or admin endpoint):
|
||||
from app.services.site_finder.sales_tracker_mv_refresh import refresh_sales_tracker_mvs
|
||||
|
||||
counts = refresh_sales_tracker_mvs(db)
|
||||
# logs: "mv_sales_tracker_velocity_by_district refreshed: 70 rows", etc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import DatabaseError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MV_NAMES: tuple[str, ...] = (
|
||||
"mv_sales_tracker_velocity_by_district",
|
||||
"mv_sales_tracker_absorption_curves",
|
||||
)
|
||||
|
||||
|
||||
def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> int:
|
||||
"""Run REFRESH MATERIALIZED VIEW [CONCURRENTLY] <mv_name>, return row count.
|
||||
|
||||
Falls back to non-concurrent on the known "cannot refresh concurrently"
|
||||
error (MV empty or no UNIQUE index — should not happen in prod since the
|
||||
migration creates the UNIQUE index and populates the MV, but provides a
|
||||
safe recovery path for first-run / post-recreation edge cases).
|
||||
"""
|
||||
try:
|
||||
if concurrently:
|
||||
db.execute(text(f"REFRESH MATERIALIZED VIEW CONCURRENTLY {mv_name}"))
|
||||
else:
|
||||
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
|
||||
db.commit()
|
||||
except DatabaseError as e:
|
||||
# PostgreSQL emits "CONCURRENTLY cannot be used when the materialized
|
||||
# view ... is not populated" (matview.c, SQLSTATE 55000), surfaced by
|
||||
# psycopg3 as an InternalError (a DatabaseError sibling).
|
||||
if concurrently and "concurrently cannot be used" in str(e).lower():
|
||||
logger.warning(
|
||||
"%s: CONCURRENTLY failed (MV likely not populated), "
|
||||
"falling back to non-concurrent refresh",
|
||||
mv_name,
|
||||
)
|
||||
db.rollback()
|
||||
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
|
||||
db.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
row = db.execute(text(f"SELECT COUNT(*) FROM {mv_name}")).first()
|
||||
count = int(row[0]) if row else 0
|
||||
logger.info("%s refreshed: %d rows", mv_name, count)
|
||||
return count
|
||||
|
||||
|
||||
def refresh_sales_tracker_mvs(db: Session, *, concurrently: bool = True) -> dict[str, int]:
|
||||
"""Refresh both sales-tracker MVs.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session (sync).
|
||||
concurrently: When True, uses REFRESH CONCURRENTLY (non-blocking —
|
||||
readers continue). Requires the per-MV UNIQUE indexes
|
||||
(mv_sales_tracker_velocity_by_district_pk,
|
||||
mv_sales_tracker_absorption_curves_pk) and the MVs to be already
|
||||
populated. Pass False only for first populate or after recreation.
|
||||
|
||||
Returns:
|
||||
Mapping mv_name -> row count after refresh (for observability).
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for mv_name in _MV_NAMES:
|
||||
counts[mv_name] = _refresh_mv(db, mv_name, concurrently=concurrently)
|
||||
return counts
|
||||
|
|
@ -81,9 +81,14 @@ _EKB_REGION_CODE: int = 66
|
|||
# Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations).
|
||||
_L2_MEDIUM_MIN_COVERAGE: float = 0.6
|
||||
|
||||
# L2/L3 граница (включительно в L2, исключительно в L3) — половинно-открытые интервалы:
|
||||
# L2 (hidden) : ready_dt IS NULL OR ready_dt <= now + L2_NEAR_HORIZON [now, now+12mo]
|
||||
# L3 (future) : ready_dt IS NOT NULL AND ready_dt > now + L3_FUTURE_HORIZON (now+12mo, ∞)
|
||||
# Оба используют одно и то же значение 12 мес., но по разные стороны границы — нет overlap.
|
||||
_L2_NEAR_HORIZON_MONTHS: int = 12 # L2 берёт объекты с ready_dt ≤ сейчас+12мес (или NULL)
|
||||
# L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже
|
||||
# почти-на-рынке предложение (ловится L1/L2), не «future».
|
||||
_L3_FUTURE_HORIZON_MONTHS: int = 12
|
||||
_L3_FUTURE_HORIZON_MONTHS: int = 12 # L3 берёт объекты с ready_dt > сейчас+12мес
|
||||
|
||||
# L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго
|
||||
# 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт.
|
||||
|
|
@ -215,6 +220,10 @@ _L1_OPEN_SQL = text(
|
|||
# free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим;
|
||||
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
|
||||
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
|
||||
#
|
||||
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
|
||||
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
|
||||
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
|
||||
_L2_HIDDEN_SQL = text(
|
||||
"""
|
||||
WITH latest AS (
|
||||
|
|
@ -222,7 +231,8 @@ _L2_HIDDEN_SQL = text(
|
|||
o.obj_id,
|
||||
o.district_name,
|
||||
o.flat_count,
|
||||
o.free_flats
|
||||
o.free_flats,
|
||||
o.ready_dt
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.region_cd = :region_cd
|
||||
AND o.district_name IS NOT NULL
|
||||
|
|
@ -240,6 +250,8 @@ _L2_HIDDEN_SQL = text(
|
|||
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)
|
||||
GROUP BY district_name
|
||||
"""
|
||||
)
|
||||
|
|
@ -368,7 +380,11 @@ def compute_layer2_hidden(
|
|||
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
|
||||
units_estimate всегда ≥ 0 (clamp). Пустые данные → []. Не бросает.
|
||||
"""
|
||||
params: dict[str, Any] = {"region_cd": _EKB_REGION_CODE, "district": district}
|
||||
params: dict[str, Any] = {
|
||||
"region_cd": _EKB_REGION_CODE,
|
||||
"district": district,
|
||||
"near_horizon": f"{_L2_NEAR_HORIZON_MONTHS} months",
|
||||
}
|
||||
rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2)
|
||||
|
||||
out: list[SupplyLayerRow] = []
|
||||
|
|
|
|||
|
|
@ -16,9 +16,13 @@ Foundation: domrf_kn_objects (lat/lon, comm_name, obj_class, region_cd),
|
|||
Fallback: rosreestr_deals (quarter_cad_number, deal_count, period_start_date).
|
||||
|
||||
Linkage: domrf_kn_objects.obj_id
|
||||
→ objective_complex_mapping.domrf_obj_id
|
||||
→ objective_complex_mapping.domrf_obj_id (gated: is_reviewed/manual/score≥0.85)
|
||||
→ objective_complex_mapping.objective_complex_name
|
||||
→ objective_corpus_room_month.project_name
|
||||
|
||||
OBJ-2 (#307): маппинги фильтруются по confidence (_MAPPING_CONFIDENCE_GATE) —
|
||||
unreviewed low-score auto-matches (#1331/#1333 backfill) исключаются как
|
||||
false-positive risk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -40,6 +44,24 @@ _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0
|
|||
# пытаемся rosreestr_fallback.
|
||||
_OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50
|
||||
|
||||
# OBJ-2 (#307): gate objective_complex_mapping by confidence перед использованием
|
||||
# в velocity. Fuzzy-trgm backfill (#1331/#1333) добавил ~115 auto-matched строк с
|
||||
# is_reviewed=false и низким match_score (вплоть до 0.50-0.625) — false-positive
|
||||
# risk, который раздувал/искажал velocity конкурентов.
|
||||
#
|
||||
# Принимаем mapping только если:
|
||||
# - is_reviewed = TRUE (человек подтвердил), ИЛИ
|
||||
# - match_method = 'manual' (ручной маппинг, score обычно NULL), ИЛИ
|
||||
# - match_score >= 0.85 (AUTO_ACCEPT_THRESHOLD — high-confidence auto,
|
||||
# vault: fuzzy_trgm 0.85+ надёжен для auto-use).
|
||||
#
|
||||
# Строгий gate только на is_reviewed=true дал бы 2 строки из 303 → обнулил бы
|
||||
# velocity-покрытие; 0.85-порог сохраняет 264/303 EKB-маппингов, отбрасывая 39
|
||||
# низкоуверенных. Совпадает с AUTO_ACCEPT_THRESHOLD из objective_backfill.py.
|
||||
_MAPPING_CONFIDENCE_GATE: str = (
|
||||
"(cm.is_reviewed = TRUE OR cm.match_method = 'manual' OR cm.match_score >= 0.85)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VelocityResult:
|
||||
|
|
@ -195,7 +217,7 @@ def compute_velocity(
|
|||
sales_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
WITH all_competitors AS (
|
||||
SELECT unnest(CAST(:obj_ids AS int[])) AS obj_id
|
||||
),
|
||||
|
|
@ -204,6 +226,7 @@ def compute_velocity(
|
|||
cm.objective_complex_name
|
||||
FROM objective_complex_mapping cm
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND {_MAPPING_CONFIDENCE_GATE}
|
||||
)
|
||||
SELECT
|
||||
ac.obj_id,
|
||||
|
|
@ -311,12 +334,13 @@ def compute_velocity(
|
|||
bucket_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
WITH mapped AS (
|
||||
SELECT cm.domrf_obj_id AS obj_id,
|
||||
cm.objective_complex_name
|
||||
FROM objective_complex_mapping cm
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND {_MAPPING_CONFIDENCE_GATE}
|
||||
)
|
||||
SELECT
|
||||
m.obj_id,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ _RE_FLOORS = re.compile(
|
|||
+ _DASH
|
||||
+ r"\s*"
|
||||
+ _NUM
|
||||
+ r"\s*этаж",
|
||||
+ r"(?:\s*этаж\w*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
|
||||
|
|
|
|||
|
|
@ -130,19 +130,28 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
|||
if not daily.get("time"):
|
||||
return None
|
||||
|
||||
t_max = daily.get("temperature_2m_max") or []
|
||||
t_min = daily.get("temperature_2m_min") or []
|
||||
precip = daily.get("precipitation_sum") or []
|
||||
uv = daily.get("uv_index_max") or []
|
||||
wind_d = daily.get("winddirection_10m_dominant") or []
|
||||
wind_s = daily.get("windspeed_10m_max") or []
|
||||
# Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
|
||||
# при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
|
||||
# TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
|
||||
t_max = [v for v in (daily.get("temperature_2m_max") or []) if v is not None]
|
||||
t_min = [v for v in (daily.get("temperature_2m_min") or []) if v is not None]
|
||||
precip = [v for v in (daily.get("precipitation_sum") or []) if v is not None]
|
||||
uv = [v for v in (daily.get("uv_index_max") or []) if v is not None]
|
||||
wind_d = [v for v in (daily.get("winddirection_10m_dominant") or []) if v is not None]
|
||||
wind_s = [v for v in (daily.get("windspeed_10m_max") or []) if v is not None]
|
||||
|
||||
# Circular mean направления ветра (vector sum) — избегает jump 359→1
|
||||
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
|
||||
y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None)
|
||||
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0
|
||||
# Circular mean направления ветра (vector sum) — избегает jump 359→1.
|
||||
# Если нет ни одного валидного сэмпла — dominant=None (не 0.0° = север).
|
||||
if wind_d:
|
||||
x = sum(math.cos(math.radians(d)) for d in wind_d)
|
||||
y = sum(math.sin(math.radians(d)) for d in wind_d)
|
||||
dominant_deg: float | None = (math.degrees(math.atan2(y, x)) + 360) % 360
|
||||
else:
|
||||
dominant_deg = None
|
||||
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
||||
wind_label = rose[round(dominant / 45) % 8]
|
||||
wind_label: str | None = (
|
||||
rose[round(dominant_deg / 45) % 8] if dominant_deg is not None else None
|
||||
)
|
||||
|
||||
return {
|
||||
"forecast_days": len(daily.get("time", [])),
|
||||
|
|
@ -156,7 +165,9 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
|||
"precipitation_days": sum(1 for p in precip if p and p > 0.5),
|
||||
"uv_index_max": round(max(uv), 1) if uv else None,
|
||||
"wind": {
|
||||
"dominant_direction_deg": round(dominant),
|
||||
"dominant_direction_deg": (
|
||||
round(dominant_deg) if dominant_deg is not None else None
|
||||
),
|
||||
"dominant_direction_label": wind_label,
|
||||
"max_speed_m_s": round(max(wind_s), 1) if wind_s else None,
|
||||
},
|
||||
|
|
@ -226,13 +237,21 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
|||
if not vals["t_max"]:
|
||||
seasons[season] = None
|
||||
continue
|
||||
# t_min/precip накапливаются НЕЗАВИСИМО от t_max (раздельные None-guard'ы
|
||||
# выше) — при непустом t_max и all-null t_min/precip эти списки пусты, и
|
||||
# sum()/len() даст ZeroDivisionError, min([]) — ValueError (#1578). Метрики
|
||||
# по пустому списку → None вместо падения всего сезонного ответа.
|
||||
t_min = vals["t_min"]
|
||||
precip = vals["precip"]
|
||||
seasons[season] = {
|
||||
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
|
||||
"avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
|
||||
"avg_t_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
|
||||
"max_t_c": round(max(vals["t_max"]), 1),
|
||||
"min_t_c": round(min(vals["t_min"]), 1),
|
||||
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
|
||||
"total_precip_mm": round(sum(vals["precip"]), 0),
|
||||
"min_t_c": round(min(t_min), 1) if t_min else None,
|
||||
"avg_precip_per_day_mm": (
|
||||
round(sum(precip) / len(precip), 1) if precip else None
|
||||
),
|
||||
"total_precip_mm": round(sum(precip), 0) if precip else 0,
|
||||
"days_observed": len(vals["t_max"]),
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -137,6 +137,16 @@ def _build_beat_schedule_from_db() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# РИАСУРТ Свердл (#108): региональные градостроительные зоны агломерации ЕКБ меняются
|
||||
# медленно → ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК). NB: harvest_all_*
|
||||
# пока на ПЛЕЙСХОЛДЕР-bbox 5 МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO) — фактический
|
||||
# прод-прогон только после уточнения границ.
|
||||
schedule["riasurt-sverdl-harvest-quarterly"] = {
|
||||
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
|
||||
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
return schedule
|
||||
|
||||
|
||||
|
|
@ -192,6 +202,14 @@ def _build_beat_schedule_fallback() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
|
||||
# NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
|
||||
schedule["riasurt-sverdl-harvest-quarterly"] = {
|
||||
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
|
||||
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
return schedule
|
||||
|
||||
|
||||
|
|
@ -506,4 +524,37 @@ def build_beat_schedule() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# Refresh mv_layout_velocity (#1666) — питает «лучшие планировки» best_layouts (#113).
|
||||
# До этого MV рефрешился ТОЛЬКО ручным вызовом refresh_layout_velocity() → в проде
|
||||
# данные молча устаревали. REFRESH ... CONCURRENTLY (non-blocking) по mv_layout_velocity.
|
||||
#
|
||||
# Воскресенье 03:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
|
||||
# Намеренно ВНЕ monday-кластера тяжёлых site_finder-рефрешей (scrape_kn 04:15,
|
||||
# ird 05:00, gknspecial 05:30, supply-layers 06:00, genplan 06:30, location 07:00) и
|
||||
# вне воскресного okn-objects (04:30) — час запаса, не конкурирует за БД/CPU.
|
||||
# MV агрегирует темпы вымывания планировок (меняются медленно) → еженедельно хватает.
|
||||
# Техническая infra-задача, не в job_settings (как refresh-quarter-price-index / supply-layers).
|
||||
schedule["refresh-layout-velocity"] = {
|
||||
"task": "tasks.refresh_layout_velocity.refresh_layout_velocity",
|
||||
"schedule": _parse_cron("0 3 * * sun"), # 03:00 MSK, воскресенье
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# Sales-tracker MVs (#61) — питают Site Finder Velocity Score (4-й критерий) +
|
||||
# recommend_mix / sellout-forecast. Оба MV (mv_sales_tracker_velocity_by_district,
|
||||
# mv_sales_tracker_absorption_curves) рефрешатся CONCURRENTLY (non-blocking, требуют
|
||||
# unique-индексы из миграции 161). Источник — objective_lots / objective_lots_history
|
||||
# (Объектив-шахматки), наполняются objective_sync (Mon 04:15 МСК по умолчанию).
|
||||
#
|
||||
# Понедельник 04:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233) —
|
||||
# ПОСЛЕ objective_sync (04:15), чтобы агрегаты считались по свежему снапшоту; в
|
||||
# окне до тяжёлого monday-кластера site_finder-рефрешей (ird 05:00, gknspecial 05:30,
|
||||
# supply-layers 06:00). Refresh лёгкий (~6с на 1.1M lots). Техническая infra-задача,
|
||||
# не в job_settings (как refresh-quarter-price-index / refresh-layout-velocity).
|
||||
schedule["mv-sales-tracker-refresh-weekly"] = {
|
||||
"task": "tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs",
|
||||
"schedule": _parse_cron("30 4 * * mon"), # 04:30 MSK, понедельник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
return schedule
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ celery_app = Celery(
|
|||
"app.workers.tasks.ekburg_permits_sync",
|
||||
"app.workers.tasks.cbr_macro_sync",
|
||||
"app.workers.tasks.rosstat_macro_sync",
|
||||
"app.workers.tasks.refresh_quarter_price_index",
|
||||
"app.workers.tasks.etl_newbuilding_crossload",
|
||||
"app.workers.tasks.supply_layers_refresh",
|
||||
"app.workers.tasks.location_refresh",
|
||||
"app.workers.tasks.forecast",
|
||||
|
|
@ -79,6 +81,9 @@ celery_app = Celery(
|
|||
"app.workers.tasks.pat_subzones_load",
|
||||
"app.workers.tasks.izyatie_ocr_ingest",
|
||||
"app.workers.tasks.developer_registry_refresh",
|
||||
"app.workers.tasks.refresh_layout_velocity",
|
||||
"app.workers.tasks.riasurt_sverdl_harvest",
|
||||
"app.workers.tasks.mv_sales_tracker_refresh",
|
||||
],
|
||||
)
|
||||
celery_app.conf.timezone = "Europe/Moscow"
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ Handlers регистрируются через Celery signals декорато
|
|||
worker_process_init — dispose SQLAlchemy engine в каждом prefork child-процессе,
|
||||
чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
|
||||
|
||||
worker_ready — при рестарте воркера находит все 'running'/'paused' записи
|
||||
worker_ready — при рестарте воркера находит 'running'/'paused' записи
|
||||
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
|
||||
Для nspd_geo 'paused' (WAF-пауза) применяется 30-минутный cooldown
|
||||
(_ZOMBIE_PAUSED_THRESHOLD), чтобы редеплой не аннулировал WAF-защиту.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -147,10 +149,18 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
|||
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
|
||||
|
||||
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
|
||||
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' / 'paused' jobs
|
||||
# — на worker_ready по определению нет активных воркеров, всё running ==
|
||||
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' jobs — на
|
||||
# worker_ready по определению нет активных воркеров, всё running ==
|
||||
# zombie. Раньше требовали heartbeat >10мин, что пропускало jobs убитых
|
||||
# за минуту до редеплоя и оставляло их вечно висеть.
|
||||
#
|
||||
# 'paused' (consecutive_waf>=8 — NSPD-WAF забанил IP VPS) НЕ ре-enqueue'им
|
||||
# безусловно: иначе каждый рестарт/редеплой воркера мгновенно аннулировал бы
|
||||
# WAF-cooldown и worker снова долбил бы забаненный сервис. Применяем тот же
|
||||
# 30-минутный порог, что и периодический cleanup_zombies
|
||||
# (_ZOMBIE_PAUSED_THRESHOLD), переиспользуя константу чтобы избежать дрейфа.
|
||||
from app.workers.tasks.nspd_geo import _ZOMBIE_PAUSED_THRESHOLD
|
||||
|
||||
db = SessionLocal()
|
||||
geo_resume_jobs: list[int] = []
|
||||
try:
|
||||
|
|
@ -161,10 +171,16 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
|||
UPDATE nspd_geo_jobs
|
||||
SET status = 'queued',
|
||||
error = COALESCE(error, 'auto-resume at worker_ready')
|
||||
WHERE status IN ('running', 'paused')
|
||||
WHERE status = 'running'
|
||||
OR (
|
||||
status = 'paused'
|
||||
AND heartbeat_at
|
||||
< NOW() - CAST(:paused_threshold AS interval)
|
||||
)
|
||||
RETURNING job_id
|
||||
"""
|
||||
)
|
||||
),
|
||||
{"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
|
|||
|
|
@ -41,19 +41,21 @@ from app.workers.celery_app import celery_app
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
|
||||
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123):
|
||||
# (indicator_type, region, obs_date, value, source, frequency, unit, comment,
|
||||
# updated_at) PK (indicator_type, region, obs_date).
|
||||
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
|
||||
# (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
|
||||
# comment, updated_at). PK (indicator_type, region, obs_date, period_type).
|
||||
# CBR-ряды используют period_type='unknown' (литерал) — они не несут sub-period
|
||||
# granularity и различаются по obs_date.
|
||||
UPSERT_KEY_RATE_SQL = text(
|
||||
"""
|
||||
INSERT INTO macro_indicator (
|
||||
indicator_type, region, obs_date, value,
|
||||
indicator_type, region, obs_date, period_type, value,
|
||||
source, frequency, unit, comment
|
||||
) VALUES (
|
||||
'key_rate', 'rf', CAST(:d AS date), CAST(:v AS numeric),
|
||||
'key_rate', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric),
|
||||
'cbr', 'daily', '%', 'CBR key rate'
|
||||
)
|
||||
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
|
||||
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
updated_at = now()
|
||||
"""
|
||||
|
|
@ -61,16 +63,17 @@ UPSERT_KEY_RATE_SQL = text(
|
|||
|
||||
# Инфляция «% г/г» (ИПЦ YoY): indicator_type='inflation_yoy', monthly, region='rf'.
|
||||
# obs_date уже нормализован к 1-му числу месяца парсером (parse_inflation_xlsx).
|
||||
# period_type='unknown' — месячный ряд без sub-period granularity.
|
||||
UPSERT_INFLATION_SQL = text(
|
||||
"""
|
||||
INSERT INTO macro_indicator (
|
||||
indicator_type, region, obs_date, value,
|
||||
indicator_type, region, obs_date, period_type, value,
|
||||
source, frequency, unit, comment
|
||||
) VALUES (
|
||||
'inflation_yoy', 'rf', CAST(:d AS date), CAST(:v AS numeric),
|
||||
'inflation_yoy', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric),
|
||||
'cbr', 'monthly', '%', 'CBR inflation YoY (ИПЦ, % г/г)'
|
||||
)
|
||||
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
|
||||
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
updated_at = now()
|
||||
"""
|
||||
|
|
|
|||
52
backend/app/workers/tasks/mv_sales_tracker_refresh.py
Normal file
52
backend/app/workers/tasks/mv_sales_tracker_refresh.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Celery task: refresh the sales-tracker MVs (Issue #61).
|
||||
|
||||
Scheduled via hardcoded beat entry in workers/beat_schedule.py:
|
||||
'mv-sales-tracker-refresh-weekly' — weekly on Monday at 04:30 MSK.
|
||||
|
||||
Refreshes (both CONCURRENTLY, non-blocking):
|
||||
- mv_sales_tracker_velocity_by_district (Site Finder Velocity Score, 4th criterion)
|
||||
- mv_sales_tracker_absorption_curves (recommend_mix + sellout forecast foundation)
|
||||
|
||||
Both MVs are built from the Объектив sales-tracker ("шахматки") snapshots
|
||||
(objective_lots / objective_lots_history). Source data refreshes via the
|
||||
objective_sync beat job, so a weekly MV refresh keeps the aggregates current.
|
||||
|
||||
MV-source migration: data/sql/164_mv_sales_tracker_velocity_absorption.sql.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.site_finder.sales_tracker_mv_refresh import refresh_sales_tracker_mvs
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs",
|
||||
max_retries=2,
|
||||
)
|
||||
def refresh_sales_tracker_mvs_task(self: Any) -> dict[str, Any]:
|
||||
"""REFRESH both sales-tracker MVs (#61).
|
||||
|
||||
Both MVs are refreshed CONCURRENTLY (non-blocking, require their UNIQUE
|
||||
indexes created by migration 161); the service falls back to non-concurrent
|
||||
if an MV is found unpopulated (first-run edge case).
|
||||
|
||||
Returns result dict for the Celery task result store / logging.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
counts = refresh_sales_tracker_mvs(db, concurrently=True)
|
||||
logger.info("refresh_sales_tracker_mvs: completed, rows=%s", counts)
|
||||
return {"status": "ok", "rows": counts}
|
||||
except Exception as e:
|
||||
logger.exception("refresh_sales_tracker_mvs failed: %s", e)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -51,6 +51,16 @@ WAF_BACKOFF_BASE_S = 30
|
|||
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
|
||||
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
|
||||
_ZOMBIE_PAUSED_THRESHOLD = "30 minutes"
|
||||
# Issue #1655: jobs, застрявшие в 'queued' после ре-enqueue (worker_ready /
|
||||
# cleanup_zombies ставят 'queued' + apply_async), но чьё broker-сообщение
|
||||
# потерялось (flush брокера, crash до pickup, purge очереди) — раньше никто
|
||||
# не ре-reap'ил. cleanup_zombies матчил только 'running'/'paused'.
|
||||
# Матчим только 'queued' c НЕ-NULL stale heartbeat_at: свежесозданный job
|
||||
# (enqueue_geo_job) имеет heartbeat_at IS NULL (нет дефолта в схеме 77_) — его
|
||||
# initial apply_async ещё в полёте, ре-reap'ить рано. Порог = running-порог
|
||||
# (6 мин): сообщение либо взято воркером (→ status станет 'running' через claim),
|
||||
# либо потеряно — 6 мин достаточно, чтобы отличить потерю от задержки очереди.
|
||||
_ZOMBIE_QUEUED_THRESHOLD = "6 minutes"
|
||||
|
||||
|
||||
# ── Helpers для job/target lifecycle ────────────────────────────────────────
|
||||
|
|
@ -79,8 +89,19 @@ def _log(
|
|||
pass
|
||||
|
||||
|
||||
def _start_job(db: Session, job_id: int) -> None:
|
||||
db.execute(
|
||||
def _start_job(db: Session, job_id: int) -> bool:
|
||||
"""Атомарный claim job'а: переводит в 'running' ТОЛЬКО если он ещё не 'running'.
|
||||
|
||||
Issue #1621: дублирующие task-сообщения на один job_id (worker_ready resume +
|
||||
cleanup_zombies beat одновременно, либо overlap контейнеров при rolling-redeploy,
|
||||
либо два prefork-child при --concurrency>1) раньше оба безусловно делали
|
||||
UPDATE→'running' и оба входили в while-loop → двойные WAF-хиты + затёртые счётчики.
|
||||
|
||||
Теперь claim атомарен: `WHERE job_id=:id AND status <> 'running'` + RETURNING.
|
||||
Если строка не вернулась — кто-то уже держит claim, второй worker должен выйти.
|
||||
Returns True если claim получен, False если job уже 'running' (или не найден).
|
||||
"""
|
||||
claimed = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE nspd_geo_jobs
|
||||
|
|
@ -89,12 +110,17 @@ def _start_job(db: Session, job_id: int) -> None:
|
|||
heartbeat_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE job_id = :id
|
||||
AND status <> 'running'
|
||||
RETURNING job_id
|
||||
"""
|
||||
),
|
||||
{"id": job_id},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
db.commit()
|
||||
if claimed is None:
|
||||
return False
|
||||
_log(db, job_id, "info", "start", "job started/resumed")
|
||||
return True
|
||||
|
||||
|
||||
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
|
||||
|
|
@ -385,7 +411,15 @@ def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
|
|||
if not job:
|
||||
return {"error": "job_not_found", "job_id": job_id}
|
||||
|
||||
_start_job(db, job_id)
|
||||
# Атомарный claim (issue #1621): если job уже 'running', значит другое
|
||||
# task-сообщение на тот же job_id уже в работе — выходим, не дублируя
|
||||
# WAF-хиты и не затирая счётчики параллельным циклом.
|
||||
if not _start_job(db, job_id):
|
||||
logger.info(
|
||||
"process_nspd_geo_job: job=%s already claimed (running) — skipping duplicate",
|
||||
job_id,
|
||||
)
|
||||
return {"job_id": job_id, "skipped": True, "reason": "already_running"}
|
||||
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
|
||||
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
|
||||
if job["rate_ms"]:
|
||||
|
|
@ -637,6 +671,10 @@ def cleanup_zombies() -> dict[str, Any]:
|
|||
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) →
|
||||
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
|
||||
сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис.
|
||||
- status='queued' c НЕ-NULL heartbeat старше _ZOMBIE_QUEUED_THRESHOLD (6 мин)
|
||||
→ ре-enqueue'нутый job, чьё broker-сообщение потерялось (issue #1655).
|
||||
heartbeat_at IS NOT NULL отсекает свежесозданные jobs (initial apply_async
|
||||
ещё в полёте) — их heartbeat ставится только при первом _start_job.
|
||||
|
||||
Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat
|
||||
не матчит WHERE-clause.
|
||||
|
|
@ -661,12 +699,22 @@ def cleanup_zombies() -> dict[str, Any]:
|
|||
AND heartbeat_at
|
||||
< NOW() - CAST(:paused_threshold AS interval)
|
||||
)
|
||||
OR (
|
||||
-- issue #1655: ре-enqueue'нутый job застрял в 'queued'
|
||||
-- (потерянное broker-сообщение). heartbeat_at IS NOT NULL
|
||||
-- отсекает свежесозданные jobs с ещё-в-полёте apply_async.
|
||||
status = 'queued'
|
||||
AND heartbeat_at IS NOT NULL
|
||||
AND heartbeat_at
|
||||
< NOW() - CAST(:queued_threshold AS interval)
|
||||
)
|
||||
RETURNING job_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
|
||||
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
|
||||
"queued_threshold": _ZOMBIE_QUEUED_THRESHOLD,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
|
|||
|
|
@ -153,9 +153,12 @@ def import_anton_objective(
|
|||
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
# Дополним результат полезной диагностикой
|
||||
# Логируем полезную диагностику, но НЕ возвращаем dict —
|
||||
# re-raise, чтобы Celery-таск ушёл в FAILURE (не SUCCESS) и не
|
||||
# рассинхронился с objective_scrape_runs.status='failed' (#1623).
|
||||
info = get_sqlite_info(sqlite_path_eff)
|
||||
return {"run_id": run_id, "error": "sqlite_not_found", "sqlite_info": info}
|
||||
logger.error("sqlite_not_found diagnostics for run=%s: %s", run_id, info)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("import_anton_objective failed: %s", e)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,10 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
|
|||
bbox = _geojson_bbox_3857(qfeat.geometry)
|
||||
if bbox is None:
|
||||
continue
|
||||
n_quarters += 1
|
||||
# Staging-счётчик квартала: прибавляем к итогам ТОЛЬКО после успешного
|
||||
# commit (строка ниже). Иначе при сбое commit + rollback откатятся
|
||||
# незакоммиченные UPSERT'ы, а отчёт завысит число persisted фич (#1624).
|
||||
q_features = 0
|
||||
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
|
||||
layer_id = LAYERS.get(layer_key)
|
||||
if layer_id is None:
|
||||
|
|
@ -94,10 +97,13 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
|
|||
feature=feat,
|
||||
fetched_at=fetched_at,
|
||||
):
|
||||
n_features += 1
|
||||
q_features += 1
|
||||
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
|
||||
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
|
||||
db.commit()
|
||||
# Commit прошёл → фичи квартала реально persisted, учитываем в отчёте.
|
||||
n_quarters += 1
|
||||
n_features += q_features
|
||||
except Exception as exc:
|
||||
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
|
||||
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
|
||||
|
|
|
|||
47
backend/app/workers/tasks/refresh_layout_velocity.py
Normal file
47
backend/app/workers/tasks/refresh_layout_velocity.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Celery task: refresh mv_layout_velocity (питает «лучшие планировки» best_layouts).
|
||||
|
||||
Scheduled via hardcoded beat entry in workers/beat_schedule.py:
|
||||
'refresh-layout-velocity' — weekly on Sunday at 03:00 MSK.
|
||||
Стоит в «ночном» окне воскресенья, отдельно от monday-кластера тяжёлых
|
||||
site_finder-рефрешей (supply-layers / ird / gknspecial / cbr и т.д.), чтобы
|
||||
не конкурировать с ними за БД/CPU.
|
||||
|
||||
Issue: #1666 (рефреш не был подключён в beat → данные best_layouts молча
|
||||
устаревали в проде). MV-источник: #113 (PR B, mv_layout_velocity → best_layouts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="tasks.refresh_layout_velocity.refresh_layout_velocity",
|
||||
max_retries=2,
|
||||
)
|
||||
def refresh_layout_velocity_task(self: Any) -> dict[str, Any]:
|
||||
"""REFRESH MATERIALIZED VIEW mv_layout_velocity (best_layouts, #113 / #1666).
|
||||
|
||||
MV рефрешится CONCURRENTLY (non-blocking, требует unique-индекс
|
||||
mv_layout_velocity_pk); сервис сам падает в non-concurrent при unpopulated MV.
|
||||
|
||||
Returns result dict for Celery task result store / logging.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
count = refresh_layout_velocity(db, concurrently=True)
|
||||
logger.info("refresh_layout_velocity: completed, mv rows=%d", count)
|
||||
return {"status": "ok", "mv_layout_velocity_rows": count}
|
||||
except Exception as e:
|
||||
logger.exception("refresh_layout_velocity failed: %s", e)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
271
backend/app/workers/tasks/riasurt_sverdl_harvest.py
Normal file
271
backend/app/workers/tasks/riasurt_sverdl_harvest.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""Celery task: harvest РИАСУРТ Свердл (folderId 1224) → ``riasurt_sverdl`` (#108).
|
||||
|
||||
14 ключевых слоёв РИАСУРТ Свердл (ПЗЗ/планирование, инженерные ЗОУИТ, risk, opportunity)
|
||||
по агломерации ЕКБ — ОКРАИНЫ (Берёзовский, В.Пышма, Среднеуральск, Арамиль, Сысерть),
|
||||
НЕ ЕКБ-сити (тот не интегрирован в ФГИС ТП). Foundation для multi-city scaling Site Finder.
|
||||
|
||||
Источник — NSPD aeggis/v4 WMS (тот же, что федеральные слои), layerId из регионального
|
||||
каталога РИАСУРТ (см. ``nspd_client.RIASURT_SVERDL_LAYERS``). Bulk-метод клиента
|
||||
``get_riasurt_sverdl_in_bbox`` grid-walk'ит каждый слой в bbox одного МО.
|
||||
|
||||
Mirror conventions (ird_harvest.py / backend.md):
|
||||
• SessionLocal() + try/finally close; logger (не print).
|
||||
• SAVEPOINT per-row (``with db.begin_nested():``) — одна битая фича не валит батч; commit в конце.
|
||||
• CAST(:x AS type) — НИКОГДА :x::type (psycopg v3). Геометрия 3857 → MULTIPOLYGON.
|
||||
• geom_msk66 — generated STORED в БД (миграция 163), здесь НЕ пишем.
|
||||
• Сбой одного слоя/МО не валит весь прогон.
|
||||
|
||||
Расписание — ежеквартально (beat_schedule.py: riasurt-sverdl-harvest-quarterly): региональные
|
||||
градостроительные зоны меняются медленно.
|
||||
|
||||
ВНИМАНИЕ: bbox 5 МО — ПЛЕЙСХОЛДЕРЫ (см. MO_BBOXES TODO). Точные bbox резолвятся ПОСЛЕ
|
||||
деплоя через NSPD search по границам МО / ручную выгрузку. Текущие значения — грубые
|
||||
приближения в EPSG:3857 (Web Mercator), достаточные для smoke, НО НЕ для прод-harvest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.scrapers.nspd_client import (
|
||||
RIASURT_SVERDL_LAYERS,
|
||||
NSPDClient,
|
||||
riasurt_layer_topic,
|
||||
)
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── bbox 5 МО агломерации ЕКБ (EPSG:3857) ────────────────────────────────────
|
||||
# TODO(#108): ПЛЕЙСХОЛДЕРЫ — грубые приближения вокруг центров МО (±~6 км в 3857).
|
||||
# Точные административные bbox резолвятся ПОСЛЕ деплоя (NSPD search по границе МО /
|
||||
# ручная выгрузка границ из РИАСУРТ). НЕ запускать harvest_all_riasurt_sverdl на проде
|
||||
# до уточнения — иначе grid-walk промахнётся мимо застройки. Формат: (xmin, ymin, xmax, ymax).
|
||||
#
|
||||
# Центры (приблизительно, WGS84 → 3857):
|
||||
# Берёзовский ~56.91N 60.81E В.Пышма ~56.97N 60.58E
|
||||
# Среднеуральск ~56.99N 60.48E Арамиль ~56.69N 60.83E
|
||||
# Сысерть ~56.50N 60.82E
|
||||
_PLACEHOLDER_HALF_M = 6000.0 # ±6 км вокруг центра — заведомо грубо, заменить на реальные границы
|
||||
MO_BBOXES: dict[str, tuple[float, float, float, float]] = {
|
||||
# TODO(#108): заменить плейсхолдеры на реальные административные bbox МО.
|
||||
"Берёзовский": (6770000.0, 7710000.0, 6782000.0, 7722000.0),
|
||||
"Верхняя Пышма": (6745000.0, 7720000.0, 6757000.0, 7732000.0),
|
||||
"Среднеуральск": (6734000.0, 7723000.0, 6746000.0, 7735000.0),
|
||||
"Арамиль": (6772000.0, 7677000.0, 6784000.0, 7689000.0),
|
||||
"Сысерть": (6771000.0, 7639000.0, 6783000.0, 7651000.0),
|
||||
}
|
||||
|
||||
# UPSERT фичи РИАСУРТ. Геометрия НСПД (GeoJSON 3857) → ST_GeomFromGeoJSON → SetSRID 3857 →
|
||||
# ST_Multi (приводим Polygon к MULTIPOLYGON под колонку). geom_msk66 — generated в БД.
|
||||
# Нет стабильного natural-ключа дедупа у РИАСУРТ-фич → INSERT без ON CONFLICT; перед
|
||||
# harvest_all очищаем строки по mo_name (re-harvest квартала идемпотентен на уровне МО).
|
||||
_INSERT_SQL = text(
|
||||
"""
|
||||
INSERT INTO riasurt_sverdl (
|
||||
source_layer_id, layer_topic, mo_name, obshnz, description,
|
||||
raw_props, geom, fetched_at
|
||||
) VALUES (
|
||||
CAST(:source_layer_id AS integer),
|
||||
CAST(:layer_topic AS text),
|
||||
CAST(:mo_name AS text),
|
||||
CAST(:obshnz AS text),
|
||||
CAST(:description AS text),
|
||||
CAST(:raw_props AS jsonb),
|
||||
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geojson AS text)), 3857)),
|
||||
CAST(:fetched_at AS timestamptz)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
_DELETE_MO_SQL = text("DELETE FROM riasurt_sverdl WHERE mo_name = CAST(:mo_name AS text)")
|
||||
|
||||
|
||||
def _extract_obshnz(props: dict[str, Any]) -> str | None:
|
||||
"""reg_numb_border / индекс зоны из properties фичи (когда есть)."""
|
||||
opts = props.get("options") or {}
|
||||
val = (
|
||||
props.get("reg_numb_border")
|
||||
or opts.get("reg_numb_border")
|
||||
or props.get("obshnz")
|
||||
or opts.get("obshnz")
|
||||
)
|
||||
return str(val) if val is not None else None
|
||||
|
||||
|
||||
def _extract_description(props: dict[str, Any]) -> str | None:
|
||||
"""Человекочитаемое название/описание фичи."""
|
||||
opts = props.get("options") or {}
|
||||
val = (
|
||||
props.get("label")
|
||||
or props.get("name")
|
||||
or props.get("descr")
|
||||
or opts.get("label")
|
||||
or opts.get("descr")
|
||||
)
|
||||
return str(val) if val is not None else None
|
||||
|
||||
|
||||
def _insert_feature(
|
||||
db: Session,
|
||||
*,
|
||||
layer_id: int,
|
||||
mo_name: str,
|
||||
feature: Any,
|
||||
fetched_at: str,
|
||||
) -> bool:
|
||||
"""INSERT одной РИАСУРТ-фичи под SAVEPOINT. True если записана.
|
||||
|
||||
Пропускает фичи без geometry — одна битая не валит батч.
|
||||
"""
|
||||
geom = feature.geometry
|
||||
if not geom:
|
||||
return False
|
||||
props = feature.properties or {}
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.execute(
|
||||
_INSERT_SQL,
|
||||
{
|
||||
"source_layer_id": layer_id,
|
||||
"layer_topic": riasurt_layer_topic(layer_id),
|
||||
"mo_name": mo_name,
|
||||
"obshnz": _extract_obshnz(props),
|
||||
"description": _extract_description(props),
|
||||
"raw_props": json.dumps(props, ensure_ascii=False),
|
||||
"geojson": json.dumps(geom),
|
||||
"fetched_at": fetched_at,
|
||||
},
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"riasurt_sverdl: insert failed layer=%s mo=%s: %s",
|
||||
layer_id,
|
||||
mo_name,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.riasurt_sverdl_harvest.harvest_riasurt_sverdl_for_mo",
|
||||
rate_limit="1/s",
|
||||
)
|
||||
def harvest_riasurt_sverdl_for_mo(
|
||||
mo_name: str,
|
||||
mo_bbox: list[float] | tuple[float, float, float, float],
|
||||
layers: list[int] | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Harvest 14 слоёв РИАСУРТ Свердл в bbox одного МО → riasurt_sverdl.
|
||||
|
||||
Перед записью очищает прошлые строки этого МО (re-harvest идемпотентен на уровне МО —
|
||||
у РИАСУРТ-фич нет стабильного natural-ключа дедупа).
|
||||
|
||||
Args:
|
||||
mo_name: имя МО (Берёзовский / Верхняя Пышма / ...). Денормализуется в строки.
|
||||
mo_bbox: (xmin, ymin, xmax, ymax) в EPSG:3857.
|
||||
layers: список layerId РИАСУРТ. None → все 14 ключевых.
|
||||
|
||||
Returns:
|
||||
{'features': M, 'layers': L} — записано фич / обработано слоёв.
|
||||
"""
|
||||
fetched_at = datetime.now(UTC).isoformat()
|
||||
bbox = (
|
||||
float(mo_bbox[0]),
|
||||
float(mo_bbox[1]),
|
||||
float(mo_bbox[2]),
|
||||
float(mo_bbox[3]),
|
||||
)
|
||||
client = NSPDClient()
|
||||
db = SessionLocal()
|
||||
n_features = 0
|
||||
n_layers = 0
|
||||
try:
|
||||
per_layer = client.get_riasurt_sverdl_in_bbox(bbox, layers)
|
||||
# Идемпотентность на уровне МО: чистим прошлый проход перед записью нового.
|
||||
with db.begin_nested():
|
||||
db.execute(_DELETE_MO_SQL, {"mo_name": mo_name})
|
||||
for layer_id, feats in per_layer.items():
|
||||
n_layers += 1
|
||||
for feat in feats:
|
||||
if _insert_feature(
|
||||
db,
|
||||
layer_id=layer_id,
|
||||
mo_name=mo_name,
|
||||
feature=feat,
|
||||
fetched_at=fetched_at,
|
||||
):
|
||||
n_features += 1
|
||||
db.commit()
|
||||
logger.info(
|
||||
"riasurt_sverdl: МО=%s слоёв=%d фич=%d",
|
||||
mo_name,
|
||||
n_layers,
|
||||
n_features,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("riasurt_sverdl: harvest_riasurt_sverdl_for_mo упал, mo=%s", mo_name)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
return {"features": n_features, "layers": n_layers}
|
||||
|
||||
|
||||
@celery_app.task(name="tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl")
|
||||
def harvest_all_riasurt_sverdl(layers: list[int] | None = None) -> dict[str, int]:
|
||||
"""Прогон по всем 5 МО агломерации ЕКБ (MO_BBOXES) → riasurt_sverdl.
|
||||
|
||||
Вызывает harvest_riasurt_sverdl_for_mo синхронно по каждому МО (rate_limit самой
|
||||
per-MO задачи действует при .delay()-вызове; здесь синхронный прогон — каждый МО
|
||||
последовательно, чтобы один beat-тик не запускал 5 параллельных grid-walk'ов).
|
||||
|
||||
Args:
|
||||
layers: список layerId. None → все 14 ключевых (RIASURT_SVERDL_LAYERS).
|
||||
|
||||
Returns:
|
||||
{'mo': N, 'features': M} — обработано МО / суммарно записано фич.
|
||||
"""
|
||||
if not settings.enable_riasurt_harvest:
|
||||
logger.warning(
|
||||
"RIASURT harvest disabled — set ENABLE_RIASURT_HARVEST=true after configuring "
|
||||
"real MO_BBOXES (#108)"
|
||||
)
|
||||
return {"mo": 0, "features": 0}
|
||||
|
||||
n_mo = 0
|
||||
n_features = 0
|
||||
for mo_name, bbox in MO_BBOXES.items():
|
||||
try:
|
||||
res = harvest_riasurt_sverdl_for_mo(mo_name, bbox, layers)
|
||||
n_features += int(res.get("features", 0))
|
||||
n_mo += 1
|
||||
except Exception:
|
||||
logger.exception("riasurt_sverdl: МО %s упал — продолжаем остальные", mo_name)
|
||||
continue
|
||||
logger.info(
|
||||
"riasurt_sverdl: harvest_all завершён, МО=%d (из %d) фич=%d",
|
||||
n_mo,
|
||||
len(MO_BBOXES),
|
||||
n_features,
|
||||
)
|
||||
return {"mo": n_mo, "features": n_features}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MO_BBOXES",
|
||||
"harvest_all_riasurt_sverdl",
|
||||
"harvest_riasurt_sverdl_for_mo",
|
||||
]
|
||||
|
||||
# Layers catalog re-export (для удобства тестов / интроспекции).
|
||||
_RIASURT_LAYER_COUNT = len(RIASURT_SVERDL_LAYERS)
|
||||
|
|
@ -47,21 +47,23 @@ from app.workers.celery_app import celery_app
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
|
||||
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123):
|
||||
# (indicator_type, region, obs_date, value, source, frequency, unit, comment,
|
||||
# updated_at) PK (indicator_type, region, obs_date). source='rosstat',
|
||||
# frequency='yearly' (текущие ряды Росстата — годовые: obs_date = 1 января года).
|
||||
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
|
||||
# (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
|
||||
# comment, updated_at). PK (indicator_type, region, obs_date, period_type).
|
||||
# Не-ЕМИСС источники (open-data, СМР) используют period_type='unknown' (литерал) —
|
||||
# они различаются по obs_date без sub-period granularity.
|
||||
# source='rosstat', frequency='yearly' (текущие ряды Росстата — годовые).
|
||||
UPSERT_ROSSTAT_SQL = text(
|
||||
"""
|
||||
INSERT INTO macro_indicator (
|
||||
indicator_type, region, obs_date, value,
|
||||
indicator_type, region, obs_date, period_type, value,
|
||||
source, frequency, unit, comment
|
||||
) VALUES (
|
||||
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
|
||||
CAST(:v AS numeric),
|
||||
'unknown', CAST(:v AS numeric),
|
||||
'rosstat', 'yearly', CAST(:unit AS text), CAST(:comment AS text)
|
||||
)
|
||||
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
|
||||
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
source = EXCLUDED.source,
|
||||
frequency = EXCLUDED.frequency,
|
||||
|
|
@ -71,20 +73,23 @@ UPSERT_ROSSTAT_SQL = text(
|
|||
"""
|
||||
)
|
||||
|
||||
# ЕМИСС-ряды (source='emiss'): frequency параметризована (quarterly для доходов,
|
||||
# monthly для ИПЦ когда добавится) — в отличие от open-data, где она фикс 'yearly'.
|
||||
# Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
|
||||
# ЕМИСС-ряды (source='emiss'): period_type параметризован — 'year' | 'quarter' |
|
||||
# 'month' (из _emiss_period_granularity). Это ключевое отличие от не-ЕМИСС источников:
|
||||
# период ЕМИСС-ряда несёт granularity, необходимую для разделения годового агрегата
|
||||
# ('год' → 'year') и Q1 ('I квартал' → 'quarter'), оба с obs_date=YYYY-01-01 (#1606).
|
||||
# frequency параметризована (quarterly для доходов, monthly для ИПЦ когда добавится).
|
||||
# CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
|
||||
UPSERT_EMISS_SQL = text(
|
||||
"""
|
||||
INSERT INTO macro_indicator (
|
||||
indicator_type, region, obs_date, value,
|
||||
indicator_type, region, obs_date, period_type, value,
|
||||
source, frequency, unit, comment
|
||||
) VALUES (
|
||||
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
|
||||
CAST(:v AS numeric),
|
||||
CAST(:period_type AS text), CAST(:v AS numeric),
|
||||
'emiss', CAST(:freq AS text), CAST(:unit AS text), CAST(:comment AS text)
|
||||
)
|
||||
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
|
||||
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
source = EXCLUDED.source,
|
||||
frequency = EXCLUDED.frequency,
|
||||
|
|
@ -96,19 +101,19 @@ UPSERT_EMISS_SQL = text(
|
|||
|
||||
# Индекс цен на СМР — открытый xlsx Росстата: source='rosstat' (rosstat.gov.ru-файл),
|
||||
# НО frequency='monthly' (в отличие от open-data демографии — там фикс 'yearly'),
|
||||
# поэтому frequency параметризована. MacroRow не несёт frequency-поля, подставляем
|
||||
# литералом в bind. Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — psycopg v3.
|
||||
# поэтому frequency параметризована. MacroRow не несёт frequency/period_type-полей;
|
||||
# подставляем литералами. CAST(:x AS type) — psycopg v3.
|
||||
UPSERT_ROSSTAT_MONTHLY_SQL = text(
|
||||
"""
|
||||
INSERT INTO macro_indicator (
|
||||
indicator_type, region, obs_date, value,
|
||||
indicator_type, region, obs_date, period_type, value,
|
||||
source, frequency, unit, comment
|
||||
) VALUES (
|
||||
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
|
||||
CAST(:v AS numeric),
|
||||
'unknown', CAST(:v AS numeric),
|
||||
'rosstat', 'monthly', CAST(:unit AS text), CAST(:comment AS text)
|
||||
)
|
||||
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
|
||||
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
source = EXCLUDED.source,
|
||||
frequency = EXCLUDED.frequency,
|
||||
|
|
@ -185,9 +190,15 @@ def _upsert_monthly_rows(db: Session, rows: list[MacroRow]) -> int:
|
|||
|
||||
|
||||
def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
|
||||
"""Апсертит EmissRow в macro_indicator (source='emiss', frequency per-row).
|
||||
"""Апсертит EmissRow в macro_indicator (source='emiss', frequency+period_type per-row).
|
||||
|
||||
SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает
|
||||
число успешных upsert'ов."""
|
||||
число успешных upsert'ов.
|
||||
|
||||
period_type (из r.period_type) — часть нового PK (migration 163): позволяет
|
||||
годовому агрегату ('year') и Q1 ('quarter') за один год коexist в таблице без
|
||||
взаимной перезаписи (#1606).
|
||||
"""
|
||||
upserted = 0
|
||||
for r in rows:
|
||||
try:
|
||||
|
|
@ -198,6 +209,7 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
|
|||
"itype": r.indicator_type,
|
||||
"region": r.region,
|
||||
"d": r.obs_date,
|
||||
"period_type": r.period_type,
|
||||
"v": r.value,
|
||||
"freq": r.frequency,
|
||||
"unit": r.unit,
|
||||
|
|
@ -207,10 +219,11 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
|
|||
upserted += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"upsert emiss %s/%s@%s=%s failed: %s",
|
||||
"upsert emiss %s/%s@%s[%s]=%s failed: %s",
|
||||
r.indicator_type,
|
||||
r.region,
|
||||
r.obs_date,
|
||||
r.period_type,
|
||||
r.value,
|
||||
e,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -401,11 +401,28 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
|
|||
)
|
||||
skipped_fresh = set(rows)
|
||||
if skipped_fresh:
|
||||
# Корректируем targets_total — skipped quarters не входят в прогресс
|
||||
# Корректируем targets_total — skipped quarters не входят в прогресс.
|
||||
# Bug #1654: блок выполняется при КАЖДОМ вызове (resume re-enqueue'ит
|
||||
# тот же enqueue_cadastre_harvest), а уже обработанные кварталы тоже
|
||||
# получают свежий cad_quarter_stats.fetched_at → попадают в
|
||||
# skipped_fresh повторно. Поэтому:
|
||||
# • targets_skipped — идемпотентный SET (= число свежих сейчас),
|
||||
# а не cumulative INCREMENT (иначе раздувается на каждый resume);
|
||||
# • targets_total — :new_total это только ОСТАВШИЕСЯ к обработке
|
||||
# кварталы (len(quarters) − skipped_fresh). При resume уже
|
||||
# обработанные попадают в skipped_fresh, поэтому к остатку
|
||||
# добавляем уже учтённый прогресс (done + failed), иначе total
|
||||
# занижается и _maybe_finish_job помечает job 'done' после первого
|
||||
# доработанного квартала (#1654-followup). GREATEST с самим
|
||||
# прогрессом сохраняет монотонность (total не уменьшается).
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE cadastre_jobs SET targets_total = :new_total, "
|
||||
"targets_skipped = COALESCE(targets_skipped, 0) + :sk "
|
||||
"UPDATE cadastre_jobs SET "
|
||||
"targets_total = GREATEST("
|
||||
":new_total + COALESCE(targets_done, 0) + COALESCE(targets_failed, 0), "
|
||||
"COALESCE(targets_done, 0) + COALESCE(targets_failed, 0)"
|
||||
"), "
|
||||
"targets_skipped = :sk "
|
||||
"WHERE job_id = :id"
|
||||
),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ logger = logging.getLogger(__name__)
|
|||
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
|
||||
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
|
||||
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
|
||||
#
|
||||
# LIMIT :max_objects: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
|
||||
# max_objects=None (force "Загрузить все" без явного потолка) грузим ВСЕ строки
|
||||
# последнего snapshot, а не молча режем до _DEFAULT_MAX_OBJECTS.
|
||||
_SELECT_TARGETS_SQL = text(
|
||||
"""
|
||||
SELECT obj_id, snapshot_date
|
||||
|
|
@ -74,10 +78,12 @@ def scrape_kn_catalog_objects(
|
|||
|
||||
Args:
|
||||
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
|
||||
max_objects: Максимум объектов за один run. Default 300.
|
||||
force: Если True — игнорирует фильтр "уже сегодня обновлён" и грузит
|
||||
все объекты последнего snapshot (admin "Загрузить все"). По умолчанию
|
||||
False — пропускает то, что уже скраплено сегодня.
|
||||
max_objects: Максимум объектов за один run. Если не задан: при force=True
|
||||
лимита нет (грузим все), при force=False — _DEFAULT_MAX_OBJECTS (300).
|
||||
force: Если True — игнорирует фильтр "уже сегодня обновлён" и (при не
|
||||
заданном max_objects) снимает лимит, грузя ВСЕ объекты последнего
|
||||
snapshot (admin "Загрузить все"). По умолчанию False — пропускает
|
||||
то, что уже скраплено сегодня, и режет batch до 300.
|
||||
|
||||
Returns:
|
||||
dict с ключами: region_code, snapshot_date, obj_ids_count,
|
||||
|
|
@ -95,7 +101,15 @@ def scrape_kn_catalog_objects(
|
|||
"""
|
||||
from app.services.scrapers.domrf_catalog_object import scrape_catalog_objects
|
||||
|
||||
limit = max_objects if max_objects is not None else _DEFAULT_MAX_OBJECTS
|
||||
# Явный max_objects всегда уважается. Без него:
|
||||
# force=True ("Загрузить все") → лимита нет (limit=None → SQL LIMIT NULL = все строки);
|
||||
# force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_OBJECTS.
|
||||
if max_objects is not None:
|
||||
limit: int | None = max_objects
|
||||
elif force:
|
||||
limit = None
|
||||
else:
|
||||
limit = _DEFAULT_MAX_OBJECTS
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
|
@ -136,11 +150,11 @@ def scrape_kn_catalog_objects(
|
|||
snapshot_date_val: date = rows[0]["snapshot_date"]
|
||||
|
||||
logger.info(
|
||||
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%d force=%s",
|
||||
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%s force=%s",
|
||||
region_code,
|
||||
snapshot_date_val,
|
||||
len(obj_ids),
|
||||
limit,
|
||||
"ALL" if limit is None else limit,
|
||||
force,
|
||||
)
|
||||
|
||||
|
|
|
|||
321
backend/scripts/spike_plan_vectorize.py
Normal file
321
backend/scripts/spike_plan_vectorize.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""Spike — contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.
|
||||
|
||||
Forgejo issue #299 (Phase 1 exploration SPIKE). **This is a throwaway probe, NOT
|
||||
production wiring**: it answers one question — is Potrace contour tracing good
|
||||
enough to turn floor-plan rasters into compact, readable SVG for the planning UI
|
||||
catalog and PDF reports? No ML, no room semantics, no DOM.RF coupling.
|
||||
|
||||
WHAT IT MEASURES
|
||||
----------------
|
||||
For each input raster the pipeline runs:
|
||||
|
||||
1. load (Pillow) → grayscale (``L``)
|
||||
2. binarise: pixels below ``--threshold`` (0-255) become ink (black), the rest
|
||||
become paper (white). Optional ``--invert`` for light-on-dark plans.
|
||||
3. write a 1-bit PBM bitmap (Potrace's native input) to the output dir
|
||||
4. ``potrace -s`` → SVG (centerline-free, filled-contour tracing)
|
||||
5. record size metrics: raster bytes, svg bytes, ratio (raster / svg)
|
||||
|
||||
It also (optionally, ``--render-back``) rasterises the produced SVG back to PNG
|
||||
via ``rsvg-convert`` so a human can eyeball SVG-vs-original side by side and
|
||||
judge whether wall/room contours survived the round trip.
|
||||
|
||||
WHY POTRACE IS SOURCE-AGNOSTIC
|
||||
------------------------------
|
||||
Potrace traces the boundary between ink and paper regions of a bitmap. It does
|
||||
not care where the raster came from — a Wikimedia line-drawing plan, a scanned
|
||||
blueprint, or a DOM.RF UI tile all reduce to "dark strokes on a light field"
|
||||
after thresholding. So the Phase 1 question ("are the contours traceable and how
|
||||
much do they compress?") is validly answered on any representative floor-plan
|
||||
rasters; see the spike doc for the explicit DOM.RF caveat.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
# vectorise every raster in a folder, collect a metrics table
|
||||
uv run python backend/scripts/spike_plan_vectorize.py \
|
||||
--in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out
|
||||
|
||||
# also render the SVGs back to PNG for visual QA
|
||||
uv run python backend/scripts/spike_plan_vectorize.py \
|
||||
--in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out --render-back
|
||||
|
||||
Requires the ``potrace`` binary on PATH (``brew install potrace``) and, for
|
||||
``--render-back``, ``rsvg-convert`` (``brew install librsvg``). Pillow ships with
|
||||
the backend deps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger("spike_plan_vectorize")
|
||||
|
||||
_RASTER_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"}
|
||||
_POTRACE_TIMEOUT_S = 120
|
||||
_RSVG_TIMEOUT_S = 120
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VectorizeResult:
|
||||
"""One raster's trip through the pipeline."""
|
||||
|
||||
name: str
|
||||
raster_bytes: int
|
||||
svg_bytes: int
|
||||
svg_path: Path
|
||||
rendered_path: Path | None
|
||||
|
||||
@property
|
||||
def ratio(self) -> float:
|
||||
"""Compression ratio raster / svg (>1 means SVG is smaller)."""
|
||||
return self.raster_bytes / self.svg_bytes if self.svg_bytes else 0.0
|
||||
|
||||
def metrics_line(self) -> str:
|
||||
return (
|
||||
f"{self.name:<44} "
|
||||
f"raster={self.raster_bytes:>9}B "
|
||||
f"svg={self.svg_bytes:>8}B "
|
||||
f"ratio={self.ratio:>6.2f}x"
|
||||
)
|
||||
|
||||
|
||||
def _require_binary(name: str) -> str:
|
||||
"""Resolve an external binary on PATH or exit with a clear message."""
|
||||
path = shutil.which(name)
|
||||
if path is None:
|
||||
logger.error("required binary %r not found on PATH", name)
|
||||
raise SystemExit(f"{name!r} not found — install it (e.g. `brew install {name}`) and retry")
|
||||
return path
|
||||
|
||||
|
||||
def raster_to_pbm(src: Path, pbm_path: Path, *, threshold: int, invert: bool) -> None:
|
||||
"""Load a raster, grayscale + threshold it, and write a 1-bit PBM bitmap.
|
||||
|
||||
Potrace consumes 1-bit bitmaps (PBM/PGM/BMP). We binarise with a fixed
|
||||
threshold so the spike's behaviour is deterministic and explainable — black
|
||||
(ink) is what Potrace traces, white is background. ``invert`` flips the test
|
||||
for light-stroke-on-dark plans.
|
||||
"""
|
||||
with Image.open(src) as im:
|
||||
gray = im.convert("L")
|
||||
# point(): pixel < threshold → ink (0), else paper (255). Pillow's "1" mode
|
||||
# then packs to a true 1-bit bitmap that Potrace reads natively.
|
||||
cutoff = threshold
|
||||
if invert:
|
||||
bitmap = gray.point(lambda px: 0 if px >= cutoff else 255).convert("1")
|
||||
else:
|
||||
bitmap = gray.point(lambda px: 0 if px < cutoff else 255).convert("1")
|
||||
bitmap.save(pbm_path)
|
||||
|
||||
|
||||
def pbm_to_svg(potrace_bin: str, pbm_path: Path, svg_path: Path) -> None:
|
||||
"""Trace a PBM bitmap to SVG with ``potrace -s`` (SVG backend)."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[potrace_bin, "-s", "-o", str(svg_path), str(pbm_path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=_POTRACE_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.decode("utf-8", "replace").strip()
|
||||
logger.error("potrace failed for %s: %s", pbm_path.name, stderr)
|
||||
raise
|
||||
|
||||
|
||||
def svg_to_png(rsvg_bin: str, svg_path: Path, png_path: Path, *, width: int) -> None:
|
||||
"""Render an SVG back to PNG via ``rsvg-convert`` for visual QA.
|
||||
|
||||
We composite onto an explicit white background. Potrace's SVG is black
|
||||
filled paths on a *transparent* canvas; without ``--background-color=white``
|
||||
rsvg renders the black fill onto transparency and a flattening viewer shows
|
||||
a solid black tile. White matches the real catalog/PDF use case anyway.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
rsvg_bin,
|
||||
"-w",
|
||||
str(width),
|
||||
"--background-color=white",
|
||||
"-o",
|
||||
str(png_path),
|
||||
str(svg_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=_RSVG_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.decode("utf-8", "replace").strip()
|
||||
logger.error("rsvg-convert failed for %s: %s", svg_path.name, stderr)
|
||||
raise
|
||||
|
||||
|
||||
def vectorize_one(
|
||||
src: Path,
|
||||
out_dir: Path,
|
||||
*,
|
||||
potrace_bin: str,
|
||||
rsvg_bin: str | None,
|
||||
threshold: int,
|
||||
invert: bool,
|
||||
render_width: int,
|
||||
) -> VectorizeResult:
|
||||
"""Run the full PNG/JPG → PBM → SVG (→ PNG) pipeline for a single raster."""
|
||||
stem = src.stem
|
||||
pbm_path = out_dir / f"{stem}.pbm"
|
||||
svg_path = out_dir / f"{stem}.svg"
|
||||
|
||||
raster_to_pbm(src, pbm_path, threshold=threshold, invert=invert)
|
||||
pbm_to_svg(potrace_bin, pbm_path, svg_path)
|
||||
|
||||
rendered_path: Path | None = None
|
||||
if rsvg_bin is not None:
|
||||
rendered_path = out_dir / f"{stem}.rendered.png"
|
||||
svg_to_png(rsvg_bin, svg_path, rendered_path, width=render_width)
|
||||
|
||||
return VectorizeResult(
|
||||
name=src.name,
|
||||
raster_bytes=src.stat().st_size,
|
||||
svg_bytes=svg_path.stat().st_size,
|
||||
svg_path=svg_path,
|
||||
rendered_path=rendered_path,
|
||||
)
|
||||
|
||||
|
||||
def discover_rasters(in_dir: Path) -> list[Path]:
|
||||
"""Return sorted raster files in ``in_dir`` (non-recursive)."""
|
||||
return sorted(
|
||||
p for p in in_dir.iterdir() if p.is_file() and p.suffix.lower() in _RASTER_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def summarise(results: list[VectorizeResult]) -> None:
|
||||
"""Print a per-file metrics table plus min/median/max compression."""
|
||||
if not results:
|
||||
logger.warning("no results to summarise")
|
||||
return
|
||||
|
||||
print("\n=== per-raster metrics ===")
|
||||
for r in results:
|
||||
print(r.metrics_line())
|
||||
|
||||
ratios = sorted(r.ratio for r in results)
|
||||
print("\n=== compression summary ===")
|
||||
print(f"samples : {len(ratios)}")
|
||||
print(f"min ratio : {min(ratios):.2f}x")
|
||||
print(f"median ratio : {statistics.median(ratios):.2f}x")
|
||||
print(f"max ratio : {max(ratios):.2f}x")
|
||||
total_raster = sum(r.raster_bytes for r in results)
|
||||
total_svg = sum(r.svg_bytes for r in results)
|
||||
agg_ratio = total_raster / total_svg if total_svg else float("inf")
|
||||
print(
|
||||
f"aggregate : {total_raster}B raster -> {total_svg}B svg " f"({agg_ratio:.2f}x overall)"
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Spike: contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--in-dir",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="folder containing input rasters (PNG/JPG/...)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out-dir",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="folder for output .pbm/.svg (and .rendered.png with --render-back)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=int,
|
||||
default=128,
|
||||
help="grayscale ink/paper cutoff 0-255 (default 128)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--invert",
|
||||
action="store_true",
|
||||
help="treat light strokes on a dark field as ink",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--render-back",
|
||||
action="store_true",
|
||||
help="rasterise each SVG back to PNG via rsvg-convert for visual QA",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--render-width",
|
||||
type=int,
|
||||
default=900,
|
||||
help="width in px for --render-back output (default 900)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
in_dir: Path = args.in_dir
|
||||
out_dir: Path = args.out_dir
|
||||
if not in_dir.is_dir():
|
||||
logger.error("input dir does not exist: %s", in_dir)
|
||||
return 2
|
||||
if not 0 <= args.threshold <= 255:
|
||||
logger.error("--threshold must be 0-255, got %d", args.threshold)
|
||||
return 2
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
potrace_bin = _require_binary("potrace")
|
||||
rsvg_bin = _require_binary("rsvg-convert") if args.render_back else None
|
||||
|
||||
rasters = discover_rasters(in_dir)
|
||||
if not rasters:
|
||||
logger.error("no raster files found in %s", in_dir)
|
||||
return 1
|
||||
logger.info("found %d raster(s) in %s", len(rasters), in_dir)
|
||||
|
||||
results: list[VectorizeResult] = []
|
||||
failed = 0
|
||||
for src in rasters:
|
||||
try:
|
||||
results.append(
|
||||
vectorize_one(
|
||||
src,
|
||||
out_dir,
|
||||
potrace_bin=potrace_bin,
|
||||
rsvg_bin=rsvg_bin,
|
||||
threshold=args.threshold,
|
||||
invert=args.invert,
|
||||
render_width=args.render_width,
|
||||
)
|
||||
)
|
||||
logger.info("vectorized %s", src.name)
|
||||
except Exception:
|
||||
# Log + continue: one bad raster must not abort the batch, but we
|
||||
# never swallow silently — the traceback is recorded and the file
|
||||
# is counted as a failure in the final tally.
|
||||
failed += 1
|
||||
logger.exception("failed to vectorize %s", src.name)
|
||||
|
||||
summarise(results)
|
||||
if failed:
|
||||
logger.warning("%d/%d raster(s) failed", failed, len(rasters))
|
||||
return 0 if results else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -23,6 +23,7 @@ from app.services.scrapers.rosstat_emiss import (
|
|||
OKATO_SVERDLOVSK_OBLAST,
|
||||
EmissIndicatorSpec,
|
||||
RegionTarget,
|
||||
_emiss_period_granularity,
|
||||
_emiss_period_to_month,
|
||||
parse_emiss_sdmx,
|
||||
)
|
||||
|
|
@ -59,17 +60,78 @@ def test_period_year_and_unknown() -> None:
|
|||
assert _emiss_period_to_month("") is None
|
||||
|
||||
|
||||
def test_period_granularity() -> None:
|
||||
"""_emiss_period_granularity корректно классифицирует все под-периоды (#1606)."""
|
||||
assert _emiss_period_granularity("год") == "year"
|
||||
assert _emiss_period_granularity("значение показателя за год") == "year"
|
||||
assert _emiss_period_granularity("за год") == "year"
|
||||
assert _emiss_period_granularity("I квартал") == "quarter"
|
||||
assert _emiss_period_granularity("IV квартал") == "quarter"
|
||||
assert _emiss_period_granularity("январь") == "month"
|
||||
assert _emiss_period_granularity("декабрь") == "month"
|
||||
assert _emiss_period_granularity("3 неделя") == "unknown"
|
||||
assert _emiss_period_granularity("") == "unknown"
|
||||
|
||||
|
||||
def test_yearly_and_q1_both_survive_dedup() -> None:
|
||||
"""Годовой ряд ('год') и Q1 ('I квартал') за один год — оба остаются (#1606).
|
||||
|
||||
До фикса: оба давали obs_date=YYYY-01-01, ключ (region, obs_date) был один →
|
||||
второй по XML-порядку перезаписывал первого. После фикса: ключ трёхкомпонентный
|
||||
(region, obs_date, granularity) → 'year' и 'quarter' — разные слоты.
|
||||
"""
|
||||
xml = (
|
||||
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
|
||||
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
|
||||
"<DataSet>"
|
||||
# годовой ряд за 2023 — значение 48000
|
||||
"<generic:Series><generic:SeriesKey>"
|
||||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||
'<generic:Attributes><generic:Value concept="PERIOD" value="год"/>'
|
||||
"</generic:Attributes>"
|
||||
"<generic:Obs><generic:Time>2023</generic:Time>"
|
||||
'<generic:ObsValue value="48000"/></generic:Obs></generic:Series>'
|
||||
# Q1 того же 2023 — значение 42000; obs_date тоже 2023-01-01
|
||||
"<generic:Series><generic:SeriesKey>"
|
||||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||||
"</generic:Attributes>"
|
||||
"<generic:Obs><generic:Time>2023</generic:Time>"
|
||||
'<generic:ObsValue value="42000"/></generic:Obs></generic:Series>'
|
||||
"</DataSet></GenericData>"
|
||||
)
|
||||
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
|
||||
# Оба наблюдения должны присутствовать — разные гранулярности
|
||||
assert len(rows) == 2, f"ожидались 2 строки, получили {len(rows)}: {rows}"
|
||||
values = {r.value for r in rows}
|
||||
assert Decimal("48000") in values, "годовое наблюдение потеряно"
|
||||
assert Decimal("42000") in values, "Q1-наблюдение потеряно"
|
||||
# obs_date у обоих одинаковый (это нормально — коллизия теперь на стороне DB-upsert)
|
||||
assert all(r.obs_date == date(2023, 1, 1) for r in rows)
|
||||
# period_type: годовой → 'year', Q1 → 'quarter' (часть PK в macro_indicator, migration 163)
|
||||
period_types = {r.period_type for r in rows}
|
||||
assert period_types == {
|
||||
"year",
|
||||
"quarter",
|
||||
}, f"ожидались period_type year+quarter, получили {period_types}"
|
||||
|
||||
|
||||
# ── real-fixture extraction (income id=57039) ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_income_extracts_sverdlovsk_only() -> None:
|
||||
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет."""
|
||||
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет.
|
||||
Все строки квартальные → period_type='quarter' (часть PK macro_indicator, migration 163).
|
||||
"""
|
||||
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
|
||||
assert rows, "ожидались строки по Свердл"
|
||||
assert {r.region for r in rows} == {"sverdl"}
|
||||
assert all(r.indicator_type == "income_per_capita" for r in rows)
|
||||
assert all(r.unit == "руб" for r in rows)
|
||||
assert all(r.frequency == "quarterly" for r in rows)
|
||||
assert all(
|
||||
r.period_type == "quarter" for r in rows
|
||||
), "квартальные строки должны иметь period_type='quarter'"
|
||||
|
||||
|
||||
def test_income_concrete_values_and_dates() -> None:
|
||||
|
|
@ -154,13 +216,13 @@ def test_unknown_period_series_skipped() -> None:
|
|||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||
'<generic:Attributes><generic:Value concept="PERIOD" value="3 неделя"/>'
|
||||
"</generic:Attributes>"
|
||||
'<generic:Obs><generic:Time>2024</generic:Time>'
|
||||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||||
'<generic:ObsValue value="999"/></generic:Obs></generic:Series>'
|
||||
"<generic:Series><generic:SeriesKey>"
|
||||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||||
"</generic:Attributes>"
|
||||
'<generic:Obs><generic:Time>2024</generic:Time>'
|
||||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||||
'<generic:ObsValue value="50000"/></generic:Obs></generic:Series>'
|
||||
"</DataSet></GenericData>"
|
||||
)
|
||||
|
|
@ -179,7 +241,7 @@ def test_comma_decimal_value_parsed() -> None:
|
|||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||
'<generic:Attributes><generic:Value concept="PERIOD" value="январь"/>'
|
||||
"</generic:Attributes>"
|
||||
'<generic:Obs><generic:Time>2024</generic:Time>'
|
||||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||||
'<generic:ObsValue value="113,2"/></generic:Obs></generic:Series></DataSet>'
|
||||
"</GenericData>"
|
||||
)
|
||||
|
|
@ -199,13 +261,13 @@ def test_dedup_last_wins() -> None:
|
|||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||||
"</generic:Attributes>"
|
||||
'<generic:Obs><generic:Time>2024</generic:Time>'
|
||||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||||
'<generic:ObsValue value="100"/></generic:Obs></generic:Series>'
|
||||
"<generic:Series><generic:SeriesKey>"
|
||||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||||
"</generic:Attributes>"
|
||||
'<generic:Obs><generic:Time>2024</generic:Time>'
|
||||
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||||
'<generic:ObsValue value="200"/></generic:Obs></generic:Series>'
|
||||
"</DataSet></GenericData>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from app.services.exporters.excel import (
|
|||
_SHEET_SUMMARY,
|
||||
_fmt,
|
||||
_sanitize_formula,
|
||||
_scenario_deficit_cell,
|
||||
_scenario_deficit_index,
|
||||
export_report_xlsx,
|
||||
)
|
||||
|
|
@ -313,6 +314,53 @@ class TestContractKeysWritten:
|
|||
assert di is not None
|
||||
assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках"
|
||||
|
||||
def test_scenario_deficit_cell_annotates_fallback_horizon(self) -> None:
|
||||
"""#1590: при fallback на горизонт != 12 ячейка несёт «(гор. N мес)»."""
|
||||
# Сценарий без 12-мес прогноза — только 6-мес.
|
||||
payload_6mo = ScenarioForecast(
|
||||
scenario="base", # type: ignore[arg-type]
|
||||
rate_path={6: 18.0},
|
||||
forecasts=[
|
||||
DemandSupplyForecast(
|
||||
segment={"obj_class": "комфорт"},
|
||||
horizon_months=6,
|
||||
base_pace_units_per_mo=8.0,
|
||||
demand_norm_coefficient=1.0,
|
||||
macro_coefficient=1.0,
|
||||
projected_demand_units=410.0,
|
||||
open_units=300,
|
||||
hidden_release_units=80.0,
|
||||
future_online_units=20.0,
|
||||
projected_supply_units=380.0,
|
||||
balance_units=30.0,
|
||||
balance_ratio=1.08,
|
||||
deficit_index=0.22,
|
||||
months_of_inventory=5.5,
|
||||
rate_future=18.0,
|
||||
rate_sensitivity_phrase=None,
|
||||
future_competitors=[],
|
||||
advisory=True,
|
||||
confidence="low",
|
||||
)
|
||||
],
|
||||
advisory=True,
|
||||
).as_dict()
|
||||
cell_value = _scenario_deficit_cell(payload_6mo)
|
||||
# Должна быть строка с аннотацией горизонта, не голое число.
|
||||
assert isinstance(cell_value, str), "ожидается строка с аннотацией горизонта"
|
||||
assert "гор. 6 мес" in cell_value, f"ожидалось «гор. 6 мес» в '{cell_value}'"
|
||||
assert "0.22" in cell_value or "0.22" in str(cell_value)
|
||||
|
||||
def test_scenario_deficit_cell_no_annotation_for_primary_horizon(self) -> None:
|
||||
"""#1590: при 12-мес горизонте ячейка возвращает скаляр без аннотации."""
|
||||
payload_12mo = _scenario("base", deficit_12mo=0.34)
|
||||
cell_value = _scenario_deficit_cell(payload_12mo)
|
||||
# Для основного горизонта — голое число, не строка с «(гор. N мес)».
|
||||
assert not isinstance(
|
||||
cell_value, str
|
||||
), f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'"
|
||||
assert cell_value == 0.34
|
||||
|
||||
def test_overall_score_in_cells(self) -> None:
|
||||
data = _full_report().as_dict()
|
||||
overall = data["scoring"]["overall"]
|
||||
|
|
@ -466,10 +514,6 @@ class TestFormulaInjection:
|
|||
payload = export_report_xlsx(report)
|
||||
wb = _reload(payload)
|
||||
formula_cells = [
|
||||
c
|
||||
for ws in wb.worksheets
|
||||
for col in ws.iter_cols()
|
||||
for c in col
|
||||
if c.data_type == "f"
|
||||
c for ws in wb.worksheets for col in ws.iter_cols() for c in col if c.data_type == "f"
|
||||
]
|
||||
assert not formula_cells, f"ни одна ячейка не должна быть формулой: {formula_cells}"
|
||||
|
|
|
|||
|
|
@ -269,11 +269,14 @@ class TestPaymentAtScenario:
|
|||
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
||||
assert res.payment_at_scenario is not None
|
||||
principal = 120_000.0 * _REF_AREA_M2
|
||||
# rate_path несёт КЛЮЧЕВУЮ ставку сценария; affordability приводит к рыночной
|
||||
# базе (+ _KEY_RATE_MARKET_SPREAD_PP), как и monthly_payment_rub (#1639). Ожидания
|
||||
# выражаем символически — тест переживёт перекалибровку спреда.
|
||||
assert res.payment_at_scenario[6] == pytest.approx(
|
||||
_annuity(principal, 8.0, _ANNUITY_TERM_MONTHS)
|
||||
_annuity(principal, 8.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
|
||||
)
|
||||
assert res.payment_at_scenario[12] == pytest.approx(
|
||||
_annuity(principal, 20.0, _ANNUITY_TERM_MONTHS)
|
||||
_annuity(principal, 20.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
|
||||
)
|
||||
# Выше ставка → выше платёж на этом горизонте.
|
||||
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
||||
|
|
|
|||
|
|
@ -342,6 +342,26 @@ class TestComputeReportConfidence:
|
|||
# Полностью JSON-сериализуем (контракт для экспортёров/чата).
|
||||
assert json.loads(json.dumps(d, ensure_ascii=False)) == d
|
||||
|
||||
def test_deal_count_note_carries_window_months(self) -> None:
|
||||
# #1637: deal_count_months → нота «за N мес» в факторе (и в rationale).
|
||||
res = compute_report_confidence(
|
||||
deal_count=7,
|
||||
deal_count_months=6,
|
||||
advisory=False,
|
||||
)
|
||||
dc_factor = next(f for f in res.factors if f.name == "deal_count")
|
||||
assert "за 6 мес" in dc_factor.note
|
||||
assert "7 сделок" in dc_factor.note
|
||||
# Структурная причина тоже содержит период (через ноту фактора-виновника).
|
||||
assert "за 6 мес" in res.rationale
|
||||
|
||||
def test_deal_count_note_without_window_has_no_period(self) -> None:
|
||||
# deal_count_months=None (по умолчанию) → нота без «за N мес» (backward compat).
|
||||
res = compute_report_confidence(deal_count=7, advisory=False)
|
||||
dc_factor = next(f for f in res.factors if f.name == "deal_count")
|
||||
assert "7 сделок" in dc_factor.note
|
||||
assert "за" not in dc_factor.note
|
||||
|
||||
def test_ignores_garbage_component_confidence(self) -> None:
|
||||
# Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
|
||||
res = compute_report_confidence(
|
||||
|
|
|
|||
|
|
@ -138,6 +138,50 @@ class TestSeasonalFactors:
|
|||
assert adj.applied is False
|
||||
assert all(f == 1.0 for f in adj.factors.values())
|
||||
|
||||
# ── fix #1638: zero-filled series must fail the min-years guard ───────────
|
||||
|
||||
def test_zero_filled_36m_fails_min_years_guard(self) -> None:
|
||||
"""Ряд 36 мес, все units=0 (zero-fill от fill_month_grid) → applied=False.
|
||||
|
||||
До fix #1638 _count_full_years считал нулевые значения за наблюдения
|
||||
и возвращал n_full_years=3 → guard пропускал → overall_mean==0 спасал
|
||||
от деления, но прохождение guard'а было семантически неверным.
|
||||
После fix: 0 не считается наблюдением → n_full_years=0 → applied=False.
|
||||
"""
|
||||
months = _months(36)
|
||||
adj = seasonal_factors(months, [0] * 36)
|
||||
assert (
|
||||
adj.n_full_years == 0
|
||||
), f"expected 0 full years on all-zero series, got {adj.n_full_years}"
|
||||
assert adj.applied is False
|
||||
assert all(f == 1.0 for f in adj.factors.values())
|
||||
|
||||
def test_mixed_zero_and_real_partial_years_fails_guard(self) -> None:
|
||||
"""2 года месяцев, но только часть месяцев ненулевые — не 2 полных года.
|
||||
|
||||
Сценарий: новый ЖК, продажи только в нескольких месяцах каждого года
|
||||
(остальные zero-fill). _count_full_years должен не считать это «полным годом».
|
||||
"""
|
||||
months = _months(24) # 2 календарных года
|
||||
# Только январь-июнь каждого года ненулевые → нет полного покрытия 12 мес.
|
||||
units = [10 if d.month <= 6 else 0 for d in months]
|
||||
adj = seasonal_factors(months, units)
|
||||
assert (
|
||||
adj.n_full_years == 0
|
||||
), f"partial-coverage years should not count as full, got {adj.n_full_years}"
|
||||
assert adj.applied is False
|
||||
|
||||
def test_real_nonzero_series_passes_guard_and_applies(self) -> None:
|
||||
"""Ряд с реальными ненулевыми данными за 2+ лет проходит guard и применяется.
|
||||
|
||||
Страховка: fix #1638 не должен ломать штатный путь с настоящими данными.
|
||||
"""
|
||||
months = _months(36)
|
||||
units = _seasonal_units(months) # baseline×паттерн, все > 0
|
||||
adj = seasonal_factors(months, units)
|
||||
assert adj.n_full_years == 3
|
||||
assert adj.applied is True
|
||||
|
||||
def test_month_with_no_observations_factor_one(self) -> None:
|
||||
# Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
|
||||
# (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
|
||||
|
|
@ -239,3 +283,17 @@ class TestNormalizeDemand:
|
|||
units = _seasonal_units(months)
|
||||
result = normalize_demand(_make_series(months, units))
|
||||
assert all(isinstance(u, int) and u >= 0 for u in result.units)
|
||||
|
||||
def test_zero_filled_series_returned_unchanged(self) -> None:
|
||||
"""fix #1638: zero-filled SalesSeries (все units=0) возвращается без изменений.
|
||||
|
||||
До fix: guard считал 3 «полных года» на нулях → adjustment.applied=True
|
||||
(пройдя через overall_mean==0 check) или мог пройти guard и вернуть
|
||||
идентичный ряд через нейтральные факторы. После fix: n_full_years=0 →
|
||||
applied=False → функция возвращает тот же объект (short-circuit).
|
||||
"""
|
||||
months = _months(36)
|
||||
series = _make_series(months, [0] * 36)
|
||||
result = normalize_demand(series)
|
||||
assert result is series, "zero-filled series must be returned as-is (no adjustment)"
|
||||
assert result.units == [0] * 36
|
||||
|
|
|
|||
|
|
@ -409,7 +409,7 @@ class TestScoreDifferentiation:
|
|||
|
||||
class TestScoreCommercial:
|
||||
def test_available_share_scaled(self) -> None:
|
||||
signal = {"available": True, "commercial_share_pct": 40.0, "confidence": "medium"}
|
||||
signal = {"available": True, "commercial_sell_through_pct": 40.0, "confidence": "medium"}
|
||||
value, conf, _r = _score_commercial(signal)
|
||||
assert value == pytest.approx(0.4)
|
||||
assert conf == "medium"
|
||||
|
|
@ -417,7 +417,7 @@ class TestScoreCommercial:
|
|||
def test_in_range(self) -> None:
|
||||
for share in (0.0, 25.0, 100.0):
|
||||
value, _c, _r = _score_commercial(
|
||||
{"available": True, "commercial_share_pct": share, "confidence": "low"}
|
||||
{"available": True, "commercial_sell_through_pct": share, "confidence": "low"}
|
||||
)
|
||||
assert value is not None
|
||||
assert 0.0 <= value <= 1.0
|
||||
|
|
@ -439,7 +439,7 @@ class TestScoreCommercial:
|
|||
|
||||
def test_bad_confidence_falls_back_low(self) -> None:
|
||||
value, conf, _r = _score_commercial(
|
||||
{"available": True, "commercial_share_pct": 10.0, "confidence": "garbage"}
|
||||
{"available": True, "commercial_sell_through_pct": 10.0, "confidence": "garbage"}
|
||||
)
|
||||
assert value is not None
|
||||
assert conf == "low"
|
||||
|
|
@ -728,7 +728,7 @@ def _patch_all(
|
|||
overlay: Any = None,
|
||||
) -> Any:
|
||||
"""Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом)."""
|
||||
commercial = {"available": True, "commercial_share_pct": 30.0, "confidence": "medium"}
|
||||
commercial = {"available": True, "commercial_sell_through_pct": 30.0, "confidence": "medium"}
|
||||
forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)]
|
||||
competitors_rv = (
|
||||
competitors if competitors is not None else _competitors_response_stub([0.4, 0.2])
|
||||
|
|
|
|||
|
|
@ -329,9 +329,13 @@ class TestDemandSupplyGraceful:
|
|||
# ── build_forecast_overlay: demand_only (cad_num=None) ─────────────────────────
|
||||
|
||||
|
||||
def _mk_metrics(unit_velocity: float | None) -> MagicMock:
|
||||
def _mk_metrics(
|
||||
unit_velocity: float | None,
|
||||
velocity_by_room: dict[str, float] | None = None,
|
||||
) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.unit_velocity = unit_velocity
|
||||
m.velocity_by_room = velocity_by_room
|
||||
return m
|
||||
|
||||
|
||||
|
|
@ -453,6 +457,71 @@ class TestDemandOnlyOverlay:
|
|||
assert out["ranked_segments"] == []
|
||||
assert out["warnings"]
|
||||
|
||||
def test_per_bucket_velocity_drives_ranking(self) -> None:
|
||||
"""#1593: velocity_by_room даёт реальные per-bucket §9.2-темпы.
|
||||
|
||||
При одинаковом §9.5 macro_coef=1.0 ранкинг должен определяться
|
||||
исключительно per-bucket velocity_by_room, а не aggregate base_pace.
|
||||
Студии ("студия") → самый высокий темп → первые в ранкинге.
|
||||
"""
|
||||
|
||||
# velocity_by_room: студии продаются в 5× быстрее чем 1-к, остальные ≈ 0.
|
||||
vel_by_room = {
|
||||
"студия": 10.0, # "Студии 15-30" → "студия"
|
||||
"1": 2.0, # "1-к 30-45" → "1"
|
||||
"2": 1.0, # "2-к 45-60" → "2"
|
||||
"3": 0.5, # "3-к 60-80" → "3"
|
||||
"4": 0.1, # "80+ м²" → "4" + "5+"
|
||||
"5+": 0.1,
|
||||
}
|
||||
with (
|
||||
patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=vel_by_room)),
|
||||
patch(_GET_MACRO, return_value=[]),
|
||||
patch(_HOLD, return_value={12: 18.0}),
|
||||
patch(_NORM, return_value=_mk_coef(1.0)),
|
||||
patch(_MACRO, return_value=_mk_coef(1.0)), # нейтральный §9.5 (изолируем §9.2)
|
||||
):
|
||||
out = build_forecast_overlay(
|
||||
MagicMock(),
|
||||
district="Ленинский",
|
||||
cad_num=None,
|
||||
horizon_months=12,
|
||||
target_class=None,
|
||||
)
|
||||
buckets = [s["bucket"] for s in out["ranked_segments"]]
|
||||
# При одинаковом macro_coef=1.0 ранкинг = velocity_by_room:
|
||||
# студии (10.0) > 1-к (2.0) > 2-к (1.0) > 3-к (0.5) > 80+ (0.1+0.1=0.2)
|
||||
assert buckets[0] == "1-Студия", f"ожидали студии первыми, получили {buckets}"
|
||||
assert buckets[1] == "2-1-к", f"ожидали 1-к вторыми, получили {buckets}"
|
||||
assert buckets[-1] == "5-80+ м²", f"ожидали 80+ м² последними, получили {buckets}"
|
||||
# deficit_index нормирован к 1.0 для топа
|
||||
assert out["ranked_segments"][0]["deficit_index"] == pytest.approx(1.0)
|
||||
|
||||
def test_fallback_to_aggregate_when_no_velocity_by_room(self) -> None:
|
||||
"""#1593: при velocity_by_room=None возвращаемся к aggregate base_pace.
|
||||
|
||||
Ranking в этом случае определяется §9.5 macro_coef (старое поведение),
|
||||
но не крашит и не фабрикует данные.
|
||||
"""
|
||||
macro_coeffs = iter([_mk_coef(c) for c in (0.5, 1.0, 1.5, 1.2, 0.8)])
|
||||
with (
|
||||
patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=None)),
|
||||
patch(_GET_MACRO, return_value=[]),
|
||||
patch(_HOLD, return_value={12: 18.0}),
|
||||
patch(_NORM, return_value=_mk_coef(1.0)),
|
||||
patch(_MACRO, side_effect=lambda *a, **k: next(macro_coeffs)),
|
||||
):
|
||||
out = build_forecast_overlay(
|
||||
MagicMock(),
|
||||
district="Ленинский",
|
||||
cad_num=None,
|
||||
horizon_months=12,
|
||||
target_class=None,
|
||||
)
|
||||
# Graceful: 5 сегментов, deficit_index ∈ (0, 1].
|
||||
assert len(out["ranked_segments"]) == 5
|
||||
assert max(s["deficit_index"] for s in out["ranked_segments"]) == pytest.approx(1.0)
|
||||
|
||||
def test_five_default_room_buckets_ranked(self) -> None:
|
||||
with (
|
||||
patch(_METRICS, return_value=_mk_metrics(4.0)),
|
||||
|
|
@ -744,7 +813,7 @@ class TestCommercialSignal:
|
|||
out = _commercial_signal(MagicMock(), "Ленинский", 12)
|
||||
assert out is not None
|
||||
assert out["available"] is True
|
||||
assert out["commercial_share_pct"] == 42.5
|
||||
assert out["commercial_sell_through_pct"] == 42.5
|
||||
assert out["n_lots"] == 120
|
||||
assert out["confidence"] == "medium"
|
||||
assert out["reason"]["advisory"] is True
|
||||
|
|
|
|||
|
|
@ -625,14 +625,22 @@ def _launch_window_index(horizon: int | None) -> SpecialIndex:
|
|||
"""Готовый Launch Window SpecialIndex с заданным best_horizon_months (None → unavail)."""
|
||||
if horizon is None:
|
||||
return SpecialIndex(
|
||||
key=KEY_LAUNCH_WINDOW, value=None, label=None, confidence="low",
|
||||
key=KEY_LAUNCH_WINDOW,
|
||||
value=None,
|
||||
label=None,
|
||||
confidence="low",
|
||||
detail={"reason": "deficit None на всех горизонтах"},
|
||||
method=_METHOD_UNAVAILABLE, advisory=True,
|
||||
method=_METHOD_UNAVAILABLE,
|
||||
advisory=True,
|
||||
)
|
||||
return SpecialIndex(
|
||||
key=KEY_LAUNCH_WINDOW, value=0.8, label=f"{horizon} мес", confidence="medium",
|
||||
key=KEY_LAUNCH_WINDOW,
|
||||
value=0.8,
|
||||
label=f"{horizon} мес",
|
||||
confidence="medium",
|
||||
detail={"best_horizon_months": horizon, "deficit_by_horizon": {}},
|
||||
method="deficit_peak_scan", advisory=True,
|
||||
method="deficit_peak_scan",
|
||||
advisory=True,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -679,11 +687,39 @@ class TestGeoWeight:
|
|||
|
||||
def test_unknown_distance_floor_weight(self) -> None:
|
||||
# нет координат → низкий floor (НЕ 0, НЕ 1): проект сигналит, но не доминирует.
|
||||
assert _geo_weight(None) == pytest.approx(0.1)
|
||||
# #1633: понижено с 0.1 до 0.05 — старое значение 0.1 совпадало с весом
|
||||
# подтверждённо дальнего проекта (~6.9 км), создавая инверсию.
|
||||
assert _geo_weight(None) == pytest.approx(0.05)
|
||||
|
||||
def test_negative_distance_clamped(self) -> None:
|
||||
assert _geo_weight(-5.0) == pytest.approx(1.0)
|
||||
|
||||
def test_near_project_weight_exceeds_far_project_weight(self) -> None:
|
||||
# #1633: ближний проект ВСЕГДА перебивает дальний (монотонность).
|
||||
near = _geo_weight(1.0)
|
||||
far = _geo_weight(10.0)
|
||||
assert near > far
|
||||
|
||||
def test_confirmed_far_project_weight_below_unknown(self) -> None:
|
||||
# #1633: подтверждённо дальний (>6.9 км) должен иметь вес НИЖЕ неизвестного.
|
||||
# До фикса: exp(-6.9/3)≈0.10 == _GEO_WEIGHT_UNKNOWN (0.10) — инверсия/паритет.
|
||||
# После фикса: _GEO_WEIGHT_UNKNOWN=0.05, exp(-6.9/3)≈0.10 > 0.05 (правильно).
|
||||
# А при ещё бо́льшем расстоянии (10 км, ≈0.036) — тем более выше 0.05.
|
||||
# Здесь проверяем что неизвестный вес НИЖЕ вполне подтверждённо БЛИЗКОГО (1 км),
|
||||
# и что подтверждённо ДАЛЬНИЙ (10 км) не превышает неизвестный (чтобы сигналил,
|
||||
# но скромно — оба низкие; инверсия устранена).
|
||||
import math
|
||||
|
||||
unknown = _geo_weight(None)
|
||||
confirmed_near = _geo_weight(1.0) # ≈ 0.72
|
||||
confirmed_far = _geo_weight(10.0) # ≈ 0.036
|
||||
# confirmed_near >> unknown >> confirmed_far (правильная иерархия).
|
||||
assert confirmed_near > unknown
|
||||
assert unknown > confirmed_far
|
||||
# Числовая граница: exp(-6.9/3) ≈ 0.10 — вес "just-confirmed-far";
|
||||
# unknown (0.05) строго ниже этого порога.
|
||||
assert unknown < math.exp(-6.9 / 3.0)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# §25.3 own-portfolio overlap — пара (среднее доступных осей) + агрегация (soft-max)
|
||||
|
|
@ -745,16 +781,25 @@ class TestAggregateOverlap:
|
|||
def test_takes_max_signal_not_mean(self) -> None:
|
||||
# один сильный (0.9) + два слабых (0.1) → агрегат = 0.9 (soft-max), не среднее.
|
||||
strong = _own_portfolio_overlap(
|
||||
class_overlap=0.9, price_overlap=0.9,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=0.9,
|
||||
price_overlap=0.9,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
weak1 = _own_portfolio_overlap(
|
||||
class_overlap=0.1, price_overlap=0.1,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=0.1,
|
||||
price_overlap=0.1,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
weak2 = _own_portfolio_overlap(
|
||||
class_overlap=0.1, price_overlap=0.1,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=0.1,
|
||||
price_overlap=0.1,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
assert _aggregate_overlap([strong, weak1, weak2]) == pytest.approx(0.9)
|
||||
|
||||
|
|
@ -762,19 +807,28 @@ class TestAggregateOverlap:
|
|||
# сильное пересечение, но далеко (geo 0.1) → signal 0.05; близкое слабое
|
||||
# (overlap 0.3, geo 1.0 → 0.3) перебивает. Агрегат = 0.3.
|
||||
distant_strong = _own_portfolio_overlap(
|
||||
class_overlap=1.0, price_overlap=1.0,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=0.1,
|
||||
class_overlap=1.0,
|
||||
price_overlap=1.0,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=0.1,
|
||||
)
|
||||
near_weak = _own_portfolio_overlap(
|
||||
class_overlap=0.3, price_overlap=0.3,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=0.3,
|
||||
price_overlap=0.3,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
assert _aggregate_overlap([distant_strong, near_weak]) == pytest.approx(0.3)
|
||||
|
||||
def test_all_none_signals_is_none(self) -> None:
|
||||
empty = _own_portfolio_overlap(
|
||||
class_overlap=None, price_overlap=None,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=None,
|
||||
price_overlap=None,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
assert _aggregate_overlap([empty, empty]) is None
|
||||
|
||||
|
|
@ -955,9 +1009,7 @@ class TestArtificialDemandSQL:
|
|||
def test_sql_reads_mortgage_signal_columns(self, _identity_resolver: Any) -> None:
|
||||
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
|
||||
with _identity_resolver:
|
||||
_query_artificial_demand(
|
||||
db, district=None, obj_class=None, premise_kind="квартира"
|
||||
)
|
||||
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||
sql = _executed_sql(db)
|
||||
# сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
|
||||
assert "encumbrance_type" in sql
|
||||
|
|
@ -1007,9 +1059,7 @@ class TestArtificialDemandSQL:
|
|||
# #1205: district=None → has_district=False, districts=[]. EKB-wide.
|
||||
db = _mock_db_one({"n_sold": 200, "n_mortgage": 80})
|
||||
with _identity_resolver:
|
||||
_query_artificial_demand(
|
||||
db, district=None, obj_class=None, premise_kind="квартира"
|
||||
)
|
||||
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||
p = _executed_params(db)
|
||||
assert p["has_district"] is False
|
||||
assert p["districts"] == []
|
||||
|
|
@ -1527,7 +1577,9 @@ class TestComputeSpecialIndicesGraceful:
|
|||
# Центроид участка для гео-веса (lon, lat) в окрестностях ЕКБ.
|
||||
_CENTROID = (60.6000, 56.8000)
|
||||
_CAND_SPEC = SegmentSpec(
|
||||
obj_class="комфорт", room_bucket="2-к 45-60", district="Академический",
|
||||
obj_class="комфорт",
|
||||
room_bucket="2-к 45-60",
|
||||
district="Академический",
|
||||
price_bucket=PRICE_BUCKET_COMFORT,
|
||||
)
|
||||
|
||||
|
|
@ -1625,7 +1677,9 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
|||
# Launch Window резолвится (пик h=24) → candidate_release_month выведен →
|
||||
# тайминговая ось теперь СЧИТАЕТСЯ (на тот же месяц, что наш проект → 1.0).
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||
)
|
||||
card = _timing_card([own])
|
||||
|
|
@ -1639,12 +1693,24 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
|||
# near: release_month = выведенный месяц кандидата (timing 1.0); far: на 4 года
|
||||
# позже (timing → почти 0). Прочие оси идентичны → near должен дать выше value.
|
||||
near = _timing_card(
|
||||
[_own("Близкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
release_month=_DERIVED_CANDIDATE_MONTH)]
|
||||
[
|
||||
_own(
|
||||
"Близкий-во-времени",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||
)
|
||||
]
|
||||
)
|
||||
far = _timing_card(
|
||||
[_own("Далёкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48))]
|
||||
[
|
||||
_own(
|
||||
"Далёкий-во-времени",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48),
|
||||
)
|
||||
]
|
||||
)
|
||||
near_v = near.indices[KEY_CANNIBALIZATION].value
|
||||
far_v = far.indices[KEY_CANNIBALIZATION].value
|
||||
|
|
@ -1657,7 +1723,9 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
|||
# классу/цене/гео (не падает, не фабрикует тайминг).
|
||||
forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
release_month=date(2027, 1, 1), # есть дата, но кандидатной нет
|
||||
)
|
||||
db = MagicMock()
|
||||
|
|
@ -1686,10 +1754,16 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
|||
def test_timing_deterministic_same_inputs_identical_as_dict(self) -> None:
|
||||
# Детерминизм (§16): одинаковые входы (фикс. дата отчёта) → идентичный as_dict.
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
release_month=_DERIVED_CANDIDATE_MONTH),
|
||||
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85,
|
||||
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6)),
|
||||
_own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], release_month=_DERIVED_CANDIDATE_MONTH
|
||||
),
|
||||
_own(
|
||||
"Наш-Б",
|
||||
obj_class="комфорт+",
|
||||
lon=60.65,
|
||||
lat=56.85,
|
||||
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6),
|
||||
),
|
||||
]
|
||||
first = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||
second = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||
|
|
@ -1703,8 +1777,14 @@ class TestCannibalizationTrueMode:
|
|||
# наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0.
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
||||
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
|
||||
lon=60.9, lat=57.1),
|
||||
_own(
|
||||
"Наш-Б",
|
||||
obj_class="бизнес",
|
||||
price_min=200_000.0,
|
||||
price_max=240_000.0,
|
||||
lon=60.9,
|
||||
lat=57.1,
|
||||
),
|
||||
]
|
||||
card = _cannibalization_card(portfolio)
|
||||
can = card.indices[KEY_CANNIBALIZATION]
|
||||
|
|
@ -1717,8 +1797,14 @@ class TestCannibalizationTrueMode:
|
|||
def test_top_contributor_surfaced(self) -> None:
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
||||
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
|
||||
lon=60.9, lat=57.1),
|
||||
_own(
|
||||
"Наш-Б",
|
||||
obj_class="бизнес",
|
||||
price_min=200_000.0,
|
||||
price_max=240_000.0,
|
||||
lon=60.9,
|
||||
lat=57.1,
|
||||
),
|
||||
]
|
||||
card = _cannibalization_card(portfolio)
|
||||
can = card.indices[KEY_CANNIBALIZATION]
|
||||
|
|
@ -1742,20 +1828,24 @@ class TestCannibalizationTrueMode:
|
|||
assert near_v > far_v
|
||||
|
||||
def test_no_geometry_still_computes_via_other_axes(self) -> None:
|
||||
# нет центроида → гео-вес на floor (0.1), но класс+цена дают overlap → индекс есть.
|
||||
# нет центроида → гео-вес на floor (_GEO_WEIGHT_UNKNOWN=0.05), класс+цена дают
|
||||
# overlap → индекс есть (None-not-0). #1633: floor снижен 0.1→0.05.
|
||||
card = _cannibalization_card([_own("Без-гео")], centroid=None)
|
||||
can = card.indices[KEY_CANNIBALIZATION]
|
||||
assert can.method == "own_portfolio_overlap"
|
||||
assert can.detail["has_geometry"] is False
|
||||
# overlap 1.0 × geo floor 0.1 = 0.1.
|
||||
assert can.value == pytest.approx(0.1)
|
||||
# overlap 1.0 × geo floor 0.05 = 0.05.
|
||||
assert can.value == pytest.approx(0.05)
|
||||
|
||||
def test_unit_mix_and_timing_axes_when_supplied(self) -> None:
|
||||
# caller передаёт квартирографию/тайминг кандидата → оси становятся доступны.
|
||||
db = MagicMock()
|
||||
own_proj = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5}, release_month=date(2026, 6, 1),
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
release_month=date(2026, 6, 1),
|
||||
)
|
||||
with (
|
||||
patch(f"{_MOD}.get_own_portfolio", return_value=[own_proj]),
|
||||
|
|
@ -1764,7 +1854,9 @@ class TestCannibalizationTrueMode:
|
|||
from app.services.forecasting.special_indices import _build_cannibalization
|
||||
|
||||
idx = _build_cannibalization(
|
||||
db, spec=_CAND_SPEC, cad_num="66:41:0303161:123",
|
||||
db,
|
||||
spec=_CAND_SPEC,
|
||||
cad_num="66:41:0303161:123",
|
||||
candidate_unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
candidate_release_month=date(2026, 6, 1),
|
||||
)
|
||||
|
|
@ -1777,8 +1869,16 @@ class TestCannibalizationTrueMode:
|
|||
# наш проект без класса И без цены → ни класс, ни цена не считаются;
|
||||
# тайминг/квартирография тоже None → пара без осей → индекс unavailable (НЕ 0).
|
||||
card = _cannibalization_card(
|
||||
[_own("Пустой", obj_class=None, price_min=None, price_max=None,
|
||||
lon=_CENTROID[0], lat=_CENTROID[1])]
|
||||
[
|
||||
_own(
|
||||
"Пустой",
|
||||
obj_class=None,
|
||||
price_min=None,
|
||||
price_max=None,
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
)
|
||||
]
|
||||
)
|
||||
can = card.indices[KEY_CANNIBALIZATION]
|
||||
assert can.value is None
|
||||
|
|
@ -1809,9 +1909,9 @@ class TestCannibalizationThinPortfolioConfidence:
|
|||
assert "future" in can.detail["confidence_note"]
|
||||
|
||||
def test_single_project_is_low_data_scarce(self) -> None:
|
||||
can = _cannibalization_card(
|
||||
[_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]
|
||||
).indices[KEY_CANNIBALIZATION]
|
||||
can = _cannibalization_card([_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]).indices[
|
||||
KEY_CANNIBALIZATION
|
||||
]
|
||||
assert can.confidence == "low"
|
||||
assert "данных мало" in can.detail["confidence_note"]
|
||||
|
||||
|
|
@ -1857,8 +1957,14 @@ class TestCannibalizationDeterminism:
|
|||
def test_same_inputs_identical_output(self) -> None:
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
||||
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
|
||||
lon=60.9, lat=57.1),
|
||||
_own(
|
||||
"Наш-Б",
|
||||
obj_class="бизнес",
|
||||
price_min=200_000.0,
|
||||
price_max=240_000.0,
|
||||
lon=60.9,
|
||||
lat=57.1,
|
||||
),
|
||||
_own("Наш-В", obj_class="комфорт+", lon=60.65, lat=56.85),
|
||||
]
|
||||
first = _cannibalization_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||
|
|
@ -1932,7 +2038,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
# recommend_mix отдаёт микс кандидата (RU-подписи), наш проект — manual латиница с
|
||||
# ТЕМ ЖЕ распределением по комнатности → ось квартирографии = 1.0 (через канон).
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
|
||||
)
|
||||
card = _unitmix_card(
|
||||
|
|
@ -1951,21 +2059,30 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
# Все прочие оси (класс/цена/гео/тайминг) идентичны → разница только в квартирографии.
|
||||
rec = _recommend_mix_result({"Студии 15-30": 50.0, "1-к 30-45": 50.0})
|
||||
similar = _unitmix_card(
|
||||
[_own("Похожий", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5})],
|
||||
[
|
||||
_own(
|
||||
"Похожий",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
)
|
||||
],
|
||||
recommend_return=rec,
|
||||
)
|
||||
dissimilar = _unitmix_card(
|
||||
[_own("Непохожий", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"2k": 0.5, "3k": 0.5})],
|
||||
[
|
||||
_own(
|
||||
"Непохожий", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"2k": 0.5, "3k": 0.5}
|
||||
)
|
||||
],
|
||||
recommend_return=rec,
|
||||
)
|
||||
sim_axis = similar.indices[KEY_CANNIBALIZATION].detail[
|
||||
"top_contributors"
|
||||
][0]["axes"]["unit_mix"]
|
||||
dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail[
|
||||
"top_contributors"
|
||||
][0]["axes"]["unit_mix"]
|
||||
sim_axis = similar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
|
||||
"unit_mix"
|
||||
]
|
||||
dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
|
||||
"unit_mix"
|
||||
]
|
||||
assert sim_axis > dis_axis
|
||||
# и итоговое значение каннибализации выше при похожем миксе.
|
||||
assert (
|
||||
|
|
@ -1977,7 +2094,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
# recommend_mix вернул None → микс None → ось квартирографии исключена (None-not-0),
|
||||
# каннибализация всё равно считается по классу/цене/тайм/гео.
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
)
|
||||
card = _unitmix_card([own], recommend_return=None)
|
||||
|
|
@ -1990,7 +2109,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
def test_axis_excluded_when_recommend_empty_buckets(self) -> None:
|
||||
# recommend_mix отдал пустые buckets (тонкие данные) → микс None → ось исключена.
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
)
|
||||
card = _unitmix_card([own], recommend_return={"buckets": []})
|
||||
|
|
@ -2001,7 +2122,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
def test_axis_excluded_when_recommend_raises_no_crash(self) -> None:
|
||||
# recommend_mix БРОСИЛ → graceful None → ось исключена, карточка цела (НЕ crash).
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
)
|
||||
card = _unitmix_card([own], recommend_side_effect=RuntimeError("heavy query boom"))
|
||||
|
|
@ -2015,7 +2138,10 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
# ЧЕСТНОСТЬ (own_portfolio PR1): current-проект несёт unit_mix=None → даже при
|
||||
# валидном recommend-миксе кандидата ось не считается (нечего сравнивать).
|
||||
own = _own(
|
||||
"Текущий", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Текущий",
|
||||
source="current",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix=None,
|
||||
)
|
||||
card = _unitmix_card(
|
||||
|
|
@ -2029,7 +2155,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
def test_all_four_axes_active_together(self) -> None:
|
||||
# class + price + timing + unit_mix ВСЕ доступны на одном проекте → n_axes == 4.
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
release_month=_DERIVED_CANDIDATE_MONTH, # совпадает с выведенным тайм. кандидата
|
||||
)
|
||||
|
|
@ -2045,28 +2173,34 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
def test_unit_mix_axis_deterministic_identical_as_dict(self) -> None:
|
||||
# Детерминизм (§16): одинаковые входы → идентичный as_dict (с активной осью).
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3}),
|
||||
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85,
|
||||
unit_mix={"1k": 0.6, "2k": 0.4}),
|
||||
_own(
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
|
||||
),
|
||||
_own(
|
||||
"Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85, unit_mix={"1k": 0.6, "2k": 0.4}
|
||||
),
|
||||
]
|
||||
rec = _recommend_mix_result(
|
||||
{"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0}
|
||||
rec = _recommend_mix_result({"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0})
|
||||
first = (
|
||||
_unitmix_card(list(portfolio), recommend_return=rec)
|
||||
.indices[KEY_CANNIBALIZATION]
|
||||
.as_dict()
|
||||
)
|
||||
second = (
|
||||
_unitmix_card(list(portfolio), recommend_return=rec)
|
||||
.indices[KEY_CANNIBALIZATION]
|
||||
.as_dict()
|
||||
)
|
||||
first = _unitmix_card(list(portfolio), recommend_return=rec).indices[
|
||||
KEY_CANNIBALIZATION
|
||||
].as_dict()
|
||||
second = _unitmix_card(list(portfolio), recommend_return=rec).indices[
|
||||
KEY_CANNIBALIZATION
|
||||
].as_dict()
|
||||
assert first == second
|
||||
# подтверждаем, что ось реально участвовала (не пустой детерминизм).
|
||||
assert first["detail"]["axes_available"]["unit_mix"] == 2
|
||||
|
||||
def test_recommend_mix_called_with_expected_args(self) -> None:
|
||||
# cost/корректность: recommend_mix зовётся с district+target_class+cad_num кандидата.
|
||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 1.0})
|
||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
|
||||
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
|
||||
db = MagicMock()
|
||||
with (
|
||||
|
|
@ -2091,8 +2225,7 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
|
||||
def test_no_district_skips_recommend_mix(self) -> None:
|
||||
# нет района → recommend_mix НЕ зовётся (район обязателен) → ось исключена, без вызова.
|
||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 1.0})
|
||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
|
||||
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
|
||||
db = MagicMock()
|
||||
with (
|
||||
|
|
@ -2155,10 +2288,22 @@ class TestCannibalizationUnitMixGate:
|
|||
# Портфель только из current/domrf (unit_mix=None) → ось квартирографии всё равно
|
||||
# исключилась бы → тяжёлый recommend_mix НЕ должен вызываться (call_count == 0).
|
||||
portfolio = [
|
||||
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH),
|
||||
_own("Текущий-2", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH),
|
||||
_own(
|
||||
"Текущий-1",
|
||||
source="current",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix=None,
|
||||
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||
),
|
||||
_own(
|
||||
"Текущий-2",
|
||||
source="current",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix=None,
|
||||
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||
),
|
||||
]
|
||||
card, rec_mock, _ = _gated_card(portfolio)
|
||||
# Гейт сработал: тяжёлый запрос НЕ выполнен.
|
||||
|
|
@ -2179,8 +2324,13 @@ class TestCannibalizationUnitMixGate:
|
|||
# Есть future-проект с unit_mix → ось может внести вклад → recommend_mix зовётся
|
||||
# РОВНО один раз (не O(n), не дважды), и get_own_portfolio фетчится РОВНО один раз.
|
||||
portfolio = [
|
||||
_own("Будущий-А", source="future", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 1.0}),
|
||||
_own(
|
||||
"Будущий-А",
|
||||
source="future",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 1.0},
|
||||
),
|
||||
]
|
||||
card, rec_mock, portfolio_mock = _gated_card(portfolio)
|
||||
# Тяжёлый запрос — ровно один доп. вызов на отчёт.
|
||||
|
|
@ -2197,8 +2347,7 @@ class TestCannibalizationUnitMixGate:
|
|||
# Даже когда гейт пропускает recommend_mix, get_own_portfolio всё равно фетчится
|
||||
# РОВНО один раз (портфель нужен и для гейта, и для самой каннибализации).
|
||||
portfolio = [
|
||||
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix=None),
|
||||
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix=None),
|
||||
]
|
||||
_, rec_mock, portfolio_mock = _gated_card(portfolio)
|
||||
assert rec_mock.call_count == 0
|
||||
|
|
@ -2207,8 +2356,9 @@ class TestCannibalizationUnitMixGate:
|
|||
def test_empty_mix_dict_does_not_trigger_recommend_mix(self) -> None:
|
||||
# Пустой dict unit_mix={} (грязь) — НЕ «есть микс» → recommend_mix не зовётся.
|
||||
portfolio = [
|
||||
_own("Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={}),
|
||||
_own(
|
||||
"Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={}
|
||||
),
|
||||
]
|
||||
_, rec_mock, _ = _gated_card(portfolio)
|
||||
assert rec_mock.call_count == 0
|
||||
|
|
|
|||
|
|
@ -210,3 +210,125 @@ def test_safe_payload_defaults_are_empty_and_non_confidential() -> None:
|
|||
assert p.text == ""
|
||||
assert p.fields == {}
|
||||
assert p.is_confidential is False
|
||||
|
||||
|
||||
# ── #1640: INN context-guard — large bare numbers must NOT be redacted ────────
|
||||
|
||||
|
||||
def test_large_sum_10_digits_not_redacted_without_inn_keyword() -> None:
|
||||
"""Слитная 10-значная сумма (1 200 000 000 руб) без якоря «ИНН» — НЕ редактируется.
|
||||
|
||||
Регрессия #1640: старый _INN_RE ловил любые 10/12 цифр → «1200000000» уходило в
|
||||
[REDACTED:inn]. Новый паттерн требует маркер ИНН/inn перед числом.
|
||||
"""
|
||||
text = "стоимость участка составила 1200000000 рублей по договору"
|
||||
out = scrub_text(text)
|
||||
assert out == text, f"bare large sum was incorrectly redacted: {out!r}"
|
||||
|
||||
|
||||
def test_large_sum_12_digits_not_redacted_without_inn_keyword() -> None:
|
||||
"""12-значная слитная сумма без якоря — НЕ редактируется."""
|
||||
text = "капитализация компании 123456789012 рублей"
|
||||
out = scrub_text(text)
|
||||
assert out == text
|
||||
|
||||
|
||||
def test_round_billion_not_redacted() -> None:
|
||||
"""Круглые суммы без контекста — НЕ редактируются."""
|
||||
for amount in ("5000000000", "1000000000", "9876543210"):
|
||||
out = scrub_text(f"сумма {amount} руб")
|
||||
assert "[REDACTED:inn]" not in out, f"amount {amount!r} was falsely redacted"
|
||||
|
||||
|
||||
def test_inn_with_keyword_is_redacted() -> None:
|
||||
"""Валидный ИНН с явным маркером «ИНН» — редактируется."""
|
||||
out = scrub_text("ИНН 7707083893 зарегистрирован в ЕГРЮЛ")
|
||||
assert "[REDACTED:inn]" in out
|
||||
assert "7707083893" not in out
|
||||
|
||||
|
||||
def test_inn_12_with_keyword_is_redacted() -> None:
|
||||
"""Валидный 12-значный ИНН физлица с маркером — редактируется."""
|
||||
out = scrub_text("ИНН физлица 500100732259 подтверждён")
|
||||
assert "[REDACTED:inn]" in out
|
||||
assert "500100732259" not in out
|
||||
|
||||
|
||||
def test_inn_keyword_latin_case_insensitive() -> None:
|
||||
"""Маркер 'inn' (латиница, любой регистр) тоже активирует редакцию."""
|
||||
out = scrub_text("inn: 7707083893")
|
||||
assert "[REDACTED:inn]" in out
|
||||
|
||||
|
||||
def test_inn_bad_checksum_with_keyword_not_redacted() -> None:
|
||||
"""Число с маркером «ИНН», но не прошедшее checksum, НЕ редактируется.
|
||||
|
||||
Предотвращает ложные срабатывания на «ИНН 1234567890» (неверная контрольная цифра).
|
||||
"""
|
||||
out = scrub_text("ИНН 1234567890 (некорректный)")
|
||||
assert "[REDACTED:inn]" not in out
|
||||
assert "1234567890" in out
|
||||
|
||||
|
||||
# ── #1640 follow-up: word-boundary guards ((?<!\w)/(?!\w)) ───────────────────
|
||||
|
||||
|
||||
def test_inn_digits_alpha_prefix_not_redacted() -> None:
|
||||
"""Цифры ИНН как суффикс буквенно-цифрового токена НЕ редактируются.
|
||||
|
||||
Регрессия: (?<!\\d) не защищал от «ИНН ref7707083893» — 'f' не цифра, старый
|
||||
lookaround пропускал. Новый (?<!\\w) блокирует любой word-char перед цифровым блоком.
|
||||
"""
|
||||
out = scrub_text("ИНН ref7707083893")
|
||||
assert "[REDACTED:inn]" not in out
|
||||
assert "7707083893" in out
|
||||
|
||||
|
||||
def test_inn_digits_alpha_suffix_not_redacted() -> None:
|
||||
"""Цифры ИНН как префикс буквенно-цифрового токена НЕ редактируются.
|
||||
|
||||
Регрессия: старый (?!\\d) не блокировал буквенный суффикс — «7707083893more»
|
||||
совпадало, т.к. 'm' не цифра. Новый (?!\\w) закрывает alpha-суффикс.
|
||||
"""
|
||||
out = scrub_text("ИНН 7707083893more")
|
||||
assert "[REDACTED:inn]" not in out
|
||||
assert "7707083893" in out
|
||||
|
||||
|
||||
def test_inn_digits_embedded_in_alphanumeric_token_not_redacted() -> None:
|
||||
"""ИНН-подобный блок цифр внутри алфавитно-цифрового токена НЕ редактируется.
|
||||
|
||||
«ИНН ref7707083893xyz» — цифры с обеих сторон окружены word-char'ами.
|
||||
"""
|
||||
out = scrub_text("ИНН ref7707083893xyz")
|
||||
assert "[REDACTED:inn]" not in out
|
||||
|
||||
|
||||
def test_inn_alpha_prefix_on_12digit_block_not_redacted() -> None:
|
||||
"""12-значный валидный ИНН с буквенным префиксом (без пробела) НЕ редактируется.
|
||||
|
||||
«ИНН ABC500100732259» — 'C' является word-char, (?<!\\w) блокирует совпадение.
|
||||
"""
|
||||
out = scrub_text("ИНН ABC500100732259")
|
||||
assert "[REDACTED:inn]" not in out
|
||||
assert "500100732259" in out
|
||||
|
||||
|
||||
def test_inn_standalone_with_punctuation_is_redacted() -> None:
|
||||
"""Валидный ИНН, отделённый знаком препинания (не word-char), редактируется.
|
||||
|
||||
Двоеточие, запятая, скобка — не \\w → (?<!\\w) пропускает. Это корректное поведение.
|
||||
"""
|
||||
out = scrub_text("ИНН:7707083893 в реестре")
|
||||
assert "[REDACTED:inn]" in out
|
||||
assert "7707083893" not in out
|
||||
|
||||
|
||||
def test_inn_standalone_at_end_of_string_is_redacted() -> None:
|
||||
"""Валидный ИНН в конце строки без символов после редактируется.
|
||||
|
||||
Конец строки — не \\w → (?!\\w) пропускает. Базовый smoke.
|
||||
"""
|
||||
out = scrub_text("ИНН 7707083893")
|
||||
assert "[REDACTED:inn]" in out
|
||||
assert "7707083893" not in out
|
||||
|
|
|
|||
261
backend/tests/services/scrapers/test_domrf_catalog_parse.py
Normal file
261
backend/tests/services/scrapers/test_domrf_catalog_parse.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""Тесты для parse_catalog_flat() из domrf_catalog.py (issue #1609).
|
||||
|
||||
Покрывает:
|
||||
- _classify_status_kw — классификация ключевых слов
|
||||
- Статус: sold (все морфоварианты: продан/продана/продано/реализована)
|
||||
- Статус: reserved (забронирована/забронировано/бронь)
|
||||
- Статус: free (в продаже/свободна)
|
||||
- Уровень 1: badge-класс CSS → sold берётся из badge, а не из nav "в продаже"
|
||||
- Уровень 2: proximity "Статус" → следующий блок
|
||||
- Уровень 3: sold/reserved побеждают free, даже если free встретился раньше в blocks
|
||||
- Нет статуса → result не содержит ключ "status"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.scrapers.domrf_catalog import (
|
||||
STATUS_FREE,
|
||||
STATUS_RESERVED,
|
||||
STATUS_SOLD,
|
||||
_classify_status_kw,
|
||||
parse_catalog_flat,
|
||||
)
|
||||
|
||||
# ── _classify_status_kw ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
# sold — все морфоварианты
|
||||
("продан", STATUS_SOLD),
|
||||
("продана", STATUS_SOLD),
|
||||
("продано", STATUS_SOLD),
|
||||
("Квартира продана", STATUS_SOLD),
|
||||
("ПРОДАНО", STATUS_SOLD),
|
||||
("реализована", STATUS_SOLD),
|
||||
("реализовано", STATUS_SOLD),
|
||||
("sold", STATUS_SOLD),
|
||||
# reserved
|
||||
("забронирована", STATUS_RESERVED),
|
||||
("забронировано", STATUS_RESERVED),
|
||||
("бронь", STATUS_RESERVED),
|
||||
("reserved", STATUS_RESERVED),
|
||||
# free
|
||||
("в продаже", STATUS_FREE),
|
||||
("свободна", STATUS_FREE),
|
||||
("свободно", STATUS_FREE),
|
||||
("free", STATUS_FREE),
|
||||
# нераспознанные
|
||||
("какой-то текст", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_classify_status_kw(text: str, expected: str | None) -> None:
|
||||
assert _classify_status_kw(text) == expected
|
||||
|
||||
|
||||
# ── Вспомогательные конструкторы HTML-фрагментов ──────────────────────────────
|
||||
|
||||
|
||||
def _html_with_badge(badge_text: str, nav_text: str = "другие квартиры в продаже") -> str:
|
||||
"""HTML с типичной структурой DOM.РФ: nav-блок + статус-бейдж."""
|
||||
return (
|
||||
"<!doctype html><html><body>"
|
||||
# Навигационный блок — появляется раньше статуса в документе
|
||||
f'<nav><a href="/catalog">Все квартиры</a><span>{nav_text}</span></nav>'
|
||||
# Статус-бейдж с CSS-классом "status-badge"
|
||||
f'<div class="status-badge">{badge_text}</div>'
|
||||
"<p>Площадь 45 м²</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _html_with_label(label: str, value: str, nav_text: str = "") -> str:
|
||||
"""HTML с лейблом «Статус» и значением в следующем блоке."""
|
||||
nav = f"<span>{nav_text}</span>" if nav_text else ""
|
||||
return (
|
||||
"<!doctype html><html><body>"
|
||||
f"{nav}"
|
||||
f"<span>{label}</span>"
|
||||
f"<span>{value}</span>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _html_blocks_only(blocks: list[tuple[str, str]]) -> str:
|
||||
"""Генерирует HTML из списка (css_class, text) без badge-классов."""
|
||||
parts = ["<!doctype html><html><body>"]
|
||||
for cls, text in blocks:
|
||||
parts.append(f'<div class="{cls}">{text}</div>')
|
||||
parts.append("</body></html>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ── Уровень 1: badge CSS-класс ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_status_badge_sold_wins_over_nav_free() -> None:
|
||||
"""Проданная квартира: badge=продана, nav=в продаже → status=sold (#1609 core bug)."""
|
||||
html = _html_with_badge("Продана", nav_text="другие квартиры в продаже")
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_SOLD
|
||||
|
||||
|
||||
def test_status_badge_prodano() -> None:
|
||||
"""Морфовариант 'продано' в badge → sold."""
|
||||
html = _html_with_badge("Продано")
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_SOLD
|
||||
|
||||
|
||||
def test_status_badge_reserved() -> None:
|
||||
"""Забронирована в badge → reserved."""
|
||||
html = _html_with_badge("Забронирована")
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_RESERVED
|
||||
|
||||
|
||||
def test_status_badge_free() -> None:
|
||||
"""В продаже в badge (нет nav-коллизии) → free."""
|
||||
html = _html_with_badge("В продаже", nav_text="")
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_FREE
|
||||
|
||||
|
||||
def test_status_badge_realizovana() -> None:
|
||||
"""'Реализована' (синоним sold) в badge → sold."""
|
||||
html = _html_with_badge("Реализована")
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_SOLD
|
||||
|
||||
|
||||
# ── Уровень 2: proximity "Статус" ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_status_label_proximity_sold() -> None:
|
||||
"""Блок 'Статус' → следующий блок 'Продана' → sold."""
|
||||
html = _html_with_label("Статус", "Продана")
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_SOLD
|
||||
|
||||
|
||||
def test_status_label_proximity_free() -> None:
|
||||
"""Блок 'Статус' → следующий блок 'В продаже' → free."""
|
||||
html = _html_with_label("Статус", "В продаже")
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_FREE
|
||||
|
||||
|
||||
# ── Уровень 3: blocks scan, sold/reserved > free ─────────────────────────────
|
||||
|
||||
|
||||
def test_status_level3_sold_beats_earlier_free() -> None:
|
||||
"""Без badge-класса и без label: free-блок раньше, sold-блок позже → sold побеждает."""
|
||||
# Нет badge-класса: используем нейтральные классы
|
||||
html = _html_blocks_only(
|
||||
[
|
||||
("nav-link", "другие квартиры в продаже"), # free — но это нав
|
||||
("info-card", "Квартира продана"), # sold — специфичнее
|
||||
]
|
||||
)
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_SOLD
|
||||
|
||||
|
||||
def test_status_level3_reserved_beats_earlier_free() -> None:
|
||||
"""Без badge: free раньше, reserved позже → reserved побеждает."""
|
||||
html = _html_blocks_only(
|
||||
[
|
||||
("nav", "в продаже"),
|
||||
("info", "Забронировано"),
|
||||
]
|
||||
)
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_RESERVED
|
||||
|
||||
|
||||
def test_status_level3_free_only() -> None:
|
||||
"""Без badge: только free-блок (без sold/reserved) → free."""
|
||||
html = _html_blocks_only(
|
||||
[
|
||||
("info", "В продаже"),
|
||||
]
|
||||
)
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_FREE
|
||||
|
||||
|
||||
def test_status_no_keywords() -> None:
|
||||
"""HTML без статусных слов → ключ 'status' отсутствует в результате."""
|
||||
html = "<!doctype html><html><body><p>Площадь 45 м²</p></body></html>"
|
||||
result = parse_catalog_flat(html)
|
||||
assert "status" not in result
|
||||
|
||||
|
||||
# ── Регрессия: продана (ж.р.) не сбивает free-баг ────────────────────────────
|
||||
|
||||
|
||||
def test_prodana_feminine_classified_as_sold() -> None:
|
||||
"""'Квартира продана' (ж.р., feminine) → sold, а не free (issue #1609)."""
|
||||
# Симулируем реальную страницу: nav содержит "в продаже" раньше
|
||||
html = (
|
||||
"<!doctype html><html><body>"
|
||||
"<header><a>Все квартиры в продаже</a></header>"
|
||||
'<div class="flat-status-tag">Квартира продана</div>'
|
||||
"</body></html>"
|
||||
)
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_SOLD
|
||||
|
||||
|
||||
def test_generic_label_tag_not_treated_as_badge() -> None:
|
||||
"""Элемент с классом 'label' или 'tag' (без слова 'status') НЕ должен
|
||||
активировать Уровень 1 (#1686 follow-up: regex был слишком широк).
|
||||
|
||||
Страница: generic <span class="label"> с текстом 'В продаже' в шапке,
|
||||
реальный статус-бейдж с классом 'status-badge' содержит 'Продана'.
|
||||
Уровень 1 должен выбрать только блок status-badge, а не label-шапку.
|
||||
"""
|
||||
html = (
|
||||
"<!doctype html><html><body>"
|
||||
# generic label в шапке — НЕ должен активировать Level-1 badge match
|
||||
'<span class="label">В продаже</span>'
|
||||
'<span class="tag">Новостройка</span>'
|
||||
'<span class="chip">ЕКБ</span>'
|
||||
'<span class="badge">Акция</span>'
|
||||
# настоящий статус-бейдж — должен быть подхвачен Level-1
|
||||
'<div class="status-badge">Продана</div>'
|
||||
"</body></html>"
|
||||
)
|
||||
result = parse_catalog_flat(html)
|
||||
# Level-1 должен взять status-badge (Продана → sold),
|
||||
# generic label/tag/chip/badge не должны перебить правильный результат
|
||||
assert result.get("status") == STATUS_SOLD
|
||||
|
||||
|
||||
def test_full_page_sold_flat_no_false_free() -> None:
|
||||
"""Реалистичный HTML проданной квартиры: навигация 'в продаже', бейдж 'продано'."""
|
||||
html = (
|
||||
"<!doctype html><html><head><title>Квартира</title></head><body>"
|
||||
"<nav>"
|
||||
' <a href="/catalog">Каталог квартир в продаже</a>'
|
||||
' <span class="breadcrumb">Квартиры в продаже</span>'
|
||||
"</nav>"
|
||||
'<main class="flat-page">'
|
||||
' <h1 class="flat-title">Квартира 2-комнатная</h1>'
|
||||
' <div class="price-block">4 500 000 ₽</div>'
|
||||
' <div class="status-label">Продано</div>'
|
||||
' <div class="flat-info">Площадь 55 м² Подъезд 2</div>'
|
||||
"</main>"
|
||||
'<section class="similar">'
|
||||
" <h2>Похожие квартиры в продаже</h2>"
|
||||
"</section>"
|
||||
"</body></html>"
|
||||
)
|
||||
result = parse_catalog_flat(html)
|
||||
assert result.get("status") == STATUS_SOLD
|
||||
# Цена тоже должна распознаться
|
||||
assert result.get("price_rub") == 4_500_000
|
||||
|
|
@ -275,6 +275,7 @@ class TestAsDict:
|
|||
demand_concentration=0.3333333,
|
||||
price_sensitivity=-1.234567,
|
||||
price_sensitivity_source="regression",
|
||||
velocity_by_room={"1": 1.5, "2": 0.5},
|
||||
)
|
||||
d = m.as_dict()
|
||||
assert d["absorption_rate"] == 0.0476
|
||||
|
|
@ -304,6 +305,7 @@ class TestAsDict:
|
|||
demand_concentration=None,
|
||||
price_sensitivity=None,
|
||||
price_sensitivity_source=None,
|
||||
velocity_by_room=None,
|
||||
)
|
||||
d = m.as_dict()
|
||||
assert d["absorption_rate"] is None
|
||||
|
|
@ -421,6 +423,14 @@ class TestComputeMarketMetricsShape:
|
|||
assert m.price_sensitivity == -1.4
|
||||
assert m.price_sensitivity_source == "regression"
|
||||
assert m.confidence == "high"
|
||||
# #1593: velocity_by_room — per-bucket ед./мес из sold_by_room.
|
||||
# _FULL_SALES: rooms_int=1 (30 ед.), rooms_int=2 (20 ед.), rooms_int=0 (10 ед.)
|
||||
# window_months=6 → "1": 30/6=5.0, "2": 20/6≈3.333, "студия": 10/6≈1.667
|
||||
assert m.velocity_by_room is not None
|
||||
assert set(m.velocity_by_room.keys()) == {"1", "2", "студия"}
|
||||
assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
|
||||
assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
|
||||
assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)
|
||||
|
||||
def test_elasticity_reuse_invoked_with_district(self) -> None:
|
||||
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
||||
|
|
@ -516,9 +526,7 @@ class TestPriceSensitivityDistrictResolution:
|
|||
случайно совпадал в обоих вокабулярах).
|
||||
"""
|
||||
|
||||
def test_admin_resolves_to_micros_in_elasticity_call(
|
||||
self, _patch_resolver: MagicMock
|
||||
) -> None:
|
||||
def test_admin_resolves_to_micros_in_elasticity_call(self, _patch_resolver: MagicMock) -> None:
|
||||
# Резолвер: 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
|
||||
_patch_resolver.side_effect = lambda _db, d: (
|
||||
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||||
|
|
@ -542,9 +550,7 @@ class TestPriceSensitivityDistrictResolution:
|
|||
assert value == -1.2
|
||||
assert source == "regression"
|
||||
|
||||
def test_resolver_none_passes_empty_list_ekb_wide(
|
||||
self, _patch_resolver: MagicMock
|
||||
) -> None:
|
||||
def test_resolver_none_passes_empty_list_ekb_wide(self, _patch_resolver: MagicMock) -> None:
|
||||
# Резолвер None ('не определён' / нет чистых алиасов) → передаём [] →
|
||||
# _elasticity_coef уходит в EKB-wide ветку (без district-фильтра).
|
||||
_patch_resolver.side_effect = lambda _db, _d: None
|
||||
|
|
@ -555,9 +561,7 @@ class TestPriceSensitivityDistrictResolution:
|
|||
_args, kwargs = elast.call_args
|
||||
assert kwargs["districts"] == []
|
||||
|
||||
def test_none_district_skips_resolver_and_elasticity(
|
||||
self, _patch_resolver: MagicMock
|
||||
) -> None:
|
||||
def test_none_district_skips_resolver_and_elasticity(self, _patch_resolver: MagicMock) -> None:
|
||||
# district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни
|
||||
# резолвер, ни _elasticity_coef.
|
||||
db = MagicMock()
|
||||
|
|
@ -600,6 +604,45 @@ class TestPriceSensitivityDistrictResolution:
|
|||
assert source is None
|
||||
|
||||
|
||||
class TestVelocityByRoom:
|
||||
"""#1593: MarketMetrics.velocity_by_room — per-bucket ед./мес."""
|
||||
|
||||
def test_velocity_by_room_computed_from_sold_by_room(self) -> None:
|
||||
# sold_by_room: "1" → 30, "2" → 20, "студия" → 10 ед. за 6 мес.
|
||||
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||
m = compute_market_metrics(db, district="Автовокзал", window_months=6)
|
||||
assert m.velocity_by_room is not None
|
||||
assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
|
||||
assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
|
||||
assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)
|
||||
|
||||
def test_velocity_by_room_none_when_no_sample(self) -> None:
|
||||
# n_lots=0 → выборка пуста → velocity_by_room None (зеркало unit_velocity).
|
||||
empty_stock = {
|
||||
"n_lots": 0,
|
||||
"n_sold": 0,
|
||||
"n_available": 0,
|
||||
"obj_count": 0,
|
||||
"n_long_unsold": 0,
|
||||
}
|
||||
db = _mock_db(empty_stock, [])
|
||||
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
|
||||
m = compute_market_metrics(db, district="ПустойРайон")
|
||||
assert m.velocity_by_room is None
|
||||
|
||||
def test_velocity_by_room_none_when_no_by_room_data(self) -> None:
|
||||
# Только grand-total строка (нет разбивки по комнатности) → sold_by_room пустой
|
||||
# → velocity_by_room None.
|
||||
sales_total_only = [
|
||||
{"units_sold_window": 5, "area_sold_window": 200.0, "rooms_int": None, "is_total": 1}
|
||||
]
|
||||
db = _mock_db(_FULL_STOCK, sales_total_only)
|
||||
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
|
||||
m = compute_market_metrics(db, district="Тихий")
|
||||
assert m.velocity_by_room is None
|
||||
|
||||
|
||||
class TestComputeMarketMetricsThinData:
|
||||
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
|
||||
|
||||
|
|
@ -623,6 +666,7 @@ class TestComputeMarketMetricsThinData:
|
|||
assert m.liquidity_index is None
|
||||
assert m.overstock_index is None
|
||||
assert m.demand_concentration is None
|
||||
assert m.velocity_by_room is None
|
||||
assert m.confidence == "low"
|
||||
|
||||
def test_stock_none_row_no_crash(self) -> None:
|
||||
|
|
|
|||
146
backend/tests/services/site_finder/test_riasurt_lookup.py
Normal file
146
backend/tests/services/site_finder/test_riasurt_lookup.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Тесты riasurt_lookup (#108) — is_in_aglomeration_but_not_ekb + parcel_riasurt_gate.
|
||||
|
||||
БД мокается фейк-сессией, возвращающей заранее заданные mapping-строки. Проверяем
|
||||
агломерация-гейтинг (ЕКБ-сити исключён), topic-бакеты, graceful при ошибке БД.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
|
||||
from app.services.site_finder import riasurt_lookup
|
||||
from app.services.site_finder.riasurt_lookup import (
|
||||
is_in_aglomeration_but_not_ekb,
|
||||
parcel_riasurt_gate,
|
||||
)
|
||||
|
||||
_WKT = "POLYGON((60 56,60.1 56,60.1 56.1,60 56.1,60 56))"
|
||||
|
||||
|
||||
def test_is_in_aglomeration_excludes_ekb_city() -> None:
|
||||
"""ЕКБ-сити (66:41) → False."""
|
||||
assert is_in_aglomeration_but_not_ekb("66:41:0204016:10") is False
|
||||
assert is_in_aglomeration_but_not_ekb("66:41") is False
|
||||
|
||||
|
||||
def test_is_in_aglomeration_includes_outskirts() -> None:
|
||||
"""Окраины (66:35 Берёзовский, 66:62 В.Пышма, 66:25 Сысерть) → True."""
|
||||
assert is_in_aglomeration_but_not_ekb("66:35:0101001:5") is True
|
||||
assert is_in_aglomeration_but_not_ekb("66:62:1234567:1") is True
|
||||
assert is_in_aglomeration_but_not_ekb("66:25:0000001:99") is True
|
||||
|
||||
|
||||
def test_is_in_aglomeration_rejects_non_sverdl_and_empty() -> None:
|
||||
"""Не-Свердл (не 66:*) и пусто → False."""
|
||||
assert is_in_aglomeration_but_not_ekb("50:21:0000001:1") is False
|
||||
assert is_in_aglomeration_but_not_ekb(None) is False
|
||||
assert is_in_aglomeration_but_not_ekb("") is False
|
||||
|
||||
|
||||
class _Row(dict):
|
||||
"""mapping-строка: поддерживает r["key"]."""
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, rows: list[_Row]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def mappings(self) -> _Result:
|
||||
return self
|
||||
|
||||
def all(self) -> list[_Row]:
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, rows: list[_Row] | Exception) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
|
||||
if isinstance(self._rows, Exception):
|
||||
raise self._rows
|
||||
return _Result(self._rows)
|
||||
|
||||
|
||||
def test_gate_not_applicable_for_ekb_city() -> None:
|
||||
"""ЕКБ-сити → applicable=False, БД не дёргается."""
|
||||
db = _FakeDB([])
|
||||
gate = parcel_riasurt_gate(db, _WKT, "66:41:0204016:10") # type: ignore[arg-type]
|
||||
assert gate["applicable"] is False
|
||||
assert gate["overlaps"] == []
|
||||
|
||||
|
||||
def test_gate_buckets_topics() -> None:
|
||||
"""Пересечения раскладываются по topic-бакетам."""
|
||||
rows = [
|
||||
_Row(
|
||||
source_layer_id=845274,
|
||||
layer_topic="territorial_zone",
|
||||
mo_name="Берёзовский",
|
||||
obshnz="Ж-1",
|
||||
description="Зона застройки",
|
||||
),
|
||||
_Row(
|
||||
source_layer_id=846381,
|
||||
layer_topic="functional_zone",
|
||||
mo_name="Берёзовский",
|
||||
obshnz=None,
|
||||
description="Жилая функц.зона",
|
||||
),
|
||||
_Row(
|
||||
source_layer_id=844759,
|
||||
layer_topic="red_lines",
|
||||
mo_name="Берёзовский",
|
||||
obshnz=None,
|
||||
description=None,
|
||||
),
|
||||
_Row(
|
||||
source_layer_id=846365,
|
||||
layer_topic="szz",
|
||||
mo_name="Берёзовский",
|
||||
obshnz=None,
|
||||
description=None,
|
||||
),
|
||||
_Row(
|
||||
source_layer_id=845425,
|
||||
layer_topic="flood_zone",
|
||||
mo_name="Берёзовский",
|
||||
obshnz=None,
|
||||
description=None,
|
||||
),
|
||||
_Row(
|
||||
source_layer_id=844478,
|
||||
layer_topic="krt",
|
||||
mo_name="Берёзовский",
|
||||
obshnz=None,
|
||||
description=None,
|
||||
),
|
||||
]
|
||||
db = _FakeDB(rows)
|
||||
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
|
||||
|
||||
assert gate["applicable"] is True
|
||||
assert gate["tier"] == "Зона застройки"
|
||||
assert gate["func_zone"] == "Жилая функц.зона"
|
||||
assert gate["red_lines"] is True
|
||||
assert gate["szz"] is True
|
||||
assert gate["szo"] is False
|
||||
assert gate["flood_zone"] is True
|
||||
assert gate["krt"] is True
|
||||
assert len(gate["overlaps"]) == 6
|
||||
|
||||
|
||||
def test_gate_graceful_on_db_error() -> None:
|
||||
"""Ошибка БД (pre-migration) → пустой gate, не падаем."""
|
||||
db = _FakeDB(ProgrammingError("stmt", {}, Exception("no table")))
|
||||
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
|
||||
assert gate == riasurt_lookup.RIASURT_EMPTY_GATE
|
||||
|
||||
|
||||
def test_gate_empty_wkt_returns_empty() -> None:
|
||||
"""Нет WKT → пустой gate."""
|
||||
db = _FakeDB([])
|
||||
gate = parcel_riasurt_gate(db, None, "66:35:0101001:5") # type: ignore[arg-type]
|
||||
assert gate["applicable"] is False
|
||||
|
|
@ -304,12 +304,15 @@ def test_denorm_dump_empty_features() -> None:
|
|||
db = _make_mock_session()
|
||||
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[])
|
||||
|
||||
assert counts == {"parcels": 0, "buildings": 0, "errors": 0}
|
||||
assert counts == {"parcels": 0, "buildings": 0, "errors": 0, "skipped": 0}
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_denorm_dump_no_cad_num_counted_as_error() -> None:
|
||||
"""Parcel без cad_num → denorm_parcel_feature returns False → errors += 1."""
|
||||
def test_denorm_dump_no_cad_num_counted_as_skipped() -> None:
|
||||
"""Parcel без cad_num → pre-check в denorm_dump → skipped += 1, не errors.
|
||||
|
||||
denorm_parcel_feature не вызывается вовсе — пропуск штатный, не сбой UPSERT.
|
||||
"""
|
||||
db = _make_mock_session()
|
||||
feature: dict[str, Any] = {
|
||||
"layer": "parcels",
|
||||
|
|
@ -319,4 +322,5 @@ def test_denorm_dump_no_cad_num_counted_as_error() -> None:
|
|||
}
|
||||
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature])
|
||||
assert counts["parcels"] == 0
|
||||
assert counts["errors"] == 1
|
||||
assert counts["errors"] == 0
|
||||
assert counts["skipped"] == 1
|
||||
|
|
|
|||
|
|
@ -77,7 +77,12 @@ def test_find_match_candidates_returns_candidates() -> None:
|
|||
|
||||
|
||||
def test_auto_apply_matches_dry_run_no_inserts() -> None:
|
||||
"""dry_run=True возвращает счётчики без обращения к БД (execute не вызывается)."""
|
||||
"""dry_run=True возвращает projected-счётчики без обращения к БД.
|
||||
|
||||
auto_accepted = сколько кандидатов БЫЛО БЫ принято (preview), а не 0 —
|
||||
смысл dry-run в admin-endpoint'е именно показать оператору объём перед
|
||||
реальным insert. execute/commit при этом не вызываются.
|
||||
"""
|
||||
mock_db = MagicMock()
|
||||
|
||||
candidates = [
|
||||
|
|
@ -88,8 +93,9 @@ def test_auto_apply_matches_dry_run_no_inserts() -> None:
|
|||
|
||||
result = auto_apply_matches(mock_db, candidates, dry_run=True)
|
||||
|
||||
assert result["auto_accepted"] == 0
|
||||
assert result["auto_accepted"] == 1 # projected: 1 кандидат >= AUTO_ACCEPT_THRESHOLD
|
||||
assert result["review_queue"] == 2
|
||||
assert result["skipped"] == 0
|
||||
mock_db.execute.assert_not_called()
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue