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 <> ''
|
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.
|
# developers per project_name — агрегат из objective_lots ТОЛЬКО для dev-confirm.
|
||||||
# ARRAY_REMOVE(..., NULL) — чистый список без NULL-хвостов.
|
# ARRAY_REMOVE(..., NULL) — чистый список без NULL-хвостов.
|
||||||
# ОБЯЗАТЕЛЬНО scoped по кандидатам (:names): глобальный агрегат — seq scan 1.9M
|
# ОБЯЗАТЕЛЬНО scoped по кандидатам (:names): глобальный агрегат — seq scan 1.9M
|
||||||
|
|
@ -1083,7 +1120,9 @@ def get_best_layouts(
|
||||||
# больше не теряет сделки, привязанные к 1-й через mapping).
|
# больше не теряет сделки, привязанные к 1-й через mapping).
|
||||||
core_group_to_project: dict[str, str] = {}
|
core_group_to_project: dict[str, str] = {}
|
||||||
try:
|
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
|
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
|
||||||
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
|
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
|
||||||
preliminary = match_groups_to_projects(complex_groups, project_names, None)
|
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,
|
_COMPETITORS_IN_RADIUS_SQL,
|
||||||
_INLINE_VELOCITY_SQL,
|
_INLINE_VELOCITY_SQL,
|
||||||
_OBJECTIVE_DEVELOPERS_SQL,
|
_OBJECTIVE_DEVELOPERS_SQL,
|
||||||
|
_OBJECTIVE_LOTS_PROJECT_NAMES_SQL,
|
||||||
_OBJECTIVE_PROJECT_NAMES_SQL,
|
_OBJECTIVE_PROJECT_NAMES_SQL,
|
||||||
_PARCEL_CENTROID_SQL,
|
_PARCEL_CENTROID_SQL,
|
||||||
_SUPPLY_BATCH_SQL,
|
_SUPPLY_BATCH_SQL,
|
||||||
|
|
@ -89,6 +90,7 @@ def _vel_row(
|
||||||
avg_price_rub: float | None = 120000.0,
|
avg_price_rub: float | None = 120000.0,
|
||||||
obj_ids: list[int] | None = None,
|
obj_ids: list[int] | None = None,
|
||||||
project_names: list[str] | None = None,
|
project_names: list[str] | None = None,
|
||||||
|
lots_project_names: list[str] | None = None,
|
||||||
window_start: dt.date | None = None,
|
window_start: dt.date | None = None,
|
||||||
window_end: dt.date | None = None,
|
window_end: dt.date | None = None,
|
||||||
) -> MagicMock:
|
) -> MagicMock:
|
||||||
|
|
@ -161,6 +163,7 @@ def _make_db(
|
||||||
vel_rows: list[MagicMock] | None = None,
|
vel_rows: list[MagicMock] | None = None,
|
||||||
supply_rows: list[MagicMock] | None = None,
|
supply_rows: list[MagicMock] | None = None,
|
||||||
project_names: list[str] | None = None,
|
project_names: list[str] | None = None,
|
||||||
|
lots_project_names: list[str] | None = None,
|
||||||
developers_by_project: dict[str, list[str]] | None = None,
|
developers_by_project: dict[str, list[str]] | None = None,
|
||||||
supply_only_rows: list[MagicMock] | None = None,
|
supply_only_rows: list[MagicMock] | None = None,
|
||||||
) -> MagicMock:
|
) -> MagicMock:
|
||||||
|
|
@ -172,6 +175,7 @@ def _make_db(
|
||||||
• _PARCEL_CENTROID_SQL → .mappings().first()
|
• _PARCEL_CENTROID_SQL → .mappings().first()
|
||||||
• _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
|
• _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
|
||||||
• _OBJECTIVE_PROJECT_NAMES_SQL → .all() (core-attribution, #2177 шаг 2b)
|
• _OBJECTIVE_PROJECT_NAMES_SQL → .all() (core-attribution, #2177 шаг 2b)
|
||||||
|
• _OBJECTIVE_LOTS_PROJECT_NAMES_SQL → .all() (пул supply-only, шаг 3)
|
||||||
• _OBJECTIVE_DEVELOPERS_SQL → .all() (dev-confirm)
|
• _OBJECTIVE_DEVELOPERS_SQL → .all() (dev-confirm)
|
||||||
• _INLINE_VELOCITY_SQL → .mappings().all()
|
• _INLINE_VELOCITY_SQL → .mappings().all()
|
||||||
• _SUPPLY_BATCH_SQL → .mappings().all()
|
• _SUPPLY_BATCH_SQL → .mappings().all()
|
||||||
|
|
@ -192,6 +196,10 @@ def _make_db(
|
||||||
r_proj = MagicMock()
|
r_proj = MagicMock()
|
||||||
r_proj.all.return_value = _proj_name_rows(project_names or [])
|
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 = MagicMock()
|
||||||
r_dev.all.return_value = _dev_rows(developers_by_project or {})
|
r_dev.all.return_value = _dev_rows(developers_by_project or {})
|
||||||
|
|
||||||
|
|
@ -207,6 +215,7 @@ def _make_db(
|
||||||
_PARCEL_CENTROID_SQL: r_centroid,
|
_PARCEL_CENTROID_SQL: r_centroid,
|
||||||
_COMPETITORS_IN_RADIUS_SQL: r_comp,
|
_COMPETITORS_IN_RADIUS_SQL: r_comp,
|
||||||
_OBJECTIVE_PROJECT_NAMES_SQL: r_proj,
|
_OBJECTIVE_PROJECT_NAMES_SQL: r_proj,
|
||||||
|
_OBJECTIVE_LOTS_PROJECT_NAMES_SQL: r_lots_proj,
|
||||||
_OBJECTIVE_DEVELOPERS_SQL: r_dev,
|
_OBJECTIVE_DEVELOPERS_SQL: r_dev,
|
||||||
_INLINE_VELOCITY_SQL: r_vel,
|
_INLINE_VELOCITY_SQL: r_vel,
|
||||||
_SUPPLY_BATCH_SQL: r_supply,
|
_SUPPLY_BATCH_SQL: r_supply,
|
||||||
|
|
@ -962,6 +971,30 @@ def test_match_groups_dev_conflict_not_matched() -> None:
|
||||||
assert matched == {}
|
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:
|
def test_match_groups_no_dev_signal_matches_on_core() -> None:
|
||||||
"""Нет dev-сигнала (у проекта пустые developers) → матч по уникальному ядру достаточен."""
|
"""Нет dev-сигнала (у проекта пустые developers) → матч по уникальному ядру достаточен."""
|
||||||
rows = [_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60)]
|
rows = [_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60)]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue