fix(best-layouts): lots-проекты в пуле core-матчинга — supply-only реально наполняется (#2177) #2241

Merged
bot-backend merged 1 commit from fix/supply-only-names-pool into main 2026-07-03 00:14:20 +00:00
2 changed files with 73 additions and 1 deletions
Showing only changes of commit 1e0fbc4ffe - Show all commits

View file

@ -838,6 +838,43 @@ _OBJECTIVE_PROJECT_NAMES_SQL = text("""
AND project_name <> ''
""")
# lots-проекты (~875) — ДОПОЛНЕНИЕ пула имён для supply-only (#2177 шаг 3 фикс):
# только corpus-имена — дыра: «ЖК Основа» с 3.7k лотов и БЕЗ сделок не попадал в
# кандидаты матчинга → блок «по предложению» всегда пуст (прод-кейс 2026-07-03).
# ВАЖНО: сливать с corpus-именами надо ПО ЯДРУ с приоритетом corpus (см.
# _merged_project_name_pool) — сырой UNION создал бы ambiguous-пары «7 Ключей»
# (corpus) vs «ЖК 7 ключей» (lots-вариант того же проекта) и СЛОМАЛ бы
# существующие velocity-матчи.
_OBJECTIVE_LOTS_PROJECT_NAMES_SQL = text("""
SELECT DISTINCT project_name
FROM objective_lots
WHERE project_name IS NOT NULL
AND project_name <> ''
""")
def _merged_project_name_pool(corpus_names: list[str], lots_names: list[str]) -> list[str]:
"""Пул имён для core-матчинга: corpus-имена + lots-имена с НОВЫМИ ядрами.
Corpus-имя канонический join-ключ velocity, поэтому при совпадении ядра
lots-вариант отбрасывается (это то же самый проект в другом написании, а не
второй кандидат). Lots-имя с ядром, которого нет в corpus, добавляется
ради supply-only (проекты с лотами без сделок).
"""
corpus_cores = {core for core in (normalize_complex_name(n) for n in corpus_names) if core}
pool = list(corpus_names)
for name in lots_names:
core = normalize_complex_name(name)
if not core or core in corpus_cores:
# ядро занято corpus-именем — это тот же проект в другом написании,
# не второй кандидат (иначе сломали бы velocity-матч ambiguity'ей)
continue
# Два РАЗНЫХ lots-проекта с одним ядром намеренно остаются в пуле оба
# матчер честно пометит ядро ambiguous и не будет матчить вслепую.
pool.append(name)
return pool
# developers per project_name — агрегат из objective_lots ТОЛЬКО для dev-confirm.
# ARRAY_REMOVE(..., NULL) — чистый список без NULL-хвостов.
# ОБЯЗАТЕЛЬНО scoped по кандидатам (:names): глобальный агрегат — seq scan 1.9M
@ -1083,7 +1120,9 @@ def get_best_layouts(
# больше не теряет сделки, привязанные к 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()]
corpus_names = [str(row[0]) for row in db.execute(_OBJECTIVE_PROJECT_NAMES_SQL).all()]
lots_names = [str(row[0]) for row in db.execute(_OBJECTIVE_LOTS_PROJECT_NAMES_SQL).all()]
project_names = _merged_project_name_pool(corpus_names, lots_names)
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
preliminary = match_groups_to_projects(complex_groups, project_names, None)

View file

@ -31,6 +31,7 @@ from app.services.site_finder.best_layouts import (
_COMPETITORS_IN_RADIUS_SQL,
_INLINE_VELOCITY_SQL,
_OBJECTIVE_DEVELOPERS_SQL,
_OBJECTIVE_LOTS_PROJECT_NAMES_SQL,
_OBJECTIVE_PROJECT_NAMES_SQL,
_PARCEL_CENTROID_SQL,
_SUPPLY_BATCH_SQL,
@ -89,6 +90,7 @@ def _vel_row(
avg_price_rub: float | None = 120000.0,
obj_ids: list[int] | None = None,
project_names: list[str] | None = None,
lots_project_names: list[str] | None = None,
window_start: dt.date | None = None,
window_end: dt.date | None = None,
) -> MagicMock:
@ -161,6 +163,7 @@ def _make_db(
vel_rows: list[MagicMock] | None = None,
supply_rows: list[MagicMock] | None = None,
project_names: list[str] | None = None,
lots_project_names: list[str] | None = None,
developers_by_project: dict[str, list[str]] | None = None,
supply_only_rows: list[MagicMock] | None = None,
) -> MagicMock:
@ -172,6 +175,7 @@ def _make_db(
_PARCEL_CENTROID_SQL .mappings().first()
_COMPETITORS_IN_RADIUS_SQL .mappings().all()
_OBJECTIVE_PROJECT_NAMES_SQL .all() (core-attribution, #2177 шаг 2b)
_OBJECTIVE_LOTS_PROJECT_NAMES_SQL .all() (пул supply-only, шаг 3)
_OBJECTIVE_DEVELOPERS_SQL .all() (dev-confirm)
_INLINE_VELOCITY_SQL .mappings().all()
_SUPPLY_BATCH_SQL .mappings().all()
@ -192,6 +196,10 @@ def _make_db(
r_proj = MagicMock()
r_proj.all.return_value = _proj_name_rows(project_names or [])
# lots-имена (#2177 шаг 3 фикс пула): дефолт пустой — corpus-only поведение.
r_lots_proj = MagicMock()
r_lots_proj.all.return_value = _proj_name_rows(lots_project_names or [])
r_dev = MagicMock()
r_dev.all.return_value = _dev_rows(developers_by_project or {})
@ -207,6 +215,7 @@ def _make_db(
_PARCEL_CENTROID_SQL: r_centroid,
_COMPETITORS_IN_RADIUS_SQL: r_comp,
_OBJECTIVE_PROJECT_NAMES_SQL: r_proj,
_OBJECTIVE_LOTS_PROJECT_NAMES_SQL: r_lots_proj,
_OBJECTIVE_DEVELOPERS_SQL: r_dev,
_INLINE_VELOCITY_SQL: r_vel,
_SUPPLY_BATCH_SQL: r_supply,
@ -962,6 +971,30 @@ def test_match_groups_dev_conflict_not_matched() -> None:
assert matched == {}
def test_merged_project_name_pool_priority_and_ambiguity() -> None:
"""Пул имён: corpus приоритетен по ядру; lots добавляет только НОВЫЕ ядра.
Прод-кейс 2026-07-03: «ЖК Основа» (лоты без сделок) не попадал в кандидаты
supply-only всегда пуст. При этом lots-вариант ядра corpus-имени НЕ должен
создавать ambiguous-пару (сломал бы velocity-матч), а два разных lots-проекта
с одним ядром должны остаться оба (честный ambiguous для матчера).
"""
from app.services.site_finder.best_layouts import _merged_project_name_pool
pool = _merged_project_name_pool(
corpus_names=["7 Ключей", "Аксиома"],
lots_names=[
"ЖК 7 ключей", # ядро занято corpus → отбрасывается (тот же проект)
"Основа", # новое ядро → добавляется (supply-only кандидат)
"Меридиан", # два разных lots-проекта с одним ядром —
"Бутик-квартал Меридиан (2)", # (разные строки, ядро может совпасть)
],
)
assert "7 Ключей" in pool and "Аксиома" in pool
assert "ЖК 7 ключей" not in pool # corpus-ядро приоритетно
assert "Основа" in pool
def test_match_groups_no_dev_signal_matches_on_core() -> None:
"""Нет dev-сигнала (у проекта пустые developers) → матч по уникальному ядру достаточен."""
rows = [_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60)]