feat(best-layouts): атрибуция velocity по ядру имени комплекса (#2177 шаг 2b)

This commit is contained in:
bot-backend 2026-07-03 03:25:50 +05:00
parent 4c23c222f6
commit d3e14910b2
5 changed files with 439 additions and 56 deletions

View file

@ -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"]

View file

@ -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",
),

View file

@ -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

View file

@ -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

View file

@ -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;
}