diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 2fcb98d8..2e0c35c9 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -1414,12 +1414,21 @@ def _district_market_saturation(db: Session, *, district_name: str) -> tuple[flo Возвращает (median_pct, n_objects). None если <5 ЖК с perc. """ + # #1221: domrf_kn_objects/domrf_kn_sales_agg хранят ~3 snapshot'а на obj_id + # (UNIQUE(obj_id,[type,]snapshot_date), weekly UPSERT, ретенции нет). Без + # latest-фильтра один ЖК входил в медиану N раз → sold_median тянется старыми + # perc (sold% растёт во времени → sat_factor занижен), гейт n>=5 проходил уже + # при 2 ЖК, sat_n завышен ~×N. Фильтруем оба JOIN-партнёра MAX(snapshot_date) + # (зеркало #1210/#1212, ср. строки 1200/1208 и latest_obj-CTE стр. ~2780 + # «иначе comparables дублируются»). COUNT(DISTINCT a.obj_id) — defensive: + # после latest-snapshot пары (obj_id, type='apartments') уникальны, но явный + # DISTINCT исключает регрессию, если кто-то ослабит UNIQUE-constraint. row = ( db.execute( text( """ SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median, - COUNT(*) AS n + COUNT(DISTINCT a.obj_id) AS n FROM domrf_kn_sales_agg a JOIN domrf_kn_objects o ON o.obj_id = a.obj_id @@ -1428,6 +1437,12 @@ def _district_market_saturation(db: Session, *, district_name: str) -> tuple[flo AND a.perc IS NOT NULL AND o.district_name = :dn AND o.site_status = 'Строящиеся' + AND a.snapshot_date = ( + SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg + ) + AND o.snapshot_date = ( + SELECT MAX(snapshot_date) FROM domrf_kn_objects + ) """ ), {"dn": district_name}, diff --git a/backend/app/services/forecasting/demand_supply_forecast.py b/backend/app/services/forecasting/demand_supply_forecast.py index 5aec90c1..e4396b82 100644 --- a/backend/app/services/forecasting/demand_supply_forecast.py +++ b/backend/app/services/forecasting/demand_supply_forecast.py @@ -176,6 +176,13 @@ class DemandSupplyForecast: advisory: bool # ВСЕГДА True (движок не для production-решений) confidence: Confidence # MIN(компоненты), жёстко ≤ _CONFIDENCE_CAP + # Шок-окно (PR2): True, если §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity + # сообщили `confounded` (ряд пересекает структурный разрыв 2024-07-01). Прокидываем + # в `as_dict()` → `report_assembler._confounded` → `compute_report_confidence` (#990) + # confounded factor: НИКОГДА не позволяет §15 объявить 'high'. Дефолт False (чистое + # окно). Без этого поля §15-фактор шок-окна (#1222) перманентно мёртв. + confounded: bool = False + def as_dict(self) -> dict[str, Any]: return { "segment": dict(self.segment), @@ -197,6 +204,7 @@ class DemandSupplyForecast: "future_competitors": list(self.future_competitors), "advisory": self.advisory, "confidence": self.confidence, + "confounded": self.confounded, } @@ -601,6 +609,13 @@ def compute_demand_supply_forecast( # ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика) sensitivity = compute_rate_regime_sensitivity(db, spec=spec) + # Шок-окно (PR2): берём ИЗ §9.5/§9.6 ровно один раз и прокидываем во все per- + # горизонт DemandSupplyForecast — иначе #990 confounded-factor мёртв (#1222). + # MagicMock-стабы в тестах могут не задавать атрибут → `getattr(..., False)` + + # `bool(...)` для безопасности (Mock-attribute truthy исказил бы фактическое + # значение). + confounded = _series_confounded(macro_coef, sensitivity) + out: list[DemandSupplyForecast] = [] for h in horizon_list: out.append( @@ -616,12 +631,27 @@ def compute_demand_supply_forecast( market_confidence=metrics.confidence, macro_coef=macro_coef, sensitivity_phrase=sensitivity.phrase, + confounded=confounded, premise_kind=premise_kind, ) ) return out +def _series_confounded(macro_coef: Any, sensitivity: Any) -> bool: + """Окно §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity пересекает шок-период. + + PR2-флаг приходит ИЗ под-сервисов готовым `.confounded: bool` (см. + `MacroCoefficient` / `RateSensitivity`). Берём ИЛИ-агрегат: достаточно одного + True, чтобы окно считалось confounded. `getattr` с дефолтом False для + устойчивости к стабам/будущим под-вариантам без поля. Используется в `as_dict()` + → `report_assembler._confounded` → `compute_report_confidence` (#990, #1222). + """ + macro_flag = bool(getattr(macro_coef, "confounded", False) or False) + sens_flag = bool(getattr(sensitivity, "confounded", False) or False) + return macro_flag or sens_flag + + def _forecast_for_horizon( db: Session, *, @@ -635,6 +665,7 @@ def _forecast_for_horizon( market_confidence: Confidence, macro_coef: Any, sensitivity_phrase: str | None, + confounded: bool, premise_kind: str, ) -> DemandSupplyForecast: """Собрать прогноз для ОДНОГО горизонта (тонкий — pure-логика выше). Graceful.""" @@ -682,7 +713,8 @@ def _forecast_for_horizon( logger.info( "demand_supply_forecast: segment=%s h=%d base_pace=%s norm=%s macro=%s " - "demand=%s supply=%.1f balance=%s ratio=%s deficit_index=%s moi=%s confidence=%s", + "demand=%s supply=%.1f balance=%s ratio=%s deficit_index=%s moi=%s " + "confidence=%s confounded=%s", segment, horizon, _round_or_none(base_pace, 2), @@ -695,6 +727,7 @@ def _forecast_for_horizon( _round_or_none(deficit_index, 3), _round_or_none(months_of_inventory, 1), confidence, + confounded, ) return DemandSupplyForecast( @@ -717,6 +750,7 @@ def _forecast_for_horizon( future_competitors=future_competitors, advisory=True, confidence=confidence, + confounded=confounded, ) diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index b1d2d0df..cd45e8f1 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -226,9 +226,13 @@ def _history_months( def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool: """Пересекает ли окно прогноза шок-период — для confounded #990. PURE. - Любой per-горизонт forecast несёт флаг `confounded`/`is_confounded_window` (PR2)? - Если хоть один True → отчётное окно считаем confounded (оценки смещены, #990 → - НИКОГДА не 'high'). Нет флага нигде → False (чистое окно). + Канонический ключ — `confounded` (см. `DemandSupplyForecast.as_dict()`, #1222: + прокинут из §9.5 macro_coefficient / §9.6 rate_sensitivity). Исторический алиас + `is_confounded_window` оставляем ради forward-совместимости со старыми форматами + forecast-диктов. Всегда через `.get()` (без default → None) — отсутствие ключа + НЕ KeyError, а «нет сигнала» (None ≠ True → False). Если хоть один forecast + True → отчётное окно считаем confounded (оценки смещены, #990 → НИКОГДА не + 'high'). Нет флага нигде → False (чистое окно). """ for f in forecasts: if f.get("confounded") is True or f.get("is_confounded_window") is True: diff --git a/backend/app/services/objective_sync_config.py b/backend/app/services/objective_sync_config.py index 873284ca..de845a85 100644 --- a/backend/app/services/objective_sync_config.py +++ b/backend/app/services/objective_sync_config.py @@ -100,7 +100,7 @@ def get_config(db) -> ObjectiveSyncConfig: ) db.commit() except Exception: - pass + logger.exception("objective_sync_config seed failed (id=1 ON CONFLICT DO NOTHING)") return _defaults_from_settings() return ObjectiveSyncConfig( diff --git a/backend/app/services/scrapers/izyatie_ocr.py b/backend/app/services/scrapers/izyatie_ocr.py index 467a9808..2205e52e 100644 --- a/backend/app/services/scrapers/izyatie_ocr.py +++ b/backend/app/services/scrapers/izyatie_ocr.py @@ -76,6 +76,8 @@ def _normalize_ocr_text(text: str) -> str: # Также в начале кад-паттерна «6б:41» → «66:41»: # б (кирилл.) → 6, если справа стоит «6:» (т.е. первые два символа кад-номера). text = re.sub(r"\bб(?=6:)", "6", text, flags=re.IGNORECASE) + # Симметрично «6б:» → «66:» (OCR съел второй символ кад-номера МСК-66). См. #1228. + text = re.sub(r"(?<=6)б(?=:)", "6", text, flags=re.IGNORECASE) # Шаг 3: мягкий дефис и нулевая ширина. text = text.replace("­", "").replace("​", "") diff --git a/backend/app/services/scrapers/okn_egrkn_client.py b/backend/app/services/scrapers/okn_egrkn_client.py index e2d2a0ea..c701229c 100644 --- a/backend/app/services/scrapers/okn_egrkn_client.py +++ b/backend/app/services/scrapers/okn_egrkn_client.py @@ -1,15 +1,22 @@ -"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте (#1066). +"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте + detail-card (#1066, #1159). + +Источник: +- list: ``POST https://okn-mk.mkrf.ru/Maps/searchMap`` (Content-Type: form-urlencoded). +- detail: ``GET https://okn-mk.mkrf.ru/Show/Show?id={source_id}`` (HTML-карточка). -Источник: https://okn-mk.mkrf.ru/Maps/searchMap -Метод: POST, Content-Type: application/x-www-form-urlencoded Аутентификация: не требуется (открытый endpoint). -Ответ: GeoJSON FeatureCollection. -Каждая фича: {id, geometry: {type: "Point", coordinates: [lat, lon]}, properties: {...}}. +Ответ searchMap: GeoJSON FeatureCollection. +Каждая фича: {id, geometry: {type: "Point", coordinates: [lat, lon]}, properties: {id}}. ВНИМАНИЕ: ЕГРКН использует Яндекс-порядок координат [lat, lon], НЕ [lon, lat]. Для PostGIS: ST_MakePoint(coordinates[1], coordinates[0]) → (lon, lat). +searchMap отдаёт в properties **только {id}** — название/адрес/датировка приходят +отдельным detail-endpoint'ом ``Show/Show?id=`` (HTML-карточка ОКН с метаданными). +Парсим название/адрес/категорию охраны через BeautifulSoup, ошибки detail-парсинга +не валят прогон (raw_props сохраняется без detail-полей, баг #1159 acceptance 2). + TLS: mkrf.ru использует сертификат, верификация по умолчанию включена. При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242). """ @@ -17,17 +24,23 @@ TLS: mkrf.ru использует сертификат, верификация from __future__ import annotations import logging +import re import urllib.parse from typing import Any import httpx +from bs4 import BeautifulSoup logger = logging.getLogger(__name__) _SEARCHMAP_URL = "https://okn-mk.mkrf.ru/Maps/searchMap" +_DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show" -# Таймаут на один HTTP-запрос к ЕГРКН (секунды) +# Таймаут на один HTTP-запрос к ЕГРКН (секунды). +# Detail-вызов короче (10 s) — карточки лёгкие, длинный timeout растягивал бы прогон +# на сотни секунд при ~700 объектах. _REQUEST_TIMEOUT = 30.0 +_DETAIL_TIMEOUT = 10.0 # Browser headers -- required: without User-Agent the server returns 403. # Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его @@ -43,6 +56,15 @@ _HEADERS: dict[str, str] = { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", } +# Detail-карточка отдаёт HTML, не JSON — Content-Type form-urlencoded убираем, +# X-Requested-With оставляем для совместимости (на случай SSR-условного рендера). +_DETAIL_HEADERS: dict[str, str] = { + "User-Agent": _HEADERS["User-Agent"], + "Referer": "https://okn-mk.mkrf.ru/Maps", + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "ru-RU,ru;q=0.9", +} + def _build_form_data(address: str, category_type: int) -> list[tuple[str, str]]: """Построить form-data в формате jQuery serializeArray для поиска по карте. @@ -68,7 +90,8 @@ def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]: Returns: Список feature-dict'ов из GeoJSON FeatureCollection. Каждый dict содержит поля: id, geometry (с coordinates=[lat, lon]), - properties (сырые свойства из ЕГРКН, часть полей требует авторизации). + properties (обычно {id} — название/адрес/датировку нужно догружать + через ``fetch_okn_detail``). Пустой список при ошибке или пустом ответе. Note: @@ -144,4 +167,146 @@ def _do_request( return features -__all__ = ["fetch_okn_points"] +# ── Detail-карточка ────────────────────────────────────────────────────────── + + +# Поля карточки ЕГРКН: «Наименование», «Адрес», «Категория историко-культурного значения», +# «Вид объекта», «Датировка», «Документ о постановке на гос. охрану», «Регистрационный номер». +# HTML-структура — labelled rows, label слева, value справа в одном tr/td-блоке. +# Используем устойчивые keys-маркеры (lowercased substring match) для тегов
//