diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index f63526cb..744e9bc2 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -548,6 +548,11 @@ class LayoutDataQuality(BaseModel): objects_with_velocity_data: int # число комплексов с velocity-данными objects_total_in_radius: int # число комплексов (сгруппированных obj_id) raw_objects_total: int # сырое число obj_id в радиусе (до группировки) + # Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по + # нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id — + # прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в + # радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping. + groups_matched_by_core: int = 0 velocity_coverage_pct: float confidence: Literal["high", "medium", "low"] diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index edec7322..1573ae44 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -49,6 +49,11 @@ from app.schemas.parcel import ( ) from app.services.site_finder.layout_signature import area_bin, layout_signature +# Fix #2177 (шаг 2b) dev-confirm: _dev_confirms/_norm_dev импортируются ЛЕНИВО внутри +# match_groups_to_projects — objective_backfill на верхнем уровне сам импортирует +# normalize_complex_name отсюда, поэтому module-level import создал бы цикл +# (best_layouts ↔ objective_backfill). objective_backfill.py при этом НЕ меняем. + logger = logging.getLogger(__name__) # Confidence thresholds (per coverage % of objects with MV velocity data) @@ -113,12 +118,15 @@ _COMPETITORS_IN_RADIUS_SQL = text(""" ORDER BY obj_id, snapshot_date DESC NULLS LAST """) -# ── SQL: inline velocity из objective_corpus_room_month + mapping ───────────── +# ── SQL: inline velocity из objective_corpus_room_month + mapping + core-match ─ # Fix SF-01: честный фильтр по report_month вместо деления mv_layout_velocity (24 мес). # Параметры: # :window_interval — PostgreSQL interval string ('1 month', '3 months', '12 months') -# :competitor_obj_ids — list[int] obj_id конкурентов в радиусе +# :competitor_obj_ids — list[int] obj_id конкурентов в радиусе (mapping-источник) +# :matched_project_names — list[str] project_name'ов, сматченных по ЯДРУ комплекса +# (Fix #2177 шаг 2b, core-attribution). Может быть пустым. # CAST(:window_interval AS interval) — psycopg v3 / SQLAlchemy 2.0 safe (не ::interval). +# ANY(CAST(:matched_project_names AS text[])) — psycopg v3 массивный bind (backend.md). # # Fix #1391: fan-out по objective_complex_mapping завышал SUM-метрики. UNIQUE-ключ @@ -129,10 +137,21 @@ _COMPETITORS_IN_RADIUS_SQL = text(""" # (competitor_obj_ids / competitor_count) не страдали, но SUM — да. # Решение: deals агрегируем per (project_name, room_bucket) в CTE `crm_agg` # (без mapping-джойна → fan-out невозможен). Список project_name'ов в радиусе берём из -# CTE `mapped_projects` (DISTINCT по objective_complex_name) и джойним один-к-одному — +# CTE `in_radius_projects` (DISTINCT по objective_complex_name) и джойним один-к-одному — # каждый project_name вносит свои сделки ровно один раз. competitor_obj_ids / # competitor_count считаем отдельно в `obj_per_bucket` (через unnest mapping-obj_id), # чтобы fan-out по obj_id не попал в SUM-агрегаты, и приджойниваем по room_bucket. +# +# Fix #2177 шаг 2b (core-attribution): mapping-join бьёт единственный domrf_obj_id +# на комплекс (UNIQUE (objective_complex_name, objective_group)), поэтому вторая очередь +# ЖК с другим obj_id в радиусе не резолвит сделки первой очереди («7 ключей»: obj 8360 +# в mapping, но в радиусе obj 15731 — сделки не входили). Теперь project_name'ы в scope = +# mapping-проекты ∪ project_name'ы, сматченные по ЯДРУ имени (Python-этап, передаётся +# :matched_project_names). Оба множества — это НАБОР project_name; crm-сделки агрегированы +# per project_name → включение имени из ДВУХ источников НЕ даёт двойного счёта (это set, +# каждый project_name вносит свои сделки ровно один раз). Дополнительно возвращаем +# matched_project_names per bucket (array_agg DISTINCT) — для coverage-атрибуции групп +# по project_name (а не по obj_id) на Python-стороне. _INLINE_VELOCITY_SQL = text(""" WITH crm_agg AS ( @@ -158,10 +177,15 @@ _INLINE_VELOCITY_SQL = text(""" ELSE crm.room_bucket END ), - mapped_projects AS ( + in_radius_projects AS ( + -- Источник 1: mapping по domrf_obj_id (legacy). SELECT DISTINCT cm.objective_complex_name AS project_name FROM objective_complex_mapping cm WHERE cm.domrf_obj_id = ANY(:competitor_obj_ids) + UNION + -- Источник 2: core-attribution (Fix #2177 шаг 2b) — сматченные по ядру имена. + SELECT DISTINCT project_name + FROM unnest(CAST(:matched_project_names AS text[])) AS project_name ), deals_per_bucket AS ( SELECT @@ -177,10 +201,11 @@ _INLINE_VELOCITY_SQL = text(""" / NULLIF(SUM(a.deals_window), 0), 0 )::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub, + array_agg(DISTINCT a.project_name) AS matched_project_names, MIN(a.window_start) AS window_start, MAX(a.window_end) AS window_end FROM crm_agg a - JOIN mapped_projects mp ON mp.project_name = a.project_name + JOIN in_radius_projects mp ON mp.project_name = a.project_name GROUP BY a.room_bucket ), obj_per_bucket AS ( @@ -199,12 +224,13 @@ _INLINE_VELOCITY_SQL = text(""" d.deals_window AS deals_window, d.avg_area_m2 AS avg_area_m2, d.avg_price_per_m2_rub AS avg_price_per_m2_rub, - o.competitor_obj_ids AS competitor_obj_ids, - o.competitor_count AS competitor_count, + COALESCE(o.competitor_obj_ids, ARRAY[]::bigint[]) AS competitor_obj_ids, + COALESCE(o.competitor_count, 0) AS competitor_count, + d.matched_project_names AS matched_project_names, d.window_start AS window_start, d.window_end AS window_end FROM deals_per_bucket d - JOIN obj_per_bucket o ON o.room_bucket = d.room_bucket + LEFT JOIN obj_per_bucket o ON o.room_bucket = d.room_bucket """) # ── SQL: supply по (room_bucket, area_bin) за последний снимок ─────────────── @@ -696,6 +722,102 @@ def _cluster_nameless(items: list[dict[str, Any]]) -> list[ComplexGroup]: return groups +# ── Core-attribution: сопоставление групп радиуса с objective-проектами (#2177) ── +# Проблема: velocity-join шёл ТОЛЬКО через objective_complex_mapping по единственному +# domrf_obj_id на комплекс (UNIQUE (objective_complex_name, objective_group)). Если в +# радиусе стоит obj_id ДРУГОЙ очереди того же ЖК (напр. «7 ключей»: mapping держит +# obj 8360 1-й очереди, а в радиусе obj 15731 2-й), группа считается непокрытой, а +# свежие сделки не входят в расчёт. Фикс: матчим группу радиуса на objective-проект +# по НОРМАЛИЗОВАННОМУ ЯДРУ имени (normalize_complex_name обеих сторон) — тот же приём, +# что в city-wide backfill (#2198), но здесь группа уже локализована радиусом, поэтому +# порог риска ниже: dev-confirm требуем ТОЛЬКО когда сигнал есть с обеих сторон. + +# distinct project_name'ы Объектива по ЕКБ-группе (лёгкий SELECT, ~263 имени). +# group_name = 'Екатеринбург' — единственное значение (objective_backfill.py docstring). +_OBJECTIVE_PROJECT_NAMES_SQL = text(""" + SELECT DISTINCT project_name + FROM objective_corpus_room_month + WHERE group_name = 'Екатеринбург' + AND project_name IS NOT NULL + AND project_name <> '' +""") + +# developers per project_name — агрегат из objective_lots ТОЛЬКО для dev-confirm. +# ARRAY_REMOVE(..., NULL) — чистый список без NULL-хвостов. +# ОБЯЗАТЕЛЬНО scoped по кандидатам (:names): глобальный агрегат — seq scan 1.9M +# строк + external-merge sort 115MB, ~3.8 с на КАЖДЫЙ вызов best-layouts (EXPLAIN +# 2026-07-03); кандидатов же обычно 0-3 → index probe по project_name. +_OBJECTIVE_DEVELOPERS_SQL = text(""" + SELECT + ol.project_name, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers + FROM objective_lots ol + WHERE ol.project_name = ANY(CAST(:names AS text[])) + GROUP BY ol.project_name +""") + + +def match_groups_to_projects( + groups: list[ComplexGroup], + project_names: list[str], + developers_by_project: dict[str, list[str]] | None = None, +) -> dict[str, str]: + """Сопоставить группы радиуса с objective-проектами по ядру имени (#2177 шаг 2b). + + Args: + groups: ComplexGroup из group_radius_objects (core_name уже нормализован). + project_names: distinct project_name Объектива (ЕКБ). + developers_by_project: project_name → [developer, ...] для dev-confirm + (опционально; если None — dev-confirm пропускается). + + Логика на каждую группу: + • кандидаты = objective-проекты с тем же нормализованным ядром; + • ровно 1 кандидат → матч на его project_name; + • >1 кандидата → НЕ матчим (ambiguous — честно, вслепую не выбираем); + • 0 кандидатов → не наш случай (переименование/нет в Объективе). + Dev-confirm: если у группы есть dev_name-сигнал И у проекта есть developers — + требуем подтверждения (_dev_confirms). Иначе (сигнала нет с одной из сторон) + матч по уникальному ядру достаточен: группа уже локализована радиусом. + + Returns: + dict group_key → objective project_name (только однозначные матчи). + """ + # Ленивый импорт — разрывает цикл best_layouts ↔ objective_backfill (см. шапку файла). + from app.services.etl.objective_backfill import _dev_confirms, _norm_dev + + # objective-сторона: core → [project_name, ...] + projects_by_core: dict[str, list[str]] = {} + for pname in project_names: + core = normalize_complex_name(pname) + if not core: + continue + projects_by_core.setdefault(core, []).append(pname) + + devs_map = developers_by_project or {} + result: dict[str, str] = {} + + for grp in groups: + core = grp.core_name + if not core: + continue # безымянная гео-группа — ядра нет, матчить нечем + candidates = projects_by_core.get(core) + if not candidates or len(candidates) > 1: + # 0 → нет objective-проекта с таким ядром; >1 → ambiguous, не матчим. + continue + + pname = candidates[0] + # Dev-confirm только когда сигнал есть с ОБЕИХ сторон. + group_dev = _norm_dev(grp.dev_name_norm) + project_devs = {d for d in devs_map.get(pname, []) if d} + if group_dev and project_devs and not _dev_confirms(grp.dev_name_norm, project_devs): + # dev-конфликт: одноимённое ядро, но застройщик не подтверждает → не матчим. + continue + + result[grp.key] = pname + + return result + + # ── Главная функция ─────────────────────────────────────────────────────────── @@ -794,9 +916,46 @@ def get_best_layouts( raw_objects_total=raw_objects_total_in_radius, ) + # ── Step 2b: core-attribution групп радиуса на objective-проекты (#2177) ── + # Матчим ГРУППУ радиуса на project_name по нормализованному ядру имени, обходя + # mapping-ограничение «один domrf_obj_id на комплекс» (2-я очередь ЖК в радиусе + # больше не теряет сделки, привязанные к 1-й через mapping). + core_group_to_project: dict[str, str] = {} + try: + project_names = [str(row[0]) for row in db.execute(_OBJECTIVE_PROJECT_NAMES_SQL).all()] + # Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers + # тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата). + preliminary = match_groups_to_projects(complex_groups, project_names, None) + candidate_names = sorted(set(preliminary.values())) + developers_by_project: dict[str, list[str]] = {} + if candidate_names: + developers_by_project = { + str(row[0]): [str(d) for d in (row[1] or []) if d] + for row in db.execute(_OBJECTIVE_DEVELOPERS_SQL, {"names": candidate_names}).all() + } + core_group_to_project = match_groups_to_projects( + complex_groups, project_names, developers_by_project + ) + except Exception: + # Не роняем весь ответ — core-attribution аддитивна к mapping-источнику. + # rollback: при реальной DB-ошибке транзакция в aborted-state — без отката + # следующий execute (velocity SQL) упал бы InFailedSqlTransaction, и + # «fallback на mapping-only» не состоялся бы. + db.rollback() + logger.exception( + "best_layouts: core-attribution failed for cad_num=%s — fallback to mapping-only", + cad_num, + ) + core_group_to_project = {} + + # Уникальные project_name'ы, сматченные по ядру → bind в velocity SQL. + matched_project_names: list[str] = sorted(set(core_group_to_project.values())) + # ── Step 3: inline velocity из objective_corpus_room_month ────────────── # Fix SF-01: честный фильтр report_month >= NOW() - window_interval. # Разные time_window → разные deals_window, разный mix. + # Fix #2177 шаг 2b: + matched_project_names (core-attribution) как второй источник + # project_name'ов в scope (объединяется с mapping внутри in_radius_projects CTE). try: vel_rows = ( db.execute( @@ -804,6 +963,7 @@ def get_best_layouts( { "window_interval": window_interval, "competitor_obj_ids": all_obj_ids, + "matched_project_names": matched_project_names, }, ) .mappings() @@ -860,8 +1020,11 @@ def get_best_layouts( window_start: dt.date | None = None window_end: dt.date | None = None - # Собираем obj_ids с данными в objective_corpus_room_month (для data_quality) + # Собираем obj_ids с данными в objective_corpus_room_month (для data_quality — + # coverage mapping-групп) + project_name'ы с реальными velocity-строками в окне + # (Fix #2177 шаг 2b — coverage core-матченных групп по project_name). obj_ids_with_data: set[int] = set() + projects_with_data: set[str] = set() for r in vel_rows: room_bucket = str(r["room_bucket"]) @@ -876,6 +1039,8 @@ def get_best_layouts( competitor_count = int(r["competitor_count"]) obj_ids_with_data.update(competitor_obj_ids) + if r["matched_project_names"]: + projects_with_data.update(str(p) for p in r["matched_project_names"]) # Step 4: честный velocity = сделки за окно / длина окна в месяцах velocity_per_month = round(deals_window / months_in_window, 2) @@ -975,12 +1140,29 @@ def get_best_layouts( # ── Step 9: data_quality ───────────────────────────────────────────────── # 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)». + # сырых obj_id. Прод-кейс (радиус 1 км): 12 obj_id = 5 комплексов. + # Группа покрыта, если ЛИБО: + # (a) хотя бы один её obj_id в obj_ids_with_data (mapping-источник, legacy), ЛИБО + # (b) шаг 2b: её сматченный по ядру project_name имеет velocity-строки в окне + # (projects_with_data) — так «7 ключей» (2-я очередь в радиусе, сделки на + # project '7 Ключей') теперь считается покрытой, а не «непокрытой». 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) + + def _group_covered(g: ComplexGroup) -> bool: + if any(oid in obj_ids_with_data for oid in g.obj_ids): + return True + matched = core_group_to_project.get(g.key) + return matched is not None and matched in projects_with_data + + groups_with_data = sum(1 for g in complex_groups if _group_covered(g)) + # Прозрачность источника атрибуции: сколько групп покрыто ИМЕННО по ядру + # (core-match), а не по mapping-obj_id. Считаем группы, чей матч дал velocity + # и у которых НЕТ покрытия через obj_id (чистый вклад core-attribution). + groups_matched_by_core = sum( + 1 + for g in complex_groups + if not any(oid in obj_ids_with_data for oid in g.obj_ids) + and core_group_to_project.get(g.key) in projects_with_data ) coverage_pct = ( round(groups_with_data / groups_total_after_filter * 100.0, 1) @@ -999,6 +1181,7 @@ def get_best_layouts( objects_with_velocity_data=groups_with_data, objects_total_in_radius=groups_total_after_filter, raw_objects_total=raw_objects_total_in_radius, + groups_matched_by_core=groups_matched_by_core, velocity_coverage_pct=coverage_pct, confidence=confidence, # type: ignore[arg-type] ) @@ -1140,6 +1323,7 @@ def _empty_response( objects_with_velocity_data=0, objects_total_in_radius=objects_total_in_radius, raw_objects_total=raw_objects_total, + groups_matched_by_core=0, 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 88836939..2ccaa9ae 100644 --- a/backend/tests/api/v1/test_parcel_best_layouts.py +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -23,6 +23,14 @@ import pytest from fastapi.testclient import TestClient from app.main import app +from app.services.site_finder.best_layouts import ( + _COMPETITORS_IN_RADIUS_SQL, + _INLINE_VELOCITY_SQL, + _OBJECTIVE_DEVELOPERS_SQL, + _OBJECTIVE_PROJECT_NAMES_SQL, + _PARCEL_CENTROID_SQL, + _SUPPLY_BATCH_SQL, +) # ── Фабрики mock-строк ──────────────────────────────────────────────────────── @@ -76,6 +84,8 @@ def _vel_row( "avg_price_per_m2_rub": avg_price_rub, "competitor_obj_ids": oids, "competitor_count": len(oids), + # Issue #2177 шаг 2b: project_name'ы с velocity в bucket'е (mapping ∪ core-match). + "matched_project_names": [], "window_start": ws, "window_end": we, }[k] @@ -97,42 +107,56 @@ def _make_db( id_rows: list[MagicMock] | None = None, vel_rows: list[MagicMock] | None = None, supply_rows: list[MagicMock] | None = None, + project_names: list[str] | None = None, + developers_by_project: dict[str, list[str]] | None = None, ) -> MagicMock: - """Сконструировать mock Session. + """Сконструировать mock Session с диспатчем по SQL-стейтменту. - Fix #1956: per-object последний снимок считается внутри _SUPPLY_BATCH_SQL, - отдельного db.scalar(MAX(snapshot_date)) больше нет. - Порядок db.execute(): - 1. centroid → .mappings().first() - 2. competitors-in-radius → .mappings().all() - 3. velocity → .mappings().all() - 4. supply → .mappings().all() + Fix #1956: per-object последний снимок считается внутри _SUPPLY_BATCH_SQL. + Issue #2177 шаг 2b добавил objective-projects/developers (core-attribution) перед + velocity — роутим по идентичности SQL-констант, не позиционно. db.execute(): + • _PARCEL_CENTROID_SQL → .mappings().first() + • _COMPETITORS_IN_RADIUS_SQL → .mappings().all() + • _OBJECTIVE_PROJECT_NAMES_SQL → .all() + • _OBJECTIVE_DEVELOPERS_SQL → .all() + • _INLINE_VELOCITY_SQL → .mappings().all() + • _SUPPLY_BATCH_SQL → .mappings().all() """ db = MagicMock() - results: list[MagicMock] = [] + r_centroid = MagicMock() + r_centroid.mappings.return_value.first.return_value = coord - # 1: centroid - r0 = MagicMock() - r0.mappings.return_value.first.return_value = coord - results.append(r0) + r_comp = MagicMock() + r_comp.mappings.return_value.all.return_value = id_rows or [] - # 2: competitors-in-radius - r1 = MagicMock() - r1.mappings.return_value.all.return_value = id_rows or [] - results.append(r1) + r_proj = MagicMock() + r_proj.all.return_value = [(n,) for n in (project_names or [])] - # 3: velocity (only queried if id_rows non-empty) - r2 = MagicMock() - r2.mappings.return_value.all.return_value = vel_rows or [] - results.append(r2) + r_dev = MagicMock() + r_dev.all.return_value = [ + (pname, devs) for pname, devs in (developers_by_project or {}).items() + ] - # 4: supply - r3 = MagicMock() - r3.mappings.return_value.all.return_value = supply_rows or [] - results.append(r3) + r_vel = MagicMock() + r_vel.mappings.return_value.all.return_value = vel_rows or [] - db.execute.side_effect = results + r_supply = MagicMock() + r_supply.mappings.return_value.all.return_value = supply_rows or [] + + dispatch = { + _PARCEL_CENTROID_SQL: r_centroid, + _COMPETITORS_IN_RADIUS_SQL: r_comp, + _OBJECTIVE_PROJECT_NAMES_SQL: r_proj, + _OBJECTIVE_DEVELOPERS_SQL: r_dev, + _INLINE_VELOCITY_SQL: r_vel, + _SUPPLY_BATCH_SQL: r_supply, + } + + def _execute(stmt, *_args, **_kwargs): + return dispatch[stmt] + + db.execute.side_effect = _execute return db diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 192b0295..38038a02 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -28,12 +28,19 @@ import pytest from app.schemas.parcel import BestLayoutsRequest from app.services.site_finder.best_layouts import ( + _COMPETITORS_IN_RADIUS_SQL, + _INLINE_VELOCITY_SQL, + _OBJECTIVE_DEVELOPERS_SQL, + _OBJECTIVE_PROJECT_NAMES_SQL, + _PARCEL_CENTROID_SQL, + _SUPPLY_BATCH_SQL, _TIME_WINDOW_PARAMS, MAX_BUCKET_SHARE_PCT, NAMELESS_CLUSTER_M, _cap_and_redistribute, get_best_layouts, group_radius_objects, + match_groups_to_projects, normalize_complex_name, ) @@ -80,14 +87,18 @@ def _vel_row( avg_area: float = 55.0, avg_price_rub: float | None = 120000.0, obj_ids: list[int] | None = None, + project_names: list[str] | None = None, window_start: dt.date | None = None, window_end: dt.date | None = None, ) -> MagicMock: """Строка из _INLINE_VELOCITY_SQL. deals_window — реальные сделки за честное окно (не 24 мес). + project_names — matched_project_names (#2177 шаг 2b): project_name'ы с velocity в + этом bucket'е (объединение mapping ∪ core-match). None → [] (только obj_id-источник). """ oids = obj_ids if obj_ids is not None else [1] + pnames = project_names if project_names is not None else [] ws = window_start or _TODAY - dt.timedelta(days=90) we = window_end or _TODAY @@ -99,6 +110,7 @@ def _vel_row( "avg_price_per_m2_rub": avg_price_rub, "competitor_obj_ids": oids, "competitor_count": len(oids), + "matched_project_names": pnames, "window_start": ws, "window_end": we, }[k] @@ -111,36 +123,69 @@ def _supply_row(rb: str, ab: str, units: int) -> MagicMock: return r +def _proj_name_rows(names: list[str]) -> list[tuple[str]]: + """Строки из _OBJECTIVE_PROJECT_NAMES_SQL (.all() → row[0] = project_name).""" + return [(n,) for n in names] + + +def _dev_rows(devs_by_project: dict[str, list[str]]) -> list[tuple[str, list[str]]]: + """Строки из _OBJECTIVE_DEVELOPERS_SQL (.all() → row[0]=project_name, row[1]=devs).""" + return [(pname, devs) for pname, devs in devs_by_project.items()] + + def _make_db( coord: MagicMock | None = None, id_rows: list[MagicMock] | None = None, vel_rows: list[MagicMock] | None = None, supply_rows: list[MagicMock] | None = None, + project_names: list[str] | None = None, + developers_by_project: dict[str, list[str]] | None = None, ) -> MagicMock: - """Сконструировать mock Session. + """Сконструировать mock Session с диспатчем по SQL-стейтменту. - Порядок db.execute() (Fix #1956: db.scalar(MAX(snapshot_date)) удалён — - per-object последний снимок считается внутри _SUPPLY_BATCH_SQL): - 1. centroid → .mappings().first() - 2. competitors → .mappings().all() - 3. velocity → .mappings().all() - 4. supply → .mappings().all() + Порядок вызовов теперь НЕ позиционный (шаг 2b добавил objective-projects/ + developers перед velocity) — роутим по идентичности SQL-констант, чтобы тесты + были устойчивы к порядку. db.execute(): + • _PARCEL_CENTROID_SQL → .mappings().first() + • _COMPETITORS_IN_RADIUS_SQL → .mappings().all() + • _OBJECTIVE_PROJECT_NAMES_SQL → .all() (core-attribution, #2177 шаг 2b) + • _OBJECTIVE_DEVELOPERS_SQL → .all() (dev-confirm) + • _INLINE_VELOCITY_SQL → .mappings().all() + • _SUPPLY_BATCH_SQL → .mappings().all() """ db = MagicMock() - r0 = MagicMock() - r0.mappings.return_value.first.return_value = coord + r_centroid = MagicMock() + r_centroid.mappings.return_value.first.return_value = coord - r1 = MagicMock() - r1.mappings.return_value.all.return_value = id_rows or [] + r_comp = MagicMock() + r_comp.mappings.return_value.all.return_value = id_rows or [] - r2 = MagicMock() - r2.mappings.return_value.all.return_value = vel_rows or [] + r_proj = MagicMock() + r_proj.all.return_value = _proj_name_rows(project_names or []) - r3 = MagicMock() - r3.mappings.return_value.all.return_value = supply_rows or [] + r_dev = MagicMock() + r_dev.all.return_value = _dev_rows(developers_by_project or {}) - db.execute.side_effect = [r0, r1, r2, r3] + r_vel = MagicMock() + r_vel.mappings.return_value.all.return_value = vel_rows or [] + + r_supply = MagicMock() + r_supply.mappings.return_value.all.return_value = supply_rows or [] + + dispatch = { + _PARCEL_CENTROID_SQL: r_centroid, + _COMPETITORS_IN_RADIUS_SQL: r_comp, + _OBJECTIVE_PROJECT_NAMES_SQL: r_proj, + _OBJECTIVE_DEVELOPERS_SQL: r_dev, + _INLINE_VELOCITY_SQL: r_vel, + _SUPPLY_BATCH_SQL: r_supply, + } + + def _execute(stmt, *_args, **_kwargs): + return dispatch[stmt] + + db.execute.side_effect = _execute return db @@ -838,3 +883,125 @@ def test_coverage_group_covered_by_any_member_obj_id() -> None: def test_nameless_cluster_constant_reasonable() -> None: """NAMELESS_CLUSTER_M экспортируется и в разумных пределах (guard).""" assert 100.0 <= NAMELESS_CLUSTER_M <= 500.0 + + +# ── Issue #2177 шаг 2b: core-attribution velocity по ядру комплекса ────────── + + +def test_match_groups_to_projects_unique_core() -> None: + """Группа с уникальным ядром матчится на project_name (dev-сигнал совпадает).""" + rows = [_obj_id_row(15731, "2 очередь жилого комплекса «7 ключей»", "Эфес", *_KLYUCHI)] + groups = group_radius_objects(rows) + matched = match_groups_to_projects( + groups, + project_names=["7 Ключей", "Другой ЖК"], + developers_by_project={"7 Ключей": ["СЗ Эфес"]}, + ) + assert list(matched.values()) == ["7 Ключей"] + + +def test_match_groups_ambiguous_core_not_matched() -> None: + """>1 objective-проекта с тем же ядром → группа НЕ матчится (ambiguous, честно).""" + rows = [_obj_id_row(1, "ЖК «Символ»", "Девелопер", 56.85, 60.60)] + groups = group_radius_objects(rows) + matched = match_groups_to_projects( + groups, + # два разных project_name делят ядро «символ» + project_names=["Символ", "ЖК Символ"], + developers_by_project={}, + ) + assert matched == {} + + +def test_match_groups_dev_conflict_not_matched() -> None: + """Одноимённое ядро, но застройщик группы не подтверждает проект → НЕ матчим.""" + rows = [_obj_id_row(1, "ЖК «Символ»", "Брусника", 56.85, 60.60)] + groups = group_radius_objects(rows) + matched = match_groups_to_projects( + groups, + project_names=["Символ"], + # у проекта другой застройщик — dev-confirm провалится (сигнал есть с обеих сторон) + developers_by_project={"Символ": ["СЗ Эфес"]}, + ) + assert matched == {} + + +def test_match_groups_no_dev_signal_matches_on_core() -> None: + """Нет dev-сигнала (у проекта пустые developers) → матч по уникальному ядру достаточен.""" + rows = [_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60)] + groups = group_radius_objects(rows) + matched = match_groups_to_projects( + groups, + project_names=["Траектория"], + developers_by_project={}, # developers неизвестны → dev-confirm пропускается + ) + assert list(matched.values()) == ["Траектория"] + + +def test_seven_keys_core_match_covered_and_deals_in_aggregates() -> None: + """Реальный кейс #2177: 2-я очередь «7 Ключей» в радиусе матчится по ЯДРУ на + project '7 Ключей' → группа ПОКРЫТА, её сделки входят в агрегаты планировок. + + Радиус содержит ТОЛЬКО obj 15731 (2-я очередь), которого нет в mapping. Раньше + (mapping-only) группа была непокрыта и 162 корпус-месяца сделок не входили. + Теперь velocity-строка приходит с matched_project_names=['7 Ключей'] → coverage + по project_name + deals реально в top_layouts. + """ + id_rows = [_obj_id_row(15731, "2 очередь жилого комплекса «7 ключей»", "Эфес", *_KLYUCHI)] + # velocity-строка: НЕТ mapping-obj_id (competitor_obj_ids=[]), покрытие по имени. + vel_rows = [ + _vel_row( + "2", + deals_window=162.0, + obj_ids=[], + project_names=["7 Ключей"], + ) + ] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + project_names=["7 Ключей", "Симфония"], + developers_by_project={"7 Ключей": ["СЗ Эфес"]}, + ) + req = _request(time_window="last_quarter") + resp = get_best_layouts(db, CAD_NUM, req) + + dq = resp.data_quality + # одна группа в радиусе, покрыта по ядру (не через obj_id mapping) + assert dq.objects_total_in_radius == 1 + assert dq.objects_with_velocity_data == 1 + assert dq.groups_matched_by_core == 1, "покрытие должно идти по core-attribution" + assert dq.velocity_coverage_pct == pytest.approx(100.0, rel=1e-3) + # сделки «7 Ключей» реально вошли в расчёт планировок + assert len(resp.top_layouts) == 1 + assert resp.top_layouts[0].room_bucket == "2" + assert resp.top_layouts[0].total_sold_in_window == 162 + + +def test_matched_project_names_passed_to_velocity_bind() -> None: + """Сматченные по ядру project_name'ы передаются bind-параметром в velocity SQL.""" + from unittest.mock import ANY + + id_rows = [_obj_id_row(15731, "ЖК «Траектория»", "Брусника", *_KLYUCHI)] + vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + project_names=["Траектория"], + developers_by_project={}, + ) + req = _request(time_window="last_quarter") + get_best_layouts(db, CAD_NUM, req) + + # найти вызов velocity SQL и проверить bind matched_project_names + vel_calls = [ + c for c in db.execute.call_args_list if c.args and c.args[0] is _INLINE_VELOCITY_SQL + ] + assert len(vel_calls) == 1 + params = vel_calls[0].args[1] if len(vel_calls[0].args) > 1 else vel_calls[0].kwargs + assert params["matched_project_names"] == ["Траектория"] + assert params["competitor_obj_ids"] == [15731] + # sanity: ANY использован только чтобы не падать на прочих полях + assert params.get("window_interval", ANY) is not None diff --git a/frontend/src/types/best-layouts.ts b/frontend/src/types/best-layouts.ts index f0ad1bdd..6a90d2b0 100644 --- a/frontend/src/types/best-layouts.ts +++ b/frontend/src/types/best-layouts.ts @@ -58,6 +58,9 @@ export interface LayoutDataQuality { objects_with_velocity_data: number; objects_total_in_radius: number; raw_objects_total: number; + // Issue #2177 шаг 2b: сколько комплексов покрыто по ЯДРУ имени (project_name-матч), + // а не через mapping-obj_id — прозрачность источника атрибуции velocity. + groups_matched_by_core: number; velocity_coverage_pct: number; confidence: Confidence; }