fix(p3-batch): восстановление 13 Fable 5 P3-fixes из lost branches #1301
26 changed files with 1706 additions and 289 deletions
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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("", "")
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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": {}}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 '—' }}<br/>
|
{{ district or '—' }}<br/>
|
||||||
{{ address or '—' }}
|
{{ 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 '—' }}</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 '—' }}</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 '—' }}</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 '—' }}</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 '—' }}</td>
|
<td>{{ c.comm_name or '—' }}</td>
|
||||||
<td>{{ c.dev_name or '—' }}</td>
|
<td>{{ c.dev_name or '—' }}</td>
|
||||||
<td>{{ c.obj_class or '—' }}</td>
|
<td>{{ c.obj_class or '—' }}</td>
|
||||||
<td>{{ c.flat_count or '—' }}</td>
|
<td>{{ c.flat_count or '—' }}</td>
|
||||||
<td>{{ c.distance_m }} м</td>
|
<td>{{ c.distance_m }} м</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ Sentinel source_layer_id'ы (вне диапазона НСПД): 990001–99000
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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" }}>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue