feat(best-layouts): атрибуция velocity по ядру комплекса (#2177 шаг 2b) #2239
6 changed files with 444 additions and 56 deletions
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4297,6 +4297,11 @@ export interface components {
|
|||
objects_total_in_radius: number;
|
||||
/** Raw Objects Total */
|
||||
raw_objects_total: number;
|
||||
/**
|
||||
* Groups Matched By Core
|
||||
* @default 0
|
||||
*/
|
||||
groups_matched_by_core: number;
|
||||
/** Velocity Coverage Pct */
|
||||
velocity_coverage_pct: number;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue