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

Merged
bot-backend merged 4 commits from fix/competitors-bridge into main 2026-09-17 11:16:11 +00:00
Collaborator

#2962 — gap-fill конкурентов тянул чужие ЖК

Что было

Для конкурентов, которых нет в objective_complex_mapping, скорость продаж (_COMPETITORS_SQL, CTE mapped) и ценовой fallback (_OBJECTIVE_PRICE_FALLBACK_SQL) собирались так: ближайший complex → objective_lots.complex_id → все project_name под этим id.

Откуда под complex_id чужие проекты (установлено чтением прода 17.09)

complex_id проставлен ровно один раз, миграцией 76 по complex_sources. Позже его никто не обновляет, а еженедельный 70_parse_objective_raw.py делает UPSERT ON CONFLICT (objective_lot_id) и переписывает project_name, не трогая complex_id:

строки с complex_id строк разных project_name fetched_at updated_at
проект совпадает с complex_sources.source_id 67 323 345 все 10.05 17.05–15.09
проект чужой 236 354 649 все 10.05 17.05–15.09

Все строки с complex_id вставлены одной загрузкой 10.05, а переписаны позже. В последних снапшотах почти каждая переписанная строка уезжает в чужой проект: 15.09 — 23 861 чужих и 118 своих. Среди строк без complex_id (2 917 327) новых записей с id нет. Других писателей complex_id в коде нет (grep по backend/app, data/sql).

При этом complex_sources (source='objective') — 350 строк строго 1:1, и по project_name эти 350 проектов покрывают 2 863 799 лотов, у каждого лоты есть.

Что сделано

  1. В обоих SQL complex связывается с проектом через complex_sources (source='objective'), лоты и сделки берутся по project_name. objective_lots.complex_id больше не читается.
  2. Связь в complex_sources почти вся fuzzy и не проверена: у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Клевер парк» — 'Веер Парк', у «ЖК Графит» — 'Гранит'. Поэтому имя проекта ещё раз сверяется с именем объекта ДОМ.РФ, без регистра и пунктуации, в обе стороны («СтудияПарк» = «Студия Парк», «Квартал "Татлин"» = «Квартал Татлин», «Парковый» ⊂ «Парковый квартал»). Сверка как фильтр стоит после DISTINCT ON. Внутри join планировщик считал regexp по всем 383 тыс. пар «объект × проект» раньше гео-фильтра, и запрос шёл 3 с.
  3. (по ревью) Если у complex окажется два objective-проекта, берётся сверенный по имени: name_ok DESC, source_id в ORDER BY после расстояния.

_SOLD_COUNT_SQL не трогал: он работает только по явному маппингу, и мост в него не копировался.

Замер на проде до/после (17.09, все 1556 объектов ДОМ.РФ, окно 3 мес)

категория объектов скорость изменилась цена изменилась
явный маппинг 308 0 0
не сопоставлены 1063 0 0
gap-fill 185 185 185

Gap-fill, 185 конкурентов:

было стало
доля своих лотов (имя проекта совпадает с именем ДОМ.РФ) 23.2 % 95.6 % (176 со связью)
конкурентов с долей своих ≥ 90 % 0 169 из 176; ещё 7 — «Татлин» и «СтудияПарк», где проект верный, а метрика спотыкается о кавычки
сумма скорости, сделок/мес 65 798 1 609
медиана «было/стало» по скорости 39.4×
с velocity > 0 185 146
с objective-ценой 185 176

Связь отвергнута у 9 объектов: 7 из них получили бы чужой проект (3 × VEER PARK → Clever Park, 3 × Клевер Парк → Веер Парк, 1 × Графит → Гранит). Ещё 2 — «Жилой квартал "Традиции"» против проекта «Традиция»: это, вероятно, тот же ЖК. Скорость у него и так 0 (последняя сделка в 2025-04), теряется только objective-цена. У 30 конкурентов скорость стала 0 честно: у их собственного проекта нет сделок за 3 месяца (например, «Форум Сити», последняя сделка 2025-09). Раньше им её приписывали чужие ЖК.

На 500 участках из cad_parcels_geom (детерминированная выборка по md5, радиус 1 км) у 243 есть конкуренты. Из 4915 пар «участок — конкурент» 4317 прежних не изменились ни одной, а все 598 gap-fill пар изменились. Затронуто 153 участка.

Пример: «Мичуринский» (obj 51001): скорость 683.7 → 9.3 сделки/мес, objective-цена 123 780 → 99 100 ₽/м².

Время (EXPLAIN ANALYZE на проде, центр ЕКБ, 1 км): запрос конкурентов 393 → 65 мс, ценовой fallback 23 → 54 мс.

Тесты

  • backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py прогоняет настоящие _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL на временных таблицах. Сценарии: свой ЖК с чужими лотами под устаревшим complex_id, неверная fuzzy-связь, пробел в имени, кавычки посреди имени, имя проекта длиннее имени объекта, ближайший complex без лотов (#968), complex с двумя проектами, явный маппинг. Нужен Postgres с PostGIS. В CI он теперь есть (postgis/postgis:16-3.4), и тест там исполняется; при CI/GITHUB_ACTIONS пропуск выключен. Локально на postgis/postgis:16-3.4: 9 passed, rc=0.
  • Весь backend/tests на голове 21fe2441, локально против postgis/postgis:16-3.4 в режиме CI (CI=true TESTING=1): 5116 passed, 38 skipped, 4 failed, rc=1, НЕУЧТЁННЫЙ ПРОПУСК нет, среди пропусков нет test_2962. Все 4 падения в tests/ops/test_2203_* — известная проблема BSD mktemp на macOS.
  • Весь tests/sql + tests/services/site_finder на PostGIS-образе в режиме CI: 725 passed, rc=0. Смена образа старые DB-тесты не ломает.
  • uv run ruff check app tests: All checks passed. ruff format --check по изменённым файлам: ok.

Фальсификация

  1. Вместо исправленного competitors.py подложен файл из origin/main, прогон:
E       assert 110.0 == 10.0 ± 1.0e-05
E       assert 300000.0 == 110000.0 ± 0.11
E       assert 0.0 == 5.0 ± 5.0e-06
FAILED ...::test_gapfill_velocity_counts_only_own_project
FAILED ...::test_gapfill_price_is_median_of_own_lots
FAILED ...::test_punctuation_difference_still_matches
3 failed, 2 passed
  1. Сверка имени заменена на WHERE TRUE в обоих SQL:
E       AssertionError: {1: 10.0, 2: 30.0, 3: 5.0, 4: 20.0}
E       assert 30.0 == 0.0
FAILED ...::test_wrong_fuzzy_link_gives_no_numbers
1 failed, 4 passed

После каждого прогона файл восстановлен, diff -q чистый. Фальсификация правок по ревью — в разделе ниже.

Что осталось за рамками (не делал)

  • api/v1/parcels.py geo_radius_price с тем же джойном ol.complex_id IN nearby_cx. Замер на 100 участках: медиана посчитана у 65, у 38 из них расходится с медианой по complex_sources → project_name больше чем на 10 % (медиана расхождения 15.6 %). Это отдельный потребитель со своей приёмкой (калиброванная цена участка) — заведён #3583 (туда же v_complex_full.objective_lots_n).
  • Сама колонка objective_lots.complex_id остаётся устаревшей: писатель её не обновляет. Починка = правка UPSERT в 70_parse_objective_raw.py + backfill 3.2 млн строк под триггером истории. Это запись на прод, нужно решение владельца.
  • Fuzzy-связи complex_sources (VEER/Clever, Графит/Гранит) не исправлял: правка просто перестала им верить без подтверждения по имени.
  • Несколько корпусов ДОМ.РФ одного ЖК получают одну и ту же скорость проекта (185 объектов → 64 проекта). Так было и раньше, в этой правке не менял.

Правки по ревью

1. CI красный (run 11737) — верно, исправлено (коммит 33182e7d). Лог подтвердил: 5125 passed, 33 skipped, все 5 тестов #2962 пропущены с причиной «нет расширения postgis», гейт НЕУЧТЁННЫЙ ПРОПУСК (5), pytest вернул код 1. Моя фраза «в CI ПТИЦА живой БД нет» была ошибкой: в CI есть plain postgres:16, и test_2464_area_bin_unknown там исполняется.

  • .forgejo/workflows/ci.yml: образ postgis/postgis:16-3.4 (тот же, что в ci-tradein.yml, тот же раннер). Образ сам создаёт расширение в POSTGRES_DB, проверено локально.
  • Тест: при CI/GITHUB_ACTIONS skipif выключен. Если PostGIS из CI пропадёт, тест не пропустится, а упадёт с настоящей причиной.
  • skip_allowlist.txt: пропуск объявлен только для машины без базы, с пояснением, где тест идёт.

Проверка всех трёх режимов на голове:

plain postgres:16, CI=true  → rc=1: psycopg.errors.UndefinedObject: type "geography" does not exist; 5 errors
plain postgres:16, без CI   → rc=0: 5 skipped (объявлены)
postgis/postgis:16-3.4      → rc=0: 5 passed (после второго коммита — 9 passed)

Старая голова b74b02ea на plain postgres:16 с CI=true воспроизводит красный CI: НЕУЧТЁННЫЙ ПРОПУСК (5), rc=1.

CI на 21fe2441 (run 11743): CI / backend-tests success — 5134 passed, 28 skipped, pytest вернул код 0. На b74b02ea было 5125 passed, 33 skipped: +9 — это тесты #2962, которые теперь исполняются, −5 — их прежние пропуски. Строк test_2962 среди SKIPPED в логе нет.

2. Мутации (5), (6), (7) тесты не ловили — верно, исправлено (коммит 21fe2441). Подтвердил на b74b02ea: все три дают 5 passed, rc=0. Старый тест «пунктуации» действительно проверял пробел — переименован в test_space_difference_still_matches. Добавлены кейсы:

  • кавычки посреди имени: «Квартал "Татлин"» против проекта «Квартал Татлин»;
  • имя проекта длиннее имени объекта: «Парковый» против «Парковый квартал»;
  • #968: ближайший complex «Роща» с проектом без лотов и «Роща Парк» в 100 м с лотами.

3. Два objective-проекта у одного complex — верно, исправлено (тот же коммит). UNIQUE (source, source_id) в data/sql/73_complexes_master.sql:100 второй проект у complex не запрещает; писатель complex_sources — только разовые миграции 73/76, в коде приложения записи нет. Воспроизвёл на b74b02ea: complex с проектами «Клён» и «Сосны», объект «ЖК Сосны» — DISTINCT ON брал «Клён», сверка его отвергала, скорость 0 вместо 9. Правка: ключи нормализации считаются один раз в CROSS JOIN LATERAL, сверка name_ok — в списке выборки, в ORDER BY после расстояния стоят name_ok DESC, cs.source_id. Фильтр по-прежнему после DISTINCT ON, поэтому regexp не уезжает в join.

Прод 17.09 (только чтение, BEGIN READ ONLY, выбор nearest_cx для всех объектов, b74b02ea против 21fe2441):

строк принято отвергнуто расхождений в выборе
_COMPETITORS_SQL 185 176 9 0
_OBJECTIVE_PRICE_FALLBACK_SQL 185 176 9 0

complex_sources source='objective': 350 строк, 350 complex_id, максимум 1 проект на complex. EXPLAIN ANALYZE, центр ЕКБ, 1 км, по 3 прогона: конкуренты 68–71 мс (было 68–71), ценовой fallback 103 мс (было 103–126).

Фальсификация, по одной мутации на competitors.py, после каждой файл восстановлен, diff -q чистый:

сверка → WHERE TRUE (оба SQL)        rc=1  assert 30.0 == 0.0                test_wrong_fuzzy_link_gives_no_numbers
regexp → удаление только пробелов    rc=1  assert 0.0 == 6.0 ± 6.0e-06       test_quotes_inside_name_still_match
убран EXISTS #968 (оба SQL)          rc=1  assert 0.0 == 7.0 ± 7.0e-06       test_nearest_complex_without_lots_does_not_eat_the_match
односторонний LIKE                   rc=1  assert 0.0 == 4.0 ± 4.0e-06       test_project_name_longer_than_object_name_matches
убран name_ok DESC (source_id есть)  rc=1  assert 0.0 == 9.0 ± 9.0e-06       test_complex_with_two_projects_takes_the_matching_one
убраны оба tie-breaker'а             rc=1  assert 0.0 == 9.0 ± 9.0e-06       test_complex_with_two_projects_takes_the_matching_one

Последняя мутация без tie-breaker'ов ломается по плану запроса (порядок хэша), а не гарантированно. Гарантированно ловится предпоследняя.

4. Не сделано, с проверкой:

  • Сведение ё к е: на проде ни одного случая. Среди 185 пар translate(ё→е) не меняет вердикт ни у одной (0), все 9 отвергнутых объяснены выше. Добавлять без случая не стал.
  • Короткие ключи проектов: среди принятых пар ключ длиной ≤ 4 только у «Лес» (3 объекта «Жилой комплекс "Лес"/"ЛЕС"») и «ТЕМП» («ЖК ТЕМП»), все верные. Ложные совпадения сейчас гасят радиус 200 м и сверка с canonical_name. Код не менял.
  • parcels.py geo_radius_price: заведён #3583.

Про Closes #2962. Оба места из issue починены. CI на 21fe2441 зелёный (run 11743). Приёмка на проде не раньше 18.09, до неё закрытое issue не считать проверенным.

Приёмка на проде (после деплоя, не раньше 18.09.2026)

  1. Код в контейнере: docker exec gendesign-backend-1 grep -c "name_ok DESC" /app/app/services/site_finder/competitors.py2. Этой строки нет ни в main, ни в первой версии PR (b74b02ea).
  2. POST /api/v1/parcels/66:41:0306109:31/competitors: у «Мичуринский» (obj 51001) velocity_per_month порядка 9, а не 684.
  3. POST /api/v1/parcels/66:41:0109051:134/competitors: у obj 52199/57442/70175 (VEER PARK) velocity_per_month = 0, а price_source не objective.
  4. Повторить замер «до/после» на живом коде: у явного маппинга и несопоставленных 0 изменений, у gap-fill доля своих лотов ≥ 90 %.

Closes #2962

🤖 Generated with Claude Code

## #2962 — gap-fill конкурентов тянул чужие ЖК ### Что было Для конкурентов, которых нет в `objective_complex_mapping`, скорость продаж (`_COMPETITORS_SQL`, CTE `mapped`) и ценовой fallback (`_OBJECTIVE_PRICE_FALLBACK_SQL`) собирались так: ближайший complex → `objective_lots.complex_id` → все `project_name` под этим id. ### Откуда под complex_id чужие проекты (установлено чтением прода 17.09) `complex_id` проставлен ровно один раз, миграцией 76 по `complex_sources`. Позже его никто не обновляет, а еженедельный `70_parse_objective_raw.py` делает UPSERT `ON CONFLICT (objective_lot_id)` и переписывает `project_name`, не трогая `complex_id`: | строки с complex_id | строк | разных project_name | fetched_at | updated_at | |---|---|---|---|---| | проект совпадает с `complex_sources.source_id` | 67 323 | 345 | все 10.05 | 17.05–15.09 | | **проект чужой** | **236 354** | 649 | все 10.05 | 17.05–15.09 | Все строки с complex_id вставлены одной загрузкой 10.05, а переписаны позже. В последних снапшотах почти каждая переписанная строка уезжает в чужой проект: 15.09 — 23 861 чужих и 118 своих. Среди строк без complex_id (2 917 327) новых записей с id нет. Других писателей `complex_id` в коде нет (grep по `backend/app`, `data/sql`). При этом `complex_sources` (source='objective') — 350 строк строго 1:1, и по `project_name` эти 350 проектов покрывают 2 863 799 лотов, у каждого лоты есть. ### Что сделано 1. В обоих SQL complex связывается с проектом через `complex_sources` (source='objective'), лоты и сделки берутся по `project_name`. `objective_lots.complex_id` больше не читается. 2. Связь в `complex_sources` почти вся fuzzy и не проверена: у «ЖК VEER PARK» стоит `'Clever Park'`, у «ЖК Клевер парк» — `'Веер Парк'`, у «ЖК Графит» — `'Гранит'`. Поэтому имя проекта ещё раз сверяется с именем объекта ДОМ.РФ, без регистра и пунктуации, в обе стороны («СтудияПарк» = «Студия Парк», «Квартал "Татлин"» = «Квартал Татлин», «Парковый» ⊂ «Парковый квартал»). Сверка как фильтр стоит после `DISTINCT ON`. Внутри join планировщик считал regexp по всем 383 тыс. пар «объект × проект» раньше гео-фильтра, и запрос шёл 3 с. 3. (по ревью) Если у complex окажется два objective-проекта, берётся сверенный по имени: `name_ok DESC, source_id` в `ORDER BY` после расстояния. `_SOLD_COUNT_SQL` не трогал: он работает только по явному маппингу, и мост в него не копировался. ### Замер на проде до/после (17.09, все 1556 объектов ДОМ.РФ, окно 3 мес) | категория | объектов | скорость изменилась | цена изменилась | |---|---|---|---| | явный маппинг | 308 | **0** | **0** | | не сопоставлены | 1063 | **0** | **0** | | gap-fill | 185 | 185 | 185 | Gap-fill, 185 конкурентов: | | было | стало | |---|---|---| | доля своих лотов (имя проекта совпадает с именем ДОМ.РФ) | **23.2 %** | **95.6 %** (176 со связью) | | конкурентов с долей своих ≥ 90 % | 0 | 169 из 176; ещё 7 — «Татлин» и «СтудияПарк», где проект верный, а метрика спотыкается о кавычки | | сумма скорости, сделок/мес | 65 798 | 1 609 | | медиана «было/стало» по скорости | — | 39.4× | | с velocity > 0 | 185 | 146 | | с objective-ценой | 185 | 176 | Связь отвергнута у 9 объектов: 7 из них получили бы чужой проект (3 × VEER PARK → Clever Park, 3 × Клевер Парк → Веер Парк, 1 × Графит → Гранит). Ещё 2 — «Жилой квартал "Традиции"» против проекта «Традиция»: это, вероятно, тот же ЖК. Скорость у него и так 0 (последняя сделка в 2025-04), теряется только objective-цена. У 30 конкурентов скорость стала 0 честно: у их собственного проекта нет сделок за 3 месяца (например, «Форум Сити», последняя сделка 2025-09). Раньше им её приписывали чужие ЖК. На 500 участках из `cad_parcels_geom` (детерминированная выборка по md5, радиус 1 км) у 243 есть конкуренты. Из 4915 пар «участок — конкурент» **4317 прежних не изменились ни одной**, а все 598 gap-fill пар изменились. Затронуто 153 участка. Пример: «Мичуринский» (obj 51001): скорость 683.7 → 9.3 сделки/мес, objective-цена 123 780 → 99 100 ₽/м². Время (EXPLAIN ANALYZE на проде, центр ЕКБ, 1 км): запрос конкурентов 393 → 65 мс, ценовой fallback 23 → 54 мс. ### Тесты - `backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py` прогоняет настоящие `_COMPETITORS_SQL` и `_OBJECTIVE_PRICE_FALLBACK_SQL` на временных таблицах. Сценарии: свой ЖК с чужими лотами под устаревшим complex_id, неверная fuzzy-связь, пробел в имени, кавычки посреди имени, имя проекта длиннее имени объекта, ближайший complex без лотов (#968), complex с двумя проектами, явный маппинг. Нужен Postgres с PostGIS. **В CI он теперь есть** (`postgis/postgis:16-3.4`), и тест там исполняется; при `CI`/`GITHUB_ACTIONS` пропуск выключен. Локально на `postgis/postgis:16-3.4`: **9 passed**, rc=0. - Весь `backend/tests` на голове 21fe2441, локально против `postgis/postgis:16-3.4` в режиме CI (`CI=true TESTING=1`): **5116 passed, 38 skipped, 4 failed**, rc=1, `НЕУЧТЁННЫЙ ПРОПУСК` нет, среди пропусков нет test_2962. Все 4 падения в `tests/ops/test_2203_*` — известная проблема BSD mktemp на macOS. - Весь `tests/sql` + `tests/services/site_finder` на PostGIS-образе в режиме CI: **725 passed**, rc=0. Смена образа старые DB-тесты не ломает. - `uv run ruff check app tests`: All checks passed. `ruff format --check` по изменённым файлам: ok. ### Фальсификация 1. Вместо исправленного `competitors.py` подложен файл из `origin/main`, прогон: ``` E assert 110.0 == 10.0 ± 1.0e-05 E assert 300000.0 == 110000.0 ± 0.11 E assert 0.0 == 5.0 ± 5.0e-06 FAILED ...::test_gapfill_velocity_counts_only_own_project FAILED ...::test_gapfill_price_is_median_of_own_lots FAILED ...::test_punctuation_difference_still_matches 3 failed, 2 passed ``` 2. Сверка имени заменена на `WHERE TRUE` в обоих SQL: ``` E AssertionError: {1: 10.0, 2: 30.0, 3: 5.0, 4: 20.0} E assert 30.0 == 0.0 FAILED ...::test_wrong_fuzzy_link_gives_no_numbers 1 failed, 4 passed ``` После каждого прогона файл восстановлен, `diff -q` чистый. Фальсификация правок по ревью — в разделе ниже. ### Что осталось за рамками (не делал) - **`api/v1/parcels.py` geo_radius_price** с тем же джойном `ol.complex_id IN nearby_cx`. Замер на 100 участках: медиана посчитана у 65, у 38 из них расходится с медианой по `complex_sources → project_name` больше чем на 10 % (медиана расхождения 15.6 %). Это отдельный потребитель со своей приёмкой (калиброванная цена участка) — заведён #3583 (туда же `v_complex_full.objective_lots_n`). - **Сама колонка `objective_lots.complex_id`** остаётся устаревшей: писатель её не обновляет. Починка = правка UPSERT в `70_parse_objective_raw.py` + backfill 3.2 млн строк под триггером истории. Это запись на прод, нужно решение владельца. - **Fuzzy-связи `complex_sources`** (VEER/Clever, Графит/Гранит) не исправлял: правка просто перестала им верить без подтверждения по имени. - Несколько корпусов ДОМ.РФ одного ЖК получают одну и ту же скорость проекта (185 объектов → 64 проекта). Так было и раньше, в этой правке не менял. ### Правки по ревью **1. CI красный (run 11737) — верно, исправлено** (коммит 33182e7d). Лог подтвердил: `5125 passed, 33 skipped`, все 5 тестов #2962 пропущены с причиной «нет расширения postgis», гейт `НЕУЧТЁННЫЙ ПРОПУСК (5)`, `pytest вернул код 1`. Моя фраза «в CI ПТИЦА живой БД нет» была ошибкой: в CI есть plain `postgres:16`, и `test_2464_area_bin_unknown` там исполняется. - `.forgejo/workflows/ci.yml`: образ `postgis/postgis:16-3.4` (тот же, что в `ci-tradein.yml`, тот же раннер). Образ сам создаёт расширение в `POSTGRES_DB`, проверено локально. - Тест: при `CI`/`GITHUB_ACTIONS` skipif выключен. Если PostGIS из CI пропадёт, тест не пропустится, а упадёт с настоящей причиной. - `skip_allowlist.txt`: пропуск объявлен только для машины без базы, с пояснением, где тест идёт. Проверка всех трёх режимов на голове: ``` plain postgres:16, CI=true → rc=1: psycopg.errors.UndefinedObject: type "geography" does not exist; 5 errors plain postgres:16, без CI → rc=0: 5 skipped (объявлены) postgis/postgis:16-3.4 → rc=0: 5 passed (после второго коммита — 9 passed) ``` Старая голова b74b02ea на plain postgres:16 с CI=true воспроизводит красный CI: `НЕУЧТЁННЫЙ ПРОПУСК (5)`, rc=1. **CI на 21fe2441 (run 11743): `CI / backend-tests` success — `5134 passed, 28 skipped`, `pytest вернул код 0`.** На b74b02ea было `5125 passed, 33 skipped`: +9 — это тесты #2962, которые теперь исполняются, −5 — их прежние пропуски. Строк `test_2962` среди SKIPPED в логе нет. **2. Мутации (5), (6), (7) тесты не ловили — верно, исправлено** (коммит 21fe2441). Подтвердил на b74b02ea: все три дают `5 passed`, rc=0. Старый тест «пунктуации» действительно проверял пробел — переименован в `test_space_difference_still_matches`. Добавлены кейсы: - кавычки посреди имени: «Квартал "Татлин"» против проекта «Квартал Татлин»; - имя проекта длиннее имени объекта: «Парковый» против «Парковый квартал»; - #968: ближайший complex «Роща» с проектом без лотов и «Роща Парк» в 100 м с лотами. **3. Два objective-проекта у одного complex — верно, исправлено** (тот же коммит). `UNIQUE (source, source_id)` в `data/sql/73_complexes_master.sql:100` второй проект у complex не запрещает; писатель `complex_sources` — только разовые миграции 73/76, в коде приложения записи нет. Воспроизвёл на b74b02ea: complex с проектами «Клён» и «Сосны», объект «ЖК Сосны» — `DISTINCT ON` брал «Клён», сверка его отвергала, скорость 0 вместо 9. Правка: ключи нормализации считаются один раз в `CROSS JOIN LATERAL`, сверка `name_ok` — в списке выборки, в `ORDER BY` после расстояния стоят `name_ok DESC, cs.source_id`. Фильтр по-прежнему после `DISTINCT ON`, поэтому regexp не уезжает в join. Прод 17.09 (только чтение, `BEGIN READ ONLY`, выбор `nearest_cx` для всех объектов, b74b02ea против 21fe2441): | | строк | принято | отвергнуто | расхождений в выборе | |---|---|---|---|---| | `_COMPETITORS_SQL` | 185 | 176 | 9 | **0** | | `_OBJECTIVE_PRICE_FALLBACK_SQL` | 185 | 176 | 9 | **0** | `complex_sources` source='objective': 350 строк, 350 complex_id, максимум 1 проект на complex. EXPLAIN ANALYZE, центр ЕКБ, 1 км, по 3 прогона: конкуренты 68–71 мс (было 68–71), ценовой fallback 103 мс (было 103–126). Фальсификация, по одной мутации на `competitors.py`, после каждой файл восстановлен, `diff -q` чистый: ``` сверка → WHERE TRUE (оба SQL) rc=1 assert 30.0 == 0.0 test_wrong_fuzzy_link_gives_no_numbers regexp → удаление только пробелов rc=1 assert 0.0 == 6.0 ± 6.0e-06 test_quotes_inside_name_still_match убран EXISTS #968 (оба SQL) rc=1 assert 0.0 == 7.0 ± 7.0e-06 test_nearest_complex_without_lots_does_not_eat_the_match односторонний LIKE rc=1 assert 0.0 == 4.0 ± 4.0e-06 test_project_name_longer_than_object_name_matches убран name_ok DESC (source_id есть) rc=1 assert 0.0 == 9.0 ± 9.0e-06 test_complex_with_two_projects_takes_the_matching_one убраны оба tie-breaker'а rc=1 assert 0.0 == 9.0 ± 9.0e-06 test_complex_with_two_projects_takes_the_matching_one ``` Последняя мутация без tie-breaker'ов ломается по плану запроса (порядок хэша), а не гарантированно. Гарантированно ловится предпоследняя. **4. Не сделано, с проверкой:** - Сведение ё к е: на проде ни одного случая. Среди 185 пар `translate(ё→е)` не меняет вердикт ни у одной (0), все 9 отвергнутых объяснены выше. Добавлять без случая не стал. - Короткие ключи проектов: среди принятых пар ключ длиной ≤ 4 только у «Лес» (3 объекта «Жилой комплекс "Лес"/"ЛЕС"») и «ТЕМП» («ЖК ТЕМП»), все верные. Ложные совпадения сейчас гасят радиус 200 м и сверка с `canonical_name`. Код не менял. - `parcels.py` geo_radius_price: заведён #3583. **Про Closes #2962.** Оба места из issue починены. CI на 21fe2441 зелёный (run 11743). Приёмка на проде не раньше 18.09, до неё закрытое issue не считать проверенным. ### Приёмка на проде (после деплоя, не раньше 18.09.2026) 1. Код в контейнере: `docker exec gendesign-backend-1 grep -c "name_ok DESC" /app/app/services/site_finder/competitors.py` → `2`. Этой строки нет ни в main, ни в первой версии PR (b74b02ea). 2. `POST /api/v1/parcels/66:41:0306109:31/competitors`: у «Мичуринский» (obj 51001) `velocity_per_month` порядка 9, а не 684. 3. `POST /api/v1/parcels/66:41:0109051:134/competitors`: у obj 52199/57442/70175 (VEER PARK) `velocity_per_month = 0`, а `price_source` не `objective`. 4. Повторить замер «до/после» на живом коде: у явного маппинга и несопоставленных 0 изменений, у gap-fill доля своих лотов ≥ 90 %. Closes #2962 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-17 10:11:26 +00:00
ПТИЦА конкуренты: gap-fill берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 29s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 28s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m52s
CI / backend-tests (pull_request) Failing after 8m27s
b74b02ea8b
Что было. Для конкурентов вне objective_complex_mapping мост шёл
«ближайший complex → objective_lots.complex_id → все project_name».
complex_id проставлен один раз миграцией 76 на загрузке 10.05, а
еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id
переписывает project_name и не трогает complex_id. Прод 17.09: из
303 677 строк с complex_id у 236 354 проект чужой (все вставлены 10.05,
переписаны 17.05–15.09). У 185 gap-fill конкурентов своих лотов 23 %,
скорость в медиане завышена в 39 раз (сумма 65 798 против 1 609 сделок/мес).

Что сделано. В _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL complex
связывается с проектом через complex_sources (source='objective', 1:1),
лоты и сделки берутся по project_name. Связь в complex_sources почти вся
fuzzy и не проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
'Гранит'), поэтому имя проекта дополнительно сверяется с именем объекта
ДОМ.РФ без регистра и пунктуации; сверка стоит после DISTINCT ON, внутри
join планировщик гонял regexp по 383k пар (3 с).

Замер на проде (все 1556 объектов, окно 3 мес): явный маппинг 308 и
остальные 1063 — изменилось 0; gap-fill 185 — изменились все, у 9 связь
отвергнута (7 чужих проектов + 2 «Традиции»/«Традиция»), доля своих лотов
23 % → 96 %. На 500 участках: 4317 прежних пар участок-конкурент — 0
изменений, 598 gap-fill пар — изменились все. Время запроса конкурентов
393 → 65 мс, ценового fallback 23 → 54 мс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 2 commits 2026-09-17 10:37:17 +00:00
На голове b74b02ea CI / backend-tests был красным (run 11737): 5125 passed, но
все 5 тестов test_2962_competitors_gapfill_bridge.py пропустились с причиной
«нет расширения postgis», а в skip_allowlist.txt их не было — гейт вернул rc=1.
В CI ПТИЦЫ plain postgres:16, а SQL конкурентов без PostGIS не исполнить
(ST_DWithin по geography).

- ci.yml: образ postgis/postgis:16-3.4 (тот же, что в ci-tradein.yml); образ
  сам создаёт расширение в POSTGRES_DB.
- тест: при CI/GITHUB_ACTIONS skipif выключен — если PostGIS из CI пропадёт,
  тест упадёт с настоящей причиной, а не пропустится молча.
- skip_allowlist.txt: пропуск объявлен только для машины без базы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ПТИЦА конкуренты: у 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
21fe24419a
По ревью 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>
Light1YT added 1 commit 2026-09-17 10:50:18 +00:00
Merge remote-tracking branch 'origin/main' into fix/competitors-bridge
All checks were successful
CI / backend-tests (pull_request) Successful in 10m3s
CI Trade-In / backend-tests (pull_request) Has been skipped
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 3m4s
CI Trade-In / changes (pull_request) Successful in 21s
CI / openapi-codegen-check (pull_request) Successful in 4m23s
CI / changes (pull_request) Successful in 25s
d76411b45f
bot-backend merged commit e942acaf12 into main 2026-09-17 11:16:11 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3582
No description provided.