ПТИЦА конкуренты: у 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
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:
parent
33182e7df9
commit
21fe24419a
3 changed files with 119 additions and 24 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 в прод-форме
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue