fix(best-layouts): lots-проекты в пуле core-матчинга — supply-only реально наполняется (#2177) (#2241)
This commit is contained in:
parent
9cfefc0ca4
commit
1bba3db7eb
2 changed files with 73 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue