From 1bba3db7eb303c1063c73231cb78d6b0e8e286b2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 00:14:19 +0000 Subject: [PATCH] =?UTF-8?q?fix(best-layouts):=20lots-=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D1=8B=20=D0=B2=20=D0=BF=D1=83=D0=BB=D0=B5=20?= =?UTF-8?q?core-=D0=BC=D0=B0=D1=82=D1=87=D0=B8=D0=BD=D0=B3=D0=B0=20?= =?UTF-8?q?=E2=80=94=20supply-only=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20(#2177)=20(#2241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/site_finder/best_layouts.py | 41 ++++++++++++++++++- .../services/site_finder/test_best_layouts.py | 33 +++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 1250dcb9..0f71d933 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -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) diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 4a07d593..001c86cb 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -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)]