Конкуренты ПТИЦЫ: скорость продаж и цена для несопоставленных ЖК берутся по своему проекту, а не по чужим под общим 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)
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,

View file

@ -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

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_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 в прод-форме