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 — см.
|
# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см.
|
||||||
# .github/workflows/ci.yml как образец service-блока.
|
# .github/workflows/ci.yml как образец service-блока.
|
||||||
on:
|
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:
|
pull_request:
|
||||||
branches: [main]
|
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:
|
concurrency:
|
||||||
|
# Теперь, когда остался только pull_request, github.ref стабилен на весь PR
|
||||||
|
# (refs/pull/<N>/merge) → новый push в ветку PR отменяет предыдущий незавершённый
|
||||||
|
# прогон ЭТОГО PR (cancel-in-progress) вместо накопления параллельных.
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
|
@ -89,6 +88,19 @@ jobs:
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
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
|
- name: Install system deps for geo + WeasyPrint
|
||||||
# libpq/gdal/proj/geos — geo-стек (geopandas/shapely/pyproj).
|
# libpq/gdal/proj/geos — geo-стек (geopandas/shapely/pyproj).
|
||||||
# libcairo2/libpango* — нативные либы WeasyPrint: с ними PDF-тесты
|
# libcairo2/libpango* — нативные либы WeasyPrint: с ними PDF-тесты
|
||||||
|
|
@ -230,6 +242,16 @@ jobs:
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
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
|
- name: Install system deps for geo + WeasyPrint
|
||||||
# app.main транзитивно тянет geo/PDF-модули. На macOS-dev импорт схемы
|
# app.main транзитивно тянет geo/PDF-модули. На macOS-dev импорт схемы
|
||||||
# проходит и без этих либ, но на ubuntu ставим как backend-tests
|
# проходит и без этих либ, но на ubuntu ставим как backend-tests
|
||||||
|
|
@ -239,9 +261,12 @@ jobs:
|
||||||
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
|
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
|
||||||
libcairo2 libpango-1.0-0 libpangoft2-1.0-0
|
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
|
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)
|
- name: Install frontend deps (npm ci)
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ jobs:
|
||||||
- '.forgejo/workflows/deploy-tradein.yml'
|
- '.forgejo/workflows/deploy-tradein.yml'
|
||||||
scraper:
|
scraper:
|
||||||
- 'tradein-mvp/backend/app/services/scrapers/**'
|
- '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/services/scheduler.py'
|
||||||
- 'tradein-mvp/backend/app/scheduler_main.py'
|
- 'tradein-mvp/backend/app/scheduler_main.py'
|
||||||
- 'tradein-mvp/backend/app/tasks/**'
|
- 'tradein-mvp/backend/app/tasks/**'
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -81,3 +81,6 @@ sf_anton_snapshot.db-*
|
||||||
*.csv
|
*.csv
|
||||||
*.csv.zip
|
*.csv.zip
|
||||||
!backend/db/init/*.sql
|
!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
|
## 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 рынок) | ✅ |
|
| Аналитический калькулятор `/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 логами |
|
| NSPD geo bulk-fetcher (rosreestr2coord lite, без Playwright) | ✅ admin UI с per-target логами |
|
||||||
| Rosreestr CSV-сделки 2024Q1—2026Q1 (9 квартирных партиций, 7M строк) | ✅ |
|
| Rosreestr CSV-сделки (квартирные партиции, ~7M строк, 27 мес истории) | ✅ |
|
||||||
| NSPD кадастровые кварталы + здания (709 cad-кварталов, 10 590 cad_buildings ЕКБ) | ✅ Celery beat + admin UI |
|
| NSPD кадастровые кварталы + здания ЕКБ | ✅ Celery beat + admin UI |
|
||||||
| OSM ekb_districts_geom (PostGIS-полигоны 8 районов ЕКБ) | ✅ |
|
| OSM ekb_districts_geom (PostGIS-полигоны районов ЕКБ) | ✅ |
|
||||||
| Objective ETL (Anton SQLite → PG, ports на psycopg v3) | ✅ admin UI |
|
| Objective ETL (Anton SQLite → PG, ports на psycopg v3) | ✅ admin UI |
|
||||||
| Obsidian Self-hosted LiveSync vault на `obsidian.gendsgn.ru` (268 entities из KG) | ✅ multi-device sync |
|
| Uptime Kuma мониторинг (`status.gendsgn.ru`, отдельный стек) | ✅ |
|
||||||
| Concept page (`/concept`) — Stage 1 Generative | 🟡 skeleton |
|
| Obsidian Self-hosted LiveSync vault на `obsidian.gendsgn.ru` | ✅ multi-device sync |
|
||||||
| Site Finder (`/site-finder`) — Stage 2 | 🔴 заглушка |
|
| 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 — см. ниже про разделение).
|
**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)
|
backend/ FastAPI app, services (analytics_queries, scrapers, generative, site_finder, forecasting), workers (Celery)
|
||||||
frontend/ Next.js app router (analytics, admin, concept, site-finder)
|
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
|
data/sql/ ETL артефакты — миграции схемы, загрузчики rosreestr/anton/nspd
|
||||||
ops/ backup.sh + deploy скрипты
|
ops/ backup.sh + deploy скрипты
|
||||||
scripts/ утилиты (migrate_kg_to_obsidian.py, setup-couchdb.sh, cleanup_ghosts.py)
|
scripts/ утилиты (migrate_kg_to_obsidian.py, setup-couchdb.sh, cleanup_ghosts.py)
|
||||||
|
|
@ -65,10 +83,11 @@ docs/ стратегические снапшоты (акту
|
||||||
memory/ legacy JSONL knowledge graph (deprecated, не обновляется — данные в Obsidian)
|
memory/ legacy JSONL knowledge graph (deprecated, не обновляется — данные в Obsidian)
|
||||||
docker-compose.prod.yml main стек (backend, frontend, postgres, redis, worker, beat, caddy)
|
docker-compose.prod.yml main стек (backend, frontend, postgres, redis, worker, beat, caddy)
|
||||||
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
|
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
|
||||||
|
docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную
|
||||||
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
|
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
|
||||||
├── ci.yml lint (ruff) + mypy + pytest на PR
|
├── ci.yml lint (ruff) + mypy + pytest на PR
|
||||||
├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy
|
├── 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-пайплайне
|
└── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне
|
||||||
.github/workflows/ (остаточные — только obsidian-стек на GitHub)
|
.github/workflows/ (остаточные — только obsidian-стек на GitHub)
|
||||||
└── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap)
|
└── 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 хостинг
|
## Production хостинг
|
||||||
|
|
||||||
**Beget Cloud, Москва, 2 vCPU / 4 GB / 40 GB NVMe.** Стоимость ~1 140 ₽/мес сервер + ~17 ₽/мес домен.
|
**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 |
|
Исходный 12-недельный roadmap (Stage 0 → 2c, апрель–июнь) пройден: скелет/CI/прод, NSPD-скрейпер + per-bucket эластичность, ПКК-данные и скоринг участков — всё ✅. Site Finder вышел из стадии «заглушка» в боевой v2-движок, параллельно запущен Trade-In. Активные направления:
|
||||||
|---|---|---|---|
|
|
||||||
| **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 платящих пилота |
|
|
||||||
|
|
||||||
Аналитический 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.
|
**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
|
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 alembic import context
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import Base
|
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
|
config = context.config
|
||||||
|
|
||||||
# Inject runtime DB URL.
|
# Inject runtime DB URL.
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ def leads_stats(
|
||||||
WITH window_leads AS (
|
WITH window_leads AS (
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM prinzip_leads
|
FROM prinzip_leads
|
||||||
WHERE created_at >= NOW() - (:m || ' months')::interval
|
WHERE created_at >= NOW() - make_interval(months => :m)
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT(*) FROM prinzip_leads) AS leads_total,
|
(SELECT COUNT(*) FROM prinzip_leads) AS leads_total,
|
||||||
|
|
@ -149,8 +149,20 @@ def leads_stats(
|
||||||
2
|
2
|
||||||
) AS conv_pct_window,
|
) AS conv_pct_window,
|
||||||
(SELECT COUNT(DISTINCT source) FROM prinzip_leads) AS sources_total,
|
(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
|
FROM window_leads
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -268,7 +268,7 @@ def revoke_task(
|
||||||
def list_failures(
|
def list_failures(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
run_id: int | None = None,
|
run_id: int | None = None,
|
||||||
limit: int = 50,
|
limit: int = Query(default=50, ge=1),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Per-request failure log for manual browser verification."""
|
"""Per-request failure log for manual browser verification."""
|
||||||
where = ""
|
where = ""
|
||||||
|
|
@ -314,7 +314,7 @@ def list_logs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
run_id: int | None = None,
|
run_id: int | None = None,
|
||||||
since_id: int | None = None,
|
since_id: int | None = None,
|
||||||
limit: int = 200,
|
limit: int = Query(default=200, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Per-run progress events. Use since_id to poll incrementally:
|
"""Per-run progress events. Use since_id to poll incrementally:
|
||||||
pass the highest log_id seen → returns only newer rows."""
|
pass the highest log_id seen → returns only newer rows."""
|
||||||
|
|
@ -483,7 +483,7 @@ def trigger_objective_etl(
|
||||||
@router.get("/objective/runs")
|
@router.get("/objective/runs")
|
||||||
def list_objective_runs(
|
def list_objective_runs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
limit: int = 20,
|
limit: int = Query(default=20, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -889,14 +889,20 @@ def bulk_enqueue_geo(
|
||||||
|
|
||||||
geo_queue = get_setting_value("nspd_geo", "queue_name", "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]] = []
|
jobs_summary: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for thematic_id in payload.thematic_ids:
|
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]
|
meta = _THEMATIC_META[thematic_id]
|
||||||
|
|
||||||
# 1) Собрать cad-номера
|
# 1) Собрать cad-номера
|
||||||
|
|
@ -967,7 +973,7 @@ def bulk_enqueue_geo(
|
||||||
@router.get("/geo/jobs")
|
@router.get("/geo/jobs")
|
||||||
def list_geo_jobs(
|
def list_geo_jobs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
limit: int = 30,
|
limit: int = Query(default=30, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Список последних geo-jobs (для UI dashboard)."""
|
"""Список последних geo-jobs (для UI dashboard)."""
|
||||||
rows = (
|
rows = (
|
||||||
|
|
@ -1087,7 +1093,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
|
||||||
def list_all_runs(
|
def list_all_runs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
scraper_type: str | None = None,
|
scraper_type: str | None = None,
|
||||||
limit: int = 30,
|
limit: int = Query(default=30, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Унифицированный список прогонов (kn + nspd + objective).
|
"""Унифицированный список прогонов (kn + nspd + objective).
|
||||||
|
|
||||||
|
|
@ -1145,7 +1151,7 @@ def list_all_logs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
scraper_type: str | None = None,
|
scraper_type: str | None = None,
|
||||||
run_id: int | None = None,
|
run_id: int | None = None,
|
||||||
limit: int = 200,
|
limit: int = Query(default=200, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
|
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
|
||||||
where: list[str] = []
|
where: list[str] = []
|
||||||
|
|
@ -1278,9 +1284,7 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _classify_freshness(
|
def _classify_freshness(age_days: float | None, fresh_days: float, stale_days: float) -> str:
|
||||||
age_days: float | None, fresh_days: float, stale_days: float
|
|
||||||
) -> str:
|
|
||||||
"""fresh / stale / critical / unknown по возрасту последнего успешного прогона."""
|
"""fresh / stale / critical / unknown по возрасту последнего успешного прогона."""
|
||||||
if age_days is None:
|
if age_days is None:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
@ -1416,7 +1420,7 @@ def scrape_freshness(
|
||||||
@router.get("/runs")
|
@router.get("/runs")
|
||||||
def list_runs(
|
def list_runs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
limit: int = 20,
|
limit: int = Query(default=20, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
|
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 import wkt as _shp_wkt
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -77,6 +78,7 @@ from app.services.site_finder.quarter_dump_lookup import (
|
||||||
get_quarter_dump_data,
|
get_quarter_dump_data,
|
||||||
make_empty_result,
|
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.saturation import compute_district_saturation
|
||||||
from app.services.site_finder.velocity import compute_velocity
|
from app.services.site_finder.velocity import compute_velocity
|
||||||
from app.services.site_finder.weight_profiles import (
|
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:
|
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["средне"]:
|
if s < SCORE_THRESHOLDS["средне"]:
|
||||||
return "плохо" if s < SCORE_THRESHOLDS["плохо"] else "средне"
|
return "средне"
|
||||||
return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично"
|
if s < SCORE_THRESHOLDS["хорошо"]:
|
||||||
|
return "хорошо"
|
||||||
|
return "отлично"
|
||||||
|
|
||||||
|
|
||||||
def _confidence_label(c: float) -> str:
|
def _confidence_label(c: float) -> str:
|
||||||
|
|
@ -821,11 +830,20 @@ def _compute_confidence(
|
||||||
market_trend: dict[str, Any] | None,
|
market_trend: dict[str, Any] | None,
|
||||||
zoning: dict[str, Any],
|
zoning: dict[str, Any],
|
||||||
) -> 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): использует сигналы которые уже
|
Это НАДЁЖНОСТЬ САМОГО SITE-FINDER СКОРИНГА (coverage/свежесть входных
|
||||||
доступны на main. Композитный балл = avg of subscore'ов; caveats — list
|
сигналов: POI, район, рынок, конкуренты, источник геометрии) — отдельная
|
||||||
конкретных проблем для UI ("Нет данных N, score K ненадёжен").
|
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] = []
|
caveats: list[str] = []
|
||||||
subscores: dict[str, float] = {}
|
subscores: dict[str, float] = {}
|
||||||
|
|
@ -2177,8 +2195,44 @@ def analyze_parcel(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("red_lines_block query failed for %s: %s", cad_num, e)
|
logger.warning("red_lines_block query failed for %s: %s", cad_num, e)
|
||||||
|
|
||||||
# B5-4) Metro placeholder — заполнится после merge 22h metro scraper
|
# B5-4) Metro — ближайшие 3 станции метро к участку.
|
||||||
metro_block: dict[str, Any] = {"nearest_top3": None}
|
# Данные уже в 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)
|
# B5-5) District price ranges из objective_lots (SF-B5)
|
||||||
district_price_block: dict[str, Any] = {
|
district_price_block: dict[str, Any] = {
|
||||||
|
|
@ -2921,7 +2975,75 @@ def analyze_parcel(
|
||||||
"risks": risks_block,
|
"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, провал НЕ меняет
|
# Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
|
||||||
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
|
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
|
||||||
# district денормализуем из result["district"]["district_name"] (для фильтрации
|
# district денормализуем из result["district"]["district_name"] (для фильтрации
|
||||||
|
|
@ -2952,20 +3074,22 @@ def analyze_parcel(
|
||||||
created_by=x_authenticated_user,
|
created_by=x_authenticated_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
# §22-форсайт (3b-ii, #995): best-effort fire-and-forget enqueue после persist.
|
# §22-форсайт enqueue — СТРОГО ПОСЛЕ persist_analysis_run. persist_analysis_run —
|
||||||
# Таска `forecast_site_finder_report` читает только что сохранённый analyze-1.0
|
# единственный commit analyze-рана (get_db() на success не коммитит), поэтому enqueue
|
||||||
# ран и в фоне (~30-180s) считает §22 SiteFinderReport ('1.0'). analyze НЕ ждёт
|
# должен случиться только после того, как ран закоммичен: иначе фоновая таска
|
||||||
# её — возвращаемся сразу. Celery/Redis down НЕ должен валить ответ (он уже успешен:
|
# `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.
|
# frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
|
||||||
# Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
|
# Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
|
||||||
try:
|
try:
|
||||||
from app.workers.tasks.forecast import forecast_site_finder_report
|
from app.workers.tasks.forecast import forecast_site_finder_report
|
||||||
|
|
||||||
forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
|
forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
|
||||||
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
|
# Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
|
||||||
# клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
|
# клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
|
||||||
|
# persisted снимок остаётся "pending" (безвреден — poll читает live-статус рана).
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
|
"forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
|
||||||
cad_num,
|
cad_num,
|
||||||
|
|
@ -2974,40 +3098,6 @@ def analyze_parcel(
|
||||||
)
|
)
|
||||||
result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
|
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
|
return result_payload
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3249,8 +3339,10 @@ async def get_parcel_competitors(
|
||||||
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
|
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
|
||||||
участка с рассчитанным velocity_per_month за указанный time_window.
|
участка с рассчитанным velocity_per_month за указанный time_window.
|
||||||
"""
|
"""
|
||||||
|
# sync get_competitors (несколько db.execute, competitors.py:518) мостится через
|
||||||
|
# run_in_threadpool — иначе sync DB-IO блокирует event loop (тот же приём, что в chat.py).
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except Exception as 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
|
Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
|
||||||
× objective_complex_mapping).
|
× objective_complex_mapping).
|
||||||
"""
|
"""
|
||||||
|
# sync get_best_layouts (db.execute, best_layouts.py:377) мостится через
|
||||||
|
# run_in_threadpool — иначе sync DB-IO блокирует event loop.
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except Exception as 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 для тендера.
|
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:
|
try:
|
||||||
response = get_best_layouts(db=db, cad_num=cad_num, request=body)
|
response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
|
||||||
pdf_bytes = render_layout_tz_pdf(
|
pdf_bytes = await run_in_threadpool(
|
||||||
|
render_layout_tz_pdf,
|
||||||
response,
|
response,
|
||||||
cad_num=cad_num,
|
cad_num=cad_num,
|
||||||
radius_km=body.radius_km,
|
radius_km=body.radius_km,
|
||||||
|
|
|
||||||
|
|
@ -111,16 +111,25 @@ def get_photo(
|
||||||
data = _fetch_upstream(upstream)
|
data = _fetch_upstream(upstream)
|
||||||
if data:
|
if data:
|
||||||
src = _persist_original(obj_id, file_id, photo_name, 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)
|
generated = make_thumbnail(src)
|
||||||
if generated and generated.exists():
|
if generated and generated.exists():
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE domrf_kn_photos"
|
"UPDATE domrf_kn_photos SET thumb_path = :tp"
|
||||||
" SET local_path = :lp, thumb_path = :tp,"
|
|
||||||
" downloaded_at = NOW()"
|
|
||||||
" WHERE obj_id = :o AND obj_file_id = :f"
|
" 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()
|
db.commit()
|
||||||
return FileResponse(str(generated), media_type="image/webp", headers=headers)
|
return FileResponse(str(generated), media_type="image/webp", headers=headers)
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,7 @@ def estimate_pdf(
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="estimate not found")
|
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)")
|
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
|
||||||
|
|
||||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,23 @@ class Settings(BaseSettings):
|
||||||
# возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
|
# возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
|
||||||
enable_ird_analyze: bool = True
|
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) ────────────────────────────────────────────
|
# ── LLM infrastructure (#960) ────────────────────────────────────────────
|
||||||
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
|
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
|
||||||
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный
|
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ if settings.glitchtip_dsn:
|
||||||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||||
profiles_sample_rate=0.0,
|
profiles_sample_rate=0.0,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
|
before_send=scrub_sensitive_query,
|
||||||
before_send_transaction=scrub_sensitive_query,
|
before_send_transaction=scrub_sensitive_query,
|
||||||
integrations=[
|
integrations=[
|
||||||
StarletteIntegration(),
|
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 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.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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_retries: Mapped[int] = mapped_column(SmallInteger, default=2, nullable=False)
|
||||||
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
|
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
|
||||||
extra_config: Mapped[dict] = mapped_column(JSONB, default=dict, 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)
|
updated_by: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
description: 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);
|
NB: реальные данные участков живут в таблицах ``cad_parcels`` /
|
||||||
project to МСК-66 via pyproj when computing distances/areas.
|
``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 app.core.db import Base # noqa: F401 (re-export для регистрации будущих моделей)
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
|
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
|
||||||
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
|
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
|
||||||
|
|
@ -84,10 +84,21 @@ class ChatAskRequest(BaseModel):
|
||||||
)
|
)
|
||||||
history: list[ChatTurn] | None = Field(
|
history: list[ChatTurn] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
max_length=_HISTORY_MAX_TURNS,
|
|
||||||
description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)",
|
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):
|
class ChatAskResponse(BaseModel):
|
||||||
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).
|
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ class ConceptInput(BaseModel):
|
||||||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||||||
target_floors: int = Field(9, ge=1, le=30)
|
target_floors: int = Field(9, ge=1, le=30)
|
||||||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
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):
|
class TEAP(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -302,10 +302,18 @@ class NSPDBulkClient:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await self._get_json(NSPD_SEARCH_URL, params=params)
|
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:
|
except NspdBulkError as e:
|
||||||
# 404 или пустой ответ → возвращаем пустой snapshot
|
# Остаётся базовый NspdBulkError = прочие 4xx (см. _get_json:234).
|
||||||
|
# Текст: f"HTTP {code}: {url} — {body_preview}" → классифицируем по
|
||||||
|
# ПРЕФИКСУ (код до URL), а не по вхождению в произвольное тело ответа.
|
||||||
err_str = str(e)
|
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(
|
logger.info(
|
||||||
"search_by_quarter: quarter %s not found (404/400), returning empty"
|
"search_by_quarter: quarter %s not found (404/400), returning empty"
|
||||||
" (category_id=%s)",
|
" (category_id=%s)",
|
||||||
|
|
@ -342,8 +350,23 @@ class NSPDBulkClient:
|
||||||
for m in raw_meta:
|
for m in raw_meta:
|
||||||
cat_id = m.get("categoryId")
|
cat_id = m.get("categoryId")
|
||||||
total = m.get("totalCount")
|
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)
|
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(
|
logger.info(
|
||||||
"search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
|
"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)
|
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]
|
return [NSPDBulkFeature.model_validate(f) for f in raw_features]
|
||||||
|
|
||||||
# ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
|
# ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
|
||||||
|
|
@ -473,9 +508,39 @@ class NSPDBulkClient:
|
||||||
|
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
results: list[dict] = []
|
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):
|
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):
|
if isinstance(cell_result, Exception):
|
||||||
|
# Прочие (сетевые/parse) ошибки одной ячейки — не валим обход и НЕ
|
||||||
|
# считаем server-side fail (иначе сеть ложно triggers layer_failed).
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
|
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
|
||||||
layer_id,
|
layer_id,
|
||||||
|
|
@ -483,6 +548,7 @@ class NSPDBulkClient:
|
||||||
cell_result,
|
cell_result,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
ok_cells += 1
|
||||||
for feature in cell_result:
|
for feature in cell_result:
|
||||||
fid = str(feature.id) if feature.id is not None else ""
|
fid = str(feature.id) if feature.id is not None else ""
|
||||||
if fid and fid in seen_ids:
|
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(
|
logger.info(
|
||||||
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
|
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
|
||||||
layer_id,
|
layer_id,
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,15 @@ def _jsonb_param(value: Any) -> str:
|
||||||
|
|
||||||
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
|
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
|
||||||
json.dumps(..., ensure_ascii=False) — кириллица как есть (зеркало pzz_loader).
|
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(
|
def persist_analysis_run(
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,12 @@ list rather than silently ignored.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -54,6 +55,11 @@ _SUPPORTED_METHODS = (_CALC_BASIS, _CALC_PREVIOUS)
|
||||||
# as unsupported.
|
# as unsupported.
|
||||||
_SUPPORTED_SUBJECT = "66"
|
_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:
|
def _f(value: Any) -> float | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -148,6 +154,50 @@ def get_ddu_indicator(
|
||||||
if clean_buckets:
|
if clean_buckets:
|
||||||
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
|
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
|
||||||
params["buckets"] = clean_buckets
|
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 = ""
|
period_filter = ""
|
||||||
if period_from:
|
if period_from:
|
||||||
|
|
@ -184,8 +234,10 @@ def get_ddu_indicator(
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
except OperationalError:
|
except (ProgrammingError, OperationalError):
|
||||||
# Most likely the MV does not exist yet (migration 152 not applied).
|
# 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?)")
|
logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)")
|
||||||
raise
|
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 recent_mean,
|
||||||
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
|
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
|
||||||
STDDEV_SAMP(realised) FILTER (WHERE rn > :recent_window) AS prior_std,
|
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
|
FROM ranked
|
||||||
GROUP BY obj_id, n_months
|
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]]:
|
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":
|
if source == "portfolio":
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT room_count_type, flat_count, area_sqm, percent
|
WITH latest AS (
|
||||||
FROM domrf_region_aggregates
|
SELECT MAX(snapshot_date) AS snap
|
||||||
WHERE region_id = :region_id
|
FROM domrf_flat_area_distribution
|
||||||
AND snapshot_date = (
|
WHERE region_id = 0
|
||||||
SELECT MAX(snapshot_date)
|
),
|
||||||
FROM domrf_region_aggregates
|
mapped AS (
|
||||||
WHERE region_id = :region_id
|
SELECT
|
||||||
)
|
CASE area_bucket
|
||||||
AND room_count_type <> 'TOTAL'
|
WHEN 'FROM_0_TO_25' THEN '1-Студия'
|
||||||
ORDER BY CASE room_count_type
|
WHEN 'FROM_25_TO_35' THEN '2-1-к'
|
||||||
WHEN 'ONE' THEN 1
|
WHEN 'FROM_35_TO_45' THEN '2-1-к'
|
||||||
WHEN 'TWO' THEN 2
|
WHEN 'FROM_45_TO_55' THEN '3-2-к'
|
||||||
WHEN 'THREE' THEN 3
|
WHEN 'FROM_55_TO_70' THEN '4-3-к'
|
||||||
WHEN 'FOUR' THEN 4
|
WHEN 'FROM_70_TO_85' THEN '4-3-к'
|
||||||
END
|
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()
|
.mappings()
|
||||||
.all()
|
.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 [
|
return [
|
||||||
{
|
{
|
||||||
"bucket": {
|
"bucket": _pretty[r["chart_bucket"]],
|
||||||
"ONE": "1-к",
|
"flat_count": int(r["flat_count"] or 0),
|
||||||
"TWO": "2-к",
|
|
||||||
"THREE": "3-к",
|
|
||||||
"FOUR": "4+",
|
|
||||||
}.get(r["room_count_type"], r["room_count_type"]),
|
|
||||||
"flat_count": r["flat_count"],
|
|
||||||
"area_sqm": _f(r["area_sqm"]),
|
"area_sqm": _f(r["area_sqm"]),
|
||||||
"percent": r["percent"],
|
"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,
|
"avg_area": (_f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None),
|
||||||
}
|
}
|
||||||
for r in rows
|
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»-окно каждую неделю по мере
|
-- ('2025-07-01' расширял «recent»-окно каждую неделю по мере
|
||||||
-- доливки ETL новых report_months → перекос в сторону всё
|
-- доливки ETL новых report_months → перекос в сторону всё
|
||||||
-- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL.
|
-- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL.
|
||||||
AND period_start_date >= NOW()
|
AND period_start_date >= NOW() - make_interval(months => :months_window)
|
||||||
- (:months_window || ' months')::INTERVAL
|
|
||||||
),
|
),
|
||||||
bucketed AS (
|
bucketed AS (
|
||||||
SELECT CASE
|
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
|
SELECT month, source, leads, engaged, converted, conv_pct
|
||||||
FROM prinzip_funnel_monthly
|
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
|
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,
|
SUM(converted) AS converted,
|
||||||
ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct
|
ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct
|
||||||
FROM prinzip_funnel_monthly
|
FROM prinzip_funnel_monthly
|
||||||
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
|
WHERE month >= (CURRENT_DATE - make_interval(months => :months))::date
|
||||||
GROUP BY source
|
GROUP BY source
|
||||||
ORDER BY leads DESC
|
ORDER BY leads DESC
|
||||||
"""
|
"""
|
||||||
|
|
@ -1327,8 +1363,7 @@ _BUCKET_SQL = text(
|
||||||
AND deal_count > 0
|
AND deal_count > 0
|
||||||
AND (area / deal_count) BETWEEN 15 AND 200
|
AND (area / deal_count) BETWEEN 15 AND 200
|
||||||
AND price_per_sqm BETWEEN 30000 AND 1000000
|
AND price_per_sqm BETWEEN 30000 AND 1000000
|
||||||
AND period_start_date >= NOW()
|
AND period_start_date >= NOW() - make_interval(months => :months_window)
|
||||||
- (:months_window || ' months')::INTERVAL
|
|
||||||
),
|
),
|
||||||
bucketed AS (
|
bucketed AS (
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
|
|
@ -1958,7 +1993,7 @@ def _elasticity_coef(
|
||||||
{where_district}
|
{where_district}
|
||||||
{where_class}
|
{where_class}
|
||||||
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 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
|
SELECT
|
||||||
regr_slope(y, x) AS slope,
|
regr_slope(y, x) AS slope,
|
||||||
|
|
@ -2049,7 +2084,7 @@ def _elasticity_per_bucket_coef(
|
||||||
{where_class}
|
{where_class}
|
||||||
AND crm.deals_total_count > 0
|
AND crm.deals_total_count > 0
|
||||||
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 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,
|
SELECT bucket,
|
||||||
regr_slope(y, x) AS slope,
|
regr_slope(y, x) AS slope,
|
||||||
|
|
@ -2632,9 +2667,7 @@ def recommend_mix(
|
||||||
else:
|
else:
|
||||||
# Rosreestr fallback aggregate: district+class deals / months / n_comp =
|
# Rosreestr fallback aggregate: district+class deals / months / n_comp =
|
||||||
# среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
|
# среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
|
||||||
market_vel_pm = (
|
market_vel_pm = (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
|
||||||
(total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
|
|
||||||
)
|
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
|
f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
|
||||||
f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."
|
f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."
|
||||||
|
|
@ -2786,6 +2819,25 @@ def recommend_mix(
|
||||||
if b["bucket"] != top_bucket_name:
|
if b["bucket"] != top_bucket_name:
|
||||||
b["share_pct"] = round(b["share_pct"] * scale, 1)
|
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 → перераспределяем
|
# #1359: success-boost изменил share_pct → перераспределяем
|
||||||
# units/revenue/months_to_sellout и агрегаты под новые доли,
|
# units/revenue/months_to_sellout и агрегаты под новые доли,
|
||||||
# иначе share_pct рассогласуется с units_planned/revenue/sellout
|
# иначе share_pct рассогласуется с units_planned/revenue/sellout
|
||||||
|
|
|
||||||
|
|
@ -247,21 +247,11 @@ async def harvest_quarter(
|
||||||
db.commit()
|
db.commit()
|
||||||
update_progress(done_progress)
|
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 ─────────────────
|
# ── 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]
|
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
||||||
if stats_features:
|
if stats_features:
|
||||||
upsert_quarter_stats(db, quarter, stats_features[0])
|
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)
|
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
|
||||||
db.commit()
|
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 по всем
|
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
|
||||||
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
||||||
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
# 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)
|
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()
|
discovered_cads: set[str] = set()
|
||||||
upserted = 0
|
upserted = 0
|
||||||
requests = 0
|
requests = 0
|
||||||
|
|
@ -411,7 +422,7 @@ async def _grid_walk_category(
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
stats = upsert_features(db, [feature], source="wms_grid_walk")
|
stats = upsert_features(db, [feature], source="wms_grid_walk")
|
||||||
upserted += sum(stats.values())
|
upserted += stats[layer_count_key]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"_grid_walk_category: upsert failed cad=%s layer=%d: %s",
|
"_grid_walk_category: upsert failed cad=%s layer=%d: %s",
|
||||||
|
|
@ -544,9 +555,7 @@ async def backfill_parcel_geom(
|
||||||
# Один сбойный квартал не валит весь backfill — лог + продолжаем.
|
# Один сбойный квартал не валит весь backfill — лог + продолжаем.
|
||||||
# (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
|
# (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
|
||||||
# caller-task ловит и не ретраит, как в bulk_harvest.)
|
# caller-task ловит и не ретраит, как в bulk_harvest.)
|
||||||
logger.warning(
|
logger.warning("backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e)
|
||||||
"backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e
|
|
||||||
)
|
|
||||||
db.rollback()
|
db.rollback()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -1423,7 +1432,7 @@ def upsert_quarter_stats(
|
||||||
"cost_value_total_geom": _safe_numeric(raw_opts.get("cost_value_total_geom")),
|
"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_area": _safe_numeric(raw_opts.get("sum_land_area")),
|
||||||
"sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_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")),
|
"real_srid": _safe_int(raw_opts.get("real_srid")),
|
||||||
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
"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")
|
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")
|
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:
|
try:
|
||||||
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
|
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
|
||||||
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
|
# 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(:permitted_use AS text),
|
||||||
CAST(:raw_props AS jsonb),
|
CAST(:raw_props AS jsonb),
|
||||||
CASE WHEN CAST(:geom AS text) IS NOT NULL
|
CASE WHEN CAST(:geom AS text) IS NOT NULL
|
||||||
THEN ST_Transform(
|
THEN ST_Multi(
|
||||||
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
|
ST_Transform(
|
||||||
4326
|
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
|
||||||
|
4326
|
||||||
|
)
|
||||||
)::geography
|
)::geography
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None:
|
||||||
"""Получить bbox квартала из cad_quarters_geom в EPSG:3857.
|
"""Получить 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
|
# geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors
|
||||||
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
|
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
|
||||||
# broken — snapshot phase даст 20 per cat, что норм для MVP.
|
# 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:
|
if width < 100 or height < 100 or width > 10000 or height > 10000:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm "
|
"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):
|
if isinstance(value, int):
|
||||||
return _fmt_thousands(value)
|
return _fmt_thousands(value)
|
||||||
if isinstance(value, float):
|
if isinstance(value, float):
|
||||||
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
|
if not math.isfinite(value): # NaN/Inf — не число: честно пропускаем (как None),
|
||||||
return str(value)
|
return None # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585)
|
||||||
if value == int(value):
|
if value == int(value):
|
||||||
return _fmt_thousands(value)
|
return _fmt_thousands(value)
|
||||||
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
|
# Точность отчёта сохраняем: 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:
|
if summary:
|
||||||
lines.append(str(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")
|
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
|
||||||
):
|
):
|
||||||
lines.append("Раздел рекомендации продукта в отчёте пуст.")
|
lines.append("Раздел рекомендации продукта в отчёте пуст.")
|
||||||
|
|
@ -266,16 +266,21 @@ def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
|
||||||
|
|
||||||
future = report.get("future_market")
|
future = report.get("future_market")
|
||||||
if isinstance(future, dict):
|
if isinstance(future, dict):
|
||||||
sections_used.append("future_market")
|
future_emitted = False # секцию в provenance кладём только если выведена строка (#1630)
|
||||||
horizons = future.get("forecasts_by_horizon")
|
horizons = future.get("forecasts_by_horizon")
|
||||||
if isinstance(horizons, list) and horizons:
|
if isinstance(horizons, list) and horizons:
|
||||||
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
|
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
|
||||||
|
future_emitted = True
|
||||||
future_supply = future.get("future_supply")
|
future_supply = future.get("future_supply")
|
||||||
if isinstance(future_supply, dict) and future_supply:
|
if isinstance(future_supply, dict) and future_supply:
|
||||||
lines.append("Учтено давление будущего предложения (выходящие проекты).")
|
lines.append("Учтено давление будущего предложения (выходящие проекты).")
|
||||||
|
future_emitted = True
|
||||||
summary = future.get("summary")
|
summary = future.get("summary")
|
||||||
if summary:
|
if summary:
|
||||||
lines.append(str(summary))
|
lines.append(str(summary))
|
||||||
|
future_emitted = True
|
||||||
|
if future_emitted:
|
||||||
|
sections_used.append("future_market")
|
||||||
else:
|
else:
|
||||||
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))
|
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,18 @@ def orchestrate_chat(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Нет tool_calls → финальная проза.
|
# Нет 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()
|
answer = (result.content or "").strip()
|
||||||
if not answer:
|
if not answer:
|
||||||
# Модель вернула пустоту — пустой ответ клиенту не отдаём, деградируем.
|
# Модель вернула пустоту — пустой ответ клиенту не отдаём, деградируем.
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ def find_match_candidates(
|
||||||
objective_distinct AS (
|
objective_distinct AS (
|
||||||
SELECT DISTINCT project_name
|
SELECT DISTINCT project_name
|
||||||
FROM objective_corpus_room_month
|
FROM objective_corpus_room_month
|
||||||
|
WHERE group_name = 'Екатеринбург'
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
d.obj_id,
|
d.obj_id,
|
||||||
|
|
@ -168,7 +169,7 @@ def auto_apply_matches(
|
||||||
len(auto),
|
len(auto),
|
||||||
len(review),
|
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
|
inserted = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,9 @@ _FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
|
||||||
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
|
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
|
||||||
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
|
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
|
||||||
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
|
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
|
||||||
# основного горизонта как сводный показатель сценария.
|
# основного горизонта как сводный показатель. При fallback на иной горизонт ячейка
|
||||||
|
# несёт «(гор. N мес)» через _scenario_deficit_cell, чтобы шапка «(12 мес)» не лгала
|
||||||
|
# (#1590).
|
||||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||||
|
|
||||||
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
|
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
|
||||||
|
|
@ -140,7 +142,7 @@ def _fmt(value: Any) -> Any:
|
||||||
return _sanitize_formula(value)
|
return _sanitize_formula(value)
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
return value
|
return value
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, dict | list):
|
||||||
return _sanitize_formula(str(value))
|
return _sanitize_formula(str(value))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
@ -208,6 +210,48 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
||||||
return None
|
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-побочка
|
# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка
|
||||||
# (мутируют переданный лист). Не ходят в БД/сеть.
|
# (мутируют переданный лист). Не ходят в БД/сеть.
|
||||||
|
|
@ -481,12 +525,14 @@ def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
|
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
|
||||||
|
# _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт
|
||||||
|
# «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590).
|
||||||
row = _write_title(ws, row, "Сводка по сценариям")
|
row = _write_title(ws, row, "Сводка по сценариям")
|
||||||
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
|
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
|
||||||
table_rows: list[list[Any]] = []
|
table_rows: list[list[Any]] = []
|
||||||
for name, payload in by_scenario.items():
|
for name, payload in by_scenario.items():
|
||||||
data = _as_dict(payload)
|
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)
|
_write_table(ws, row, headers, table_rows)
|
||||||
|
|
||||||
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])
|
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из
|
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из
|
||||||
`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря,
|
`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря,
|
||||||
`_fmt` — строковое форматирование ячейки/None→"—", `_level_ru`, `_as_dict`/`_as_list`,
|
`_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-специфичные
|
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные
|
||||||
микро-билдеры (заголовки/абзацы/таблицы python-docx).
|
микро-билдеры (заголовки/абзацы/таблицы python-docx).
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ from app.services.exporters.report_pdf import (
|
||||||
_future_supply_pairs,
|
_future_supply_pairs,
|
||||||
_level_ru,
|
_level_ru,
|
||||||
_normalize,
|
_normalize,
|
||||||
_scenario_deficit_index,
|
_scenario_deficit_cell,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -76,7 +76,8 @@ _TABLE_STYLE: str = "Table Grid"
|
||||||
_COMPETITORS_TOP_N: int = 5
|
_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
|
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -272,7 +273,7 @@ def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
||||||
rate_str = (
|
rate_str = (
|
||||||
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
", ".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 = [
|
headers = [
|
||||||
"Сценарий",
|
"Сценарий",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from app.services.exporters.report_pdf import (
|
||||||
_future_supply_pairs,
|
_future_supply_pairs,
|
||||||
_level_ru,
|
_level_ru,
|
||||||
_normalize,
|
_normalize,
|
||||||
|
_scenario_deficit_horizon,
|
||||||
_scenario_deficit_index,
|
_scenario_deficit_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -109,10 +110,7 @@ def _md_table(headers: list[str], rows: list[list[Any]]) -> str:
|
||||||
if not rows:
|
if not rows:
|
||||||
empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |"
|
empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |"
|
||||||
return "\n".join([head, sep, empty])
|
return "\n".join([head, sep, empty])
|
||||||
body = [
|
body = ["| " + " | ".join(_md_cell(cell) for cell in row) + " |" for row in rows]
|
||||||
"| " + " | ".join(_md_cell(cell) for cell in row) + " |"
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
return "\n".join([head, sep, *body])
|
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:
|
def _md_kv_lines(pairs: list[tuple[str, Any]]) -> str:
|
||||||
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE."""
|
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE.
|
||||||
|
|
||||||
|
Переводы строк в значении сворачиваем в пробел (по аналогии с `_md_cell`):
|
||||||
|
иначе многострочный value (напр. product_tz.summary) разорвал бы буллет.
|
||||||
|
"""
|
||||||
if not pairs:
|
if not pairs:
|
||||||
return _NO_DATA
|
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:
|
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:
|
def _build_scenarios(report: dict[str, Any]) -> str:
|
||||||
"""§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
|
"""§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
|
||||||
scenarios = _as_dict(report.get("scenarios"))
|
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():
|
for name, payload in by_scenario.items():
|
||||||
data = _as_dict(payload)
|
data = _as_dict(payload)
|
||||||
rate_path = _as_dict(data.get("rate_path"))
|
rate_path = _as_dict(data.get("rate_path"))
|
||||||
rate_str = (
|
rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
||||||
", ".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")])
|
||||||
)
|
|
||||||
rows.append(
|
|
||||||
[name, _scenario_deficit_index(data), rate_str, data.get("advisory")]
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
"Сценарий",
|
"Сценарий",
|
||||||
|
|
@ -266,9 +283,7 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
||||||
"Advisory",
|
"Advisory",
|
||||||
]
|
]
|
||||||
return (
|
return (
|
||||||
f"## {_TITLE_SCENARIOS}\n\n"
|
f"## {_TITLE_SCENARIOS}\n\n{_fmt(scenarios.get('summary'))}\n\n{_md_table(headers, rows)}"
|
||||||
f"{_fmt(scenarios.get('summary'))}\n\n"
|
|
||||||
f"{_md_table(headers, rows)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -290,9 +305,7 @@ def _build_product_tz(report: dict[str, Any]) -> str:
|
||||||
if isinstance(m, dict)
|
if isinstance(m, dict)
|
||||||
]
|
]
|
||||||
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
|
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
|
||||||
reason_rows = [
|
reason_rows = [[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)]
|
||||||
[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"## {_TITLE_PRODUCT_TZ}\n\n"
|
f"## {_TITLE_PRODUCT_TZ}\n\n"
|
||||||
|
|
@ -444,7 +457,9 @@ def _tg_scenario_deficit_line(report: dict[str, Any]) -> str | None:
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for name in ordered:
|
for name in ordered:
|
||||||
label = _TG_SCENARIO_RU.get(name, str(name))
|
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)}")
|
parts.append(f"{label} {_fmt(deficit)}")
|
||||||
return " · ".join(parts) if parts else None
|
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)
|
scenario_line = _tg_scenario_deficit_line(data)
|
||||||
if scenario_line is not None:
|
if scenario_line is not None:
|
||||||
horizons = _as_list(meta.get("horizons"))
|
# Метку «(гор. 12 мес)» ставим, только если ВСЕ выведенные дефициты реально взяты
|
||||||
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if horizons else ""
|
# с основного 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}")
|
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» — есть
|
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
|
||||||
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
|
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
|
||||||
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
|
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
|
||||||
|
|
||||||
|
NB: эту функцию импортируют report_md/docx/pptx — НЕ менять сигнатуру (вернёт скаляр).
|
||||||
|
Для подписи фактического горизонта используй `_scenario_deficit_horizon`.
|
||||||
"""
|
"""
|
||||||
forecasts = _as_list(payload.get("forecasts"))
|
forecasts = _as_list(payload.get("forecasts"))
|
||||||
primary = next(
|
primary = next(
|
||||||
|
|
@ -476,6 +479,49 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
||||||
return None
|
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:
|
def _build_scenarios(report: dict[str, Any]) -> str:
|
||||||
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
||||||
scenarios = _as_dict(report.get("scenarios"))
|
scenarios = _as_dict(report.get("scenarios"))
|
||||||
|
|
@ -484,9 +530,9 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
||||||
rows: list[list[Any]] = []
|
rows: list[list[Any]] = []
|
||||||
for name, payload in by_scenario.items():
|
for name, payload in by_scenario.items():
|
||||||
data = _as_dict(payload)
|
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"""
|
return f"""
|
||||||
<div class="section" id="scenarios">
|
<div class="section" id="scenarios">
|
||||||
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
|
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из
|
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из
|
||||||
`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря,
|
`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря,
|
||||||
`_fmt` — строковое форматирование значения/None→"—", `_level_ru`, `_as_dict`/`_as_list`,
|
`_fmt` — строковое форматирование значения/None→"—", `_level_ru`, `_as_dict`/`_as_list`,
|
||||||
`_scenario_deficit_index`) + переиспользуем его named-константы
|
`_scenario_deficit_cell`) + переиспользуем его named-константы
|
||||||
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные
|
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные
|
||||||
микро-билдеры (слайды/буллеты/таблица python-pptx).
|
микро-билдеры (слайды/буллеты/таблица python-pptx).
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@ from app.services.exporters.report_pdf import (
|
||||||
_fmt,
|
_fmt,
|
||||||
_level_ru,
|
_level_ru,
|
||||||
_normalize,
|
_normalize,
|
||||||
_scenario_deficit_index,
|
_scenario_deficit_cell,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -78,7 +78,8 @@ _LAYOUT_TITLE_CONTENT: int = 1
|
||||||
_LAYOUT_TITLE_ONLY: int = 5
|
_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
|
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||||
|
|
||||||
# Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно).
|
# Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно).
|
||||||
|
|
@ -235,7 +236,7 @@ def _build_scenarios(prs: _PptxPresentation, report: dict[str, Any]) -> None:
|
||||||
rate_str = (
|
rate_str = (
|
||||||
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH
|
", ".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(
|
bullets.append(
|
||||||
(str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}")
|
(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 "—"
|
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else "—"
|
||||||
poi_items = _build_poi_items(poi_rows, limit=7)
|
poi_items = _build_poi_items(poi_rows, limit=7)
|
||||||
|
|
||||||
# Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC;
|
# Конкуренты — берём топ N БЛИЖАЙШИХ. competitor_rows приходят отсортированными
|
||||||
# переупорядочиваем по distance_m для удобства чтения)
|
# по flat_count DESC (крупнейшие ЖК), поэтому сначала пересортировываем весь
|
||||||
|
# список по distance_m ASC, и лишь затем срезаем N — иначе в блок попадали бы
|
||||||
|
# 5 крупнейших из радиуса, а не непосредственное конкурентное окружение пятна.
|
||||||
competitors_display = sorted(
|
competitors_display = sorted(
|
||||||
competitor_rows[:competitors_limit],
|
competitor_rows,
|
||||||
key=lambda r: float(r.get("distance_m") or 0),
|
key=lambda r: float(r.get("distance_m") or 0),
|
||||||
)
|
)[:competitors_limit]
|
||||||
competitors_ctx: list[dict[str, Any]] = [
|
competitors_ctx: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"comm_name": r.get("comm_name"),
|
"comm_name": r.get("comm_name"),
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,11 @@ def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str:
|
||||||
for lot in lots:
|
for lot in lots:
|
||||||
date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else "—"
|
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 "—"
|
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 = "Дата сделки" if is_deal else "В продаже"
|
||||||
_ = label # used for header only
|
_ = label # used for header only
|
||||||
rows.append(
|
rows.append(
|
||||||
|
|
|
||||||
|
|
@ -294,8 +294,10 @@ def compute_affordability(
|
||||||
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
||||||
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
||||||
months=_ANNUITY_TERM_MONTHS).
|
months=_ANNUITY_TERM_MONTHS).
|
||||||
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h]) — платёж на
|
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h] + спред) —
|
||||||
каждом горизонте сценарной ставки (None rate_path → поле None).
|
платёж на каждом горизонте сценарной КЛЮЧЕВОЙ ставки ЦБ, приведённой к той
|
||||||
|
же рыночной базе, что и monthly_payment_rub (key_rate + калиброванный спред
|
||||||
|
4.5 п.п.); None rate_path → поле None (#1639).
|
||||||
|
|
||||||
Graceful: нет ставки/цены → платёж None; ставка ≤0 → аннуитет деградирует к
|
Graceful: нет ставки/цены → платёж None; ставка ≤0 → аннуитет деградирует к
|
||||||
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
|
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
|
||||||
|
|
@ -304,8 +306,10 @@ def compute_affordability(
|
||||||
db: SQLAlchemy sync Session.
|
db: SQLAlchemy sync Session.
|
||||||
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
|
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
|
||||||
price_per_m2: цена ₽/м² (None → берём сегментную среднюю reuse-ом).
|
price_per_m2: цена ₽/м² (None → берём сегментную среднюю reuse-ом).
|
||||||
rate_path: сценарный {horizon: годовая ставка %} для payment_at_scenario;
|
rate_path: сценарный {horizon: КЛЮЧЕВАЯ ставка ЦБ %} для payment_at_scenario
|
||||||
None → payment_at_scenario None.
|
(контракт #952/#984: конверт key_rate, НЕ рыночная — к ней внутри
|
||||||
|
добавляется тот же спред _KEY_RATE_MARKET_SPREAD_PP, что и в базовом
|
||||||
|
monthly_payment_rub); None → payment_at_scenario None.
|
||||||
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
|
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
|
||||||
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
|
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
|
||||||
|
|
||||||
|
|
@ -337,7 +341,18 @@ def compute_affordability(
|
||||||
if rate_path is not None:
|
if rate_path is not None:
|
||||||
scenario: dict[int, float] = {}
|
scenario: dict[int, float] = {}
|
||||||
for horizon, scenario_rate in rate_path.items():
|
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:
|
if payment is not None:
|
||||||
scenario[horizon] = payment
|
scenario[horizon] = payment
|
||||||
payment_at_scenario = scenario
|
payment_at_scenario = scenario
|
||||||
|
|
|
||||||
|
|
@ -367,9 +367,12 @@ def _build_rationale(
|
||||||
|
|
||||||
if advisory_capped and level == _ADVISORY_CEILING:
|
if advisory_capped and level == _ADVISORY_CEILING:
|
||||||
# Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
|
# Уровень упёрся в 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»"
|
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
|
||||||
if notes:
|
if other:
|
||||||
base += "; также " + _join_notes(notes)
|
base += "; также " + _join_notes(other)
|
||||||
return base + "."
|
return base + "."
|
||||||
|
|
||||||
if not notes:
|
if not notes:
|
||||||
|
|
@ -409,6 +412,7 @@ def compute_report_confidence(
|
||||||
*,
|
*,
|
||||||
component_confidences: Sequence[ComponentConfidence] | None = None,
|
component_confidences: Sequence[ComponentConfidence] | None = None,
|
||||||
deal_count: int | None = None,
|
deal_count: int | None = None,
|
||||||
|
deal_count_months: int | None = None,
|
||||||
analog_count: int | None = None,
|
analog_count: int | None = None,
|
||||||
domrf_coverage: float | None = None,
|
domrf_coverage: float | None = None,
|
||||||
history_months: int | None = None,
|
history_months: int | None = None,
|
||||||
|
|
@ -439,6 +443,8 @@ def compute_report_confidence(
|
||||||
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
|
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
|
||||||
имя сервиса проносится сквозь стек в RU-метку фактора).
|
имя сервиса проносится сквозь стек в RU-метку фактора).
|
||||||
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
||||||
|
deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес»
|
||||||
|
в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода.
|
||||||
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
||||||
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||||
history_months: глубина ряда (мес).
|
history_months: глубина ряда (мес).
|
||||||
|
|
@ -453,6 +459,7 @@ def compute_report_confidence(
|
||||||
|
|
||||||
# ── 1. Сырые счётчики качества данных → факторы (только заданные) ──────────
|
# ── 1. Сырые счётчики качества данных → факторы (только заданные) ──────────
|
||||||
if deal_count is not None:
|
if deal_count is not None:
|
||||||
|
_deal_suffix = f"за {deal_count_months} мес" if deal_count_months is not None else ""
|
||||||
factors.append(
|
factors.append(
|
||||||
_factor_from_count(
|
_factor_from_count(
|
||||||
_F_DEAL_COUNT,
|
_F_DEAL_COUNT,
|
||||||
|
|
@ -460,6 +467,7 @@ def compute_report_confidence(
|
||||||
high_at=_DEAL_COUNT_HIGH,
|
high_at=_DEAL_COUNT_HIGH,
|
||||||
low_below=_DEAL_COUNT_LOW,
|
low_below=_DEAL_COUNT_LOW,
|
||||||
unit="сделок",
|
unit="сделок",
|
||||||
|
suffix=_deal_suffix,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if analog_count is not None:
|
if analog_count is not None:
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,10 @@
|
||||||
МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard — graceful, без усиления шума на тонких данных):
|
МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard — graceful, без усиления шума на тонких данных):
|
||||||
сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить
|
сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить
|
||||||
случайным шумом одного-двух наблюдений:
|
случайным шумом одного-двух наблюдений:
|
||||||
• нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — иначе у каждого месяца
|
• нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — считаем только по
|
||||||
< 2 наблюдений, «сезонность» неотличима от шума → ВСЕ факторы = 1.0 (ряд
|
NON-ZERO месяцам (fill_month_grid ставит units=0 для «нет сделок», но 0 не
|
||||||
возвращается без изменений);
|
несёт сезонного сигнала); иначе у каждого месяца < 2 наблюдений, «сезонность»
|
||||||
|
неотличима от шума → ВСЕ факторы = 1.0 (ряд возвращается без изменений);
|
||||||
• месяц без наблюдений → его фактор = 1.0 (нет базы для оценки);
|
• месяц без наблюдений → его фактор = 1.0 (нет базы для оценки);
|
||||||
• overall_mean ≤ 0 (пустой/нулевой ряд) → ВСЕ факторы = 1.0 (нет масштаба, и
|
• overall_mean ≤ 0 (пустой/нулевой ряд) → ВСЕ факторы = 1.0 (нет масштаба, и
|
||||||
защита от деления на ноль);
|
защита от деления на ноль);
|
||||||
|
|
@ -114,7 +115,8 @@ def seasonal_factors(
|
||||||
None-значения в values пропускаются (нет наблюдения — не подмешиваем 0).
|
None-значения в values пропускаются (нет наблюдения — не подмешиваем 0).
|
||||||
|
|
||||||
GUARD (деградация к нейтрали 1.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);
|
• overall_mean ≤ 0 → ВСЕ факторы 1.0 (нет масштаба / защита от /0);
|
||||||
• месяц без наблюдений ИЛИ со средним ≤ 0 → его фактор 1.0.
|
• месяц без наблюдений ИЛИ со средним ≤ 0 → его фактор 1.0.
|
||||||
|
|
||||||
|
|
@ -167,15 +169,20 @@ def seasonal_factors(
|
||||||
|
|
||||||
|
|
||||||
def _count_full_years(months: list[date], values: list[float | int | None]) -> int:
|
def _count_full_years(months: list[date], values: list[float | int | None]) -> int:
|
||||||
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 наблюдение) в ряду.
|
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 NON-ZERO наблюдение).
|
||||||
|
|
||||||
Считаем по годам, где присутствует наблюдение в КАЖДОМ из 12 месяцев — это
|
Считаем по годам, где есть НЕНУЛЕВОЕ наблюдение в КАЖДОМ из 12 месяцев — это
|
||||||
«полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки
|
«полный год» в смысле сезонного покрытия (а не просто диапазон дат). 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]] = {}
|
months_seen: dict[int, set[int]] = {}
|
||||||
for d, v in zip(months, values, strict=False):
|
for d, v in zip(months, values, strict=False):
|
||||||
if v is None:
|
if v is None or float(v) == 0.0:
|
||||||
continue
|
continue
|
||||||
months_seen.setdefault(d.year, set()).add(d.month)
|
months_seen.setdefault(d.year, set()).add(d.month)
|
||||||
return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR)
|
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 — нет данных ≠ нет ниш).
|
# дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш).
|
||||||
_DIFF_TARGET_USP: float = 3.0
|
_DIFF_TARGET_USP: float = 3.0
|
||||||
|
|
||||||
# commercial: commercial_share_pct ∈ [0,100] (§10.4 реализованная доля нежилого) → /100.
|
# commercial: commercial_sell_through_pct ∈ [0,100] (§10.4 темп распродажи нежилого,
|
||||||
# Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): overlay.commercial.available=False
|
# sell-through, прокси спроса) → /100. Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё):
|
||||||
# → скор None (НЕ 0). available=True → share/100 как прокси силы спроса на коммерцию.
|
# overlay.commercial.available=False → скор None (НЕ 0). available=True → sell_through/100
|
||||||
|
# как прокси силы спроса на коммерцию.
|
||||||
|
|
||||||
# confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] →
|
# confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] →
|
||||||
# усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 —
|
# усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 —
|
||||||
|
|
@ -463,21 +464,24 @@ def _score_differentiation(
|
||||||
def _score_commercial(
|
def _score_commercial(
|
||||||
commercial: dict[str, Any] | None,
|
commercial: dict[str, Any] | None,
|
||||||
) -> tuple[float | None, Confidence, str]:
|
) -> 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 (прокси спроса на
|
overlay.commercial.available=True → commercial_sell_through_pct/100 (темп распродажи
|
||||||
нежилое). available=False / None (objective ~ жильё, тонко) → None (НЕ 0-как-заглушка).
|
нежилого, sell-through, прокси спроса). available=False / None (objective ~ жильё, тонко)
|
||||||
confidence наследуется из сигнала (или 'low').
|
→ None (НЕ 0-как-заглушка). confidence наследуется из сигнала (или 'low').
|
||||||
"""
|
"""
|
||||||
if not commercial or not commercial.get("available"):
|
if not commercial or not commercial.get("available"):
|
||||||
return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)."
|
return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)."
|
||||||
share_pct = commercial.get("commercial_share_pct")
|
sell_through_pct = commercial.get("commercial_sell_through_pct")
|
||||||
if share_pct is None:
|
if sell_through_pct is None:
|
||||||
return None, "low", "Коммерческий сигнал §10.4 недоступен (доля не измерена)."
|
return None, "low", "Коммерческий сигнал §10.4 недоступен (темп распродажи не измерен)."
|
||||||
raw_conf = commercial.get("confidence", "low")
|
raw_conf = commercial.get("confidence", "low")
|
||||||
conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low"
|
conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low"
|
||||||
value = _clamp01(float(share_pct) / 100.0)
|
value = _clamp01(float(sell_through_pct) / 100.0)
|
||||||
reason = f"Реализованная доля коммерции ~{round(float(share_pct), 1)}% (§10.4, прокси спроса)."
|
reason = (
|
||||||
|
f"Темп распродажи нежилого ~{round(float(sell_through_pct), 1)}% (§10.4, прокси "
|
||||||
|
"ликвидности/спроса на нежилое; НЕ доля нежилого в объёме застройки)."
|
||||||
|
)
|
||||||
return value, conf, reason
|
return value, conf, reason
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -818,7 +822,10 @@ def _competitor_signal(
|
||||||
)
|
)
|
||||||
return None, None
|
return None, None
|
||||||
weights = [c.relevance_weight for c in response.competitors if c.relevance_weight is not 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:
|
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/конкуренты
|
• mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты
|
||||||
НЕизмеримы → считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default
|
НЕизмеримы → считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default
|
||||||
room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace ∈
|
room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace ∈
|
||||||
(0,1] — это ПРОКСИ относительной силы спроса, НЕ supply-based дефицит;
|
(0,1]; `balance_units=None`, confidence='low', обязательный warning что supply
|
||||||
`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: пакет
|
ИМПОРТЫ §9.x — ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет
|
||||||
`forecasting/__init__` тянет `affordability`, который импортит
|
`forecasting/__init__` тянет `affordability`, который импортит
|
||||||
|
|
@ -68,6 +75,18 @@ _FORECAST_TO_LIVE_BUCKET: dict[str, str] = {
|
||||||
# дефолтной сетке room-bucket'ов §9.7 и для map_room_bucket_inverse).
|
# дефолтной сетке 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()}
|
_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, «человеческий»
|
# Live target_class (вокабуляр schemas.recommend ClassLiteral, «человеческий»
|
||||||
# регистр) → forecast obj_class (3 массовых класса §9.7 _DEFAULT_CLASSES). None и
|
# регистр) → forecast obj_class (3 массовых класса §9.7 _DEFAULT_CLASSES). None и
|
||||||
# незнакомое → None (вызывающий тогда отдаёт движку дефолтную сетку классов).
|
# незнакомое → None (вызывающий тогда отдаёт движку дефолтную сетку классов).
|
||||||
|
|
@ -565,9 +584,14 @@ def _demand_only_overlay(
|
||||||
БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса
|
БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса
|
||||||
pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient
|
pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient
|
||||||
per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace ∈
|
per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace ∈
|
||||||
(0,1] — ПРОКСИ относительной силы спроса (НЕ supply-based дефицит).
|
(0,1]. balance_units=None, confidence='low', обязательный warning. НИКОГДА не
|
||||||
balance_units=None, confidence='low', обязательный warning. НИКОГДА не
|
|
||||||
фабрикуем supply. Graceful → [].
|
фабрикуем 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).
|
# Локальные импорты — избегаем import-cycle (см. module docstring).
|
||||||
from app.services.forecasting.demand_normalization import compute_demand_normalization
|
from app.services.forecasting.demand_normalization import compute_demand_normalization
|
||||||
|
|
@ -581,9 +605,10 @@ def _demand_only_overlay(
|
||||||
]
|
]
|
||||||
mapped_class = map_class(target_class)
|
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)
|
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)
|
macro = get_monthly_macro(db)
|
||||||
rate_future = hold_last_rate(macro, [horizon_months]).get(horizon_months)
|
rate_future = hold_last_rate(macro, [horizon_months]).get(horizon_months)
|
||||||
|
|
||||||
|
|
@ -609,17 +634,38 @@ def _demand_only_overlay(
|
||||||
room_bucket=forecast_bucket,
|
room_bucket=forecast_bucket,
|
||||||
district=district,
|
district=district,
|
||||||
)
|
)
|
||||||
# §9.4 нормализация под будущий режим ставки (β внутри; rate_future None →
|
# §9.2 #1593: per-bucket velocity из velocity_by_room (objective_lots
|
||||||
# деградирует к нейтрали внутри себя, передаём 0.0 как placeholder).
|
# per комнатность). _FORECAST_TO_METRIC_BUCKETS даёт список metric-ключей
|
||||||
norm = compute_demand_normalization(
|
# для данного forecast_bucket ("80+ м²" = "4"+"5+"). Суммируем ед./мес по
|
||||||
db, spec=spec, rate_future=rate_future if rate_future is not None else 0.0
|
# бакетам. Честный 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.
|
# §9.5 макро-коэффициент (ортогонален β); профиль — класс + room_bucket.
|
||||||
profile: dict[str, Any] = {"room_bucket": forecast_bucket}
|
profile: dict[str, Any] = {"room_bucket": forecast_bucket}
|
||||||
if mapped_class is not None:
|
if mapped_class is not None:
|
||||||
profile["obj_class"] = mapped_class
|
profile["obj_class"] = mapped_class
|
||||||
macro_coef = compute_macro_coefficient(db, segment_profile=profile)
|
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))
|
paces.append((live_bucket, mapped_class, pace))
|
||||||
|
|
||||||
max_pace = max((p for _, _, p in paces), default=0.0)
|
max_pace = max((p for _, _, p in paces), default=0.0)
|
||||||
|
|
@ -635,7 +681,10 @@ def _demand_only_overlay(
|
||||||
demand_only=True,
|
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)
|
paces.sort(key=lambda t: t[2], reverse=True)
|
||||||
ranked_segments: list[dict[str, Any]] = [
|
ranked_segments: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
|
|
@ -649,12 +698,13 @@ def _demand_only_overlay(
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"forecast-overlay demand_only: district=%s horizon=%d ranked=%d base_pace=%.3f "
|
"forecast-overlay demand_only: district=%s horizon=%d ranked=%d "
|
||||||
"(ADVISORY, supply НЕ учтён)",
|
"base_pace_agg=%.3f vel_by_room=%s (ADVISORY, supply НЕ учтён, #1593 per-bucket)",
|
||||||
district,
|
district,
|
||||||
horizon_months,
|
horizon_months,
|
||||||
len(ranked_segments),
|
len(ranked_segments),
|
||||||
base_pace,
|
base_pace,
|
||||||
|
vel_by_room,
|
||||||
)
|
)
|
||||||
return _enrich_overlay(
|
return _enrich_overlay(
|
||||||
db,
|
db,
|
||||||
|
|
@ -698,13 +748,15 @@ def _overlay(
|
||||||
def _commercial_signal(
|
def _commercial_signal(
|
||||||
db: Session, district: str | None, horizon_months: int
|
db: Session, district: str | None, horizon_months: int
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful.
|
"""§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest.
|
||||||
|
|
||||||
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
|
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
|
||||||
"нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда
|
"нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда
|
||||||
возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число.
|
возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число.
|
||||||
Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка доли
|
Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка ТЕМПА
|
||||||
коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason.
|
РАСПРОДАЖИ нежилого: sell_through_pct = проданные ÷ (проданные+доступные)·100
|
||||||
|
(market_metrics §-определение) — прокси ликвидности/спроса на нежилое, НЕ доля
|
||||||
|
нежилого в объёме застройки + §16-подобный reason.
|
||||||
НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал.
|
НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -747,22 +799,26 @@ def _commercial_signal(
|
||||||
)
|
)
|
||||||
return {"available": False, "caveat": caveat, "advisory": True}
|
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"
|
confidence = confidence if confidence in ("high", "medium", "low") else "low"
|
||||||
return {
|
return {
|
||||||
"available": True,
|
"available": True,
|
||||||
"premise_kind": _COMMERCIAL_PREMISE_KIND,
|
"premise_kind": _COMMERCIAL_PREMISE_KIND,
|
||||||
"commercial_share_pct": share_pct,
|
# #1635: ключ = ТЕМП РАСПРОДАЖИ нежилого (sell_through, прокси
|
||||||
|
# ликвидности/спроса), НЕ доля нежилого в объёме застройки.
|
||||||
|
"commercial_sell_through_pct": sell_through_pct,
|
||||||
"n_lots": n_lots,
|
"n_lots": n_lots,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"reason": {
|
"reason": {
|
||||||
"why": (
|
"why": (
|
||||||
f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} "
|
f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} "
|
||||||
f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)."
|
f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на "
|
||||||
|
f"нежилые помещения, НЕ доля нежилого в объёме застройки)."
|
||||||
),
|
),
|
||||||
"drivers": [
|
"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": "+"},
|
{"factor": "n_lots", "value": n_lots, "direction": "+"},
|
||||||
],
|
],
|
||||||
"rejected": [],
|
"rejected": [],
|
||||||
|
|
@ -780,14 +836,14 @@ def _as_int(value: Any) -> int | None:
|
||||||
Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой
|
Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой
|
||||||
нечисловой/bool/сбойный вход → None → degraded-honest путь, без падения.
|
нечисловой/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 None
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
def _as_float(value: Any) -> float | None:
|
def _as_float(value: Any) -> float | None:
|
||||||
"""Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE."""
|
"""Безопасно привести значение к 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 None
|
||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ import numpy as np
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.forecast_request_cache import cached
|
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.rate_sensitivity import Confidence, RateSensitivity, _delta
|
||||||
from app.services.forecasting.sales_series import (
|
from app.services.forecasting.sales_series import (
|
||||||
SegmentSpec,
|
SegmentSpec,
|
||||||
|
|
@ -672,7 +672,7 @@ def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity
|
||||||
|
|
||||||
|
|
||||||
def _fit_to_sensitivity(
|
def _fit_to_sensitivity(
|
||||||
fit: DistributedLagFit, *, segment: dict[str, str | None]
|
fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
|
||||||
) -> RateSensitivity:
|
) -> RateSensitivity:
|
||||||
"""Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.
|
"""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
|
• r2 / n_obs ← fit.r2 / fit.n
|
||||||
• confidence ← 'regression' → "medium" (gated-OK but advisory-grade) |
|
• confidence ← 'regression' → "medium" (gated-OK but advisory-grade) |
|
||||||
'fallback' → "low"
|
'fallback' → "low"
|
||||||
Source-B-only outputs (z_area_floor, most_sensitive_bucket, confounded,
|
• confounded ← passed in by the caller (computed from the ACTUAL fit window
|
||||||
shrinkage_weight) have no analogue in a district×class distributed-lag fit
|
via is_confounded_window — #1636). The §9.6 production path
|
||||||
(no room×area bucketing here) → None / sensible defaults. PURE.
|
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
|
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
|
Σ_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,
|
r2=fit.r2,
|
||||||
n_obs=fit.n,
|
n_obs=fit.n,
|
||||||
shrinkage_weight=0.0,
|
shrinkage_weight=0.0,
|
||||||
confounded=False,
|
confounded=confounded,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
phrase=fit.phrase,
|
phrase=fit.phrase,
|
||||||
)
|
)
|
||||||
|
|
@ -778,4 +785,32 @@ def compute_rate_regime_sensitivity(
|
||||||
)
|
)
|
||||||
return _insufficient_sensitivity(segment)
|
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:
|
if supply_layers is not None:
|
||||||
coverage = supply_layers.get("domrf_coverage")
|
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))
|
return _clamp_fraction(float(coverage))
|
||||||
pct = analyze.get("market_data_coverage_pct")
|
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 _clamp_fraction(float(pct) / 100.0)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -223,6 +223,19 @@ def _history_months(
|
||||||
return None
|
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:
|
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
|
||||||
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
|
"""Пересекает ли окно прогноза шок-период — для 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:
|
if primary is not None and primary.get("deficit_index") is not None:
|
||||||
di = primary["deficit_index"]
|
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:
|
for f in forecasts:
|
||||||
di = f.get("deficit_index")
|
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 float(di)
|
||||||
return None
|
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:
|
if primary is not None and primary.get("months_of_inventory") is not None:
|
||||||
moi = primary["months_of_inventory"]
|
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:
|
for f in forecasts:
|
||||||
moi = f.get("months_of_inventory")
|
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 float(moi)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -352,7 +365,7 @@ def _overall_score(product_scores: dict[str, Any] | None) -> float | None:
|
||||||
if not isinstance(product_scores, dict):
|
if not isinstance(product_scores, dict):
|
||||||
return None
|
return None
|
||||||
overall = product_scores.get("overall")
|
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 float(overall)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -398,14 +411,26 @@ def _market_now_summary(
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if market_metrics is not None:
|
if market_metrics is not None:
|
||||||
velocity = market_metrics.get("unit_velocity")
|
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)} ед./мес")
|
parts.append(f"абсорбция ~{round(float(velocity), 1)} ед./мес")
|
||||||
avg_price = analyze.get("market_avg_price_per_m2")
|
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(",", " "))
|
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
|
||||||
n_comp = _analog_count(analyze, market_metrics)
|
# #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
|
||||||
if n_comp is not None:
|
# всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
|
||||||
parts.append(f"{n_comp} ЖК-конкурентов рядом")
|
# Метка честно следует источнику: 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:
|
if not parts:
|
||||||
return None
|
return None
|
||||||
return "Текущий рынок: " + ", ".join(parts) + "."
|
return "Текущий рынок: " + ", ".join(parts) + "."
|
||||||
|
|
@ -616,6 +641,7 @@ def _build_confidence(
|
||||||
market_metrics, future_supply, forecasts, product_scores, special_indices
|
market_metrics, future_supply, forecasts, product_scores, special_indices
|
||||||
),
|
),
|
||||||
deal_count=_deal_count(analyze, market_metrics),
|
deal_count=_deal_count(analyze, market_metrics),
|
||||||
|
deal_count_months=_deal_count_months(market_metrics),
|
||||||
analog_count=_analog_count(analyze, market_metrics),
|
analog_count=_analog_count(analyze, market_metrics),
|
||||||
domrf_coverage=_domrf_coverage(analyze, supply_layers),
|
domrf_coverage=_domrf_coverage(analyze, supply_layers),
|
||||||
history_months=_history_months(market_metrics, forecasts),
|
history_months=_history_months(market_metrics, forecasts),
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,12 @@ _GEO_WEIGHT_SCALE_KM: float = 3.0
|
||||||
# НЕ исключаем проект и НЕ даём полный вес — низкий floor, чтобы он мог сигналить, но
|
# НЕ исключаем проект и НЕ даём полный вес — низкий floor, чтобы он мог сигналить, но
|
||||||
# не доминировал над проектом с подтверждённой близостью. None-not-0 дух (есть проект,
|
# не доминировал над проектом с подтверждённой близостью. None-not-0 дух (есть проект,
|
||||||
# но геопривязка слабая → down-weight, а не выкидываем).
|
# но геопривязка слабая → 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 (сигнал есть, но тонкий).
|
# надёжной. < этого (напр. только класс) → пара low-confidence (сигнал есть, но тонкий).
|
||||||
|
|
@ -1023,9 +1028,7 @@ def _launch_window_horizon(launch_window: SpecialIndex) -> int | None:
|
||||||
return horizon if isinstance(horizon, int) else None
|
return horizon if isinstance(horizon, int) else None
|
||||||
|
|
||||||
|
|
||||||
def _candidate_release_month(
|
def _candidate_release_month(launch_window: SpecialIndex, *, as_of: date) -> date | None:
|
||||||
launch_window: SpecialIndex, *, as_of: date
|
|
||||||
) -> date | None:
|
|
||||||
"""Когда рекомендованный проект реально выйдет на рынок (тайминг §25.3). PURE.
|
"""Когда рекомендованный проект реально выйдет на рынок (тайминг §25.3). PURE.
|
||||||
|
|
||||||
= as_of (дата отчёта) + горизонт окна запуска §25.1: на этот месяц #980 предсказывает
|
= as_of (дата отчёта) + горизонт окна запуска §25.1: на этот месяц #980 предсказывает
|
||||||
|
|
@ -1288,9 +1291,7 @@ def _build_cannibalization_true(
|
||||||
contributions: list[tuple[float, OwnProject, _PairOverlap]] = []
|
contributions: list[tuple[float, OwnProject, _PairOverlap]] = []
|
||||||
for own in portfolio:
|
for own in portfolio:
|
||||||
distance_km = _own_distance_km(centroid, own)
|
distance_km = _own_distance_km(centroid, own)
|
||||||
price_overlap = _price_overlap(
|
price_overlap = _price_overlap(candidate_band, own.price_min_per_m2, own.price_max_per_m2)
|
||||||
candidate_band, own.price_min_per_m2, own.price_max_per_m2
|
|
||||||
)
|
|
||||||
pair = _own_portfolio_overlap(
|
pair = _own_portfolio_overlap(
|
||||||
class_overlap=_class_overlap(spec.obj_class, own.obj_class),
|
class_overlap=_class_overlap(spec.obj_class, own.obj_class),
|
||||||
price_overlap=price_overlap,
|
price_overlap=price_overlap,
|
||||||
|
|
@ -1315,7 +1316,7 @@ def _build_cannibalization_true(
|
||||||
# Топ-вкладчики (сильнейшие каннибализаторы) — детерминированно по signal DESC,
|
# Топ-вкладчики (сильнейшие каннибализаторы) — детерминированно по signal DESC,
|
||||||
# tie-break по имени проекта (стабильно, без RNG/порядка set).
|
# tie-break по имени проекта (стабильно, без RNG/порядка set).
|
||||||
contributions.sort(key=lambda t: (-t[0], t[1].name))
|
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))
|
confidence = _cap_confidence(_portfolio_confidence(portfolio))
|
||||||
return SpecialIndex(
|
return SpecialIndex(
|
||||||
key=KEY_CANNIBALIZATION,
|
key=KEY_CANNIBALIZATION,
|
||||||
|
|
@ -1382,9 +1383,7 @@ def _axes_available_summary(pairs: Sequence[_PairOverlap]) -> dict[str, int]:
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def _contribution_detail(
|
def _contribution_detail(signal: float, own: OwnProject, pair: _PairOverlap) -> dict[str, Any]:
|
||||||
signal: float, own: OwnProject, pair: _PairOverlap
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Explainability-карточка одного топ-каннибализатора. PURE."""
|
"""Explainability-карточка одного топ-каннибализатора. PURE."""
|
||||||
return {
|
return {
|
||||||
"name": own.name,
|
"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}
|
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)
|
n_available = sum(1 for idx in indices.values() if idx.value is not None)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import logging
|
||||||
|
|
||||||
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
|
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
|
||||||
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
||||||
|
from ezdxf.enums import TextEntityAlignment
|
||||||
from ezdxf.filemanagement import new as ezdxf_new
|
from ezdxf.filemanagement import new as ezdxf_new
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
|
|
||||||
|
|
@ -43,13 +44,33 @@ _LAYER_BUILDINGS = "BUILDINGS"
|
||||||
_LABEL_HEIGHT_M = 2.0
|
_LABEL_HEIGHT_M = 2.0
|
||||||
|
|
||||||
|
|
||||||
def _polygon_points(poly: Polygon) -> list[tuple[float, float]]:
|
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||||||
"""Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки)."""
|
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||||
coords = list(poly.exterior.coords)
|
pts = list(coords)
|
||||||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||||||
if len(coords) > 1 and coords[0] == coords[-1]:
|
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||||
coords = coords[:-1]
|
pts = pts[:-1]
|
||||||
return [(float(x), float(y)) for x, y in coords]
|
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:
|
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()
|
msp = doc.modelspace()
|
||||||
|
|
||||||
# Участок и пятно застройки — из метрической геометрии Parcel.
|
# Участок и пятно застройки — из метрической геометрии Parcel.
|
||||||
msp.add_lwpolyline(
|
_add_polygon(msp, parcel.polygon_m, _LAYER_PARCEL)
|
||||||
_polygon_points(parcel.polygon_m),
|
_add_polygon(msp, parcel.buildable_m, _LAYER_BUILDABLE)
|
||||||
close=True,
|
|
||||||
dxfattribs={"layer": _LAYER_PARCEL},
|
|
||||||
)
|
|
||||||
msp.add_lwpolyline(
|
|
||||||
_polygon_points(parcel.buildable_m),
|
|
||||||
close=True,
|
|
||||||
dxfattribs={"layer": _LAYER_BUILDABLE},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
|
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
|
||||||
features = variant.buildings_geojson.get("features", [])
|
features = variant.buildings_geojson.get("features", [])
|
||||||
|
|
@ -91,18 +104,17 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
||||||
if footprint is None:
|
if footprint is None:
|
||||||
continue
|
continue
|
||||||
section_count += 1
|
section_count += 1
|
||||||
msp.add_lwpolyline(
|
_add_polygon(msp, footprint, _LAYER_BUILDINGS)
|
||||||
_polygon_points(footprint),
|
|
||||||
close=True,
|
|
||||||
dxfattribs={"layer": _LAYER_BUILDINGS},
|
|
||||||
)
|
|
||||||
centroid = footprint.centroid
|
centroid = footprint.centroid
|
||||||
label = str(_feature_section_id(feature, section_count))
|
label = str(_feature_section_id(feature, section_count))
|
||||||
text = msp.add_text(
|
text = msp.add_text(
|
||||||
label,
|
label,
|
||||||
dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M},
|
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()
|
stream = io.BytesIO()
|
||||||
doc.write(stream, fmt="bin")
|
doc.write(stream, fmt="bin")
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ from shapely.strtree import STRtree
|
||||||
|
|
||||||
from app.schemas.concept import ConceptInput, ConceptVariant
|
from app.schemas.concept import ConceptInput, ConceptVariant
|
||||||
from app.services.generative import financial, teap
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -191,11 +191,25 @@ def place_strategy(
|
||||||
parcel: Parcel,
|
parcel: Parcel,
|
||||||
payload: ConceptInput,
|
payload: ConceptInput,
|
||||||
spec: StrategySpec,
|
spec: StrategySpec,
|
||||||
) -> ConceptVariant:
|
) -> ConceptVariant | None:
|
||||||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant."""
|
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
|
||||||
|
|
||||||
|
Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
|
||||||
|
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
|
||||||
|
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный —
|
||||||
|
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
|
||||||
|
"""
|
||||||
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
|
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
|
||||||
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
|
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
|
||||||
footprints = _greedy_place(parcel, spec, 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(
|
teap_result = teap.compute_teap(
|
||||||
footprints=footprints,
|
footprints=footprints,
|
||||||
|
|
@ -220,8 +234,22 @@ def place_strategy(
|
||||||
|
|
||||||
|
|
||||||
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
|
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
|
||||||
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced)."""
|
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
|
||||||
variants = [place_strategy(parcel, payload, spec) for spec in _STRATEGIES]
|
|
||||||
|
Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают
|
||||||
|
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
|
||||||
|
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
|
||||||
|
: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(
|
logger.info(
|
||||||
"placed all strategies: %s",
|
"placed all strategies: %s",
|
||||||
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
|
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ class LLMResult:
|
||||||
fallback_used: True если результат — сигнал к детерминированному fallback.
|
fallback_used: True если результат — сигнал к детерминированному fallback.
|
||||||
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
|
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
|
||||||
redaction_refused/provider_error/call_cap/no_api_key). None при ok.
|
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).
|
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
|
||||||
model: модель, ответившая на запрос ("" при fallback).
|
model: модель, ответившая на запрос ("" при fallback).
|
||||||
"""
|
"""
|
||||||
|
|
@ -67,6 +71,7 @@ class LLMResult:
|
||||||
tool_calls: list[ToolCall] = field(default_factory=list)
|
tool_calls: list[ToolCall] = field(default_factory=list)
|
||||||
fallback_used: bool = False
|
fallback_used: bool = False
|
||||||
reason: str | None = None
|
reason: str | None = None
|
||||||
|
finish_reason: str | None = None
|
||||||
prompt_tokens: int = 0
|
prompt_tokens: int = 0
|
||||||
completion_tokens: int = 0
|
completion_tokens: int = 0
|
||||||
model: str = ""
|
model: str = ""
|
||||||
|
|
@ -81,6 +86,7 @@ class LLMResult:
|
||||||
ok=True,
|
ok=True,
|
||||||
content=resp.content,
|
content=resp.content,
|
||||||
tool_calls=list(resp.tool_calls),
|
tool_calls=list(resp.tool_calls),
|
||||||
|
finish_reason=resp.finish_reason,
|
||||||
prompt_tokens=resp.prompt_tokens,
|
prompt_tokens=resp.prompt_tokens,
|
||||||
completion_tokens=resp.completion_tokens,
|
completion_tokens=resp.completion_tokens,
|
||||||
model=resp.model,
|
model=resp.model,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,21 @@ class LLMProvider(ABC):
|
||||||
# ── OpenAI (external) ─────────────────────────────────────────────────────────
|
# ── 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:
|
def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
|
||||||
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
|
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
|
||||||
choices = data.get("choices") or []
|
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"),
|
content=message.get("content"),
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
finish_reason=choice.get("finish_reason"),
|
finish_reason=choice.get("finish_reason"),
|
||||||
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
prompt_tokens=_coerce_token_count(usage.get("prompt_tokens", 0)),
|
||||||
completion_tokens=int(usage.get("completion_tokens", 0) or 0),
|
completion_tokens=_coerce_token_count(usage.get("completion_tokens", 0)),
|
||||||
model=str(data.get("model") or fallback_model),
|
model=str(data.get("model") or fallback_model),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
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» + хотя бы одного разделителя.
|
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
|
||||||
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
|
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
|
||||||
_PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
|
_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.
|
||||||
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
|
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
|
||||||
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
|
# ИНН: требуется контекстный якорь — маркер «ИНН» / «inn» (регистронезависимо) не
|
||||||
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
|
# далее ~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 требует
|
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
|
||||||
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
|
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
|
||||||
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
|
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
|
||||||
|
|
@ -97,18 +156,22 @@ _FULLNAME_RE = re.compile(
|
||||||
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
|
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
|
||||||
)
|
)
|
||||||
|
|
||||||
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
|
# (regex, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
|
||||||
|
# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
|
||||||
|
# Применяются последовательно в этом порядке.
|
||||||
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
|
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
|
||||||
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
|
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
|
||||||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
_Repl = str | Callable[[re.Match[str]], str]
|
||||||
(_SNILS_RE, "snils"),
|
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
|
||||||
(_PASSPORT_RE, "passport"),
|
(_SNILS_RE, "snils", "[REDACTED:snils]"),
|
||||||
(_PHONE_RE, "phone"),
|
(_PASSPORT_RE, "passport", "[REDACTED:passport]"),
|
||||||
(_PHONE_BARE_RE, "phone"),
|
(_PHONE_RE, "phone", "[REDACTED:phone]"),
|
||||||
(_EMAIL_RE, "email"),
|
(_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
|
||||||
(_INN_RE, "inn"),
|
(_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
|
||||||
(_SNILS_BARE_RE, "snils"),
|
(_EMAIL_RE, "email", "[REDACTED:email]"),
|
||||||
(_FULLNAME_RE, "name"),
|
(_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:
|
if not value:
|
||||||
return value
|
return value
|
||||||
redacted = value
|
redacted = value
|
||||||
for pattern, kind in _PII_PATTERNS:
|
placeholder = "[REDACTED:%s]"
|
||||||
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
|
for pattern, kind, repl in _PII_PATTERNS:
|
||||||
if n:
|
# 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-значения.
|
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
|
||||||
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
|
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
|
||||||
return redacted
|
return redacted
|
||||||
|
|
@ -135,7 +205,7 @@ def _scrub_value(value: Any) -> Any:
|
||||||
return scrub_text(value)
|
return scrub_text(value)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {k: _scrub_value(v) for k, v in value.items()}
|
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]
|
scrubbed = [_scrub_value(v) for v in value]
|
||||||
return type(value)(scrubbed)
|
return type(value)(scrubbed)
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import logging
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from contextlib import closing
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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["size_bytes"] = st.st_size
|
||||||
info["modified_at"] = st.st_mtime # epoch seconds
|
info["modified_at"] = st.st_mtime # epoch seconds
|
||||||
try:
|
try:
|
||||||
c = sqlite3.connect(p)
|
with closing(sqlite3.connect(p)) as c:
|
||||||
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
|
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
|
||||||
info["corp_room_month"] = c.execute("SELECT COUNT(*) FROM objective_corp_month").fetchone()[
|
info["corp_room_month"] = c.execute(
|
||||||
0
|
"SELECT COUNT(*) FROM objective_corp_month"
|
||||||
]
|
).fetchone()[0]
|
||||||
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
|
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
|
||||||
c.close()
|
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
info["error"] = str(e)
|
info["error"] = str(e)
|
||||||
return info
|
return info
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ opens the original DOM.РФ URL (we don't need to mirror originals).
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
@ -36,21 +37,40 @@ def make_thumbnail(
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
"""Generate a WebP cover-thumbnail next to src. Returns thumb path on success.
|
"""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.
|
(logged) so callers can keep going with the next file.
|
||||||
"""
|
"""
|
||||||
if not src.exists():
|
if not src.exists():
|
||||||
return None
|
return None
|
||||||
dst = thumb_path_for(src)
|
dst = thumb_path_for(src)
|
||||||
if dst.exists() and not overwrite:
|
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:
|
try:
|
||||||
with Image.open(src) as im:
|
with Image.open(src) as im:
|
||||||
im = ImageOps.exif_transpose(im)
|
im = ImageOps.exif_transpose(im)
|
||||||
im = im.convert("RGB")
|
im = im.convert("RGB")
|
||||||
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
|
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
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
|
return dst
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("thumbnail %s failed: %s", src, e)
|
logger.warning("thumbnail %s failed: %s", src, e)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,38 @@ STATUS_FREE = "free"
|
||||||
STATUS_SOLD = "sold"
|
STATUS_SOLD = "sold"
|
||||||
STATUS_RESERVED = "reserved"
|
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 ─────────────────────────────────────────────────────────────
|
# ── HTML fetching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -157,6 +189,28 @@ class _TextCollector(HTMLParser):
|
||||||
extraction известных структур страницы каталога.
|
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:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._stack: list[tuple[str, dict[str, str]]] = []
|
self._stack: list[tuple[str, dict[str, str]]] = []
|
||||||
|
|
@ -165,11 +219,15 @@ class _TextCollector(HTMLParser):
|
||||||
self._buf: list[str] = []
|
self._buf: list[str] = []
|
||||||
|
|
||||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
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}
|
attr_dict = {k: (v or "") for k, v in attrs}
|
||||||
self._stack.append((tag, attr_dict))
|
self._stack.append((tag, attr_dict))
|
||||||
self._buf.append("") # начало нового буфера для этого тега
|
self._buf.append("") # начало нового буфера для этого тега
|
||||||
|
|
||||||
def handle_endtag(self, _tag: str) -> None:
|
def handle_endtag(self, _tag: str) -> None:
|
||||||
|
if _tag in self._VOID_TAGS:
|
||||||
|
return # void-теги фрейм не открывали — нечего поп'ать (issue #1608)
|
||||||
if not self._stack:
|
if not self._stack:
|
||||||
return
|
return
|
||||||
tag, attr_dict = self._stack.pop()
|
tag, attr_dict = self._stack.pop()
|
||||||
|
|
@ -239,8 +297,11 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
|
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
|
||||||
|
|
||||||
# Price: "7 890 000 ₽" или "7 890 000 руб"
|
# Price: "7 890 000 ₽" или "7 890 000 руб"
|
||||||
|
# Negative lookahead (?!\s*[/⁄]) исключает цену за м² ("217 835 ₽/м²"),
|
||||||
|
# которая на странице обычно выше полной стоимости и иначе матчилась бы
|
||||||
|
# первой → отбрасывалась санити-границей → price_rub=NULL (issue #1645).
|
||||||
price_match = re.search(
|
price_match = re.search(
|
||||||
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)",
|
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)(?!\s*[/⁄])",
|
||||||
html,
|
html,
|
||||||
re.UNICODE,
|
re.UNICODE,
|
||||||
)
|
)
|
||||||
|
|
@ -267,20 +328,66 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Status: ищем характерные слова рядом с "статус" или в badge
|
# Status extraction (issue #1609 fix).
|
||||||
status_match = re.search(
|
#
|
||||||
r"(в\s*продаже|свободна|free|продано|sold|забронирована|бронь|reserved)",
|
# Проблема: re.search по всему HTML находит "в продаже" из навигации
|
||||||
html,
|
# ("другие квартиры в продаже") РАНЬШЕ, чем реальный статус-бейдж →
|
||||||
re.IGNORECASE | re.UNICODE,
|
# проданная/забронированная квартира ошибочно маркируется как free.
|
||||||
)
|
#
|
||||||
if status_match:
|
# Стратегия (3 уровня, от точного к широкому):
|
||||||
s = status_match.group(1).lower()
|
#
|
||||||
if any(kw in s for kw in ("продаже", "свободна", "free")):
|
# 1. Блоки с CSS-классом, содержащим "status"/"badge"/"tag"/"chip"/"label" —
|
||||||
result["status"] = STATUS_FREE
|
# типовые имена для статус-бейджей в Next.js/Tailwind.
|
||||||
elif any(kw in s for kw in ("продано", "sold")):
|
# 2. Ближайший блок ПОСЛЕ лейбла "Статус" (proximity-поиск в blocks).
|
||||||
result["status"] = STATUS_SOLD
|
# 3. Полный перебор всех blocks: sold/reserved побеждают free
|
||||||
elif any(kw in s for kw in ("бронь", "забронирована", "reserved")):
|
# (они никогда не появляются в навигации, в отличие от "в продаже").
|
||||||
result["status"] = STATUS_RESERVED
|
#
|
||||||
|
# Морфоварианты: продан / продана / продано / продан → продан[аоы]?
|
||||||
|
# Аналогично: забронирован[аоы]? / реализован[аоы]? (синоним для 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 type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
|
||||||
finishing_match = re.search(
|
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["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)
|
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
|
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(
|
logger.info(
|
||||||
"catalog scrape ods_id=%s: fields=%d updated=%s",
|
"catalog scrape ods_id=%s: fields=%d updated=%s",
|
||||||
ods_id,
|
ods_id,
|
||||||
|
|
@ -512,11 +630,15 @@ async def scrape_catalog_batch(
|
||||||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{total, success, failed, fields_total}
|
{total, success, updated, failed, fields_total}
|
||||||
|
- success: прошли fetch+parse без исключения
|
||||||
|
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||||
|
фактическое число записанных квартир, в отличие от success
|
||||||
"""
|
"""
|
||||||
stats: dict[str, Any] = {
|
stats: dict[str, Any] = {
|
||||||
"total": len(flats),
|
"total": len(flats),
|
||||||
"success": 0,
|
"success": 0,
|
||||||
|
"updated": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"fields_total": 0,
|
"fields_total": 0,
|
||||||
}
|
}
|
||||||
|
|
@ -550,6 +672,8 @@ async def scrape_catalog_batch(
|
||||||
if outcome["success"]:
|
if outcome["success"]:
|
||||||
stats["success"] += 1
|
stats["success"] += 1
|
||||||
stats["fields_total"] += outcome["fields_extracted"]
|
stats["fields_total"] += outcome["fields_extracted"]
|
||||||
|
if outcome["updated"]:
|
||||||
|
stats["updated"] += 1
|
||||||
else:
|
else:
|
||||||
stats["failed"] += 1
|
stats["failed"] += 1
|
||||||
|
|
||||||
|
|
@ -565,9 +689,10 @@ async def scrape_catalog_batch(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info(
|
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["total"],
|
||||||
stats["success"],
|
stats["success"],
|
||||||
|
stats["updated"],
|
||||||
stats["failed"],
|
stats["failed"],
|
||||||
stats["fields_total"],
|
stats["fields_total"],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,30 @@ def _to_int(v: Any) -> int | None:
|
||||||
return 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:
|
def _to_date(v: Any) -> date | None:
|
||||||
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
|
"""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'.
|
'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.
|
# 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.
|
# 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:
|
# Coerce оба операнда в float ДО сравнения/деления: API иногда отдаёт totalArea/price
|
||||||
price_per_m2 = round(price_rub / total_area, 2)
|
# строкой ('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(
|
logger.info(
|
||||||
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
|
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
|
||||||
price_per_m2,
|
price_per_m2,
|
||||||
|
|
@ -1432,34 +1461,78 @@ def _place_str(region_code: int) -> str:
|
||||||
return str(region_code)
|
return str(region_code)
|
||||||
|
|
||||||
|
|
||||||
|
OBJECTS_PAGE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
async def fetch_objects_for_status(
|
async def fetch_objects_for_status(
|
||||||
sess: BrowserSession, place: str, status: int
|
sess: BrowserSession, place: str, status: int
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Fetch ALL objects for a given (place, objStatus) — server returns up to limit=999999."""
|
"""Fetch ALL objects for a given (place, objStatus), пагинируя по страницам.
|
||||||
payload = await sess.get_json(
|
|
||||||
PATH_OBJECTS,
|
Раньше делали единственный запрос с limit=999999 и доверяли допущению, что сервер
|
||||||
{
|
вернёт всё. Если WAF/прокси DOM.РФ обрезает гигантский limit до своего max, хвост
|
||||||
"offset": 0,
|
объектов молча терялся (не скрейпился), а run всё равно рапортовал status='done' (#1605).
|
||||||
"limit": 999999,
|
Теперь идём страницами по OBJECTS_PAGE_SIZE и аккумулируем, пока не наберём total
|
||||||
"sortField": "default",
|
(из payload) либо страница не вернётся короче запрошенной / пустой.
|
||||||
"sortType": "desc",
|
"""
|
||||||
"place": place,
|
rows: list[dict[str, Any]] = []
|
||||||
"objStatus": status,
|
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)
|
if total is not None and len(rows) < total:
|
||||||
total = _extract_total(payload)
|
logger.warning(
|
||||||
logger.info("kn/object place=%s status=%d -> %d/%s rows", place, status, len(rows), total)
|
"kn/object place=%s status=%d: получено %d < total=%d — возможен недобор хвоста",
|
||||||
|
place,
|
||||||
|
status,
|
||||||
|
len(rows),
|
||||||
|
total,
|
||||||
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict[str, Any]]:
|
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)."""
|
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened).
|
||||||
try:
|
|
||||||
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
|
On HTTP / WAF / non-JSON errors raises — caller (_fetch_flats_safe) ловит и кладёт
|
||||||
except Exception as e:
|
Exception в result-tuple, который result-loop отдаёт в _classify_and_log →
|
||||||
logger.warning("flats fetch obj=%s failed: %s", obj_id, e)
|
kn_scrape_failures. Раньше try/except здесь возвращал [] на ЛЮБУЮ ошибку, из-за чего
|
||||||
return []
|
провал /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:[...]}]}]}
|
# Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]}
|
||||||
return _flatten_table(payload)
|
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:
|
def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] | None:
|
||||||
|
|
@ -375,26 +375,28 @@ class EkburgPermitsClient:
|
||||||
Пропускает листы «Справочник», «Лист1» и неизвестные.
|
Пропускает листы «Справочник», «Лист1» и неизвестные.
|
||||||
"""
|
"""
|
||||||
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
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:
|
permit_type = _detect_permit_type(sheet_name)
|
||||||
if sheet_name.lower() in _SKIP_SHEETS:
|
if permit_type is None:
|
||||||
continue
|
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
|
||||||
|
continue
|
||||||
|
|
||||||
permit_type = _detect_permit_type(sheet_name)
|
sheet = wb[sheet_name]
|
||||||
if permit_type is None:
|
data_start = _detect_header_row(sheet)
|
||||||
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
|
logger.info(
|
||||||
continue
|
"Parsing sheet %r (%s) year=%d, data starts at row %d",
|
||||||
|
sheet_name,
|
||||||
sheet = wb[sheet_name]
|
permit_type,
|
||||||
data_start = _detect_header_row(sheet)
|
year,
|
||||||
logger.info(
|
data_start,
|
||||||
"Parsing sheet %r (%s) year=%d, data starts at row %d",
|
)
|
||||||
sheet_name,
|
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
|
||||||
permit_type,
|
finally:
|
||||||
year,
|
wb.close()
|
||||||
data_start,
|
|
||||||
)
|
|
||||||
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
|
|
||||||
|
|
||||||
def _parse_sheet(
|
def _parse_sheet(
|
||||||
self,
|
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 limit (мс между запросами) — баланс между скоростью и WAF
|
||||||
DEFAULT_RATE_MS = 600
|
DEFAULT_RATE_MS = 600
|
||||||
|
|
||||||
|
|
@ -612,6 +651,58 @@ class NSPDClient:
|
||||||
)
|
)
|
||||||
return deduped
|
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 ──────────────────────────────────────────────────────
|
# ── 4. list_layers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]:
|
def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]:
|
||||||
|
|
@ -924,6 +1015,7 @@ __all__ = [
|
||||||
"LAYERS",
|
"LAYERS",
|
||||||
"NSPD_THEMES",
|
"NSPD_THEMES",
|
||||||
"NSPD_WMS_BASE",
|
"NSPD_WMS_BASE",
|
||||||
|
"RIASURT_SVERDL_LAYERS",
|
||||||
"THEME_ARN",
|
"THEME_ARN",
|
||||||
"THEME_PKK",
|
"THEME_PKK",
|
||||||
"NSPDClient",
|
"NSPDClient",
|
||||||
|
|
@ -935,4 +1027,5 @@ __all__ = [
|
||||||
"QuarterDump",
|
"QuarterDump",
|
||||||
"bbox_around_point_m",
|
"bbox_around_point_m",
|
||||||
"lonlat_to_3857",
|
"lonlat_to_3857",
|
||||||
|
"riasurt_layer_topic",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -329,25 +329,37 @@ def denorm_dump(
|
||||||
features: плоский list из features_json JSONB (уже декодированный Python list).
|
features: плоский list из features_json JSONB (уже декодированный Python list).
|
||||||
|
|
||||||
Returns:
|
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()
|
snapshot_date = datetime.date.today().isoformat()
|
||||||
parcels_n = 0
|
parcels_n = 0
|
||||||
buildings_n = 0
|
buildings_n = 0
|
||||||
errors_n = 0
|
errors_n = 0
|
||||||
|
skipped_n = 0
|
||||||
|
|
||||||
for feat in features:
|
for feat in features:
|
||||||
layer = feat.get("layer", "")
|
layer = feat.get("layer", "")
|
||||||
try:
|
try:
|
||||||
if layer == "parcels":
|
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
|
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||||
):
|
):
|
||||||
parcels_n += 1
|
parcels_n += 1
|
||||||
else:
|
else:
|
||||||
errors_n += 1
|
errors_n += 1
|
||||||
elif layer == "buildings":
|
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
|
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||||
):
|
):
|
||||||
buildings_n += 1
|
buildings_n += 1
|
||||||
|
|
@ -360,10 +372,16 @@ def denorm_dump(
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(
|
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,
|
quarter_cad,
|
||||||
parcels_n,
|
parcels_n,
|
||||||
buildings_n,
|
buildings_n,
|
||||||
errors_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:
|
try:
|
||||||
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
|
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
|
||||||
body = r.read().decode("utf-8", "ignore")
|
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:
|
except urllib.error.HTTPError as e:
|
||||||
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
|
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
|
||||||
if e.code in (403, 429):
|
if e.code in (403, 429):
|
||||||
|
|
@ -162,6 +172,12 @@ def fetch_via_rosreestr2coord(
|
||||||
_ = delay # silence unused — см. docstring выше
|
_ = delay # silence unused — см. docstring выше
|
||||||
try:
|
try:
|
||||||
from rosreestr2coord.parser import Area
|
from rosreestr2coord.parser import Area
|
||||||
|
from rosreestr2coord.request.exceptions import (
|
||||||
|
HTTPErrorException,
|
||||||
|
HTTPForbiddenException,
|
||||||
|
RequestException,
|
||||||
|
TimeoutException,
|
||||||
|
)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
|
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
|
||||||
|
|
||||||
|
|
@ -170,19 +186,33 @@ def fetch_via_rosreestr2coord(
|
||||||
# denied при первом fetch.
|
# denied при первом fetch.
|
||||||
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
|
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
|
||||||
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
|
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
|
||||||
a = Area(
|
#
|
||||||
code=cad_num,
|
# NB: with_log=False → конструктор Area() сразу делает HTTP-запрос
|
||||||
area_type=area_type,
|
# (get_geometry без try/except), поэтому WAF/сетевые исключения летят уже
|
||||||
timeout=timeout,
|
# отсюда, а не из to_geojson_poly(). Оба вызова под одним try.
|
||||||
with_log=False,
|
|
||||||
use_cache=False,
|
|
||||||
media_path="/tmp/rosreestr2coord",
|
|
||||||
)
|
|
||||||
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-сериализованную
|
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
|
||||||
# строку. Default в v5 = True → строка → крах в `_persist_target` который
|
# строку. Default в v5 = True → строка → крах в `_persist_target` который
|
||||||
# ожидает dict с `.get("properties")` etc.
|
# ожидает dict с `.get("properties")` etc.
|
||||||
return a.to_geojson_poly(dumps=False)
|
return a.to_geojson_poly(dumps=False)
|
||||||
except Exception as e:
|
except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
|
||||||
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
|
# WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
|
||||||
return None
|
# 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"],
|
"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(
|
_UPSERT_CHECKS_SQL = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
|
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():
|
for field, value in data.items():
|
||||||
ct = _CHECK_FIELD_MAP.get(field)
|
ct = _CHECK_FIELD_MAP.get(field)
|
||||||
if ct and ct not in found:
|
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 напрямую
|
# Также проверить canonical names напрямую
|
||||||
for ct in CHECK_TYPES:
|
for ct in CHECK_TYPES:
|
||||||
if ct not in found and ct in data:
|
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:
|
if found:
|
||||||
|
# Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
|
||||||
|
# а не FAILED — не фабрикуем passed=False для непришедших проверок.
|
||||||
for ct in CHECK_TYPES:
|
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
|
return results
|
||||||
# dict не содержит известных полей — попробуем как list-формат ниже
|
# dict не содержит известных полей — попробуем как list-формат ниже
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -128,11 +164,24 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
|
||||||
continue
|
continue
|
||||||
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
|
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
|
||||||
if ct_raw and str(ct_raw) in CHECK_TYPES:
|
if ct_raw and str(ct_raw) in CHECK_TYPES:
|
||||||
passed_raw = item.get("passed") or item.get("value") or item.get("status")
|
# Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
|
||||||
found_list[str(ct_raw)] = bool(passed_raw)
|
# Берём первый ключ, который реально присутствует в 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:
|
if found_list:
|
||||||
|
# См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
|
||||||
for ct in CHECK_TYPES:
|
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
|
return results
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]
|
"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()
|
raw = (raw or "").strip()
|
||||||
if not raw or raw in {"-", "—", "…", "..."}:
|
if not raw or raw in {"-", "—", "…", "..."}:
|
||||||
return None
|
return None
|
||||||
cleaned = (
|
cleaned = raw.replace(" ", "").replace(" ", "").replace(" ", "").replace(",", ".")
|
||||||
raw.replace(" ", "").replace(" ", "").replace(" ", "").replace(",", ".")
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
return Decimal(cleaned)
|
return Decimal(cleaned)
|
||||||
except (InvalidOperation, ValueError):
|
except (InvalidOperation, ValueError):
|
||||||
|
|
@ -399,9 +397,7 @@ def parse_dataset_rows(
|
||||||
# ── HTTP fetch (thin) ───────────────────────────────────────────────────────────
|
# ── HTTP fetch (thin) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _http_get(
|
def _http_get(client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES) -> bytes:
|
||||||
client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES
|
|
||||||
) -> bytes:
|
|
||||||
"""GET с ретраями (сеть / 5xx) → байты тела. Module-level для monkeypatch в тестах.
|
"""GET с ретраями (сеть / 5xx) → байты тела. Module-level для monkeypatch в тестах.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
|
@ -622,9 +618,7 @@ _MONTH_NAME_TO_NUM: dict[str, int] = {
|
||||||
"декабрь": 12,
|
"декабрь": 12,
|
||||||
}
|
}
|
||||||
# Годовой под-период (некоторые индикаторы): «значение показателя за год», «год».
|
# Годовой под-период (некоторые индикаторы): «значение показателя за год», «год».
|
||||||
_YEAR_PERIODS: frozenset[str] = frozenset(
|
_YEAR_PERIODS: frozenset[str] = frozenset({"год", "значение показателя за год", "за год"})
|
||||||
{"год", "значение показателя за год", "за год"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -673,7 +667,14 @@ class EmissRow:
|
||||||
"""Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss').
|
"""Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss').
|
||||||
|
|
||||||
Отдельно от ``MacroRow`` (open-data, source='rosstat', yearly): ЕМИСС-ряды несут
|
Отдельно от ``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
|
indicator_type: str
|
||||||
|
|
@ -683,6 +684,7 @@ class EmissRow:
|
||||||
unit: str
|
unit: str
|
||||||
frequency: str
|
frequency: str
|
||||||
comment: str
|
comment: str
|
||||||
|
period_type: str = "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _emiss_period_to_month(period: str) -> int | None:
|
def _emiss_period_to_month(period: str) -> int | None:
|
||||||
|
|
@ -704,6 +706,25 @@ def _emiss_period_to_month(period: str) -> int | None:
|
||||||
return 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]]:
|
def _series_concepts(series: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
|
||||||
"""Извлечь (SeriesKey, Attributes) concept→value словари из <generic:Series>.
|
"""Извлечь (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) → стартовый месяц
|
не среди spec.targets — ряд пропускается. PERIOD (Attributes) → стартовый месяц
|
||||||
под-периода; год — из <Obs><Time>; obs_date = date(year, month, 1). Значение —
|
под-периода; год — из <Obs><Time>; obs_date = date(year, month, 1). Значение —
|
||||||
из <Obs><ObsValue value=...> через _parse_value (запятая-десятичный, разделители).
|
из <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 []
|
return []
|
||||||
|
|
||||||
target_by_okato = {t.oktmo: t for t in spec.targets}
|
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):
|
for series in dataset.findall("g:Series", _SDMX_NS):
|
||||||
key, attrs = _series_concepts(series)
|
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)
|
target = target_by_okato.get(okato)
|
||||||
if target is None:
|
if target is None:
|
||||||
continue
|
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:
|
if month is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"EMISS %s: неизвестный PERIOD %r (ОКАТО %s) — пропуск",
|
"EMISS %s: неизвестный PERIOD %r (ОКАТО %s) — пропуск",
|
||||||
spec.indicator_id,
|
spec.indicator_id,
|
||||||
attrs.get("PERIOD"),
|
period_raw,
|
||||||
okato,
|
okato,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
granularity = _emiss_period_granularity(period_raw)
|
||||||
for obs in series.findall("g:Obs", _SDMX_NS):
|
for obs in series.findall("g:Obs", _SDMX_NS):
|
||||||
time_el = obs.find("g:Time", _SDMX_NS)
|
time_el = obs.find("g:Time", _SDMX_NS)
|
||||||
val_el = obs.find("g:ObsValue", _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:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
obs_date = date(int(yr), month, 1)
|
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,
|
indicator_type=spec.indicator_type,
|
||||||
region=target.region,
|
region=target.region,
|
||||||
obs_date=obs_date,
|
obs_date=obs_date,
|
||||||
|
|
@ -796,6 +825,7 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
|
||||||
unit=spec.unit,
|
unit=spec.unit,
|
||||||
frequency=spec.frequency,
|
frequency=spec.frequency,
|
||||||
comment=spec.comment,
|
comment=spec.comment,
|
||||||
|
period_type=granularity,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_construction_index_xlsx(
|
def parse_construction_index_xlsx(content: bytes, spec: XlsxIndicatorSpec) -> list[MacroRow]:
|
||||||
content: bytes, spec: XlsxIndicatorSpec
|
|
||||||
) -> list[MacroRow]:
|
|
||||||
"""Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow.
|
"""Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow.
|
||||||
|
|
||||||
Алгоритм (форма листа — см. блок выше):
|
Алгоритм (форма листа — см. блок выше):
|
||||||
|
|
|
||||||
|
|
@ -227,13 +227,14 @@ class BrowserSession:
|
||||||
"""
|
"""
|
||||||
if self._context is None:
|
if self._context is None:
|
||||||
raise RuntimeError("BrowserSession not bootstrapped")
|
raise RuntimeError("BrowserSession not bootstrapped")
|
||||||
await jitter_sleep(200, 500) # Lighter throttle for static assets.
|
async with self._sem:
|
||||||
self._request_count += 1
|
await jitter_sleep(200, 500) # Lighter throttle for static assets.
|
||||||
resp = await self._context.request.get(
|
self._request_count += 1
|
||||||
url,
|
resp = await self._context.request.get(
|
||||||
headers={"Authorization": self.auth} if self.auth else {},
|
url,
|
||||||
)
|
headers={"Authorization": self.auth} if self.auth else {},
|
||||||
if resp.status != 200:
|
)
|
||||||
body = await resp.text()
|
if resp.status != 200:
|
||||||
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
|
body = await resp.text()
|
||||||
return await resp.body()
|
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
|
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
|
||||||
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
|
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
|
||||||
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
|
# непустые 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("""
|
_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
|
SELECT
|
||||||
cm.domrf_obj_id AS obj_id,
|
p.obj_id,
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_per_m2_rub)
|
||||||
AS median_price_per_m2
|
AS median_price_per_m2
|
||||||
FROM objective_complex_mapping cm
|
FROM (
|
||||||
JOIN objective_lots ol
|
SELECT obj_id, price_per_m2_rub FROM primary_price
|
||||||
ON ol.project_name = cm.objective_complex_name
|
UNION ALL
|
||||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
SELECT obj_id, price_per_m2_rub FROM gapfill_price
|
||||||
AND ol.price_per_m2_rub IS NOT NULL
|
) p
|
||||||
GROUP BY cm.domrf_obj_id
|
GROUP BY p.obj_id
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -648,7 +713,13 @@ def get_competitors(
|
||||||
if missing_price_ids:
|
if missing_price_ids:
|
||||||
try:
|
try:
|
||||||
obj_price_rows = (
|
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()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Pure function — no DB dependencies, consumes already-fetched data from analyze
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Literal, TypedDict
|
from typing import Literal, TypedDict
|
||||||
|
|
||||||
from app.services.site_finder.network_obremenenie import (
|
from app.services.site_finder.network_obremenenie import (
|
||||||
|
|
@ -16,11 +17,28 @@ from app.services.site_finder.network_obremenenie import (
|
||||||
|
|
||||||
# ── Zone matching ─────────────────────────────────────────────────────────────
|
# ── Zone matching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5)
|
# Regex для zone_code из PKK6 / pzz_zones_ekb: "Ж", "Ж-1", "Жс", "ЖС-2" и т.д.
|
||||||
RESIDENTIAL_ZONE_PREFIXES = ("Ж-", "Ж1", "Ж2", "Ж3", "Ж4", "Ж5")
|
# Кейс-инсенситив для защиты от "ж-1" в нестандартных источниках.
|
||||||
# Fallback по zone_name (lowercase substring)
|
# 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 = ("жил",)
|
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 ────────────────────────────────────────────────────────────
|
# ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода).
|
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода).
|
||||||
|
|
@ -45,6 +63,11 @@ WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-з
|
||||||
# Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py
|
# Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py
|
||||||
ENGINEERING_NEARBY_THRESHOLD_M = 200
|
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 ────────────────────────────────────────────────────────────────
|
# ── TypedDicts ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -71,19 +94,46 @@ class GateVerdict(TypedDict):
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── 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.
|
"""Check if ПЗЗ zone permits residential (МКД) building.
|
||||||
|
|
||||||
Checks zone_code prefix first (exact ПЗЗ codes), then zone_name lowercase
|
Detection strategy (in priority order):
|
||||||
substring match as fallback for non-standard zone labels.
|
|
||||||
|
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:
|
# 1. PKK6-style zone_code: starts with Ж (case-insensitive)
|
||||||
if any(zone_code.startswith(p) for p in RESIDENTIAL_ZONE_PREFIXES):
|
if zone_code and _RESIDENTIAL_CODE_RE.match(zone_code):
|
||||||
return True
|
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:
|
if zone_name:
|
||||||
zn_lower = zone_name.lower()
|
zn_lower = zone_name.lower()
|
||||||
if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS):
|
if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -134,7 +184,11 @@ def compute_gate_verdict(
|
||||||
# Check 1 — ПЗЗ residential zone
|
# Check 1 — ПЗЗ residential zone
|
||||||
checks.append("ПЗЗ зональность")
|
checks.append("ПЗЗ зональность")
|
||||||
if nspd_zoning:
|
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 = (
|
zone_label = (
|
||||||
nspd_zoning.get("zone_name") or nspd_zoning.get("zone_code") or "неизвестна"
|
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"]
|
can_build: bool | Literal["unknown"]
|
||||||
if blockers:
|
if blockers:
|
||||||
can_build = False
|
can_build = False
|
||||||
label = "Нельзя"
|
label = "Нельзя"
|
||||||
elif warnings:
|
elif verdict_relevant_warnings:
|
||||||
can_build = True
|
can_build = True
|
||||||
label = "С ограничениями"
|
label = "С ограничениями"
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
||||||
Returns:
|
Returns:
|
||||||
list[{project_type, doc_status_name, is_active, paga_number, paga_date,
|
list[{project_type, doc_status_name, is_active, paga_number, paga_date,
|
||||||
subject, doc_full_name}].
|
subject, doc_full_name}].
|
||||||
- ``is_active``: doc_status_name == 'Действующий'.
|
- ``is_active``: doc_status_name == 'действующий' (регистронезависимо).
|
||||||
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
|
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
|
||||||
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
|
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
|
||||||
Отсортировано: сначала действующие, внутри группы — по paga_date desc (свежие первыми).
|
Отсортировано: сначала действующие, внутри группы — по paga_date desc (свежие первыми).
|
||||||
|
|
@ -97,7 +97,10 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
full_name: str | None = r["full_name"]
|
full_name: str | None = r["full_name"]
|
||||||
paga_number, paga_date, subject = _parse_paga(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(
|
result.append(
|
||||||
{
|
{
|
||||||
"project_type": r["project_type"],
|
"project_type": r["project_type"],
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ def _market_metrics_key(
|
||||||
premise_kind,
|
premise_kind,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
Confidence = Literal["high", "medium", "low"]
|
Confidence = Literal["high", "medium", "low"]
|
||||||
|
|
||||||
# Лот считается «зависшим» (overstock), если он в продаже дольше этого числа
|
# Лот считается «зависшим» (overstock), если он в продаже дольше этого числа
|
||||||
|
|
@ -111,6 +112,9 @@ class MarketMetrics:
|
||||||
demand_concentration: float | None # Херфиндаль продаж по комнатности (0..1)
|
demand_concentration: float | None # Херфиндаль продаж по комнатности (0..1)
|
||||||
price_sensitivity: float | None # эластичность цена↔спрос (slope, обычно < 0)
|
price_sensitivity: float | None # эластичность цена↔спрос (slope, обычно < 0)
|
||||||
price_sensitivity_source: str | None # 'regression' | 'fallback' | None
|
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]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -136,6 +140,8 @@ class MarketMetrics:
|
||||||
"demand_concentration": _round_or_none(self.demand_concentration, 3),
|
"demand_concentration": _round_or_none(self.demand_concentration, 3),
|
||||||
"price_sensitivity": _round_or_none(self.price_sensitivity, 4),
|
"price_sensitivity": _round_or_none(self.price_sensitivity, 4),
|
||||||
"price_sensitivity_source": self.price_sensitivity_source,
|
"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)
|
liquidity = _liquidity_index(sold_by_room)
|
||||||
overstock = _overstock_index(n_long_unsold, n_available)
|
overstock = _overstock_index(n_long_unsold, n_available)
|
||||||
demand_conc = _demand_concentration(sold_by_room)
|
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 — reuse analytics_queries._elasticity_coef ───────────
|
||||||
price_sensitivity, price_sensitivity_source = _price_sensitivity(
|
price_sensitivity, price_sensitivity_source = _price_sensitivity(
|
||||||
|
|
@ -450,6 +465,7 @@ def compute_market_metrics(
|
||||||
demand_concentration=demand_conc,
|
demand_concentration=demand_conc,
|
||||||
price_sensitivity=price_sensitivity,
|
price_sensitivity=price_sensitivity,
|
||||||
price_sensitivity_source=price_sensitivity_source,
|
price_sensitivity_source=price_sensitivity_source,
|
||||||
|
velocity_by_room=vel_by_room,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,21 @@ CATEGORY_WEIGHTS: dict[str, float] = {
|
||||||
"default": 1.0,
|
"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):
|
class PoiScoreItem(BaseModel):
|
||||||
"""Один POI в ranked-ответе."""
|
"""Один POI в ranked-ответе."""
|
||||||
|
|
@ -52,12 +67,17 @@ class PoiScoreItem(BaseModel):
|
||||||
category: str
|
category: str
|
||||||
distance_m: float
|
distance_m: float
|
||||||
weight: float
|
weight: float
|
||||||
|
# Вклад данного POI в суммарный балл, в процентах от 0..100.
|
||||||
|
# Позволяет фронтенду рендерить «долю» без знания формулы нормировки.
|
||||||
|
score_contribution: float
|
||||||
address: str | None
|
address: str | None
|
||||||
|
|
||||||
|
|
||||||
class PoiScoreResponse(BaseModel):
|
class PoiScoreResponse(BaseModel):
|
||||||
cad_num: str
|
cad_num: str
|
||||||
radius_m: int
|
radius_m: int
|
||||||
|
# Суммарный взвешенный балл инфраструктуры, нормированный в диапазон 0..100.
|
||||||
|
poi_weighted_score: float
|
||||||
top_poi: list[PoiScoreItem]
|
top_poi: list[PoiScoreItem]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -155,6 +175,7 @@ def compute_poi_weighted_top7(
|
||||||
category=category,
|
category=category,
|
||||||
distance_m=round(distance_m, 1),
|
distance_m=round(distance_m, 1),
|
||||||
weight=round(weight, 6),
|
weight=round(weight, 6),
|
||||||
|
score_contribution=0.0, # заполним после нормировки
|
||||||
address=address,
|
address=address,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -163,9 +184,18 @@ def compute_poi_weighted_top7(
|
||||||
items.sort(key=lambda x: x.weight, reverse=True)
|
items.sort(key=lambda x: x.weight, reverse=True)
|
||||||
top_items = items[:top_n]
|
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(
|
return PoiScoreResponse(
|
||||||
cad_num=cad_num,
|
cad_num=cad_num,
|
||||||
radius_m=radius_m,
|
radius_m=radius_m,
|
||||||
|
poi_weighted_score=poi_weighted_score,
|
||||||
top_poi=top_items,
|
top_poi=top_items,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -303,7 +333,9 @@ def compute_poi_routing_decay(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not rows:
|
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]
|
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]
|
times_min: list[float | None]
|
||||||
routing_used = False
|
routing_used = False
|
||||||
try:
|
try:
|
||||||
times_min = ors_client.matrix_durations_min(
|
times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile)
|
||||||
lon, lat, destinations, profile=profile
|
|
||||||
)
|
|
||||||
routing_used = True
|
routing_used = True
|
||||||
except ors_client.OrsUnavailableError as exc:
|
except ors_client.OrsUnavailableError as exc:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -355,13 +385,25 @@ def compute_poi_routing_decay(
|
||||||
category=category,
|
category=category,
|
||||||
distance_m=round(distance_m, 1),
|
distance_m=round(distance_m, 1),
|
||||||
weight=round(weight, 6),
|
weight=round(weight, 6),
|
||||||
|
score_contribution=0.0, # заполним после нормировки
|
||||||
address=address,
|
address=address,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
items.sort(key=lambda x: x.weight, reverse=True)
|
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(
|
return PoiScoreResponse(
|
||||||
cad_num=cad_num,
|
cad_num=cad_num,
|
||||||
radius_m=radius_m,
|
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
|
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
|
||||||
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
|
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
|
||||||
1) ``doc_ref = source_key::text`` (прямое равенство);
|
1) ``doc_ref = CAST(source_key AS text)`` (прямое равенство);
|
||||||
2) ``doc_ref ILIKE '%doc_full_name%'`` (fallback для slug-ов вида ppt2018_22823).
|
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.
|
Граф вызова: build_ird_analyze_block → parcel_ppt_tep → JOIN planning_projects ∩ ekb_ppt_tep.
|
||||||
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
|
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
|
||||||
|
|
@ -33,26 +39,49 @@ logger = logging.getLogger(__name__)
|
||||||
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
|
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
|
||||||
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
|
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
|
||||||
_PPT_TEP_OVERLAP_SQL = text(
|
_PPT_TEP_OVERLAP_SQL = text(
|
||||||
"""
|
r"""
|
||||||
SELECT
|
SELECT
|
||||||
pp.project_type,
|
project_type,
|
||||||
pp.doc_status_name,
|
doc_status_name,
|
||||||
pp.full_name,
|
full_name,
|
||||||
pp.doc_full_name,
|
doc_full_name,
|
||||||
pp.source_key,
|
source_key,
|
||||||
t.doc_ref,
|
doc_ref,
|
||||||
t.zone_balance,
|
zone_balance,
|
||||||
t.tep,
|
tep,
|
||||||
t.phasing,
|
phasing,
|
||||||
t.source_pdf,
|
source_pdf,
|
||||||
t.fetched_at
|
fetched_at
|
||||||
FROM planning_projects pp
|
FROM (
|
||||||
JOIN ekb_ppt_tep t ON (
|
-- DISTINCT ON (source_key): один ППТ/ПМТ-документ = одна строка (контракт docstring).
|
||||||
t.doc_ref = CAST(pp.source_key AS text)
|
-- Точная ветка (doc_ref = source_key) приоритетна над slug-fallback (ILIKE).
|
||||||
OR (pp.doc_full_name IS NOT NULL AND t.doc_ref ILIKE '%' || pp.doc_full_name || '%')
|
SELECT DISTINCT ON (pp.source_key)
|
||||||
)
|
pp.project_type,
|
||||||
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
|
pp.doc_status_name,
|
||||||
ORDER BY pp.dmd_actual_year DESC NULLS LAST, pp.project_type
|
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).
|
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
|
||||||
# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает
|
# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
|
||||||
# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по
|
# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
|
||||||
# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на
|
# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
|
||||||
# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно
|
# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
|
||||||
# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка
|
# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
|
||||||
# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см.
|
# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
|
||||||
# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
|
||||||
|
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
|
||||||
|
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
|
||||||
|
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
||||||
_RESOLVE_CAD_SQL = text("""
|
_RESOLVE_CAD_SQL = text("""
|
||||||
SELECT
|
SELECT
|
||||||
b.cad_num,
|
b.cad_num,
|
||||||
|
|
@ -97,13 +100,13 @@ _RESOLVE_CAD_SQL = text("""
|
||||||
) AS distance_m
|
) AS distance_m
|
||||||
FROM cad_buildings b
|
FROM cad_buildings b
|
||||||
WHERE b.geom IS NOT NULL
|
WHERE b.geom IS NOT NULL
|
||||||
ORDER BY b.geom <-> ST_SetSRID(
|
ORDER BY b.geom::geography <-> ST_SetSRID(
|
||||||
ST_MakePoint(
|
ST_MakePoint(
|
||||||
CAST(:lon AS double precision),
|
CAST(:lon AS double precision),
|
||||||
CAST(:lat AS double precision)
|
CAST(:lat AS double precision)
|
||||||
),
|
),
|
||||||
4326
|
4326
|
||||||
)
|
)::geography
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
@ -119,8 +122,9 @@ def resolve_cad_for_domrf(
|
||||||
|
|
||||||
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
|
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
|
||||||
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
|
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
|
||||||
GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до
|
GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
|
||||||
footprint ≤ max_dist_m. Точка внутри площадного footprint → distance 0.
|
принимаем матч, если расстояние до footprint ≤ max_dist_m. Точка внутри
|
||||||
|
площадного footprint → distance 0.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
|
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import DatabaseError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -41,8 +41,11 @@ def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> None:
|
||||||
else:
|
else:
|
||||||
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
|
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
|
||||||
db.commit()
|
db.commit()
|
||||||
except OperationalError as e:
|
except DatabaseError as e:
|
||||||
if concurrently and "cannot refresh materialized view" in str(e).lower():
|
# 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(
|
logger.warning(
|
||||||
"%s: CONCURRENTLY failed (MV likely not populated), "
|
"%s: CONCURRENTLY failed (MV likely not populated), "
|
||||||
"falling back to non-concurrent refresh",
|
"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).
|
# Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations).
|
||||||
_L2_MEDIUM_MIN_COVERAGE: float = 0.6
|
_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 дальше этого горизонта от сегодня. Раньше — это уже
|
# L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже
|
||||||
# почти-на-рынке предложение (ловится L1/L2), не «future».
|
# почти-на-рынке предложение (ловится L1/L2), не «future».
|
||||||
_L3_FUTURE_HORIZON_MONTHS: int = 12
|
_L3_FUTURE_HORIZON_MONTHS: int = 12 # L3 берёт объекты с ready_dt > сейчас+12мес
|
||||||
|
|
||||||
# L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго
|
# L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго
|
||||||
# 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт.
|
# 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт.
|
||||||
|
|
@ -215,6 +220,10 @@ _L1_OPEN_SQL = text(
|
||||||
# free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим;
|
# free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим;
|
||||||
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
|
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
|
||||||
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
|
# 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(
|
_L2_HIDDEN_SQL = text(
|
||||||
"""
|
"""
|
||||||
WITH latest AS (
|
WITH latest AS (
|
||||||
|
|
@ -222,7 +231,8 @@ _L2_HIDDEN_SQL = text(
|
||||||
o.obj_id,
|
o.obj_id,
|
||||||
o.district_name,
|
o.district_name,
|
||||||
o.flat_count,
|
o.flat_count,
|
||||||
o.free_flats
|
o.free_flats,
|
||||||
|
o.ready_dt
|
||||||
FROM domrf_kn_objects o
|
FROM domrf_kn_objects o
|
||||||
WHERE o.region_cd = :region_cd
|
WHERE o.region_cd = :region_cd
|
||||||
AND o.district_name IS NOT NULL
|
AND o.district_name IS NOT NULL
|
||||||
|
|
@ -240,6 +250,8 @@ _L2_HIDDEN_SQL = text(
|
||||||
GREATEST(0, COALESCE(flat_count, 0) - free_flats)
|
GREATEST(0, COALESCE(flat_count, 0) - free_flats)
|
||||||
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
|
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
|
||||||
FROM latest
|
FROM latest
|
||||||
|
WHERE ready_dt IS NULL
|
||||||
|
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
|
||||||
GROUP BY district_name
|
GROUP BY district_name
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -368,7 +380,11 @@ def compute_layer2_hidden(
|
||||||
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
|
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
|
||||||
units_estimate всегда ≥ 0 (clamp). Пустые данные → []. Не бросает.
|
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)
|
rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2)
|
||||||
|
|
||||||
out: list[SupplyLayerRow] = []
|
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).
|
Fallback: rosreestr_deals (quarter_cad_number, deal_count, period_start_date).
|
||||||
|
|
||||||
Linkage: domrf_kn_objects.obj_id
|
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_complex_mapping.objective_complex_name
|
||||||
→ objective_corpus_room_month.project_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
|
from __future__ import annotations
|
||||||
|
|
@ -40,6 +44,24 @@ _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0
|
||||||
# пытаемся rosreestr_fallback.
|
# пытаемся rosreestr_fallback.
|
||||||
_OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50
|
_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)
|
@dataclass(frozen=True)
|
||||||
class VelocityResult:
|
class VelocityResult:
|
||||||
|
|
@ -195,7 +217,7 @@ def compute_velocity(
|
||||||
sales_rows = (
|
sales_rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
WITH all_competitors AS (
|
WITH all_competitors AS (
|
||||||
SELECT unnest(CAST(:obj_ids AS int[])) AS obj_id
|
SELECT unnest(CAST(:obj_ids AS int[])) AS obj_id
|
||||||
),
|
),
|
||||||
|
|
@ -204,6 +226,7 @@ def compute_velocity(
|
||||||
cm.objective_complex_name
|
cm.objective_complex_name
|
||||||
FROM objective_complex_mapping cm
|
FROM objective_complex_mapping cm
|
||||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||||
|
AND {_MAPPING_CONFIDENCE_GATE}
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
ac.obj_id,
|
ac.obj_id,
|
||||||
|
|
@ -311,12 +334,13 @@ def compute_velocity(
|
||||||
bucket_rows = (
|
bucket_rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
WITH mapped AS (
|
WITH mapped AS (
|
||||||
SELECT cm.domrf_obj_id AS obj_id,
|
SELECT cm.domrf_obj_id AS obj_id,
|
||||||
cm.objective_complex_name
|
cm.objective_complex_name
|
||||||
FROM objective_complex_mapping cm
|
FROM objective_complex_mapping cm
|
||||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||||
|
AND {_MAPPING_CONFIDENCE_GATE}
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
m.obj_id,
|
m.obj_id,
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ _RE_FLOORS = re.compile(
|
||||||
+ _DASH
|
+ _DASH
|
||||||
+ r"\s*"
|
+ r"\s*"
|
||||||
+ _NUM
|
+ _NUM
|
||||||
+ r"\s*этаж",
|
+ r"(?:\s*этаж\w*)?",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
|
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
|
||||||
|
|
|
||||||
|
|
@ -130,19 +130,28 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
||||||
if not daily.get("time"):
|
if not daily.get("time"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
t_max = daily.get("temperature_2m_max") or []
|
# Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
|
||||||
t_min = daily.get("temperature_2m_min") or []
|
# при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
|
||||||
precip = daily.get("precipitation_sum") or []
|
# TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
|
||||||
uv = daily.get("uv_index_max") or []
|
t_max = [v for v in (daily.get("temperature_2m_max") or []) if v is not None]
|
||||||
wind_d = daily.get("winddirection_10m_dominant") or []
|
t_min = [v for v in (daily.get("temperature_2m_min") or []) if v is not None]
|
||||||
wind_s = daily.get("windspeed_10m_max") or []
|
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
|
# Circular mean направления ветра (vector sum) — избегает jump 359→1.
|
||||||
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
|
# Если нет ни одного валидного сэмпла — dominant=None (не 0.0° = север).
|
||||||
y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None)
|
if wind_d:
|
||||||
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0
|
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 = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
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 {
|
return {
|
||||||
"forecast_days": len(daily.get("time", [])),
|
"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),
|
"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,
|
"uv_index_max": round(max(uv), 1) if uv else None,
|
||||||
"wind": {
|
"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,
|
"dominant_direction_label": wind_label,
|
||||||
"max_speed_m_s": round(max(wind_s), 1) if wind_s else None,
|
"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"]:
|
if not vals["t_max"]:
|
||||||
seasons[season] = None
|
seasons[season] = None
|
||||||
continue
|
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] = {
|
seasons[season] = {
|
||||||
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
|
"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),
|
"max_t_c": round(max(vals["t_max"]), 1),
|
||||||
"min_t_c": round(min(vals["t_min"]), 1),
|
"min_t_c": round(min(t_min), 1) if t_min else None,
|
||||||
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
|
"avg_precip_per_day_mm": (
|
||||||
"total_precip_mm": round(sum(vals["precip"]), 0),
|
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"]),
|
"days_observed": len(vals["t_max"]),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,16 @@ def _build_beat_schedule_from_db() -> dict:
|
||||||
"options": {"queue": "celery"},
|
"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
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -192,6 +202,14 @@ def _build_beat_schedule_fallback() -> dict:
|
||||||
"options": {"queue": "celery"},
|
"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
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -506,4 +524,37 @@ def build_beat_schedule() -> dict:
|
||||||
"options": {"queue": "celery"},
|
"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
|
return schedule
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.ekburg_permits_sync",
|
"app.workers.tasks.ekburg_permits_sync",
|
||||||
"app.workers.tasks.cbr_macro_sync",
|
"app.workers.tasks.cbr_macro_sync",
|
||||||
"app.workers.tasks.rosstat_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.supply_layers_refresh",
|
||||||
"app.workers.tasks.location_refresh",
|
"app.workers.tasks.location_refresh",
|
||||||
"app.workers.tasks.forecast",
|
"app.workers.tasks.forecast",
|
||||||
|
|
@ -79,6 +81,9 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.pat_subzones_load",
|
"app.workers.tasks.pat_subzones_load",
|
||||||
"app.workers.tasks.izyatie_ocr_ingest",
|
"app.workers.tasks.izyatie_ocr_ingest",
|
||||||
"app.workers.tasks.developer_registry_refresh",
|
"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"
|
celery_app.conf.timezone = "Europe/Moscow"
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ Handlers регистрируются через Celery signals декорато
|
||||||
worker_process_init — dispose SQLAlchemy engine в каждом prefork child-процессе,
|
worker_process_init — dispose SQLAlchemy engine в каждом prefork child-процессе,
|
||||||
чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
|
чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
|
||||||
|
|
||||||
worker_ready — при рестарте воркера находит все 'running'/'paused' записи
|
worker_ready — при рестарте воркера находит 'running'/'paused' записи
|
||||||
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
|
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
|
||||||
|
Для nspd_geo 'paused' (WAF-пауза) применяется 30-минутный cooldown
|
||||||
|
(_ZOMBIE_PAUSED_THRESHOLD), чтобы редеплой не аннулировал WAF-защиту.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -147,10 +149,18 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
||||||
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
|
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
|
||||||
|
|
||||||
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
|
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
|
||||||
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' / 'paused' jobs
|
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' jobs — на
|
||||||
# — на worker_ready по определению нет активных воркеров, всё running ==
|
# worker_ready по определению нет активных воркеров, всё running ==
|
||||||
# zombie. Раньше требовали heartbeat >10мин, что пропускало jobs убитых
|
# 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()
|
db = SessionLocal()
|
||||||
geo_resume_jobs: list[int] = []
|
geo_resume_jobs: list[int] = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -161,10 +171,16 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
||||||
UPDATE nspd_geo_jobs
|
UPDATE nspd_geo_jobs
|
||||||
SET status = 'queued',
|
SET status = 'queued',
|
||||||
error = COALESCE(error, 'auto-resume at worker_ready')
|
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
|
RETURNING job_id
|
||||||
"""
|
"""
|
||||||
)
|
),
|
||||||
|
{"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
|
||||||
|
|
@ -41,19 +41,21 @@ from app.workers.celery_app import celery_app
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
|
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
|
||||||
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123):
|
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
|
||||||
# (indicator_type, region, obs_date, value, source, frequency, unit, comment,
|
# (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
|
||||||
# updated_at) PK (indicator_type, region, obs_date).
|
# 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(
|
UPSERT_KEY_RATE_SQL = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO macro_indicator (
|
INSERT INTO macro_indicator (
|
||||||
indicator_type, region, obs_date, value,
|
indicator_type, region, obs_date, period_type, value,
|
||||||
source, frequency, unit, comment
|
source, frequency, unit, comment
|
||||||
) VALUES (
|
) 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'
|
'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,
|
value = EXCLUDED.value,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
"""
|
"""
|
||||||
|
|
@ -61,16 +63,17 @@ UPSERT_KEY_RATE_SQL = text(
|
||||||
|
|
||||||
# Инфляция «% г/г» (ИПЦ YoY): indicator_type='inflation_yoy', monthly, region='rf'.
|
# Инфляция «% г/г» (ИПЦ YoY): indicator_type='inflation_yoy', monthly, region='rf'.
|
||||||
# obs_date уже нормализован к 1-му числу месяца парсером (parse_inflation_xlsx).
|
# obs_date уже нормализован к 1-му числу месяца парсером (parse_inflation_xlsx).
|
||||||
|
# period_type='unknown' — месячный ряд без sub-period granularity.
|
||||||
UPSERT_INFLATION_SQL = text(
|
UPSERT_INFLATION_SQL = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO macro_indicator (
|
INSERT INTO macro_indicator (
|
||||||
indicator_type, region, obs_date, value,
|
indicator_type, region, obs_date, period_type, value,
|
||||||
source, frequency, unit, comment
|
source, frequency, unit, comment
|
||||||
) VALUES (
|
) 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 (ИПЦ, % г/г)'
|
'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,
|
value = EXCLUDED.value,
|
||||||
updated_at = now()
|
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-паузу.
|
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
|
||||||
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
|
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
|
||||||
_ZOMBIE_PAUSED_THRESHOLD = "30 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 ────────────────────────────────────────
|
# ── Helpers для job/target lifecycle ────────────────────────────────────────
|
||||||
|
|
@ -79,8 +89,19 @@ def _log(
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _start_job(db: Session, job_id: int) -> None:
|
def _start_job(db: Session, job_id: int) -> bool:
|
||||||
db.execute(
|
"""Атомарный 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(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE nspd_geo_jobs
|
UPDATE nspd_geo_jobs
|
||||||
|
|
@ -89,12 +110,17 @@ def _start_job(db: Session, job_id: int) -> None:
|
||||||
heartbeat_at = NOW(),
|
heartbeat_at = NOW(),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE job_id = :id
|
WHERE job_id = :id
|
||||||
|
AND status <> 'running'
|
||||||
|
RETURNING job_id
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"id": job_id},
|
{"id": job_id},
|
||||||
)
|
).scalar_one_or_none()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if claimed is None:
|
||||||
|
return False
|
||||||
_log(db, job_id, "info", "start", "job started/resumed")
|
_log(db, job_id, "info", "start", "job started/resumed")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
|
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:
|
if not job:
|
||||||
return {"error": "job_not_found", "job_id": job_id}
|
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.
|
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
|
||||||
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
|
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
|
||||||
if job["rate_ms"]:
|
if job["rate_ms"]:
|
||||||
|
|
@ -637,6 +671,10 @@ def cleanup_zombies() -> dict[str, Any]:
|
||||||
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) →
|
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) →
|
||||||
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
|
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
|
||||||
сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис.
|
сразу аннулировал бы защиту и 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
|
Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat
|
||||||
не матчит WHERE-clause.
|
не матчит WHERE-clause.
|
||||||
|
|
@ -661,12 +699,22 @@ def cleanup_zombies() -> dict[str, Any]:
|
||||||
AND heartbeat_at
|
AND heartbeat_at
|
||||||
< NOW() - CAST(:paused_threshold AS interval)
|
< 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
|
RETURNING job_id
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
|
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
|
||||||
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
|
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
|
||||||
|
"queued_threshold": _ZOMBIE_QUEUED_THRESHOLD,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
|
||||||
|
|
@ -153,9 +153,12 @@ def import_anton_objective(
|
||||||
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
|
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Дополним результат полезной диагностикой
|
# Логируем полезную диагностику, но НЕ возвращаем dict —
|
||||||
|
# re-raise, чтобы Celery-таск ушёл в FAILURE (не SUCCESS) и не
|
||||||
|
# рассинхронился с objective_scrape_runs.status='failed' (#1623).
|
||||||
info = get_sqlite_info(sqlite_path_eff)
|
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:
|
except Exception as e:
|
||||||
logger.exception("import_anton_objective failed: %s", 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)
|
bbox = _geojson_bbox_3857(qfeat.geometry)
|
||||||
if bbox is None:
|
if bbox is None:
|
||||||
continue
|
continue
|
||||||
n_quarters += 1
|
# Staging-счётчик квартала: прибавляем к итогам ТОЛЬКО после успешного
|
||||||
|
# commit (строка ниже). Иначе при сбое commit + rollback откатятся
|
||||||
|
# незакоммиченные UPSERT'ы, а отчёт завысит число persisted фич (#1624).
|
||||||
|
q_features = 0
|
||||||
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
|
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
|
||||||
layer_id = LAYERS.get(layer_key)
|
layer_id = LAYERS.get(layer_key)
|
||||||
if layer_id is None:
|
if layer_id is None:
|
||||||
|
|
@ -94,10 +97,13 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
|
||||||
feature=feat,
|
feature=feat,
|
||||||
fetched_at=fetched_at,
|
fetched_at=fetched_at,
|
||||||
):
|
):
|
||||||
n_features += 1
|
q_features += 1
|
||||||
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
|
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
|
||||||
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
|
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# Commit прошёл → фичи квартала реально persisted, учитываем в отчёте.
|
||||||
|
n_quarters += 1
|
||||||
|
n_features += q_features
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
|
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
|
||||||
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
|
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
|
||||||
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123):
|
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
|
||||||
# (indicator_type, region, obs_date, value, source, frequency, unit, comment,
|
# (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
|
||||||
# updated_at) PK (indicator_type, region, obs_date). source='rosstat',
|
# comment, updated_at). PK (indicator_type, region, obs_date, period_type).
|
||||||
# frequency='yearly' (текущие ряды Росстата — годовые: obs_date = 1 января года).
|
# Не-ЕМИСС источники (open-data, СМР) используют period_type='unknown' (литерал) —
|
||||||
|
# они различаются по obs_date без sub-period granularity.
|
||||||
|
# source='rosstat', frequency='yearly' (текущие ряды Росстата — годовые).
|
||||||
UPSERT_ROSSTAT_SQL = text(
|
UPSERT_ROSSTAT_SQL = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO macro_indicator (
|
INSERT INTO macro_indicator (
|
||||||
indicator_type, region, obs_date, value,
|
indicator_type, region, obs_date, period_type, value,
|
||||||
source, frequency, unit, comment
|
source, frequency, unit, comment
|
||||||
) VALUES (
|
) VALUES (
|
||||||
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
|
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)
|
'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,
|
value = EXCLUDED.value,
|
||||||
source = EXCLUDED.source,
|
source = EXCLUDED.source,
|
||||||
frequency = EXCLUDED.frequency,
|
frequency = EXCLUDED.frequency,
|
||||||
|
|
@ -71,20 +73,23 @@ UPSERT_ROSSTAT_SQL = text(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# ЕМИСС-ряды (source='emiss'): frequency параметризована (quarterly для доходов,
|
# ЕМИСС-ряды (source='emiss'): period_type параметризован — 'year' | 'quarter' |
|
||||||
# monthly для ИПЦ когда добавится) — в отличие от open-data, где она фикс 'yearly'.
|
# 'month' (из _emiss_period_granularity). Это ключевое отличие от не-ЕМИСС источников:
|
||||||
# Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
|
# период ЕМИСС-ряда несёт 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(
|
UPSERT_EMISS_SQL = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO macro_indicator (
|
INSERT INTO macro_indicator (
|
||||||
indicator_type, region, obs_date, value,
|
indicator_type, region, obs_date, period_type, value,
|
||||||
source, frequency, unit, comment
|
source, frequency, unit, comment
|
||||||
) VALUES (
|
) VALUES (
|
||||||
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
|
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)
|
'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,
|
value = EXCLUDED.value,
|
||||||
source = EXCLUDED.source,
|
source = EXCLUDED.source,
|
||||||
frequency = EXCLUDED.frequency,
|
frequency = EXCLUDED.frequency,
|
||||||
|
|
@ -96,19 +101,19 @@ UPSERT_EMISS_SQL = text(
|
||||||
|
|
||||||
# Индекс цен на СМР — открытый xlsx Росстата: source='rosstat' (rosstat.gov.ru-файл),
|
# Индекс цен на СМР — открытый xlsx Росстата: source='rosstat' (rosstat.gov.ru-файл),
|
||||||
# НО frequency='monthly' (в отличие от open-data демографии — там фикс 'yearly'),
|
# НО frequency='monthly' (в отличие от open-data демографии — там фикс 'yearly'),
|
||||||
# поэтому frequency параметризована. MacroRow не несёт frequency-поля, подставляем
|
# поэтому frequency параметризована. MacroRow не несёт frequency/period_type-полей;
|
||||||
# литералом в bind. Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — psycopg v3.
|
# подставляем литералами. CAST(:x AS type) — psycopg v3.
|
||||||
UPSERT_ROSSTAT_MONTHLY_SQL = text(
|
UPSERT_ROSSTAT_MONTHLY_SQL = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO macro_indicator (
|
INSERT INTO macro_indicator (
|
||||||
indicator_type, region, obs_date, value,
|
indicator_type, region, obs_date, period_type, value,
|
||||||
source, frequency, unit, comment
|
source, frequency, unit, comment
|
||||||
) VALUES (
|
) VALUES (
|
||||||
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
|
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)
|
'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,
|
value = EXCLUDED.value,
|
||||||
source = EXCLUDED.source,
|
source = EXCLUDED.source,
|
||||||
frequency = EXCLUDED.frequency,
|
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:
|
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, чтобы один битый ряд не откатывал всю транзакцию. Возвращает
|
SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает
|
||||||
число успешных upsert'ов."""
|
число успешных upsert'ов.
|
||||||
|
|
||||||
|
period_type (из r.period_type) — часть нового PK (migration 163): позволяет
|
||||||
|
годовому агрегату ('year') и Q1 ('quarter') за один год коexist в таблице без
|
||||||
|
взаимной перезаписи (#1606).
|
||||||
|
"""
|
||||||
upserted = 0
|
upserted = 0
|
||||||
for r in rows:
|
for r in rows:
|
||||||
try:
|
try:
|
||||||
|
|
@ -198,6 +209,7 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
|
||||||
"itype": r.indicator_type,
|
"itype": r.indicator_type,
|
||||||
"region": r.region,
|
"region": r.region,
|
||||||
"d": r.obs_date,
|
"d": r.obs_date,
|
||||||
|
"period_type": r.period_type,
|
||||||
"v": r.value,
|
"v": r.value,
|
||||||
"freq": r.frequency,
|
"freq": r.frequency,
|
||||||
"unit": r.unit,
|
"unit": r.unit,
|
||||||
|
|
@ -207,10 +219,11 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
|
||||||
upserted += 1
|
upserted += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"upsert emiss %s/%s@%s=%s failed: %s",
|
"upsert emiss %s/%s@%s[%s]=%s failed: %s",
|
||||||
r.indicator_type,
|
r.indicator_type,
|
||||||
r.region,
|
r.region,
|
||||||
r.obs_date,
|
r.obs_date,
|
||||||
|
r.period_type,
|
||||||
r.value,
|
r.value,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -401,11 +401,28 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
|
||||||
)
|
)
|
||||||
skipped_fresh = set(rows)
|
skipped_fresh = set(rows)
|
||||||
if skipped_fresh:
|
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(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE cadastre_jobs SET targets_total = :new_total, "
|
"UPDATE cadastre_jobs SET "
|
||||||
"targets_skipped = COALESCE(targets_skipped, 0) + :sk "
|
"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"
|
"WHERE job_id = :id"
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ logger = logging.getLogger(__name__)
|
||||||
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
|
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
|
||||||
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
|
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
|
||||||
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
|
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
|
||||||
|
#
|
||||||
|
# LIMIT :max_objects: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
|
||||||
|
# max_objects=None (force "Загрузить все" без явного потолка) грузим ВСЕ строки
|
||||||
|
# последнего snapshot, а не молча режем до _DEFAULT_MAX_OBJECTS.
|
||||||
_SELECT_TARGETS_SQL = text(
|
_SELECT_TARGETS_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT obj_id, snapshot_date
|
SELECT obj_id, snapshot_date
|
||||||
|
|
@ -74,10 +78,12 @@ def scrape_kn_catalog_objects(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
|
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
|
||||||
max_objects: Максимум объектов за один run. Default 300.
|
max_objects: Максимум объектов за один run. Если не задан: при force=True
|
||||||
force: Если True — игнорирует фильтр "уже сегодня обновлён" и грузит
|
лимита нет (грузим все), при force=False — _DEFAULT_MAX_OBJECTS (300).
|
||||||
все объекты последнего snapshot (admin "Загрузить все"). По умолчанию
|
force: Если True — игнорирует фильтр "уже сегодня обновлён" и (при не
|
||||||
False — пропускает то, что уже скраплено сегодня.
|
заданном max_objects) снимает лимит, грузя ВСЕ объекты последнего
|
||||||
|
snapshot (admin "Загрузить все"). По умолчанию False — пропускает
|
||||||
|
то, что уже скраплено сегодня, и режет batch до 300.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict с ключами: region_code, snapshot_date, obj_ids_count,
|
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
|
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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|
@ -136,11 +150,11 @@ def scrape_kn_catalog_objects(
|
||||||
snapshot_date_val: date = rows[0]["snapshot_date"]
|
snapshot_date_val: date = rows[0]["snapshot_date"]
|
||||||
|
|
||||||
logger.info(
|
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,
|
region_code,
|
||||||
snapshot_date_val,
|
snapshot_date_val,
|
||||||
len(obj_ids),
|
len(obj_ids),
|
||||||
limit,
|
"ALL" if limit is None else limit,
|
||||||
force,
|
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,
|
OKATO_SVERDLOVSK_OBLAST,
|
||||||
EmissIndicatorSpec,
|
EmissIndicatorSpec,
|
||||||
RegionTarget,
|
RegionTarget,
|
||||||
|
_emiss_period_granularity,
|
||||||
_emiss_period_to_month,
|
_emiss_period_to_month,
|
||||||
parse_emiss_sdmx,
|
parse_emiss_sdmx,
|
||||||
)
|
)
|
||||||
|
|
@ -59,17 +60,78 @@ def test_period_year_and_unknown() -> None:
|
||||||
assert _emiss_period_to_month("") is 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) ─────────────────────────────────────
|
# ── real-fixture extraction (income id=57039) ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_income_extracts_sverdlovsk_only() -> None:
|
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)
|
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
|
||||||
assert rows, "ожидались строки по Свердл"
|
assert rows, "ожидались строки по Свердл"
|
||||||
assert {r.region for r in rows} == {"sverdl"}
|
assert {r.region for r in rows} == {"sverdl"}
|
||||||
assert all(r.indicator_type == "income_per_capita" for r in rows)
|
assert all(r.indicator_type == "income_per_capita" for r in rows)
|
||||||
assert all(r.unit == "руб" for r in rows)
|
assert all(r.unit == "руб" for r in rows)
|
||||||
assert all(r.frequency == "quarterly" 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:
|
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:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||||
'<generic:Attributes><generic:Value concept="PERIOD" value="3 неделя"/>'
|
'<generic:Attributes><generic:Value concept="PERIOD" value="3 неделя"/>'
|
||||||
"</generic:Attributes>"
|
"</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:ObsValue value="999"/></generic:Obs></generic:Series>'
|
||||||
"<generic:Series><generic:SeriesKey>"
|
"<generic:Series><generic:SeriesKey>"
|
||||||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||||||
"</generic:Attributes>"
|
"</generic:Attributes>"
|
||||||
'<generic:Obs><generic:Time>2024</generic:Time>'
|
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||||||
'<generic:ObsValue value="50000"/></generic:Obs></generic:Series>'
|
'<generic:ObsValue value="50000"/></generic:Obs></generic:Series>'
|
||||||
"</DataSet></GenericData>"
|
"</DataSet></GenericData>"
|
||||||
)
|
)
|
||||||
|
|
@ -179,7 +241,7 @@ def test_comma_decimal_value_parsed() -> None:
|
||||||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||||
'<generic:Attributes><generic:Value concept="PERIOD" value="январь"/>'
|
'<generic:Attributes><generic:Value concept="PERIOD" value="январь"/>'
|
||||||
"</generic:Attributes>"
|
"</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>'
|
'<generic:ObsValue value="113,2"/></generic:Obs></generic:Series></DataSet>'
|
||||||
"</GenericData>"
|
"</GenericData>"
|
||||||
)
|
)
|
||||||
|
|
@ -199,13 +261,13 @@ def test_dedup_last_wins() -> None:
|
||||||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||||||
"</generic:Attributes>"
|
"</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:ObsValue value="100"/></generic:Obs></generic:Series>'
|
||||||
"<generic:Series><generic:SeriesKey>"
|
"<generic:Series><generic:SeriesKey>"
|
||||||
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
|
||||||
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
|
||||||
"</generic:Attributes>"
|
"</generic:Attributes>"
|
||||||
'<generic:Obs><generic:Time>2024</generic:Time>'
|
"<generic:Obs><generic:Time>2024</generic:Time>"
|
||||||
'<generic:ObsValue value="200"/></generic:Obs></generic:Series>'
|
'<generic:ObsValue value="200"/></generic:Obs></generic:Series>'
|
||||||
"</DataSet></GenericData>"
|
"</DataSet></GenericData>"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from app.services.exporters.excel import (
|
||||||
_SHEET_SUMMARY,
|
_SHEET_SUMMARY,
|
||||||
_fmt,
|
_fmt,
|
||||||
_sanitize_formula,
|
_sanitize_formula,
|
||||||
|
_scenario_deficit_cell,
|
||||||
_scenario_deficit_index,
|
_scenario_deficit_index,
|
||||||
export_report_xlsx,
|
export_report_xlsx,
|
||||||
)
|
)
|
||||||
|
|
@ -313,6 +314,53 @@ class TestContractKeysWritten:
|
||||||
assert di is not None
|
assert di is not None
|
||||||
assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках"
|
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:
|
def test_overall_score_in_cells(self) -> None:
|
||||||
data = _full_report().as_dict()
|
data = _full_report().as_dict()
|
||||||
overall = data["scoring"]["overall"]
|
overall = data["scoring"]["overall"]
|
||||||
|
|
@ -466,10 +514,6 @@ class TestFormulaInjection:
|
||||||
payload = export_report_xlsx(report)
|
payload = export_report_xlsx(report)
|
||||||
wb = _reload(payload)
|
wb = _reload(payload)
|
||||||
formula_cells = [
|
formula_cells = [
|
||||||
c
|
c for ws in wb.worksheets for col in ws.iter_cols() for c in col if c.data_type == "f"
|
||||||
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}"
|
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})
|
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
||||||
assert res.payment_at_scenario is not None
|
assert res.payment_at_scenario is not None
|
||||||
principal = 120_000.0 * _REF_AREA_M2
|
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(
|
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(
|
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]
|
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,26 @@ class TestComputeReportConfidence:
|
||||||
# Полностью JSON-сериализуем (контракт для экспортёров/чата).
|
# Полностью JSON-сериализуем (контракт для экспортёров/чата).
|
||||||
assert json.loads(json.dumps(d, ensure_ascii=False)) == d
|
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:
|
def test_ignores_garbage_component_confidence(self) -> None:
|
||||||
# Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
|
# Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
|
||||||
res = compute_report_confidence(
|
res = compute_report_confidence(
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,50 @@ class TestSeasonalFactors:
|
||||||
assert adj.applied is False
|
assert adj.applied is False
|
||||||
assert all(f == 1.0 for f in adj.factors.values())
|
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:
|
def test_month_with_no_observations_factor_one(self) -> None:
|
||||||
# Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
|
# Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
|
||||||
# (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
|
# (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
|
||||||
|
|
@ -239,3 +283,17 @@ class TestNormalizeDemand:
|
||||||
units = _seasonal_units(months)
|
units = _seasonal_units(months)
|
||||||
result = normalize_demand(_make_series(months, units))
|
result = normalize_demand(_make_series(months, units))
|
||||||
assert all(isinstance(u, int) and u >= 0 for u in result.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:
|
class TestScoreCommercial:
|
||||||
def test_available_share_scaled(self) -> None:
|
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)
|
value, conf, _r = _score_commercial(signal)
|
||||||
assert value == pytest.approx(0.4)
|
assert value == pytest.approx(0.4)
|
||||||
assert conf == "medium"
|
assert conf == "medium"
|
||||||
|
|
@ -417,7 +417,7 @@ class TestScoreCommercial:
|
||||||
def test_in_range(self) -> None:
|
def test_in_range(self) -> None:
|
||||||
for share in (0.0, 25.0, 100.0):
|
for share in (0.0, 25.0, 100.0):
|
||||||
value, _c, _r = _score_commercial(
|
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 value is not None
|
||||||
assert 0.0 <= value <= 1.0
|
assert 0.0 <= value <= 1.0
|
||||||
|
|
@ -439,7 +439,7 @@ class TestScoreCommercial:
|
||||||
|
|
||||||
def test_bad_confidence_falls_back_low(self) -> None:
|
def test_bad_confidence_falls_back_low(self) -> None:
|
||||||
value, conf, _r = _score_commercial(
|
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 value is not None
|
||||||
assert conf == "low"
|
assert conf == "low"
|
||||||
|
|
@ -728,7 +728,7 @@ def _patch_all(
|
||||||
overlay: Any = None,
|
overlay: Any = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом)."""
|
"""Контекст-менеджер: патчит ВСЕ 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)]
|
forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)]
|
||||||
competitors_rv = (
|
competitors_rv = (
|
||||||
competitors if competitors is not None else _competitors_response_stub([0.4, 0.2])
|
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) ─────────────────────────
|
# ── 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 = MagicMock()
|
||||||
m.unit_velocity = unit_velocity
|
m.unit_velocity = unit_velocity
|
||||||
|
m.velocity_by_room = velocity_by_room
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -453,6 +457,71 @@ class TestDemandOnlyOverlay:
|
||||||
assert out["ranked_segments"] == []
|
assert out["ranked_segments"] == []
|
||||||
assert out["warnings"]
|
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:
|
def test_five_default_room_buckets_ranked(self) -> None:
|
||||||
with (
|
with (
|
||||||
patch(_METRICS, return_value=_mk_metrics(4.0)),
|
patch(_METRICS, return_value=_mk_metrics(4.0)),
|
||||||
|
|
@ -744,7 +813,7 @@ class TestCommercialSignal:
|
||||||
out = _commercial_signal(MagicMock(), "Ленинский", 12)
|
out = _commercial_signal(MagicMock(), "Ленинский", 12)
|
||||||
assert out is not None
|
assert out is not None
|
||||||
assert out["available"] is True
|
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["n_lots"] == 120
|
||||||
assert out["confidence"] == "medium"
|
assert out["confidence"] == "medium"
|
||||||
assert out["reason"]["advisory"] is True
|
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)."""
|
"""Готовый Launch Window SpecialIndex с заданным best_horizon_months (None → unavail)."""
|
||||||
if horizon is None:
|
if horizon is None:
|
||||||
return SpecialIndex(
|
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 на всех горизонтах"},
|
detail={"reason": "deficit None на всех горизонтах"},
|
||||||
method=_METHOD_UNAVAILABLE, advisory=True,
|
method=_METHOD_UNAVAILABLE,
|
||||||
|
advisory=True,
|
||||||
)
|
)
|
||||||
return SpecialIndex(
|
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": {}},
|
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:
|
def test_unknown_distance_floor_weight(self) -> None:
|
||||||
# нет координат → низкий floor (НЕ 0, НЕ 1): проект сигналит, но не доминирует.
|
# нет координат → низкий 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:
|
def test_negative_distance_clamped(self) -> None:
|
||||||
assert _geo_weight(-5.0) == pytest.approx(1.0)
|
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)
|
# §25.3 own-portfolio overlap — пара (среднее доступных осей) + агрегация (soft-max)
|
||||||
|
|
@ -745,16 +781,25 @@ class TestAggregateOverlap:
|
||||||
def test_takes_max_signal_not_mean(self) -> None:
|
def test_takes_max_signal_not_mean(self) -> None:
|
||||||
# один сильный (0.9) + два слабых (0.1) → агрегат = 0.9 (soft-max), не среднее.
|
# один сильный (0.9) + два слабых (0.1) → агрегат = 0.9 (soft-max), не среднее.
|
||||||
strong = _own_portfolio_overlap(
|
strong = _own_portfolio_overlap(
|
||||||
class_overlap=0.9, price_overlap=0.9,
|
class_overlap=0.9,
|
||||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
price_overlap=0.9,
|
||||||
|
unit_mix_overlap=None,
|
||||||
|
timing_overlap=None,
|
||||||
|
geo_weight=1.0,
|
||||||
)
|
)
|
||||||
weak1 = _own_portfolio_overlap(
|
weak1 = _own_portfolio_overlap(
|
||||||
class_overlap=0.1, price_overlap=0.1,
|
class_overlap=0.1,
|
||||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
price_overlap=0.1,
|
||||||
|
unit_mix_overlap=None,
|
||||||
|
timing_overlap=None,
|
||||||
|
geo_weight=1.0,
|
||||||
)
|
)
|
||||||
weak2 = _own_portfolio_overlap(
|
weak2 = _own_portfolio_overlap(
|
||||||
class_overlap=0.1, price_overlap=0.1,
|
class_overlap=0.1,
|
||||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
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)
|
assert _aggregate_overlap([strong, weak1, weak2]) == pytest.approx(0.9)
|
||||||
|
|
||||||
|
|
@ -762,19 +807,28 @@ class TestAggregateOverlap:
|
||||||
# сильное пересечение, но далеко (geo 0.1) → signal 0.05; близкое слабое
|
# сильное пересечение, но далеко (geo 0.1) → signal 0.05; близкое слабое
|
||||||
# (overlap 0.3, geo 1.0 → 0.3) перебивает. Агрегат = 0.3.
|
# (overlap 0.3, geo 1.0 → 0.3) перебивает. Агрегат = 0.3.
|
||||||
distant_strong = _own_portfolio_overlap(
|
distant_strong = _own_portfolio_overlap(
|
||||||
class_overlap=1.0, price_overlap=1.0,
|
class_overlap=1.0,
|
||||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=0.1,
|
price_overlap=1.0,
|
||||||
|
unit_mix_overlap=None,
|
||||||
|
timing_overlap=None,
|
||||||
|
geo_weight=0.1,
|
||||||
)
|
)
|
||||||
near_weak = _own_portfolio_overlap(
|
near_weak = _own_portfolio_overlap(
|
||||||
class_overlap=0.3, price_overlap=0.3,
|
class_overlap=0.3,
|
||||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
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)
|
assert _aggregate_overlap([distant_strong, near_weak]) == pytest.approx(0.3)
|
||||||
|
|
||||||
def test_all_none_signals_is_none(self) -> None:
|
def test_all_none_signals_is_none(self) -> None:
|
||||||
empty = _own_portfolio_overlap(
|
empty = _own_portfolio_overlap(
|
||||||
class_overlap=None, price_overlap=None,
|
class_overlap=None,
|
||||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
price_overlap=None,
|
||||||
|
unit_mix_overlap=None,
|
||||||
|
timing_overlap=None,
|
||||||
|
geo_weight=1.0,
|
||||||
)
|
)
|
||||||
assert _aggregate_overlap([empty, empty]) is None
|
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:
|
def test_sql_reads_mortgage_signal_columns(self, _identity_resolver: Any) -> None:
|
||||||
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
|
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
|
||||||
with _identity_resolver:
|
with _identity_resolver:
|
||||||
_query_artificial_demand(
|
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||||
db, district=None, obj_class=None, premise_kind="квартира"
|
|
||||||
)
|
|
||||||
sql = _executed_sql(db)
|
sql = _executed_sql(db)
|
||||||
# сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
|
# сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
|
||||||
assert "encumbrance_type" in sql
|
assert "encumbrance_type" in sql
|
||||||
|
|
@ -1007,9 +1059,7 @@ class TestArtificialDemandSQL:
|
||||||
# #1205: district=None → has_district=False, districts=[]. EKB-wide.
|
# #1205: district=None → has_district=False, districts=[]. EKB-wide.
|
||||||
db = _mock_db_one({"n_sold": 200, "n_mortgage": 80})
|
db = _mock_db_one({"n_sold": 200, "n_mortgage": 80})
|
||||||
with _identity_resolver:
|
with _identity_resolver:
|
||||||
_query_artificial_demand(
|
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||||
db, district=None, obj_class=None, premise_kind="квартира"
|
|
||||||
)
|
|
||||||
p = _executed_params(db)
|
p = _executed_params(db)
|
||||||
assert p["has_district"] is False
|
assert p["has_district"] is False
|
||||||
assert p["districts"] == []
|
assert p["districts"] == []
|
||||||
|
|
@ -1527,7 +1577,9 @@ class TestComputeSpecialIndicesGraceful:
|
||||||
# Центроид участка для гео-веса (lon, lat) в окрестностях ЕКБ.
|
# Центроид участка для гео-веса (lon, lat) в окрестностях ЕКБ.
|
||||||
_CENTROID = (60.6000, 56.8000)
|
_CENTROID = (60.6000, 56.8000)
|
||||||
_CAND_SPEC = SegmentSpec(
|
_CAND_SPEC = SegmentSpec(
|
||||||
obj_class="комфорт", room_bucket="2-к 45-60", district="Академический",
|
obj_class="комфорт",
|
||||||
|
room_bucket="2-к 45-60",
|
||||||
|
district="Академический",
|
||||||
price_bucket=PRICE_BUCKET_COMFORT,
|
price_bucket=PRICE_BUCKET_COMFORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1625,7 +1677,9 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
||||||
# Launch Window резолвится (пик h=24) → candidate_release_month выведен →
|
# Launch Window резолвится (пик h=24) → candidate_release_month выведен →
|
||||||
# тайминговая ось теперь СЧИТАЕТСЯ (на тот же месяц, что наш проект → 1.0).
|
# тайминговая ось теперь СЧИТАЕТСЯ (на тот же месяц, что наш проект → 1.0).
|
||||||
own = _own(
|
own = _own(
|
||||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
"Наш-А",
|
||||||
|
lon=_CENTROID[0],
|
||||||
|
lat=_CENTROID[1],
|
||||||
release_month=_DERIVED_CANDIDATE_MONTH,
|
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||||
)
|
)
|
||||||
card = _timing_card([own])
|
card = _timing_card([own])
|
||||||
|
|
@ -1639,12 +1693,24 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
||||||
# near: release_month = выведенный месяц кандидата (timing 1.0); far: на 4 года
|
# near: release_month = выведенный месяц кандидата (timing 1.0); far: на 4 года
|
||||||
# позже (timing → почти 0). Прочие оси идентичны → near должен дать выше value.
|
# позже (timing → почти 0). Прочие оси идентичны → near должен дать выше value.
|
||||||
near = _timing_card(
|
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(
|
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
|
near_v = near.indices[KEY_CANNIBALIZATION].value
|
||||||
far_v = far.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)]
|
forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
|
||||||
own = _own(
|
own = _own(
|
||||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
"Наш-А",
|
||||||
|
lon=_CENTROID[0],
|
||||||
|
lat=_CENTROID[1],
|
||||||
release_month=date(2027, 1, 1), # есть дата, но кандидатной нет
|
release_month=date(2027, 1, 1), # есть дата, но кандидатной нет
|
||||||
)
|
)
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -1686,10 +1754,16 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
||||||
def test_timing_deterministic_same_inputs_identical_as_dict(self) -> None:
|
def test_timing_deterministic_same_inputs_identical_as_dict(self) -> None:
|
||||||
# Детерминизм (§16): одинаковые входы (фикс. дата отчёта) → идентичный as_dict.
|
# Детерминизм (§16): одинаковые входы (фикс. дата отчёта) → идентичный as_dict.
|
||||||
portfolio = [
|
portfolio = [
|
||||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
_own(
|
||||||
release_month=_DERIVED_CANDIDATE_MONTH),
|
"Наш-А", 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(
|
||||||
|
"Наш-Б",
|
||||||
|
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()
|
first = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||||
second = _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.
|
# наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0.
|
||||||
portfolio = [
|
portfolio = [
|
||||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
||||||
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
|
_own(
|
||||||
lon=60.9, lat=57.1),
|
"Наш-Б",
|
||||||
|
obj_class="бизнес",
|
||||||
|
price_min=200_000.0,
|
||||||
|
price_max=240_000.0,
|
||||||
|
lon=60.9,
|
||||||
|
lat=57.1,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
card = _cannibalization_card(portfolio)
|
card = _cannibalization_card(portfolio)
|
||||||
can = card.indices[KEY_CANNIBALIZATION]
|
can = card.indices[KEY_CANNIBALIZATION]
|
||||||
|
|
@ -1717,8 +1797,14 @@ class TestCannibalizationTrueMode:
|
||||||
def test_top_contributor_surfaced(self) -> None:
|
def test_top_contributor_surfaced(self) -> None:
|
||||||
portfolio = [
|
portfolio = [
|
||||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
||||||
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
|
_own(
|
||||||
lon=60.9, lat=57.1),
|
"Наш-Б",
|
||||||
|
obj_class="бизнес",
|
||||||
|
price_min=200_000.0,
|
||||||
|
price_max=240_000.0,
|
||||||
|
lon=60.9,
|
||||||
|
lat=57.1,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
card = _cannibalization_card(portfolio)
|
card = _cannibalization_card(portfolio)
|
||||||
can = card.indices[KEY_CANNIBALIZATION]
|
can = card.indices[KEY_CANNIBALIZATION]
|
||||||
|
|
@ -1742,20 +1828,24 @@ class TestCannibalizationTrueMode:
|
||||||
assert near_v > far_v
|
assert near_v > far_v
|
||||||
|
|
||||||
def test_no_geometry_still_computes_via_other_axes(self) -> None:
|
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)
|
card = _cannibalization_card([_own("Без-гео")], centroid=None)
|
||||||
can = card.indices[KEY_CANNIBALIZATION]
|
can = card.indices[KEY_CANNIBALIZATION]
|
||||||
assert can.method == "own_portfolio_overlap"
|
assert can.method == "own_portfolio_overlap"
|
||||||
assert can.detail["has_geometry"] is False
|
assert can.detail["has_geometry"] is False
|
||||||
# overlap 1.0 × geo floor 0.1 = 0.1.
|
# overlap 1.0 × geo floor 0.05 = 0.05.
|
||||||
assert can.value == pytest.approx(0.1)
|
assert can.value == pytest.approx(0.05)
|
||||||
|
|
||||||
def test_unit_mix_and_timing_axes_when_supplied(self) -> None:
|
def test_unit_mix_and_timing_axes_when_supplied(self) -> None:
|
||||||
# caller передаёт квартирографию/тайминг кандидата → оси становятся доступны.
|
# caller передаёт квартирографию/тайминг кандидата → оси становятся доступны.
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
own_proj = _own(
|
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 (
|
with (
|
||||||
patch(f"{_MOD}.get_own_portfolio", return_value=[own_proj]),
|
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
|
from app.services.forecasting.special_indices import _build_cannibalization
|
||||||
|
|
||||||
idx = _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_unit_mix={"studio": 0.5, "1k": 0.5},
|
||||||
candidate_release_month=date(2026, 6, 1),
|
candidate_release_month=date(2026, 6, 1),
|
||||||
)
|
)
|
||||||
|
|
@ -1777,8 +1869,16 @@ class TestCannibalizationTrueMode:
|
||||||
# наш проект без класса И без цены → ни класс, ни цена не считаются;
|
# наш проект без класса И без цены → ни класс, ни цена не считаются;
|
||||||
# тайминг/квартирография тоже None → пара без осей → индекс unavailable (НЕ 0).
|
# тайминг/квартирография тоже None → пара без осей → индекс unavailable (НЕ 0).
|
||||||
card = _cannibalization_card(
|
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]
|
can = card.indices[KEY_CANNIBALIZATION]
|
||||||
assert can.value is None
|
assert can.value is None
|
||||||
|
|
@ -1809,9 +1909,9 @@ class TestCannibalizationThinPortfolioConfidence:
|
||||||
assert "future" in can.detail["confidence_note"]
|
assert "future" in can.detail["confidence_note"]
|
||||||
|
|
||||||
def test_single_project_is_low_data_scarce(self) -> None:
|
def test_single_project_is_low_data_scarce(self) -> None:
|
||||||
can = _cannibalization_card(
|
can = _cannibalization_card([_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]).indices[
|
||||||
[_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]
|
KEY_CANNIBALIZATION
|
||||||
).indices[KEY_CANNIBALIZATION]
|
]
|
||||||
assert can.confidence == "low"
|
assert can.confidence == "low"
|
||||||
assert "данных мало" in can.detail["confidence_note"]
|
assert "данных мало" in can.detail["confidence_note"]
|
||||||
|
|
||||||
|
|
@ -1857,8 +1957,14 @@ class TestCannibalizationDeterminism:
|
||||||
def test_same_inputs_identical_output(self) -> None:
|
def test_same_inputs_identical_output(self) -> None:
|
||||||
portfolio = [
|
portfolio = [
|
||||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
||||||
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
|
_own(
|
||||||
lon=60.9, lat=57.1),
|
"Наш-Б",
|
||||||
|
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),
|
_own("Наш-В", obj_class="комфорт+", lon=60.65, lat=56.85),
|
||||||
]
|
]
|
||||||
first = _cannibalization_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
first = _cannibalization_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||||
|
|
@ -1932,7 +2038,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
||||||
# recommend_mix отдаёт микс кандидата (RU-подписи), наш проект — manual латиница с
|
# recommend_mix отдаёт микс кандидата (RU-подписи), наш проект — manual латиница с
|
||||||
# ТЕМ ЖЕ распределением по комнатности → ось квартирографии = 1.0 (через канон).
|
# ТЕМ ЖЕ распределением по комнатности → ось квартирографии = 1.0 (через канон).
|
||||||
own = _own(
|
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},
|
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
|
||||||
)
|
)
|
||||||
card = _unitmix_card(
|
card = _unitmix_card(
|
||||||
|
|
@ -1951,21 +2059,30 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
||||||
# Все прочие оси (класс/цена/гео/тайминг) идентичны → разница только в квартирографии.
|
# Все прочие оси (класс/цена/гео/тайминг) идентичны → разница только в квартирографии.
|
||||||
rec = _recommend_mix_result({"Студии 15-30": 50.0, "1-к 30-45": 50.0})
|
rec = _recommend_mix_result({"Студии 15-30": 50.0, "1-к 30-45": 50.0})
|
||||||
similar = _unitmix_card(
|
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,
|
recommend_return=rec,
|
||||||
)
|
)
|
||||||
dissimilar = _unitmix_card(
|
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,
|
recommend_return=rec,
|
||||||
)
|
)
|
||||||
sim_axis = similar.indices[KEY_CANNIBALIZATION].detail[
|
sim_axis = similar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
|
||||||
"top_contributors"
|
"unit_mix"
|
||||||
][0]["axes"]["unit_mix"]
|
]
|
||||||
dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail[
|
dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
|
||||||
"top_contributors"
|
"unit_mix"
|
||||||
][0]["axes"]["unit_mix"]
|
]
|
||||||
assert sim_axis > dis_axis
|
assert sim_axis > dis_axis
|
||||||
# и итоговое значение каннибализации выше при похожем миксе.
|
# и итоговое значение каннибализации выше при похожем миксе.
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -1977,7 +2094,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
||||||
# recommend_mix вернул None → микс None → ось квартирографии исключена (None-not-0),
|
# recommend_mix вернул None → микс None → ось квартирографии исключена (None-not-0),
|
||||||
# каннибализация всё равно считается по классу/цене/тайм/гео.
|
# каннибализация всё равно считается по классу/цене/тайм/гео.
|
||||||
own = _own(
|
own = _own(
|
||||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
"Наш-А",
|
||||||
|
lon=_CENTROID[0],
|
||||||
|
lat=_CENTROID[1],
|
||||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||||
)
|
)
|
||||||
card = _unitmix_card([own], recommend_return=None)
|
card = _unitmix_card([own], recommend_return=None)
|
||||||
|
|
@ -1990,7 +2109,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
||||||
def test_axis_excluded_when_recommend_empty_buckets(self) -> None:
|
def test_axis_excluded_when_recommend_empty_buckets(self) -> None:
|
||||||
# recommend_mix отдал пустые buckets (тонкие данные) → микс None → ось исключена.
|
# recommend_mix отдал пустые buckets (тонкие данные) → микс None → ось исключена.
|
||||||
own = _own(
|
own = _own(
|
||||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
"Наш-А",
|
||||||
|
lon=_CENTROID[0],
|
||||||
|
lat=_CENTROID[1],
|
||||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||||
)
|
)
|
||||||
card = _unitmix_card([own], recommend_return={"buckets": []})
|
card = _unitmix_card([own], recommend_return={"buckets": []})
|
||||||
|
|
@ -2001,7 +2122,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
||||||
def test_axis_excluded_when_recommend_raises_no_crash(self) -> None:
|
def test_axis_excluded_when_recommend_raises_no_crash(self) -> None:
|
||||||
# recommend_mix БРОСИЛ → graceful None → ось исключена, карточка цела (НЕ crash).
|
# recommend_mix БРОСИЛ → graceful None → ось исключена, карточка цела (НЕ crash).
|
||||||
own = _own(
|
own = _own(
|
||||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
"Наш-А",
|
||||||
|
lon=_CENTROID[0],
|
||||||
|
lat=_CENTROID[1],
|
||||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||||
)
|
)
|
||||||
card = _unitmix_card([own], recommend_side_effect=RuntimeError("heavy query boom"))
|
card = _unitmix_card([own], recommend_side_effect=RuntimeError("heavy query boom"))
|
||||||
|
|
@ -2015,7 +2138,10 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
||||||
# ЧЕСТНОСТЬ (own_portfolio PR1): current-проект несёт unit_mix=None → даже при
|
# ЧЕСТНОСТЬ (own_portfolio PR1): current-проект несёт unit_mix=None → даже при
|
||||||
# валидном recommend-миксе кандидата ось не считается (нечего сравнивать).
|
# валидном recommend-миксе кандидата ось не считается (нечего сравнивать).
|
||||||
own = _own(
|
own = _own(
|
||||||
"Текущий", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
"Текущий",
|
||||||
|
source="current",
|
||||||
|
lon=_CENTROID[0],
|
||||||
|
lat=_CENTROID[1],
|
||||||
unit_mix=None,
|
unit_mix=None,
|
||||||
)
|
)
|
||||||
card = _unitmix_card(
|
card = _unitmix_card(
|
||||||
|
|
@ -2029,7 +2155,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
||||||
def test_all_four_axes_active_together(self) -> None:
|
def test_all_four_axes_active_together(self) -> None:
|
||||||
# class + price + timing + unit_mix ВСЕ доступны на одном проекте → n_axes == 4.
|
# class + price + timing + unit_mix ВСЕ доступны на одном проекте → n_axes == 4.
|
||||||
own = _own(
|
own = _own(
|
||||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
"Наш-А",
|
||||||
|
lon=_CENTROID[0],
|
||||||
|
lat=_CENTROID[1],
|
||||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||||
release_month=_DERIVED_CANDIDATE_MONTH, # совпадает с выведенным тайм. кандидата
|
release_month=_DERIVED_CANDIDATE_MONTH, # совпадает с выведенным тайм. кандидата
|
||||||
)
|
)
|
||||||
|
|
@ -2045,28 +2173,34 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
||||||
def test_unit_mix_axis_deterministic_identical_as_dict(self) -> None:
|
def test_unit_mix_axis_deterministic_identical_as_dict(self) -> None:
|
||||||
# Детерминизм (§16): одинаковые входы → идентичный as_dict (с активной осью).
|
# Детерминизм (§16): одинаковые входы → идентичный as_dict (с активной осью).
|
||||||
portfolio = [
|
portfolio = [
|
||||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
_own(
|
||||||
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3}),
|
"Наш-А",
|
||||||
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85,
|
lon=_CENTROID[0],
|
||||||
unit_mix={"1k": 0.6, "2k": 0.4}),
|
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(
|
rec = _recommend_mix_result({"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0})
|
||||||
{"Студии 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 == second
|
||||||
# подтверждаем, что ось реально участвовала (не пустой детерминизм).
|
# подтверждаем, что ось реально участвовала (не пустой детерминизм).
|
||||||
assert first["detail"]["axes_available"]["unit_mix"] == 2
|
assert first["detail"]["axes_available"]["unit_mix"] == 2
|
||||||
|
|
||||||
def test_recommend_mix_called_with_expected_args(self) -> None:
|
def test_recommend_mix_called_with_expected_args(self) -> None:
|
||||||
# cost/корректность: recommend_mix зовётся с district+target_class+cad_num кандидата.
|
# cost/корректность: recommend_mix зовётся с district+target_class+cad_num кандидата.
|
||||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
|
||||||
unit_mix={"studio": 1.0})
|
|
||||||
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
|
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
with (
|
with (
|
||||||
|
|
@ -2091,8 +2225,7 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
||||||
|
|
||||||
def test_no_district_skips_recommend_mix(self) -> None:
|
def test_no_district_skips_recommend_mix(self) -> None:
|
||||||
# нет района → recommend_mix НЕ зовётся (район обязателен) → ось исключена, без вызова.
|
# нет района → recommend_mix НЕ зовётся (район обязателен) → ось исключена, без вызова.
|
||||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
|
||||||
unit_mix={"studio": 1.0})
|
|
||||||
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
|
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
with (
|
with (
|
||||||
|
|
@ -2155,10 +2288,22 @@ class TestCannibalizationUnitMixGate:
|
||||||
# Портфель только из current/domrf (unit_mix=None) → ось квартирографии всё равно
|
# Портфель только из current/domrf (unit_mix=None) → ось квартирографии всё равно
|
||||||
# исключилась бы → тяжёлый recommend_mix НЕ должен вызываться (call_count == 0).
|
# исключилась бы → тяжёлый recommend_mix НЕ должен вызываться (call_count == 0).
|
||||||
portfolio = [
|
portfolio = [
|
||||||
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
_own(
|
||||||
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH),
|
"Текущий-1",
|
||||||
_own("Текущий-2", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
source="current",
|
||||||
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH),
|
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)
|
card, rec_mock, _ = _gated_card(portfolio)
|
||||||
# Гейт сработал: тяжёлый запрос НЕ выполнен.
|
# Гейт сработал: тяжёлый запрос НЕ выполнен.
|
||||||
|
|
@ -2179,8 +2324,13 @@ class TestCannibalizationUnitMixGate:
|
||||||
# Есть future-проект с unit_mix → ось может внести вклад → recommend_mix зовётся
|
# Есть future-проект с unit_mix → ось может внести вклад → recommend_mix зовётся
|
||||||
# РОВНО один раз (не O(n), не дважды), и get_own_portfolio фетчится РОВНО один раз.
|
# РОВНО один раз (не O(n), не дважды), и get_own_portfolio фетчится РОВНО один раз.
|
||||||
portfolio = [
|
portfolio = [
|
||||||
_own("Будущий-А", source="future", lon=_CENTROID[0], lat=_CENTROID[1],
|
_own(
|
||||||
unit_mix={"studio": 1.0}),
|
"Будущий-А",
|
||||||
|
source="future",
|
||||||
|
lon=_CENTROID[0],
|
||||||
|
lat=_CENTROID[1],
|
||||||
|
unit_mix={"studio": 1.0},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
card, rec_mock, portfolio_mock = _gated_card(portfolio)
|
card, rec_mock, portfolio_mock = _gated_card(portfolio)
|
||||||
# Тяжёлый запрос — ровно один доп. вызов на отчёт.
|
# Тяжёлый запрос — ровно один доп. вызов на отчёт.
|
||||||
|
|
@ -2197,8 +2347,7 @@ class TestCannibalizationUnitMixGate:
|
||||||
# Даже когда гейт пропускает recommend_mix, get_own_portfolio всё равно фетчится
|
# Даже когда гейт пропускает recommend_mix, get_own_portfolio всё равно фетчится
|
||||||
# РОВНО один раз (портфель нужен и для гейта, и для самой каннибализации).
|
# РОВНО один раз (портфель нужен и для гейта, и для самой каннибализации).
|
||||||
portfolio = [
|
portfolio = [
|
||||||
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix=None),
|
||||||
unit_mix=None),
|
|
||||||
]
|
]
|
||||||
_, rec_mock, portfolio_mock = _gated_card(portfolio)
|
_, rec_mock, portfolio_mock = _gated_card(portfolio)
|
||||||
assert rec_mock.call_count == 0
|
assert rec_mock.call_count == 0
|
||||||
|
|
@ -2207,8 +2356,9 @@ class TestCannibalizationUnitMixGate:
|
||||||
def test_empty_mix_dict_does_not_trigger_recommend_mix(self) -> None:
|
def test_empty_mix_dict_does_not_trigger_recommend_mix(self) -> None:
|
||||||
# Пустой dict unit_mix={} (грязь) — НЕ «есть микс» → recommend_mix не зовётся.
|
# Пустой dict unit_mix={} (грязь) — НЕ «есть микс» → recommend_mix не зовётся.
|
||||||
portfolio = [
|
portfolio = [
|
||||||
_own("Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1],
|
_own(
|
||||||
unit_mix={}),
|
"Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={}
|
||||||
|
),
|
||||||
]
|
]
|
||||||
_, rec_mock, _ = _gated_card(portfolio)
|
_, rec_mock, _ = _gated_card(portfolio)
|
||||||
assert rec_mock.call_count == 0
|
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.text == ""
|
||||||
assert p.fields == {}
|
assert p.fields == {}
|
||||||
assert p.is_confidential is False
|
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,
|
demand_concentration=0.3333333,
|
||||||
price_sensitivity=-1.234567,
|
price_sensitivity=-1.234567,
|
||||||
price_sensitivity_source="regression",
|
price_sensitivity_source="regression",
|
||||||
|
velocity_by_room={"1": 1.5, "2": 0.5},
|
||||||
)
|
)
|
||||||
d = m.as_dict()
|
d = m.as_dict()
|
||||||
assert d["absorption_rate"] == 0.0476
|
assert d["absorption_rate"] == 0.0476
|
||||||
|
|
@ -304,6 +305,7 @@ class TestAsDict:
|
||||||
demand_concentration=None,
|
demand_concentration=None,
|
||||||
price_sensitivity=None,
|
price_sensitivity=None,
|
||||||
price_sensitivity_source=None,
|
price_sensitivity_source=None,
|
||||||
|
velocity_by_room=None,
|
||||||
)
|
)
|
||||||
d = m.as_dict()
|
d = m.as_dict()
|
||||||
assert d["absorption_rate"] is None
|
assert d["absorption_rate"] is None
|
||||||
|
|
@ -421,6 +423,14 @@ class TestComputeMarketMetricsShape:
|
||||||
assert m.price_sensitivity == -1.4
|
assert m.price_sensitivity == -1.4
|
||||||
assert m.price_sensitivity_source == "regression"
|
assert m.price_sensitivity_source == "regression"
|
||||||
assert m.confidence == "high"
|
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:
|
def test_elasticity_reuse_invoked_with_district(self) -> None:
|
||||||
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
||||||
|
|
@ -516,9 +526,7 @@ class TestPriceSensitivityDistrictResolution:
|
||||||
случайно совпадал в обоих вокабулярах).
|
случайно совпадал в обоих вокабулярах).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_admin_resolves_to_micros_in_elasticity_call(
|
def test_admin_resolves_to_micros_in_elasticity_call(self, _patch_resolver: MagicMock) -> None:
|
||||||
self, _patch_resolver: MagicMock
|
|
||||||
) -> None:
|
|
||||||
# Резолвер: 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
|
# Резолвер: 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
|
||||||
_patch_resolver.side_effect = lambda _db, d: (
|
_patch_resolver.side_effect = lambda _db, d: (
|
||||||
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||||||
|
|
@ -542,9 +550,7 @@ class TestPriceSensitivityDistrictResolution:
|
||||||
assert value == -1.2
|
assert value == -1.2
|
||||||
assert source == "regression"
|
assert source == "regression"
|
||||||
|
|
||||||
def test_resolver_none_passes_empty_list_ekb_wide(
|
def test_resolver_none_passes_empty_list_ekb_wide(self, _patch_resolver: MagicMock) -> None:
|
||||||
self, _patch_resolver: MagicMock
|
|
||||||
) -> None:
|
|
||||||
# Резолвер None ('не определён' / нет чистых алиасов) → передаём [] →
|
# Резолвер None ('не определён' / нет чистых алиасов) → передаём [] →
|
||||||
# _elasticity_coef уходит в EKB-wide ветку (без district-фильтра).
|
# _elasticity_coef уходит в EKB-wide ветку (без district-фильтра).
|
||||||
_patch_resolver.side_effect = lambda _db, _d: None
|
_patch_resolver.side_effect = lambda _db, _d: None
|
||||||
|
|
@ -555,9 +561,7 @@ class TestPriceSensitivityDistrictResolution:
|
||||||
_args, kwargs = elast.call_args
|
_args, kwargs = elast.call_args
|
||||||
assert kwargs["districts"] == []
|
assert kwargs["districts"] == []
|
||||||
|
|
||||||
def test_none_district_skips_resolver_and_elasticity(
|
def test_none_district_skips_resolver_and_elasticity(self, _patch_resolver: MagicMock) -> None:
|
||||||
self, _patch_resolver: MagicMock
|
|
||||||
) -> None:
|
|
||||||
# district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни
|
# district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни
|
||||||
# резолвер, ни _elasticity_coef.
|
# резолвер, ни _elasticity_coef.
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -600,6 +604,45 @@ class TestPriceSensitivityDistrictResolution:
|
||||||
assert source is None
|
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:
|
class TestComputeMarketMetricsThinData:
|
||||||
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
|
"""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.liquidity_index is None
|
||||||
assert m.overstock_index is None
|
assert m.overstock_index is None
|
||||||
assert m.demand_concentration is None
|
assert m.demand_concentration is None
|
||||||
|
assert m.velocity_by_room is None
|
||||||
assert m.confidence == "low"
|
assert m.confidence == "low"
|
||||||
|
|
||||||
def test_stock_none_row_no_crash(self) -> None:
|
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()
|
db = _make_mock_session()
|
||||||
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[])
|
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()
|
db.commit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_denorm_dump_no_cad_num_counted_as_error() -> None:
|
def test_denorm_dump_no_cad_num_counted_as_skipped() -> None:
|
||||||
"""Parcel без cad_num → denorm_parcel_feature returns False → errors += 1."""
|
"""Parcel без cad_num → pre-check в denorm_dump → skipped += 1, не errors.
|
||||||
|
|
||||||
|
denorm_parcel_feature не вызывается вовсе — пропуск штатный, не сбой UPSERT.
|
||||||
|
"""
|
||||||
db = _make_mock_session()
|
db = _make_mock_session()
|
||||||
feature: dict[str, Any] = {
|
feature: dict[str, Any] = {
|
||||||
"layer": "parcels",
|
"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])
|
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature])
|
||||||
assert counts["parcels"] == 0
|
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:
|
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()
|
mock_db = MagicMock()
|
||||||
|
|
||||||
candidates = [
|
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)
|
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["review_queue"] == 2
|
||||||
|
assert result["skipped"] == 0
|
||||||
mock_db.execute.assert_not_called()
|
mock_db.execute.assert_not_called()
|
||||||
mock_db.commit.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