diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index f78862d5..d5faccac 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -354,8 +354,11 @@ _COMPETITORS_SQL = text(""" SELECT DISTINCT ON (o.obj_id) o.obj_id, cs.source_id AS project_name, - regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key, - regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key + -- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и + -- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин», + -- «Парковый» ⊂ «Парковый квартал») + ( k.comm_key LIKE '%' || k.project_key || '%' + OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok FROM latest_obj o JOIN complexes c ON c.latitude IS NOT NULL @@ -381,6 +384,10 @@ _COMPETITORS_SQL = text(""" SELECT 1 FROM objective_lots ol WHERE ol.project_name = cs.source_id ) + CROSS JOIN LATERAL ( + SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key, + regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key + ) k WHERE o.comm_name IS NOT NULL AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping) @@ -388,7 +395,13 @@ _COMPETITORS_SQL = text(""" ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography - ) ASC + ) ASC, + -- #2962: 1:1 complex → objective-проект держится только на данных + -- (UNIQUE(source, source_id) второй проект у complex не запрещает). + -- При двух проектах берём сверенный по имени, иначе DISTINCT ON + -- брал любой, сверка его отвергала, и верный терялся. + name_ok DESC, + cs.source_id ), mapped AS ( -- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical. @@ -403,12 +416,11 @@ _COMPETITORS_SQL = text(""" FROM nearest_cx nc -- #2962: связь complex → проект в complex_sources почти вся fuzzy и не -- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» — - -- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз, - -- без регистра и пунктуации («СтудияПарк» = «Студия Парк»). Фильтр стоит - -- ПОСЛЕ DISTINCT ON: внутри join планировщик считал regexp на всех парах - -- объект × проект (383k) раньше гео-фильтра — 3 с против 0.4 с. - WHERE nc.comm_key LIKE '%' || nc.project_key || '%' - OR nc.project_key LIKE '%' || nc.comm_key || '%' + -- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз + -- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join + -- планировщик считал regexp на всех парах объект × проект (383k) раньше + -- гео-фильтра — 3 с против 0.4 с. + WHERE nc.name_ok ), velocity AS ( SELECT @@ -643,8 +655,11 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" SELECT DISTINCT ON (o.obj_id) o.obj_id, cs.source_id AS project_name, - regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key, - regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key + -- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и + -- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин», + -- «Парковый» ⊂ «Парковый квартал») + ( k.comm_key LIKE '%' || k.project_key || '%' + OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok FROM domrf_kn_objects o JOIN complexes c ON c.latitude IS NOT NULL @@ -667,6 +682,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" SELECT 1 FROM objective_lots ol WHERE ol.project_name = cs.source_id ) + CROSS JOIN LATERAL ( + SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key, + regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key + ) k WHERE o.obj_id = ANY(:obj_ids) AND o.latitude IS NOT NULL AND o.longitude IS NOT NULL @@ -677,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography - ) ASC + ) ASC, + -- #2962: 1:1 complex → objective-проект держится только на данных + -- (UNIQUE(source, source_id) второй проект у complex не запрещает). + -- При двух проектах берём сверенный по имени, иначе DISTINCT ON + -- брал любой, сверка его отвергала, и верный терялся. + name_ok DESC, + cs.source_id ), gapfill_price AS ( SELECT @@ -688,8 +713,7 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" ON ol.project_name = nc.project_name AND ol.price_per_m2_rub IS NOT NULL -- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962) - WHERE nc.comm_key LIKE '%' || nc.project_key || '%' - OR nc.project_key LIKE '%' || nc.comm_key || '%' + WHERE nc.name_ok ) SELECT p.obj_id, diff --git a/backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py b/backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py index 20996211..10905e7c 100644 --- a/backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py +++ b/backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py @@ -69,7 +69,7 @@ _SCHEMA = [ project_name text, report_month date, deals_total_count int) ON COMMIT DROP""", ] -MICH, VEER, STUDIO, EXPLICIT = 1, 2, 3, 4 +MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES = 1, 2, 3, 4, 5, 6, 7, 8 _DATA = [ # obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга @@ -78,17 +78,35 @@ _DATA = [ (2, 'ЖК VEER PARK', NULL, NULL, NULL, 56.850, 60.620, 500, 'Строящиеся', CURRENT_DATE), (3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500, 'Строящиеся', CURRENT_DATE), - (4, 'ЖК Явный', NULL, NULL, NULL, 56.845, 60.640, 500, 'Строящиеся', CURRENT_DATE)""", + (4, 'ЖК Явный', NULL, NULL, NULL, 56.845, 60.640, 500, 'Строящиеся', CURRENT_DATE), + (5, 'Квартал "Татлин"', NULL, NULL, NULL, 56.835, 60.610, 500, 'Строящиеся', + CURRENT_DATE), + (6, 'Парковый', NULL, NULL, NULL, 56.855, 60.590, 500, 'Строящиеся', CURRENT_DATE), + (7, 'ЖК Роща Парк', NULL, NULL, NULL, 56.825, 60.630, 500, 'Строящиеся', CURRENT_DATE), + (8, 'ЖК Сосны', NULL, NULL, NULL, 56.860, 60.650, 500, 'Строящиеся', CURRENT_DATE)""", "INSERT INTO objective_complex_mapping VALUES (4, 'Явный')", """INSERT INTO complexes VALUES (10, 'ЖК Мичуринский', 56.840, 60.600), (20, 'ЖК VEER PARK', 56.850, 60.620), - (30, 'СтудияПарк', 56.830, 60.580)""", - # 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493) + (30, 'СтудияПарк', 56.830, 60.580), + (50, 'Квартал "Татлин"', 56.835, 60.610), + (60, 'Парковый', 56.855, 60.590), + (70, 'Роща', 56.825, 60.630), + (71, 'Роща Парк', 56.8259, 60.630), + (80, 'ЖК Сосны', 56.860, 60.650)""", + # 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493). + # 70 → 'Роща': проект без лотов, complex ближе «Роща Парк» (71, в 100 м). + # 80 → два objective-проекта: UNIQUE(source, source_id) этого не запрещает. """INSERT INTO complex_sources VALUES (10, 'objective', 'Мичуринский'), (20, 'objective', 'Clever Park'), - (30, 'objective', 'Студия Парк')""", + (30, 'objective', 'Студия Парк'), + (50, 'objective', 'Квартал Татлин'), + (60, 'objective', 'Парковый квартал'), + (70, 'objective', 'Роща'), + (71, 'objective', 'Роща Парк'), + (80, 'objective', 'Клён'), + (80, 'objective', 'Сосны')""", # Под complex_id=10 лежит и свой лот, и три лота чужого «Малахита» с устаревшим # complex_id; два своих лота новые — complex_id у них NULL. """INSERT INTO objective_lots VALUES @@ -97,13 +115,23 @@ _DATA = [ ('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Clever Park', NULL, 150000), ('Студия Парк', NULL, 90000), - ('Явный', NULL, 200000)""", + ('Явный', NULL, 200000), + ('Квартал Татлин', NULL, 95000), + ('Парковый квартал', NULL, 130000), + ('Роща Парк', NULL, 105000), + ('Клён', NULL, 250000), + ('Сосны', NULL, 115000)""", """INSERT INTO objective_corpus_room_month VALUES ('Мичуринский', date_trunc('month', now())::date, 30), ('Малахит', date_trunc('month', now())::date, 300), ('Clever Park', date_trunc('month', now())::date, 90), ('Студия Парк', date_trunc('month', now())::date, 15), - ('Явный', date_trunc('month', now())::date, 60)""", + ('Явный', date_trunc('month', now())::date, 60), + ('Квартал Татлин', date_trunc('month', now())::date, 18), + ('Парковый квартал', date_trunc('month', now())::date, 12), + ('Роща Парк', date_trunc('month', now())::date, 21), + ('Клён', date_trunc('month', now())::date, 240), + ('Сосны', date_trunc('month', now())::date, 27)""", ] @@ -135,7 +163,7 @@ def result() -> tuple[dict[int, float], dict[int, float]]: price_rows = session.execute( _OBJECTIVE_PRICE_FALLBACK_SQL, { - "obj_ids": [MICH, VEER, STUDIO, EXPLICIT], + "obj_ids": [MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES], "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M, }, ).mappings() @@ -165,7 +193,7 @@ def test_wrong_fuzzy_link_gives_no_numbers(result) -> None: assert VEER not in price, price -def test_punctuation_difference_still_matches(result) -> None: +def test_space_difference_still_matches(result) -> None: """«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс.""" velocity, price = result assert velocity[STUDIO] == pytest.approx(5.0), velocity @@ -177,3 +205,42 @@ def test_explicit_mapping_unchanged(result) -> None: velocity, price = result assert velocity[EXPLICIT] == pytest.approx(20.0), velocity assert price[EXPLICIT] == pytest.approx(200000.0), price + + +def test_quotes_inside_name_still_match(result) -> None: + """«Квартал "Татлин"» и «Квартал Татлин»: кавычки посреди имени сверку не ломают. + + Убрать только пробелы мало: «квартал"татлин"» и «кварталтатлин» не входят друг в + друга. 18 сделок / 3 = 6, цена 95 тыс. + """ + velocity, price = result + assert velocity[TATLIN] == pytest.approx(6.0), velocity + assert price[TATLIN] == pytest.approx(95000.0), price + + +def test_project_name_longer_than_object_name_matches(result) -> None: + """Объект «Парковый», проект «Парковый квартал»: имя сверяется в обе стороны. 12 / 3 = 4.""" + velocity, price = result + assert velocity[PARK] == pytest.approx(4.0), velocity + assert price[PARK] == pytest.approx(130000.0), price + + +def test_nearest_complex_without_lots_does_not_eat_the_match(result) -> None: + """#968: ближайший complex «Роща» без лотов не забирает объект у «Роща Парк» в 100 м. + + Иначе у «ЖК Роща Парк» не было бы ни скорости, ни цены. 21 / 3 = 7, цена 105 тыс. + """ + velocity, price = result + assert velocity[GROVE] == pytest.approx(7.0), velocity + assert price[GROVE] == pytest.approx(105000.0), price + + +def test_complex_with_two_projects_takes_the_matching_one(result) -> None: + """У complex два objective-проекта, «Клён» и «Сосны»: берём сверенный по имени. + + Без этого DISTINCT ON брал любой из двух (здесь — «Клён»), сверка его отвергала, + и «Сосны» терялись. 27 / 3 = 9, цена 115 тыс. + """ + velocity, price = result + assert velocity[PINES] == pytest.approx(9.0), velocity + assert price[PINES] == pytest.approx(115000.0), price diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index bcde3d05..fe4b428e 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -154,8 +154,12 @@ tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_velocity_counts_only_own_project tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_price_is_median_of_own_lots tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_wrong_fuzzy_link_gives_no_numbers -tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_punctuation_difference_still_matches +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_space_difference_still_matches tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_explicit_mapping_unchanged +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_quotes_inside_name_still_match +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project_name_longer_than_object_name_matches +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one # ── #2464: backfill act_date (миграция 191) ────────────────────────────────── # Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме