Merge remote-tracking branch 'forgejo-backend/main' into fix/sf-feedback-batch

This commit is contained in:
bot-backend 2026-06-18 10:01:43 +03:00
commit bcd24923b8
190 changed files with 12669 additions and 3165 deletions

View file

@ -15,23 +15,22 @@ name: CI
# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см.
# .github/workflows/ci.yml как образец service-блока.
on:
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709).
# WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым
# PR триггерил ДВА прогона на ОДИН SHA: push-событие (github.ref=refs/heads/<branch>)
# и pull_request-событие (github.ref=refs/pull/<N>/merge). Разный github.ref →
# разные concurrency-группы (см. ниже) → прогоны НЕ отменяют друг друга → 2× job
# при и так дефицитных раннерах. В bot-пайплайне каждый коммит идёт через PR, так
# что pull_request гейтит его полностью; push-прогон был чистым дублем.
# Trade-off: push в feature-ветку БЕЗ открытого PR не получит CI до открытия PR
# (бот открывает PR сразу после первого push) — приемлемо.
pull_request:
branches: [main]
push:
branches:
# Mirror the bot-PR / feature-branch flow в .claude/rules/git-pr.md:
# PR получает gate, прямые пуши в feature-ветки — тоже.
- "feat/**"
- "fix/**"
- "refactor/**"
- "chore/**"
- "docs/**"
- "perf/**"
- "test/**"
- "ci/**"
- "hotfix/**"
concurrency:
# Теперь, когда остался только pull_request, github.ref стабилен на весь PR
# (refs/pull/<N>/merge) → новый push в ветку PR отменяет предыдущий незавершённый
# прогон ЭТОГО PR (cancel-in-progress) вместо накопления параллельных.
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@ -89,6 +88,19 @@ jobs:
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Cache uv packages
# Кросс-прогонный кэш скачанных/собранных wheel'ов (~/.cache/uv по умолч.).
# `uv sync --frozen` без него каждый прогон тянет весь geo-стек заново —
# доминирующая часть времени job (#1709). Ключ по uv.lock; continue-on-error
# чтобы сбой cache-бэкенда раннера НИКОГДА не ронял gate.
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-
- name: Install system deps for geo + WeasyPrint
# libpq/gdal/proj/geos — geo-стек (geopandas/shapely/pyproj).
# libcairo2/libpango* — нативные либы WeasyPrint: с ними PDF-тесты
@ -230,6 +242,16 @@ jobs:
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Cache uv packages
# См. backend-tests: кросс-прогонный кэш ~/.cache/uv, тот же ключ по uv.lock.
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-
- name: Install system deps for geo + WeasyPrint
# app.main транзитивно тянет geo/PDF-модули. На macOS-dev импорт схемы
# проходит и без этих либ, но на ubuntu ставим как backend-tests
@ -239,9 +261,12 @@ jobs:
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0
- name: Install backend deps (uv sync --frozen)
- name: Install backend deps (uv sync --frozen --no-dev)
working-directory: backend
run: uv sync --frozen
# --no-dev: этот job только дампит app.openapi() (нужен runtime app.main).
# pytest/ruff/coverage не используются → не ставим dev-группу (быстрее).
# Dockerfile тоже собирает с --no-dev → импорт app.main гарантированно ок.
run: uv sync --frozen --no-dev
- name: Install frontend deps (npm ci)
working-directory: frontend

View file

@ -47,6 +47,7 @@ jobs:
- '.forgejo/workflows/deploy-tradein.yml'
scraper:
- 'tradein-mvp/backend/app/services/scrapers/**'
- 'tradein-mvp/backend/app/services/scrape_pipeline.py'
- 'tradein-mvp/backend/app/services/scheduler.py'
- 'tradein-mvp/backend/app/scheduler_main.py'
- 'tradein-mvp/backend/app/tasks/**'

3
.gitignore vendored
View file

@ -81,3 +81,6 @@ sf_anton_snapshot.db-*
*.csv
*.csv.zip
!backend/db/init/*.sql
# Log cruft at repo root
debug.log

102
README.md
View file

@ -1,6 +1,12 @@
# Generative Design + Site Finder
# GenDesign — Site Finder + Trade-In «Мера»
Две продуктовые линии в одной воронке: **Site Finder** (AI-подбор инвестиционных земельных участков) + **Generative Design** (автоматическая генерация концепций застройки) для девелоперов РФ. Целевой регион Discovery — Свердловская обл. (ЕКБ, ПЗЗ, МСК-66).
Несколько продуктовых линий для девелоперов и риелторов РФ в одном монорепо:
- **Site Finder v2 «Foresight»** — AI-подбор инвестиционных земельных участков + прогноз продуктовой стратегии («GG-форсайт»: спрос/предложение, сценарии, что строить, gate-вердикт по ПЗЗ/ЗОУИТ/ОКН).
- **Trade-In «Мера»** — оценка выкупной стоимости вторичного жилья (адрес → оценка по аналогам и реальным сделкам, PDF-отчёт, white-label). `tradein-mvp/`, отдельный пайплайн.
- **Generative Design** — автоматическая генерация концепций застройки (на ранней стадии).
Целевой регион Discovery — Свердловская обл. (ЕКБ, ПЗЗ, МСК-66).
## North Star
@ -14,16 +20,23 @@
| Слой | Статус |
|---|---|
| **Site Finder v2 «Foresight»** (`/site-finder`) — анализ участка по cad_num, карта by-bbox, forecast-движок | ✅ live |
| ↳ Forecast-движок (`/parcels/{cad}/forecast` + `/analyze`): спрос/предложение, сценарии, confidence, affordability, rate-sensitivity, продуктовое ТЗ | ✅ (`backend/app/services/forecasting/*`, фронт `Section6Forecast` + `ScenariosBlock` + `ForecastConfidenceBlock`) |
| ↳ Gate-вердикт ПЗЗ/ЗОУИТ/ОКН (`gate_verdict`) + ИРД-overlay/analyze | ✅ backend (`gate_verdict.py`, `ird_overlay_lookup.py`, `ird_analyze.py`); фронт `GateVerdictBanner` |
| ↳ Конкуренты / best-layouts / изохроны (`/parcels/{cad}/competitors`, `/best-layouts`, `/isochrones`) | ✅ live |
| **Trade-In «Мера»** (`/trade-in`) — оценка вторички по аналогам + реальным сделкам, PDF, white-label | ✅ live (`tradein-mvp/`, отдельный стек/пайплайн) |
| ↳ Скрейперы аналогов: Avito / Cian / Domclick / Yandex.Realty (+ детальные парсеры, browser-fetcher) | ✅ |
| ↳ SberIndex time-adjust (city-level вторичный индекс цен) + Rosreestr ДКП-сделки + ДаДата/геокодер | ✅ |
| Аналитический калькулятор `/analytics/recommend` (Tier 3 per-bucket эластичность, кадастр vs рынок) | ✅ |
| DOM.РФ kn-API скрейпер (442 строящихся ЖК Свердл, sale_graph + infra + photos) | ✅ Celery beat + admin UI |
| DOM.РФ kn-API скрейпер (строящиеся ЖК Свердл, sale_graph + infra + photos) | ✅ Celery beat + admin UI |
| NSPD geo bulk-fetcher (rosreestr2coord lite, без Playwright) | ✅ admin UI с per-target логами |
| Rosreestr CSV-сделки 2024Q1—2026Q1 (9 квартирных партиций, 7M строк) | ✅ |
| NSPD кадастровые кварталы + здания (709 cad-кварталов, 10 590 cad_buildings ЕКБ) | ✅ Celery beat + admin UI |
| OSM ekb_districts_geom (PostGIS-полигоны 8 районов ЕКБ) | ✅ |
| Rosreestr CSV-сделки (квартирные партиции, ~7M строк, 27 мес истории) | ✅ |
| NSPD кадастровые кварталы + здания ЕКБ | ✅ Celery beat + admin UI |
| OSM ekb_districts_geom (PostGIS-полигоны районов ЕКБ) | ✅ |
| Objective ETL (Anton SQLite → PG, ports на psycopg v3) | ✅ admin UI |
| Obsidian Self-hosted LiveSync vault на `obsidian.gendsgn.ru` (268 entities из KG) | ✅ multi-device sync |
| Concept page (`/concept`) — Stage 1 Generative | 🟡 skeleton |
| Site Finder (`/site-finder`) — Stage 2 | 🔴 заглушка |
| Uptime Kuma мониторинг (`status.gendsgn.ru`, отдельный стек) | ✅ |
| Obsidian Self-hosted LiveSync vault на `obsidian.gendsgn.ru` | ✅ multi-device sync |
| Concept page (`/concept`) — Generative Design | 🟡 skeleton |
**Backlog:** [Forgejo Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) (трекаемые задачи с PR-связью) + [Obsidian vault](https://obsidian.gendsgn.ru) (планы, ADR, research — см. ниже про разделение).
@ -56,8 +69,13 @@ curl http://localhost:8000/health # → {"status":"ok","environment":"dev"}
Структура монорепо:
```
backend/ FastAPI app, services (analytics_queries, scrapers, generative, site_finder), workers (Celery)
frontend/ Next.js app router (analytics, admin, concept, site-finder)
backend/ FastAPI app, services (analytics_queries, scrapers, generative, site_finder, forecasting), workers (Celery)
frontend/ Next.js app router (analytics, admin, concept, site-finder + forecast/scenarios компоненты)
tradein-mvp/ Trade-In «Мера» — standalone стек (FastAPI + Next.js + Postgres + Caddy), скрейперы вторички, PDF-оценка
site-finder/ standalone pipeline-скрипты SF v1 (0112_*.py загрузка/скоринг) + server.py (Leaflet UI на SQLite-кэше)
auth/ roles.yaml — RBAC (single source of truth, bind-mount в main + tradein backend)
caddy/ users.caddy.snippet — basic-auth snippet для Caddy
preview/ статичные HTML-макеты (analytics / landing / monitoring / site-finder)
data/sql/ ETL артефакты — миграции схемы, загрузчики rosreestr/anton/nspd
ops/ backup.sh + deploy скрипты
scripts/ утилиты (migrate_kg_to_obsidian.py, setup-couchdb.sh, cleanup_ghosts.py)
@ -65,10 +83,11 @@ docs/ стратегические снапшоты (акту
memory/ legacy JSONL knowledge graph (deprecated, не обновляется — данные в Obsidian)
docker-compose.prod.yml main стек (backend, frontend, postgres, redis, worker, beat, caddy)
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
├── ci.yml lint (ruff) + mypy + pytest на PR
├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн)
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн + свой _schema_migrations)
└── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне
.github/workflows/ (остаточные — только obsidian-стек на GitHub)
└── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap)
@ -92,6 +111,35 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
---
## Site Finder v2 «Foresight» — что доступно
Анализ инвестиционного участка по кадастровому номеру: карта (`/parcels/by-bbox`), запуск анализа (`POST /parcels/{cad}/analyze`), прогнозный движок (`GET /parcels/{cad}/forecast` + экспорт). Бизнес-логика — `backend/app/services/site_finder/*` и `backend/app/services/forecasting/*`.
- **Forecast-движок** (`forecasting/orchestrator.py`): прогноз спроса/предложения (`demand_supply_forecast`), сценарии (`scenarios`), оценка уверенности (`confidence_engine`), доступность (`affordability`), чувствительность к ставке (`rate_sensitivity`), продуктовый скоринг и «что строить» (`product_scoring`, `what_to_build`). Фронт: `Section6Forecast`, `ScenariosBlock`, `ForecastChart`, `ForecastConfidenceBlock`, `ForecastHorizonsBlock`, `ForecastProductTzBlock`.
- **Gate-вердикт**: проверка регуляторных ограничений ПЗЗ/ЗОУИТ/ОКН (`gate_verdict.py`, `pzz_loader`, `zone_regulation`, `okn_lookup`) → баннер вердикта на фронте (`GateVerdictBanner`).
- **ИРД** (исходно-разрешительная документация): overlay-lookup + анализ на backend (`ird_overlay_lookup.py`, `ird_analyze.py`).
- **Соседство/контекст**: конкуренты (`POST /parcels/{cad}/competitors`), лучшие планировки (`/best-layouts` + PDF), изохроны (`/isochrones`), POI-скоринг, КРТ/резервирование/network-обременения.
`site-finder/` (отдельная папка) — это **standalone-пайплайн SF v1**: скрипты `01_…12_*.py` (загрузка участков, fetch POI, скоринг v1/v2, отчёты) + `server.py` (Leaflet-UI поверх SQLite-кэша POI). Это исследовательский прекурсор прод-движка в `backend/`, не путать с прод-API.
---
## Trade-In «Мера» — оценка вторички
Отдельный продукт в `tradein-mvp/` (standalone стек: FastAPI + Next.js 15 + Postgres + Caddy, свой `deploy-tradein.yml`). Дефолтный бренд переименован «Trade-In» → **«Мера»** (white-label-бренды клиентов резолвятся по slug из таблицы `brands`; URL-пути `/trade-in/*` и code-идентификаторы сохранены).
**Поток:** адрес → геокодер (ДаДата) → подбор аналогов в радиусе (PostGIS `ST_DWithin`) → оценка → PDF-отчёт (WeasyPrint, layout по референсу «Брусника.Обмен»).
- **Источники аналогов**: скрейперы Avito / Cian / Domclick / Yandex.Realty (`backend/app/services/scrapers/*`, с детальными парсерами и `browser_fetcher`).
- **Реальные сделки**: Rosreestr ДКП-поллинг (`rosreestr_poll.py`).
- **Time-adjust**: SberIndex city-level вторичный индекс цен (`sber_index.py`) — приведение разновременных аналогов/сделок к текущему месяцу.
- **Оценка** (`estimator.py`): SQL-агрегация поверх listings + deals, Tukey-фильтр выбросов (1.5×IQR), медиана/Q1/Q3 + confidence; больше **не** mock.
- **API**: `POST /api/v1/trade-in/estimate`, `GET …/estimate/{id}`, `GET …/estimate/{id}/pdf`. Multi-tenant: `/api/v1/brand/{slug}`, RBAC через `auth/roles.yaml`.
Подробнее — [`tradein-mvp/README.md`](tradein-mvp/README.md) и [`tradein-mvp/DEPLOY.md`](tradein-mvp/DEPLOY.md).
---
## Production хостинг
**Beget Cloud, Москва, 2 vCPU / 4 GB / 40 GB NVMe.** Стоимость ~1 140 ₽/мес сервер + ~17 ₽/мес домен.
@ -132,20 +180,16 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
---
## Roadmap (12 недель)
## Текущий фокус (июнь 2026)
| Stage | Сроки | Что | Acceptance |
|---|---|---|---|
| **0** ✅ | нед. 1 | Скелет монорепо, CI, прод | curl /health = ok |
| **Sprint 1** ✅ | конец апр 2026 | NSPD-скрейпер + per-bucket эластичность + кадастр cross-check + Sentry release tracking | recommend_mix Tier 3 в проде |
| **1a** | нед. 23 | Shapely-сервис, отступы, API-контракт `/concept` ([#5](https://git.gendsgn.ru/lekss361/gendesign/issues/5)) | GeoJSON → 3 заглушки |
| **1b** | нед. 45 | Greedy-алгоритм, 3 стратегии, STRtree ([#12](https://git.gendsgn.ru/lekss361/gendesign/issues/12)) | 3 варианта ≤15с на 1 га |
| **1c** | нед. 67 | ТЭП + финмодель + PDF/Excel/DXF ([#6](https://git.gendsgn.ru/lekss361/gendesign/issues/6)) | Продаваемый артефакт |
| **2a** | нед. 89 | ПКК-парсер + sales_tracker ([#7](https://git.gendsgn.ru/lekss361/gendesign/issues/7)) | ≥1000 участков ЕКБ |
| **2b** | нед. 1011 | Скоринг + API + UI Site Finder ([#8](https://git.gendsgn.ru/lekss361/gendesign/issues/8)) | Карта с цветовой градацией |
| **2c** | нед. 12 | Site Finder ↔ Generative + реестр ПО ([#9](https://git.gendsgn.ru/lekss361/gendesign/issues/9)) | 3 платящих пилота |
Исходный 12-недельный roadmap (Stage 0 → 2c, апрель–июнь) пройден: скелет/CI/прод, NSPD-скрейпер + per-bucket эластичность, ПКК-данные и скоринг участков — всё ✅. Site Finder вышел из стадии «заглушка» в боевой v2-движок, параллельно запущен Trade-In. Активные направления:
Аналитический backlog: [#17](https://git.gendsgn.ru/lekss361/gendesign/issues/17) velocity-anomaly, [#18](https://git.gendsgn.ru/lekss361/gendesign/issues/18) ekb_districts median refresh, [#19](https://git.gendsgn.ru/lekss361/gendesign/issues/19) competitor-360, [#20](https://git.gendsgn.ru/lekss361/gendesign/issues/20) mortgage-rate sensitivity, [#21](https://git.gendsgn.ru/lekss361/gendesign/issues/21) cad_buildings filters в Site Finder.
- **Site Finder v2 «Foresight»** ([#944](https://git.gendsgn.ru/lekss361/gendesign/issues/944) GG-форсайт) — ИИ-прогноз продуктовой стратегии девелопера: спрос/предложение, сценарии, confidence, продуктовое ТЗ. Движок и фронт-секции отгружены, продолжается доведение точности и покрытия.
- **ИРД** ([#1067](https://git.gendsgn.ru/lekss361/gendesign/issues/1067)) — исходно-разрешительная документация: overlay + analyze на участке.
- **Trade-In «Мера»** — оценка вторички на реальных данных (скрейперы Avito/Cian/Domclick/Yandex + Rosreestr ДКП + SberIndex), расширение покрытия аналогов и detail-парсинга.
- **Generative Design** (`/concept`) — на ранней стадии (skeleton), доводится после стабилизации SF/Trade-In.
Трекаемый backlog и приоритеты — [Forgejo Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) с лейблами `scope/*`, `вторичка`, `status/*`. North Star — см. секцию выше.
---
@ -212,6 +256,16 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
**Workflow:** тривиально (typo, 1-line) → main session; single-domain → профильный worker; cross-domain → `tech-analyst` сначала. Worker → `code-reviewer` → коммит → push → PR в Forgejo. Branch + PR обязательны, никаких direct push в main.
**Автономный bot-loop.** Помимо ручных subagent'ов есть набор автономных персон (`.claude/agents/auto-*.md`, status `draft`), которые крутятся каждая в отдельном Claude Code-окне на `/loop` и двигают задачи через лейблы `status/*` (ready → wip → review → qa → done):
- `auto-analyst` — декомпозирует work-items из vault/feedback в actionable Forgejo issues.
- `auto-backend` / `auto-frontend` — claim issue `scope/*` → ветка + код + push + PR (`Refs #N`, не `Closes`).
- `auto-code-reviewer` — читает diff, выносит verdict, мерджит при APPROVE (merge-authority).
- `auto-qa-tester` — Playwright golden-path по `status/qa`, закрывает issue на `status/done`.
- `auto-resolver` — снимает блокеры `needs-human`, используя capabilities, которых нет у headless-ботов (dev-IP, куки, SSH на прод, прямой доступ к БД).
`stale-claims.yml` авто-снимает протухшие claim-метки. Контракт claim/state-transition — `.claude/agents/_autonomous_pickup.md`.
---
## Полезные ссылки

View file

@ -5,14 +5,13 @@ from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
# Import the models package so every ORM model registers on Base.metadata.
# Add new model modules in app/models/__init__.py as they appear.
import app.models # noqa: F401
from alembic import context
from app.core.config import settings
from app.core.db import Base
# Import models so they register on Base.metadata.
# Add new model modules here as they appear.
from app.models import parcel # noqa: F401
config = context.config
# Inject runtime DB URL.

View file

@ -138,7 +138,7 @@ def leads_stats(
WITH window_leads AS (
SELECT *
FROM prinzip_leads
WHERE created_at >= NOW() - (:m || ' months')::interval
WHERE created_at >= NOW() - make_interval(months => :m)
)
SELECT
(SELECT COUNT(*) FROM prinzip_leads) AS leads_total,
@ -149,8 +149,20 @@ def leads_stats(
2
) AS conv_pct_window,
(SELECT COUNT(DISTINCT source) FROM prinzip_leads) AS sources_total,
(SELECT SUM(deal_price) FROM prinzip_deals) AS revenue_total,
(SELECT COUNT(*) FROM prinzip_deals) AS deals_total
(
SELECT SUM(d.deal_price)
FROM prinzip_deals d
WHERE d.deal_id IN (
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
)
) AS revenue_total,
(
SELECT COUNT(*)
FROM prinzip_deals d
WHERE d.deal_id IN (
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
)
) AS deals_total
FROM window_leads
"""
),

View file

@ -15,7 +15,7 @@ import logging
from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -268,7 +268,7 @@ def revoke_task(
def list_failures(
db: Annotated[Session, Depends(get_db)],
run_id: int | None = None,
limit: int = 50,
limit: int = Query(default=50, ge=1),
) -> list[dict[str, Any]]:
"""Per-request failure log for manual browser verification."""
where = ""
@ -314,7 +314,7 @@ def list_logs(
db: Annotated[Session, Depends(get_db)],
run_id: int | None = None,
since_id: int | None = None,
limit: int = 200,
limit: int = Query(default=200, ge=0),
) -> list[dict[str, Any]]:
"""Per-run progress events. Use since_id to poll incrementally:
pass the highest log_id seen returns only newer rows."""
@ -483,7 +483,7 @@ def trigger_objective_etl(
@router.get("/objective/runs")
def list_objective_runs(
db: Annotated[Session, Depends(get_db)],
limit: int = 20,
limit: int = Query(default=20, ge=0),
) -> list[dict[str, Any]]:
rows = (
db.execute(
@ -889,14 +889,20 @@ def bulk_enqueue_geo(
geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
# Валидируем ВСЕ thematic_ids ДО любых сайд-эффектов (создание jobs / apply_async),
# иначе невалидный id в середине списка приводит к partial execution: для предыдущих
# валидных id строки в nspd_geo_jobs уже созданы и задачи улетели в очередь geo,
# а клиент получает 400 без идемпотентного отката (#1562).
invalid_ids = [tid for tid in payload.thematic_ids if tid not in _THEMATIC_META]
if invalid_ids:
raise HTTPException(
status_code=400,
detail=f"thematic_id={invalid_ids} не поддерживается (допустимы: 1, 2, 5)",
)
jobs_summary: list[dict[str, Any]] = []
for thematic_id in payload.thematic_ids:
if thematic_id not in _THEMATIC_META:
raise HTTPException(
status_code=400,
detail=f"thematic_id={thematic_id} не поддерживается (допустимы: 1, 2, 5)",
)
meta = _THEMATIC_META[thematic_id]
# 1) Собрать cad-номера
@ -967,7 +973,7 @@ def bulk_enqueue_geo(
@router.get("/geo/jobs")
def list_geo_jobs(
db: Annotated[Session, Depends(get_db)],
limit: int = 30,
limit: int = Query(default=30, ge=0),
) -> list[dict[str, Any]]:
"""Список последних geo-jobs (для UI dashboard)."""
rows = (
@ -1087,7 +1093,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
def list_all_runs(
db: Annotated[Session, Depends(get_db)],
scraper_type: str | None = None,
limit: int = 30,
limit: int = Query(default=30, ge=0),
) -> list[dict[str, Any]]:
"""Унифицированный список прогонов (kn + nspd + objective).
@ -1145,7 +1151,7 @@ def list_all_logs(
db: Annotated[Session, Depends(get_db)],
scraper_type: str | None = None,
run_id: int | None = None,
limit: int = 200,
limit: int = Query(default=200, ge=0),
) -> list[dict[str, Any]]:
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
where: list[str] = []
@ -1278,9 +1284,7 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
]
def _classify_freshness(
age_days: float | None, fresh_days: float, stale_days: float
) -> str:
def _classify_freshness(age_days: float | None, fresh_days: float, stale_days: float) -> str:
"""fresh / stale / critical / unknown по возрасту последнего успешного прогона."""
if age_days is None:
return "unknown"
@ -1416,7 +1420,7 @@ def scrape_freshness(
@router.get("/runs")
def list_runs(
db: Annotated[Session, Depends(get_db)],
limit: int = 20,
limit: int = Query(default=20, ge=0),
) -> list[dict[str, Any]]:
rows = (
db.execute(

View file

@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
import httpx
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
from fastapi.concurrency import run_in_threadpool
from shapely import wkt as _shp_wkt
from shapely.geometry import Polygon
from sqlalchemy import text
@ -77,6 +78,7 @@ from app.services.site_finder.quarter_dump_lookup import (
get_quarter_dump_data,
make_empty_result,
)
from app.services.site_finder.riasurt_lookup import parcel_riasurt_gate
from app.services.site_finder.saturation import compute_district_saturation
from app.services.site_finder.velocity import compute_velocity
from app.services.site_finder.weight_profiles import (
@ -145,10 +147,17 @@ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
def _score_label(s: float) -> str:
"""Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ."""
"""Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ.
Шкала: <5 плохо, 5-15 средне, 15-25 хорошо, >=25 отлично.
"""
if s < SCORE_THRESHOLDS["плохо"]:
return "плохо"
if s < SCORE_THRESHOLDS["средне"]:
return "плохо" if s < SCORE_THRESHOLDS["плохо"] else "средне"
return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично"
return "средне"
if s < SCORE_THRESHOLDS["хорошо"]:
return "хорошо"
return "отлично"
def _confidence_label(c: float) -> str:
@ -821,11 +830,20 @@ def _compute_confidence(
market_trend: dict[str, Any] | None,
zoning: dict[str, Any],
) -> dict[str, Any]:
"""X2 (#48) — composite confidence score 0..1 + caveats.
"""X2 (#48) — composite confidence score 0..1 + caveats для site-finder analyze.
Stub-версия (до реализации G1/G2/D1/D2): использует сигналы которые уже
доступны на main. Композитный балл = avg of subscore'ов; caveats — list
конкретных проблем для UI ("Нет данных N, score K ненадёжен").
Это НАДЁЖНОСТЬ САМОГО SITE-FINDER СКОРИНГА (coverage/свежесть входных
сигналов: POI, район, рынок, конкуренты, источник геометрии) отдельная
by-design метрика, НЕ форсайтный §15 `forecasting.confidence_engine`
(`compute_report_confidence`). §15 оценивает надёжность ПРОГНОЗА
спроса/цены и работает над форсайт-входами (coverage/confounded/
special_indices/sales_series), которых в analyze hot-path нет поэтому
analyze намеренно держит свою лёгкую coverage-метрику, а не зовёт §15.
Композитный балл = avg of subscore'ов; caveats — list конкретных проблем
для UI ("Нет данных N, score K ненадёжен"). См. #1668 (решение: оставить
раздельно by-design; «stub до G1/G2/D1/D2» устаревшая формулировка,
те эшелоны уже отгружены, их confidence сюда не вплетается намеренно).
"""
caveats: list[str] = []
subscores: dict[str, float] = {}
@ -2177,8 +2195,44 @@ def analyze_parcel(
except Exception as e:
logger.warning("red_lines_block query failed for %s: %s", cad_num, e)
# B5-4) Metro placeholder — заполнится после merge 22h metro scraper
metro_block: dict[str, Any] = {"nearest_top3": None}
# B5-4) Metro — ближайшие 3 станции метро к участку.
# Данные уже в osm_poi_ekb (category='metro_stop', грузятся poi_loader.py:44),
# скрапер не нужен — прямой KNN-запрос по geom <-> centroid участка.
# nearest_top3=[] (НЕ None) когда метро не найдено: отличаем "посчитано, рядом
# нет" от "не реализовано". None только при ошибке запроса.
metro_block: dict[str, Any] = {"nearest_top3": []}
try:
with db.begin_nested():
metro_rows = (
db.execute(
text("""
SELECT name,
ST_Distance(
m.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS dist_m
FROM osm_poi_ekb m
WHERE m.category = 'metro_stop'
ORDER BY m.geom <-> ST_Centroid(ST_GeomFromText(:wkt, 4326))
LIMIT 3
"""),
{"wkt": geom_wkt},
)
.mappings()
.all()
)
metro_block = {
"nearest_top3": [
{
"name": mr["name"],
"distance_m": round(float(mr["dist_m"])) if mr["dist_m"] is not None else None,
}
for mr in metro_rows
]
}
except Exception as e:
logger.warning("metro_block query failed for %s: %s", cad_num, e)
metro_block = {"nearest_top3": None}
# B5-5) District price ranges из objective_lots (SF-B5)
district_price_block: dict[str, Any] = {
@ -2921,7 +2975,75 @@ def analyze_parcel(
"risks": risks_block,
}
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs.
# #1561: forecast/ird/developer_attribution дописываются в result_payload ДО persist —
# иначе persist_analysis_run сериализует jsonb-снимок (repository.py:124) и коммитит
# (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
# (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).
# §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы
# jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический
# enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть
# ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не
# посчитается, а ретраев у таски нет (regression #1561-followup). result_payload
# передаётся в persist by-reference; если enqueue провалится после persist —
# перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted
# снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана).
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
# источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful —
# сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
if settings.enable_ird_analyze:
try:
result_payload["ird"] = build_ird_analyze_block(
db, geom_wkt, centroid_lon, centroid_lat, cad_num
)
except Exception:
logger.warning(
"ird block failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
# РИАСУРТ Свердл gate (#108, multi-city scaling): для участков АГЛОМЕРАЦИИ ЕКБ
# (окраины — Берёзовский/В.Пышма/Среднеуральск/Арамиль/Сысерть), НЕ ЕКБ-сити (66:41).
# Flag-gated (default off): таблица riasurt_sverdl наполняется post-deploy harvest'ом на
# уточнённых bbox МО. Полностью graceful (parcel_riasurt_gate сам отдаёт пустой gate при
# отсутствии таблицы / вне агломерации) — сбой не меняет успех/форму ответа (extra="allow").
# Ключ добавляется только когда gate применим (applicable=True) — иначе не шумим.
if settings.enable_riasurt_gate:
try:
_riasurt_gate = parcel_riasurt_gate(db, geom_wkt, cad_num)
if _riasurt_gate.get("applicable"):
result_payload.setdefault("gate", {})["riasurt"] = _riasurt_gate
except Exception:
logger.warning(
"riasurt gate failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
# Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага
# (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms,
# резолвер ~30ms), без live-geoportal в hot-пути — низкий риск. get_developer_attribution
# сам graceful (None при пустом матче / нет геометрии / pre-migration), но обёрнут в
# try/except как остальные блоки — сбой не меняет успех/форму ответа (extra="allow"). Ключ
# добавляется ТОЛЬКО при наличии застройщика (None → ключ отсутствует, не null-шум).
try:
_dev_attr = get_developer_attribution(db, cad_num)
if _dev_attr is not None:
result_payload["developer_attribution"] = _dev_attr.model_dump()
except Exception:
logger.warning(
"developer_attribution block failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs — ПОСЛЕ дописывания
# forecast/ird/developer_attribution, чтобы jsonb-снимок совпадал с live-ответом (#1561).
# Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
# district денормализуем из result["district"]["district_name"] (для фильтрации
@ -2952,20 +3074,22 @@ def analyze_parcel(
created_by=x_authenticated_user,
)
# §22-форсайт (3b-ii, #995): best-effort fire-and-forget enqueue после persist.
# Таска `forecast_site_finder_report` читает только что сохранённый analyze-1.0
# ран и в фоне (~30-180s) считает §22 SiteFinderReport ('1.0'). analyze НЕ ждёт
# её — возвращаемся сразу. Celery/Redis down НЕ должен валить ответ (он уже успешен:
# §22-форсайт enqueue — СТРОГО ПОСЛЕ persist_analysis_run. persist_analysis_run —
# единственный commit analyze-рана (get_db() на success не коммитит), поэтому enqueue
# должен случиться только после того, как ран закоммичен: иначе фоновая таска
# `forecast_site_finder_report` (~30-180s) прочтёт latest_run_for и не найдёт свежий
# ран (None/старый) → форсайт молча не посчитается, ретраев нет (#1561-followup).
# Best-effort fire-and-forget: Celery/Redis down НЕ валит ответ (он уже успешен,
# frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
# Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
try:
from app.workers.tasks.forecast import forecast_site_finder_report
forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
except Exception:
# Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
# клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
# persisted снимок остаётся "pending" (безвреден — poll читает live-статус рана).
logger.warning(
"forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
cad_num,
@ -2974,40 +3098,6 @@ def analyze_parcel(
)
result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
# источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful —
# сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
if settings.enable_ird_analyze:
try:
result_payload["ird"] = build_ird_analyze_block(
db, geom_wkt, centroid_lon, centroid_lat, cad_num
)
except Exception:
logger.warning(
"ird block failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
# Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага
# (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms,
# резолвер ~30ms), без live-geoportal в hot-пути — низкий риск. get_developer_attribution
# сам graceful (None при пустом матче / нет геометрии / pre-migration), но обёрнут в
# try/except как остальные блоки — сбой не меняет успех/форму ответа (extra="allow"). Ключ
# добавляется ТОЛЬКО при наличии застройщика (None → ключ отсутствует, не null-шум).
try:
_dev_attr = get_developer_attribution(db, cad_num)
if _dev_attr is not None:
result_payload["developer_attribution"] = _dev_attr.model_dump()
except Exception:
logger.warning(
"developer_attribution block failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
return result_payload
@ -3249,8 +3339,10 @@ async def get_parcel_competitors(
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
участка с рассчитанным velocity_per_month за указанный time_window.
"""
# sync get_competitors (несколько db.execute, competitors.py:518) мостится через
# run_in_threadpool — иначе sync DB-IO блокирует event loop (тот же приём, что в chat.py).
try:
return get_competitors(db=db, cad_num=cad_num, request=body)
return await run_in_threadpool(get_competitors, db=db, cad_num=cad_num, request=body)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
@ -3303,8 +3395,10 @@ async def get_parcel_best_layouts(
Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
× objective_complex_mapping).
"""
# sync get_best_layouts (db.execute, best_layouts.py:377) мостится через
# run_in_threadpool — иначе sync DB-IO блокирует event loop.
try:
return get_best_layouts(db=db, cad_num=cad_num, request=body)
return await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
@ -3322,9 +3416,12 @@ async def get_parcel_best_layouts_pdf(
Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера.
"""
# sync get_best_layouts (DB-IO) + render_layout_tz_pdf (CPU-bound WeasyPrint
# write_pdf, сотни мс) мостятся через run_in_threadpool — иначе блокируют event loop.
try:
response = get_best_layouts(db=db, cad_num=cad_num, request=body)
pdf_bytes = render_layout_tz_pdf(
response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
pdf_bytes = await run_in_threadpool(
render_layout_tz_pdf,
response,
cad_num=cad_num,
radius_km=body.radius_km,

View file

@ -111,16 +111,25 @@ def get_photo(
data = _fetch_upstream(upstream)
if data:
src = _persist_original(obj_id, file_id, photo_name, data)
# Record the original immediately so a failed thumbnail does not
# orphan the on-disk file and trigger an eternal re-fetch.
db.execute(
text(
"UPDATE domrf_kn_photos"
" SET local_path = :lp, downloaded_at = NOW()"
" WHERE obj_id = :o AND obj_file_id = :f"
),
{"lp": str(src), "o": obj_id, "f": file_id},
)
db.commit()
generated = make_thumbnail(src)
if generated and generated.exists():
db.execute(
text(
"UPDATE domrf_kn_photos"
" SET local_path = :lp, thumb_path = :tp,"
" downloaded_at = NOW()"
"UPDATE domrf_kn_photos SET thumb_path = :tp"
" WHERE obj_id = :o AND obj_file_id = :f"
),
{"lp": str(src), "tp": str(generated), "o": obj_id, "f": file_id},
{"tp": str(generated), "o": obj_id, "f": file_id},
)
db.commit()
return FileResponse(str(generated), media_type="image/webp", headers=headers)

View file

@ -344,7 +344,7 @@ def estimate_pdf(
if row is None:
raise HTTPException(status_code=404, detail="estimate not found")
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
if row.expires_at.astimezone(UTC) < datetime.now(tz=UTC):
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
analogs = [AnalogLot(**a) for a in (row.analogs or [])]

View file

@ -190,6 +190,23 @@ class Settings(BaseSettings):
# возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
enable_ird_analyze: bool = True
# РИАСУРТ Свердл gate в analyze (#108, multi-city scaling): поле `gate.riasurt` в ответе
# analyze — пересечения участка с зонами РИАСУРТ Свердл (тер.зона/функц.зона/красные линии/
# СЗЗ/ЗСО/затопление/КРТ). ТОЛЬКО для участков в агломерации ЕКБ, НЕ в самом ЕКБ-сити
# (is_in_aglomeration_but_not_ekb). По умолчанию OFF: таблица riasurt_sverdl наполняется
# post-deploy harvest'ом на уточнённых bbox МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO).
# Override через env ENABLE_RIASURT_GATE=true.
enable_riasurt_gate: bool = False
# РИАСУРТ Свердл harvest kill-switch (#108 review): ежеквартальный beat
# `harvest_all_riasurt_sverdl` прогоняет grid-walk по MO_BBOXES. Эти bbox — ПЛЕЙСХОЛДЕРЫ
# (грубые ±6 км вокруг центров МО), реальные административные границы резолвятся post-deploy.
# По умолчанию OFF, иначе beat на следующем тике дёргает WMS с мусорными bbox.
# Включать ТОЛЬКО после замены MO_BBOXES на реальные границы. Override через
# env ENABLE_RIASURT_HARVEST=true. Single-MO harvest_riasurt_sverdl_for_mo не гейтится
# (callable вручную для smoke-тестирования конкретного bbox).
enable_riasurt_harvest: bool = False
# ── LLM infrastructure (#960) ────────────────────────────────────────────
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный

View file

@ -59,6 +59,7 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_sensitive_query,
before_send_transaction=scrub_sensitive_query,
integrations=[
StarletteIntegration(),

View file

@ -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"]

View file

@ -2,7 +2,7 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text
from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
@ -20,6 +20,8 @@ class JobSetting(Base):
max_retries: Mapped[int] = mapped_column(SmallInteger, default=2, nullable=False)
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
extra_config: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=text("now()"), nullable=False
)
updated_by: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)

View file

@ -1,29 +1,20 @@
"""SQLAlchemy + GeoAlchemy2 ORM models.
"""SQLAlchemy ORM models for parcel data.
Stage 2a: real Parcel model. Geometry stored in WGS84 (EPSG:4326);
project to МСК-66 via pyproj when computing distances/areas.
NB: реальные данные участков живут в таблицах ``cad_parcels`` /
``cad_parcels_geom`` (см. ``data/sql/92_cad_bulk_layers.sql`` и
``data/sql/83_cad_parcels_geom.sql``) и читаются через сырые PostGIS-запросы
(``app.services.site_finder.filters``, ``app.api.v1.parcels``), не через ORM.
Прежняя ORM-модель ``Parcel`` (таблица ``parcels``) удалена: соответствующего
DDL в ``data/sql/`` нет, ни один ORM-запрос её не использовал, а её колонки
(в т.ч. ``geometry(POLYGON, 4326)``) расходились с реальной схемой
``cad_parcels.geom`` которую миграция 93 уже перевела на ``MultiPolygon``,
т.к. НСПД отдаёт многоконтурные участки. Единственным её потребителем был
alembic autogenerate (``alembic/env.py``), для которого она порождала
фантомный ``CREATE TABLE parcels``.
Модуль сохранён (его импортирует ``alembic/env.py``), чтобы новые ORM-модели
регистрировались на ``Base.metadata`` именно отсюда.
"""
from datetime import datetime
from geoalchemy2 import Geometry
from sqlalchemy import JSON, DateTime, Float, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
class Parcel(Base):
__tablename__ = "parcels"
id: Mapped[str] = mapped_column(String, primary_key=True)
cadastral_number: Mapped[str] = mapped_column(String, unique=True, index=True)
vri: Mapped[str] = mapped_column(String, index=True)
area_sqm: Mapped[float] = mapped_column(Float)
address: Mapped[str | None] = mapped_column(String, nullable=True)
geometry: Mapped[object] = mapped_column(Geometry("POLYGON", srid=4326))
enrichment: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
from app.core.db import Base # noqa: F401 (re-export для регистрации будущих моделей)

View file

@ -15,7 +15,7 @@ from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
@ -84,10 +84,21 @@ class ChatAskRequest(BaseModel):
)
history: list[ChatTurn] | None = Field(
default=None,
max_length=_HISTORY_MAX_TURNS,
description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)",
)
@field_validator("history")
@classmethod
def _cap_history(cls, v: list[ChatTurn] | None) -> list[ChatTurn] | None:
"""Graceful-усечение: кэпируем хвост до последних _HISTORY_MAX_TURNS ходов.
НЕ отклоняем длинный диалог 422 (контракт обещает усечение, не отказ)
оставляем самые свежие ходы, отбрасывая старые с головы.
"""
if v is not None and len(v) > _HISTORY_MAX_TURNS:
return v[-_HISTORY_MAX_TURNS:]
return v
class ChatAskResponse(BaseModel):
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).

View file

@ -12,7 +12,9 @@ class ConceptInput(BaseModel):
housing_class: Literal["econom", "comfort", "business"] = "comfort"
target_floors: int = Field(9, ge=1, le=30)
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
land_cost_rub: float | None = Field(None, description="Optional land cost for financial model")
land_cost_rub: float | None = Field(
None, ge=0, description="Optional land cost for financial model"
)
class TEAP(BaseModel):

View file

@ -302,10 +302,18 @@ class NSPDBulkClient:
try:
data = await self._get_json(NSPD_SEARCH_URL, params=params)
except (NspdBulkWafError, NspdBulkRateLimitError, NspdBulkServerError):
# 403 WAF / 429 / 5xx+ServiceException — НЕ «квартал не найден».
# Пробрасываем как есть: caller (autoretry) ретраит квартал, WAF
# останавливает harvest. Раньше подстрочная классификация по str(e)
# с подмешанным body_preview могла ложно проглотить 5xx как 404.
raise
except NspdBulkError as e:
# 404 или пустой ответ → возвращаем пустой snapshot
# Остаётся базовый NspdBulkError = прочие 4xx (см. _get_json:234).
# Текст: f"HTTP {code}: {url} — {body_preview}" → классифицируем по
# ПРЕФИКСУ (код до URL), а не по вхождению в произвольное тело ответа.
err_str = str(e)
if "HTTP 404" in err_str or "HTTP 400" in err_str:
if err_str.startswith("HTTP 404:") or err_str.startswith("HTTP 400:"):
logger.info(
"search_by_quarter: quarter %s not found (404/400), returning empty"
" (category_id=%s)",
@ -342,8 +350,23 @@ class NSPDBulkClient:
for m in raw_meta:
cat_id = m.get("categoryId")
total = m.get("totalCount")
if cat_id is not None and total is not None:
if cat_id is None or total is None:
continue
# NSPD изредка отдаёт categoryId/totalCount нечисловой/float-строкой
# ('20.0') или иным типом → голый int() кинул бы ValueError/TypeError,
# который НЕ подкласс NspdBulkError и завалил бы всю Phase 1 квартала.
# Зеркалит защитный try/except в schemas/nspd_bulk.py и
# list_objects_in_building. Битую meta-запись просто пропускаем.
try:
meta_counts[int(cat_id)] = int(total)
except (ValueError, TypeError):
logger.warning(
"search_by_quarter: non-numeric meta entry quarter=%s"
" categoryId=%r totalCount=%r — skipping",
quarter,
cat_id,
total,
)
logger.info(
"search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
@ -416,7 +439,19 @@ class NSPDBulkClient:
}
data = await self._get_json(url, params=params)
raw_features: list[dict[str, Any]] = (data or {}).get("features") or []
# NSPD/GeoServer изредка отдаёт валидный JSON, но не объект (list/str —
# Bug_Nspd_Geo_Str_Object). Тогда `(data or {})` вернул бы сам truthy
# data, а .get("features") кинул бы AttributeError (не NspdBulkError →
# уронил бы ячейку grid-walk без сигнала). Унифицируем guard с
# search_by_quarter: не-dict трактуем как пустой ответ.
if not isinstance(data, dict):
logger.warning(
"wms_feature_info: non-dict JSON response layer=%d type=%s — returning empty",
layer_id,
type(data).__name__,
)
return []
raw_features: list[dict[str, Any]] = data.get("features") or []
return [NSPDBulkFeature.model_validate(f) for f in raw_features]
# ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
@ -473,9 +508,39 @@ class NSPDBulkClient:
seen_ids: set[str] = set()
results: list[dict] = []
# Issue #252-mirror: считаем server-side провалы и успешные ячейки, чтобы
# отличить «слой реально пуст» (ok_cells>0, 0 features) от «слой/IP лёг»
# (все ячейки 5xx/WAF). Раньше любой Exception молча падал на DEBUG и метод
# отдавал [] → в БД писался ложный tz_count=0 без layer_failed-сигнала.
server_errors = 0
ok_cells = 0
first_server_error: NspdBulkServerError | None = None
for idx, cell_result in enumerate(cell_results):
if isinstance(cell_result, NspdBulkWafError):
# 403 WAF (бан IP) — по docstring должен ОСТАНОВИТЬ harvest, не
# маскироваться пустым результатом. Пробрасываем немедленно.
logger.warning(
"get_features_in_bbox_grid: layer=%d cell=%d WAF 403 — aborting grid-walk: %s",
layer_id,
idx,
cell_result,
)
raise cell_result
if isinstance(cell_result, NspdBulkServerError):
server_errors += 1
if first_server_error is None:
first_server_error = cell_result
logger.debug(
"get_features_in_bbox_grid: layer=%d cell=%d server error: %s",
layer_id,
idx,
cell_result,
)
continue
if isinstance(cell_result, Exception):
# Прочие (сетевые/parse) ошибки одной ячейки — не валим обход и НЕ
# считаем server-side fail (иначе сеть ложно triggers layer_failed).
logger.debug(
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
layer_id,
@ -483,6 +548,7 @@ class NSPDBulkClient:
cell_result,
)
continue
ok_cells += 1
for feature in cell_result:
fid = str(feature.id) if feature.id is not None else ""
if fid and fid in seen_ids:
@ -497,6 +563,21 @@ class NSPDBulkClient:
}
)
# Если БЫЛИ server-side провалы И ни одна ячейка не прошла — слой/NSPD лёг
# целиком. Возврат [] здесь означал бы ложный tz_count=0 («зонирование
# отсутствует»). Пробрасываем server-error, чтобы caller отличил сбой от
# реально пустого слоя (мирроринг _grid_walk_category.layer_failed).
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
logger.warning(
"get_features_in_bbox_grid: layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
"(%d server errors, 0 ok cells) — raising вместо ложного пустого результата",
layer_id,
grid_n,
grid_n,
server_errors,
)
raise first_server_error
logger.info(
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
layer_id,

View file

@ -64,8 +64,15 @@ def _jsonb_param(value: Any) -> str:
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
json.dumps(..., ensure_ascii=False) кириллица как есть (зеркало pzz_loader).
allow_nan=False (#1580): дефолтный allow_nan=True выводит нестандартные литералы
NaN/Infinity/-Infinity, которые JSONB-парсер PostgreSQL отвергает ("invalid input
syntax for type json") → INSERT падает, а broad-except в persist_analysis_run
проглатывает это и теряет ран молча. С allow_nan=False json.dumps бросает ValueError
ДО SQL провал детерминирован и виден в logger.exception (с traceback), а не
маскируется под невнятную psycopg-ошибку синтаксиса.
"""
return json.dumps(jsonable_encoder(value), ensure_ascii=False)
return json.dumps(jsonable_encoder(value), ensure_ascii=False, allow_nan=False)
def persist_analysis_run(

View file

@ -24,11 +24,12 @@ list rather than silently ignored.
from __future__ import annotations
import logging
import re
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@ -54,6 +55,11 @@ _SUPPORTED_METHODS = (_CALC_BASIS, _CALC_PREVIOUS)
# as unsupported.
_SUPPORTED_SUBJECT = "66"
# ARN period_value format (matches mv_ddu_price_indicator.period_value, e.g.
# '2026-Q1'). Used to reject malformed bounds whose lexicographic comparison
# against well-formed period_value would silently drop rows.
_PERIOD_RE = re.compile(r"^\d{4}-Q[1-4]$")
def _f(value: Any) -> float | None:
if value is None:
@ -148,6 +154,50 @@ def get_ddu_indicator(
if clean_buckets:
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
params["buckets"] = clean_buckets
else:
# Client narrowed by area buckets but every id is out of range (0..6)
# → honour the explicit narrowing with an empty result, never silently
# widen back to all buckets. Mirrors the `not subject_ok` branch above.
notes.append(
f"areaRanges={area_ranges} вне диапазона 0..6 "
f"(0=все площади, 1..6=диапазоны м²) — нет подходящих площадей."
)
return {
"meta": {
"market": "primary_ddu",
"region_code": int(_SUPPORTED_SUBJECT),
"calculation_method": method,
"period_type": "Q",
},
"table": [],
"graph": [],
"notes": notes,
}
# Validate period bounds against the documented ARN 'YYYY-QN' format before
# binding them. period_value is compared lexicographically (it is text); a
# malformed bound ('foo', '2026') would silently drop rows, so drop the bad
# bound and explain it in notes (this endpoint's convention is notes, not 422).
if period_from and not _PERIOD_RE.match(period_from):
notes.append(
f"periodFrom={period_from!r} не в формате 'YYYY-QN' (напр. '2025-Q2') "
f"— граница проигнорирована."
)
period_from = None
if period_to and not _PERIOD_RE.match(period_to):
notes.append(
f"periodTo={period_to!r} не в формате 'YYYY-QN' (напр. '2026-Q1') "
f"— граница проигнорирована."
)
period_to = None
# Inverted range (from > to) yields an empty table with no signal otherwise.
# Lexicographic comparison is correct here because the format is zero-padded
# 'YYYY-QN'.
if period_from and period_to and period_from > period_to:
notes.append(
f"periodFrom={period_from!r} > periodTo={period_to!r} — границы "
f"перепутаны (диапазон инвертирован), результат пуст."
)
period_filter = ""
if period_from:
@ -184,8 +234,10 @@ def get_ddu_indicator(
.mappings()
.all()
)
except OperationalError:
except (ProgrammingError, OperationalError):
# Most likely the MV does not exist yet (migration 152 not applied).
# A missing relation is SQLSTATE 42P01 (UndefinedTable) → ProgrammingError;
# OperationalError is kept for connection-level failures.
logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)")
raise

View file

@ -186,7 +186,7 @@ def detect_velocity_anomalies(
AVG(realised) FILTER (WHERE rn <= :recent_window) AS recent_mean,
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
STDDEV_SAMP(realised) FILTER (WHERE rn > :recent_window) AS prior_std,
COUNT(*) FILTER (WHERE rn > :recent_window) AS prior_n
COUNT(realised) FILTER (WHERE rn > :recent_window) AS prior_n
FROM ranked
GROUP BY obj_id, n_months
),

View file

@ -79,46 +79,83 @@ def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]:
"""source: 'portfolio' (что строится) or 'deals' (реально покупают)."""
"""source: 'portfolio' (что строится) or 'deals' (реально покупают).
Оба источника возвращают одни и те же 5 area-бакетов, выровненных с осью
QuartirographyChart ("Студии 15-30" / "1-к 30-45" / "2-к 45-60" /
"3-к 60-80" / "80+ м²").
Портфель: domrf_flat_area_distribution (RF-wide snapshot, region_id=0 т.к.
API DOM.РФ игнорирует ?regionId на этом эндпоинте). Бакеты DOM.РФ (FROM_0_TO_25
FROM_100) маппируются на chart-бакеты по средней площади:
FROM_0_TO_25 (~22 м²) "Студии 15-30"
FROM_25_TO_35 (~31 м², мелкие однушки) "1-к 30-45"
FROM_35_TO_45 (~40 м²) "1-к 30-45"
FROM_45_TO_55 (~50 м²) "2-к 45-60"
FROM_55_TO_70 (~62 м²) "3-к 60-80"
FROM_70_TO_85 (~77 м²) "3-к 60-80"
FROM_85_TO_100 (~92 м²) "80+ м²"
FROM_100 (100+ м²) "80+ м²"
"""
if source == "portfolio":
rows = (
db.execute(
text(
"""
SELECT room_count_type, flat_count, area_sqm, percent
FROM domrf_region_aggregates
WHERE region_id = :region_id
AND snapshot_date = (
SELECT MAX(snapshot_date)
FROM domrf_region_aggregates
WHERE region_id = :region_id
)
AND room_count_type <> 'TOTAL'
ORDER BY CASE room_count_type
WHEN 'ONE' THEN 1
WHEN 'TWO' THEN 2
WHEN 'THREE' THEN 3
WHEN 'FOUR' THEN 4
END
WITH latest AS (
SELECT MAX(snapshot_date) AS snap
FROM domrf_flat_area_distribution
WHERE region_id = 0
),
mapped AS (
SELECT
CASE area_bucket
WHEN 'FROM_0_TO_25' THEN '1-Студия'
WHEN 'FROM_25_TO_35' THEN '2-1-к'
WHEN 'FROM_35_TO_45' THEN '2-1-к'
WHEN 'FROM_45_TO_55' THEN '3-2-к'
WHEN 'FROM_55_TO_70' THEN '4-3-к'
WHEN 'FROM_70_TO_85' THEN '4-3-к'
WHEN 'FROM_85_TO_100' THEN '5-80+ м²'
WHEN 'FROM_100' THEN '5-80+ м²'
END AS chart_bucket,
flat_count,
area_sqm
FROM domrf_flat_area_distribution
CROSS JOIN latest
WHERE region_id = 0
AND room_count_type = 'TOTAL'
AND snapshot_date = latest.snap
)
SELECT chart_bucket,
SUM(flat_count)::bigint AS flat_count,
SUM(area_sqm) AS area_sqm
FROM mapped
WHERE chart_bucket IS NOT NULL
GROUP BY chart_bucket
ORDER BY chart_bucket
"""
),
{"region_id": region_id},
{},
)
.mappings()
.all()
)
_pretty = {
"1-Студия": "Студии 15-30",
"2-1-к": "1-к 30-45",
"3-2-к": "2-к 45-60",
"4-3-к": "3-к 60-80",
"5-80+ м²": "80+ м²",
}
total_flats = sum(r["flat_count"] or 0 for r in rows) or 1
return [
{
"bucket": {
"ONE": "1-к",
"TWO": "2-к",
"THREE": "3-к",
"FOUR": "4+",
}.get(r["room_count_type"], r["room_count_type"]),
"flat_count": r["flat_count"],
"bucket": _pretty[r["chart_bucket"]],
"flat_count": int(r["flat_count"] or 0),
"area_sqm": _f(r["area_sqm"]),
"percent": r["percent"],
"avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None,
"percent": round((r["flat_count"] or 0) * 100 / total_flats, 1),
"avg_area": (_f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None),
}
for r in rows
]
@ -147,8 +184,7 @@ def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[s
-- ('2025-07-01' расширял «recent»-окно каждую неделю по мере
-- доливки ETL новых report_months перекос в сторону всё
-- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL.
AND period_start_date >= NOW()
- (:months_window || ' months')::INTERVAL
AND period_start_date >= NOW() - make_interval(months => :months_window)
),
bucketed AS (
SELECT CASE
@ -1132,7 +1168,7 @@ def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]
"""
SELECT month, source, leads, engaged, converted, conv_pct
FROM prinzip_funnel_monthly
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
WHERE month >= (CURRENT_DATE - make_interval(months => :months))::date
ORDER BY month DESC, leads DESC
"""
),
@ -1166,7 +1202,7 @@ def prinzip_funnel_by_source(db: Session, months: int = 12) -> list[dict[str, An
SUM(converted) AS converted,
ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct
FROM prinzip_funnel_monthly
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
WHERE month >= (CURRENT_DATE - make_interval(months => :months))::date
GROUP BY source
ORDER BY leads DESC
"""
@ -1327,8 +1363,7 @@ _BUCKET_SQL = text(
AND deal_count > 0
AND (area / deal_count) BETWEEN 15 AND 200
AND price_per_sqm BETWEEN 30000 AND 1000000
AND period_start_date >= NOW()
- (:months_window || ' months')::INTERVAL
AND period_start_date >= NOW() - make_interval(months => :months_window)
),
bucketed AS (
SELECT CASE
@ -1958,7 +1993,7 @@ def _elasticity_coef(
{where_district}
{where_class}
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
AND crm.report_month >= NOW() - (:ew || ' months')::interval
AND crm.report_month >= NOW() - make_interval(months => :ew)
)
SELECT
regr_slope(y, x) AS slope,
@ -2049,7 +2084,7 @@ def _elasticity_per_bucket_coef(
{where_class}
AND crm.deals_total_count > 0
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
AND crm.report_month >= NOW() - (:ew || ' months')::interval
AND crm.report_month >= NOW() - make_interval(months => :ew)
)
SELECT bucket,
regr_slope(y, x) AS slope,
@ -2632,9 +2667,7 @@ def recommend_mix(
else:
# Rosreestr fallback aggregate: district+class deals / months / n_comp =
# среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
market_vel_pm = (
(total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
)
market_vel_pm = (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
warnings.append(
f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."
@ -2786,6 +2819,25 @@ def recommend_mix(
if b["bucket"] != top_bucket_name:
b["share_pct"] = round(b["share_pct"] * scale, 1)
# #1576: success-boost изменил share_pct → средневзвешенная цена
# должна пересчитаться под новые доли, иначе weighted_avg_price
# остаётся от ДО-boost микса (пробел в фиксе #1359, который
# обновлял только units/revenue). price_median_per_m2 уже включает
# combined_price_factor (line 2743), поэтому домножать НЕ нужно —
# это согласовано с per-bucket revenue ниже. Веса = area_avg×share,
# независимо от area_total_m2, поэтому вне ветки area_total_m2.
wnum = sum(
b["_area_avg_raw"] * b["share_pct"] * b["price_median_per_m2"]
for b in buckets
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
)
wden = sum(
b["_area_avg_raw"] * b["share_pct"]
for b in buckets
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
)
weighted_avg_price = round(wnum / wden, 2) if wden > 0 else None
# #1359: success-boost изменил share_pct → перераспределяем
# units/revenue/months_to_sellout и агрегаты под новые доли,
# иначе share_pct рассогласуется с units_planned/revenue/sellout

View file

@ -247,21 +247,11 @@ async def harvest_quarter(
db.commit()
update_progress(done_progress)
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
# Выполняем после основного grid-walk (Phase 2-3). Требует bbox квартала.
quarter_bbox = quarter_bbox_3857(db, quarter)
if quarter_bbox is not None:
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
try:
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
tz_count = _save_territorial_zones(db, quarter, tz_features)
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
if stats_features:
upsert_quarter_stats(db, quarter, stats_features[0])
@ -276,6 +266,22 @@ async def harvest_quarter(
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
db.commit()
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
quarter_bbox = quarter_bbox_3857(db, quarter)
if quarter_bbox is not None:
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
try:
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
tz_count = _save_territorial_zones(db, quarter, tz_features)
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
@ -344,6 +350,11 @@ async def _grid_walk_category(
grid_points = generate_grid_click_points(bbox, grid_size=grid_size, tile_size=tile_size)
# Bug #1584: считаем discovered ТОЛЬКО для таблицы запрошенного layer_id, а не
# sum(stats.values()). Иначе skipped/чужекатегорийные features завышают счётчик,
# ошибочно приписываясь к parcels/buildings вызывающим (harvest_quarter:230-233).
layer_count_key = "parcels" if layer_id == CAT_PARCEL else "buildings"
discovered_cads: set[str] = set()
upserted = 0
requests = 0
@ -411,7 +422,7 @@ async def _grid_walk_category(
try:
with db.begin_nested():
stats = upsert_features(db, [feature], source="wms_grid_walk")
upserted += sum(stats.values())
upserted += stats[layer_count_key]
except Exception as e:
logger.warning(
"_grid_walk_category: upsert failed cad=%s layer=%d: %s",
@ -544,9 +555,7 @@ async def backfill_parcel_geom(
# Один сбойный квартал не валит весь backfill — лог + продолжаем.
# (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
# caller-task ловит и не ретраит, как в bulk_harvest.)
logger.warning(
"backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e
)
logger.warning("backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e)
db.rollback()
continue
@ -1423,7 +1432,7 @@ def upsert_quarter_stats(
"cost_value_total_geom": _safe_numeric(raw_opts.get("cost_value_total_geom")),
"sum_land_area": _safe_numeric(raw_opts.get("sum_land_area")),
"sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_area")),
"date_cr": raw_opts.get("date_cr"),
"date_cr": _parse_nspd_date(raw_opts.get("date_cr")),
"real_srid": _safe_int(raw_opts.get("real_srid")),
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
},
@ -1466,6 +1475,21 @@ def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict])
zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone")
permitted_use = props.get("permitted_use") or props.get("vri")
# cad_territorial_zones.geom — geography(MultiPolygon, 4326).
# Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString →
# geography-INSERT fail → SAVEPOINT откат → строка дропается молча.
# Зеркалит фильтр upsert_zouit (~1196).
geom_type = geom.get("type") if isinstance(geom, dict) else None
if geom_type not in ("Polygon", "MultiPolygon"):
if geom_type:
logger.info(
"_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)"
" — geom=NULL",
zone_id,
geom_type,
)
geom_geojson = None
try:
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
@ -1484,9 +1508,11 @@ def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict])
CAST(:permitted_use AS text),
CAST(:raw_props AS jsonb),
CASE WHEN CAST(:geom AS text) IS NOT NULL
THEN ST_Transform(
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
4326
THEN ST_Multi(
ST_Transform(
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
4326
)
)::geography
ELSE NULL
END

View file

@ -7,12 +7,16 @@
from __future__ import annotations
import logging
import math
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Радиус сферы Web Mercator (EPSG:3857), м. Нужен для инверсии Y → широта.
_WEB_MERCATOR_R = 6378137.0
def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None:
"""Получить bbox квартала из cad_quarters_geom в EPSG:3857.
@ -56,8 +60,19 @@ def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, f
# geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
# broken — snapshot phase даст 20 per cat, что норм для MVP.
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
#
# Bug #1629: EPSG:3857 (Web Mercator) — конформная, НЕ равнопротяжённая
# проекция. Линейный масштаб = sec(lat); на широте ЕКБ (~56-60°N) фактор
# ≈1.8-2.0, т.е. 1 наземный метр ≈ 1.8-2.0 единиц 3857. Пороги ниже заданы
# в НАЗЕМНЫХ метрах, поэтому переводим протяжённость bbox из единиц 3857 в
# наземные метры умножением на cos(lat) (lat берём из центра bbox через
# инверсию Y). Иначе верхняя граница 10000 отсекала бы реальные крупные/
# вытянутые кварталы (10000 единиц 3857 ≈ всего ~5050 м земли на широте ЕКБ).
y_mid = (bbox[1] + bbox[3]) / 2.0
lat_mid = 2.0 * math.atan(math.exp(y_mid / _WEB_MERCATOR_R)) - math.pi / 2.0
ground_scale = math.cos(lat_mid) # ground_m = mercator_units * cos(lat)
width = (bbox[2] - bbox[0]) * ground_scale
height = (bbox[3] - bbox[1]) * ground_scale
if width < 100 or height < 100 or width > 10000 or height > 10000:
logger.warning(
"quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm "

View file

@ -151,8 +151,8 @@ def _fmt_number(value: Any) -> str | None:
if isinstance(value, int):
return _fmt_thousands(value)
if isinstance(value, float):
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
return str(value)
if not math.isfinite(value): # NaN/Inf — не число: честно пропускаем (как None),
return None # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585)
if value == int(value):
return _fmt_thousands(value)
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
@ -251,7 +251,7 @@ def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
if summary:
lines.append(str(summary))
if len(sections_used) == 1 and not any(
if not any(
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
):
lines.append("Раздел рекомендации продукта в отчёте пуст.")
@ -266,16 +266,21 @@ def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
future = report.get("future_market")
if isinstance(future, dict):
sections_used.append("future_market")
future_emitted = False # секцию в provenance кладём только если выведена строка (#1630)
horizons = future.get("forecasts_by_horizon")
if isinstance(horizons, list) and horizons:
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
future_emitted = True
future_supply = future.get("future_supply")
if isinstance(future_supply, dict) and future_supply:
lines.append("Учтено давление будущего предложения (выходящие проекты).")
future_emitted = True
summary = future.get("summary")
if summary:
lines.append(str(summary))
future_emitted = True
if future_emitted:
sections_used.append("future_market")
else:
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))

View file

@ -165,6 +165,18 @@ def orchestrate_chat(
continue
# Нет tool_calls → финальная проза.
# Проверяем finish_reason: 'length' / 'content_filter' означают обрезанный
# или отфильтрованный ответ — неполная проза клиенту не отдаётся (LLMResult
# docstring §64: «консьюмер ОБЯЗАН проверять даже при ok=True»).
if result.finish_reason not in (None, "stop", "end_turn", "tool_calls"):
logger.warning(
"chat: non-stop finish_reason=%r for cad=%s run=%s → deterministic fallback",
result.finish_reason,
cad_num,
run_id,
)
return _deterministic(report_dict, message, f"finish_reason:{result.finish_reason}")
answer = (result.content or "").strip()
if not answer:
# Модель вернула пустоту — пустой ответ клиенту не отдаём, деградируем.

View file

@ -94,6 +94,7 @@ def find_match_candidates(
objective_distinct AS (
SELECT DISTINCT project_name
FROM objective_corpus_room_month
WHERE group_name = 'Екатеринбург'
)
SELECT
d.obj_id,
@ -168,7 +169,7 @@ def auto_apply_matches(
len(auto),
len(review),
)
return {"auto_accepted": 0, "review_queue": len(review), "skipped": 0}
return {"auto_accepted": len(auto), "review_queue": len(review), "skipped": 0}
inserted = 0
skipped = 0

View file

@ -61,7 +61,9 @@ _FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
# основного горизонта как сводный показатель сценария.
# основного горизонта как сводный показатель. При fallback на иной горизонт ячейка
# несёт «(гор. N мес)» через _scenario_deficit_cell, чтобы шапка «(12 мес)» не лгала
# (#1590).
_PRIMARY_HORIZON_MONTHS: int = 12
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
@ -140,7 +142,7 @@ def _fmt(value: Any) -> Any:
return _sanitize_formula(value)
if isinstance(value, int):
return value
if isinstance(value, (dict, list)):
if isinstance(value, dict | list):
return _sanitize_formula(str(value))
return value
@ -208,6 +210,48 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
return None
def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None:
"""Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE.
Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес первый с не-None
дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала
при fallback на чужой горизонт. Нет дефицита None (#1590, зеркало report_pdf.py).
"""
forecasts = _as_list(payload.get("forecasts"))
primary = next(
(
f
for f in forecasts
if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
),
None,
)
if primary is not None and primary.get("deficit_index") is not None:
return _PRIMARY_HORIZON_MONTHS
for f in forecasts:
if isinstance(f, dict) and f.get("deficit_index") is not None:
h = f.get("horizon_months")
return h if isinstance(h, int) else None
return None
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. При отсутствии 12-мес прогноза `_scenario_deficit_index` делает fallback на чужой
горизонт помечаем ячейку реальным горизонтом, чтобы число под общей шапкой
«(12 мес)» не читалось как 12-месячное. Дефицит None отдаём как есть ( "").
Зеркалит report_pdf._scenario_deficit_cell (#1590).
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
# ──────────────────────────────────────────────────────────────────────────────
# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка
# (мутируют переданный лист). Не ходят в БД/сеть.
@ -481,12 +525,14 @@ def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
row += 1
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
# _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт
# «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590).
row = _write_title(ws, row, "Сводка по сценариям")
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
table_rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
table_rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
table_rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
_write_table(ws, row, headers, table_rows)
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])

View file

@ -10,7 +10,7 @@
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из
`report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря,
`_fmt` строковое форматирование ячейки/None"", `_level_ru`, `_as_dict`/`_as_list`,
`_future_supply_pairs`, `_scenario_deficit_index`) + переиспользуем его named-константы
`_future_supply_pairs`, `_scenario_deficit_cell`) + переиспользуем его named-константы
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные
микро-билдеры (заголовки/абзацы/таблицы python-docx).
@ -44,7 +44,7 @@ from app.services.exporters.report_pdf import (
_future_supply_pairs,
_level_ru,
_normalize,
_scenario_deficit_index,
_scenario_deficit_cell,
)
if TYPE_CHECKING:
@ -76,7 +76,8 @@ _TABLE_STYLE: str = "Table Grid"
_COMPETITORS_TOP_N: int = 5
# Основной продуктовый горизонт (мес) — подпись столбца сводного дефицита сценария
# (зеркало report_md._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index).
# (зеркало report_md._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell —
# при fallback на чужой горизонт ячейка несёт «(гор. N мес)», #1590).
_PRIMARY_HORIZON_MONTHS: int = 12
@ -272,7 +273,7 @@ def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None:
rate_str = (
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
)
rows.append([name, _scenario_deficit_index(data), rate_str, data.get("advisory")])
rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
headers = [
"Сценарий",

View file

@ -39,6 +39,7 @@ from app.services.exporters.report_pdf import (
_future_supply_pairs,
_level_ru,
_normalize,
_scenario_deficit_horizon,
_scenario_deficit_index,
)
@ -109,10 +110,7 @@ def _md_table(headers: list[str], rows: list[list[Any]]) -> str:
if not rows:
empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |"
return "\n".join([head, sep, empty])
body = [
"| " + " | ".join(_md_cell(cell) for cell in row) + " |"
for row in rows
]
body = ["| " + " | ".join(_md_cell(cell) for cell in row) + " |" for row in rows]
return "\n".join([head, sep, *body])
@ -123,10 +121,17 @@ def _md_kv_table(data: dict[str, Any]) -> str:
def _md_kv_lines(pairs: list[tuple[str, Any]]) -> str:
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE."""
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE.
Переводы строк в значении сворачиваем в пробел (по аналогии с `_md_cell`):
иначе многострочный value (напр. product_tz.summary) разорвал бы буллет.
"""
if not pairs:
return _NO_DATA
return "\n".join(f"- **{label}:** {_fmt(value)}" for label, value in pairs)
return "\n".join(
f"- **{label}:** {_fmt(value).replace(chr(10), ' ').replace(chr(13), ' ')}"
for label, value in pairs
)
def _join_horizons(values: list[Any]) -> Any:
@ -243,6 +248,22 @@ def _build_future_market(report: dict[str, Any]) -> str:
)
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. Зеркало report_pdf._scenario_deficit_cell: при fallback `_scenario_deficit_index`
на чужой горизонт (нет 12-мес прогноза) помечаем число реальным горизонтом, чтобы под
общей шапкой «(12 мес)» оно не читалось как 12-месячное. Дефицит None как есть ( "").
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
def _build_scenarios(report: dict[str, Any]) -> str:
"""§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
scenarios = _as_dict(report.get("scenarios"))
@ -252,12 +273,8 @@ def _build_scenarios(report: dict[str, Any]) -> str:
for name, payload in by_scenario.items():
data = _as_dict(payload)
rate_path = _as_dict(data.get("rate_path"))
rate_str = (
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
)
rows.append(
[name, _scenario_deficit_index(data), rate_str, data.get("advisory")]
)
rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
headers = [
"Сценарий",
@ -266,9 +283,7 @@ def _build_scenarios(report: dict[str, Any]) -> str:
"Advisory",
]
return (
f"## {_TITLE_SCENARIOS}\n\n"
f"{_fmt(scenarios.get('summary'))}\n\n"
f"{_md_table(headers, rows)}"
f"## {_TITLE_SCENARIOS}\n\n{_fmt(scenarios.get('summary'))}\n\n{_md_table(headers, rows)}"
)
@ -290,9 +305,7 @@ def _build_product_tz(report: dict[str, Any]) -> str:
if isinstance(m, dict)
]
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
reason_rows = [
[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)
]
reason_rows = [[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)]
return (
f"## {_TITLE_PRODUCT_TZ}\n\n"
@ -444,7 +457,9 @@ def _tg_scenario_deficit_line(report: dict[str, Any]) -> str | None:
parts: list[str] = []
for name in ordered:
label = _TG_SCENARIO_RU.get(name, str(name))
deficit = _scenario_deficit_index(_as_dict(by_scenario.get(name)))
# _scenario_deficit_cell сам помечает «(гор. N мес)» при fallback на чужой горизонт,
# чтобы метка периода в чате не лгала (см. _build_scenarios).
deficit = _scenario_deficit_cell(_as_dict(by_scenario.get(name)))
parts.append(f"{label} {_fmt(deficit)}")
return " · ".join(parts) if parts else None
@ -504,8 +519,20 @@ def render_report_telegram_summary(report: Any) -> str:
# Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии).
scenario_line = _tg_scenario_deficit_line(data)
if scenario_line is not None:
horizons = _as_list(meta.get("horizons"))
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if horizons else ""
# Метку «(гор. 12 мес)» ставим, только если ВСЕ выведенные дефициты реально взяты
# с основного 12-мес горизонта; при fallback на чужой горизонт у сценария метка
# «(гор. N мес)» уже стоит в самой строке (см. _scenario_deficit_cell), а общая
# метка молчит, чтобы не лгать про период (баг #1590).
by_scenario = _as_dict(_as_dict(data.get("scenarios")).get("by_scenario"))
with_deficit = [
_as_dict(payload)
for payload in by_scenario.values()
if _scenario_deficit_index(_as_dict(payload)) is not None
]
all_primary = bool(with_deficit) and all(
_scenario_deficit_horizon(p) == _PRIMARY_HORIZON_MONTHS for p in with_deficit
)
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if all_primary else ""
lines.append(f"• Дефицит по сценариям{horizon_note}: {scenario_line}")
# Рекомендованный класс продукта (если задан).

View file

@ -458,6 +458,9 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» есть
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет None ( "").
NB: эту функцию импортируют report_md/docx/pptx НЕ менять сигнатуру (вернёт скаляр).
Для подписи фактического горизонта используй `_scenario_deficit_horizon`.
"""
forecasts = _as_list(payload.get("forecasts"))
primary = next(
@ -476,6 +479,49 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
return None
def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None:
"""Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE.
Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес первый с не-None
дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала
при fallback на чужой горизонт. Нет дефицита None. NB: сигнатура
`_scenario_deficit_index` (импортируется в report_md/docx/pptx) НЕ меняется это
отдельная чистая функция-зеркало.
"""
forecasts = _as_list(payload.get("forecasts"))
primary = next(
(
f
for f in forecasts
if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
),
None,
)
if primary is not None and primary.get("deficit_index") is not None:
return _PRIMARY_HORIZON_MONTHS
for f in forecasts:
if isinstance(f, dict) and f.get("deficit_index") is not None:
h = f.get("horizon_months")
return h if isinstance(h, int) else None
return None
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. При отсутствии 12-мес прогноза `_scenario_deficit_index` делает fallback на чужой
горизонт помечаем ячейку реальным горизонтом, чтобы число под общей шапкой
«(12 мес)» не читалось как 12-месячное. Дефицит None отдаём как есть ( "").
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
def _build_scenarios(report: dict[str, Any]) -> str:
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
scenarios = _as_dict(report.get("scenarios"))
@ -484,9 +530,9 @@ def _build_scenarios(report: dict[str, Any]) -> str:
rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
return f"""
<div class="section" id="scenarios">
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>

View file

@ -12,7 +12,7 @@
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из
`report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря,
`_fmt` строковое форматирование значения/None"", `_level_ru`, `_as_dict`/`_as_list`,
`_scenario_deficit_index`) + переиспользуем его named-константы
`_scenario_deficit_cell`) + переиспользуем его named-константы
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные
микро-билдеры (слайды/буллеты/таблица python-pptx).
@ -46,7 +46,7 @@ from app.services.exporters.report_pdf import (
_fmt,
_level_ru,
_normalize,
_scenario_deficit_index,
_scenario_deficit_cell,
)
if TYPE_CHECKING:
@ -78,7 +78,8 @@ _LAYOUT_TITLE_CONTENT: int = 1
_LAYOUT_TITLE_ONLY: int = 5
# Основной продуктовый горизонт (мес) — подпись сводного дефицита сценария
# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index).
# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell —
# при fallback на чужой горизонт буллет несёт «(гор. N мес)», #1590).
_PRIMARY_HORIZON_MONTHS: int = 12
# Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно).
@ -235,7 +236,7 @@ def _build_scenarios(prs: _PptxPresentation, report: dict[str, Any]) -> None:
rate_str = (
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH
)
deficit = _fmt(_scenario_deficit_index(data))
deficit = _fmt(_scenario_deficit_cell(data))
bullets.append(
(str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}")
)

View file

@ -166,12 +166,14 @@ def generate_snapshot_pdf(
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else ""
poi_items = _build_poi_items(poi_rows, limit=7)
# Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC;
# переупорядочиваем по distance_m для удобства чтения)
# Конкуренты — берём топ N БЛИЖАЙШИХ. competitor_rows приходят отсортированными
# по flat_count DESC (крупнейшие ЖК), поэтому сначала пересортировываем весь
# список по distance_m ASC, и лишь затем срезаем N — иначе в блок попадали бы
# 5 крупнейших из радиуса, а не непосредственное конкурентное окружение пятна.
competitors_display = sorted(
competitor_rows[:competitors_limit],
competitor_rows,
key=lambda r: float(r.get("distance_m") or 0),
)
)[:competitors_limit]
competitors_ctx: list[dict[str, Any]] = [
{
"comm_name": r.get("comm_name"),

View file

@ -58,7 +58,11 @@ def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str:
for lot in lots:
date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else ""
dom_val = str(lot.days_on_market) if lot.days_on_market is not None else ""
floor_val = f"{lot.floor}/{lot.total_floors}" if lot.floor and lot.total_floors else ""
floor_val = (
f"{lot.floor}/{lot.total_floors}"
if lot.floor is not None and lot.total_floors is not None
else ""
)
label = "Дата сделки" if is_deal else "В продаже"
_ = label # used for header only
rows.append(

View file

@ -294,8 +294,10 @@ def compute_affordability(
усреднение НЕнулевых месяцев). None платёж None (graceful).
monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
months=_ANNUITY_TERM_MONTHS).
payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h]) платёж на
каждом горизонте сценарной ставки (None rate_path поле None).
payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h] + спред)
платёж на каждом горизонте сценарной КЛЮЧЕВОЙ ставки ЦБ, приведённой к той
же рыночной базе, что и monthly_payment_rub (key_rate + калиброванный спред
4.5 п.п.); None rate_path поле None (#1639).
Graceful: нет ставки/цены платёж None; ставка 0 аннуитет деградирует к
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
@ -304,8 +306,10 @@ def compute_affordability(
db: SQLAlchemy sync Session.
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
price_per_m2: цена /м² (None берём сегментную среднюю reuse-ом).
rate_path: сценарный {horizon: годовая ставка %} для payment_at_scenario;
None payment_at_scenario None.
rate_path: сценарный {horizon: КЛЮЧЕВАЯ ставка ЦБ %} для payment_at_scenario
(контракт #952/#984: конверт key_rate, НЕ рыночная — к ней внутри
добавляется тот же спред _KEY_RATE_MARKET_SPREAD_PP, что и в базовом
monthly_payment_rub); None payment_at_scenario None.
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
@ -337,7 +341,18 @@ def compute_affordability(
if rate_path is not None:
scenario: dict[int, float] = {}
for horizon, scenario_rate in rate_path.items():
payment = _annuity(principal, scenario_rate, _ANNUITY_TERM_MONTHS)
# rate_path[h] = КЛЮЧЕВАЯ ставка ЦБ сценария (#952/#984 контракт:
# demand_supply_forecast.py:586, scenarios.py — конверт key_rate), НЕ
# рыночная. Приводим к той же рыночной базе, что и monthly_payment_rub:
# key_rate + калиброванный спред (_current_market_rate, строка 251).
# Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п.
# ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639).
market_scenario_rate = (
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP
if scenario_rate is not None
else None
)
payment = _annuity(principal, market_scenario_rate, _ANNUITY_TERM_MONTHS)
if payment is not None:
scenario[horizon] = payment
payment_at_scenario = scenario

View file

@ -367,9 +367,12 @@ def _build_rationale(
if advisory_capped and level == _ADVISORY_CEILING:
# Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
# _F_ADVISORY_CAP-фактор уже проговорён в base — исключаем его ноту из «также»,
# иначе advisory-cap-сообщение дублируется (частый all-high случай).
other = [f.note for f in drag if f.name != _F_ADVISORY_CAP]
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
if notes:
base += "; также " + _join_notes(notes)
if other:
base += "; также " + _join_notes(other)
return base + "."
if not notes:
@ -409,6 +412,7 @@ def compute_report_confidence(
*,
component_confidences: Sequence[ComponentConfidence] | None = None,
deal_count: int | None = None,
deal_count_months: int | None = None,
analog_count: int | None = None,
domrf_coverage: float | None = None,
history_months: int | None = None,
@ -439,6 +443,8 @@ def compute_report_confidence(
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
имя сервиса проносится сквозь стек в RU-метку фактора).
deal_count: число сделок за окно (None нет данных, тянет в low).
deal_count_months: окно наблюдения для deal_count (мес) добавляет «за N мес»
в ноту фактора («7 сделок за 6 мес мало»). None нота без периода.
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
domrf_coverage: доля domrfobjective [0,1] (главный sparse-риск проекта).
history_months: глубина ряда (мес).
@ -453,6 +459,7 @@ def compute_report_confidence(
# ── 1. Сырые счётчики качества данных → факторы (только заданные) ──────────
if deal_count is not None:
_deal_suffix = f"за {deal_count_months} мес" if deal_count_months is not None else ""
factors.append(
_factor_from_count(
_F_DEAL_COUNT,
@ -460,6 +467,7 @@ def compute_report_confidence(
high_at=_DEAL_COUNT_HIGH,
low_below=_DEAL_COUNT_LOW,
unit="сделок",
suffix=_deal_suffix,
)
)
if analog_count is not None:

View file

@ -36,9 +36,10 @@
МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard graceful, без усиления шума на тонких данных):
сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить
случайным шумом одного-двух наблюдений:
нужно _MIN_FULL_YEARS полных лет (по умолчанию 2) иначе у каждого месяца
< 2 наблюдений, «сезонность» неотличима от шума ВСЕ факторы = 1.0 (ряд
возвращается без изменений);
нужно _MIN_FULL_YEARS полных лет (по умолчанию 2) считаем только по
NON-ZERO месяцам (fill_month_grid ставит units=0 для «нет сделок», но 0 не
несёт сезонного сигнала); иначе у каждого месяца < 2 наблюдений, «сезонность»
неотличима от шума ВСЕ факторы = 1.0 (ряд возвращается без изменений);
месяц без наблюдений его фактор = 1.0 (нет базы для оценки);
overall_mean 0 (пустой/нулевой ряд) ВСЕ факторы = 1.0 (нет масштаба, и
защита от деления на ноль);
@ -114,7 +115,8 @@ def seasonal_factors(
None-значения в values пропускаются (нет наблюдения не подмешиваем 0).
GUARD (деградация к нейтрали 1.0, без деления на ноль / усиления шума):
полных лет < min_full_years ВСЕ факторы 1.0, applied=False;
полных лет (по NON-ZERO месяцам) < min_full_years ВСЕ факторы 1.0,
applied=False (fix #1638: zero-filled месяцы не считаются за наблюдения);
overall_mean 0 ВСЕ факторы 1.0 (нет масштаба / защита от /0);
месяц без наблюдений ИЛИ со средним 0 его фактор 1.0.
@ -167,15 +169,20 @@ def seasonal_factors(
def _count_full_years(months: list[date], values: list[float | int | None]) -> int:
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 наблюдение) в ряду.
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 NON-ZERO наблюдение).
Считаем по годам, где присутствует наблюдение в КАЖДОМ из 12 месяцев это
Считаем по годам, где есть НЕНУЛЕВОЕ наблюдение в КАЖДОМ из 12 месяцев это
«полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки
наблюдением НЕ считаются. PURE.
и нулевые значения (zero-filled месяцы из fill_month_grid) наблюдением НЕ
считаются: у ряда с units=0 нет сезонного сигнала для оценки факторов. PURE.
Это исправляет баг #1638: fill_month_grid выставляет units=0 для отсутствующих
месяцев (не None), поэтому старый guard, пропускавший только None, насчитывал
«полные годы» на фактически пустом ряду.
"""
months_seen: dict[int, set[int]] = {}
for d, v in zip(months, values, strict=False):
if v is None:
if v is None or float(v) == 0.0:
continue
months_seen.setdefault(d.year, set()).add(d.month)
return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR)

View file

@ -174,9 +174,10 @@ _MORTG_MAX_X_PCT: float = 25.0
# дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш).
_DIFF_TARGET_USP: float = 3.0
# commercial: commercial_share_pct ∈ [0,100] (§10.4 реализованная доля нежилого) → /100.
# Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): overlay.commercial.available=False
# → скор None (НЕ 0). available=True → share/100 как прокси силы спроса на коммерцию.
# commercial: commercial_sell_through_pct ∈ [0,100] (§10.4 темп распродажи нежилого,
# sell-through, прокси спроса) → /100. Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё):
# overlay.commercial.available=False → скор None (НЕ 0). available=True → sell_through/100
# как прокси силы спроса на коммерцию.
# confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] →
# усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 —
@ -463,21 +464,24 @@ def _score_differentiation(
def _score_commercial(
commercial: dict[str, Any] | None,
) -> tuple[float | None, Confidence, str]:
"""commercial ← recommendation §10.4 сигнал → share_pct/100. ДЕГРАДИРУЕТ в None. PURE.
"""commercial ← recommendation §10.4 сигнал → sell_through_pct/100. ДЕГРАДИРУЕТ в None. PURE.
overlay.commercial.available=True commercial_share_pct/100 (прокси спроса на
нежилое). available=False / None (objective ~ жильё, тонко) None (НЕ 0-как-заглушка).
confidence наследуется из сигнала (или 'low').
overlay.commercial.available=True commercial_sell_through_pct/100 (темп распродажи
нежилого, sell-through, прокси спроса). available=False / None (objective ~ жильё, тонко)
None (НЕ 0-как-заглушка). confidence наследуется из сигнала (или 'low').
"""
if not commercial or not commercial.get("available"):
return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)."
share_pct = commercial.get("commercial_share_pct")
if share_pct is None:
return None, "low", "Коммерческий сигнал §10.4 недоступен (доля не измерена)."
sell_through_pct = commercial.get("commercial_sell_through_pct")
if sell_through_pct is None:
return None, "low", "Коммерческий сигнал §10.4 недоступен (темп распродажи не измерен)."
raw_conf = commercial.get("confidence", "low")
conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low"
value = _clamp01(float(share_pct) / 100.0)
reason = f"Реализованная доля коммерции ~{round(float(share_pct), 1)}% (§10.4, прокси спроса)."
value = _clamp01(float(sell_through_pct) / 100.0)
reason = (
f"Темп распродажи нежилого ~{round(float(sell_through_pct), 1)}% (§10.4, прокси "
"ликвидности/спроса на нежилое; НЕ доля нежилого в объёме застройки)."
)
return value, conf, reason
@ -818,7 +822,10 @@ def _competitor_signal(
)
return None, None
weights = [c.relevance_weight for c in response.competitors if c.relevance_weight is not None]
return weights, len(response.competitors)
# #1595: count считаем по тем же конкурентам, у которых есть relevance_weight, иначе при
# частичных данных (None-веса) count > len(weights) → density завышена, future_competition
# занижен. В проде get_competitors всегда задаёт вес (число), но поле допускает None (мок).
return weights, len(weights)
def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:

View file

@ -28,9 +28,16 @@ LIVE-ENDPOINT-SAFE: НИКОГДА не бросает — на любом то
mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты
НЕизмеримы считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default
room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace
(0,1] это ПРОКСИ относительной силы спроса, НЕ supply-based дефицит;
`balance_units=None`, confidence='low', обязательный warning что supply НЕ учтён.
НИКОГДА не фабрикуем предложение/конкурентов из воздуха.
(0,1]; `balance_units=None`, confidence='low', обязательный warning что supply
НЕ учтён. НИКОГДА не фабрикуем предложение/конкурентов из воздуха.
#1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через
MarketMetrics.velocity_by_room (ед./мес из objective_lots per комнатность).
Маппинг forecast_bucket metric-ключи: _FORECAST_TO_METRIC_BUCKETS.
"80+ м²" = сумма bucket'ов "4" + "5+" (оба крупные форматы). Если данных
по конкретному bucket'у нет — fallback на агрегатный unit_velocity, что
лучше чем фабрикация 0. §9.4 norm.coefficient по-прежнему без per-bucket
фита (β фитится district×obj_class), поэтому он одинаков между bucket'ами
и СОКРАЩАЕТСЯ в pace/max_pace это честно задокументировано.
ИМПОРТЫ §9.x ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет
`forecasting/__init__` тянет `affordability`, который импортит
@ -68,6 +75,18 @@ _FORECAST_TO_LIVE_BUCKET: dict[str, str] = {
# дефолтной сетке room-bucket'ов §9.7 и для map_room_bucket_inverse).
_LIVE_TO_FORECAST_BUCKET: dict[str, str] = {v: k for k, v in _FORECAST_TO_LIVE_BUCKET.items()}
# #1593: forecast room-bucket → список ключей MarketMetrics.velocity_by_room.
# Ключи velocity_by_room — вокабуляр _room_bucket() из market_metrics
# ("студия","1","2","3","4","5+"). "80+ м²" накрывает 4-комнатные и 5+
# (большие квартиры), поэтому суммируем оба бакета.
_FORECAST_TO_METRIC_BUCKETS: dict[str, list[str]] = {
"Студии 15-30": ["студия"],
"1-к 30-45": ["1"],
"2-к 45-60": ["2"],
"3-к 60-80": ["3"],
"80+ м²": ["4", "5+"],
}
# Live target_class (вокабуляр schemas.recommend ClassLiteral, «человеческий»
# регистр) → forecast obj_class (3 массовых класса §9.7 _DEFAULT_CLASSES). None и
# незнакомое → None (вызывающий тогда отдаёт движку дефолтную сетку классов).
@ -565,9 +584,14 @@ def _demand_only_overlay(
БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса
pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient
per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace
(0,1] ПРОКСИ относительной силы спроса (НЕ supply-based дефицит).
balance_units=None, confidence='low', обязательный warning. НИКОГДА не
(0,1]. balance_units=None, confidence='low', обязательный warning. НИКОГДА не
фабрикуем supply. Graceful [].
#1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через
MarketMetrics.velocity_by_room. §9.4 norm.coefficient одинаков между
bucket'ами (β без room_bucket фита) и СОКРАЩАЕТСЯ в pace/max_pace — это
задокументировано, не скрыто. Ранкинг теперь отражает реальные per-bucket
темпы спроса из objective_lots, а не только §9.5 macro_coef.
"""
# Локальные импорты — избегаем import-cycle (см. module docstring).
from app.services.forecasting.demand_normalization import compute_demand_normalization
@ -581,9 +605,10 @@ def _demand_only_overlay(
]
mapped_class = map_class(target_class)
# Один раз на вызов: §9.2 наблюдаемый темп (base_pace) + hold-last-rate для §9.4.
# Один раз на вызов: §9.2 наблюдаемый темп (aggregate + per-bucket) + hold-last-rate.
metrics = compute_market_metrics(db, district=district)
base_pace = metrics.unit_velocity
base_pace = metrics.unit_velocity # агрегатный fallback (используется если нет per-bucket)
vel_by_room = metrics.velocity_by_room # #1593: per-bucket из objective_lots
macro = get_monthly_macro(db)
rate_future = hold_last_rate(macro, [horizon_months]).get(horizon_months)
@ -609,17 +634,38 @@ def _demand_only_overlay(
room_bucket=forecast_bucket,
district=district,
)
# §9.4 нормализация под будущий режим ставки (β внутри; rate_future None →
# деградирует к нейтрали внутри себя, передаём 0.0 как placeholder).
norm = compute_demand_normalization(
db, spec=spec, rate_future=rate_future if rate_future is not None else 0.0
)
# §9.2 #1593: per-bucket velocity из velocity_by_room (objective_lots
# per комнатность). _FORECAST_TO_METRIC_BUCKETS даёт список metric-ключей
# для данного forecast_bucket ("80+ м²" = "4"+"5+"). Суммируем ед./мес по
# бакетам. Честный 0 при известных бакетах — НЕ fallback. Fallback на
# агрегатный base_pace только когда velocity_by_room=None (пустая
# выборка) или bucket неизвестен в _FORECAST_TO_METRIC_BUCKETS.
metric_keys = _FORECAST_TO_METRIC_BUCKETS.get(forecast_bucket, [])
if vel_by_room is not None and metric_keys:
bucket_velocity: float = sum(vel_by_room.get(k, 0.0) for k in metric_keys)
# Если нет продаж ни в одном из ключей — честный 0 (НЕ fallback к
# aggregate: лучше показать реальный 0 чем маскировать его aggregate).
bucket_base_pace: float = bucket_velocity
else:
# vel_by_room None (пустая выборка) или неизвестный forecast_bucket →
# aggregate fallback сохраняет graceful поведение.
bucket_base_pace = float(base_pace)
# §9.4 нормализация под будущий режим ставки (β внутри). rate_future None
# (hold_last_rate не дал ставку) → НЕ применяем §9.4: 0.0-placeholder дал бы
# delta=rate_window_avg → exp(β·delta) и клэмп к _NORM_MAX (макс. аплифт),
# а НЕ нейтраль. Честная нейтраль при отсутствии будущей ставки = коэф. 1.0.
if rate_future is not None:
norm_coefficient = compute_demand_normalization(
db, spec=spec, rate_future=rate_future
).coefficient
else:
norm_coefficient = 1.0
# §9.5 макро-коэффициент (ортогонален β); профиль — класс + room_bucket.
profile: dict[str, Any] = {"room_bucket": forecast_bucket}
if mapped_class is not None:
profile["obj_class"] = mapped_class
macro_coef = compute_macro_coefficient(db, segment_profile=profile)
pace = base_pace * norm.coefficient * macro_coef.coefficient
pace = bucket_base_pace * norm_coefficient * macro_coef.coefficient
paces.append((live_bucket, mapped_class, pace))
max_pace = max((p for _, _, p in paces), default=0.0)
@ -635,7 +681,10 @@ def _demand_only_overlay(
demand_only=True,
)
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1] (ПРОКСИ, НЕ supply-based).
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1].
# #1593: base_pace теперь per-bucket из velocity_by_room → ранкинг отражает
# реальный спрос по комнатности. §9.4 norm одинаков между bucket'ами и
# сокращается в pace/max_pace (β без per-bucket фита — честно задокументировано).
paces.sort(key=lambda t: t[2], reverse=True)
ranked_segments: list[dict[str, Any]] = [
{
@ -649,12 +698,13 @@ def _demand_only_overlay(
]
logger.info(
"forecast-overlay demand_only: district=%s horizon=%d ranked=%d base_pace=%.3f "
"(ADVISORY, supply НЕ учтён)",
"forecast-overlay demand_only: district=%s horizon=%d ranked=%d "
"base_pace_agg=%.3f vel_by_room=%s (ADVISORY, supply НЕ учтён, #1593 per-bucket)",
district,
horizon_months,
len(ranked_segments),
base_pace,
vel_by_room,
)
return _enrich_overlay(
db,
@ -698,13 +748,15 @@ def _overlay(
def _commercial_signal(
db: Session, district: str | None, horizon_months: int
) -> dict[str, Any] | None:
"""§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful.
"""§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest.
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
"нежилое")`. objective покрывает в основном жильё выборка обычно тонкая. Тогда
возвращаем degraded-honest {available: False, caveat, advisory} НЕ фабрикуем число.
Если данных достаточно ( _COMMERCIAL_MIN_LOTS лотов) советующая оценка доли
коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason.
Если данных достаточно ( _COMMERCIAL_MIN_LOTS лотов) советующая оценка ТЕМПА
РАСПРОДАЖИ нежилого: sell_through_pct = проданные ÷ (проданные+доступные)·100
(market_metrics §-определение) прокси ликвидности/спроса на нежилое, НЕ доля
нежилого в объёме застройки + §16-подобный reason.
НИКОГДА не бросает: любой сбой движка/импорта degraded-honest None-сигнал.
Args:
@ -747,22 +799,26 @@ def _commercial_signal(
)
return {"available": False, "caveat": caveat, "advisory": True}
# Достаточно данных: советующая оценка реализованной доли коммерции (прокси).
share_pct = round(sell_through, 1)
# Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (прокси
# ликвидности/спроса), НЕ доли нежилого в объёме застройки (#1635).
sell_through_pct = round(sell_through, 1)
confidence = confidence if confidence in ("high", "medium", "low") else "low"
return {
"available": True,
"premise_kind": _COMMERCIAL_PREMISE_KIND,
"commercial_share_pct": share_pct,
# #1635: ключ = ТЕМП РАСПРОДАЖИ нежилого (sell_through, прокси
# ликвидности/спроса), НЕ доля нежилого в объёме застройки.
"commercial_sell_through_pct": sell_through_pct,
"n_lots": n_lots,
"confidence": confidence,
"reason": {
"why": (
f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} "
f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)."
f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} "
f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на "
f"нежилые помещения, НЕ доля нежилого в объёме застройки)."
),
"drivers": [
{"factor": "sell_through_pct", "value": share_pct, "direction": "+"},
{"factor": "sell_through_pct", "value": sell_through_pct, "direction": "+"},
{"factor": "n_lots", "value": n_lots, "direction": "+"},
],
"rejected": [],
@ -780,14 +836,14 @@ def _as_int(value: Any) -> int | None:
Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой
нечисловой/bool/сбойный вход None degraded-honest путь, без падения.
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
if isinstance(value, bool) or not isinstance(value, int | float):
return None
return int(value)
def _as_float(value: Any) -> float | None:
"""Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
if isinstance(value, bool) or not isinstance(value, int | float):
return None
return float(value)

View file

@ -54,7 +54,7 @@ import numpy as np
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
from app.services.forecasting.macro_series import get_monthly_macro
from app.services.forecasting.macro_series import get_monthly_macro, is_confounded_window
from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta
from app.services.forecasting.sales_series import (
SegmentSpec,
@ -672,7 +672,7 @@ def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity
def _fit_to_sensitivity(
fit: DistributedLagFit, *, segment: dict[str, str | None]
fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
) -> RateSensitivity:
"""Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.
@ -684,9 +684,16 @@ def _fit_to_sensitivity(
r2 / n_obs fit.r2 / fit.n
confidence 'regression' "medium" (gated-OK but advisory-grade) |
'fallback' "low"
Source-B-only outputs (z_area_floor, most_sensitive_bucket, confounded,
shrinkage_weight) have no analogue in a district×class distributed-lag fit
(no room×area bucketing here) None / sensible defaults. PURE.
confounded passed in by the caller (computed from the ACTUAL fit window
via is_confounded_window #1636). The §9.6 production path
OR-aggregates this with §9.5 macro_coefficient.confounded in
demand_supply_forecast._series_confounded шок-фактор (#1222).
The 48-мес regression window overlaps the 2024-07-01 shock long
after the 12-мес macro window stops doing so, so hardcoding
False here silently dropped the shock signal on this channel.
Source-B-only outputs (z_area_floor, most_sensitive_bucket, shrinkage_weight) have
no analogue in a district×class distributed-lag fit (no room×area bucketing here)
None / sensible defaults. PURE.
BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier
Σ_j β_j on Δln the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT
@ -705,7 +712,7 @@ def _fit_to_sensitivity(
r2=fit.r2,
n_obs=fit.n,
shrinkage_weight=0.0,
confounded=False,
confounded=confounded,
confidence=confidence,
phrase=fit.phrase,
)
@ -778,4 +785,32 @@ def compute_rate_regime_sensitivity(
)
return _insufficient_sensitivity(segment)
return _fit_to_sensitivity(fit, segment=segment)
# #1636: confounded must reflect the ACTUAL §9.6 fit window. The regression fits
# over the same macro grid as compute_district_rate_regression (get_monthly_macro,
# months_back); re-reading it here is a cache hit (same args). The 48-мес window
# crosses the 2024-07-01 shock long after the §9.5 12-мес macro window stops → this
# is exactly the channel that was silently never raising the shock flag (#1222).
confounded = _macro_window_confounded(db, months_back=months_back)
return _fit_to_sensitivity(fit, segment=segment, confounded=confounded)
def _macro_window_confounded(db: Session, *, months_back: int) -> bool:
"""True если §9.6 fit-окно [min..max] макро-сетки пересекает шок-дату (#1636).
Зеркалит macro_coefficient._series_confounded / rate_sensitivity._series_confounded
(PR2 is_confounded_window). Окно = та же сетка get_monthly_macro(months_back), что
использует compute_district_rate_regression cache-hit, без лишнего запроса.
Пустая сетка / сбой False (нет окна нечего конфаундить), НЕ crash.
"""
try:
months = [m.month for m in get_monthly_macro(db, months_back=months_back)]
except Exception:
logger.exception(
"rate_regime_sensitivity: macro window read for confounded-flag failed "
"(months_back=%d) → treating as not confounded",
months_back,
)
return False
if not months:
return False
return is_confounded_window(min(months), max(months))

View file

@ -189,10 +189,10 @@ def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | Non
"""
if supply_layers is not None:
coverage = supply_layers.get("domrf_coverage")
if isinstance(coverage, (int, float)) and not isinstance(coverage, bool):
if isinstance(coverage, int | float) and not isinstance(coverage, bool):
return _clamp_fraction(float(coverage))
pct = analyze.get("market_data_coverage_pct")
if isinstance(pct, (int, float)) and not isinstance(pct, bool):
if isinstance(pct, int | float) and not isinstance(pct, bool):
return _clamp_fraction(float(pct) / 100.0)
return None
@ -223,6 +223,19 @@ def _history_months(
return None
def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None:
"""Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE.
Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months`
именно за это окно считается n_sold. Нет None (#990 пропускает суффикс «за N мес»).
"""
if market_metrics is not None:
window = market_metrics.get("window_months")
if isinstance(window, int) and window > 0:
return window
return None
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
@ -303,10 +316,10 @@ def _primary_deficit_index(forecasts: Sequence[dict[str, Any]]) -> float | None:
)
if primary is not None and primary.get("deficit_index") is not None:
di = primary["deficit_index"]
return float(di) if isinstance(di, (int, float)) and not isinstance(di, bool) else None
return float(di) if isinstance(di, int | float) and not isinstance(di, bool) else None
for f in forecasts:
di = f.get("deficit_index")
if isinstance(di, (int, float)) and not isinstance(di, bool):
if isinstance(di, int | float) and not isinstance(di, bool):
return float(di)
return None
@ -324,10 +337,10 @@ def _primary_months_of_inventory(forecasts: Sequence[dict[str, Any]]) -> float |
)
if primary is not None and primary.get("months_of_inventory") is not None:
moi = primary["months_of_inventory"]
return float(moi) if isinstance(moi, (int, float)) and not isinstance(moi, bool) else None
return float(moi) if isinstance(moi, int | float) and not isinstance(moi, bool) else None
for f in forecasts:
moi = f.get("months_of_inventory")
if isinstance(moi, (int, float)) and not isinstance(moi, bool):
if isinstance(moi, int | float) and not isinstance(moi, bool):
return float(moi)
return None
@ -352,7 +365,7 @@ def _overall_score(product_scores: dict[str, Any] | None) -> float | None:
if not isinstance(product_scores, dict):
return None
overall = product_scores.get("overall")
if isinstance(overall, (int, float)) and not isinstance(overall, bool):
if isinstance(overall, int | float) and not isinstance(overall, bool):
return float(overall)
return None
@ -398,14 +411,26 @@ def _market_now_summary(
parts: list[str] = []
if market_metrics is not None:
velocity = market_metrics.get("unit_velocity")
if isinstance(velocity, (int, float)) and not isinstance(velocity, bool):
if isinstance(velocity, int | float) and not isinstance(velocity, bool):
parts.append(f"абсорбция ~{round(float(velocity), 1)} ед./мес")
avg_price = analyze.get("market_avg_price_per_m2")
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool):
if isinstance(avg_price, int | float) and not isinstance(avg_price, bool):
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
n_comp = _analog_count(analyze, market_metrics)
if n_comp is not None:
parts.append(f"{n_comp} ЖК-конкурентов рядом")
# #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
# всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
# Метка честно следует источнику: obj_count → «в выборке района», локальный fallback
# из analyze (competitors_total / len(competitors)) → «рядом».
if market_metrics is not None and isinstance(market_metrics.get("obj_count"), int):
parts.append(f"{market_metrics['obj_count']} ЖК в выборке района")
else:
n_local: int | None = None
pulse = analyze.get("market_pulse")
if isinstance(pulse, dict) and isinstance(pulse.get("competitors_total"), int):
n_local = pulse["competitors_total"]
elif isinstance(analyze.get("competitors"), list):
n_local = len(analyze["competitors"])
if n_local is not None:
parts.append(f"{n_local} ЖК-конкурентов рядом")
if not parts:
return None
return "Текущий рынок: " + ", ".join(parts) + "."
@ -616,6 +641,7 @@ def _build_confidence(
market_metrics, future_supply, forecasts, product_scores, special_indices
),
deal_count=_deal_count(analyze, market_metrics),
deal_count_months=_deal_count_months(market_metrics),
analog_count=_analog_count(analyze, market_metrics),
domrf_coverage=_domrf_coverage(analyze, supply_layers),
history_months=_history_months(market_metrics, forecasts),

View file

@ -143,7 +143,12 @@ _GEO_WEIGHT_SCALE_KM: float = 3.0
# НЕ исключаем проект и НЕ даём полный вес — низкий floor, чтобы он мог сигналить, но
# не доминировал над проектом с подтверждённой близостью. None-not-0 дух (есть проект,
# но геопривязка слабая → down-weight, а не выкидываем).
_GEO_WEIGHT_UNKNOWN: float = 0.1
#
# ВАЖНО: значение НИЖЕ exp(6.9/3)≈0.10 (вес подтверждённо дальнего ~6.9 км проекта),
# иначе «координаты неизвестны» даёт бо́льший вес чем «подтверждённо далеко» — инверсия.
# При scale=3 км: exp(9/3)=exp(3)≈0.050. Значение 0.05 ≈ «условные 9 км»: проект без
# координат не перебивает подтверждённо дальний (>6.9 км), но сохраняет ненулевой сигнал.
_GEO_WEIGHT_UNKNOWN: float = 0.05
# Минимум доступных осей пересечения для пары кандидат↔наш проект, чтобы считать оценку
# надёжной. < этого (напр. только класс) → пара low-confidence (сигнал есть, но тонкий).
@ -1023,9 +1028,7 @@ def _launch_window_horizon(launch_window: SpecialIndex) -> int | None:
return horizon if isinstance(horizon, int) else None
def _candidate_release_month(
launch_window: SpecialIndex, *, as_of: date
) -> date | None:
def _candidate_release_month(launch_window: SpecialIndex, *, as_of: date) -> date | None:
"""Когда рекомендованный проект реально выйдет на рынок (тайминг §25.3). PURE.
= as_of (дата отчёта) + горизонт окна запуска §25.1: на этот месяц #980 предсказывает
@ -1288,9 +1291,7 @@ def _build_cannibalization_true(
contributions: list[tuple[float, OwnProject, _PairOverlap]] = []
for own in portfolio:
distance_km = _own_distance_km(centroid, own)
price_overlap = _price_overlap(
candidate_band, own.price_min_per_m2, own.price_max_per_m2
)
price_overlap = _price_overlap(candidate_band, own.price_min_per_m2, own.price_max_per_m2)
pair = _own_portfolio_overlap(
class_overlap=_class_overlap(spec.obj_class, own.obj_class),
price_overlap=price_overlap,
@ -1315,7 +1316,7 @@ def _build_cannibalization_true(
# Топ-вкладчики (сильнейшие каннибализаторы) — детерминированно по signal DESC,
# tie-break по имени проекта (стабильно, без RNG/порядка set).
contributions.sort(key=lambda t: (-t[0], t[1].name))
top = contributions[: _COMPETITOR_TOP_N]
top = contributions[:_COMPETITOR_TOP_N]
confidence = _cap_confidence(_portfolio_confidence(portfolio))
return SpecialIndex(
key=KEY_CANNIBALIZATION,
@ -1382,9 +1383,7 @@ def _axes_available_summary(pairs: Sequence[_PairOverlap]) -> dict[str, int]:
return summary
def _contribution_detail(
signal: float, own: OwnProject, pair: _PairOverlap
) -> dict[str, Any]:
def _contribution_detail(signal: float, own: OwnProject, pair: _PairOverlap) -> dict[str, Any]:
"""Explainability-карточка одного топ-каннибализатора. PURE."""
return {
"name": own.name,
@ -1689,7 +1688,13 @@ def compute_special_indices(
indices: dict[str, SpecialIndex] = {key: _run(key, builders[key]) for key in _INDEX_KEYS}
confidence = _min_confidence([idx.confidence for idx in indices.values()])
# confidence = MIN по ДОСТУПНЫМ индексам (контракт SpecialIndices / docstring выше).
# Недоступный индекс (_unavailable → value=None, confidence='low') НЕ участвует: его
# 'low' — артефакт деградации, а не сигнал низкой уверенности доступных индексов
# (#1592: _min_confidence отбрасывает только None, поэтому фильтруем здесь по value).
confidence = _min_confidence(
[idx.confidence for idx in indices.values() if idx.value is not None]
)
n_available = sum(1 for idx in indices.values() if idx.value is not None)
logger.info(

View file

@ -22,6 +22,7 @@ import logging
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
# импорт из модуля удовлетворяет strict no-implicit-reexport.
from ezdxf.enums import TextEntityAlignment
from ezdxf.filemanagement import new as ezdxf_new
from shapely.geometry import Polygon
@ -43,13 +44,33 @@ _LAYER_BUILDINGS = "BUILDINGS"
_LABEL_HEIGHT_M = 2.0
def _polygon_points(poly: Polygon) -> list[tuple[float, float]]:
"""Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки)."""
coords = list(poly.exterior.coords)
def _ring_points(coords: object) -> list[tuple[float, float]]:
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
pts = list(coords)
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
if len(coords) > 1 and coords[0] == coords[-1]:
coords = coords[:-1]
return [(float(x), float(y)) for x, y in coords]
if len(pts) > 1 and pts[0] == pts[-1]:
pts = pts[:-1]
return [(float(x), float(y)) for x, y in pts]
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
замкнутой полилинией на том же слое иначе легальные вырезы (двор, сервитут,
охранная зона) терялись бы и заливались сплошняком.
"""
msp.add_lwpolyline(
_ring_points(poly.exterior.coords),
close=True,
dxfattribs={"layer": layer},
)
for interior in poly.interiors:
msp.add_lwpolyline(
_ring_points(interior.coords),
close=True,
dxfattribs={"layer": layer},
)
def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
@ -71,16 +92,8 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
msp = doc.modelspace()
# Участок и пятно застройки — из метрической геометрии Parcel.
msp.add_lwpolyline(
_polygon_points(parcel.polygon_m),
close=True,
dxfattribs={"layer": _LAYER_PARCEL},
)
msp.add_lwpolyline(
_polygon_points(parcel.buildable_m),
close=True,
dxfattribs={"layer": _LAYER_BUILDABLE},
)
_add_polygon(msp, parcel.polygon_m, _LAYER_PARCEL)
_add_polygon(msp, parcel.buildable_m, _LAYER_BUILDABLE)
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
features = variant.buildings_geojson.get("features", [])
@ -91,18 +104,17 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
if footprint is None:
continue
section_count += 1
msp.add_lwpolyline(
_polygon_points(footprint),
close=True,
dxfattribs={"layer": _LAYER_BUILDINGS},
)
_add_polygon(msp, footprint, _LAYER_BUILDINGS)
centroid = footprint.centroid
label = str(_feature_section_id(feature, section_count))
text = msp.add_text(
label,
dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M},
)
text.set_placement((float(centroid.x), float(centroid.y)))
text.set_placement(
(float(centroid.x), float(centroid.y)),
align=TextEntityAlignment.MIDDLE_CENTER,
)
stream = io.BytesIO()
doc.write(stream, fmt="bin")

View file

@ -30,7 +30,7 @@ from shapely.strtree import STRtree
from app.schemas.concept import ConceptInput, ConceptVariant
from app.services.generative import financial, teap
from app.services.generative.geometry import Parcel
from app.services.generative.geometry import Parcel, ParcelGeometryError
logger = logging.getLogger(__name__)
@ -191,11 +191,25 @@ def place_strategy(
parcel: Parcel,
payload: ConceptInput,
spec: StrategySpec,
) -> ConceptVariant:
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant."""
) -> ConceptVariant | None:
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
"""
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
footprints = _greedy_place(parcel, spec, coverage_cap)
if not footprints:
logger.warning(
"strategy=%s placed 0 sections (footprint %.0fx%.0f m not buildable) — отбраковка",
spec.name,
spec.section_w,
spec.section_d,
)
return None
teap_result = teap.compute_teap(
footprints=footprints,
@ -220,8 +234,22 @@ def place_strategy(
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced)."""
variants = [place_strategy(parcel, payload, spec) for spec in _STRATEGIES]
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
Вырожденные стратегии (нулевое размещение) отбраковываются в результат попадают
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
:class:`ParcelGeometryError` (API мапит в 422) лучше отказ, чем пустой/лживый ответ.
"""
variants = [
variant
for spec in _STRATEGIES
if (variant := place_strategy(parcel, payload, spec)) is not None
]
if not variants:
raise ParcelGeometryError(
"ни одна стратегия размещения не вместила секцию — участок слишком узкий/мелкий"
)
logger.info(
"placed all strategies: %s",
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),

View file

@ -58,6 +58,10 @@ class LLMResult:
fallback_used: True если результат сигнал к детерминированному fallback.
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
redaction_refused/provider_error/call_cap/no_api_key). None при ok.
finish_reason: причина завершения от провайдера (stop/length/content_filter/
tool_calls/). None при fallback. Консьюмер ОБЯЗАН проверять её даже при
ok=True: 'length'/'content_filter' ответ обрезан/отфильтрован (частичный
или пустой content) и не является полноценным результатом.
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
model: модель, ответившая на запрос ("" при fallback).
"""
@ -67,6 +71,7 @@ class LLMResult:
tool_calls: list[ToolCall] = field(default_factory=list)
fallback_used: bool = False
reason: str | None = None
finish_reason: str | None = None
prompt_tokens: int = 0
completion_tokens: int = 0
model: str = ""
@ -81,6 +86,7 @@ class LLMResult:
ok=True,
content=resp.content,
tool_calls=list(resp.tool_calls),
finish_reason=resp.finish_reason,
prompt_tokens=resp.prompt_tokens,
completion_tokens=resp.completion_tokens,
model=resp.model,

View file

@ -91,6 +91,21 @@ class LLMProvider(ABC):
# ── OpenAI (external) ─────────────────────────────────────────────────────────
def _coerce_token_count(value: Any) -> int:
"""usage-токены → int, толерантно к мусору (str "abc"/None/dict → 0).
OpenAI обычно отдаёт int, но через внешний прокси/нестандартный провайдер поле
может прийти нечисловым. int() на таком значении бросил бы ValueError/TypeError
мимо LLM*-контракта (его ловит client._call_with_retries) и пробил бы инвариант
«complete никогда не падает наружу» (#1601). Невалидный токен-счётчик — не повод
ронять ответ: деградируем до 0.
"""
try:
return int(value or 0)
except (ValueError, TypeError):
return 0
def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
choices = data.get("choices") or []
@ -114,8 +129,8 @@ def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> Prov
content=message.get("content"),
tool_calls=tool_calls,
finish_reason=choice.get("finish_reason"),
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
completion_tokens=int(usage.get("completion_tokens", 0) or 0),
prompt_tokens=_coerce_token_count(usage.get("prompt_tokens", 0)),
completion_tokens=_coerce_token_count(usage.get("completion_tokens", 0)),
model=str(data.get("model") or fallback_model),
)

View file

@ -24,6 +24,7 @@ from __future__ import annotations
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
@ -77,10 +78,68 @@ _PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
_PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
# Телефон РФ «локальный»: 10 значащих цифр БЕЗ кода страны, начинаются с мобильного
# «9», сгруппированы 9XX XXX XX XX через пробел/дефис (#1641). _PHONE_RE требует
# префикс +7/8, _PHONE_BARE_RE — ровно 11 слитных цифр → формат «922 123 45 67» /
# «922-123-45-67» проходил мимо. Якорь «9» + фиксированная группировка 3-3-2-2 не
# пересекается с паспортом (4+6) / СНИЛС (3-3-3 2). Ставится ПОСЛЕ префиксных форм,
# чтобы local-часть «+7 912 …» сначала ушла как полноценный phone.
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
# Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
# ИНН: требуется контекстный якорь — маркер «ИНН» / «inn» (регистронезависимо) не
# далее ~20 символов перед 10/12-значным блоком цифр (#1640). Голые цифры без якоря
# НЕ редактируются — это исключает легитимные суммы (1 200 000 000 = 10 цифр) и
# прочие числа, которые случайно совпадают с длиной ИНН.
# «Прокладка» (?:[^\d\n]{0,20}) позволяет «ИНН физлица 500100732259» (слово между
# маркером и числом), но не перешагивает через строки или другие цифровые блоки.
# Группа 1 захватывает только цифры — передаётся в _inn_checksum_valid.
# Границы слова (#1640 follow-up): (?<!\w) / (?!\w) вокруг цифрового блока гарантируют,
# что блок не является частью более длинного алфавитно-цифрового токена.
# \b не подходит: Python \w включает цифры и буквы, поэтому \b между буквой и цифрой
# отсутствует (оба — \w). Lookaround'ы (?<!\w)/(?!\w) дают нужную границу «не-\w перед/после».
# (?<!\d) заменён на (?<!\w): закрывает случай «ref7707083893» (alpha-префикс).
# (?!\d) заменён на (?!\w): закрывает случай «7707083893more» (alpha-суффикс).
_INN_RE = re.compile(r"(?i:инн|inn)\s*[:№]?\s*(?:[^\d\n]{0,20})(?<!\w)(\d{12}|\d{10})(?!\w)")
def _inn_checksum_valid(token: str) -> bool:
"""Проверить контрольные цифры ИНН по алгоритму ФНС.
10-значный (юрлицо): одна контрольная цифра (последняя).
12-значный (физлицо/ИП): две контрольные цифры (11-я и 12-я).
Веса стандартные коэффициенты ФНС.
Вызывается из _inn_repl с group(1) только цифровой блок без keyword-префикса.
Дополнительный gate поверх контекстного якоря (_INN_RE): отфильтровывает
синтетические 10/12-значные числа, у которых первые цифры случайно дают верную
контрольную сумму.
"""
digits = [int(c) for c in token]
if len(digits) == 10:
weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
control = sum(w * d for w, d in zip(weights, digits[:9], strict=True)) % 11 % 10
return control == digits[9]
if len(digits) == 12:
w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
c1 = sum(w * d for w, d in zip(w1, digits[:10], strict=True)) % 11 % 10
c2 = sum(w * d for w, d in zip(w2, digits[:11], strict=True)) % 11 % 10
return c1 == digits[10] and c2 == digits[11]
return False
def _inn_repl(match: re.Match[str]) -> str:
"""re.sub callback: редактировать ИНН только при валидной контрольной сумме.
_INN_RE захватывает цифры в group(1) keyword-префикс в group(0). Для замены
нужна только часть с цифрами (group 1). Если checksum не проходит возвращаем
исходную строку целиком (keyword + цифры), не удаляем ничего.
"""
digits = match.group(1)
return "[REDACTED:inn]" if _inn_checksum_valid(digits) else match.group(0)
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
@ -97,18 +156,22 @@ _FULLNAME_RE = re.compile(
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
)
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
# (regex, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
# Применяются последовательно в этом порядке.
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(_SNILS_RE, "snils"),
(_PASSPORT_RE, "passport"),
(_PHONE_RE, "phone"),
(_PHONE_BARE_RE, "phone"),
(_EMAIL_RE, "email"),
(_INN_RE, "inn"),
(_SNILS_BARE_RE, "snils"),
(_FULLNAME_RE, "name"),
_Repl = str | Callable[[re.Match[str]], str]
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
(_SNILS_RE, "snils", "[REDACTED:snils]"),
(_PASSPORT_RE, "passport", "[REDACTED:passport]"),
(_PHONE_RE, "phone", "[REDACTED:phone]"),
(_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
(_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
(_EMAIL_RE, "email", "[REDACTED:email]"),
(_INN_RE, "inn", _inn_repl),
(_SNILS_BARE_RE, "snils", "[REDACTED:snils]"),
(_FULLNAME_RE, "name", "[REDACTED:name]"),
)
@ -121,9 +184,16 @@ def scrub_text(value: str) -> str:
if not value:
return value
redacted = value
for pattern, kind in _PII_PATTERNS:
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
if n:
placeholder = "[REDACTED:%s]"
for pattern, kind, repl in _PII_PATTERNS:
# n из subn для callback-repl (ИНН) считает ВСЕ совпадения, включая кандидаты,
# которые callback вернул без изменений (не прошли checksum). Поэтому реальное
# число замен берём по приросту числа плейсхолдеров — корректно и для str, и
# для callback, без утечки самого PII-значения в лог.
before_count = redacted.count(placeholder % kind)
redacted = pattern.sub(repl, redacted)
n = redacted.count(placeholder % kind) - before_count
if n > 0:
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
return redacted
@ -135,7 +205,7 @@ def _scrub_value(value: Any) -> Any:
return scrub_text(value)
if isinstance(value, dict):
return {k: _scrub_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
if isinstance(value, list | tuple):
scrubbed = [_scrub_value(v) for v in value]
return type(value)(scrubbed)
return value

View file

@ -24,6 +24,7 @@ import logging
import re
import sqlite3
from collections.abc import Callable
from contextlib import closing
from datetime import date
from pathlib import Path
from typing import Any
@ -466,13 +467,12 @@ def get_sqlite_info(sqlite_path: str | Path) -> dict[str, Any]:
info["size_bytes"] = st.st_size
info["modified_at"] = st.st_mtime # epoch seconds
try:
c = sqlite3.connect(p)
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
info["corp_room_month"] = c.execute("SELECT COUNT(*) FROM objective_corp_month").fetchone()[
0
]
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
c.close()
with closing(sqlite3.connect(p)) as c:
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
info["corp_room_month"] = c.execute(
"SELECT COUNT(*) FROM objective_corp_month"
).fetchone()[0]
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
except sqlite3.Error as e:
info["error"] = str(e)
return info

View file

@ -11,6 +11,7 @@ opens the original DOM.РФ URL (we don't need to mirror originals).
from __future__ import annotations
import logging
import os
from pathlib import Path
from PIL import Image, ImageOps
@ -36,21 +37,40 @@ def make_thumbnail(
) -> Path | None:
"""Generate a WebP cover-thumbnail next to src. Returns thumb path on success.
Skips if thumb already exists and overwrite=False. Returns None on any error
Skips if thumb already exists, is not stale (dst mtime >= src mtime) and
overwrite=False. A stale thumb (src rewritten after thumb, e.g. original
re-downloaded on size mismatch) is regenerated. Returns None on any error
(logged) so callers can keep going with the next file.
"""
if not src.exists():
return None
dst = thumb_path_for(src)
if dst.exists() and not overwrite:
return dst
# Existence alone is not freshness: if src was rewritten after dst was
# generated, the thumb is stale and must be regenerated. Only skip when
# the thumb is at least as new as the source.
try:
if dst.stat().st_mtime >= src.stat().st_mtime:
return dst
except OSError as e:
# stat failed (race / removed) — fall through and try to regenerate.
logger.warning("thumbnail freshness check %s failed: %s", src, e)
try:
with Image.open(src) as im:
im = ImageOps.exif_transpose(im)
im = im.convert("RGB")
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
dst.parent.mkdir(parents=True, exist_ok=True)
im.save(dst, format="WEBP", quality=quality, method=4)
# Atomic write: encode to a sibling temp file, then os.replace() so a
# crash/OOM mid-encode never leaves a truncated .webp at the canonical
# path (which dst.exists() would later treat as a valid cached thumb).
tmp = dst.with_name(f".{dst.name}.tmp")
try:
im.save(tmp, format="WEBP", quality=quality, method=4)
os.replace(tmp, dst)
except BaseException:
tmp.unlink(missing_ok=True)
raise
return dst
except Exception as e:
logger.warning("thumbnail %s failed: %s", src, e)

View file

@ -71,6 +71,38 @@ STATUS_FREE = "free"
STATUS_SOLD = "sold"
STATUS_RESERVED = "reserved"
# Паттерны для извлечения статуса (issue #1609).
# Все морфоварианты: продан/продана/продано, забронирован[аоы]?, реализован[аоы]?.
_STATUS_KW_RE = re.compile(
r"\s*продаже|свободн[аоы]?|free"
r"|продан[аоы]?|реализован[аоы]?|sold"
r"|забронирован[аоы]?|бронь|reserved)",
re.IGNORECASE | re.UNICODE,
)
# CSS-классы, характерные для статус-бейджей в Next.js/Tailwind.
# Требуем слово «status» как отдельный токен внутри hyphen-separated CSS-класса
# (напр. «status-badge», «flat-status-tag», «object-status»).
# Предыдущий паттерн «status|badge|tag|chip|label» был слишком широк —
# срабатывал на любой generic элемент с классом «tag»/«chip»/«label»/«badge»,
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
def _classify_status_kw(matched_text: str) -> str | None:
"""Классифицировать совпавшее ключевое слово в STATUS_*.
sold/reserved проверяются ПЕРВЫМИ они специфичнее "в продаже" и никогда
не появляются в навигационных блоках страницы, в отличие от «в продаже».
"""
s = matched_text.lower()
if any(kw in s for kw in ("продан", "реализован", "sold")):
return STATUS_SOLD
if any(kw in s for kw in ("бронь", "забронирован", "reserved")):
return STATUS_RESERVED
if any(kw in s for kw in ("продаже", "свободн", "free")):
return STATUS_FREE
return None
# ── HTML fetching ─────────────────────────────────────────────────────────────
@ -157,6 +189,28 @@ class _TextCollector(HTMLParser):
extraction известных структур страницы каталога.
"""
# HTML5 void-элементы: не имеют закрывающего тега → handle_endtag не вызывается.
# Если пушить их в стек/буфер, чужой handle_endtag поп'ает чужой фрейм →
# рассинхрон стека, текст блоков теряется (issue #1608).
_VOID_TAGS = frozenset(
{
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
)
def __init__(self) -> None:
super().__init__()
self._stack: list[tuple[str, dict[str, str]]] = []
@ -165,11 +219,15 @@ class _TextCollector(HTMLParser):
self._buf: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in self._VOID_TAGS:
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
attr_dict = {k: (v or "") for k, v in attrs}
self._stack.append((tag, attr_dict))
self._buf.append("") # начало нового буфера для этого тега
def handle_endtag(self, _tag: str) -> None:
if _tag in self._VOID_TAGS:
return # void-теги фрейм не открывали — нечего поп'ать (issue #1608)
if not self._stack:
return
tag, attr_dict = self._stack.pop()
@ -239,8 +297,11 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
# Price: "7 890 000 ₽" или "7 890 000 руб"
# Negative lookahead (?!\s*[/]) исключает цену за м² ("217 835 ₽/м²"),
# которая на странице обычно выше полной стоимости и иначе матчилась бы
# первой → отбрасывалась санити-границей → price_rub=NULL (issue #1645).
price_match = re.search(
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)",
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)(?!\s*[/])",
html,
re.UNICODE,
)
@ -267,20 +328,66 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
except ValueError:
pass
# Status: ищем характерные слова рядом с "статус" или в badge
status_match = re.search(
r"\s*продаже|свободна|free|продано|sold|забронирована|бронь|reserved)",
html,
re.IGNORECASE | re.UNICODE,
)
if status_match:
s = status_match.group(1).lower()
if any(kw in s for kw in ("продаже", "свободна", "free")):
result["status"] = STATUS_FREE
elif any(kw in s for kw in ("продано", "sold")):
result["status"] = STATUS_SOLD
elif any(kw in s for kw in ("бронь", "забронирована", "reserved")):
result["status"] = STATUS_RESERVED
# Status extraction (issue #1609 fix).
#
# Проблема: re.search по всему HTML находит "в продаже" из навигации
# ("другие квартиры в продаже") РАНЬШЕ, чем реальный статус-бейдж →
# проданная/забронированная квартира ошибочно маркируется как free.
#
# Стратегия (3 уровня, от точного к широкому):
#
# 1. Блоки с CSS-классом, содержащим "status"/"badge"/"tag"/"chip"/"label" —
# типовые имена для статус-бейджей в Next.js/Tailwind.
# 2. Ближайший блок ПОСЛЕ лейбла "Статус" (proximity-поиск в blocks).
# 3. Полный перебор всех blocks: sold/reserved побеждают free
# (они никогда не появляются в навигации, в отличие от "в продаже").
#
# Морфоварианты: продан / продана / продано / продан → продан[аоы]?
# Аналогично: забронирован[аоы]? / реализован[аоы]? (синоним для sold).
# Уровень 1: блок с CSS-классом типа badge/status/tag
status_from_badge: str | None = None
for cls, block_text in blocks:
if _STATUS_BADGE_CLS_RE.search(cls):
m = _STATUS_KW_RE.search(block_text)
if m:
status_from_badge = _classify_status_kw(m.group(1))
if status_from_badge:
break
if status_from_badge:
result["status"] = status_from_badge
else:
# Уровень 2: блок сразу после лейбла "Статус"
status_label_value = _find_text_near(blocks, r"^статус$")
status_from_label: str | None = None
if status_label_value:
m2 = _STATUS_KW_RE.search(status_label_value)
if m2:
status_from_label = _classify_status_kw(m2.group(1))
if status_from_label:
result["status"] = status_from_label
else:
# Уровень 3: перебор всех blocks — sold/reserved побеждают free.
# Если встретили sold/reserved — сразу break (не появляются в nav).
# Если только free — запоминаем как кандидата (может быть перебит).
free_candidate: bool = False
sold_reserved_found: str | None = None
for _cls, block_text in blocks:
m3 = _STATUS_KW_RE.search(block_text)
if not m3:
continue
classified = _classify_status_kw(m3.group(1))
if classified in (STATUS_SOLD, STATUS_RESERVED):
sold_reserved_found = classified
break # точнее nav-текстов, дальше не ищем
if classified == STATUS_FREE:
free_candidate = True # продолжаем — вдруг sold встретится позже
if sold_reserved_found:
result["status"] = sold_reserved_found
elif free_candidate:
result["status"] = STATUS_FREE
# Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
finishing_match = re.search(
@ -483,7 +590,18 @@ async def scrape_one_flat(
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
# success отражает прохождение пайплайна fetch+parse без исключения; реально
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
# ненайденном ods_id / пустом парсе (fields_extracted==0) — issue #1610.
outcome["success"] = True
if not outcome["updated"]:
logger.warning(
"catalog scrape ods_id=%s: fetched+parsed but DB row NOT updated "
"(fields=%d, ods_id missing or all-NULL parse)",
ods_id,
outcome["fields_extracted"],
)
logger.info(
"catalog scrape ods_id=%s: fields=%d updated=%s",
ods_id,
@ -512,11 +630,15 @@ async def scrape_catalog_batch(
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
Returns:
{total, success, failed, fields_total}
{total, success, updated, failed, fields_total}
- success: прошли fetch+parse без исключения
- updated: реально затронули строку в БД (issue #1610) — отражает
фактическое число записанных квартир, в отличие от success
"""
stats: dict[str, Any] = {
"total": len(flats),
"success": 0,
"updated": 0,
"failed": 0,
"fields_total": 0,
}
@ -550,6 +672,8 @@ async def scrape_catalog_batch(
if outcome["success"]:
stats["success"] += 1
stats["fields_total"] += outcome["fields_extracted"]
if outcome["updated"]:
stats["updated"] += 1
else:
stats["failed"] += 1
@ -565,9 +689,10 @@ async def scrape_catalog_batch(
raise
logger.info(
"scrape_catalog_batch done: total=%d success=%d failed=%d fields_total=%d",
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
stats["total"],
stats["success"],
stats["updated"],
stats["failed"],
stats["fields_total"],
)

View file

@ -115,6 +115,30 @@ def _to_int(v: Any) -> int | None:
return None
def _to_float(v: Any) -> float | None:
"""Coerce a DOM.РФ numeric value into float. None / bool / non-numeric / empty → None.
Accepts int, float, and numeric strings ('45.2'). Mirror of _to_int for area/price."""
if v is None or isinstance(v, bool):
return None
if isinstance(v, int | float):
f = float(v)
if f != f or f in (float("inf"), float("-inf")): # NaN / ±inf guard
return None
return f
if isinstance(v, str):
s = v.strip()
if not s:
return None
try:
f = float(s)
except (ValueError, OverflowError):
return None
if f != f or f in (float("inf"), float("-inf")):
return None
return f
return None
def _to_date(v: Any) -> date | None:
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'.
@ -414,8 +438,13 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
# Derive price_per_m2 when API returns price_rub but omits pricePerSquareMeter.
# Covers cases where the table endpoint has the flat price but no pre-computed m² rate.
if price_per_m2 is None and price_rub is not None and total_area and total_area > 0:
price_per_m2 = round(price_rub / total_area, 2)
# Coerce оба операнда в float ДО сравнения/деления: API иногда отдаёт totalArea/price
# строкой ('45.2'), и `total_area > 0` на str роняло бы _norm_flat с TypeError
# → весь run падал бы в status='failed' (#1644).
price_rub_num = _to_float(price_rub)
total_area_num = _to_float(total_area)
if price_per_m2 is None and price_rub_num is not None and total_area_num and total_area_num > 0:
price_per_m2 = round(price_rub_num / total_area_num, 2)
logger.info(
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
price_per_m2,
@ -1432,34 +1461,78 @@ def _place_str(region_code: int) -> str:
return str(region_code)
OBJECTS_PAGE_SIZE = 500
async def fetch_objects_for_status(
sess: BrowserSession, place: str, status: int
) -> list[dict[str, Any]]:
"""Fetch ALL objects for a given (place, objStatus) — server returns up to limit=999999."""
payload = await sess.get_json(
PATH_OBJECTS,
{
"offset": 0,
"limit": 999999,
"sortField": "default",
"sortType": "desc",
"place": place,
"objStatus": status,
},
"""Fetch ALL objects for a given (place, objStatus), пагинируя по страницам.
Раньше делали единственный запрос с limit=999999 и доверяли допущению, что сервер
вернёт всё. Если WAF/прокси DOM.РФ обрезает гигантский limit до своего max, хвост
объектов молча терялся (не скрейпился), а run всё равно рапортовал status='done' (#1605).
Теперь идём страницами по OBJECTS_PAGE_SIZE и аккумулируем, пока не наберём total
(из payload) либо страница не вернётся короче запрошенной / пустой.
"""
rows: list[dict[str, Any]] = []
offset = 0
total: int | None = None
while True:
payload = await sess.get_json(
PATH_OBJECTS,
{
"offset": offset,
"limit": OBJECTS_PAGE_SIZE,
"sortField": "default",
"sortType": "desc",
"place": place,
"objStatus": status,
},
)
page = _extract_list(payload)
if total is None:
total = _extract_total(payload)
rows.extend(page)
# Стоп-условия: пустая страница, недобор до размера страницы (последняя),
# либо набрали заявленный total. total может быть None (сервер его не отдал) —
# тогда полагаемся на размер страницы как сигнал конца.
if not page or len(page) < OBJECTS_PAGE_SIZE:
break
if total is not None and len(rows) >= total:
break
offset += OBJECTS_PAGE_SIZE
logger.info(
"kn/object place=%s status=%d -> %d/%s rows (paginated, page=%d)",
place,
status,
len(rows),
total,
OBJECTS_PAGE_SIZE,
)
rows = _extract_list(payload)
total = _extract_total(payload)
logger.info("kn/object place=%s status=%d -> %d/%s rows", place, status, len(rows), total)
if total is not None and len(rows) < total:
logger.warning(
"kn/object place=%s status=%d: получено %d < total=%d — возможен недобор хвоста",
place,
status,
len(rows),
total,
)
return rows
async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict[str, Any]]:
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened)."""
try:
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
except Exception as e:
logger.warning("flats fetch obj=%s failed: %s", obj_id, e)
return []
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened).
On HTTP / WAF / non-JSON errors raises caller (_fetch_flats_safe) ловит и кладёт
Exception в result-tuple, который result-loop отдаёт в _classify_and_log
kn_scrape_failures. Раньше try/except здесь возвращал [] на ЛЮБУЮ ошибку, из-за чего
провал /portal/table (429, 5xx, WAF-challenge) не попадал в журнал отказов, а run
рапортовал ложную полноту по квартирам (#1643). Поведение теперь как у остальных
fetch_* endpoint'ов, которые исключения не глотают.
"""
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
# Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]}
return _flatten_table(payload)

View file

@ -83,7 +83,7 @@ _EKB_LAT_MAX = 57.5
# Паттерн для разбора первого числа из строки координат
# (ячейки могут содержать несколько точек через пробелы или запятую-десятичный разделитель)
_COORD_FIRST_RE = re.compile(r"[\d]+[.,][\d]+")
_COORD_FIRST_RE = re.compile(r"[\d]+(?:[.,][\d]+)?")
def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] | None:
@ -375,26 +375,28 @@ class EkburgPermitsClient:
Пропускает листы «Справочник», «Лист1» и неизвестные.
"""
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
try:
for sheet_name in wb.sheetnames:
if sheet_name.lower() in _SKIP_SHEETS:
continue
for sheet_name in wb.sheetnames:
if sheet_name.lower() in _SKIP_SHEETS:
continue
permit_type = _detect_permit_type(sheet_name)
if permit_type is None:
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
continue
permit_type = _detect_permit_type(sheet_name)
if permit_type is None:
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
continue
sheet = wb[sheet_name]
data_start = _detect_header_row(sheet)
logger.info(
"Parsing sheet %r (%s) year=%d, data starts at row %d",
sheet_name,
permit_type,
year,
data_start,
)
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
sheet = wb[sheet_name]
data_start = _detect_header_row(sheet)
logger.info(
"Parsing sheet %r (%s) year=%d, data starts at row %d",
sheet_name,
permit_type,
year,
data_start,
)
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
finally:
wb.close()
def _parse_sheet(
self,

View file

@ -143,6 +143,45 @@ _GRID_WALK_LAYERS: frozenset[str] = frozenset(
}
)
# ── РИАСУРТ Свердл (folderId 1224) — агломерация ЕКБ, multi-city scaling (#108) ──
# РИАСУРТ Свердл агрегирует данные от МАЛЫХ МО Свердл (Берёзовский, В.Пышма,
# Среднеуральск, Арамиль, Сысерть) — НЕ от ЕКБ-сити (тот не интегрирован в ФГИС ТП).
# Слои на той же NSPD aeggis/v4 WMS-инфраструктуре, что и федеральные LAYERS, но
# layerId из регионального каталога (84xxxx/85xxxx). 14 ключевых слоёв (см. issue #108).
#
# layerId → (topic, человекочитаемое название). topic группирует слой для analyze-gate
# (тер.зона/функц.зона/красные линии/СЗЗ/ЗСО/затопление/КРТ и т.п.).
RIASURT_SVERDL_LAYERS: dict[int, tuple[str, str]] = {
# ПЗЗ / Планирование
845274: ("territorial_zone", "Территориальные зоны"),
846381: ("functional_zone", "Функциональные зоны"),
844759: ("red_lines", "Красные линии"),
844774: ("setback_lines", "Линии отступа от красных линий"),
845298: ("ppt_oks_boundary", "Границы ППТ ОКС"),
# Инженерные ЗОУИТ
846365: ("szz", "Санитарно-защитная зона (СЗЗ)"),
846369: ("san_gap_engineering", "Санитарный разрыв инженерных коммуникаций"),
845392: ("szo", "Зоны санитарной охраны питьевого водоснабжения (ЗСО)"),
846373: ("water_protection", "Водоохранные зоны"),
# Risk
845425: ("flood_zone", "Зоны затопления / подтопления"),
846378: ("emergency_alert", "Зона экстренного оповещения"),
844795: ("emergency_risk", "Территории риска ЧС"),
# Opportunity
844478: ("krt", "Территории КРТ"),
846379: ("oez", "Особые экономические зоны (ОЭЗ)"),
}
def riasurt_layer_topic(layer_id: int) -> str:
"""Topic (бакет для analyze-gate) по layerId РИАСУРТ Свердл.
Неизвестный layerId 'other' (graceful; не падаем на новых слоях каталога).
"""
entry = RIASURT_SVERDL_LAYERS.get(layer_id)
return entry[0] if entry else "other"
# Default rate limit (мс между запросами) — баланс между скоростью и WAF
DEFAULT_RATE_MS = 600
@ -612,6 +651,58 @@ class NSPDClient:
)
return deduped
# ── 3c. get_riasurt_sverdl_in_bbox (РИАСУРТ Свердл bulk, #108) ───────────
def get_riasurt_sverdl_in_bbox(
self,
bbox_3857: tuple[float, float, float, float],
layers: list[int] | None = None,
*,
grid_n: int = 7,
step_m: float = 50.0,
) -> dict[int, list[NSPDFeature]]:
"""Bulk WMS GetFeatureInfo по слоям РИАСУРТ Свердл в пределах bbox.
Для каждого layerId делает grid-walk (``get_features_in_bbox_grid``) area/
linear слои (зоны, линии) под-возвращаются single-pixel probe, поэтому grid.
Слой, который упал (WAF / сеть), логируется и пропускается (его ключ = []),
остальные слои продолжают частичный harvest лучше полного провала по агломерации.
Args:
bbox_3857: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры) bbox одного МО.
layers: список layerId РИАСУРТ. None все 14 ключевых
(``RIASURT_SVERDL_LAYERS``). Неизвестный layerId всё равно
запрашивается (topic='other' на стороне классификатора).
grid_n / step_m: параметры grid-walk (см. get_features_in_bbox_grid).
Returns:
dict[layerId, list[NSPDFeature]]. Ключи все запрошенные layerId
(пустой list если слой пуст / упал). Стабильная форма для caller'а.
"""
layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys())
result: dict[int, list[NSPDFeature]] = {}
for layer_id in layer_ids:
try:
feats = self.get_features_in_bbox_grid(
layer_id, bbox_3857, grid_n=grid_n, step_m=step_m
)
except (NspdLiteError, NspdLiteWafError) as exc:
logger.warning(
"get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем",
layer_id,
exc,
)
result[layer_id] = []
continue
result[layer_id] = feats
logger.info(
"get_riasurt_sverdl_in_bbox: layer=%d topic=%s count=%d",
layer_id,
riasurt_layer_topic(layer_id),
len(feats),
)
return result
# ── 4. list_layers ──────────────────────────────────────────────────────
def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]:
@ -924,6 +1015,7 @@ __all__ = [
"LAYERS",
"NSPD_THEMES",
"NSPD_WMS_BASE",
"RIASURT_SVERDL_LAYERS",
"THEME_ARN",
"THEME_PKK",
"NSPDClient",
@ -935,4 +1027,5 @@ __all__ = [
"QuarterDump",
"bbox_around_point_m",
"lonlat_to_3857",
"riasurt_layer_topic",
]

View file

@ -329,25 +329,37 @@ def denorm_dump(
features: плоский list из features_json JSONB (уже декодированный Python list).
Returns:
dict {"parcels": N, "buildings": M, "errors": K} количество обработанных строк.
dict {"parcels": N, "buildings": M, "errors": K, "skipped": S}
количество обработанных строк. ``errors`` только реальные сбои UPSERT;
``skipped`` штатные пропуски feature без cad_num.
"""
snapshot_date = datetime.date.today().isoformat()
parcels_n = 0
buildings_n = 0
errors_n = 0
skipped_n = 0
for feat in features:
layer = feat.get("layer", "")
try:
if layer == "parcels":
if denorm_parcel_feature(
# denorm_parcel_feature возвращает False и при штатном пропуске
# (нет cad_num), и при реальном сбое UPSERT — различаем их здесь,
# чтобы не завышать error-счётчик прогона.
props = feat.get("properties") or {}
if not (props.get("cad_num") or props.get("cadastral_number")):
skipped_n += 1
elif denorm_parcel_feature(
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
):
parcels_n += 1
else:
errors_n += 1
elif layer == "buildings":
if denorm_building_feature(
props = feat.get("properties") or {}
if not (props.get("cad_num") or props.get("cadastral_number")):
skipped_n += 1
elif denorm_building_feature(
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
):
buildings_n += 1
@ -360,10 +372,16 @@ def denorm_dump(
db.commit()
logger.info(
"denorm_dump quarter=%s parcels=%d buildings=%d errors=%d",
"denorm_dump quarter=%s parcels=%d buildings=%d errors=%d skipped=%d",
quarter_cad,
parcels_n,
buildings_n,
errors_n,
skipped_n,
)
return {"parcels": parcels_n, "buildings": buildings_n, "errors": errors_n}
return {
"parcels": parcels_n,
"buildings": buildings_n,
"errors": errors_n,
"skipped": skipped_n,
}

View file

@ -113,7 +113,17 @@ def fetch_geoportal(
try:
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
body = r.read().decode("utf-8", "ignore")
return json.loads(body)
try:
return json.loads(body)
except json.JSONDecodeError as e:
# HTTP 200, но тело — не JSON. Это WAF/прокси-челлендж (HTML или
# пустое тело) с кодом 200 вместо 403/429. Та же transient-ситуация,
# что и явный WAF → NspdLiteWafError, чтобы caller сделал backoff
# (harvest_quarter autoretry_for=(NspdLiteWafError,)), а не пометил
# строку постоянным harvest_error.
raise NspdLiteWafError(
f"HTTP 200 but non-JSON body (WAF challenge?): {body[:300]}"
) from e
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
if e.code in (403, 429):
@ -162,6 +172,12 @@ def fetch_via_rosreestr2coord(
_ = delay # silence unused — см. docstring выше
try:
from rosreestr2coord.parser import Area
from rosreestr2coord.request.exceptions import (
HTTPErrorException,
HTTPForbiddenException,
RequestException,
TimeoutException,
)
except ImportError as e:
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
@ -170,19 +186,33 @@ def fetch_via_rosreestr2coord(
# denied при первом fetch.
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
a = Area(
code=cad_num,
area_type=area_type,
timeout=timeout,
with_log=False,
use_cache=False,
media_path="/tmp/rosreestr2coord",
)
#
# NB: with_log=False → конструктор Area() сразу делает HTTP-запрос
# (get_geometry без try/except), поэтому WAF/сетевые исключения летят уже
# отсюда, а не из to_geojson_poly(). Оба вызова под одним try.
try:
a = Area(
code=cad_num,
area_type=area_type,
timeout=timeout,
with_log=False,
use_cache=False,
media_path="/tmp/rosreestr2coord",
)
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
# строку. Default в v5 = True → строка → крах в `_persist_target` который
# ожидает dict с `.get("properties")` etc.
return a.to_geojson_poly(dumps=False)
except Exception as e:
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
return None
except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
# WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
# HTTPErrorException) и таймауты — transient. Поднимаем NspdLiteWafError,
# чтобы воркер process_nspd_geo_job сделал backoff (exponential, инкремент
# waf_blocked_count, пауза после серии WAF), а не пометил цель 'done' с 0
# features (false run-status). См. nspd_geo.py:464.
raise NspdLiteWafError(f"rosreestr2coord WAF/transient for {cad_num}: {e}") from e
except RequestException as e:
# Прочие ошибки запроса (RequestException, в т.ч. is_error_response с
# сообщением об ошибке от NSPD) — не отличить от transient WAF надёжно,
# но это НЕ легитимное 'участок не найден' (то возвращает feature=None →
# None без исключения). Классифицируем как ошибку, не как пустой результат.
raise NspdLiteError(f"rosreestr2coord request failed for {cad_num}: {e}") from e

View file

@ -69,6 +69,35 @@ _CHECK_TYPE_ALIASES: dict[str, list[str]] = {
"declaration": ["declaration", "hasDeclaration", "declarationFlg"],
}
# Строковые флаги, которые сторонний API может прислать вместо bool.
# Схема payload не верифицирована (см. docstring), поэтому приводим явно.
_TRUE_STRINGS = {"true", "1", "yes", "y", "да", "passed", "ok"}
_FALSE_STRINGS = {"false", "0", "no", "n", "нет", "failed", "not_passed"}
def _coerce_flag(value: Any) -> bool | None:
"""Привести значение флага проверки к bool либо None (UNKNOWN).
bool(value) ломается на строках ('false'/'0'/'нет' True) и не отличает
отсутствие данных от False. Возвращаем None, если значение нераспознаваемо
вызывающий код НЕ должен фабриковать False для UNKNOWN.
"""
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
s = value.strip().lower()
if s in _TRUE_STRINGS:
return True
if s in _FALSE_STRINGS:
return False
return None
return None
_UPSERT_CHECKS_SQL = text(
"""
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
@ -106,14 +135,21 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
for field, value in data.items():
ct = _CHECK_FIELD_MAP.get(field)
if ct and ct not in found:
found[ct] = bool(value)
flag = _coerce_flag(value)
if flag is not None:
found[ct] = flag
# Также проверить canonical names напрямую
for ct in CHECK_TYPES:
if ct not in found and ct in data:
found[ct] = bool(data[ct])
flag = _coerce_flag(data[ct])
if flag is not None:
found[ct] = flag
if found:
# Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
# а не FAILED — не фабрикуем passed=False для непришедших проверок.
for ct in CHECK_TYPES:
results.append({"check_type": ct, "passed": found.get(ct, False)})
if ct in found:
results.append({"check_type": ct, "passed": found[ct]})
return results
# dict не содержит известных полей — попробуем как list-формат ниже
logger.warning(
@ -128,11 +164,24 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
continue
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
if ct_raw and str(ct_raw) in CHECK_TYPES:
passed_raw = item.get("passed") or item.get("value") or item.get("status")
found_list[str(ct_raw)] = bool(passed_raw)
# Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
# Берём первый ключ, который реально присутствует в item.
if "passed" in item:
passed_raw = item["passed"]
elif "value" in item:
passed_raw = item["value"]
elif "status" in item:
passed_raw = item["status"]
else:
passed_raw = None
flag = _coerce_flag(passed_raw)
if flag is not None:
found_list[str(ct_raw)] = flag
if found_list:
# См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
for ct in CHECK_TYPES:
results.append({"check_type": ct, "passed": found_list.get(ct, False)})
if ct in found_list:
results.append({"check_type": ct, "passed": found_list[ct]})
return results
logger.warning(
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]

View file

@ -298,9 +298,7 @@ def _parse_value(raw: str) -> Decimal | None:
raw = (raw or "").strip()
if not raw or raw in {"-", "", "", "..."}:
return None
cleaned = (
raw.replace(" ", "").replace("", "").replace(" ", "").replace(",", ".")
)
cleaned = raw.replace(" ", "").replace("", "").replace(" ", "").replace(",", ".")
try:
return Decimal(cleaned)
except (InvalidOperation, ValueError):
@ -399,9 +397,7 @@ def parse_dataset_rows(
# ── HTTP fetch (thin) ───────────────────────────────────────────────────────────
def _http_get(
client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES
) -> bytes:
def _http_get(client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES) -> bytes:
"""GET с ретраями (сеть / 5xx) → байты тела. Module-level для monkeypatch в тестах.
Raises:
@ -622,9 +618,7 @@ _MONTH_NAME_TO_NUM: dict[str, int] = {
"декабрь": 12,
}
# Годовой под-период (некоторые индикаторы): «значение показателя за год», «год».
_YEAR_PERIODS: frozenset[str] = frozenset(
{"год", "значение показателя за год", "за год"}
)
_YEAR_PERIODS: frozenset[str] = frozenset({"год", "значение показателя за год", "за год"})
@dataclass(frozen=True)
@ -673,7 +667,14 @@ class EmissRow:
"""Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss').
Отдельно от ``MacroRow`` (open-data, source='rosstat', yearly): ЕМИСС-ряды несут
свою frequency (quarterly/monthly) и source контракт upsert'а у них иной.
свою frequency (quarterly/monthly), source и period_type контракт upsert'а у
них иной.
period_type: гранулярность под-периода ('year' | 'quarter' | 'month' | 'unknown').
Берётся из _emiss_period_granularity(PERIOD). Необходима как часть PK
macro_indicator (migration 163), чтобы годовой агрегат ('год' 'year') и
Q1 ('I квартал' 'quarter') за один год не перезаписывали друг друга при
ON CONFLICT DO UPDATE (оба дают obs_date=YYYY-01-01) (#1606).
"""
indicator_type: str
@ -683,6 +684,7 @@ class EmissRow:
unit: str
frequency: str
comment: str
period_type: str = "unknown"
def _emiss_period_to_month(period: str) -> int | None:
@ -704,6 +706,25 @@ def _emiss_period_to_month(period: str) -> int | None:
return None
def _emiss_period_granularity(period: str) -> str:
"""PERIOD-строку fedstat → гранулярность ('year' | 'quarter' | 'month').
Используется как третья компонента ключа дедупликации в parse_emiss_sdmx, чтобы
годовой ряд ('год') и квартальный Q1 ('I квартал') не перезаписывали друг друга при
одинаковых (region, obs_date=YYYY-01-01) (#1606).
Возвращает 'unknown' для нераспознанных под-периодов (на практике такие ряды
пропускаются ещё до этого вызова через _emiss_period_to_month None).
"""
p = (period or "").strip().lower()
if p in _YEAR_PERIODS:
return "year"
if p in _QUARTER_START_MONTH:
return "quarter"
if p in _MONTH_NAME_TO_NUM:
return "month"
return "unknown"
def _series_concepts(series: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
"""Извлечь (SeriesKey, Attributes) concept→value словари из <generic:Series>.
@ -736,7 +757,10 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
не среди spec.targets ряд пропускается. PERIOD (Attributes) стартовый месяц
под-периода; год из <Obs><Time>; obs_date = date(year, month, 1). Значение
из <Obs><ObsValue value=...> через _parse_value (запятая-десятичный, разделители).
3. Дубли по (region, obs_date) схлопываются (последний выигрывает) PK всё равно один.
3. Дубли по (region, obs_date, granularity) схлопываются (последний выигрывает).
Ключ трёхкомпонентный гранулярность ('year'/'quarter'/'month') предотвращает
перезапись годового ряда ('год' obs_date YYYY-01-01) Q1-рядом ('I квартал'
тот же YYYY-01-01) при совместном их присутствии в одном SDMX-ответе (#1606).
Никогда не бросает на данных: битые ячейки/периоды/годы наблюдение пропускается.
"""
@ -759,7 +783,10 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
return []
target_by_okato = {t.oktmo: t for t in spec.targets}
by_key: dict[tuple[str, date], EmissRow] = {}
# Ключ: (region, obs_date, granularity) — три компоненты предотвращают коллизию
# годового ряда ('год' → YYYY-01-01, granularity='year') и Q1 ('I квартал' →
# тот же YYYY-01-01, granularity='quarter') при их совместном присутствии в SDMX (#1606).
by_key: dict[tuple[str, date, str], EmissRow] = {}
for series in dataset.findall("g:Series", _SDMX_NS):
key, attrs = _series_concepts(series)
@ -767,15 +794,17 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
target = target_by_okato.get(okato)
if target is None:
continue
month = _emiss_period_to_month(attrs.get("PERIOD", ""))
period_raw = attrs.get("PERIOD", "")
month = _emiss_period_to_month(period_raw)
if month is None:
logger.warning(
"EMISS %s: неизвестный PERIOD %r (ОКАТО %s) — пропуск",
spec.indicator_id,
attrs.get("PERIOD"),
period_raw,
okato,
)
continue
granularity = _emiss_period_granularity(period_raw)
for obs in series.findall("g:Obs", _SDMX_NS):
time_el = obs.find("g:Time", _SDMX_NS)
val_el = obs.find("g:ObsValue", _SDMX_NS)
@ -788,7 +817,7 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
if value is None:
continue
obs_date = date(int(yr), month, 1)
by_key[(target.region, obs_date)] = EmissRow(
by_key[(target.region, obs_date, granularity)] = EmissRow(
indicator_type=spec.indicator_type,
region=target.region,
obs_date=obs_date,
@ -796,6 +825,7 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
unit=spec.unit,
frequency=spec.frequency,
comment=spec.comment,
period_type=granularity,
)
return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]
@ -948,9 +978,7 @@ def _parse_year_cell(raw: Any) -> int | None:
return None
def parse_construction_index_xlsx(
content: bytes, spec: XlsxIndicatorSpec
) -> list[MacroRow]:
def parse_construction_index_xlsx(content: bytes, spec: XlsxIndicatorSpec) -> list[MacroRow]:
"""Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow.
Алгоритм (форма листа см. блок выше):

View file

@ -227,13 +227,14 @@ class BrowserSession:
"""
if self._context is None:
raise RuntimeError("BrowserSession not bootstrapped")
await jitter_sleep(200, 500) # Lighter throttle for static assets.
self._request_count += 1
resp = await self._context.request.get(
url,
headers={"Authorization": self.auth} if self.auth else {},
)
if resp.status != 200:
body = await resp.text()
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
return await resp.body()
async with self._sem:
await jitter_sleep(200, 500) # Lighter throttle for static assets.
self._request_count += 1
resp = await self._context.request.get(
url,
headers={"Authorization": self.auth} if self.auth else {},
)
if resp.status != 200:
body = await resp.text()
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
return await resp.body()

View file

@ -488,17 +488,82 @@ _AVG_PRICE_SQL = text("""
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
#
# #1615: velocity обогащается из ДВУХ источников (см. _COMPETITORS_SQL mapped CTE) —
# явного objective_complex_mapping И спатиально-именного nearest_cx gap-fill. Ценовой
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
WITH primary_price AS (
SELECT
cm.domrf_obj_id AS obj_id,
ol.price_per_m2_rub AS price_per_m2_rub
FROM objective_complex_mapping cm
JOIN objective_lots ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND ol.price_per_m2_rub IS NOT NULL
),
nearest_cx AS (
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
c.id AS complex_id
FROM domrf_kn_objects o
JOIN complexes c
ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND c.canonical_name IS NOT NULL
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
)
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
CAST(:velocity_match_radius_m AS float)
)
AND (
lower(btrim(o.comm_name)) = lower(btrim(c.canonical_name))
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
)
WHERE o.obj_id = ANY(:obj_ids)
AND o.latitude IS NOT NULL
AND o.longitude IS NOT NULL
AND o.comm_name IS NOT NULL
AND btrim(o.comm_name) <> ''
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
ORDER BY o.obj_id,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
) ASC
),
gapfill_price AS (
SELECT
nc.obj_id AS obj_id,
ol.price_per_m2_rub AS price_per_m2_rub
FROM nearest_cx nc
JOIN objective_lots ol
ON ol.complex_id = nc.complex_id
AND ol.price_per_m2_rub IS NOT NULL
)
SELECT
cm.domrf_obj_id AS obj_id,
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
p.obj_id,
percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_per_m2_rub)
AS median_price_per_m2
FROM objective_complex_mapping cm
JOIN objective_lots ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND ol.price_per_m2_rub IS NOT NULL
GROUP BY cm.domrf_obj_id
FROM (
SELECT obj_id, price_per_m2_rub FROM primary_price
UNION ALL
SELECT obj_id, price_per_m2_rub FROM gapfill_price
) p
GROUP BY p.obj_id
""")
@ -648,7 +713,13 @@ def get_competitors(
if missing_price_ids:
try:
obj_price_rows = (
db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids})
db.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL,
{
"obj_ids": missing_price_ids,
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
)
.mappings()
.all()
)

View file

@ -7,6 +7,7 @@ Pure function — no DB dependencies, consumes already-fetched data from analyze
from __future__ import annotations
import re
from typing import Literal, TypedDict
from app.services.site_finder.network_obremenenie import (
@ -16,11 +17,28 @@ from app.services.site_finder.network_obremenenie import (
# ── Zone matching ─────────────────────────────────────────────────────────────
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5)
RESIDENTIAL_ZONE_PREFIXES = ("Ж-", "Ж1", "Ж2", "Ж3", "Ж4", "Ж5")
# Fallback по zone_name (lowercase substring)
# Regex для zone_code из PKK6 / pzz_zones_ekb: "Ж", "Ж-1", "Жс", "ЖС-2" и т.д.
# Кейс-инсенситив для защиты от "ж-1" в нестандартных источниках.
# NSPD quarter-dump в zone_code кладёт кадастровый рег.номер (напр. "66:41-7.14") —
# он никогда не начинается на "Ж", поэтому этот regex не даст false positive.
_RESIDENTIAL_CODE_RE = re.compile(r"^[Жж]", re.UNICODE)
# Fallback по zone_name (lowercase substring) — "жилая", "жилой", "жилая зона" и т.д.
RESIDENTIAL_KEYWORDS = ("жил",)
# NSPD territorial zones layer 875838: subcategory (int) → тип зоны.
# Значения получены из реальных данных nspd_quarter_dumps (ЕКБ, 2026-02):
# subcategory=1 — 39 объектов — Жилые зоны малоэтажной застройки (ИЖС/таунхаусы)
# subcategory=2 — 221 объект — Жилые зоны (МКД разрешён, крупнейшая группа ЕКБ)
# subcategory=3 — 126 объектов — Зоны смешанного использования (МКД по ПЗЗ ЕКБ допустим)
# subcategory=4 — 77 объектов — Общественно-деловые
# subcategory=5 — 112 объектов — Производственные
# subcategory=6 — 92 объекта — Рекреационные
# subcategory=8 — 16 объектов — Специальные
# subcategory=10 — 113 объектов — Промышленные/транспортные
# subcategory=1 исключён намеренно: ИЖС-зона МКД не допускает.
NSPD_RESIDENTIAL_SUBCATEGORIES: frozenset[int] = frozenset({2, 3})
# ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода).
@ -45,6 +63,11 @@ WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-з
# Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py
ENGINEERING_NEARBY_THRESHOLD_M = 200
# Warning codes that are purely informational — they appear in warnings[] for the user
# but do NOT downgrade the final verdict from «Можно» to «С ограничениями» (#1617).
# A clean residential parcel with only these warnings still gets verdict_label="Можно".
INFORMATIONAL_WARNING_CODES: frozenset[str] = frozenset({"NO_ENGINEERING_NEARBY"})
# ── TypedDicts ────────────────────────────────────────────────────────────────
@ -71,19 +94,46 @@ class GateVerdict(TypedDict):
# ── Helpers ───────────────────────────────────────────────────────────────────
def is_residential_zone(zone_code: str | None, zone_name: str | None) -> bool:
def is_residential_zone(
zone_code: str | None,
zone_name: str | None,
raw_props: dict | None = None,
) -> bool:
"""Check if ПЗЗ zone permits residential (МКД) building.
Checks zone_code prefix first (exact ПЗЗ codes), then zone_name lowercase
substring match as fallback for non-standard zone labels.
Detection strategy (in priority order):
1. zone_code regex «^Ж» ПЗЗ коды PKK6/pzz_zones_ekb: "Ж-1", "Жс", "ЖС-2" и т.д.
NSPD quarter-dump кладёт в zone_code кадастровый рег.номер ("66:41-7.14")
он не начинается на «Ж», ложных срабатываний нет.
2. NSPD subcategory из raw_props единственный надёжный тип-дискриминатор в
реальных данных nspd_quarter_dumps (layer 875838, ЕКБ 2026-02):
subcategory=2 ("Жилые зоны") и subcategory=3 ("Смешанное использование")
допускают МКД по ПЗЗ ЕКБ. subcategory=1 (ИЖС) намеренно исключён.
3. zone_name substring «жил» fallback для нестандартных источников.
"""
if zone_code:
if any(zone_code.startswith(p) for p in RESIDENTIAL_ZONE_PREFIXES):
return True
# 1. PKK6-style zone_code: starts with Ж (case-insensitive)
if zone_code and _RESIDENTIAL_CODE_RE.match(zone_code):
return True
# 2. NSPD subcategory from raw_props (primary discriminator for quarter-dump data)
if raw_props:
sub_raw = raw_props.get("subcategory")
if sub_raw is not None:
try:
if int(sub_raw) in NSPD_RESIDENTIAL_SUBCATEGORIES:
return True
except (ValueError, TypeError):
pass
# 3. zone_name substring fallback
if zone_name:
zn_lower = zone_name.lower()
if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS):
return True
return False
@ -134,7 +184,11 @@ def compute_gate_verdict(
# Check 1 — ПЗЗ residential zone
checks.append("ПЗЗ зональность")
if nspd_zoning:
if not is_residential_zone(nspd_zoning.get("zone_code"), nspd_zoning.get("zone_name")):
if not is_residential_zone(
nspd_zoning.get("zone_code"),
nspd_zoning.get("zone_name"),
nspd_zoning.get("raw_props"),
):
zone_label = (
nspd_zoning.get("zone_name") or nspd_zoning.get("zone_code") or "неизвестна"
)
@ -259,12 +313,17 @@ def compute_gate_verdict(
)
)
# Final verdict
# Final verdict.
# Informational warnings (e.g. NO_ENGINEERING_NEARBY) appear in warnings[] for display
# but do NOT by themselves downgrade a clean parcel to «С ограничениями» (#1617).
verdict_relevant_warnings = [
w for w in warnings if w["code"] not in INFORMATIONAL_WARNING_CODES
]
can_build: bool | Literal["unknown"]
if blockers:
can_build = False
label = "Нельзя"
elif warnings:
elif verdict_relevant_warnings:
can_build = True
label = "С ограничениями"
else:

View file

@ -79,7 +79,7 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
Returns:
list[{project_type, doc_status_name, is_active, paga_number, paga_date,
subject, doc_full_name}].
- ``is_active``: doc_status_name == 'Действующий'.
- ``is_active``: doc_status_name == 'действующий' (регистронезависимо).
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
Отсортировано: сначала действующие, внутри группы по paga_date desc (свежие первыми).
@ -97,7 +97,10 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
for r in rows:
full_name: str | None = r["full_name"]
paga_number, paga_date, subject = _parse_paga(full_name)
is_active = r["doc_status_name"] == "Действующий"
status_name: str | None = r["doc_status_name"]
# Регистронезависимо: harvest пишет doc_status_name из WFS без нормализации,
# DDL/COMMENT документируют хранимое значение как lowercase ('действующий').
is_active = (status_name or "").strip().lower() == "действующий"
result.append(
{
"project_type": r["project_type"],

View file

@ -65,6 +65,7 @@ def _market_metrics_key(
premise_kind,
)
Confidence = Literal["high", "medium", "low"]
# Лот считается «зависшим» (overstock), если он в продаже дольше этого числа
@ -111,6 +112,9 @@ class MarketMetrics:
demand_concentration: float | None # Херфиндаль продаж по комнатности (0..1)
price_sensitivity: float | None # эластичность цена↔спрос (slope, обычно < 0)
price_sensitivity_source: str | None # 'regression' | 'fallback' | None
# #1593: ед./мес per room-bucket (ключи — вокабуляр _room_bucket():
# "студия","1","2","3","4","5+"). None если нет данных в окне.
velocity_by_room: dict[str, float] | None
def as_dict(self) -> dict[str, Any]:
return {
@ -136,6 +140,8 @@ class MarketMetrics:
"demand_concentration": _round_or_none(self.demand_concentration, 3),
"price_sensitivity": _round_or_none(self.price_sensitivity, 4),
"price_sensitivity_source": self.price_sensitivity_source,
# velocity_by_room намеренно не сериализуется: это внутренний
# pipeline-атрибут (потребляется recommendation.py напрямую).
}
@ -409,6 +415,15 @@ def compute_market_metrics(
liquidity = _liquidity_index(sold_by_room)
overstock = _overstock_index(n_long_unsold, n_available)
demand_conc = _demand_concentration(sold_by_room)
# #1593: per-bucket velocity — ед./мес по каждой комнатности. Ключи зеркалят
# _room_bucket() ("студия","1","2","3","4","5+"). При has_sample=False нет
# смысла делить 0 лотов → None (graceful, зеркало unit_velocity поведения).
# window_months > 0 защищает от ZeroDivisionError (зеркало _monthly_rate()).
vel_by_room: dict[str, float] | None = (
{bkt: float(cnt) / float(window_months) for bkt, cnt in sold_by_room.items()}
if has_sample and sold_by_room and window_months > 0
else None
)
# ── price_sensitivity — reuse analytics_queries._elasticity_coef ───────────
price_sensitivity, price_sensitivity_source = _price_sensitivity(
@ -450,6 +465,7 @@ def compute_market_metrics(
demand_concentration=demand_conc,
price_sensitivity=price_sensitivity,
price_sensitivity_source=price_sensitivity_source,
velocity_by_room=vel_by_room,
)

View file

@ -44,6 +44,21 @@ CATEGORY_WEIGHTS: dict[str, float] = {
"default": 1.0,
}
# ── Нормировочные константы (для вывода score_contribution / poi_weighted_score в 0..100) ──
#
# Теоретический максимум суммы весов top-7 POI при идеальном расположении:
# straight-line: w_i = cat_weight_i / (0+100) → max_sum = Σ(top7 cat_weights) / 100
# routing-decay: w_i = cat_weight_i * decay(0) = cat_weight_i → max_sum = Σ(top7 cat_weights)
#
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
# straight-line mode: каждый POI берётся с множителем 1/(d+100); при d=0 → /100
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
# routing-decay mode: decay ∈ [0,1], при t=0 decay=1.0 → max = cat_weight
_MAX_ROUTING_SCORE: float = _TOP7_WEIGHT_SUM # = 31.5
class PoiScoreItem(BaseModel):
"""Один POI в ranked-ответе."""
@ -52,12 +67,17 @@ class PoiScoreItem(BaseModel):
category: str
distance_m: float
weight: float
# Вклад данного POI в суммарный балл, в процентах от 0..100.
# Позволяет фронтенду рендерить «долю» без знания формулы нормировки.
score_contribution: float
address: str | None
class PoiScoreResponse(BaseModel):
cad_num: str
radius_m: int
# Суммарный взвешенный балл инфраструктуры, нормированный в диапазон 0..100.
poi_weighted_score: float
top_poi: list[PoiScoreItem]
@ -155,6 +175,7 @@ def compute_poi_weighted_top7(
category=category,
distance_m=round(distance_m, 1),
weight=round(weight, 6),
score_contribution=0.0, # заполним после нормировки
address=address,
)
)
@ -163,9 +184,18 @@ def compute_poi_weighted_top7(
items.sort(key=lambda x: x.weight, reverse=True)
top_items = items[:top_n]
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_STRAIGHT_SCORE) * 100, клэмп [0, 100]
raw_sum = sum(i.weight for i in top_items)
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0), 1)
# score_contribution — доля данного POI в общем балле (0..100)
for item in top_items:
item.score_contribution = round((item.weight / _MAX_STRAIGHT_SCORE) * 100.0, 1)
return PoiScoreResponse(
cad_num=cad_num,
radius_m=radius_m,
poi_weighted_score=poi_weighted_score,
top_poi=top_items,
)
@ -303,7 +333,9 @@ def compute_poi_routing_decay(
)
if not rows:
return PoiScoreResponse(cad_num=cad_num, radius_m=radius_m, top_poi=[])
return PoiScoreResponse(
cad_num=cad_num, radius_m=radius_m, poi_weighted_score=0.0, top_poi=[]
)
destinations = [(float(r["lon"]), float(r["lat"])) for r in rows]
@ -311,9 +343,7 @@ def compute_poi_routing_decay(
times_min: list[float | None]
routing_used = False
try:
times_min = ors_client.matrix_durations_min(
lon, lat, destinations, profile=profile
)
times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile)
routing_used = True
except ors_client.OrsUnavailableError as exc:
logger.info(
@ -355,13 +385,25 @@ def compute_poi_routing_decay(
category=category,
distance_m=round(distance_m, 1),
weight=round(weight, 6),
score_contribution=0.0, # заполним после нормировки
address=address,
)
)
items.sort(key=lambda x: x.weight, reverse=True)
top_items = items[:top_n]
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_ROUTING_SCORE) * 100, клэмп [0, 100]
raw_sum = sum(i.weight for i in top_items)
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_ROUTING_SCORE) * 100.0), 1)
# score_contribution — доля данного POI в общем балле (0..100)
for item in top_items:
item.score_contribution = round((item.weight / _MAX_ROUTING_SCORE) * 100.0, 1)
return PoiScoreResponse(
cad_num=cad_num,
radius_m=radius_m,
top_poi=items[:top_n],
poi_weighted_score=poi_weighted_score,
top_poi=top_items,
)

View file

@ -10,8 +10,14 @@
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
source_key целое и не совпадает форматом. Сопоставление две дешёвые ветки:
1) ``doc_ref = source_key::text`` (прямое равенство);
2) ``doc_ref ILIKE '%doc_full_name%'`` (fallback для slug-ов вида ppt2018_22823).
1) ``doc_ref = CAST(source_key AS text)`` (прямое равенство);
2) ``doc_full_name ILIKE '%' || doc_ref || '%'`` (fallback для slug-ов вида
ppt2018_22823: длинное WFS-описание содержит короткий slug парсера; LIKE-метасимволы
в doc_ref экранируются ESCAPE '\').
Дедуп: best-effort OR-JOIN может зацепить несколько строк ``ekb_ppt_tep`` на один
``planning_projects`` (точная ветка + ILIKE-fallback). ``DISTINCT ON (source_key)``
держит контракт «одна строка = один ППТ/ПМТ-документ», отдавая приоритет точной ветке.
Граф вызова: build_ird_analyze_block parcel_ppt_tep JOIN planning_projects ekb_ppt_tep.
Graceful: нет таблицы / пустой WKT / БД-ошибка []. Зеркалит стиль ``planning_lookup.py``.
@ -33,26 +39,49 @@ logger = logging.getLogger(__name__)
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
_PPT_TEP_OVERLAP_SQL = text(
"""
r"""
SELECT
pp.project_type,
pp.doc_status_name,
pp.full_name,
pp.doc_full_name,
pp.source_key,
t.doc_ref,
t.zone_balance,
t.tep,
t.phasing,
t.source_pdf,
t.fetched_at
FROM planning_projects pp
JOIN ekb_ppt_tep t ON (
t.doc_ref = CAST(pp.source_key AS text)
OR (pp.doc_full_name IS NOT NULL AND t.doc_ref ILIKE '%' || pp.doc_full_name || '%')
)
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
ORDER BY pp.dmd_actual_year DESC NULLS LAST, pp.project_type
project_type,
doc_status_name,
full_name,
doc_full_name,
source_key,
doc_ref,
zone_balance,
tep,
phasing,
source_pdf,
fetched_at
FROM (
-- DISTINCT ON (source_key): один ППТ/ПМТ-документ = одна строка (контракт docstring).
-- Точная ветка (doc_ref = source_key) приоритетна над slug-fallback (ILIKE).
SELECT DISTINCT ON (pp.source_key)
pp.project_type,
pp.doc_status_name,
pp.full_name,
pp.doc_full_name,
pp.source_key,
pp.dmd_actual_year,
t.doc_ref,
t.zone_balance,
t.tep,
t.phasing,
t.source_pdf,
t.fetched_at
FROM planning_projects pp
JOIN ekb_ppt_tep t ON (
t.doc_ref = CAST(pp.source_key AS text)
OR (
pp.doc_full_name IS NOT NULL
AND pp.doc_full_name ILIKE
'%' || replace(replace(replace(t.doc_ref, '\', '\\'), '%', '\%'), '_', '\_')
|| '%' ESCAPE '\'
)
)
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
ORDER BY pp.source_key, (t.doc_ref = CAST(pp.source_key AS text)) DESC
) deduped
ORDER BY dmd_actual_year DESC NULLS LAST, project_type
"""
)

View file

@ -74,13 +74,16 @@ class BuildingMatch:
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает
# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по
# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на
# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно
# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка
# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см.
# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type.
# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
_RESOLVE_CAD_SQL = text("""
SELECT
b.cad_num,
@ -97,13 +100,13 @@ _RESOLVE_CAD_SQL = text("""
) AS distance_m
FROM cad_buildings b
WHERE b.geom IS NOT NULL
ORDER BY b.geom <-> ST_SetSRID(
ORDER BY b.geom::geography <-> ST_SetSRID(
ST_MakePoint(
CAST(:lon AS double precision),
CAST(:lat AS double precision)
),
4326
)
)::geography
LIMIT 1
""")
@ -119,8 +122,9 @@ def resolve_cad_for_domrf(
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до
footprint max_dist_m. Точка внутри площадного footprint distance 0.
GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
принимаем матч, если расстояние до footprint max_dist_m. Точка внутри
площадного footprint distance 0.
Returns:
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в

View file

@ -22,7 +22,7 @@ from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import DatabaseError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@ -41,8 +41,11 @@ def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> None:
else:
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
db.commit()
except OperationalError as e:
if concurrently and "cannot refresh materialized view" in str(e).lower():
except DatabaseError as e:
# PostgreSQL emits "CONCURRENTLY cannot be used when the materialized
# view ... is not populated" (matview.c, SQLSTATE 55000), which psycopg3
# surfaces as InternalError (a DatabaseError sibling of OperationalError).
if concurrently and "concurrently cannot be used" in str(e).lower():
logger.warning(
"%s: CONCURRENTLY failed (MV likely not populated), "
"falling back to non-concurrent refresh",

View 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",
]

View 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

View file

@ -81,9 +81,14 @@ _EKB_REGION_CODE: int = 66
# Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations).
_L2_MEDIUM_MIN_COVERAGE: float = 0.6
# L2/L3 граница (включительно в L2, исключительно в L3) — половинно-открытые интервалы:
# L2 (hidden) : ready_dt IS NULL OR ready_dt <= now + L2_NEAR_HORIZON [now, now+12mo]
# L3 (future) : ready_dt IS NOT NULL AND ready_dt > now + L3_FUTURE_HORIZON (now+12mo, ∞)
# Оба используют одно и то же значение 12 мес., но по разные стороны границы — нет overlap.
_L2_NEAR_HORIZON_MONTHS: int = 12 # L2 берёт объекты с ready_dt ≤ сейчас+12мес (или NULL)
# L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже
# почти-на-рынке предложение (ловится L1/L2), не «future».
_L3_FUTURE_HORIZON_MONTHS: int = 12
_L3_FUTURE_HORIZON_MONTHS: int = 12 # L3 берёт объекты с ready_dt > сейчас+12мес
# L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго
# 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт.
@ -215,6 +220,10 @@ _L1_OPEN_SQL = text(
# free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим;
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
#
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
_L2_HIDDEN_SQL = text(
"""
WITH latest AS (
@ -222,7 +231,8 @@ _L2_HIDDEN_SQL = text(
o.obj_id,
o.district_name,
o.flat_count,
o.free_flats
o.free_flats,
o.ready_dt
FROM domrf_kn_objects o
WHERE o.region_cd = :region_cd
AND o.district_name IS NOT NULL
@ -240,6 +250,8 @@ _L2_HIDDEN_SQL = text(
GREATEST(0, COALESCE(flat_count, 0) - free_flats)
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
FROM latest
WHERE ready_dt IS NULL
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
GROUP BY district_name
"""
)
@ -368,7 +380,11 @@ def compute_layer2_hidden(
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
units_estimate всегда 0 (clamp). Пустые данные []. Не бросает.
"""
params: dict[str, Any] = {"region_cd": _EKB_REGION_CODE, "district": district}
params: dict[str, Any] = {
"region_cd": _EKB_REGION_CODE,
"district": district,
"near_horizon": f"{_L2_NEAR_HORIZON_MONTHS} months",
}
rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2)
out: list[SupplyLayerRow] = []

View file

@ -16,9 +16,13 @@ Foundation: domrf_kn_objects (lat/lon, comm_name, obj_class, region_cd),
Fallback: rosreestr_deals (quarter_cad_number, deal_count, period_start_date).
Linkage: domrf_kn_objects.obj_id
objective_complex_mapping.domrf_obj_id
objective_complex_mapping.domrf_obj_id (gated: is_reviewed/manual/score0.85)
objective_complex_mapping.objective_complex_name
objective_corpus_room_month.project_name
OBJ-2 (#307): маппинги фильтруются по confidence (_MAPPING_CONFIDENCE_GATE) —
unreviewed low-score auto-matches (#1331/#1333 backfill) исключаются как
false-positive risk.
"""
from __future__ import annotations
@ -40,6 +44,24 @@ _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0
# пытаемся rosreestr_fallback.
_OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50
# OBJ-2 (#307): gate objective_complex_mapping by confidence перед использованием
# в velocity. Fuzzy-trgm backfill (#1331/#1333) добавил ~115 auto-matched строк с
# is_reviewed=false и низким match_score (вплоть до 0.50-0.625) — false-positive
# risk, который раздувал/искажал velocity конкурентов.
#
# Принимаем mapping только если:
# - is_reviewed = TRUE (человек подтвердил), ИЛИ
# - match_method = 'manual' (ручной маппинг, score обычно NULL), ИЛИ
# - match_score >= 0.85 (AUTO_ACCEPT_THRESHOLD — high-confidence auto,
# vault: fuzzy_trgm 0.85+ надёжен для auto-use).
#
# Строгий gate только на is_reviewed=true дал бы 2 строки из 303 → обнулил бы
# velocity-покрытие; 0.85-порог сохраняет 264/303 EKB-маппингов, отбрасывая 39
# низкоуверенных. Совпадает с AUTO_ACCEPT_THRESHOLD из objective_backfill.py.
_MAPPING_CONFIDENCE_GATE: str = (
"(cm.is_reviewed = TRUE OR cm.match_method = 'manual' OR cm.match_score >= 0.85)"
)
@dataclass(frozen=True)
class VelocityResult:
@ -195,7 +217,7 @@ def compute_velocity(
sales_rows = (
db.execute(
text(
"""
f"""
WITH all_competitors AS (
SELECT unnest(CAST(:obj_ids AS int[])) AS obj_id
),
@ -204,6 +226,7 @@ def compute_velocity(
cm.objective_complex_name
FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND {_MAPPING_CONFIDENCE_GATE}
)
SELECT
ac.obj_id,
@ -311,12 +334,13 @@ def compute_velocity(
bucket_rows = (
db.execute(
text(
"""
f"""
WITH mapped AS (
SELECT cm.domrf_obj_id AS obj_id,
cm.objective_complex_name
FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND {_MAPPING_CONFIDENCE_GATE}
)
SELECT
m.obj_id,

View file

@ -78,7 +78,7 @@ _RE_FLOORS = re.compile(
+ _DASH
+ r"\s*"
+ _NUM
+ r"\s*этаж",
+ r"(?:\s*этаж\w*)?",
re.IGNORECASE,
)
# высота — 'предельная высота ... 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)

View file

@ -130,19 +130,28 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
if not daily.get("time"):
return None
t_max = daily.get("temperature_2m_max") or []
t_min = daily.get("temperature_2m_min") or []
precip = daily.get("precipitation_sum") or []
uv = daily.get("uv_index_max") or []
wind_d = daily.get("winddirection_10m_dominant") or []
wind_s = daily.get("windspeed_10m_max") or []
# Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
# при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
# TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
t_max = [v for v in (daily.get("temperature_2m_max") or []) if v is not None]
t_min = [v for v in (daily.get("temperature_2m_min") or []) if v is not None]
precip = [v for v in (daily.get("precipitation_sum") or []) if v is not None]
uv = [v for v in (daily.get("uv_index_max") or []) if v is not None]
wind_d = [v for v in (daily.get("winddirection_10m_dominant") or []) if v is not None]
wind_s = [v for v in (daily.get("windspeed_10m_max") or []) if v is not None]
# Circular mean направления ветра (vector sum) — избегает jump 359→1
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None)
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0
# Circular mean направления ветра (vector sum) — избегает jump 359→1.
# Если нет ни одного валидного сэмпла — dominant=None (не 0.0° = север).
if wind_d:
x = sum(math.cos(math.radians(d)) for d in wind_d)
y = sum(math.sin(math.radians(d)) for d in wind_d)
dominant_deg: float | None = (math.degrees(math.atan2(y, x)) + 360) % 360
else:
dominant_deg = None
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
wind_label = rose[round(dominant / 45) % 8]
wind_label: str | None = (
rose[round(dominant_deg / 45) % 8] if dominant_deg is not None else None
)
return {
"forecast_days": len(daily.get("time", [])),
@ -156,7 +165,9 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
"precipitation_days": sum(1 for p in precip if p and p > 0.5),
"uv_index_max": round(max(uv), 1) if uv else None,
"wind": {
"dominant_direction_deg": round(dominant),
"dominant_direction_deg": (
round(dominant_deg) if dominant_deg is not None else None
),
"dominant_direction_label": wind_label,
"max_speed_m_s": round(max(wind_s), 1) if wind_s else None,
},
@ -226,13 +237,21 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
if not vals["t_max"]:
seasons[season] = None
continue
# t_min/precip накапливаются НЕЗАВИСИМО от t_max (раздельные None-guard'ы
# выше) — при непустом t_max и all-null t_min/precip эти списки пусты, и
# sum()/len() даст ZeroDivisionError, min([]) — ValueError (#1578). Метрики
# по пустому списку → None вместо падения всего сезонного ответа.
t_min = vals["t_min"]
precip = vals["precip"]
seasons[season] = {
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
"avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
"avg_t_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
"max_t_c": round(max(vals["t_max"]), 1),
"min_t_c": round(min(vals["t_min"]), 1),
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
"total_precip_mm": round(sum(vals["precip"]), 0),
"min_t_c": round(min(t_min), 1) if t_min else None,
"avg_precip_per_day_mm": (
round(sum(precip) / len(precip), 1) if precip else None
),
"total_precip_mm": round(sum(precip), 0) if precip else 0,
"days_observed": len(vals["t_max"]),
}
return {

View file

@ -137,6 +137,16 @@ def _build_beat_schedule_from_db() -> dict:
"options": {"queue": "celery"},
}
# РИАСУРТ Свердл (#108): региональные градостроительные зоны агломерации ЕКБ меняются
# медленно → ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК). NB: harvest_all_*
# пока на ПЛЕЙСХОЛДЕР-bbox 5 МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO) — фактический
# прод-прогон только после уточнения границ.
schedule["riasurt-sverdl-harvest-quarterly"] = {
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
"options": {"queue": "celery"},
}
return schedule
@ -192,6 +202,14 @@ def _build_beat_schedule_fallback() -> dict:
"options": {"queue": "celery"},
}
# РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
# NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
schedule["riasurt-sverdl-harvest-quarterly"] = {
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
"options": {"queue": "celery"},
}
return schedule
@ -506,4 +524,37 @@ def build_beat_schedule() -> dict:
"options": {"queue": "celery"},
}
# Refresh mv_layout_velocity (#1666) — питает «лучшие планировки» best_layouts (#113).
# До этого MV рефрешился ТОЛЬКО ручным вызовом refresh_layout_velocity() → в проде
# данные молча устаревали. REFRESH ... CONCURRENTLY (non-blocking) по mv_layout_velocity.
#
# Воскресенье 03:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
# Намеренно ВНЕ monday-кластера тяжёлых site_finder-рефрешей (scrape_kn 04:15,
# ird 05:00, gknspecial 05:30, supply-layers 06:00, genplan 06:30, location 07:00) и
# вне воскресного okn-objects (04:30) — час запаса, не конкурирует за БД/CPU.
# MV агрегирует темпы вымывания планировок (меняются медленно) → еженедельно хватает.
# Техническая infra-задача, не в job_settings (как refresh-quarter-price-index / supply-layers).
schedule["refresh-layout-velocity"] = {
"task": "tasks.refresh_layout_velocity.refresh_layout_velocity",
"schedule": _parse_cron("0 3 * * sun"), # 03:00 MSK, воскресенье
"options": {"queue": "celery"},
}
# Sales-tracker MVs (#61) — питают Site Finder Velocity Score (4-й критерий) +
# recommend_mix / sellout-forecast. Оба MV (mv_sales_tracker_velocity_by_district,
# mv_sales_tracker_absorption_curves) рефрешатся CONCURRENTLY (non-blocking, требуют
# unique-индексы из миграции 161). Источник — objective_lots / objective_lots_history
# (Объектив-шахматки), наполняются objective_sync (Mon 04:15 МСК по умолчанию).
#
# Понедельник 04:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233) —
# ПОСЛЕ objective_sync (04:15), чтобы агрегаты считались по свежему снапшоту; в
# окне до тяжёлого monday-кластера site_finder-рефрешей (ird 05:00, gknspecial 05:30,
# supply-layers 06:00). Refresh лёгкий (~6с на 1.1M lots). Техническая infra-задача,
# не в job_settings (как refresh-quarter-price-index / refresh-layout-velocity).
schedule["mv-sales-tracker-refresh-weekly"] = {
"task": "tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs",
"schedule": _parse_cron("30 4 * * mon"), # 04:30 MSK, понедельник
"options": {"queue": "celery"},
}
return schedule

View file

@ -62,6 +62,8 @@ celery_app = Celery(
"app.workers.tasks.ekburg_permits_sync",
"app.workers.tasks.cbr_macro_sync",
"app.workers.tasks.rosstat_macro_sync",
"app.workers.tasks.refresh_quarter_price_index",
"app.workers.tasks.etl_newbuilding_crossload",
"app.workers.tasks.supply_layers_refresh",
"app.workers.tasks.location_refresh",
"app.workers.tasks.forecast",
@ -79,6 +81,9 @@ celery_app = Celery(
"app.workers.tasks.pat_subzones_load",
"app.workers.tasks.izyatie_ocr_ingest",
"app.workers.tasks.developer_registry_refresh",
"app.workers.tasks.refresh_layout_velocity",
"app.workers.tasks.riasurt_sverdl_harvest",
"app.workers.tasks.mv_sales_tracker_refresh",
],
)
celery_app.conf.timezone = "Europe/Moscow"

View file

@ -8,8 +8,10 @@ Handlers регистрируются через Celery signals декорато
worker_process_init dispose SQLAlchemy engine в каждом prefork child-процессе,
чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
worker_ready при рестарте воркера находит все 'running'/'paused' записи
worker_ready при рестарте воркера находит 'running'/'paused' записи
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
Для nspd_geo 'paused' (WAF-пауза) применяется 30-минутный cooldown
(_ZOMBIE_PAUSED_THRESHOLD), чтобы редеплой не аннулировал WAF-защиту.
"""
import logging
@ -147,10 +149,18 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' / 'paused' jobs
# — на worker_ready по определению нет активных воркеров, всё running ==
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' jobs — на
# worker_ready по определению нет активных воркеров, всё running ==
# zombie. Раньше требовали heartbeat >10мин, что пропускало jobs убитых
# за минуту до редеплоя и оставляло их вечно висеть.
#
# 'paused' (consecutive_waf>=8 — NSPD-WAF забанил IP VPS) НЕ ре-enqueue'им
# безусловно: иначе каждый рестарт/редеплой воркера мгновенно аннулировал бы
# WAF-cooldown и worker снова долбил бы забаненный сервис. Применяем тот же
# 30-минутный порог, что и периодический cleanup_zombies
# (_ZOMBIE_PAUSED_THRESHOLD), переиспользуя константу чтобы избежать дрейфа.
from app.workers.tasks.nspd_geo import _ZOMBIE_PAUSED_THRESHOLD
db = SessionLocal()
geo_resume_jobs: list[int] = []
try:
@ -161,10 +171,16 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
UPDATE nspd_geo_jobs
SET status = 'queued',
error = COALESCE(error, 'auto-resume at worker_ready')
WHERE status IN ('running', 'paused')
WHERE status = 'running'
OR (
status = 'paused'
AND heartbeat_at
< NOW() - CAST(:paused_threshold AS interval)
)
RETURNING job_id
"""
)
),
{"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD},
)
.mappings()
.all()

View file

@ -41,19 +41,21 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123):
# (indicator_type, region, obs_date, value, source, frequency, unit, comment,
# updated_at) PK (indicator_type, region, obs_date).
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
# (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
# comment, updated_at). PK (indicator_type, region, obs_date, period_type).
# CBR-ряды используют period_type='unknown' (литерал) — они не несут sub-period
# granularity и различаются по obs_date.
UPSERT_KEY_RATE_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
'key_rate', 'rf', CAST(:d AS date), CAST(:v AS numeric),
'key_rate', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric),
'cbr', 'daily', '%', 'CBR key rate'
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
updated_at = now()
"""
@ -61,16 +63,17 @@ UPSERT_KEY_RATE_SQL = text(
# Инфляция «% г/г» (ИПЦ YoY): indicator_type='inflation_yoy', monthly, region='rf'.
# obs_date уже нормализован к 1-му числу месяца парсером (parse_inflation_xlsx).
# period_type='unknown' — месячный ряд без sub-period granularity.
UPSERT_INFLATION_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
'inflation_yoy', 'rf', CAST(:d AS date), CAST(:v AS numeric),
'inflation_yoy', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric),
'cbr', 'monthly', '%', 'CBR inflation YoY (ИПЦ, % г/г)'
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
updated_at = now()
"""

View 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()

View file

@ -51,6 +51,16 @@ WAF_BACKOFF_BASE_S = 30
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
_ZOMBIE_PAUSED_THRESHOLD = "30 minutes"
# Issue #1655: jobs, застрявшие в 'queued' после ре-enqueue (worker_ready /
# cleanup_zombies ставят 'queued' + apply_async), но чьё broker-сообщение
# потерялось (flush брокера, crash до pickup, purge очереди) — раньше никто
# не ре-reap'ил. cleanup_zombies матчил только 'running'/'paused'.
# Матчим только 'queued' c НЕ-NULL stale heartbeat_at: свежесозданный job
# (enqueue_geo_job) имеет heartbeat_at IS NULL (нет дефолта в схеме 77_) — его
# initial apply_async ещё в полёте, ре-reap'ить рано. Порог = running-порог
# (6 мин): сообщение либо взято воркером (→ status станет 'running' через claim),
# либо потеряно — 6 мин достаточно, чтобы отличить потерю от задержки очереди.
_ZOMBIE_QUEUED_THRESHOLD = "6 minutes"
# ── Helpers для job/target lifecycle ────────────────────────────────────────
@ -79,8 +89,19 @@ def _log(
pass
def _start_job(db: Session, job_id: int) -> None:
db.execute(
def _start_job(db: Session, job_id: int) -> bool:
"""Атомарный claim job'а: переводит в 'running' ТОЛЬКО если он ещё не 'running'.
Issue #1621: дублирующие task-сообщения на один job_id (worker_ready resume +
cleanup_zombies beat одновременно, либо overlap контейнеров при rolling-redeploy,
либо два prefork-child при --concurrency>1) раньше оба безусловно делали
UPDATE'running' и оба входили в while-loop двойные WAF-хиты + затёртые счётчики.
Теперь claim атомарен: `WHERE job_id=:id AND status <> 'running'` + RETURNING.
Если строка не вернулась кто-то уже держит claim, второй worker должен выйти.
Returns True если claim получен, False если job уже 'running' (или не найден).
"""
claimed = db.execute(
text(
"""
UPDATE nspd_geo_jobs
@ -89,12 +110,17 @@ def _start_job(db: Session, job_id: int) -> None:
heartbeat_at = NOW(),
updated_at = NOW()
WHERE job_id = :id
AND status <> 'running'
RETURNING job_id
"""
),
{"id": job_id},
)
).scalar_one_or_none()
db.commit()
if claimed is None:
return False
_log(db, job_id, "info", "start", "job started/resumed")
return True
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
@ -385,7 +411,15 @@ def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
if not job:
return {"error": "job_not_found", "job_id": job_id}
_start_job(db, job_id)
# Атомарный claim (issue #1621): если job уже 'running', значит другое
# task-сообщение на тот же job_id уже в работе — выходим, не дублируя
# WAF-хиты и не затирая счётчики параллельным циклом.
if not _start_job(db, job_id):
logger.info(
"process_nspd_geo_job: job=%s already claimed (running) — skipping duplicate",
job_id,
)
return {"job_id": job_id, "skipped": True, "reason": "already_running"}
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
if job["rate_ms"]:
@ -637,6 +671,10 @@ def cleanup_zombies() -> dict[str, Any]:
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин)
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис.
- status='queued' c НЕ-NULL heartbeat старше _ZOMBIE_QUEUED_THRESHOLD (6 мин)
ре-enqueue'нутый job, чьё broker-сообщение потерялось (issue #1655).
heartbeat_at IS NOT NULL отсекает свежесозданные jobs (initial apply_async
ещё в полёте) их heartbeat ставится только при первом _start_job.
Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat
не матчит WHERE-clause.
@ -661,12 +699,22 @@ def cleanup_zombies() -> dict[str, Any]:
AND heartbeat_at
< NOW() - CAST(:paused_threshold AS interval)
)
OR (
-- issue #1655: ре-enqueue'нутый job застрял в 'queued'
-- (потерянное broker-сообщение). heartbeat_at IS NOT NULL
-- отсекает свежесозданные jobs с ещё-в-полёте apply_async.
status = 'queued'
AND heartbeat_at IS NOT NULL
AND heartbeat_at
< NOW() - CAST(:queued_threshold AS interval)
)
RETURNING job_id
"""
),
{
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
"queued_threshold": _ZOMBIE_QUEUED_THRESHOLD,
},
)
.mappings()

View file

@ -153,9 +153,12 @@ def import_anton_objective(
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
except Exception:
pass
# Дополним результат полезной диагностикой
# Логируем полезную диагностику, но НЕ возвращаем dict —
# re-raise, чтобы Celery-таск ушёл в FAILURE (не SUCCESS) и не
# рассинхронился с objective_scrape_runs.status='failed' (#1623).
info = get_sqlite_info(sqlite_path_eff)
return {"run_id": run_id, "error": "sqlite_not_found", "sqlite_info": info}
logger.error("sqlite_not_found diagnostics for run=%s: %s", run_id, info)
raise
except Exception as e:
logger.exception("import_anton_objective failed: %s", e)

View file

@ -70,7 +70,10 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
bbox = _geojson_bbox_3857(qfeat.geometry)
if bbox is None:
continue
n_quarters += 1
# Staging-счётчик квартала: прибавляем к итогам ТОЛЬКО после успешного
# commit (строка ниже). Иначе при сбое commit + rollback откатятся
# незакоммиченные UPSERT'ы, а отчёт завысит число persisted фич (#1624).
q_features = 0
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
layer_id = LAYERS.get(layer_key)
if layer_id is None:
@ -94,10 +97,13 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
feature=feat,
fetched_at=fetched_at,
):
n_features += 1
q_features += 1
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
db.commit()
# Commit прошёл → фичи квартала реально persisted, учитываем в отчёте.
n_quarters += 1
n_features += q_features
except Exception as exc:
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
db.rollback() # сбросить незакоммиченный tx квартала перед следующим

View 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()

View 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)

View file

@ -47,21 +47,23 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123):
# (indicator_type, region, obs_date, value, source, frequency, unit, comment,
# updated_at) PK (indicator_type, region, obs_date). source='rosstat',
# frequency='yearly' (текущие ряды Росстата — годовые: obs_date = 1 января года).
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
# (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
# comment, updated_at). PK (indicator_type, region, obs_date, period_type).
# Не-ЕМИСС источники (open-data, СМР) используют period_type='unknown' (литерал) —
# они различаются по obs_date без sub-period granularity.
# source='rosstat', frequency='yearly' (текущие ряды Росстата — годовые).
UPSERT_ROSSTAT_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric),
'unknown', CAST(:v AS numeric),
'rosstat', 'yearly', CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
source = EXCLUDED.source,
frequency = EXCLUDED.frequency,
@ -71,20 +73,23 @@ UPSERT_ROSSTAT_SQL = text(
"""
)
# ЕМИСС-ряды (source='emiss'): frequency параметризована (quarterly для доходов,
# monthly для ИПЦ когда добавится) — в отличие от open-data, где она фикс 'yearly'.
# Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
# ЕМИСС-ряды (source='emiss'): period_type параметризован — 'year' | 'quarter' |
# 'month' (из _emiss_period_granularity). Это ключевое отличие от не-ЕМИСС источников:
# период ЕМИСС-ряда несёт granularity, необходимую для разделения годового агрегата
# ('год' → 'year') и Q1 ('I квартал' → 'quarter'), оба с obs_date=YYYY-01-01 (#1606).
# frequency параметризована (quarterly для доходов, monthly для ИПЦ когда добавится).
# CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
UPSERT_EMISS_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric),
CAST(:period_type AS text), CAST(:v AS numeric),
'emiss', CAST(:freq AS text), CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
source = EXCLUDED.source,
frequency = EXCLUDED.frequency,
@ -96,19 +101,19 @@ UPSERT_EMISS_SQL = text(
# Индекс цен на СМР — открытый xlsx Росстата: source='rosstat' (rosstat.gov.ru-файл),
# НО frequency='monthly' (в отличие от open-data демографии — там фикс 'yearly'),
# поэтому frequency параметризована. MacroRow не несёт frequency-поля, подставляем
# литералом в bind. Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — psycopg v3.
# поэтому frequency параметризована. MacroRow не несёт frequency/period_type-полей;
# подставляем литералами. CAST(:x AS type) — psycopg v3.
UPSERT_ROSSTAT_MONTHLY_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric),
'unknown', CAST(:v AS numeric),
'rosstat', 'monthly', CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
source = EXCLUDED.source,
frequency = EXCLUDED.frequency,
@ -185,9 +190,15 @@ def _upsert_monthly_rows(db: Session, rows: list[MacroRow]) -> int:
def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
"""Апсертит EmissRow в macro_indicator (source='emiss', frequency per-row).
"""Апсертит EmissRow в macro_indicator (source='emiss', frequency+period_type per-row).
SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает
число успешных upsert'ов."""
число успешных upsert'ов.
period_type (из r.period_type) часть нового PK (migration 163): позволяет
годовому агрегату ('year') и Q1 ('quarter') за один год коexist в таблице без
взаимной перезаписи (#1606).
"""
upserted = 0
for r in rows:
try:
@ -198,6 +209,7 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
"itype": r.indicator_type,
"region": r.region,
"d": r.obs_date,
"period_type": r.period_type,
"v": r.value,
"freq": r.frequency,
"unit": r.unit,
@ -207,10 +219,11 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
upserted += 1
except Exception as e:
logger.warning(
"upsert emiss %s/%s@%s=%s failed: %s",
"upsert emiss %s/%s@%s[%s]=%s failed: %s",
r.indicator_type,
r.region,
r.obs_date,
r.period_type,
r.value,
e,
)

View file

@ -401,11 +401,28 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
)
skipped_fresh = set(rows)
if skipped_fresh:
# Корректируем targets_total — skipped quarters не входят в прогресс
# Корректируем targets_total — skipped quarters не входят в прогресс.
# Bug #1654: блок выполняется при КАЖДОМ вызове (resume re-enqueue'ит
# тот же enqueue_cadastre_harvest), а уже обработанные кварталы тоже
# получают свежий cad_quarter_stats.fetched_at → попадают в
# skipped_fresh повторно. Поэтому:
# • targets_skipped — идемпотентный SET (= число свежих сейчас),
# а не cumulative INCREMENT (иначе раздувается на каждый resume);
# • targets_total — :new_total это только ОСТАВШИЕСЯ к обработке
# кварталы (len(quarters) skipped_fresh). При resume уже
# обработанные попадают в skipped_fresh, поэтому к остатку
# добавляем уже учтённый прогресс (done + failed), иначе total
# занижается и _maybe_finish_job помечает job 'done' после первого
# доработанного квартала (#1654-followup). GREATEST с самим
# прогрессом сохраняет монотонность (total не уменьшается).
db.execute(
text(
"UPDATE cadastre_jobs SET targets_total = :new_total, "
"targets_skipped = COALESCE(targets_skipped, 0) + :sk "
"UPDATE cadastre_jobs SET "
"targets_total = GREATEST("
":new_total + COALESCE(targets_done, 0) + COALESCE(targets_failed, 0), "
"COALESCE(targets_done, 0) + COALESCE(targets_failed, 0)"
"), "
"targets_skipped = :sk "
"WHERE job_id = :id"
),
{

View file

@ -35,6 +35,10 @@ logger = logging.getLogger(__name__)
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
#
# LIMIT :max_objects: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
# max_objects=None (force "Загрузить все" без явного потолка) грузим ВСЕ строки
# последнего snapshot, а не молча режем до _DEFAULT_MAX_OBJECTS.
_SELECT_TARGETS_SQL = text(
"""
SELECT obj_id, snapshot_date
@ -74,10 +78,12 @@ def scrape_kn_catalog_objects(
Args:
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
max_objects: Максимум объектов за один run. Default 300.
force: Если True игнорирует фильтр "уже сегодня обновлён" и грузит
все объекты последнего snapshot (admin "Загрузить все"). По умолчанию
False пропускает то, что уже скраплено сегодня.
max_objects: Максимум объектов за один run. Если не задан: при force=True
лимита нет (грузим все), при force=False _DEFAULT_MAX_OBJECTS (300).
force: Если True игнорирует фильтр "уже сегодня обновлён" и (при не
заданном max_objects) снимает лимит, грузя ВСЕ объекты последнего
snapshot (admin "Загрузить все"). По умолчанию False пропускает
то, что уже скраплено сегодня, и режет batch до 300.
Returns:
dict с ключами: region_code, snapshot_date, obj_ids_count,
@ -95,7 +101,15 @@ def scrape_kn_catalog_objects(
"""
from app.services.scrapers.domrf_catalog_object import scrape_catalog_objects
limit = max_objects if max_objects is not None else _DEFAULT_MAX_OBJECTS
# Явный max_objects всегда уважается. Без него:
# force=True ("Загрузить все") → лимита нет (limit=None → SQL LIMIT NULL = все строки);
# force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_OBJECTS.
if max_objects is not None:
limit: int | None = max_objects
elif force:
limit = None
else:
limit = _DEFAULT_MAX_OBJECTS
db = SessionLocal()
try:
@ -136,11 +150,11 @@ def scrape_kn_catalog_objects(
snapshot_date_val: date = rows[0]["snapshot_date"]
logger.info(
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%d force=%s",
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%s force=%s",
region_code,
snapshot_date_val,
len(obj_ids),
limit,
"ALL" if limit is None else limit,
force,
)

View 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())

View file

@ -23,6 +23,7 @@ from app.services.scrapers.rosstat_emiss import (
OKATO_SVERDLOVSK_OBLAST,
EmissIndicatorSpec,
RegionTarget,
_emiss_period_granularity,
_emiss_period_to_month,
parse_emiss_sdmx,
)
@ -59,17 +60,78 @@ def test_period_year_and_unknown() -> None:
assert _emiss_period_to_month("") is None
def test_period_granularity() -> None:
"""_emiss_period_granularity корректно классифицирует все под-периоды (#1606)."""
assert _emiss_period_granularity("год") == "year"
assert _emiss_period_granularity("значение показателя за год") == "year"
assert _emiss_period_granularity("за год") == "year"
assert _emiss_period_granularity("I квартал") == "quarter"
assert _emiss_period_granularity("IV квартал") == "quarter"
assert _emiss_period_granularity("январь") == "month"
assert _emiss_period_granularity("декабрь") == "month"
assert _emiss_period_granularity("3 неделя") == "unknown"
assert _emiss_period_granularity("") == "unknown"
def test_yearly_and_q1_both_survive_dedup() -> None:
"""Годовой ряд ('год') и Q1 ('I квартал') за один год — оба остаются (#1606).
До фикса: оба давали obs_date=YYYY-01-01, ключ (region, obs_date) был один
второй по XML-порядку перезаписывал первого. После фикса: ключ трёхкомпонентный
(region, obs_date, granularity) 'year' и 'quarter' разные слоты.
"""
xml = (
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
"<DataSet>"
# годовой ряд за 2023 — значение 48000
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="год"/>'
"</generic:Attributes>"
"<generic:Obs><generic:Time>2023</generic:Time>"
'<generic:ObsValue value="48000"/></generic:Obs></generic:Series>'
# Q1 того же 2023 — значение 42000; obs_date тоже 2023-01-01
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>"
"<generic:Obs><generic:Time>2023</generic:Time>"
'<generic:ObsValue value="42000"/></generic:Obs></generic:Series>'
"</DataSet></GenericData>"
)
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
# Оба наблюдения должны присутствовать — разные гранулярности
assert len(rows) == 2, f"ожидались 2 строки, получили {len(rows)}: {rows}"
values = {r.value for r in rows}
assert Decimal("48000") in values, "годовое наблюдение потеряно"
assert Decimal("42000") in values, "Q1-наблюдение потеряно"
# obs_date у обоих одинаковый (это нормально — коллизия теперь на стороне DB-upsert)
assert all(r.obs_date == date(2023, 1, 1) for r in rows)
# period_type: годовой → 'year', Q1 → 'quarter' (часть PK в macro_indicator, migration 163)
period_types = {r.period_type for r in rows}
assert period_types == {
"year",
"quarter",
}, f"ожидались period_type year+quarter, получили {period_types}"
# ── real-fixture extraction (income id=57039) ─────────────────────────────────────
def test_income_extracts_sverdlovsk_only() -> None:
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет."""
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет.
Все строки квартальные period_type='quarter' (часть PK macro_indicator, migration 163).
"""
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
assert rows, "ожидались строки по Свердл"
assert {r.region for r in rows} == {"sverdl"}
assert all(r.indicator_type == "income_per_capita" for r in rows)
assert all(r.unit == "руб" for r in rows)
assert all(r.frequency == "quarterly" for r in rows)
assert all(
r.period_type == "quarter" for r in rows
), "квартальные строки должны иметь period_type='quarter'"
def test_income_concrete_values_and_dates() -> None:
@ -154,13 +216,13 @@ def test_unknown_period_series_skipped() -> None:
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="3 неделя"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
"<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="999"/></generic:Obs></generic:Series>'
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
"<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="50000"/></generic:Obs></generic:Series>'
"</DataSet></GenericData>"
)
@ -179,7 +241,7 @@ def test_comma_decimal_value_parsed() -> None:
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="январь"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
"<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="113,2"/></generic:Obs></generic:Series></DataSet>'
"</GenericData>"
)
@ -199,13 +261,13 @@ def test_dedup_last_wins() -> None:
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
"<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="100"/></generic:Obs></generic:Series>'
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
"<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="200"/></generic:Obs></generic:Series>'
"</DataSet></GenericData>"
)

View file

@ -32,6 +32,7 @@ from app.services.exporters.excel import (
_SHEET_SUMMARY,
_fmt,
_sanitize_formula,
_scenario_deficit_cell,
_scenario_deficit_index,
export_report_xlsx,
)
@ -313,6 +314,53 @@ class TestContractKeysWritten:
assert di is not None
assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках"
def test_scenario_deficit_cell_annotates_fallback_horizon(self) -> None:
"""#1590: при fallback на горизонт != 12 ячейка несёт «(гор. N мес)»."""
# Сценарий без 12-мес прогноза — только 6-мес.
payload_6mo = ScenarioForecast(
scenario="base", # type: ignore[arg-type]
rate_path={6: 18.0},
forecasts=[
DemandSupplyForecast(
segment={"obj_class": "комфорт"},
horizon_months=6,
base_pace_units_per_mo=8.0,
demand_norm_coefficient=1.0,
macro_coefficient=1.0,
projected_demand_units=410.0,
open_units=300,
hidden_release_units=80.0,
future_online_units=20.0,
projected_supply_units=380.0,
balance_units=30.0,
balance_ratio=1.08,
deficit_index=0.22,
months_of_inventory=5.5,
rate_future=18.0,
rate_sensitivity_phrase=None,
future_competitors=[],
advisory=True,
confidence="low",
)
],
advisory=True,
).as_dict()
cell_value = _scenario_deficit_cell(payload_6mo)
# Должна быть строка с аннотацией горизонта, не голое число.
assert isinstance(cell_value, str), "ожидается строка с аннотацией горизонта"
assert "гор. 6 мес" in cell_value, f"ожидалось «гор. 6 мес» в '{cell_value}'"
assert "0.22" in cell_value or "0.22" in str(cell_value)
def test_scenario_deficit_cell_no_annotation_for_primary_horizon(self) -> None:
"""#1590: при 12-мес горизонте ячейка возвращает скаляр без аннотации."""
payload_12mo = _scenario("base", deficit_12mo=0.34)
cell_value = _scenario_deficit_cell(payload_12mo)
# Для основного горизонта — голое число, не строка с «(гор. N мес)».
assert not isinstance(
cell_value, str
), f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'"
assert cell_value == 0.34
def test_overall_score_in_cells(self) -> None:
data = _full_report().as_dict()
overall = data["scoring"]["overall"]
@ -466,10 +514,6 @@ class TestFormulaInjection:
payload = export_report_xlsx(report)
wb = _reload(payload)
formula_cells = [
c
for ws in wb.worksheets
for col in ws.iter_cols()
for c in col
if c.data_type == "f"
c for ws in wb.worksheets for col in ws.iter_cols() for c in col if c.data_type == "f"
]
assert not formula_cells, f"ни одна ячейка не должна быть формулой: {formula_cells}"

View file

@ -269,11 +269,14 @@ class TestPaymentAtScenario:
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
assert res.payment_at_scenario is not None
principal = 120_000.0 * _REF_AREA_M2
# rate_path несёт КЛЮЧЕВУЮ ставку сценария; affordability приводит к рыночной
# базе (+ _KEY_RATE_MARKET_SPREAD_PP), как и monthly_payment_rub (#1639). Ожидания
# выражаем символически — тест переживёт перекалибровку спреда.
assert res.payment_at_scenario[6] == pytest.approx(
_annuity(principal, 8.0, _ANNUITY_TERM_MONTHS)
_annuity(principal, 8.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
)
assert res.payment_at_scenario[12] == pytest.approx(
_annuity(principal, 20.0, _ANNUITY_TERM_MONTHS)
_annuity(principal, 20.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
)
# Выше ставка → выше платёж на этом горизонте.
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]

View file

@ -342,6 +342,26 @@ class TestComputeReportConfidence:
# Полностью JSON-сериализуем (контракт для экспортёров/чата).
assert json.loads(json.dumps(d, ensure_ascii=False)) == d
def test_deal_count_note_carries_window_months(self) -> None:
# #1637: deal_count_months → нота «за N мес» в факторе (и в rationale).
res = compute_report_confidence(
deal_count=7,
deal_count_months=6,
advisory=False,
)
dc_factor = next(f for f in res.factors if f.name == "deal_count")
assert "за 6 мес" in dc_factor.note
assert "7 сделок" in dc_factor.note
# Структурная причина тоже содержит период (через ноту фактора-виновника).
assert "за 6 мес" in res.rationale
def test_deal_count_note_without_window_has_no_period(self) -> None:
# deal_count_months=None (по умолчанию) → нота без «за N мес» (backward compat).
res = compute_report_confidence(deal_count=7, advisory=False)
dc_factor = next(f for f in res.factors if f.name == "deal_count")
assert "7 сделок" in dc_factor.note
assert "за" not in dc_factor.note
def test_ignores_garbage_component_confidence(self) -> None:
# Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
res = compute_report_confidence(

View file

@ -138,6 +138,50 @@ class TestSeasonalFactors:
assert adj.applied is False
assert all(f == 1.0 for f in adj.factors.values())
# ── fix #1638: zero-filled series must fail the min-years guard ───────────
def test_zero_filled_36m_fails_min_years_guard(self) -> None:
"""Ряд 36 мес, все units=0 (zero-fill от fill_month_grid) → applied=False.
До fix #1638 _count_full_years считал нулевые значения за наблюдения
и возвращал n_full_years=3 guard пропускал overall_mean==0 спасал
от деления, но прохождение guard'а было семантически неверным.
После fix: 0 не считается наблюдением n_full_years=0 applied=False.
"""
months = _months(36)
adj = seasonal_factors(months, [0] * 36)
assert (
adj.n_full_years == 0
), f"expected 0 full years on all-zero series, got {adj.n_full_years}"
assert adj.applied is False
assert all(f == 1.0 for f in adj.factors.values())
def test_mixed_zero_and_real_partial_years_fails_guard(self) -> None:
"""2 года месяцев, но только часть месяцев ненулевые — не 2 полных года.
Сценарий: новый ЖК, продажи только в нескольких месяцах каждого года
(остальные zero-fill). _count_full_years должен не считать это «полным годом».
"""
months = _months(24) # 2 календарных года
# Только январь-июнь каждого года ненулевые → нет полного покрытия 12 мес.
units = [10 if d.month <= 6 else 0 for d in months]
adj = seasonal_factors(months, units)
assert (
adj.n_full_years == 0
), f"partial-coverage years should not count as full, got {adj.n_full_years}"
assert adj.applied is False
def test_real_nonzero_series_passes_guard_and_applies(self) -> None:
"""Ряд с реальными ненулевыми данными за 2+ лет проходит guard и применяется.
Страховка: fix #1638 не должен ломать штатный путь с настоящими данными.
"""
months = _months(36)
units = _seasonal_units(months) # baseline×паттерн, все > 0
adj = seasonal_factors(months, units)
assert adj.n_full_years == 3
assert adj.applied is True
def test_month_with_no_observations_factor_one(self) -> None:
# Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
# (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
@ -239,3 +283,17 @@ class TestNormalizeDemand:
units = _seasonal_units(months)
result = normalize_demand(_make_series(months, units))
assert all(isinstance(u, int) and u >= 0 for u in result.units)
def test_zero_filled_series_returned_unchanged(self) -> None:
"""fix #1638: zero-filled SalesSeries (все units=0) возвращается без изменений.
До fix: guard считал 3 «полных года» на нулях adjustment.applied=True
(пройдя через overall_mean==0 check) или мог пройти guard и вернуть
идентичный ряд через нейтральные факторы. После fix: n_full_years=0
applied=False функция возвращает тот же объект (short-circuit).
"""
months = _months(36)
series = _make_series(months, [0] * 36)
result = normalize_demand(series)
assert result is series, "zero-filled series must be returned as-is (no adjustment)"
assert result.units == [0] * 36

View file

@ -409,7 +409,7 @@ class TestScoreDifferentiation:
class TestScoreCommercial:
def test_available_share_scaled(self) -> None:
signal = {"available": True, "commercial_share_pct": 40.0, "confidence": "medium"}
signal = {"available": True, "commercial_sell_through_pct": 40.0, "confidence": "medium"}
value, conf, _r = _score_commercial(signal)
assert value == pytest.approx(0.4)
assert conf == "medium"
@ -417,7 +417,7 @@ class TestScoreCommercial:
def test_in_range(self) -> None:
for share in (0.0, 25.0, 100.0):
value, _c, _r = _score_commercial(
{"available": True, "commercial_share_pct": share, "confidence": "low"}
{"available": True, "commercial_sell_through_pct": share, "confidence": "low"}
)
assert value is not None
assert 0.0 <= value <= 1.0
@ -439,7 +439,7 @@ class TestScoreCommercial:
def test_bad_confidence_falls_back_low(self) -> None:
value, conf, _r = _score_commercial(
{"available": True, "commercial_share_pct": 10.0, "confidence": "garbage"}
{"available": True, "commercial_sell_through_pct": 10.0, "confidence": "garbage"}
)
assert value is not None
assert conf == "low"
@ -728,7 +728,7 @@ def _patch_all(
overlay: Any = None,
) -> Any:
"""Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом)."""
commercial = {"available": True, "commercial_share_pct": 30.0, "confidence": "medium"}
commercial = {"available": True, "commercial_sell_through_pct": 30.0, "confidence": "medium"}
forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)]
competitors_rv = (
competitors if competitors is not None else _competitors_response_stub([0.4, 0.2])

View file

@ -329,9 +329,13 @@ class TestDemandSupplyGraceful:
# ── build_forecast_overlay: demand_only (cad_num=None) ─────────────────────────
def _mk_metrics(unit_velocity: float | None) -> MagicMock:
def _mk_metrics(
unit_velocity: float | None,
velocity_by_room: dict[str, float] | None = None,
) -> MagicMock:
m = MagicMock()
m.unit_velocity = unit_velocity
m.velocity_by_room = velocity_by_room
return m
@ -453,6 +457,71 @@ class TestDemandOnlyOverlay:
assert out["ranked_segments"] == []
assert out["warnings"]
def test_per_bucket_velocity_drives_ranking(self) -> None:
"""#1593: velocity_by_room даёт реальные per-bucket §9.2-темпы.
При одинаковом §9.5 macro_coef=1.0 ранкинг должен определяться
исключительно per-bucket velocity_by_room, а не aggregate base_pace.
Студии ("студия") самый высокий темп первые в ранкинге.
"""
# velocity_by_room: студии продаются в 5× быстрее чем 1-к, остальные ≈ 0.
vel_by_room = {
"студия": 10.0, # "Студии 15-30" → "студия"
"1": 2.0, # "1-к 30-45" → "1"
"2": 1.0, # "2-к 45-60" → "2"
"3": 0.5, # "3-к 60-80" → "3"
"4": 0.1, # "80+ м²" → "4" + "5+"
"5+": 0.1,
}
with (
patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=vel_by_room)),
patch(_GET_MACRO, return_value=[]),
patch(_HOLD, return_value={12: 18.0}),
patch(_NORM, return_value=_mk_coef(1.0)),
patch(_MACRO, return_value=_mk_coef(1.0)), # нейтральный §9.5 (изолируем §9.2)
):
out = build_forecast_overlay(
MagicMock(),
district="Ленинский",
cad_num=None,
horizon_months=12,
target_class=None,
)
buckets = [s["bucket"] for s in out["ranked_segments"]]
# При одинаковом macro_coef=1.0 ранкинг = velocity_by_room:
# студии (10.0) > 1-к (2.0) > 2-к (1.0) > 3-к (0.5) > 80+ (0.1+0.1=0.2)
assert buckets[0] == "1-Студия", f"ожидали студии первыми, получили {buckets}"
assert buckets[1] == "2-1-к", f"ожидали 1-к вторыми, получили {buckets}"
assert buckets[-1] == "5-80+ м²", f"ожидали 80+ м² последними, получили {buckets}"
# deficit_index нормирован к 1.0 для топа
assert out["ranked_segments"][0]["deficit_index"] == pytest.approx(1.0)
def test_fallback_to_aggregate_when_no_velocity_by_room(self) -> None:
"""#1593: при velocity_by_room=None возвращаемся к aggregate base_pace.
Ranking в этом случае определяется §9.5 macro_coef (старое поведение),
но не крашит и не фабрикует данные.
"""
macro_coeffs = iter([_mk_coef(c) for c in (0.5, 1.0, 1.5, 1.2, 0.8)])
with (
patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=None)),
patch(_GET_MACRO, return_value=[]),
patch(_HOLD, return_value={12: 18.0}),
patch(_NORM, return_value=_mk_coef(1.0)),
patch(_MACRO, side_effect=lambda *a, **k: next(macro_coeffs)),
):
out = build_forecast_overlay(
MagicMock(),
district="Ленинский",
cad_num=None,
horizon_months=12,
target_class=None,
)
# Graceful: 5 сегментов, deficit_index ∈ (0, 1].
assert len(out["ranked_segments"]) == 5
assert max(s["deficit_index"] for s in out["ranked_segments"]) == pytest.approx(1.0)
def test_five_default_room_buckets_ranked(self) -> None:
with (
patch(_METRICS, return_value=_mk_metrics(4.0)),
@ -744,7 +813,7 @@ class TestCommercialSignal:
out = _commercial_signal(MagicMock(), "Ленинский", 12)
assert out is not None
assert out["available"] is True
assert out["commercial_share_pct"] == 42.5
assert out["commercial_sell_through_pct"] == 42.5
assert out["n_lots"] == 120
assert out["confidence"] == "medium"
assert out["reason"]["advisory"] is True

View file

@ -625,14 +625,22 @@ def _launch_window_index(horizon: int | None) -> SpecialIndex:
"""Готовый Launch Window SpecialIndex с заданным best_horizon_months (None → unavail)."""
if horizon is None:
return SpecialIndex(
key=KEY_LAUNCH_WINDOW, value=None, label=None, confidence="low",
key=KEY_LAUNCH_WINDOW,
value=None,
label=None,
confidence="low",
detail={"reason": "deficit None на всех горизонтах"},
method=_METHOD_UNAVAILABLE, advisory=True,
method=_METHOD_UNAVAILABLE,
advisory=True,
)
return SpecialIndex(
key=KEY_LAUNCH_WINDOW, value=0.8, label=f"{horizon} мес", confidence="medium",
key=KEY_LAUNCH_WINDOW,
value=0.8,
label=f"{horizon} мес",
confidence="medium",
detail={"best_horizon_months": horizon, "deficit_by_horizon": {}},
method="deficit_peak_scan", advisory=True,
method="deficit_peak_scan",
advisory=True,
)
@ -679,11 +687,39 @@ class TestGeoWeight:
def test_unknown_distance_floor_weight(self) -> None:
# нет координат → низкий floor (НЕ 0, НЕ 1): проект сигналит, но не доминирует.
assert _geo_weight(None) == pytest.approx(0.1)
# #1633: понижено с 0.1 до 0.05 — старое значение 0.1 совпадало с весом
# подтверждённо дальнего проекта (~6.9 км), создавая инверсию.
assert _geo_weight(None) == pytest.approx(0.05)
def test_negative_distance_clamped(self) -> None:
assert _geo_weight(-5.0) == pytest.approx(1.0)
def test_near_project_weight_exceeds_far_project_weight(self) -> None:
# #1633: ближний проект ВСЕГДА перебивает дальний (монотонность).
near = _geo_weight(1.0)
far = _geo_weight(10.0)
assert near > far
def test_confirmed_far_project_weight_below_unknown(self) -> None:
# #1633: подтверждённо дальний (>6.9 км) должен иметь вес НИЖЕ неизвестного.
# До фикса: exp(-6.9/3)≈0.10 == _GEO_WEIGHT_UNKNOWN (0.10) — инверсия/паритет.
# После фикса: _GEO_WEIGHT_UNKNOWN=0.05, exp(-6.9/3)≈0.10 > 0.05 (правильно).
# А при ещё бо́льшем расстоянии (10 км, ≈0.036) — тем более выше 0.05.
# Здесь проверяем что неизвестный вес НИЖЕ вполне подтверждённо БЛИЗКОГО (1 км),
# и что подтверждённо ДАЛЬНИЙ (10 км) не превышает неизвестный (чтобы сигналил,
# но скромно — оба низкие; инверсия устранена).
import math
unknown = _geo_weight(None)
confirmed_near = _geo_weight(1.0) # ≈ 0.72
confirmed_far = _geo_weight(10.0) # ≈ 0.036
# confirmed_near >> unknown >> confirmed_far (правильная иерархия).
assert confirmed_near > unknown
assert unknown > confirmed_far
# Числовая граница: exp(-6.9/3) ≈ 0.10 — вес "just-confirmed-far";
# unknown (0.05) строго ниже этого порога.
assert unknown < math.exp(-6.9 / 3.0)
# ──────────────────────────────────────────────────────────────────────────────
# §25.3 own-portfolio overlap — пара (среднее доступных осей) + агрегация (soft-max)
@ -745,16 +781,25 @@ class TestAggregateOverlap:
def test_takes_max_signal_not_mean(self) -> None:
# один сильный (0.9) + два слабых (0.1) → агрегат = 0.9 (soft-max), не среднее.
strong = _own_portfolio_overlap(
class_overlap=0.9, price_overlap=0.9,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
class_overlap=0.9,
price_overlap=0.9,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
)
weak1 = _own_portfolio_overlap(
class_overlap=0.1, price_overlap=0.1,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
class_overlap=0.1,
price_overlap=0.1,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
)
weak2 = _own_portfolio_overlap(
class_overlap=0.1, price_overlap=0.1,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
class_overlap=0.1,
price_overlap=0.1,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
)
assert _aggregate_overlap([strong, weak1, weak2]) == pytest.approx(0.9)
@ -762,19 +807,28 @@ class TestAggregateOverlap:
# сильное пересечение, но далеко (geo 0.1) → signal 0.05; близкое слабое
# (overlap 0.3, geo 1.0 → 0.3) перебивает. Агрегат = 0.3.
distant_strong = _own_portfolio_overlap(
class_overlap=1.0, price_overlap=1.0,
unit_mix_overlap=None, timing_overlap=None, geo_weight=0.1,
class_overlap=1.0,
price_overlap=1.0,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=0.1,
)
near_weak = _own_portfolio_overlap(
class_overlap=0.3, price_overlap=0.3,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
class_overlap=0.3,
price_overlap=0.3,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
)
assert _aggregate_overlap([distant_strong, near_weak]) == pytest.approx(0.3)
def test_all_none_signals_is_none(self) -> None:
empty = _own_portfolio_overlap(
class_overlap=None, price_overlap=None,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
class_overlap=None,
price_overlap=None,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
)
assert _aggregate_overlap([empty, empty]) is None
@ -955,9 +1009,7 @@ class TestArtificialDemandSQL:
def test_sql_reads_mortgage_signal_columns(self, _identity_resolver: Any) -> None:
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
with _identity_resolver:
_query_artificial_demand(
db, district=None, obj_class=None, premise_kind="квартира"
)
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
sql = _executed_sql(db)
# сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
assert "encumbrance_type" in sql
@ -1007,9 +1059,7 @@ class TestArtificialDemandSQL:
# #1205: district=None → has_district=False, districts=[]. EKB-wide.
db = _mock_db_one({"n_sold": 200, "n_mortgage": 80})
with _identity_resolver:
_query_artificial_demand(
db, district=None, obj_class=None, premise_kind="квартира"
)
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
p = _executed_params(db)
assert p["has_district"] is False
assert p["districts"] == []
@ -1527,7 +1577,9 @@ class TestComputeSpecialIndicesGraceful:
# Центроид участка для гео-веса (lon, lat) в окрестностях ЕКБ.
_CENTROID = (60.6000, 56.8000)
_CAND_SPEC = SegmentSpec(
obj_class="комфорт", room_bucket="2-к 45-60", district="Академический",
obj_class="комфорт",
room_bucket="2-к 45-60",
district="Академический",
price_bucket=PRICE_BUCKET_COMFORT,
)
@ -1625,7 +1677,9 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
# Launch Window резолвится (пик h=24) → candidate_release_month выведен →
# тайминговая ось теперь СЧИТАЕТСЯ (на тот же месяц, что наш проект → 1.0).
own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
"Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
release_month=_DERIVED_CANDIDATE_MONTH,
)
card = _timing_card([own])
@ -1639,12 +1693,24 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
# near: release_month = выведенный месяц кандидата (timing 1.0); far: на 4 года
# позже (timing → почти 0). Прочие оси идентичны → near должен дать выше value.
near = _timing_card(
[_own("Близкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1],
release_month=_DERIVED_CANDIDATE_MONTH)]
[
_own(
"Близкий-во-времени",
lon=_CENTROID[0],
lat=_CENTROID[1],
release_month=_DERIVED_CANDIDATE_MONTH,
)
]
)
far = _timing_card(
[_own("Далёкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1],
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48))]
[
_own(
"Далёкий-во-времени",
lon=_CENTROID[0],
lat=_CENTROID[1],
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48),
)
]
)
near_v = near.indices[KEY_CANNIBALIZATION].value
far_v = far.indices[KEY_CANNIBALIZATION].value
@ -1657,7 +1723,9 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
# классу/цене/гео (не падает, не фабрикует тайминг).
forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
"Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
release_month=date(2027, 1, 1), # есть дата, но кандидатной нет
)
db = MagicMock()
@ -1686,10 +1754,16 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
def test_timing_deterministic_same_inputs_identical_as_dict(self) -> None:
# Детерминизм (§16): одинаковые входы (фикс. дата отчёта) → идентичный as_dict.
portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
release_month=_DERIVED_CANDIDATE_MONTH),
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85,
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6)),
_own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], release_month=_DERIVED_CANDIDATE_MONTH
),
_own(
"Наш-Б",
obj_class="комфорт+",
lon=60.65,
lat=56.85,
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6),
),
]
first = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
second = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
@ -1703,8 +1777,14 @@ class TestCannibalizationTrueMode:
# наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0.
portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
lon=60.9, lat=57.1),
_own(
"Наш-Б",
obj_class="бизнес",
price_min=200_000.0,
price_max=240_000.0,
lon=60.9,
lat=57.1,
),
]
card = _cannibalization_card(portfolio)
can = card.indices[KEY_CANNIBALIZATION]
@ -1717,8 +1797,14 @@ class TestCannibalizationTrueMode:
def test_top_contributor_surfaced(self) -> None:
portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
lon=60.9, lat=57.1),
_own(
"Наш-Б",
obj_class="бизнес",
price_min=200_000.0,
price_max=240_000.0,
lon=60.9,
lat=57.1,
),
]
card = _cannibalization_card(portfolio)
can = card.indices[KEY_CANNIBALIZATION]
@ -1742,20 +1828,24 @@ class TestCannibalizationTrueMode:
assert near_v > far_v
def test_no_geometry_still_computes_via_other_axes(self) -> None:
# нет центроида → гео-вес на floor (0.1), но класс+цена дают overlap → индекс есть.
# нет центроида → гео-вес на floor (_GEO_WEIGHT_UNKNOWN=0.05), класс+цена дают
# overlap → индекс есть (None-not-0). #1633: floor снижен 0.1→0.05.
card = _cannibalization_card([_own("Без-гео")], centroid=None)
can = card.indices[KEY_CANNIBALIZATION]
assert can.method == "own_portfolio_overlap"
assert can.detail["has_geometry"] is False
# overlap 1.0 × geo floor 0.1 = 0.1.
assert can.value == pytest.approx(0.1)
# overlap 1.0 × geo floor 0.05 = 0.05.
assert can.value == pytest.approx(0.05)
def test_unit_mix_and_timing_axes_when_supplied(self) -> None:
# caller передаёт квартирографию/тайминг кандидата → оси становятся доступны.
db = MagicMock()
own_proj = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5}, release_month=date(2026, 6, 1),
"Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5},
release_month=date(2026, 6, 1),
)
with (
patch(f"{_MOD}.get_own_portfolio", return_value=[own_proj]),
@ -1764,7 +1854,9 @@ class TestCannibalizationTrueMode:
from app.services.forecasting.special_indices import _build_cannibalization
idx = _build_cannibalization(
db, spec=_CAND_SPEC, cad_num="66:41:0303161:123",
db,
spec=_CAND_SPEC,
cad_num="66:41:0303161:123",
candidate_unit_mix={"studio": 0.5, "1k": 0.5},
candidate_release_month=date(2026, 6, 1),
)
@ -1777,8 +1869,16 @@ class TestCannibalizationTrueMode:
# наш проект без класса И без цены → ни класс, ни цена не считаются;
# тайминг/квартирография тоже None → пара без осей → индекс unavailable (НЕ 0).
card = _cannibalization_card(
[_own("Пустой", obj_class=None, price_min=None, price_max=None,
lon=_CENTROID[0], lat=_CENTROID[1])]
[
_own(
"Пустой",
obj_class=None,
price_min=None,
price_max=None,
lon=_CENTROID[0],
lat=_CENTROID[1],
)
]
)
can = card.indices[KEY_CANNIBALIZATION]
assert can.value is None
@ -1809,9 +1909,9 @@ class TestCannibalizationThinPortfolioConfidence:
assert "future" in can.detail["confidence_note"]
def test_single_project_is_low_data_scarce(self) -> None:
can = _cannibalization_card(
[_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]
).indices[KEY_CANNIBALIZATION]
can = _cannibalization_card([_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]).indices[
KEY_CANNIBALIZATION
]
assert can.confidence == "low"
assert "данных мало" in can.detail["confidence_note"]
@ -1857,8 +1957,14 @@ class TestCannibalizationDeterminism:
def test_same_inputs_identical_output(self) -> None:
portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
lon=60.9, lat=57.1),
_own(
"Наш-Б",
obj_class="бизнес",
price_min=200_000.0,
price_max=240_000.0,
lon=60.9,
lat=57.1,
),
_own("Наш-В", obj_class="комфорт+", lon=60.65, lat=56.85),
]
first = _cannibalization_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
@ -1932,7 +2038,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
# recommend_mix отдаёт микс кандидата (RU-подписи), наш проект — manual латиница с
# ТЕМ ЖЕ распределением по комнатности → ось квартирографии = 1.0 (через канон).
own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
"Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
)
card = _unitmix_card(
@ -1951,21 +2059,30 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
# Все прочие оси (класс/цена/гео/тайминг) идентичны → разница только в квартирографии.
rec = _recommend_mix_result({"Студии 15-30": 50.0, "1-к 30-45": 50.0})
similar = _unitmix_card(
[_own("Похожий", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5})],
[
_own(
"Похожий",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5},
)
],
recommend_return=rec,
)
dissimilar = _unitmix_card(
[_own("Непохожий", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix={"2k": 0.5, "3k": 0.5})],
[
_own(
"Непохожий", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"2k": 0.5, "3k": 0.5}
)
],
recommend_return=rec,
)
sim_axis = similar.indices[KEY_CANNIBALIZATION].detail[
"top_contributors"
][0]["axes"]["unit_mix"]
dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail[
"top_contributors"
][0]["axes"]["unit_mix"]
sim_axis = similar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
"unit_mix"
]
dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
"unit_mix"
]
assert sim_axis > dis_axis
# и итоговое значение каннибализации выше при похожем миксе.
assert (
@ -1977,7 +2094,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
# recommend_mix вернул None → микс None → ось квартирографии исключена (None-not-0),
# каннибализация всё равно считается по классу/цене/тайм/гео.
own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
"Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5},
)
card = _unitmix_card([own], recommend_return=None)
@ -1990,7 +2109,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_axis_excluded_when_recommend_empty_buckets(self) -> None:
# recommend_mix отдал пустые buckets (тонкие данные) → микс None → ось исключена.
own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
"Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5},
)
card = _unitmix_card([own], recommend_return={"buckets": []})
@ -2001,7 +2122,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_axis_excluded_when_recommend_raises_no_crash(self) -> None:
# recommend_mix БРОСИЛ → graceful None → ось исключена, карточка цела (НЕ crash).
own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
"Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5},
)
card = _unitmix_card([own], recommend_side_effect=RuntimeError("heavy query boom"))
@ -2015,7 +2138,10 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
# ЧЕСТНОСТЬ (own_portfolio PR1): current-проект несёт unit_mix=None → даже при
# валидном recommend-миксе кандидата ось не считается (нечего сравнивать).
own = _own(
"Текущий", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
"Текущий",
source="current",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix=None,
)
card = _unitmix_card(
@ -2029,7 +2155,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_all_four_axes_active_together(self) -> None:
# class + price + timing + unit_mix ВСЕ доступны на одном проекте → n_axes == 4.
own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
"Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5},
release_month=_DERIVED_CANDIDATE_MONTH, # совпадает с выведенным тайм. кандидата
)
@ -2045,28 +2173,34 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_unit_mix_axis_deterministic_identical_as_dict(self) -> None:
# Детерминизм (§16): одинаковые входы → идентичный as_dict (с активной осью).
portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3}),
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85,
unit_mix={"1k": 0.6, "2k": 0.4}),
_own(
"Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
),
_own(
"Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85, unit_mix={"1k": 0.6, "2k": 0.4}
),
]
rec = _recommend_mix_result(
{"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0}
rec = _recommend_mix_result({"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0})
first = (
_unitmix_card(list(portfolio), recommend_return=rec)
.indices[KEY_CANNIBALIZATION]
.as_dict()
)
second = (
_unitmix_card(list(portfolio), recommend_return=rec)
.indices[KEY_CANNIBALIZATION]
.as_dict()
)
first = _unitmix_card(list(portfolio), recommend_return=rec).indices[
KEY_CANNIBALIZATION
].as_dict()
second = _unitmix_card(list(portfolio), recommend_return=rec).indices[
KEY_CANNIBALIZATION
].as_dict()
assert first == second
# подтверждаем, что ось реально участвовала (не пустой детерминизм).
assert first["detail"]["axes_available"]["unit_mix"] == 2
def test_recommend_mix_called_with_expected_args(self) -> None:
# cost/корректность: recommend_mix зовётся с district+target_class+cad_num кандидата.
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix={"studio": 1.0})
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
db = MagicMock()
with (
@ -2091,8 +2225,7 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_no_district_skips_recommend_mix(self) -> None:
# нет района → recommend_mix НЕ зовётся (район обязателен) → ось исключена, без вызова.
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix={"studio": 1.0})
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
db = MagicMock()
with (
@ -2155,10 +2288,22 @@ class TestCannibalizationUnitMixGate:
# Портфель только из current/domrf (unit_mix=None) → ось квартирографии всё равно
# исключилась бы → тяжёлый recommend_mix НЕ должен вызываться (call_count == 0).
portfolio = [
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH),
_own("Текущий-2", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH),
_own(
"Текущий-1",
source="current",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix=None,
release_month=_DERIVED_CANDIDATE_MONTH,
),
_own(
"Текущий-2",
source="current",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix=None,
release_month=_DERIVED_CANDIDATE_MONTH,
),
]
card, rec_mock, _ = _gated_card(portfolio)
# Гейт сработал: тяжёлый запрос НЕ выполнен.
@ -2179,8 +2324,13 @@ class TestCannibalizationUnitMixGate:
# Есть future-проект с unit_mix → ось может внести вклад → recommend_mix зовётся
# РОВНО один раз (не O(n), не дважды), и get_own_portfolio фетчится РОВНО один раз.
portfolio = [
_own("Будущий-А", source="future", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix={"studio": 1.0}),
_own(
"Будущий-А",
source="future",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 1.0},
),
]
card, rec_mock, portfolio_mock = _gated_card(portfolio)
# Тяжёлый запрос — ровно один доп. вызов на отчёт.
@ -2197,8 +2347,7 @@ class TestCannibalizationUnitMixGate:
# Даже когда гейт пропускает recommend_mix, get_own_portfolio всё равно фетчится
# РОВНО один раз (портфель нужен и для гейта, и для самой каннибализации).
portfolio = [
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix=None),
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix=None),
]
_, rec_mock, portfolio_mock = _gated_card(portfolio)
assert rec_mock.call_count == 0
@ -2207,8 +2356,9 @@ class TestCannibalizationUnitMixGate:
def test_empty_mix_dict_does_not_trigger_recommend_mix(self) -> None:
# Пустой dict unit_mix={} (грязь) — НЕ «есть микс» → recommend_mix не зовётся.
portfolio = [
_own("Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1],
unit_mix={}),
_own(
"Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={}
),
]
_, rec_mock, _ = _gated_card(portfolio)
assert rec_mock.call_count == 0

View file

@ -210,3 +210,125 @@ def test_safe_payload_defaults_are_empty_and_non_confidential() -> None:
assert p.text == ""
assert p.fields == {}
assert p.is_confidential is False
# ── #1640: INN context-guard — large bare numbers must NOT be redacted ────────
def test_large_sum_10_digits_not_redacted_without_inn_keyword() -> None:
"""Слитная 10-значная сумма (1 200 000 000 руб) без якоря «ИНН» — НЕ редактируется.
Регрессия #1640: старый _INN_RE ловил любые 10/12 цифр → «1200000000» уходило в
[REDACTED:inn]. Новый паттерн требует маркер ИНН/inn перед числом.
"""
text = "стоимость участка составила 1200000000 рублей по договору"
out = scrub_text(text)
assert out == text, f"bare large sum was incorrectly redacted: {out!r}"
def test_large_sum_12_digits_not_redacted_without_inn_keyword() -> None:
"""12-значная слитная сумма без якоря — НЕ редактируется."""
text = "капитализация компании 123456789012 рублей"
out = scrub_text(text)
assert out == text
def test_round_billion_not_redacted() -> None:
"""Круглые суммы без контекста — НЕ редактируются."""
for amount in ("5000000000", "1000000000", "9876543210"):
out = scrub_text(f"сумма {amount} руб")
assert "[REDACTED:inn]" not in out, f"amount {amount!r} was falsely redacted"
def test_inn_with_keyword_is_redacted() -> None:
"""Валидный ИНН с явным маркером «ИНН» — редактируется."""
out = scrub_text("ИНН 7707083893 зарегистрирован в ЕГРЮЛ")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_inn_12_with_keyword_is_redacted() -> None:
"""Валидный 12-значный ИНН физлица с маркером — редактируется."""
out = scrub_text("ИНН физлица 500100732259 подтверждён")
assert "[REDACTED:inn]" in out
assert "500100732259" not in out
def test_inn_keyword_latin_case_insensitive() -> None:
"""Маркер 'inn' (латиница, любой регистр) тоже активирует редакцию."""
out = scrub_text("inn: 7707083893")
assert "[REDACTED:inn]" in out
def test_inn_bad_checksum_with_keyword_not_redacted() -> None:
"""Число с маркером «ИНН», но не прошедшее checksum, НЕ редактируется.
Предотвращает ложные срабатывания на «ИНН 1234567890» (неверная контрольная цифра).
"""
out = scrub_text("ИНН 1234567890 (некорректный)")
assert "[REDACTED:inn]" not in out
assert "1234567890" in out
# ── #1640 follow-up: word-boundary guards ((?<!\w)/(?!\w)) ───────────────────
def test_inn_digits_alpha_prefix_not_redacted() -> None:
"""Цифры ИНН как суффикс буквенно-цифрового токена НЕ редактируются.
Регрессия: (?<!\\d) не защищал от «ИНН ref7707083893» 'f' не цифра, старый
lookaround пропускал. Новый (?<!\\w) блокирует любой word-char перед цифровым блоком.
"""
out = scrub_text("ИНН ref7707083893")
assert "[REDACTED:inn]" not in out
assert "7707083893" in out
def test_inn_digits_alpha_suffix_not_redacted() -> None:
"""Цифры ИНН как префикс буквенно-цифрового токена НЕ редактируются.
Регрессия: старый (?!\\d) не блокировал буквенный суффикс «7707083893more»
совпадало, т.к. 'm' не цифра. Новый (?!\\w) закрывает alpha-суффикс.
"""
out = scrub_text("ИНН 7707083893more")
assert "[REDACTED:inn]" not in out
assert "7707083893" in out
def test_inn_digits_embedded_in_alphanumeric_token_not_redacted() -> None:
"""ИНН-подобный блок цифр внутри алфавитно-цифрового токена НЕ редактируется.
«ИНН ref7707083893xyz» цифры с обеих сторон окружены word-char'ами.
"""
out = scrub_text("ИНН ref7707083893xyz")
assert "[REDACTED:inn]" not in out
def test_inn_alpha_prefix_on_12digit_block_not_redacted() -> None:
"""12-значный валидный ИНН с буквенным префиксом (без пробела) НЕ редактируется.
«ИНН ABC500100732259» 'C' является word-char, (?<!\\w) блокирует совпадение.
"""
out = scrub_text("ИНН ABC500100732259")
assert "[REDACTED:inn]" not in out
assert "500100732259" in out
def test_inn_standalone_with_punctuation_is_redacted() -> None:
"""Валидный ИНН, отделённый знаком препинания (не word-char), редактируется.
Двоеточие, запятая, скобка не \\w (?<!\\w) пропускает. Это корректное поведение.
"""
out = scrub_text("ИНН:7707083893 в реестре")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_inn_standalone_at_end_of_string_is_redacted() -> None:
"""Валидный ИНН в конце строки без символов после редактируется.
Конец строки не \\w (?!\\w) пропускает. Базовый smoke.
"""
out = scrub_text("ИНН 7707083893")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out

View 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

View file

@ -275,6 +275,7 @@ class TestAsDict:
demand_concentration=0.3333333,
price_sensitivity=-1.234567,
price_sensitivity_source="regression",
velocity_by_room={"1": 1.5, "2": 0.5},
)
d = m.as_dict()
assert d["absorption_rate"] == 0.0476
@ -304,6 +305,7 @@ class TestAsDict:
demand_concentration=None,
price_sensitivity=None,
price_sensitivity_source=None,
velocity_by_room=None,
)
d = m.as_dict()
assert d["absorption_rate"] is None
@ -421,6 +423,14 @@ class TestComputeMarketMetricsShape:
assert m.price_sensitivity == -1.4
assert m.price_sensitivity_source == "regression"
assert m.confidence == "high"
# #1593: velocity_by_room — per-bucket ед./мес из sold_by_room.
# _FULL_SALES: rooms_int=1 (30 ед.), rooms_int=2 (20 ед.), rooms_int=0 (10 ед.)
# window_months=6 → "1": 30/6=5.0, "2": 20/6≈3.333, "студия": 10/6≈1.667
assert m.velocity_by_room is not None
assert set(m.velocity_by_room.keys()) == {"1", "2", "студия"}
assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)
def test_elasticity_reuse_invoked_with_district(self) -> None:
db = _mock_db(_FULL_STOCK, _FULL_SALES)
@ -516,9 +526,7 @@ class TestPriceSensitivityDistrictResolution:
случайно совпадал в обоих вокабулярах).
"""
def test_admin_resolves_to_micros_in_elasticity_call(
self, _patch_resolver: MagicMock
) -> None:
def test_admin_resolves_to_micros_in_elasticity_call(self, _patch_resolver: MagicMock) -> None:
# Резолвер: 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
_patch_resolver.side_effect = lambda _db, d: (
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
@ -542,9 +550,7 @@ class TestPriceSensitivityDistrictResolution:
assert value == -1.2
assert source == "regression"
def test_resolver_none_passes_empty_list_ekb_wide(
self, _patch_resolver: MagicMock
) -> None:
def test_resolver_none_passes_empty_list_ekb_wide(self, _patch_resolver: MagicMock) -> None:
# Резолвер None ('не определён' / нет чистых алиасов) → передаём [] →
# _elasticity_coef уходит в EKB-wide ветку (без district-фильтра).
_patch_resolver.side_effect = lambda _db, _d: None
@ -555,9 +561,7 @@ class TestPriceSensitivityDistrictResolution:
_args, kwargs = elast.call_args
assert kwargs["districts"] == []
def test_none_district_skips_resolver_and_elasticity(
self, _patch_resolver: MagicMock
) -> None:
def test_none_district_skips_resolver_and_elasticity(self, _patch_resolver: MagicMock) -> None:
# district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни
# резолвер, ни _elasticity_coef.
db = MagicMock()
@ -600,6 +604,45 @@ class TestPriceSensitivityDistrictResolution:
assert source is None
class TestVelocityByRoom:
"""#1593: MarketMetrics.velocity_by_room — per-bucket ед./мес."""
def test_velocity_by_room_computed_from_sold_by_room(self) -> None:
# sold_by_room: "1" → 30, "2" → 20, "студия" → 10 ед. за 6 мес.
db = _mock_db(_FULL_STOCK, _FULL_SALES)
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
m = compute_market_metrics(db, district="Автовокзал", window_months=6)
assert m.velocity_by_room is not None
assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)
def test_velocity_by_room_none_when_no_sample(self) -> None:
# n_lots=0 → выборка пуста → velocity_by_room None (зеркало unit_velocity).
empty_stock = {
"n_lots": 0,
"n_sold": 0,
"n_available": 0,
"obj_count": 0,
"n_long_unsold": 0,
}
db = _mock_db(empty_stock, [])
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
m = compute_market_metrics(db, district="ПустойРайон")
assert m.velocity_by_room is None
def test_velocity_by_room_none_when_no_by_room_data(self) -> None:
# Только grand-total строка (нет разбивки по комнатности) → sold_by_room пустой
# → velocity_by_room None.
sales_total_only = [
{"units_sold_window": 5, "area_sold_window": 200.0, "rooms_int": None, "is_total": 1}
]
db = _mock_db(_FULL_STOCK, sales_total_only)
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
m = compute_market_metrics(db, district="Тихий")
assert m.velocity_by_room is None
class TestComputeMarketMetricsThinData:
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
@ -623,6 +666,7 @@ class TestComputeMarketMetricsThinData:
assert m.liquidity_index is None
assert m.overstock_index is None
assert m.demand_concentration is None
assert m.velocity_by_room is None
assert m.confidence == "low"
def test_stock_none_row_no_crash(self) -> None:

View 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

View file

@ -304,12 +304,15 @@ def test_denorm_dump_empty_features() -> None:
db = _make_mock_session()
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[])
assert counts == {"parcels": 0, "buildings": 0, "errors": 0}
assert counts == {"parcels": 0, "buildings": 0, "errors": 0, "skipped": 0}
db.commit.assert_called_once()
def test_denorm_dump_no_cad_num_counted_as_error() -> None:
"""Parcel без cad_num → denorm_parcel_feature returns False → errors += 1."""
def test_denorm_dump_no_cad_num_counted_as_skipped() -> None:
"""Parcel без cad_num → pre-check в denorm_dump → skipped += 1, не errors.
denorm_parcel_feature не вызывается вовсе пропуск штатный, не сбой UPSERT.
"""
db = _make_mock_session()
feature: dict[str, Any] = {
"layer": "parcels",
@ -319,4 +322,5 @@ def test_denorm_dump_no_cad_num_counted_as_error() -> None:
}
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature])
assert counts["parcels"] == 0
assert counts["errors"] == 1
assert counts["errors"] == 0
assert counts["skipped"] == 1

View file

@ -77,7 +77,12 @@ def test_find_match_candidates_returns_candidates() -> None:
def test_auto_apply_matches_dry_run_no_inserts() -> None:
"""dry_run=True возвращает счётчики без обращения к БД (execute не вызывается)."""
"""dry_run=True возвращает projected-счётчики без обращения к БД.
auto_accepted = сколько кандидатов БЫЛО БЫ принято (preview), а не 0
смысл dry-run в admin-endpoint'е именно показать оператору объём перед
реальным insert. execute/commit при этом не вызываются.
"""
mock_db = MagicMock()
candidates = [
@ -88,8 +93,9 @@ def test_auto_apply_matches_dry_run_no_inserts() -> None:
result = auto_apply_matches(mock_db, candidates, dry_run=True)
assert result["auto_accepted"] == 0
assert result["auto_accepted"] == 1 # projected: 1 кандидат >= AUTO_ACCEPT_THRESHOLD
assert result["review_queue"] == 2
assert result["skipped"] == 0
mock_db.execute.assert_not_called()
mock_db.commit.assert_not_called()

Some files were not shown because too many files have changed in this diff Show more