diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 7912880c..f63526cb 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -535,10 +535,19 @@ class LayoutTzRecommendation(BaseModel): class LayoutDataQuality(BaseModel): - """Метаданные качества данных (coverage).""" + """Метаданные качества данных (coverage). - objects_with_velocity_data: int - objects_total_in_radius: int + Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id. + domrf_kn_objects дробит один ЖК на несколько obj_id (разные очереди / + дубли snapshot-строк / гео-кучки безымянных корпусов), из-за чего сырой + знаменатель завышал число «конкурентов» (прод-кейс: 12 obj_id = 5 реальных + комплексов → «2 из 12» вместо честных «2 из 5»). Поля objects_* теперь + считают комплексы; raw_objects_total — старый счёт obj_id для прозрачности. + """ + + objects_with_velocity_data: int # число комплексов с velocity-данными + objects_total_in_radius: int # число комплексов (сгруппированных obj_id) + raw_objects_total: int # сырое число obj_id в радиусе (до группировки) velocity_coverage_pct: float confidence: Literal["high", "medium", "low"] diff --git a/backend/app/services/exporters/layout_tz_pdf.py b/backend/app/services/exporters/layout_tz_pdf.py index 116dd647..fa4531d6 100644 --- a/backend/app/services/exporters/layout_tz_pdf.py +++ b/backend/app/services/exporters/layout_tz_pdf.py @@ -137,7 +137,7 @@ def render_layout_tz_pdf(
Покрытие: {dq.objects_with_velocity_data} из - {dq.objects_total_in_radius} ЖК с данными velocity + {dq.objects_total_in_radius} комплексов с данными velocity ({dq.velocity_coverage_pct:.1f}%)
diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py
index c6da2e1d..edec7322 100644
--- a/backend/app/services/site_finder/best_layouts.py
+++ b/backend/app/services/site_finder/best_layouts.py
@@ -16,16 +16,24 @@
Step 6: per-row signature + sold_pct.
Step 7: фильтр min_velocity + sort + rank.
Step 8: build recommendation_for_tz (unit-mix, price, rationale).
- Step 9: data_quality (coverage + confidence).
+ Step 9: data_quality (coverage + confidence) в ГРУППАХ-КОМПЛЕКСАХ (#2177).
Fix SF-01: раньше mv_layout_velocity (24 мес) делился на divisor (4/12) — данные
не менялись при смене time_window. Теперь inline SQL с реальным фильтром report_month.
+
+Fix #2177: coverage-знаменатель = число КОМПЛЕКСОВ, а не сырых obj_id.
+domrf_kn_objects дробит один ЖК на несколько obj_id (очереди / дубли snapshot-строк /
+безымянные корпуса), из-за чего «2 из 12 ЖК» завышало знаменатель. group_radius_objects
+схлопывает obj_id в комплексы → честное «2 из 5 комплексов».
"""
from __future__ import annotations
import datetime as dt
import logging
+import math
+import re
+from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import text
@@ -86,7 +94,8 @@ _PARCEL_CENTROID_SQL = text("""
# filter_competitor_obj_ids: NULL = не фильтровать по списку.
_COMPETITORS_IN_RADIUS_SQL = text("""
- SELECT DISTINCT ON (obj_id) obj_id
+ SELECT DISTINCT ON (obj_id)
+ obj_id, comm_name, dev_name, latitude, longitude
FROM domrf_kn_objects
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
AND ST_DWithin(
@@ -433,6 +442,260 @@ def _hamilton_round(work: dict[str, float]) -> dict[str, int]:
return floors
+# ── Группировка obj_id в комплексы (Issue #2177) ──────────────────────────────
+# domrf_kn_objects дробит один реальный ЖК на несколько obj_id:
+# • разные очереди («2 очередь жилого комплекса «7 ключей»»);
+# • дубли snapshot-строк одного комплекса (одинаковые comm_name+dev_name, точки
+# в ~200 м — «Симфония» 3346/43063/43064);
+# • повторы ядра имени внутри строки («Квартет Квартет Квартет» у Эталона);
+# • безымянные корпуса (comm_name пустой, гео-кучка в ~10 м — 3 Эфеса 55320-22).
+# Сырой счёт obj_id завышает знаменатель coverage (прод: 12 obj_id = 5 комплексов).
+# group_radius_objects схлопывает их в комплексы для честного «N из M комплексов».
+
+# Радиус single-linkage гео-кластеризации безымянных obj_id (comm_name пустой) с
+# одинаковым dev_name. Кучка в пределах NAMELESS_CLUSTER_M — один комплекс; дальше —
+# отдельный (честно неизвестный) проект. 3 Эфеса в ~600 м от «7 ключей» → отдельно.
+NAMELESS_CLUSTER_M = 300.0
+
+# Generic-префиксы, которые убираем из начала имени перед сравнением ядра.
+# «квартал» НЕ входит: в «Квартал Депо» слово — часть имени (без кавычек), не префикс.
+_GENERIC_PREFIXES: tuple[str, ...] = (
+ "жилой комплекс",
+ "жк",
+ "мкр",
+ "микрорайон",
+ "жилой район",
+ "жилой квартал",
+ "клубный дом",
+ "клубный квартал",
+ "дом",
+)
+
+# «N очередь [жилого комплекса] …» / «N-я очередь …» / «корпус N …» и подобные
+# порядковые обёртки — вырезаем, оставляя ядро имени.
+_STAGE_PREFIX_RE = re.compile(
+ r"^\s*\d+\s*(?:-?\s*(?:я|й|ая|ой|го))?\s*"
+ r"(?:очередь|очереди|этап|этапа|корпус|корпуса|секция|секции|"
+ r"дом|блок)\b",
+ re.IGNORECASE,
+)
+
+# «жилого комплекса» / «жилой комплекс» в родительном/именительном — вырезаем
+# как соединительную обёртку внутри «… очередь ЖИЛОГО КОМПЛЕКСА «ядро»».
+_JK_WORD_RE = re.compile(
+ r"\bжил(?:ой|ого|ом|ому|ым)\s+комплекс(?:а|е|у|ом|ы)?\b",
+ re.IGNORECASE,
+)
+
+# Ёлочки-кавычки любого начертания.
+_QUOTE_INNER_RE = re.compile(r"[«»\"“”„'`]([^«»\"“”„'`]+)[«»\"“”„'`]")
+
+
+def _extract_quoted_core(name: str) -> str | None:
+ """Вернуть содержимое ёлочек, если оно есть: «7 ключей» → '7 ключей'."""
+ match = _QUOTE_INNER_RE.search(name)
+ if match:
+ core = match.group(1).strip()
+ if core:
+ return core
+ return None
+
+
+def _dedup_repeated_words(name: str) -> str:
+ """Схлопнуть подряд идущие повторы того же токена: 'квартет квартет' → 'квартет'.
+
+ Эталоновский «Квартет» приходит как «Квартет Квартет Квартет» (повтор ядра ×3
+ в одной строке). Убираем соседние дубли, регистр уже нормализован вызывающим.
+ """
+ tokens = name.split()
+ out: list[str] = []
+ for tok in tokens:
+ if not out or out[-1] != tok:
+ out.append(tok)
+ return " ".join(out)
+
+
+def normalize_complex_name(raw: str | None) -> str:
+ """Нормализовать comm_name до имени-ядра комплекса.
+
+ Шаги:
+ 1. lower + trim + схлопнуть пробелы;
+ 2. если есть ёлочки — берём их содержимое как ядро («7 ключей» из
+ «2 очередь жилого комплекса «7 ключей»»);
+ 3. иначе — режем порядковый префикс («N очередь …»), слова «жилого
+ комплекса», generic-префиксы; «квартал» сохраняем (часть имени);
+ 4. схлопываем соседние повторы токенов («Квартет Квартет» → «квартет»).
+
+ Пустой / None → '' (безымянный, пойдёт в гео-кластеризацию).
+ """
+ if not raw:
+ return ""
+ name = re.sub(r"\s+", " ", raw.strip().lower())
+ if not name:
+ return ""
+
+ quoted = _extract_quoted_core(name)
+ if quoted is not None:
+ return _dedup_repeated_words(re.sub(r"\s+", " ", quoted).strip())
+
+ name = _STAGE_PREFIX_RE.sub("", name).strip()
+ name = _JK_WORD_RE.sub(" ", name).strip()
+
+ # generic-префиксы в начале (после снятия «очереди»); «квартал» намеренно не тут.
+ changed = True
+ while changed:
+ changed = False
+ for prefix in _GENERIC_PREFIXES:
+ if name == prefix:
+ # имя ЦЕЛИКОМ generic (например просто «жк») — оставить как есть,
+ # иначе получим пустое ядро и склеим разные объекты.
+ continue
+ if name.startswith(prefix + " "):
+ name = name[len(prefix) :].strip()
+ changed = True
+ break
+
+ name = re.sub(r"\s+", " ", name).strip()
+ return _dedup_repeated_words(name)
+
+
+def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
+ """Расстояние между двумя точками (метры) по формуле haversine."""
+ r = 6_371_000.0
+ p1, p2 = math.radians(lat1), math.radians(lat2)
+ dphi = math.radians(lat2 - lat1)
+ dlam = math.radians(lon2 - lon1)
+ a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlam / 2) ** 2
+ return 2 * r * math.asin(min(1.0, math.sqrt(a)))
+
+
+@dataclass
+class ComplexGroup:
+ """Сгруппированный «комплекс» = один реальный ЖК из N сырых obj_id.
+
+ key — стабильный идентификатор группы (для дедупа / логов), НЕ user-facing.
+ obj_ids — все obj_id, схлопнутые в этот комплекс.
+ """
+
+ key: str
+ obj_ids: list[int] = field(default_factory=list)
+ dev_name_norm: str = ""
+ core_name: str = ""
+
+
+def group_radius_objects(rows: list[dict[str, Any]]) -> list[ComplexGroup]:
+ """Схлопнуть сырые obj_id радиуса в комплексы (Issue #2177).
+
+ Args:
+ rows: строки из _COMPETITORS_IN_RADIUS_SQL — dict с ключами
+ obj_id, comm_name, dev_name, latitude, longitude.
+
+ Логика:
+ • Именованные (comm_name непустой): ключ = (dev_name_норм, имя-ядро).
+ Один и тот же комплекс (одинаковое ядро+застройщик) → одна группа,
+ независимо от очереди / дублей snapshot-строк / разброса точек.
+ • Безымянные (comm_name пустой): single-linkage гео-кластеризация по
+ haversine в пределах NAMELESS_CLUSTER_M с ОДИНАКОВЫМ dev_name_норм.
+ Каждый кластер = отдельная группа (честно неизвестный проект); НЕ
+ приклеиваем к именованным группам (3 Эфеса в 600 м от «7 ключей»
+ остаются отдельным комплексом).
+
+ Returns:
+ Список ComplexGroup, порядок детерминирован (по key).
+ """
+ named: dict[tuple[str, str], ComplexGroup] = {}
+ nameless: list[dict[str, Any]] = []
+
+ for row in rows:
+ obj_id = int(row["obj_id"])
+ dev_norm = re.sub(r"\s+", " ", (row.get("dev_name") or "").strip().lower())
+ core = normalize_complex_name(row.get("comm_name"))
+
+ if core:
+ gkey = (dev_norm, core)
+ grp = named.get(gkey)
+ if grp is None:
+ grp = ComplexGroup(
+ key=f"named:{dev_norm}|{core}",
+ dev_name_norm=dev_norm,
+ core_name=core,
+ )
+ named[gkey] = grp
+ grp.obj_ids.append(obj_id)
+ else:
+ nameless.append(
+ {
+ "obj_id": obj_id,
+ "dev_norm": dev_norm,
+ "lat": row.get("latitude"),
+ "lon": row.get("longitude"),
+ }
+ )
+
+ groups: list[ComplexGroup] = list(named.values())
+ groups.extend(_cluster_nameless(nameless))
+
+ for grp in groups:
+ grp.obj_ids.sort()
+ groups.sort(key=lambda g: g.key)
+ return groups
+
+
+def _cluster_nameless(items: list[dict[str, Any]]) -> list[ComplexGroup]:
+ """Single-linkage гео-кластеризация безымянных obj_id в пределах NAMELESS_CLUSTER_M.
+
+ Кластеризуем ТОЛЬКО точки с одинаковым dev_norm (разные застройщики — заведомо
+ разные проекты). Точек мало (единицы), поэтому наивный O(n²) union-по-соседям
+ достаточен. obj_id без координат — каждый в свою группу (не с чем сравнивать).
+ """
+ groups: list[ComplexGroup] = []
+ # Партиционируем по застройщику, чтобы не сливать разных девелоперов.
+ by_dev: dict[str, list[dict[str, Any]]] = {}
+ for it in items:
+ by_dev.setdefault(it["dev_norm"], []).append(it)
+
+ for dev_norm, pts in by_dev.items():
+ # union-find по соседству ≤ NAMELESS_CLUSTER_M
+ parent = list(range(len(pts)))
+
+ def _find(i: int, parent: list[int] = parent) -> int:
+ while parent[i] != i:
+ parent[i] = parent[parent[i]]
+ i = parent[i]
+ return i
+
+ for i in range(len(pts)):
+ lat_i, lon_i = pts[i]["lat"], pts[i]["lon"]
+ if lat_i is None or lon_i is None:
+ continue
+ for j in range(i + 1, len(pts)):
+ lat_j, lon_j = pts[j]["lat"], pts[j]["lon"]
+ if lat_j is None or lon_j is None:
+ continue
+ if (
+ _haversine_m(float(lat_i), float(lon_i), float(lat_j), float(lon_j))
+ <= NAMELESS_CLUSTER_M
+ ):
+ parent[_find(i)] = _find(j)
+
+ clusters: dict[int, list[int]] = {}
+ for idx, pt in enumerate(pts):
+ root = _find(idx)
+ clusters.setdefault(root, []).append(int(pt["obj_id"]))
+
+ for obj_ids in clusters.values():
+ anchor = min(obj_ids)
+ groups.append(
+ ComplexGroup(
+ key=f"nameless:{dev_norm}|{anchor}",
+ obj_ids=obj_ids,
+ dev_name_norm=dev_norm,
+ core_name="",
+ )
+ )
+ return groups
+
+
# ── Главная функция ───────────────────────────────────────────────────────────
@@ -493,23 +756,42 @@ def get_best_layouts(
logger.exception("best_layouts: competitors-in-radius query failed for cad_num=%s", cad_num)
raise
- all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_rows]
- objects_total_in_radius = len(all_obj_ids)
+ # Issue #2177: сохраняем полные строки (comm_name/dev_name/гео) для группировки
+ # obj_id в комплексы. Сырой obj_id-счёт (raw_objects_total) — для прозрачности.
+ id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows]
+ all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts]
+ raw_objects_total_in_radius = len(all_obj_ids)
+ # Число комплексов ДО exclude/filter — знаменатель для empty-ответа (совместимо
+ # со старой семантикой: пустой ответ отражал полный радиус, а не post-filter 0).
+ groups_total_pre_filter = len(group_radius_objects(id_row_dicts))
- # Применить exclude / filter из request
+ # Применить exclude / filter из request (и к obj_ids, и к строкам для группировки)
exclude_set = set(request.exclude_competitor_obj_ids)
- if exclude_set:
- all_obj_ids = [oid for oid in all_obj_ids if oid not in exclude_set]
+ filter_set: set[int] | None = (
+ set(request.filter_competitor_obj_ids)
+ if request.filter_competitor_obj_ids is not None
+ else None
+ )
- if request.filter_competitor_obj_ids is not None:
- filter_set = set(request.filter_competitor_obj_ids)
- all_obj_ids = [oid for oid in all_obj_ids if oid in filter_set]
+ def _keep(oid: int) -> bool:
+ if oid in exclude_set:
+ return False
+ if filter_set is not None and oid not in filter_set:
+ return False
+ return True
+
+ id_row_dicts = [r for r in id_row_dicts if _keep(int(r["obj_id"]))]
+ all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)]
+
+ # Группируем отфильтрованные obj_id в комплексы — знаменатель coverage.
+ complex_groups = group_radius_objects(id_row_dicts)
if not all_obj_ids:
return _empty_response(
radius_km=request.radius_km,
time_window=request.time_window,
- objects_total_in_radius=objects_total_in_radius,
+ objects_total_in_radius=groups_total_pre_filter,
+ raw_objects_total=raw_objects_total_in_radius,
)
# ── Step 3: inline velocity из objective_corpus_room_month ──────────────
@@ -539,7 +821,8 @@ def get_best_layouts(
return _empty_response(
radius_km=request.radius_km,
time_window=request.time_window,
- objects_total_in_radius=objects_total_in_radius,
+ objects_total_in_radius=len(complex_groups),
+ raw_objects_total=raw_objects_total_in_radius,
)
# ── Step 5: supply side (батч-запрос) ────────────────────────────────────
@@ -691,14 +974,20 @@ def get_best_layouts(
)
# ── Step 9: data_quality ─────────────────────────────────────────────────
- # Denominator = post-filter set (effective consideration set после exclude/filter).
- objects_total_after_filter = len(all_obj_ids)
- objects_with_data = len(obj_ids_with_data & set(all_obj_ids))
+ # Issue #2177: знаменатель = число КОМПЛЕКСОВ (сгруппированных obj_id), а не
+ # сырых obj_id. Группа считается «с данными», если ХОТЯ БЫ ОДИН её obj_id есть
+ # в obj_ids_with_data (velocity-покрытые). Прод-кейс (радиус 1 км): 12 obj_id
+ # = 5 комплексов, из них 2 с velocity → «40% (2 из 5)» вместо «17% (2 из 12)».
+ groups_total_after_filter = len(complex_groups)
+ groups_with_data = sum(
+ 1 for g in complex_groups if any(oid in obj_ids_with_data for oid in g.obj_ids)
+ )
coverage_pct = (
- round(objects_with_data / objects_total_after_filter * 100.0, 1)
- if objects_total_after_filter > 0
+ round(groups_with_data / groups_total_after_filter * 100.0, 1)
+ if groups_total_after_filter > 0
else 0.0
)
+ # Пороги confidence — от процента (не трогаем).
if coverage_pct >= LAYOUT_CONFIDENCE_HIGH_PCT:
confidence: str = "high"
elif coverage_pct >= LAYOUT_CONFIDENCE_MEDIUM_PCT:
@@ -707,8 +996,9 @@ def get_best_layouts(
confidence = "low"
data_quality = LayoutDataQuality(
- objects_with_velocity_data=objects_with_data,
- objects_total_in_radius=objects_total_after_filter,
+ objects_with_velocity_data=groups_with_data,
+ objects_total_in_radius=groups_total_after_filter,
+ raw_objects_total=raw_objects_total_in_radius,
velocity_coverage_pct=coverage_pct,
confidence=confidence, # type: ignore[arg-type]
)
@@ -821,8 +1111,13 @@ def _empty_response(
radius_km: float,
time_window: str,
objects_total_in_radius: int,
+ raw_objects_total: int = 0,
) -> BestLayoutsResponse:
- """Ответ когда нет конкурентов или нет MV данных."""
+ """Ответ когда нет конкурентов или нет MV данных.
+
+ objects_total_in_radius — число комплексов (сгруппированных obj_id, #2177),
+ raw_objects_total — сырое число obj_id до группировки (прозрачность).
+ """
today = dt.date.today()
tw_label = {"last_month": "1 мес", "last_quarter": "квартал", "last_year": "год"}.get(
time_window, time_window
@@ -844,6 +1139,7 @@ def _empty_response(
data_quality=LayoutDataQuality(
objects_with_velocity_data=0,
objects_total_in_radius=objects_total_in_radius,
+ raw_objects_total=raw_objects_total,
velocity_coverage_pct=0.0,
confidence="low",
),
diff --git a/backend/tests/api/v1/test_parcel_best_layouts.py b/backend/tests/api/v1/test_parcel_best_layouts.py
index 478cd332..88836939 100644
--- a/backend/tests/api/v1/test_parcel_best_layouts.py
+++ b/backend/tests/api/v1/test_parcel_best_layouts.py
@@ -37,11 +37,21 @@ def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
return r
-def _obj_id_row(obj_id: int) -> MagicMock:
- """Строка obj_id из _COMPETITORS_IN_RADIUS_SQL."""
- r = MagicMock()
- r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
- return r
+def _obj_id_row(obj_id: int) -> dict[str, object]:
+ """Строка из _COMPETITORS_IN_RADIUS_SQL.
+
+ Issue #2177: SQL возвращает comm_name/dev_name/гео для группировки obj_id в
+ комплексы, а get_best_layouts делает dict(r). Возвращаем реальный dict (как
+ SQLAlchemy RowMapping). Уникальное comm_name на obj_id → каждый = своя группа,
+ поэтому существующие coverage-инварианты этих API-тестов не меняются.
+ """
+ return {
+ "obj_id": obj_id,
+ "comm_name": f"obj-{obj_id}",
+ "dev_name": f"dev-{obj_id}",
+ "latitude": None,
+ "longitude": None,
+ }
def _vel_row(
diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py
index 2174b074..192b0295 100644
--- a/backend/tests/services/site_finder/test_best_layouts.py
+++ b/backend/tests/services/site_finder/test_best_layouts.py
@@ -30,8 +30,11 @@ from app.schemas.parcel import BestLayoutsRequest
from app.services.site_finder.best_layouts import (
_TIME_WINDOW_PARAMS,
MAX_BUCKET_SHARE_PCT,
+ NAMELESS_CLUSTER_M,
_cap_and_redistribute,
get_best_layouts,
+ group_radius_objects,
+ normalize_complex_name,
)
_TODAY = dt.date.today()
@@ -47,10 +50,28 @@ def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
return r
-def _obj_id_row(obj_id: int) -> MagicMock:
- r = MagicMock()
- r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
- return r
+def _obj_id_row(
+ obj_id: int,
+ comm_name: str | None = None,
+ dev_name: str | None = None,
+ latitude: float | None = None,
+ longitude: float | None = None,
+) -> dict[str, object]:
+ """Строка из _COMPETITORS_IN_RADIUS_SQL.
+
+ Issue #2177: SQL теперь возвращает comm_name/dev_name/гео для группировки в
+ комплексы; get_best_layouts вызывает dict(r), поэтому возвращаем реальный dict
+ (SQLAlchemy RowMapping тоже dict-конвертируем). По умолчанию каждый obj_id
+ получает уникальное имя (`obj-