diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index b599c368..d619a1ef 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -260,10 +260,12 @@ jobs: # #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки # не бежали ни разу с момента написания. # - # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные - # таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему — - # проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не - # нужен, в отличие от tradein-лэйна. + # postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные + # таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL + # модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через + # ST_DWithin по geography, без расширения его не исполнить. На plain + # postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не + # проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB. # # TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/ # (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по @@ -294,15 +296,14 @@ jobs: # ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД # ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере. # - # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные - # таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы - # здесь не нужен вовсе, в отличие от tradein-лэйна. + # postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap + # схемы здесь не нужен вовсе, в отличие от tradein-лэйна. run: | set -u docker rm -fv "$CI_PG" >/dev/null 2>&1 || true docker run -d --name "$CI_PG" \ -e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \ - postgres:16 + postgis/postgis:16-3.4 ready="" for _ in $(seq 1 45); do diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index 8a19ef14..d5faccac 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -344,21 +344,26 @@ _COMPETITORS_SQL = text(""" -- важна — velocity питает forecast). Спатиальный join дёшев, т.к. -- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj -- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT). + -- + -- #2962: complex → проект Объектива берём из complex_sources (source= + -- 'objective', строго 1:1), а НЕ из objective_lots.complex_id. Тот проставлен + -- один раз миграцией 76 на загрузке 10.05, а еженедельный + -- 70_parse_objective_raw.py UPSERT'ом по objective_lot_id переписывает + -- project_name и не трогает complex_id → под одним complex_id лежат чужие ЖК + -- (прод 17.09: 236 354 из 303 677 строк, у «ЖК Мичуринский» 129 проектов). SELECT DISTINCT ON (o.obj_id) o.obj_id, - c.id AS complex_id + cs.source_id AS project_name, + -- #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 AND c.longitude IS NOT NULL AND c.canonical_name IS NOT NULL - -- #968: только complexes, у которых ЕСТЬ objective velocity-данные → - -- «ближайший» выбирается среди data-bearing. Иначе ближайший complex - -- без objective_lots съедал бы матч (prod: +37 наивно → +181 так). - AND EXISTS ( - SELECT 1 FROM objective_lots ol - WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL - ) AND ST_DWithin( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, @@ -369,6 +374,20 @@ _COMPETITORS_SQL = text(""" OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%' ) + JOIN complex_sources cs + ON cs.complex_id = c.id + AND cs.source = 'objective' + -- #968: только complexes, у которых ЕСТЬ objective-данные → + -- «ближайший» выбирается среди data-bearing. Иначе ближайший complex + -- без objective_lots съедал бы матч (prod: +37 наивно → +181 так). + AND EXISTS ( + 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) @@ -376,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. @@ -384,15 +409,18 @@ _COMPETITORS_SQL = text(""" cm.objective_complex_name FROM objective_complex_mapping cm UNION - -- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а - -- (у комплекса может быть несколько корпус-project_name → velocity - -- легитимно суммируется по ним, но НЕ по нескольким комплексам). + -- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а + -- (корпуса у Объектива — corpus_name внутри проекта, не отдельные имена). SELECT nc.obj_id, - ol.project_name AS objective_complex_name + nc.project_name AS objective_complex_name FROM nearest_cx nc - JOIN objective_lots ol - ON ol.complex_id = nc.complex_id - AND ol.project_name IS NOT NULL + -- #2962: связь complex → проект в complex_sources почти вся fuzzy и не + -- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» — + -- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз + -- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join + -- планировщик считал regexp на всех парах объект × проект (383k) раньше + -- гео-фильтра — 3 с против 0.4 с. + WHERE nc.name_ok ), velocity AS ( SELECT @@ -605,9 +633,11 @@ _SOLD_COUNT_SQL = text(""" # fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает # avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots: # PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name -# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id → -# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и -# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК). +# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources +# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON +# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и +# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под +# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx). # obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ # mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен. _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" @@ -624,16 +654,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" nearest_cx AS ( SELECT DISTINCT ON (o.obj_id) o.obj_id, - c.id AS complex_id + cs.source_id AS project_name, + -- #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 AND c.longitude IS NOT NULL AND c.canonical_name IS NOT NULL - AND EXISTS ( - SELECT 1 FROM objective_lots ol - WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL - ) AND ST_DWithin( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, @@ -644,6 +675,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%' ) + JOIN complex_sources cs + ON cs.complex_id = c.id + AND cs.source = 'objective' + AND EXISTS ( + 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 @@ -654,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 @@ -662,8 +710,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" ol.price_per_m2_rub AS price_per_m2_rub FROM nearest_cx nc JOIN objective_lots ol - ON ol.complex_id = nc.complex_id + ON ol.project_name = nc.project_name AND ol.price_per_m2_rub IS NOT NULL + -- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962) + 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 new file mode 100644 index 00000000..10905e7c --- /dev/null +++ b/backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py @@ -0,0 +1,246 @@ +"""Gap-fill конкурентов берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962). + +`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный +`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и +не трогает complex_id. Прод 17.09.2026: из 303 677 строк с complex_id у 236 354 +проект чужой; у 185 gap-fill конкурентов своих лотов 23 %, скорость завышена в +медиане в 39 раз. + +Тест герметичный и прогоняет НАСТОЯЩИЕ `_COMPETITORS_SQL` и +`_OBJECTIVE_PRICE_FALLBACK_SQL`: временные таблицы затеняют боевые в пределах сессии. +Нужен Postgres с PostGIS (ST_DWithin по geography). В CI он есть +(.forgejo/workflows/ci.yml, образ postgis/postgis), и там тест не пропускается +никогда: без PostGIS он падает с настоящей причиной. Пропуск разрешён только вне CI, +на машине без базы, и объявлен в skip_allowlist.txt. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"] + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _postgis_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first(): + return True, "" + return False, "нет расширения postgis" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _postgis_reachable() +_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI")) +pytestmark = pytest.mark.skipif( + not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}" +) + +_SCHEMA = [ + """CREATE TEMP TABLE domrf_kn_objects ( + obj_id bigint, comm_name text, dev_name text, obj_class text, + obj_class_fallback text, latitude double precision, longitude double precision, + flat_count int, site_status text, snapshot_date date) ON COMMIT DROP""", + """CREATE TEMP TABLE objective_complex_mapping ( + domrf_obj_id bigint, objective_complex_name text) ON COMMIT DROP""", + """CREATE TEMP TABLE complexes ( + id bigint, canonical_name text, latitude double precision, + longitude double precision) ON COMMIT DROP""", + """CREATE TEMP TABLE complex_sources ( + complex_id bigint, source text, source_id text) ON COMMIT DROP""", + """CREATE TEMP TABLE objective_lots ( + project_name text, complex_id bigint, price_per_m2_rub numeric) ON COMMIT DROP""", + """CREATE TEMP TABLE objective_corpus_room_month ( + project_name text, report_month date, deals_total_count int) ON COMMIT DROP""", +] + +MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES = 1, 2, 3, 4, 5, 6, 7, 8 + +_DATA = [ + # obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга + """INSERT INTO domrf_kn_objects VALUES + (1, 'ЖК Мичуринский', NULL, NULL, NULL, 56.840, 60.600, 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, + 'Строящиеся', 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), + (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', 'Студия Парк'), + (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 + ('Мичуринский', 10, 100000), ('Мичуринский', NULL, 110000), + ('Мичуринский', NULL, 120000), + ('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000), + ('Clever Park', NULL, 150000), + ('Студия Парк', NULL, 90000), + ('Явный', 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, 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)""", +] + + +@pytest.fixture(scope="module") +def result() -> tuple[dict[int, float], dict[int, float]]: + """(velocity по obj_id, медианная цена objective-fallback по obj_id).""" + from app.services.site_finder.competitors import ( + _COMPETITORS_SQL, + _OBJECTIVE_PRICE_FALLBACK_SQL, + _VELOCITY_MATCH_RADIUS_M, + ) + + session = sessionmaker(bind=create_engine(_dsn()))() + try: + for stmt in _SCHEMA + _DATA: + session.execute(text(stmt)) + vel_rows = session.execute( + _COMPETITORS_SQL, + { + "center_lon": 60.6, + "center_lat": 56.84, + "radius_m": 5000.0, + "time_window_months": 3.0, + "window_interval": "3 months", + "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M, + }, + ).mappings() + velocity = {int(r["obj_id"]): float(r["velocity_per_month"]) for r in vel_rows} + price_rows = session.execute( + _OBJECTIVE_PRICE_FALLBACK_SQL, + { + "obj_ids": [MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES], + "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M, + }, + ).mappings() + price = {int(r["obj_id"]): float(r["median_price_per_m2"]) for r in price_rows} + return velocity, price + finally: + session.rollback() + session.close() + + +def test_gapfill_velocity_counts_only_own_project(result) -> None: + """30 сделок своего ЖК за 3 месяца = 10/мес; с чужим «Малахитом» было бы 110.""" + velocity, _ = result + assert velocity[MICH] == pytest.approx(10.0), velocity + + +def test_gapfill_price_is_median_of_own_lots(result) -> None: + """Медиана своих лотов 100/110/120 тыс. = 110 тыс.; по complex_id было бы 300 тыс.""" + _, price = result + assert price[MICH] == pytest.approx(110000.0), price + + +def test_wrong_fuzzy_link_gives_no_numbers(result) -> None: + """«ЖК VEER PARK» связан в complex_sources с 'Clever Park' — чужие цифры не берём.""" + velocity, price = result + assert velocity[VEER] == 0.0, velocity + assert VEER not in price, price + + +def test_space_difference_still_matches(result) -> None: + """«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс.""" + velocity, price = result + assert velocity[STUDIO] == pytest.approx(5.0), velocity + assert price[STUDIO] == pytest.approx(90000.0), price + + +def test_explicit_mapping_unchanged(result) -> None: + """Явный маппинг правка не трогает: 60 / 3 = 20, цена 200 тыс.""" + 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 e77642d9..fe4b428e 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -144,6 +144,23 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged + +# ── #2962: мост gap-fill конкурентов (complex_sources → project_name) ───────── +# Нужен Postgres С PostGIS: тесты затеняют боевые таблицы временными и гоняют +# НАСТОЯЩИЕ _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL, а там ST_DWithin по +# geography. В CI ИДУТ (ci.yml поднимает postgis/postgis:16-3.4) и пропуститься +# там НЕ МОГУТ: при CI=true skipif выключен, без PostGIS тест падает. Записи нужны +# только для машины без базы. +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_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 в прод-форме # (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции