fix(p3-batch): восстановление 13 Fable 5 P3-fixes из lost branches #1301

Merged
bot-backend merged 13 commits from recover/fable5-p3-batch-2026-06-13 into main 2026-06-13 10:06:06 +00:00
26 changed files with 1706 additions and 289 deletions

View file

@ -1414,12 +1414,21 @@ def _district_market_saturation(db: Session, *, district_name: str) -> tuple[flo
Возвращает (median_pct, n_objects). None если <5 ЖК с perc. Возвращает (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 = ( row = (
db.execute( db.execute(
text( text(
""" """
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median, 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 FROM domrf_kn_sales_agg a
JOIN domrf_kn_objects o JOIN domrf_kn_objects o
ON o.obj_id = a.obj_id 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 a.perc IS NOT NULL
AND o.district_name = :dn AND o.district_name = :dn
AND o.site_status = 'Строящиеся' 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}, {"dn": district_name},

View file

@ -176,6 +176,13 @@ class DemandSupplyForecast:
advisory: bool # ВСЕГДА True (движок не для production-решений) advisory: bool # ВСЕГДА True (движок не для production-решений)
confidence: Confidence # MIN(компоненты), жёстко ≤ _CONFIDENCE_CAP 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]: def as_dict(self) -> dict[str, Any]:
return { return {
"segment": dict(self.segment), "segment": dict(self.segment),
@ -197,6 +204,7 @@ class DemandSupplyForecast:
"future_competitors": list(self.future_competitors), "future_competitors": list(self.future_competitors),
"advisory": self.advisory, "advisory": self.advisory,
"confidence": self.confidence, "confidence": self.confidence,
"confounded": self.confounded,
} }
@ -601,6 +609,13 @@ def compute_demand_supply_forecast(
# ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика) # ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика)
sensitivity = compute_rate_regime_sensitivity(db, spec=spec) 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] = [] out: list[DemandSupplyForecast] = []
for h in horizon_list: for h in horizon_list:
out.append( out.append(
@ -616,12 +631,27 @@ def compute_demand_supply_forecast(
market_confidence=metrics.confidence, market_confidence=metrics.confidence,
macro_coef=macro_coef, macro_coef=macro_coef,
sensitivity_phrase=sensitivity.phrase, sensitivity_phrase=sensitivity.phrase,
confounded=confounded,
premise_kind=premise_kind, premise_kind=premise_kind,
) )
) )
return out 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( def _forecast_for_horizon(
db: Session, db: Session,
*, *,
@ -635,6 +665,7 @@ def _forecast_for_horizon(
market_confidence: Confidence, market_confidence: Confidence,
macro_coef: Any, macro_coef: Any,
sensitivity_phrase: str | None, sensitivity_phrase: str | None,
confounded: bool,
premise_kind: str, premise_kind: str,
) -> DemandSupplyForecast: ) -> DemandSupplyForecast:
"""Собрать прогноз для ОДНОГО горизонта (тонкий — pure-логика выше). Graceful.""" """Собрать прогноз для ОДНОГО горизонта (тонкий — pure-логика выше). Graceful."""
@ -682,7 +713,8 @@ def _forecast_for_horizon(
logger.info( logger.info(
"demand_supply_forecast: segment=%s h=%d base_pace=%s norm=%s macro=%s " "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, segment,
horizon, horizon,
_round_or_none(base_pace, 2), _round_or_none(base_pace, 2),
@ -695,6 +727,7 @@ def _forecast_for_horizon(
_round_or_none(deficit_index, 3), _round_or_none(deficit_index, 3),
_round_or_none(months_of_inventory, 1), _round_or_none(months_of_inventory, 1),
confidence, confidence,
confounded,
) )
return DemandSupplyForecast( return DemandSupplyForecast(
@ -717,6 +750,7 @@ def _forecast_for_horizon(
future_competitors=future_competitors, future_competitors=future_competitors,
advisory=True, advisory=True,
confidence=confidence, confidence=confidence,
confounded=confounded,
) )

View file

@ -226,9 +226,13 @@ def _history_months(
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool: def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE. """Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
Любой per-горизонт forecast несёт флаг `confounded`/`is_confounded_window` (PR2)? Канонический ключ `confounded` (см. `DemandSupplyForecast.as_dict()`, #1222:
Если хоть один True отчётное окно считаем confounded (оценки смещены, #990 → прокинут из §9.5 macro_coefficient / §9.6 rate_sensitivity). Исторический алиас
НИКОГДА не 'high'). Нет флага нигде False (чистое окно). `is_confounded_window` оставляем ради forward-совместимости со старыми форматами
forecast-диктов. Всегда через `.get()` (без default None) отсутствие ключа
НЕ KeyError, а «нет сигнала» (None True False). Если хоть один forecast
True отчётное окно считаем confounded (оценки смещены, #990 → НИКОГДА не
'high'). Нет флага нигде False (чистое окно).
""" """
for f in forecasts: for f in forecasts:
if f.get("confounded") is True or f.get("is_confounded_window") is True: if f.get("confounded") is True or f.get("is_confounded_window") is True:

View file

@ -100,7 +100,7 @@ def get_config(db) -> ObjectiveSyncConfig:
) )
db.commit() db.commit()
except Exception: except Exception:
pass logger.exception("objective_sync_config seed failed (id=1 ON CONFLICT DO NOTHING)")
return _defaults_from_settings() return _defaults_from_settings()
return ObjectiveSyncConfig( return ObjectiveSyncConfig(

View file

@ -76,6 +76,8 @@ def _normalize_ocr_text(text: str) -> str:
# Также в начале кад-паттерна «6б:41» → «66:41»: # Также в начале кад-паттерна «6б:41» → «66:41»:
# б (кирилл.) → 6, если справа стоит «6:» (т.е. первые два символа кад-номера). # б (кирилл.) → 6, если справа стоит «6:» (т.е. первые два символа кад-номера).
text = re.sub(r"\bб(?=6:)", "6", text, flags=re.IGNORECASE) 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: мягкий дефис и нулевая ширина. # Шаг 3: мягкий дефис и нулевая ширина.
text = text.replace("­", "").replace("", "") text = text.replace("­", "").replace("", "")

View file

@ -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). Аутентификация: не требуется (открытый endpoint).
Ответ: GeoJSON FeatureCollection. Ответ searchMap: GeoJSON FeatureCollection.
Каждая фича: {id, geometry: {type: "Point", coordinates: [lat, lon]}, properties: {...}}. Каждая фича: {id, geometry: {type: "Point", coordinates: [lat, lon]}, properties: {id}}.
ВНИМАНИЕ: ЕГРКН использует Яндекс-порядок координат [lat, lon], НЕ [lon, lat]. ВНИМАНИЕ: ЕГРКН использует Яндекс-порядок координат [lat, lon], НЕ [lon, lat].
Для PostGIS: ST_MakePoint(coordinates[1], coordinates[0]) (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 использует сертификат, верификация по умолчанию включена. TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242). При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
""" """
@ -17,17 +24,23 @@ TLS: mkrf.ru использует сертификат, верификация
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import urllib.parse import urllib.parse
from typing import Any from typing import Any
import httpx import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SEARCHMAP_URL = "https://okn-mk.mkrf.ru/Maps/searchMap" _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 _REQUEST_TIMEOUT = 30.0
_DETAIL_TIMEOUT = 10.0
# Browser headers -- required: without User-Agent the server returns 403. # Browser headers -- required: without User-Agent the server returns 403.
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его # 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", "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]]: def _build_form_data(address: str, category_type: int) -> list[tuple[str, str]]:
"""Построить form-data в формате jQuery serializeArray для поиска по карте. """Построить form-data в формате jQuery serializeArray для поиска по карте.
@ -68,7 +90,8 @@ def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]:
Returns: Returns:
Список feature-dict'ов из GeoJSON FeatureCollection. Список feature-dict'ов из GeoJSON FeatureCollection.
Каждый dict содержит поля: id, geometry (с coordinates=[lat, lon]), Каждый dict содержит поля: id, geometry (с coordinates=[lat, lon]),
properties (сырые свойства из ЕГРКН, часть полей требует авторизации). properties (обычно {id} название/адрес/датировку нужно догружать
через ``fetch_okn_detail``).
Пустой список при ошибке или пустом ответе. Пустой список при ошибке или пустом ответе.
Note: Note:
@ -144,4 +167,146 @@ def _do_request(
return features return features
__all__ = ["fetch_okn_points"] # ── Detail-карточка ──────────────────────────────────────────────────────────
# Поля карточки ЕГРКН: «Наименование», «Адрес», «Категория историко-культурного значения»,
# «Вид объекта», «Датировка», «Документ о постановке на гос. охрану», «Регистрационный номер».
# HTML-структура — labelled rows, label слева, value справа в одном tr/td-блоке.
# Используем устойчивые keys-маркеры (lowercased substring match) для тегов <dt>/<th>/<label>.
_FIELD_MARKERS: dict[str, list[str]] = {
"name": ["наименование"],
"address": ["адрес"],
"protection_category": ["категория", "историко-культурного значения"],
"object_kind": ["вид объекта"],
"dating": ["датировка"],
"protection_doc": ["документ о постановке"],
"regnumber": ["регистрационный номер"],
}
# Лёгкая нормализация пробелов в извлечённом тексте.
_WS_RE = re.compile(r"\s+")
def _norm_text(s: str | None) -> str | None:
"""Сжать whitespace и trim. Пустую строку → None."""
if not s:
return None
value = _WS_RE.sub(" ", s).strip()
return value or None
def _match_marker(label: str, markers: list[str]) -> bool:
"""Все маркеры присутствуют в lowercased label (substring AND)."""
lab = label.lower()
return all(m in lab for m in markers)
def _extract_label_value_pairs(soup: BeautifulSoup) -> dict[str, str]:
"""Извлечь пары (label → value) из карточки ОКН.
HTML ЕГРКН построен на стандартных Bootstrap-«row»'ах: первая колонка — label,
вторая value. Также встречаются <dl><dt>/<dd>. Парсер собирает пары по обоим
шаблонам; пары накапливаются в порядке появления.
Возвращает {raw_label_lower: value}, без интерпретации.
"""
pairs: dict[str, str] = {}
# 1) <dl><dt>...</dt><dd>...</dd>
for dt in soup.find_all("dt"):
dd = dt.find_next_sibling("dd")
if dd is None:
continue
label = _norm_text(dt.get_text(" "))
value = _norm_text(dd.get_text(" "))
if label and value:
pairs.setdefault(label.lower(), value)
# 2) Bootstrap-row / table-row: пара дочерних элементов в одном контейнере.
for row in soup.find_all(["tr", "div"]):
kids = [c for c in row.find_all(recursive=False) if c.name in {"td", "th", "div", "span"}]
if len(kids) < 2:
continue
label = _norm_text(kids[0].get_text(" "))
value = _norm_text(kids[1].get_text(" "))
if label and value and len(label) < 120:
pairs.setdefault(label.lower(), value)
return pairs
def _parse_detail_html(html: str) -> dict[str, Any]:
"""Распарсить HTML-карточку ОКН в {name, address, …}.
Невалидный HTML или отсутствие распознанных полей пустой dict. Не raise.
"""
if not html or not html.strip():
return {}
try:
soup = BeautifulSoup(html, "html.parser")
except Exception as exc:
logger.warning("okn_egrkn_client: BS4 parse failed: %s", exc)
return {}
pairs = _extract_label_value_pairs(soup)
if not pairs:
return {}
out: dict[str, Any] = {}
for field, markers in _FIELD_MARKERS.items():
for label, value in pairs.items():
if _match_marker(label, markers):
out[field] = value
break
return out
def fetch_okn_detail(source_id: str) -> dict[str, Any]:
"""Запросить detail-карточку ОКН и распарсить метаданные.
Args:
source_id: id фичи из searchMap (он же id записи в ЕГРКН-реестре).
Returns:
dict с подмножеством {name, address, protection_category, object_kind,
dating, protection_doc, regnumber}. Пустой dict при ошибке (любая)
never raise, ingest продолжается с базовыми полями.
"""
if not source_id:
return {}
return _do_detail_request(source_id, verify=True)
def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap."""
params = {"id": source_id}
try:
with httpx.Client(
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
) as client:
resp = client.get(_DETAIL_URL, params=params)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.warning(
"okn_egrkn_client: detail HTTP %d source_id=%s",
exc.response.status_code,
source_id,
)
return {}
except httpx.ConnectError as exc:
if verify and _is_ssl_error(exc):
logger.warning(
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
)
return _do_detail_request(source_id, verify=False)
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
return {}
except Exception as exc:
logger.warning("okn_egrkn_client: detail request error source_id=%s: %s", source_id, exc)
return {}
return _parse_detail_html(resp.text)
__all__ = ["fetch_okn_detail", "fetch_okn_points"]

View file

@ -148,17 +148,20 @@ _INLINE_VELOCITY_SQL = text("""
# ── SQL: supply по (room_bucket, area_bin) за последний снимок ─────────────── # ── SQL: supply по (room_bucket, area_bin) за последний снимок ───────────────
# Один батч-запрос вместо N — возвращает map (rb, ab) → count. # Один батч-запрос вместо N — возвращает map (rb, ab) → count.
# room_bucket и area_bin вычисляются в SQL аналогично layout_signature.py. # room_bucket и area_bin вычисляются в SQL аналогично layout_signature.py.
# Fix #1229: bucket'ы нормализованы под velocity-вокабуляр objective_corpus_room_month
# = {studio, 1, 2, 3, 4+}. Раньше supply отдельно отдавал euro-1/euro-2 для rooms=2
# (area<35 / area<50) — эти ключи никогда не матчились с velocity (Объектив
# euro-форматов не выделяет, см. _INLINE_VELOCITY_SQL passthrough room_bucket).
# Последствия: 1) supply '2' искусственно занижен (rooms=2 + area<50 уходили в
# euro-*) → sold_pct/is_oversold двушек завышены; 2) euro-* строки supply
# становились dead lookup. SF-08 euro-биннинг откладываем до момента, когда его
# начнёт отдавать velocity-сторона.
_SUPPLY_BATCH_SQL = text(""" _SUPPLY_BATCH_SQL = text("""
SELECT SELECT
CASE CASE
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio' WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
WHEN f.rooms = 0 THEN 'studio' WHEN f.rooms = 0 THEN 'studio'
-- Fix SF-08: euro-форматы DOM.РФ маркирует малогабаритные квартиры как 2-комн.
-- rooms=2 + area<35 euro-1 (студия с отдельной кухней ~26м²)
-- rooms=2 + area<50 euro-2 (~35-50м², евро-двушка)
WHEN f.rooms = 2 AND f.total_area < 35 THEN 'euro-1'
WHEN f.rooms = 2 AND f.total_area < 50 THEN 'euro-2'
WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text
WHEN f.rooms >= 4 THEN '4+' WHEN f.rooms >= 4 THEN '4+'
ELSE '1' ELSE '1'

View file

@ -19,7 +19,7 @@ import logging
from typing import Any from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import OperationalError, ProgrammingError from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -59,8 +59,12 @@ def parcel_ird_overlaps(db: Session, parcel_wkt: str | None) -> dict[str, Any]:
return {"ird_overlaps": [], "ird_by_kind": {}} return {"ird_overlaps": [], "ird_by_kind": {}}
try: try:
rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc: except (OperationalError, ProgrammingError, DataError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade. # OperationalError — БД недоступна / connection drop.
# ProgrammingError — ird_overlays ещё не задеплоена (UndefinedTable, pre-migration).
# DataError — malformed WKT в ST_GeomFromText (PostGIS ERROR пробрасывает SQLAlchemy
# как DataError/InternalError). parcel_wkt системно-генерируемый, но при D9b-wiring
# в analyze graceful-degrade — единственная корректная стратегия (analyze не падает).
logger.warning("parcel_ird_overlaps: ird_overlays недоступна, skip: %s", exc) logger.warning("parcel_ird_overlaps: ird_overlays недоступна, skip: %s", exc)
return {"ird_overlaps": [], "ird_by_kind": {}} return {"ird_overlaps": [], "ird_by_kind": {}}

View file

@ -126,6 +126,11 @@ def _way_to_polygon_wkt(nodes: list[dict]) -> str | None:
Используется для natural=water (озёра, пруды). Если кольцо не замкнуто Используется для natural=water (озёра, пруды). Если кольцо не замкнуто
автоматически добавляем первую точку в конец. автоматически добавляем первую точку в конец.
PostGIS отвергает linear ring < 4 точек. Замкнутый way из 3 точек
[A, B, A] (points[0]==points[-1]) даёт POLYGON((A,B,A)) "geometry
requires more points". Возвращаем None, чтобы caller сделал fallback
на LineString или пропустил элемент.
""" """
points = [f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n] points = [f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n]
if len(points) < 3: if len(points) < 3:
@ -133,6 +138,9 @@ def _way_to_polygon_wkt(nodes: list[dict]) -> str | None:
# Замкнуть кольцо если нужно # Замкнуть кольцо если нужно
if points[0] != points[-1]: if points[0] != points[-1]:
points.append(points[0]) points.append(points[0])
# Итоговое кольцо должно содержать ≥4 точек (PostGIS требование).
if len(points) < 4:
return None
return f"POLYGON(({', '.join(points)}))" return f"POLYGON(({', '.join(points)}))"
@ -203,35 +211,50 @@ def sync_noise_sources_to_db() -> dict[str, int]:
**geom_params, **geom_params,
} }
result = db.execute( try:
text(f""" with db.begin_nested(): # SAVEPOINT — откат только этой записи
INSERT INTO osm_noise_sources_ekb result = db.execute(
(osm_type, osm_id, source_type, road_class, name, geom, tags, fetched_at) text(f"""
VALUES ( INSERT INTO osm_noise_sources_ekb
:osm_type, :osm_id, :source_type, :road_class, :name, (osm_type, osm_id, source_type, road_class, name, geom, tags,
{geom_sql}, fetched_at)
CAST(:tags AS jsonb), NOW() VALUES (
) :osm_type, :osm_id, :source_type, :road_class, :name,
ON CONFLICT (osm_type, osm_id) DO UPDATE {geom_sql},
SET source_type = EXCLUDED.source_type, CAST(:tags AS jsonb), NOW()
road_class = EXCLUDED.road_class, )
name = EXCLUDED.name, ON CONFLICT (osm_type, osm_id) DO UPDATE
geom = EXCLUDED.geom, SET source_type = EXCLUDED.source_type,
tags = EXCLUDED.tags, road_class = EXCLUDED.road_class,
fetched_at = NOW() name = EXCLUDED.name,
RETURNING (xmax = 0) AS is_insert geom = EXCLUDED.geom,
"""), tags = EXCLUDED.tags,
params, fetched_at = NOW()
).scalar() RETURNING (xmax = 0) AS is_insert
"""),
if result: params,
inserted += 1 ).scalar()
else: if result:
updated += 1 inserted += 1
else:
updated += 1
except Exception as e:
# Дефектный way (например, 3-точечное замкнутое кольцо POLYGON((A,B,A))
# отвергается PostGIS) не должен валить весь weekly-sync. SAVEPOINT
# откатывает только эту строку, продолжаем с остальными.
logger.warning(
"noise_sync insert failed for %s/%s (source_type=%s): %s",
osm_type,
osm_id,
source_type,
e,
)
skipped += 1
db.commit() db.commit()
except Exception: except Exception as e:
db.rollback() db.rollback()
logger.exception("noise_sync: unexpected error, outer tx rolled back: %s", e)
raise raise
finally: finally:
db.close() db.close()

View file

@ -1,15 +1,19 @@
"""ОКН-lookup для analyze_parcel (#1066). """ОКН-lookup для analyze_parcel (#1066, #1159).
Читает ``okn_objects`` (м.141) и отдаёт объекты культурного наследия Читает ``okn_objects`` (м.141) и отдаёт объекты культурного наследия
в заданном радиусе от участка [{source_id, category, distance_m}]. в заданном радиусе от участка [{source_id, category, distance_m, name,
address}].
ST_DWithin по geography (метрическое расстояние в метрах) через GIST-индекс ST_DWithin по geography (метрическое расстояние в метрах) через GIST-индекс
idx_okn_objects_geom. Участок передаётся как WKT в EPSG:4326. idx_okn_objects_geom. Участок передаётся как WKT в EPSG:4326.
``name``/``address`` извлекаются из ``raw_props`` (обогащаются воркером через
detail-карточку ЕГРКН). Если detail недоступен возвращается None для этих
полей, остальные поля сохраняются. Без detail-фикса (#1159 acceptance 2)
analyze-таблица показывала «ОКН рядом» без указания КАКОГО критично для ИРД.
Graceful: если таблица ``okn_objects`` ещё не задеплоена (pre-migration) Graceful: если таблица ``okn_objects`` ещё не задеплоена (pre-migration)
или БД-ошибка возвращает пустой список (analyze не падает). или БД-ошибка возвращает пустой список (analyze не падает).
Wiring в analyze следующий PR (analyze-wiring, follow-up #1066).
""" """
from __future__ import annotations from __future__ import annotations
@ -25,6 +29,8 @@ logger = logging.getLogger(__name__)
# Пространственный запрос: ST_DWithin по geography даёт расстояние в метрах. # Пространственный запрос: ST_DWithin по geography даёт расстояние в метрах.
# CAST psycopg v3 (никогда :param::type — vault Pattern_CAST_AS_Type). # CAST psycopg v3 (никогда :param::type — vault Pattern_CAST_AS_Type).
# raw_props->>'name'/'address' — JSON-string accessor (NULL если ключа нет
# или raw_props=NULL). Безопасно для строк без detail-обогащения.
_OKN_DWITHIN_SQL = text(""" _OKN_DWITHIN_SQL = text("""
SELECT SELECT
source_id, source_id,
@ -34,7 +40,9 @@ _OKN_DWITHIN_SQL = text("""
geom::geography, geom::geography,
ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)::geography ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)::geography
) )
)::integer AS distance_m )::integer AS distance_m,
raw_props->>'name' AS name,
raw_props->>'address' AS address
FROM okn_objects FROM okn_objects
WHERE ST_DWithin( WHERE ST_DWithin(
geom::geography, geom::geography,
@ -58,9 +66,10 @@ def parcel_okn_objects(
radius_m: радиус поиска в метрах (по умолчанию 100 м). radius_m: радиус поиска в метрах (по умолчанию 100 м).
Returns: Returns:
Список dict [{source_id, category, distance_m}], отсортированный Список dict [{source_id, category, distance_m, name, address}],
по расстоянию ASC. Пустой список при отсутствии участка / таблицы / отсортированный по расстоянию ASC. ``name``/``address`` могут быть
объектов в радиусе (analyze не падает). None если detail-карточка не догружена. Пустой список при
отсутствии участка / таблицы / объектов в радиусе (analyze не падает).
""" """
if not parcel_wkt: if not parcel_wkt:
return [] return []
@ -83,6 +92,8 @@ def parcel_okn_objects(
"source_id": row[0], "source_id": row[0],
"category": row[1], "category": row[1],
"distance_m": row[2], "distance_m": row[2],
"name": row[3],
"address": row[4],
} }
) )
return result return result

View file

@ -7,7 +7,7 @@
import asyncio import asyncio
import json import json
import logging import logging
from datetime import date, datetime from datetime import date, datetime, timedelta
import httpx import httpx
from sqlalchemy import text from sqlalchemy import text
@ -110,7 +110,9 @@ def sync_poi_to_db() -> dict[str, int]:
Returns: counters {fetched, inserted, updated, skipped_old}. Returns: counters {fetched, inserted, updated, skipped_old}.
""" """
elements = asyncio.run(fetch_overpass()) elements = asyncio.run(fetch_overpass())
two_years_ago = date.today().replace(year=date.today().year - 2) # 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02).
# Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232.
two_years_ago = date.today() - timedelta(days=730)
inserted = 0 inserted = 0
updated = 0 updated = 0

View file

@ -20,7 +20,7 @@ from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import OperationalError, ProgrammingError from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1066,7 +1066,8 @@ def _get_engineering_structures_by_boundary(
), ),
{"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m}, {"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m},
).fetchall() ).fetchall()
except (OperationalError, ProgrammingError) as e: except (OperationalError, ProgrammingError, DataError) as e:
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
logger.warning( logger.warning(
"engineering_structures query failed for quarter=%s: %s", "engineering_structures query failed for quarter=%s: %s",
quarter, quarter,
@ -1139,7 +1140,8 @@ def _get_zouit_engineering_overlaps(
), ),
{"q": quarter, "wkt": parcel_wkt}, {"q": quarter, "wkt": parcel_wkt},
).fetchall() ).fetchall()
except (OperationalError, ProgrammingError) as e: except (OperationalError, ProgrammingError, DataError) as e:
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
logger.warning( logger.warning(
"zouit_engineering query failed for quarter=%s: %s", "zouit_engineering query failed for quarter=%s: %s",
quarter, quarter,

View file

@ -137,8 +137,8 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<strong>{{ cad_num }}</strong><br/> <strong>{{ cad_num }}</strong><br/>
{{ district or '&mdash;' }}<br/> {{ district or '' }}<br/>
{{ address or '&mdash;' }} {{ address or '' }}
</div> </div>
</div> </div>
@ -155,15 +155,15 @@
</div> </div>
<div class="kpi-card"> <div class="kpi-card">
<div class="kpi-label">Категория земель</div> <div class="kpi-label">Категория земель</div>
<div class="kpi-value">{{ land_category or '&mdash;' }}</div> <div class="kpi-value">{{ land_category or '' }}</div>
</div> </div>
<div class="kpi-card"> <div class="kpi-card">
<div class="kpi-label">ВРИ</div> <div class="kpi-label">ВРИ</div>
<div class="kpi-value">{{ vri or '&mdash;' }}</div> <div class="kpi-value">{{ vri or '' }}</div>
</div> </div>
<div class="kpi-card"> <div class="kpi-card">
<div class="kpi-label">Последнее обновление</div> <div class="kpi-label">Последнее обновление</div>
<div class="kpi-value">{{ last_update or '&mdash;' }}</div> <div class="kpi-value">{{ last_update or '' }}</div>
</div> </div>
</div> </div>
@ -184,7 +184,7 @@
{% for poi in poi_items %} {% for poi in poi_items %}
<tr> <tr>
<td>{{ poi.category_ru }}</td> <td>{{ poi.category_ru }}</td>
<td>{{ poi.name or '&mdash;' }}</td> <td>{{ poi.name or '' }}</td>
<td>{{ poi.distance_m }} м</td> <td>{{ poi.distance_m }} м</td>
<td>{{ poi.walk_min }} мин</td> <td>{{ poi.walk_min }} мин</td>
<td><span class="badge badge-blue">{{ poi.weighted_score }}</span></td> <td><span class="badge badge-blue">{{ poi.weighted_score }}</span></td>
@ -212,10 +212,10 @@
<tbody> <tbody>
{% for c in competitors %} {% for c in competitors %}
<tr> <tr>
<td>{{ c.comm_name or '&mdash;' }}</td> <td>{{ c.comm_name or '' }}</td>
<td>{{ c.dev_name or '&mdash;' }}</td> <td>{{ c.dev_name or '' }}</td>
<td>{{ c.obj_class or '&mdash;' }}</td> <td>{{ c.obj_class or '' }}</td>
<td>{{ c.flat_count or '&mdash;' }}</td> <td>{{ c.flat_count or '' }}</td>
<td>{{ c.distance_m }} м</td> <td>{{ c.distance_m }} м</td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -15,6 +15,8 @@ Sentinel source_layer_id'ы (вне диапазона НСПД): 99000199000
Conventions (зеркало ird_harvest.py): Conventions (зеркало ird_harvest.py):
SessionLocal() + try/finally close. SessionLocal() + try/finally close.
SAVEPOINT per-row (with db.begin_nested():) одна битая фича не валит батч. SAVEPOINT per-row (with db.begin_nested():) одна битая фича не валит батч.
db.commit() per-prefix (durability) WorkerLost в середине прогона не теряет
уже записанные префиксы (#1116).
CAST(:x AS type) НИКОГДА :x::type (psycopg v3). CAST(:x AS type) НИКОГДА :x::type (psycopg v3).
ON CONFLICT (source_layer_id, geom_data_id) DO UPDATE. ON CONFLICT (source_layer_id, geom_data_id) DO UPDATE.
content_* пишем мы же (геопортал как источник); content НЕ трогается НСПД-harvest'ом. content_* пишем мы же (геопортал как источник); content НЕ трогается НСПД-harvest'ом.
@ -279,8 +281,11 @@ def harvest_gknspecial_zones() -> dict[str, int]:
len(feats), len(feats),
n_written, n_written,
) )
# Durable per-prefix commit: WorkerLostError (OOM / рестарт хоста) в середине
# прогона не теряет уже записанные префиксы (#1116). Mirror ird_harvest.py.
db.commit()
db.commit() db.commit() # финальный — no-op если всё уже коммитнуто per-prefix (подстраховка)
logger.info("gknspecial_harvest: готово, types=%d features=%d", n_types, n_features) logger.info("gknspecial_harvest: готово, types=%d features=%d", n_types, n_features)
return {"types": n_types, "features": n_features} return {"types": n_types, "features": n_features}
finally: finally:

View file

@ -1,18 +1,28 @@
"""Celery task: ingest ОКН-объектов ЕКБ/Свердл.обл из АИС ЕГРКН (#1066). """Celery task: ingest ОКН-объектов ЕКБ/Свердл.обл из АИС ЕГРКН (#1066, #1159).
Обходит 6 комбинаций (address, category_type): Обходит расширенный список ``(address, category_type)``-проходов: помимо базовых
- «Екатеринбург», 1/2/4 «Екатеринбург»/«Свердловская» (3 категории каждая) добавлены варианты подстрок
- «Свердловская», 1/2/4 адреса районы ЕКБ, городские округа, обиходные склонения области. Запрос
``Maps/searchMap`` фильтрует ОКН по подстроке в поле ``cob_address``: объекты,
у которых адрес начинается с улицы без слова «Екатеринбург», ловятся только
запросом по району/округу. Это закрывает coverage gap (428 vs ~798 ЕГРОКН).
Один объект может встречаться в нескольких проходах (например «Екатеринбург»-категория-2 Один объект может встречаться в нескольких проходах. Дедупликация по
и «Свердловская»-категория-2). Дедупликация по source_id: первый проход, выдавший объект, ``source_id``: первый проход устанавливает category (самая «высокая» категория
устанавливает category (самая «высокая» категория первой встретившейся). первой встретившейся). Порядок проходов: federal regional local;
«Екатеринбург» раньше областных подстрок при конфликте source_id выбирается
наиболее значимая категория.
Порядок проходов (от федерального к местному, Екатеринбург раньше области) обеспечивает, После сбора уникальных фич воркер догружает per-object detail-карточку
что при конфликте source_id category берётся из наиболее значимой категории. ``Show/Show?id=``: searchMap отдаёт ``properties={id}``, а название/адрес/датировка/
категория охраны лежат отдельно. ``raw_props`` обогащается полями ``name``,
``address``, ``protection_category``, ``object_kind``, ``dating``,
``protection_doc``, ``regnumber`` (#1159 acceptance 2). Detail-фейлы не валят
прогон объект остаётся с базовыми полями (минимум {id}).
UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, fetched_at (category не меняется UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, raw_props, fetched_at
при повторном импорте первый wins). SAVEPOINT per-row через db.begin_nested(). (category не меняется при повторном импорте first wins). SAVEPOINT per-row через
``db.begin_nested()``.
Beat: еженедельно в воскресенье в 04:30 МСК до ird-harvest (понедельник 05:00). Beat: еженедельно в воскресенье в 04:30 МСК до ird-harvest (понедельник 05:00).
""" """
@ -26,7 +36,7 @@ from typing import Any
from sqlalchemy import text from sqlalchemy import text
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.services.scrapers.okn_egrkn_client import fetch_okn_points from app.services.scrapers.okn_egrkn_client import fetch_okn_detail, fetch_okn_points
from app.workers.celery_app import celery_app from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -38,17 +48,47 @@ _CATEGORY_MAP: dict[int, str] = {
4: "local", 4: "local",
} }
# Порядок проходов: (address, category_type). # Категории по приоритету (federal → regional → local): порядок важен,
# Федеральные идут первыми — при source_id-конфликте category='federal' выигрывает. # при конфликте source_id у одного и того же объекта побеждает более высокая.
_PASSES: list[tuple[str, int]] = [ _CATEGORY_ORDER: list[int] = [1, 2, 4]
("Екатеринбург", 1),
("Екатеринбург", 2), # Подстроки для cob_address. ЕГРКН не возвращает все объекты «Свердловской обл.»
("Екатеринбург", 4), # по одному запросу — обходим расширенным списком:
("Свердловская", 1), # - город целиком («Екатеринбург»),
("Свердловская", 2), # - области («Свердловская», «Свердловской области»),
("Свердловская", 4), # - районы ЕКБ — для записей без слова «Екатеринбург», только улица + район.
# Порядок: «Екатеринбург» раньше других → при дедупе district-вариант не
# перетирает category «городского» прохода.
_ADDRESS_PASSES: list[str] = [
"Екатеринбург",
"г. Екатеринбург",
"Кировский",
"Ленинский",
"Октябрьский",
"Орджоникидзевский",
"Чкаловский",
"Верх-Исетский",
"Железнодорожный",
"Свердловская",
"Свердловской области",
] ]
# Декартово произведение (address × category) — генерируется единожды при импорте.
# Категория федеральная идёт первой по каждому address: при конфликте source_id
# (тот же объект встречается в нескольких категориях) federal/regional перебивают
# local в той же address-strata.
_PASSES: list[tuple[str, int]] = [
(addr, cat) for addr in _ADDRESS_PASSES for cat in _CATEGORY_ORDER
]
# Лимит per-run на detail-fetch. ~700 объектов * ~0.3 s сетевой задержки ≈ 4 min,
# плюс retries — приемлемо для weekly cron. Жёсткий cap защищает от runaway
# при сетевом шторме.
_DETAIL_FETCH_LIMIT = 1500
# Логгировать прогресс detail-fetch каждые N объектов.
_DETAIL_PROGRESS_EVERY = 100
_UPSERT_SQL = text(""" _UPSERT_SQL = text("""
INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at) INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at)
VALUES ( VALUES (
@ -100,19 +140,88 @@ def _extract_coords(feature: dict[str, Any]) -> tuple[float, float] | None:
return None return None
@celery_app.task(name="tasks.okn_objects_sync.sync_okn_objects", queue="celery") def _enrich_with_detail(
def sync_okn_objects() -> dict[str, int]: to_upsert: list[dict[str, Any]],
"""Ingest ОКН-объектов из ЕГРКН в okn_objects (#1066). *,
detail_fetcher: Any = fetch_okn_detail,
limit: int = _DETAIL_FETCH_LIMIT,
) -> int:
"""Обогатить raw_props detail-полями (name, address, …).
Проходит 6 комбинаций (address, category_type), дедуплицирует по source_id Принимает список row-dict'ов, уже подготовленных к UPSERT. Для каждой
(первый проход устанавливает category), UPSERT в БД с SAVEPOINT per-row. строки вызывает ``detail_fetcher(source_id)`` и сливает результат в JSON
``raw_props`` (detail-поля имеют приоритет над сохранёнными ранее).
Записи без detail-полей сохраняются с тем же ``raw_props={id}`` детали
могут быть недоступны (объект удалён из реестра / detail-endpoint лежит).
Args:
to_upsert: список row-dict для UPSERT (мутируется in-place).
detail_fetcher: callable source_id dict (для DI в тестах).
limit: максимум detail-вызовов за прогон.
Returns: Returns:
{"okn": N} число успешно обработанных фич (upsert + skip дублей). Число фич, для которых detail-поля были успешно подмёрджены.
"""
enriched = 0
total = min(len(to_upsert), limit)
for idx, row in enumerate(to_upsert[:limit], 1):
try:
detail = detail_fetcher(row["source_id"])
except Exception as exc:
logger.warning(
"okn_objects_sync: detail-fetch source_id=%s raised: %s",
row["source_id"],
exc,
)
detail = {}
if not detail:
continue
try:
base_props = json.loads(row["raw_props"]) if row["raw_props"] else {}
except (TypeError, ValueError):
base_props = {}
if not isinstance(base_props, dict):
base_props = {}
merged = {**base_props, **detail}
row["raw_props"] = json.dumps(merged, ensure_ascii=False)
enriched += 1
if idx % _DETAIL_PROGRESS_EVERY == 0:
logger.info(
"okn_objects_sync: detail-fetch progress %d/%d (enriched=%d)",
idx,
total,
enriched,
)
logger.info(
"okn_objects_sync: detail-fetch done enriched=%d of %d (limit=%d)",
enriched,
total,
limit,
)
return enriched
@celery_app.task(name="tasks.okn_objects_sync.sync_okn_objects", queue="celery")
def sync_okn_objects() -> dict[str, int]:
"""Ingest ОКН-объектов из ЕГРКН в okn_objects (#1066, #1159).
Проходит ``_PASSES`` декартово (адресная подстрока × category_type),
дедуплицирует по source_id (первый проход устанавливает category),
догружает detail-карточку для каждого уникального source_id и
UPSERT'ит в БД с SAVEPOINT per-row.
Returns:
{"okn": N, "enriched": M} N успешно обработанных фич (upsert),
M фич, для которых detail-карточка была подмёрджена в raw_props.
""" """
# Дедуп по source_id в памяти: source_id → category (first-wins) # Дедуп по source_id в памяти: source_id → category (first-wins)
seen: dict[str, str] = {} seen: dict[str, str] = {}
# Очередь для UPSERT: (source_id, category, lon, lat, raw_props_json) # Очередь для UPSERT: row-dict для исполнения _UPSERT_SQL.
to_upsert: list[dict[str, Any]] = [] to_upsert: list[dict[str, Any]] = []
for address, category_type in _PASSES: for address, category_type in _PASSES:
@ -160,6 +269,9 @@ def sync_okn_objects() -> dict[str, int]:
logger.info("okn_objects_sync: всего уникальных фич для UPSERT: %d", len(to_upsert)) logger.info("okn_objects_sync: всего уникальных фич для UPSERT: %d", len(to_upsert))
# Догрузить detail (#1159 acceptance 2). Не валит прогон при сетевых ошибках.
enriched = _enrich_with_detail(to_upsert)
inserted = 0 inserted = 0
errors = 0 errors = 0
@ -179,11 +291,12 @@ def sync_okn_objects() -> dict[str, int]:
db.commit() db.commit()
logger.info( logger.info(
"okn_objects_sync: done inserted/updated=%d errors=%d", "okn_objects_sync: done inserted/updated=%d enriched=%d errors=%d",
inserted, inserted,
enriched,
errors, errors,
) )
return {"okn": inserted} return {"okn": inserted, "enriched": enriched}
__all__ = ["sync_okn_objects"] __all__ = ["sync_okn_objects"]

View file

@ -509,6 +509,7 @@ def _make_forecast(**over: object) -> DemandSupplyForecast:
"future_competitors": [{"obj_id": 1, "relevance_weight": 0.7}], "future_competitors": [{"obj_id": 1, "relevance_weight": 0.7}],
"advisory": True, "advisory": True,
"confidence": "medium", "confidence": "medium",
"confounded": False,
} }
base.update(over) base.update(over)
return DemandSupplyForecast(**base) # type: ignore[arg-type] return DemandSupplyForecast(**base) # type: ignore[arg-type]
@ -564,10 +565,13 @@ def _norm_stub(*, coefficient: float = 0.8, confidence: str = "high") -> MagicMo
return m return m
def _macro_coef_stub(*, coefficient: float = 1.1, confidence: str = "high") -> MagicMock: def _macro_coef_stub(
*, coefficient: float = 1.1, confidence: str = "high", confounded: bool = False
) -> MagicMock:
m = MagicMock() m = MagicMock()
m.coefficient = coefficient m.coefficient = coefficient
m.confidence = confidence m.confidence = confidence
m.confounded = confounded
return m return m

View file

@ -439,6 +439,43 @@ class TestConfidence:
assert "analog_count" in factors assert "analog_count" in factors
assert "domrf_coverage" in factors assert "domrf_coverage" in factors
def test_confounded_window_factor_surfaces_when_any_forecast_confounded(self) -> None:
# #1222 end-to-end: хотя бы один forecast.confounded=True → confounded_window
# фактор присутствует и тянет уровень в 'low' (#990: confounded fact-level low,
# weakest-link → итог 'low'; advisory cap ниже 'low' не двигает).
forecasts = _sample_forecasts()
forecasts[2]["confounded"] = True # 18-мес forecast confounded
report = assemble_report(
_sample_analyze(),
market_metrics=_sample_market_metrics(),
supply_layers=_sample_supply_layers(),
forecasts=forecasts,
future_supply=_sample_future_supply(),
scenarios=_sample_scenarios(),
recommendation_overlay=_sample_overlay(),
product_scores=_sample_product_scores(),
special_indices=_sample_special_indices(),
cad_num="66:41:0000000:1",
district="Верх-Исетский",
).as_dict()
factors = report["confidence"]["factors"]
assert "confounded_window" in factors, "шок-окно перманентно мёртв — #1222 регрессия"
assert factors["confounded_window"]["value"] is True
assert factors["confounded_window"]["level"] == "low"
# Weakest-link MIN → итоговый уровень тоже 'low' (был бы 'medium' без шока).
assert report["confidence"]["level"] == "low"
def test_confounded_window_factor_absent_when_no_forecast_confounded(self) -> None:
# Зеркальный кейс: все forecast'ы confounded=False → factor отсутствует
# (confidence_engine добавляет confounded factor ТОЛЬКО при True, line 450 —
# чистое окно не тянет искусственно вверх, иначе тонкие отчёты получили бы
# фантомный 'high'-вклад). Гарантия, что previous test НЕ false-positive
# (factor не «всегда low»), достигается тем, что предыдущий тест проверяет
# уровень: «low» доступен только если confounded factor реально добавлен.
conf = _full_assemble().as_dict()["confidence"]
factors = conf["factors"]
assert "confounded_window" not in factors
# ── exec_summary — синтез ───────────────────────────────────────────────────── # ── exec_summary — синтез ─────────────────────────────────────────────────────
@ -649,6 +686,69 @@ class TestSignalExtractionHelpers:
assert _confounded([{"confounded": False}]) is False assert _confounded([{"confounded": False}]) is False
assert _confounded([]) is False assert _confounded([]) is False
def test_confounded_missing_key_is_false_not_keyerror(self) -> None:
# #1222: forecast БЕЗ confounded-ключа НЕ должен ронять (.get() default → None
# ≠ True → False). Раньше .get() уже стоял, но это контракт-тест: гарантирует,
# что defensive-чтение НЕ деградирует в KeyError при произвольных формах.
assert _confounded([{}, {"horizon_months": 12}]) is False
assert _confounded([{"confounded": "no"}]) is False # truthy-but-not-True → False
assert _confounded([{"confounded": 1}]) is False # «is True» строго, не truthy
def test_confounded_reads_real_demand_supply_forecast_as_dict(self) -> None:
# #1222 regression: DemandSupplyForecast.as_dict() ОБЯЗАН нести ключ `confounded`,
# иначе шок-окно §15 (#990) перманентно мёртв. Строим РЕАЛЬНЫЙ frozen-dataclass
# БЕЗ БД, сериализуем через его собственный `as_dict()` и убеждаемся, что
# `_confounded` видит флаг (контракт-тест: ловит дрейф ключа продьюсера).
from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast
clean = DemandSupplyForecast(
segment={"obj_class": "комфорт"},
horizon_months=12,
base_pace_units_per_mo=8.0,
demand_norm_coefficient=1.0,
macro_coefficient=1.0,
projected_demand_units=100.0,
open_units=300,
hidden_release_units=80.0,
future_online_units=20.0,
projected_supply_units=400.0,
balance_units=-300.0,
balance_ratio=0.25,
deficit_index=-0.5,
months_of_inventory=48.0,
rate_future=18.0,
rate_sensitivity_phrase=None,
future_competitors=[],
advisory=True,
confidence="medium",
confounded=False,
).as_dict()
shock = DemandSupplyForecast(
segment={"obj_class": "комфорт"},
horizon_months=24,
base_pace_units_per_mo=8.0,
demand_norm_coefficient=1.0,
macro_coefficient=1.0,
projected_demand_units=200.0,
open_units=300,
hidden_release_units=80.0,
future_online_units=20.0,
projected_supply_units=400.0,
balance_units=-200.0,
balance_ratio=0.5,
deficit_index=-0.3,
months_of_inventory=24.0,
rate_future=18.0,
rate_sensitivity_phrase=None,
future_competitors=[],
advisory=True,
confidence="medium",
confounded=True,
).as_dict()
assert "confounded" in clean and "confounded" in shock # контракт ключа
assert _confounded([clean]) is False
assert _confounded([clean, shock]) is True
def test_primary_deficit_prefers_12mo(self) -> None: def test_primary_deficit_prefers_12mo(self) -> None:
forecasts = [ forecasts = [
{"horizon_months": 6, "deficit_index": 0.21}, {"horizon_months": 6, "deficit_index": 0.21},

View file

@ -1,4 +1,4 @@
"""Тесты клиента АИС ЕГРКН (#1066) — без живой сети. """Тесты клиента АИС ЕГРКН (#1066, #1159) — без живой сети.
Покрывает: Покрывает:
- coord swap: фейк-фича coordinates=[lat, lon] lon/lat для PostGIS (критично). - coord swap: фейк-фича coordinates=[lat, lon] lon/lat для PostGIS (критично).
@ -6,6 +6,8 @@
- Некорректный JSON / HTTP-ошибка пустой список, не raise. - Некорректный JSON / HTTP-ошибка пустой список, не raise.
- _build_form_data: формат jQuery serializeArray. - _build_form_data: формат jQuery serializeArray.
- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=). - form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=).
- fetch_okn_detail: HTML карточки {name, address, }; graceful при ошибках.
- _parse_detail_html: <dl>/<dt>/<dd> и <tr>/<td> структура парсится; маркеры label.
""" """
from __future__ import annotations from __future__ import annotations
@ -16,6 +18,8 @@ from unittest.mock import MagicMock, patch
from app.services.scrapers.okn_egrkn_client import ( from app.services.scrapers.okn_egrkn_client import (
_HEADERS, _HEADERS,
_build_form_data, _build_form_data,
_parse_detail_html,
fetch_okn_detail,
fetch_okn_points, fetch_okn_points,
) )
@ -114,6 +118,21 @@ def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock:
return resp return resp
def _mock_html_response(html: str, status: int = 200) -> MagicMock:
"""Создать мок httpx.Response с HTML-содержимым (для detail-эндпойнта)."""
resp = MagicMock()
resp.status_code = status
resp.text = html
resp.raise_for_status = MagicMock()
if status >= 400:
import httpx
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"error", request=MagicMock(), response=resp
)
return resp
def test_fetch_okn_points_returns_features() -> None: def test_fetch_okn_points_returns_features() -> None:
"""fetch_okn_points возвращает список фич из FeatureCollection.""" """fetch_okn_points возвращает список фич из FeatureCollection."""
features = [ features = [
@ -336,3 +355,130 @@ def test_build_form_data_category_type_as_string() -> None:
result = _build_form_data("Свердловская", 4) result = _build_form_data("Свердловская", 4)
values = dict(result) values = dict(result)
assert values["data[1][value]"] == "4" assert values["data[1][value]"] == "4"
# ── fetch_okn_detail / _parse_detail_html (#1159) ────────────────────────────
_DETAIL_HTML_DL = """
<html><body>
<dl class="okn-card">
<dt>Наименование</dt><dd>Дом Севастьянова</dd>
<dt>Адрес</dt><dd>г. Екатеринбург, пр. Ленина, 35</dd>
<dt>Категория историко-культурного значения</dt><dd>федерального</dd>
<dt>Вид объекта</dt><dd>памятник архитектуры</dd>
<dt>Датировка</dt><dd>1866</dd>
<dt>Документ о постановке на гос. охрану</dt><dd>Постановление Правительства РФ 527</dd>
<dt>Регистрационный номер</dt><dd>661510234190006</dd>
</dl>
</body></html>
"""
_DETAIL_HTML_TABLE = """
<html><body>
<table class="okn">
<tr><td>Наименование</td><td>Гостиница «Большой Урал»</td></tr>
<tr><td>Адрес</td><td>г. Екатеринбург, ул. Красноармейская, 1</td></tr>
<tr><td>Категория историко-культурного значения</td><td>регионального</td></tr>
</table>
</body></html>
"""
def test_parse_detail_html_dl_extracts_all_fields() -> None:
"""<dl><dt>/<dd> структура → все поля извлечены."""
result = _parse_detail_html(_DETAIL_HTML_DL)
assert result["name"] == "Дом Севастьянова"
assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
assert result["protection_category"] == "федерального"
assert result["object_kind"] == "памятник архитектуры"
assert result["dating"] == "1866"
assert result["protection_doc"] == "Постановление Правительства РФ № 527"
assert result["regnumber"] == "661510234190006"
def test_parse_detail_html_table_extracts_name_address() -> None:
"""<table><tr><td>label</td><td>value</td></tr> → парсится."""
result = _parse_detail_html(_DETAIL_HTML_TABLE)
assert result["name"] == "Гостиница «Большой Урал»"
assert result["address"] == "г. Екатеринбург, ул. Красноармейская, 1"
assert result["protection_category"] == "регионального"
def test_parse_detail_html_empty_returns_empty_dict() -> None:
"""Пустой/None HTML → {}."""
assert _parse_detail_html("") == {}
assert _parse_detail_html(" ") == {}
def test_parse_detail_html_no_recognized_fields_returns_empty() -> None:
"""HTML без полей-маркеров → пустой dict."""
html = "<html><body><p>404 not found</p></body></html>"
assert _parse_detail_html(html) == {}
def test_fetch_okn_detail_returns_parsed_fields_on_success() -> None:
"""GET карточки + парсинг → {name, address, …}."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
result = fetch_okn_detail("661510234190006")
assert result["name"] == "Дом Севастьянова"
assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
# GET был вызван с params={"id": "..."}.
mock_client.get.assert_called_once()
call_kwargs = mock_client.get.call_args.kwargs
assert call_kwargs.get("params") == {"id": "661510234190006"}
def test_fetch_okn_detail_http_error_returns_empty() -> None:
"""HTTP 500 на detail → пустой dict, не raise."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response("", status=500)
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
result = fetch_okn_detail("001")
assert result == {}
def test_fetch_okn_detail_empty_source_id_returns_empty() -> None:
"""Пустой source_id → пустой dict без HTTP-вызова."""
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client") as mock_cls:
result = fetch_okn_detail("")
assert result == {}
mock_cls.assert_not_called()
def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
"""SSL ConnectError на detail → retry с verify=False (как у searchMap)."""
import httpx
clients: list[dict] = []
def _make_client(**kwargs: Any) -> MagicMock:
verify = kwargs.get("verify", True)
clients.append({"verify": verify})
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
if verify:
mc.get.side_effect = httpx.ConnectError("ssl cert verify failed")
else:
mc.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
return mc
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
result = fetch_okn_detail("test-id")
assert result["name"] == "Дом Севастьянова"
assert len(clients) == 2
assert clients[0]["verify"] is True
assert clients[1]["verify"] is False

View file

@ -342,6 +342,57 @@ def test_total_sold_in_window_matches_deals_window() -> None:
assert resp.top_layouts[0].total_sold_in_window == int(deals) assert resp.top_layouts[0].total_sold_in_window == int(deals)
# ── Тесты Fix #1229: supply / velocity bucket vocabulary match ────────────────
def test_supply_velocity_buckets_match_for_2_rooms() -> None:
"""Fix #1229: supply bucket для 2-комн матчит velocity '2' независимо от площади.
Раньше supply отдельно вычислял euro-1/euro-2 для rooms=2 + area<50 эти
строки выпадали из знаменателя bucket '2' sold_pct/is_oversold двушек
были завышены. После фикса вся supply rooms=2 идёт в '2' и sold_pct
рассчитывается от полного supply.
Здесь моделируем что весь supply для '2' уже агрегирован SQL-стороной в
один (rb='2', ab='40-60') ряд: 20 deals_window против 50 supply 40% sold.
"""
deals = 20.0
id_rows = [_obj_id_row(7)]
vel_rows = [_vel_row("2", deals_window=deals, avg_area=45.0, obj_ids=[7])]
supply_rows = [_supply_row("2", "40-60", 50)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
row = resp.top_layouts[0]
assert row.room_bucket == "2"
assert row.supply_units_in_radius == 50
# 20 / 50 * 100 = 40.0 (раньше при euro-* разделении знаменатель был меньше
# → sold_pct > 40 или is_oversold=True).
assert row.sold_pct_of_supply == pytest.approx(40.0, rel=1e-3)
assert row.is_oversold is False
def test_supply_does_not_emit_euro_buckets() -> None:
"""Fix #1229: supply SQL больше НЕ содержит литералов 'euro-1' / 'euro-2'.
Regression guard: если кто-то восстанавливает SF-08 euro-биннинг в supply
без согласования с velocity-стороной (objective_corpus_room_month отдаёт
{studio,1,2,3,4+}, не euro-*), sold_pct двушек снова поедет.
"""
from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
sql_text = str(_SUPPLY_BATCH_SQL.text)
assert "'euro-1'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-1 — see #1229"
assert "'euro-2'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-2 — see #1229"
# ── Тесты _cap_and_redistribute (Fix SF-09 review) ─────────────────────────── # ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────

View file

@ -4,7 +4,8 @@ from __future__ import annotations
from typing import Any from typing import Any
from sqlalchemy.exc import OperationalError import pytest
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
from app.services.site_finder.ird_overlay_lookup import parcel_ird_overlaps from app.services.site_finder.ird_overlay_lookup import parcel_ird_overlaps
@ -102,8 +103,32 @@ def test_empty_when_no_overlap() -> None:
assert res == {"ird_overlaps": [], "ird_by_kind": {}} assert res == {"ird_overlaps": [], "ird_by_kind": {}}
def test_graceful_when_table_missing() -> None: @pytest.mark.parametrize(
"""ird_overlays ещё не задеплоена → пусто, без падения analyze.""" ("exc", "case"),
db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("no such table"))) [
(
OperationalError("stmt", {}, Exception("no such table")),
"operational-error: connection drop / БД недоступна",
),
(
ProgrammingError("stmt", {}, Exception("relation \"ird_overlays\" does not exist")),
"programming-error: UndefinedTable — pre-migration / D9b не задеплоен",
),
(
DataError("stmt", {}, Exception("parse error - invalid geometry")),
"data-error: malformed WKT → PostGIS ST_GeomFromText ERROR",
),
],
)
def test_graceful_when_db_fails(exc: Exception, case: str) -> None:
"""ird_overlays недоступна / malformed WKT → пусто, analyze не падает.
Покрывает:
- OperationalError БД недоступна.
- ProgrammingError таблица ещё не задеплоена (реальный класс при UndefinedTable).
- DataError malformed parcel_wkt (PostGIS ST_GeomFromText бросает ERROR,
SQLAlchemy конвертит в DataError).
"""
db = _FakeDB(raise_exc=exc)
res = parcel_ird_overlaps(db, _WKT) res = parcel_ird_overlaps(db, _WKT)
assert res == {"ird_overlaps": [], "ird_by_kind": {}} assert res == {"ird_overlaps": [], "ird_by_kind": {}}, case

View file

@ -256,6 +256,71 @@ def test_returns_types_and_features_counts() -> None:
assert result["features"] == 2 assert result["features"] == 2
def test_per_prefix_commit_durability() -> None:
"""db.commit вызван N+1 раз: после каждого из 8 префиксов + финальный (#1116).
Durability: WorkerLostError в середине прогона не теряет уже записанные префиксы.
"""
feat = _make_feature(zone_state_v="Действующий")
# 8 префиксов: первый с фичей, остальные пустые
feats_per_prefix: list[list[Any]] = [[feat]] + [[] for _ in range(7)]
call_idx: list[int] = [0]
def fake_by_cql(layer: str, cql_filter: str) -> list[Any]:
idx = call_idx[0]
call_idx[0] += 1
return feats_per_prefix[idx] if idx < len(feats_per_prefix) else []
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
with (
patch("app.workers.tasks.gknspecial_harvest.EKBGeoportalClient") as mock_client_cls,
patch("app.workers.tasks.gknspecial_harvest.SessionLocal", return_value=mock_db),
):
mock_client = MagicMock()
mock_client.features_by_cql.side_effect = fake_by_cql
mock_client_cls.return_value = mock_client
harvest_gknspecial_zones()
# 8 per-prefix commit + 1 финальный (подстраховка) = 9
assert mock_db.commit.call_count == 9
def test_per_prefix_commit_skipped_on_wfs_failure() -> None:
"""Если WFS падает на префиксе — commit за этот префикс не делается (continue).
Проверяем что commit'ов столько же, сколько успешно обработанных префиксов + 1 финальный.
"""
feat = _make_feature(zone_state_v="Действующий")
# 8 префиксов: первый успех, второй WFS-fail (sentinel), остальные успех
call_idx: list[int] = [0]
def fake_by_cql(layer: str, cql_filter: str) -> list[Any]:
idx = call_idx[0]
call_idx[0] += 1
if idx == 1:
raise RuntimeError("WFS timeout")
return [feat] if idx == 0 else []
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
with (
patch("app.workers.tasks.gknspecial_harvest.EKBGeoportalClient") as mock_client_cls,
patch("app.workers.tasks.gknspecial_harvest.SessionLocal", return_value=mock_db),
):
mock_client = MagicMock()
mock_client.features_by_cql.side_effect = fake_by_cql
mock_client_cls.return_value = mock_client
harvest_gknspecial_zones()
# 7 успешных префиксов × commit + 1 финальный = 8 (failed prefix через continue → нет commit)
assert mock_db.commit.call_count == 8
def test_unmatched_feature_written_to_sentinel() -> None: def test_unmatched_feature_written_to_sentinel() -> None:
"""Фича с full_name не из _PREFIX_MAP → пишется в unmatched (source_layer_id=990099).""" """Фича с full_name не из _PREFIX_MAP → пишется в unmatched (source_layer_id=990099)."""
feat = _make_feature( feat = _make_feature(

View file

@ -1,19 +1,31 @@
"""Тесты воркера okn_objects_sync (#1066) — без живой сети. """Тесты воркера okn_objects_sync (#1066, #1159) — без живой сети.
Покрывает: Покрывает:
- upsert идемпотентность: два вызова с одними source_id один insert.
- дедуп source_id across проходов: category берётся из первого (более приоритетного).
- _extract_coords: правильный порядок [lat, lon] (lon, lat) для PostGIS. - _extract_coords: правильный порядок [lat, lon] (lon, lat) для PostGIS.
- _extract_coords: невалидные / выходящие за bbox координаты None. - _extract_coords: невалидные / выходящие за bbox координаты None.
- sync_okn_objects: мок client + мок DB, проверка UPSERT-вызовов. - _PASSES: расширенный список адресных подстрок + декартово с категориями.
- sync_okn_objects: дедуп source_id across проходов (federal wins).
- sync_okn_objects: coverage фичи из district-passes добавляются к city-pass.
- sync_okn_objects: upsert идемпотентность.
- _enrich_with_detail: merge name/address в raw_props; пустой detail noop.
- _enrich_with_detail: detail-фейл (raise) не валит прогон.
- _enrich_with_detail: limit ограничивает число fetch'ей.
- parcel_okn_objects: name/address возвращаются из БД-строки.
""" """
from __future__ import annotations from __future__ import annotations
import json
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from app.workers.tasks.okn_objects_sync import _extract_coords from app.workers.tasks.okn_objects_sync import (
_ADDRESS_PASSES,
_CATEGORY_ORDER,
_PASSES,
_enrich_with_detail,
_extract_coords,
)
# ── _extract_coords ─────────────────────────────────────────────────────────── # ── _extract_coords ───────────────────────────────────────────────────────────
@ -109,6 +121,63 @@ def test_extract_coords_single_coordinate_returns_none() -> None:
assert _extract_coords(feature) is None assert _extract_coords(feature) is None
# ── _PASSES / _ADDRESS_PASSES структура (#1159 coverage) ──────────────────────
def test_address_passes_include_ekb_districts() -> None:
"""Поверх «Екатеринбург»/«Свердловская» добавлены районы ЕКБ.
Defect 1 (#1159): searchMap не возвращает объекты с адресом без слова
«Екатеринбург». Запросы по районам ловят такие записи.
"""
expected_districts = {
"Кировский",
"Ленинский",
"Октябрьский",
"Орджоникидзевский",
"Чкаловский",
"Верх-Исетский",
"Железнодорожный",
}
assert expected_districts <= set(_ADDRESS_PASSES), (
f"Не все районы ЕКБ в _ADDRESS_PASSES: missing="
f"{expected_districts - set(_ADDRESS_PASSES)}"
)
def test_address_passes_order_city_before_district() -> None:
"""Городские запросы идут раньше районных.
При дедупе source_id first-wins: если объект всплыл в проходе «Екатеринбург»
как federal, проход «Кировский» не должен перетереть его category.
"""
city_idx = _ADDRESS_PASSES.index("Екатеринбург")
district_idx = _ADDRESS_PASSES.index("Кировский")
assert city_idx < district_idx
def test_passes_cartesian_product_address_x_category() -> None:
"""_PASSES = декартово (address × category_order)."""
expected_size = len(_ADDRESS_PASSES) * len(_CATEGORY_ORDER)
assert len(_PASSES) == expected_size
# Каждый адрес покрыт всеми тремя категориями (federal/regional/local).
for addr in _ADDRESS_PASSES:
cats = [cat for a, cat in _PASSES if a == addr]
assert sorted(cats) == sorted(_CATEGORY_ORDER), f"{addr}: {cats}"
def test_passes_federal_before_local_per_address() -> None:
"""Для каждого address federal появляется раньше regional раньше local.
Это гарантирует что при конфликте source_id в одной address-страте
более «высокая» категория выигрывает.
"""
for addr in _ADDRESS_PASSES:
positions = [i for i, (a, _c) in enumerate(_PASSES) if a == addr]
cats_for_addr = [_PASSES[i][1] for i in positions]
assert cats_for_addr == _CATEGORY_ORDER, f"{addr}: {cats_for_addr}"
# ── sync_okn_objects: дедуп source_id ──────────────────────────────────────── # ── sync_okn_objects: дедуп source_id ────────────────────────────────────────
@ -120,155 +189,272 @@ def _make_feature_full(
return { return {
"id": feature_id, "id": feature_id,
"geometry": {"type": "Point", "coordinates": [lat, lon]}, "geometry": {"type": "Point", "coordinates": [lat, lon]},
"properties": {"name": f"ОКН-{feature_id}"}, "properties": {"id": feature_id},
} }
def _build_mock_db(captured: list[dict[str, Any]]) -> MagicMock:
mock_db = MagicMock()
mock_db.__enter__ = MagicMock(return_value=mock_db)
mock_db.__exit__ = MagicMock(return_value=False)
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
def _exec(sql: Any, row: Any = None) -> MagicMock:
if row:
captured.append(dict(row))
return MagicMock()
mock_db.execute = _exec
return mock_db
def test_sync_okn_objects_dedup_source_id_across_passes() -> None: def test_sync_okn_objects_dedup_source_id_across_passes() -> None:
"""Один source_id из нескольких проходов → category из первого прохода (federal wins).""" """Один source_id из нескольких проходов → category из первого прохода (federal wins)."""
from app.workers.tasks.okn_objects_sync import sync_okn_objects from app.workers.tasks.okn_objects_sync import sync_okn_objects
# «001» встречается в проходе (Екатеринбург, 1) = federal # «001» встречается в проходе (Екатеринбург, 1) = federal
# и в проходе (Екатеринбург, 2) = regional # и в проходе (Екатеринбург, 2) = regional → category должна быть federal.
# category должна быть federal feat = _make_feature_full("001")
pass_results: dict[tuple[str, int], list[dict[str, Any]]] = {
("Екатеринбург", 1): [_make_feature_full("001")],
("Екатеринбург", 2): [_make_feature_full("001")], # дубль
("Екатеринбург", 4): [],
("Свердловская", 1): [],
("Свердловская", 2): [],
("Свердловская", 4): [],
}
def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]: def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
return pass_results.get((address, category_type), []) if address == "Екатеринбург" and category_type in (1, 2):
return [feat]
return []
upserted_rows: list[dict[str, Any]] = [] upserted_rows: list[dict[str, Any]] = []
mock_db = _build_mock_db(upserted_rows)
mock_db = MagicMock()
mock_db.__enter__ = MagicMock(return_value=mock_db)
mock_db.__exit__ = MagicMock(return_value=False)
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
def _capture_execute(sql: Any, row: Any = None) -> MagicMock:
if row:
upserted_rows.append(dict(row))
return MagicMock()
mock_db.execute = _capture_execute
with ( with (
patch("app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=_mock_fetch), patch("app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=_mock_fetch),
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
):
result = sync_okn_objects()
# Один объект, один upsert
assert result == {"okn": 1}
assert len(upserted_rows) == 1
assert upserted_rows[0]["source_id"] == "001"
assert upserted_rows[0]["category"] == "federal"
def test_sync_okn_objects_upsert_idempotent() -> None:
"""Два вызова sync_okn_objects с одними source_id → два upsert (ON CONFLICT DO UPDATE в БД).
Тест проверяет что воркер передаёт все source_id для UPSERT при каждом прогоне
идемпотентность гарантирует SQL ON CONFLICT, не фильтрация в Python.
"""
from app.workers.tasks.okn_objects_sync import sync_okn_objects
features = [_make_feature_full("001"), _make_feature_full("002")]
pass_results: dict[tuple[str, int], list[dict[str, Any]]] = {
("Екатеринбург", 1): features,
("Екатеринбург", 2): [],
("Екатеринбург", 4): [],
("Свердловская", 1): [],
("Свердловская", 2): [],
("Свердловская", 4): [],
}
upsert_counts: list[int] = []
def _run_sync() -> dict[str, int]:
upserted: list[dict[str, Any]] = []
mock_db = MagicMock()
mock_db.__enter__ = MagicMock(return_value=mock_db)
mock_db.__exit__ = MagicMock(return_value=False)
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
def _exec(sql: Any, row: Any = None) -> MagicMock:
if row:
upserted.append(dict(row))
return MagicMock()
mock_db.execute = _exec
with (
patch(
"app.workers.tasks.okn_objects_sync.fetch_okn_points",
side_effect=lambda a, c: pass_results.get((a, c), []),
),
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
):
res = sync_okn_objects()
upsert_counts.append(len(upserted))
return res
r1 = _run_sync()
r2 = _run_sync()
assert r1 == {"okn": 2}
assert r2 == {"okn": 2}
# Оба прогона делают UPSERT (идемпотентность обеспечена SQL ON CONFLICT)
assert upsert_counts == [2, 2]
def test_sync_okn_objects_returns_total_unique_count() -> None:
"""sync_okn_objects возвращает {"okn": N} — число уникальных source_id после дедупа."""
from app.workers.tasks.okn_objects_sync import sync_okn_objects
# 3 уникальных + 1 дубль = 3 upsert
pass_results: dict[tuple[str, int], list[dict[str, Any]]] = {
("Екатеринбург", 1): [_make_feature_full("A"), _make_feature_full("B")],
("Екатеринбург", 2): [_make_feature_full("C"), _make_feature_full("A")], # A дубль
("Екатеринбург", 4): [],
("Свердловская", 1): [],
("Свердловская", 2): [],
("Свердловская", 4): [],
}
mock_db = MagicMock()
mock_db.__enter__ = MagicMock(return_value=mock_db)
mock_db.__exit__ = MagicMock(return_value=False)
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
with (
patch( patch(
"app.workers.tasks.okn_objects_sync.fetch_okn_points", "app.workers.tasks.okn_objects_sync.fetch_okn_detail",
side_effect=lambda a, c: pass_results.get((a, c), []), return_value={},
), ),
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db), patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
): ):
result = sync_okn_objects() result = sync_okn_objects()
assert result == {"okn": 3} # Один объект, один upsert
assert result == {"okn": 1, "enriched": 0}
assert len(upserted_rows) == 1
assert upserted_rows[0]["source_id"] == "001"
assert upserted_rows[0]["category"] == "federal"
# ── parcel_okn_objects lookup ───────────────────────────────────────────────── def test_sync_okn_objects_coverage_collects_from_multiple_address_passes() -> None:
"""Фичи, появляющиеся только в district-проходе, добавляются к city-проходу (#1159).
Симулируем реальный кейс: 1 объект ловится «Екатеринбург», 1 только
«Кировский» (улицный адрес без слова Екатеринбург), 1 только
«Свердловская» (за городом). После прогона все три попадают в upsert.
"""
from app.workers.tasks.okn_objects_sync import sync_okn_objects
city_feat = _make_feature_full("city-001")
district_feat = _make_feature_full("district-002")
region_feat = _make_feature_full("region-003")
def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
if category_type != 2: # для краткости — все «региональные»
return []
if address == "Екатеринбург":
return [city_feat]
if address == "Кировский":
return [city_feat, district_feat] # дубль city-001 + новый
if address == "Свердловская":
return [region_feat]
return []
upserted_rows: list[dict[str, Any]] = []
mock_db = _build_mock_db(upserted_rows)
with (
patch("app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=_mock_fetch),
patch(
"app.workers.tasks.okn_objects_sync.fetch_okn_detail",
return_value={},
),
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
):
result = sync_okn_objects()
source_ids = {r["source_id"] for r in upserted_rows}
assert source_ids == {"city-001", "district-002", "region-003"}, source_ids
assert result["okn"] == 3
def test_parcel_okn_objects_returns_nearby() -> None: def test_sync_okn_objects_upsert_idempotent() -> None:
"""parcel_okn_objects: мок DB → список [{source_id, category, distance_m}].""" """Два вызова с одними source_id → два upsert (ON CONFLICT в БД, не Python-фильтр)."""
from app.workers.tasks.okn_objects_sync import sync_okn_objects
features = [_make_feature_full("001"), _make_feature_full("002")]
def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
if address == "Екатеринбург" and category_type == 1:
return features
return []
counts: list[int] = []
def _run() -> dict[str, int]:
upserted: list[dict[str, Any]] = []
mock_db = _build_mock_db(upserted)
with (
patch(
"app.workers.tasks.okn_objects_sync.fetch_okn_points",
side_effect=_mock_fetch,
),
patch(
"app.workers.tasks.okn_objects_sync.fetch_okn_detail",
return_value={},
),
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
):
res = sync_okn_objects()
counts.append(len(upserted))
return res
r1 = _run()
r2 = _run()
assert r1["okn"] == 2
assert r2["okn"] == 2
assert counts == [2, 2]
def test_sync_okn_objects_returns_total_unique_count() -> None:
"""Возвращает {"okn": N, "enriched": M}: N уникальных, M обогащённых."""
from app.workers.tasks.okn_objects_sync import sync_okn_objects
feats_1 = [_make_feature_full("A"), _make_feature_full("B")]
feats_2 = [_make_feature_full("C"), _make_feature_full("A")] # A дубль
def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
if address == "Екатеринбург" and category_type == 1:
return feats_1
if address == "Екатеринбург" and category_type == 2:
return feats_2
return []
upserted_rows: list[dict[str, Any]] = []
mock_db = _build_mock_db(upserted_rows)
with (
patch(
"app.workers.tasks.okn_objects_sync.fetch_okn_points",
side_effect=_mock_fetch,
),
patch(
"app.workers.tasks.okn_objects_sync.fetch_okn_detail",
return_value={},
),
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
):
result = sync_okn_objects()
assert result["okn"] == 3
assert result["enriched"] == 0
# ── _enrich_with_detail (#1159 acceptance 2) ─────────────────────────────────
def _row(source_id: str, base_props: dict[str, Any] | None = None) -> dict[str, Any]:
return {
"source_id": source_id,
"category": "regional",
"lon": 60.61,
"lat": 56.83,
"raw_props": json.dumps(base_props or {"id": source_id}, ensure_ascii=False),
}
def test_enrich_merges_name_address_into_raw_props() -> None:
"""detail (name, address, …) подмёрджен в raw_props; baseline id сохранён."""
rows = [_row("001"), _row("002")]
details = {
"001": {"name": "Дом Севастьянова", "address": "пр. Ленина, 35"},
"002": {"name": "Гостиница «Большой Урал»", "address": "ул. Красноармейская, 1"},
}
enriched = _enrich_with_detail(rows, detail_fetcher=lambda sid: details[sid])
assert enriched == 2
parsed_0 = json.loads(rows[0]["raw_props"])
assert parsed_0 == {"id": "001", "name": "Дом Севастьянова", "address": "пр. Ленина, 35"}
parsed_1 = json.loads(rows[1]["raw_props"])
assert parsed_1["address"] == "ул. Красноармейская, 1"
assert parsed_1["id"] == "002"
def test_enrich_with_empty_detail_keeps_baseline() -> None:
"""Пустой detail → raw_props не меняется, enriched=0."""
rows = [_row("001")]
original = rows[0]["raw_props"]
enriched = _enrich_with_detail(rows, detail_fetcher=lambda _sid: {})
assert enriched == 0
assert rows[0]["raw_props"] == original
def test_enrich_tolerates_detail_exception() -> None:
"""Detail-fetcher raises → строка пропускается, прогон не падает."""
rows = [_row("001"), _row("002")]
def _fetcher(source_id: str) -> dict[str, Any]:
if source_id == "001":
raise RuntimeError("network down")
return {"name": "OK", "address": "Х"}
enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher)
assert enriched == 1
# 001 baseline, 002 enriched
parsed_1 = json.loads(rows[1]["raw_props"])
assert parsed_1["name"] == "OK"
def test_enrich_respects_limit() -> None:
"""limit=N → только первые N source_id запрошены."""
rows = [_row(f"{i:03d}") for i in range(10)]
calls: list[str] = []
def _fetcher(source_id: str) -> dict[str, Any]:
calls.append(source_id)
return {"name": f"N-{source_id}"}
enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher, limit=3)
assert enriched == 3
assert len(calls) == 3
assert calls == ["000", "001", "002"]
def test_enrich_handles_non_dict_base_props() -> None:
"""raw_props с битым JSON (не dict) → пересоздаётся как пустой dict + merge detail."""
row = {
"source_id": "001",
"category": "regional",
"lon": 60.61,
"lat": 56.83,
"raw_props": json.dumps(["broken", "list"]), # не dict
}
enriched = _enrich_with_detail(
[row], detail_fetcher=lambda _sid: {"name": "X", "address": "Y"}
)
assert enriched == 1
parsed = json.loads(row["raw_props"])
assert parsed == {"name": "X", "address": "Y"}
# ── parcel_okn_objects lookup (#1159 acceptance 2: name/address) ─────────────
def test_parcel_okn_objects_returns_nearby_with_name_address() -> None:
"""parcel_okn_objects: мок DB → [{source_id, category, distance_m, name, address}]."""
from app.services.site_finder.okn_lookup import parcel_okn_objects from app.services.site_finder.okn_lookup import parcel_okn_objects
rows = [ rows = [
("ekb-001", "federal", 42), ("ekb-001", "federal", 42, "Дом Севастьянова", "пр. Ленина, 35"),
("ekb-002", "regional", 95), ("ekb-002", "regional", 95, None, None), # detail не догружен
] ]
mock_result = MagicMock() mock_result = MagicMock()
@ -280,8 +466,16 @@ def test_parcel_okn_objects_returns_nearby() -> None:
result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))", radius_m=100) result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))", radius_m=100)
assert len(result) == 2 assert len(result) == 2
assert result[0] == {"source_id": "ekb-001", "category": "federal", "distance_m": 42} assert result[0] == {
assert result[1] == {"source_id": "ekb-002", "category": "regional", "distance_m": 95} "source_id": "ekb-001",
"category": "federal",
"distance_m": 42,
"name": "Дом Севастьянова",
"address": "пр. Ленина, 35",
}
assert result[1]["name"] is None
assert result[1]["address"] is None
assert result[1]["source_id"] == "ekb-002"
def test_parcel_okn_objects_empty_wkt_returns_empty() -> None: def test_parcel_okn_objects_empty_wkt_returns_empty() -> None:
@ -345,10 +539,10 @@ def test_parcel_okn_objects_no_results_returns_empty() -> None:
def test_parcel_okn_objects_distance_mapping() -> None: def test_parcel_okn_objects_distance_mapping() -> None:
"""distance_m берётся из колонки [2] (третий элемент row-кортежа).""" """distance_m / name / address берутся из колонок [2..4] (порядок SELECT)."""
from app.services.site_finder.okn_lookup import parcel_okn_objects from app.services.site_finder.okn_lookup import parcel_okn_objects
rows = [("id-x", "local", 7)] rows = [("id-x", "local", 7, "Памятник Х", "ул. Y")]
mock_result = MagicMock() mock_result = MagicMock()
mock_result.fetchall.return_value = rows mock_result.fetchall.return_value = rows
@ -359,3 +553,5 @@ def test_parcel_okn_objects_distance_mapping() -> None:
assert result[0]["distance_m"] == 7 assert result[0]["distance_m"] == 7
assert result[0]["source_id"] == "id-x" assert result[0]["source_id"] == "id-x"
assert result[0]["category"] == "local" assert result[0]["category"] == "local"
assert result[0]["name"] == "Памятник Х"
assert result[0]["address"] == "ул. Y"

View file

@ -5,7 +5,7 @@
* *
* Avatar-кнопка с инициалом из username + dropdown: * Avatar-кнопка с инициалом из username + dropdown:
* - текущий username * - текущий username
* - role badge («Админ» / «Пилот») * - role badge («Админ» / «Пилот» / «Аналитик»)
* - кнопка «Выйти» invalidate Caddy basic_auth cached creds в browser * - кнопка «Выйти» invalidate Caddy basic_auth cached creds в browser
* *
* Используется в `TopNav.rightSlot`. Если /me грузится или 401 (dev без * Используется в `TopNav.rightSlot`. Если /me грузится или 401 (dev без
@ -24,7 +24,18 @@ import { useEffect, useRef, useState } from "react";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { useMe } from "@/lib/useMe"; import { useMe, type Role } from "@/lib/useMe";
/**
* Локализация ролей для badge. Должен покрывать все варианты `Role` из
* `lib/useMe.ts` TypeScript-проверка через `Record<Role, string>`
* сломает build, если backend добавит новую роль без обновления map.
*/
const ROLE_LABELS: Record<Role, string> = {
admin: "Админ",
pilot: "Пилот",
analyst: "Аналитик",
};
/** /**
* Logout invalidate basic_auth cache + reload. * Logout invalidate basic_auth cache + reload.
@ -89,7 +100,7 @@ export function UserMenu() {
if (isLoading || error || !data) return null; if (isLoading || error || !data) return null;
const initial = data.username[0]?.toUpperCase() ?? "?"; const initial = data.username[0]?.toUpperCase() ?? "?";
const roleLabel = data.role === "admin" ? "Админ" : "Пилот"; const roleLabel = ROLE_LABELS[data.role];
return ( return (
<div ref={containerRef} style={{ position: "relative" }}> <div ref={containerRef} style={{ position: "relative" }}>

View file

@ -25,6 +25,25 @@ interface FormState {
message: string; message: string;
} }
type FieldName = keyof FormState;
type FieldErrors = Partial<Record<FieldName, string>>;
// ── Schema mirror ──────────────────────────────────────────────────────────
// Зеркалирует backend Pydantic-схему `PilotRequestInput`
// (`backend/app/api/v1/pilot.py:25-34`). При изменении на бэкенде —
// синхронизировать здесь, иначе лид получит 422 и сырой JSON в модалке.
export const PILOT_LIMITS = {
name: { min: 2, max: 200 },
phone: { max: 50 },
email: { max: 200 },
company: { max: 200 },
message: { max: 2000 },
} as const;
// Тот же паттерн, что и в Pydantic Field(pattern=...) на бэкенде
// (`backend/app/api/v1/pilot.py:31`).
export const PILOT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
/** Derives a human-readable tracking ID from the UUID string returned by backend. /** Derives a human-readable tracking ID from the UUID string returned by backend.
@ -34,6 +53,85 @@ function deriveTrackingId(id: string): string {
return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase(); return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase();
} }
/**
* Валидирует одно поле против ограничений схемы. Возвращает текст ошибки
* (понятный пользователю) или пустую строку, если поле валидно.
*
* Экспортится для unit-тестов и потенциального переиспользования.
*/
export function validatePilotField(name: FieldName, value: string): string {
const trimmed = value.trim();
switch (name) {
case "name":
if (!trimmed) return "Укажите имя";
if (trimmed.length < PILOT_LIMITS.name.min)
return `Минимум ${PILOT_LIMITS.name.min} символа`;
if (trimmed.length > PILOT_LIMITS.name.max)
return `Максимум ${PILOT_LIMITS.name.max} символов`;
return "";
case "phone":
if (trimmed.length > PILOT_LIMITS.phone.max)
return `Максимум ${PILOT_LIMITS.phone.max} символов`;
return "";
case "email":
if (!trimmed) return "";
if (trimmed.length > PILOT_LIMITS.email.max)
return `Максимум ${PILOT_LIMITS.email.max} символов`;
if (!PILOT_EMAIL_RE.test(trimmed)) return "Некорректный email";
return "";
case "company":
if (trimmed.length > PILOT_LIMITS.company.max)
return `Максимум ${PILOT_LIMITS.company.max} символов`;
return "";
case "message":
if (trimmed.length > PILOT_LIMITS.message.max)
return `Максимум ${PILOT_LIMITS.message.max} символов`;
return "";
default:
return "";
}
}
/** Валидирует все поля разом. Пустой объект — форма валидна. */
export function validatePilotForm(form: FormState): FieldErrors {
const errors: FieldErrors = {};
(Object.keys(form) as FieldName[]).forEach((key) => {
const err = validatePilotField(key, form[key]);
if (err) errors[key] = err;
});
return errors;
}
/**
* Дружелюбное сообщение об ошибке отправки.
* apiFetch (`lib/api.ts:39`) кладёт сырой response.text() в Error.message
* как «API error 422: {"detail":[...]}». На публичной лид-форме сырой JSON
* показывать нельзя поэтому мапим коды в человеческий текст. На 422 это
* fallback при рассинхроне фронт/бэк валидации; клиент-сайд должен ловить
* это до отправки.
*/
export function formatSubmitError(err: unknown): string {
const fallback = "Не удалось отправить заявку. Попробуйте позже.";
if (!(err instanceof Error)) return fallback;
const match = err.message.match(/^API error (\d{3}):\s*(.*)$/s);
if (!match) return fallback;
const status = Number(match[1]);
const body = match[2];
if (status === 422) {
return "Проверьте корректность заполнения полей.";
}
if (status >= 500) {
return "Сервер недоступен. Попробуйте позже.";
}
try {
const parsed = JSON.parse(body) as { detail?: unknown };
if (typeof parsed.detail === "string") return parsed.detail;
} catch {
// not JSON — fall through to fallback
}
return fallback;
}
// ── Component ────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────
export function PilotRequestModal({ open, onClose }: Props) { export function PilotRequestModal({ open, onClose }: Props) {
@ -44,28 +142,48 @@ export function PilotRequestModal({ open, onClose }: Props) {
company: "", company: "",
message: "", message: "",
}); });
const [emailError, setEmailError] = useState<string>(""); const [errors, setErrors] = useState<FieldErrors>({});
const [trackingId, setTrackingId] = useState<string | null>(null); const [trackingId, setTrackingId] = useState<string | null>(null);
const mutation = useSubmitPilotRequest(); const mutation = useSubmitPilotRequest();
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function handleChange( function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) { ) {
const { name, value } = e.target; const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value })); const fieldName = name as FieldName;
if (name === "email") { setForm((prev) => ({ ...prev, [fieldName]: value }));
setEmailError(value && !EMAIL_RE.test(value) ? "Некорректный email" : ""); // Live re-validate только для полей где ошибка уже показана (клиент сразу
} // видит, что фикс «помог»), плюс email — исторически на онлайн-проверке.
setErrors((prev) => {
if (fieldName !== "email" && !prev[fieldName]) return prev;
const err = validatePilotField(fieldName, value);
const next: FieldErrors = { ...prev };
if (err) next[fieldName] = err;
else delete next[fieldName];
return next;
});
}
function handleBlur(
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
const { name, value } = e.target;
const fieldName = name as FieldName;
const err = validatePilotField(fieldName, value);
setErrors((prev) => {
const next: FieldErrors = { ...prev };
if (err) next[fieldName] = err;
else delete next[fieldName];
return next;
});
} }
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
if (!form.name.trim()) return; const validation = validatePilotForm(form);
if (form.email && !EMAIL_RE.test(form.email)) { if (Object.keys(validation).length > 0) {
setEmailError("Некорректный email"); setErrors(validation);
return; return;
} }
@ -76,7 +194,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
...(form.email.trim() ? { email: form.email.trim() } : {}), ...(form.email.trim() ? { email: form.email.trim() } : {}),
...(form.company.trim() ? { company: form.company.trim() } : {}), ...(form.company.trim() ? { company: form.company.trim() } : {}),
...(form.message.trim() ...(form.message.trim()
? { message: form.message.trim().slice(0, 2000) } ? { message: form.message.trim().slice(0, PILOT_LIMITS.message.max) }
: {}), : {}),
}; };
@ -89,12 +207,15 @@ export function PilotRequestModal({ open, onClose }: Props) {
function handleClose() { function handleClose() {
setForm({ name: "", phone: "", email: "", company: "", message: "" }); setForm({ name: "", phone: "", email: "", company: "", message: "" });
setEmailError(""); setErrors({});
setTrackingId(null); setTrackingId(null);
mutation.reset(); mutation.reset();
onClose(); onClose();
} }
const hasBlockingErrors = Object.keys(validatePilotForm(form)).length > 0;
const submitDisabled = mutation.isPending || hasBlockingErrors;
return ( return (
<Drawer open={open} onClose={handleClose} side="bottom"> <Drawer open={open} onClose={handleClose} side="bottom">
{/* Header */} {/* Header */}
@ -191,10 +312,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
name="name" name="name"
value={form.name} value={form.name}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur}
required required
minLength={PILOT_LIMITS.name.min}
maxLength={PILOT_LIMITS.name.max}
placeholder="Алексей Кириллов" placeholder="Алексей Кириллов"
style={inputStyle} aria-invalid={errors.name ? "true" : "false"}
aria-describedby={errors.name ? "pilot-name-error" : undefined}
style={{
...inputStyle,
borderColor: errors.name
? "var(--danger)"
: "var(--border-strong)",
}}
/> />
{errors.name ? (
<span id="pilot-name-error" style={fieldErrorStyle}>
{errors.name}
</span>
) : null}
</div> </div>
{/* Company */} {/* Company */}
@ -207,9 +343,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
name="company" name="company"
value={form.company} value={form.company}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur}
maxLength={PILOT_LIMITS.company.max}
placeholder="PRINZIP" placeholder="PRINZIP"
style={inputStyle} aria-invalid={errors.company ? "true" : "false"}
aria-describedby={
errors.company ? "pilot-company-error" : undefined
}
style={{
...inputStyle,
borderColor: errors.company
? "var(--danger)"
: "var(--border-strong)",
}}
/> />
{errors.company ? (
<span id="pilot-company-error" style={fieldErrorStyle}>
{errors.company}
</span>
) : null}
</div> </div>
{/* Phone */} {/* Phone */}
@ -223,9 +375,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
type="tel" type="tel"
value={form.phone} value={form.phone}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur}
maxLength={PILOT_LIMITS.phone.max}
placeholder="+7 900 000-00-00" placeholder="+7 900 000-00-00"
style={inputStyle} aria-invalid={errors.phone ? "true" : "false"}
aria-describedby={
errors.phone ? "pilot-phone-error" : undefined
}
style={{
...inputStyle,
borderColor: errors.phone
? "var(--danger)"
: "var(--border-strong)",
}}
/> />
{errors.phone ? (
<span id="pilot-phone-error" style={fieldErrorStyle}>
{errors.phone}
</span>
) : null}
</div> </div>
{/* Email */} {/* Email */}
@ -239,24 +407,23 @@ export function PilotRequestModal({ open, onClose }: Props) {
type="email" type="email"
value={form.email} value={form.email}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur}
maxLength={PILOT_LIMITS.email.max}
placeholder="alex@prinzip.ru" placeholder="alex@prinzip.ru"
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={
errors.email ? "pilot-email-error" : undefined
}
style={{ style={{
...inputStyle, ...inputStyle,
borderColor: emailError borderColor: errors.email
? "var(--danger)" ? "var(--danger)"
: "var(--border-strong)", : "var(--border-strong)",
}} }}
/> />
{emailError ? ( {errors.email ? (
<span <span id="pilot-email-error" style={fieldErrorStyle}>
style={{ {errors.email}
fontSize: 12,
color: "var(--danger)",
marginTop: 4,
display: "block",
}}
>
{emailError}
</span> </span>
) : null} ) : null}
</div> </div>
@ -271,14 +438,22 @@ export function PilotRequestModal({ open, onClose }: Props) {
name="message" name="message"
value={form.message} value={form.message}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur}
placeholder="Расскажите о вашем проекте или вопросе..." placeholder="Расскажите о вашем проекте или вопросе..."
rows={4} rows={4}
maxLength={2000} maxLength={PILOT_LIMITS.message.max}
aria-invalid={errors.message ? "true" : "false"}
aria-describedby={
errors.message ? "pilot-message-error" : undefined
}
style={{ style={{
...inputStyle, ...inputStyle,
resize: "vertical", resize: "vertical",
minHeight: 96, minHeight: 96,
fontFamily: "inherit", fontFamily: "inherit",
borderColor: errors.message
? "var(--danger)"
: "var(--border-strong)",
}} }}
/> />
<span <span
@ -289,13 +464,19 @@ export function PilotRequestModal({ open, onClose }: Props) {
display: "block", display: "block",
}} }}
> >
{form.message.length} / 2000 {form.message.length} / {PILOT_LIMITS.message.max}
</span> </span>
{errors.message ? (
<span id="pilot-message-error" style={fieldErrorStyle}>
{errors.message}
</span>
) : null}
</div> </div>
{/* Error */} {/* Error */}
{mutation.isError ? ( {mutation.isError ? (
<div <div
role="alert"
style={{ style={{
padding: "10px 14px", padding: "10px 14px",
background: "var(--danger-soft)", background: "var(--danger-soft)",
@ -304,35 +485,26 @@ export function PilotRequestModal({ open, onClose }: Props) {
color: "var(--danger)", color: "var(--danger)",
}} }}
> >
{mutation.error instanceof Error {formatSubmitError(mutation.error)}
? mutation.error.message
: "Ошибка отправки. Попробуйте позже."}
</div> </div>
) : null} ) : null}
{/* Submit */} {/* Submit */}
<button <button
type="submit" type="submit"
disabled={mutation.isPending || !form.name.trim()} disabled={submitDisabled}
style={{ style={{
padding: "12px 24px", padding: "12px 24px",
background: background: submitDisabled
mutation.isPending || !form.name.trim() ? "var(--accent-soft)"
? "var(--accent-soft)" : "var(--accent)",
: "var(--accent)", color: submitDisabled ? "var(--accent)" : "#fff",
color:
mutation.isPending || !form.name.trim()
? "var(--accent)"
: "#fff",
border: "none", border: "none",
borderRadius: 8, borderRadius: 8,
fontSize: 15, fontSize: 15,
fontWeight: 600, fontWeight: 600,
cursor: cursor: submitDisabled ? "not-allowed" : "pointer",
mutation.isPending || !form.name.trim() opacity: submitDisabled ? 0.7 : 1,
? "not-allowed"
: "pointer",
opacity: mutation.isPending || !form.name.trim() ? 0.7 : 1,
transition: "opacity 0.15s", transition: "opacity 0.15s",
}} }}
> >
@ -369,3 +541,10 @@ const inputStyle: React.CSSProperties = {
outline: "none", outline: "none",
boxSizing: "border-box" as const, boxSizing: "border-box" as const,
}; };
const fieldErrorStyle: React.CSSProperties = {
fontSize: 12,
color: "var(--danger)",
marginTop: 4,
display: "block",
};

View file

@ -0,0 +1,254 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, beforeEach, afterEach } from "vitest";
import {
PILOT_EMAIL_RE,
PILOT_LIMITS,
PilotRequestModal,
formatSubmitError,
validatePilotField,
validatePilotForm,
} from "../PilotRequestModal";
// ── Schema parity with backend Pydantic PilotRequestInput ─────────────────
// `backend/app/api/v1/pilot.py` defines:
// name: min_length=2, max_length=200
// phone: max_length=50
// email: max_length=200, pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
// company: max_length=200
// message: max_length=2000
describe("PilotRequestModal — schema mirror", () => {
it("PILOT_LIMITS matches backend constraints", () => {
expect(PILOT_LIMITS).toEqual({
name: { min: 2, max: 200 },
phone: { max: 50 },
email: { max: 200 },
company: { max: 200 },
message: { max: 2000 },
});
});
it("PILOT_EMAIL_RE matches backend Pydantic pattern", () => {
// Source-of-truth string from pilot.py:31
expect(PILOT_EMAIL_RE.source).toBe("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
});
});
describe("validatePilotField — name", () => {
it("rejects empty name", () => {
expect(validatePilotField("name", "")).toBe("Укажите имя");
expect(validatePilotField("name", " ")).toBe("Укажите имя");
});
it("rejects single-char name (matches backend min_length=2)", () => {
// Это центральный кейс issue #1240: «Я» проходило старую проверку и упиралось в 422.
expect(validatePilotField("name", "Я")).toBe("Минимум 2 символа");
});
it("accepts 2+ char name", () => {
expect(validatePilotField("name", "Ян")).toBe("");
expect(validatePilotField("name", "Алексей Кириллов")).toBe("");
});
it("rejects >200 char name", () => {
const long = "А".repeat(201);
expect(validatePilotField("name", long)).toBe("Максимум 200 символов");
});
it("accepts exactly 200 char name", () => {
expect(validatePilotField("name", "А".repeat(200))).toBe("");
});
});
describe("validatePilotField — phone", () => {
it("accepts empty / short phone", () => {
expect(validatePilotField("phone", "")).toBe("");
expect(validatePilotField("phone", "+7 900 000-00-00")).toBe("");
});
it("rejects >50 char phone", () => {
expect(validatePilotField("phone", "+".repeat(51))).toBe(
"Максимум 50 символов",
);
});
});
describe("validatePilotField — email", () => {
it("accepts empty email (optional field)", () => {
expect(validatePilotField("email", "")).toBe("");
});
it("rejects malformed email", () => {
expect(validatePilotField("email", "no-at-sign")).toBe("Некорректный email");
expect(validatePilotField("email", "user@")).toBe("Некорректный email");
expect(validatePilotField("email", "user@host")).toBe("Некорректный email");
expect(validatePilotField("email", "@host.ru")).toBe("Некорректный email");
});
it("accepts well-formed email", () => {
expect(validatePilotField("email", "alex@prinzip.ru")).toBe("");
expect(validatePilotField("email", "a.b+tag@example.co.uk")).toBe("");
});
it("rejects >200 char email", () => {
// 196 a-chars + "@b.ru" (5) = 201 chars total → over the limit.
const tooLong = "a".repeat(196) + "@b.ru";
expect(tooLong.length).toBe(201);
expect(validatePilotField("email", tooLong)).toBe("Максимум 200 символов");
});
});
describe("validatePilotField — company / message", () => {
it("rejects >200 char company", () => {
expect(validatePilotField("company", "x".repeat(201))).toBe(
"Максимум 200 символов",
);
});
it("rejects >2000 char message", () => {
expect(validatePilotField("message", "x".repeat(2001))).toBe(
"Максимум 2000 символов",
);
});
});
describe("validatePilotForm — composite", () => {
it("returns empty object for valid form", () => {
expect(
validatePilotForm({
name: "Алексей",
phone: "+7 900 000-00-00",
email: "alex@prinzip.ru",
company: "PRINZIP",
message: "Здравствуйте",
}),
).toEqual({});
});
it("returns error map for invalid form", () => {
const errors = validatePilotForm({
name: "Я",
phone: "",
email: "bad-email",
company: "",
message: "",
});
expect(errors.name).toBe("Минимум 2 символа");
expect(errors.email).toBe("Некорректный email");
expect(errors.phone).toBeUndefined();
});
});
describe("formatSubmitError — friendly 422 mapping", () => {
it("returns generic fallback for non-Error", () => {
expect(formatSubmitError(null)).toMatch(/Не удалось/);
expect(formatSubmitError("string")).toMatch(/Не удалось/);
});
it("maps 422 to friendly text instead of raw FastAPI JSON", () => {
// Это центральный кейс issue #1240: apiFetch кидает «API error 422: {...}»
// и модалка раньше рендерила JSON verbatim. Теперь — человеческий текст.
const raw = `API error 422: {"detail":[{"type":"string_too_short","loc":["body","name"],"msg":"String should have at least 2 characters","input":"Я","ctx":{"min_length":2}}]}`;
expect(formatSubmitError(new Error(raw))).toBe(
"Проверьте корректность заполнения полей.",
);
});
it("maps 500 to friendly fallback", () => {
expect(formatSubmitError(new Error("API error 500: boom"))).toMatch(
/Сервер недоступен/,
);
});
it("uses detail field if 4xx body is JSON with string detail", () => {
const raw = `API error 400: {"detail":"Custom backend message"}`;
expect(formatSubmitError(new Error(raw))).toBe("Custom backend message");
});
it("returns generic fallback on non-API-error message", () => {
expect(formatSubmitError(new Error("network error"))).toMatch(/Не удалось/);
});
});
// ── Component-level integration ─────────────────────────────────────────
function renderModal() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<PilotRequestModal open={true} onClose={() => {}} />
</QueryClientProvider>,
);
}
describe("PilotRequestModal — component behaviour", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(
() => new Promise(() => {}),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("disables submit until name passes min_length", async () => {
const user = userEvent.setup();
renderModal();
const submit = screen.getByRole("button", { name: /Отправить заявку/i });
expect(submit).toBeDisabled();
const nameInput = screen.getByLabelText(/Имя/i);
await user.type(nameInput, "Я");
expect(submit).toBeDisabled();
await user.type(nameInput, "н");
expect(submit).toBeEnabled();
});
it("shows inline name error on blur with 1-char name (issue #1240 trace)", async () => {
renderModal();
const nameInput = screen.getByLabelText(/Имя/i) as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "Я" } });
fireEvent.blur(nameInput);
expect(await screen.findByText(/Минимум 2 символа/i)).toBeInTheDocument();
// Submit should be blocked — no raw JSON 422 from backend reaches the user.
expect(
screen.getByRole("button", { name: /Отправить заявку/i }),
).toBeDisabled();
});
it("shows email error live when user types invalid value", async () => {
renderModal();
const nameInput = screen.getByLabelText(/Имя/i);
fireEvent.change(nameInput, { target: { value: "Алексей" } });
const emailInput = screen.getByLabelText(/Email/i) as HTMLInputElement;
fireEvent.change(emailInput, { target: { value: "no-at" } });
expect(await screen.findByText(/Некорректный email/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Отправить заявку/i }),
).toBeDisabled();
});
it("sets HTML attrs mirroring schema (minLength/maxLength)", () => {
renderModal();
expect(screen.getByLabelText(/Имя/i)).toHaveAttribute("minLength", "2");
expect(screen.getByLabelText(/Имя/i)).toHaveAttribute("maxLength", "200");
expect(screen.getByLabelText(/Компания/i)).toHaveAttribute(
"maxLength",
"200",
);
expect(screen.getByLabelText(/Телефон/i)).toHaveAttribute(
"maxLength",
"50",
);
expect(screen.getByLabelText(/Email/i)).toHaveAttribute("maxLength", "200");
expect(screen.getByLabelText(/Сообщение/i)).toHaveAttribute(
"maxLength",
"2000",
);
});
});

View file

@ -4,7 +4,7 @@
* `useMe()` TanStack-хук для GET /api/v1/me. * `useMe()` TanStack-хук для GET /api/v1/me.
* *
* Backend возвращает `UserScope` (см. `backend/app/core/auth.py`): * Backend возвращает `UserScope` (см. `backend/app/core/auth.py`):
* { username, role: "admin"|"pilot", allowed_paths: string[], deny_paths: string[] } * { username, role: "admin"|"pilot"|"analyst", allowed_paths: string[], deny_paths: string[] }
* *
* Failure modes: * Failure modes:
* - 401 нет X-Authenticated-User (локально без Caddy basic_auth). Фронт * - 401 нет X-Authenticated-User (локально без Caddy basic_auth). Фронт
@ -26,7 +26,10 @@ import { apiFetchWithStatus, HTTPError } from "@/lib/api";
// статус через `error instanceof HTTPError` — это работает в runtime независимо // статус через `error instanceof HTTPError` — это работает в runtime независимо
// от type-level decl. // от type-level decl.
export type Role = "admin" | "pilot"; // Должен совпадать с `Role` в `backend/app/core/auth.py` (Literal["admin", "pilot", "analyst"]).
// Источник правды — `auth/roles.yaml`; при добавлении новой роли расширить тут и
// `ROLE_LABELS` в `components/auth/UserMenu.tsx`.
export type Role = "admin" | "pilot" | "analyst";
export interface UserScope { export interface UserScope {
username: string; username: string;