ПТИЦА конкуренты: у complex с двумя objective-проектами берётся сверенный по имени, тесты на сверку в обе стороны, кавычки и #968 (#2962)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m45s
CI / openapi-codegen-check (pull_request) Successful in 2m50s
CI / backend-tests (pull_request) Successful in 7m14s

По ревью PR #3582. Три мутации тесты не ловили (5 passed): нормализация
только пробелов, односторонний LIKE, снятый EXISTS #968. Плюс латентный
дефект: UNIQUE(source, source_id) не запрещает complex иметь два
objective-проекта, DISTINCT ON брал любой, сверка его отвергала, и верный
проект терялся (воспроизведено: complex с «Клён» и «Сосны», брался «Клён»).

- nearest_cx в обоих SQL: нормализованные ключи в LATERAL, сверка name_ok
  считается там же и стоит в ORDER BY после расстояния (name_ok DESC,
  source_id) — при двух проектах берётся сверенный, выбор детерминирован.
  Фильтр по-прежнему ПОСЛЕ DISTINCT ON.
- тесты: «Квартал "Татлин"» = «Квартал Татлин» (кавычки посреди имени),
  «Парковый» ⊂ «Парковый квартал» (обратная сторона LIKE), ближайший
  complex без лотов не съедает матч (#968), complex с двумя проектами.
  Старый тест пунктуации проверял пробел — переименован честно.

Прод 17.09 (только чтение): выбор nearest_cx у всех 185 gap-fill объектов
в обоих SQL совпал с головой PR (0 расхождений, 176 принято, 9 отвергнуто);
время на центре ЕКБ 1 км: конкуренты 70 → 69 мс, цена 103 → 103 мс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 15:33:25 +05:00
parent 33182e7df9
commit 21fe24419a
3 changed files with 119 additions and 24 deletions

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