Конкуренты ПТИЦЫ: скорость продаж и цена для несопоставленных ЖК берутся по своему проекту, а не по чужим под общим complex_id #3582

Merged
bot-backend merged 4 commits from fix/competitors-bridge into main 2026-09-17 11:16:11 +00:00
3 changed files with 119 additions and 24 deletions
Showing only changes of commit 21fe24419a - Show all commits

View file

@ -354,8 +354,11 @@ _COMPETITORS_SQL = text("""
SELECT DISTINCT ON (o.obj_id) SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.obj_id,
cs.source_id AS project_name, cs.source_id AS project_name,
regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key, -- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key -- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
-- «Парковый» «Парковый квартал»)
( k.comm_key LIKE '%' || k.project_key || '%'
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
FROM latest_obj o FROM latest_obj o
JOIN complexes c JOIN complexes c
ON c.latitude IS NOT NULL ON c.latitude IS NOT NULL
@ -381,6 +384,10 @@ _COMPETITORS_SQL = text("""
SELECT 1 FROM objective_lots ol SELECT 1 FROM objective_lots ol
WHERE ol.project_name = cs.source_id 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 WHERE o.comm_name IS NOT NULL
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё spatial-only AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё spatial-only
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping) AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
@ -388,7 +395,13 @@ _COMPETITORS_SQL = text("""
ST_Distance( ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.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 ( mapped AS (
-- PRIMARY (unchanged): 148 явных маппингов velocity для них byte-identical. -- PRIMARY (unchanged): 148 явных маппингов velocity для них byte-identical.
@ -403,12 +416,11 @@ _COMPETITORS_SQL = text("""
FROM nearest_cx nc FROM nearest_cx nc
-- #2962: связь complex → проект в complex_sources почти вся fuzzy и не -- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
-- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» -- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит»
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз, -- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
-- без регистра и пунктуации («СтудияПарк» = «Студия Парк»). Фильтр стоит -- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
-- ПОСЛЕ DISTINCT ON: внутри join планировщик считал regexp на всех парах -- планировщик считал regexp на всех парах объект × проект (383k) раньше
-- объект × проект (383k) раньше гео-фильтра 3 с против 0.4 с. -- гео-фильтра 3 с против 0.4 с.
WHERE nc.comm_key LIKE '%' || nc.project_key || '%' WHERE nc.name_ok
OR nc.project_key LIKE '%' || nc.comm_key || '%'
), ),
velocity AS ( velocity AS (
SELECT SELECT
@ -643,8 +655,11 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
SELECT DISTINCT ON (o.obj_id) SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.obj_id,
cs.source_id AS project_name, cs.source_id AS project_name,
regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key, -- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key -- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
-- «Парковый» «Парковый квартал»)
( k.comm_key LIKE '%' || k.project_key || '%'
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
FROM domrf_kn_objects o FROM domrf_kn_objects o
JOIN complexes c JOIN complexes c
ON c.latitude IS NOT NULL ON c.latitude IS NOT NULL
@ -667,6 +682,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
SELECT 1 FROM objective_lots ol SELECT 1 FROM objective_lots ol
WHERE ol.project_name = cs.source_id 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) WHERE o.obj_id = ANY(:obj_ids)
AND o.latitude IS NOT NULL AND o.latitude IS NOT NULL
AND o.longitude IS NOT NULL AND o.longitude IS NOT NULL
@ -677,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
ST_Distance( ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.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 ( gapfill_price AS (
SELECT SELECT
@ -688,8 +713,7 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
ON ol.project_name = nc.project_name ON ol.project_name = nc.project_name
AND ol.price_per_m2_rub IS NOT NULL AND ol.price_per_m2_rub IS NOT NULL
-- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962) -- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962)
WHERE nc.comm_key LIKE '%' || nc.project_key || '%' WHERE nc.name_ok
OR nc.project_key LIKE '%' || nc.comm_key || '%'
) )
SELECT SELECT
p.obj_id, p.obj_id,

View file

@ -69,7 +69,7 @@ _SCHEMA = [
project_name text, report_month date, deals_total_count int) ON COMMIT DROP""", 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 = [ _DATA = [
# obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга # 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), (2, 'ЖК VEER PARK', NULL, NULL, NULL, 56.850, 60.620, 500, 'Строящиеся', CURRENT_DATE),
(3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500, (3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500,
'Строящиеся', CURRENT_DATE), 'Строящиеся', 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 objective_complex_mapping VALUES (4, 'Явный')",
"""INSERT INTO complexes VALUES """INSERT INTO complexes VALUES
(10, 'ЖК Мичуринский', 56.840, 60.600), (10, 'ЖК Мичуринский', 56.840, 60.600),
(20, 'ЖК VEER PARK', 56.850, 60.620), (20, 'ЖК VEER PARK', 56.850, 60.620),
(30, 'СтудияПарк', 56.830, 60.580)""", (30, 'СтудияПарк', 56.830, 60.580),
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493) (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 """INSERT INTO complex_sources VALUES
(10, 'objective', 'Мичуринский'), (10, 'objective', 'Мичуринский'),
(20, 'objective', 'Clever Park'), (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=10 лежит и свой лот, и три лота чужого «Малахита» с устаревшим
# complex_id; два своих лота новые — complex_id у них NULL. # complex_id; два своих лота новые — complex_id у них NULL.
"""INSERT INTO objective_lots VALUES """INSERT INTO objective_lots VALUES
@ -97,13 +115,23 @@ _DATA = [
('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000),
('Clever Park', NULL, 150000), ('Clever Park', NULL, 150000),
('Студия Парк', NULL, 90000), ('Студия Парк', NULL, 90000),
('Явный', NULL, 200000)""", ('Явный', NULL, 200000),
('Квартал Татлин', NULL, 95000),
('Парковый квартал', NULL, 130000),
('Роща Парк', NULL, 105000),
('Клён', NULL, 250000),
('Сосны', NULL, 115000)""",
"""INSERT INTO objective_corpus_room_month VALUES """INSERT INTO objective_corpus_room_month VALUES
('Мичуринский', date_trunc('month', now())::date, 30), ('Мичуринский', date_trunc('month', now())::date, 30),
('Малахит', date_trunc('month', now())::date, 300), ('Малахит', date_trunc('month', now())::date, 300),
('Clever Park', date_trunc('month', now())::date, 90), ('Clever Park', date_trunc('month', now())::date, 90),
('Студия Парк', date_trunc('month', now())::date, 15), ('Студия Парк', 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( price_rows = session.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL, _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, "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
}, },
).mappings() ).mappings()
@ -165,7 +193,7 @@ def test_wrong_fuzzy_link_gives_no_numbers(result) -> None:
assert VEER not in price, price 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 тыс.""" """«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс."""
velocity, price = result velocity, price = result
assert velocity[STUDIO] == pytest.approx(5.0), velocity assert velocity[STUDIO] == pytest.approx(5.0), velocity
@ -177,3 +205,42 @@ def test_explicit_mapping_unchanged(result) -> None:
velocity, price = result velocity, price = result
assert velocity[EXPLICIT] == pytest.approx(20.0), velocity assert velocity[EXPLICIT] == pytest.approx(20.0), velocity
assert price[EXPLICIT] == pytest.approx(200000.0), price 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

View file

@ -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_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_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_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_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) ────────────────────────────────── # ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме # Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме