Конкуренты ПТИЦЫ: скорость продаж и цена для несопоставленных ЖК берутся по своему проекту, а не по чужим под общим complex_id #3582
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#3582
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/competitors-bridge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
#2962 — gap-fill конкурентов тянул чужие ЖК
Что было
Для конкурентов, которых нет в
objective_complex_mapping, скорость продаж (_COMPETITORS_SQL, CTEmapped) и ценовой 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делает UPSERTON CONFLICT (objective_lot_id)и переписываетproject_name, не трогаяcomplex_id:complex_sources.source_idВсе строки с 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 лотов, у каждого лоты есть.Что сделано
complex_sources(source='objective'), лоты и сделки берутся поproject_name.objective_lots.complex_idбольше не читается.complex_sourcesпочти вся fuzzy и не проверена: у «ЖК VEER PARK» стоит'Clever Park', у «ЖК Клевер парк» —'Веер Парк', у «ЖК Графит» —'Гранит'. Поэтому имя проекта ещё раз сверяется с именем объекта ДОМ.РФ, без регистра и пунктуации, в обе стороны («СтудияПарк» = «Студия Парк», «Квартал "Татлин"» = «Квартал Татлин», «Парковый» ⊂ «Парковый квартал»). Сверка как фильтр стоит послеDISTINCT ON. Внутри join планировщик считал regexp по всем 383 тыс. пар «объект × проект» раньше гео-фильтра, и запрос шёл 3 с.name_ok DESC, source_idвORDER BYпосле расстояния._SOLD_COUNT_SQLне трогал: он работает только по явному маппингу, и мост в него не копировался.Замер на проде до/после (17.09, все 1556 объектов ДОМ.РФ, окно 3 мес)
Gap-fill, 185 конкурентов:
Связь отвергнута у 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.Фальсификация
competitors.pyподложен файл изorigin/main, прогон:WHERE TRUEв обоих SQL:После каждого прогона файл восстановлен,
diff -qчистый. Фальсификация правок по ревью — в разделе ниже.Что осталось за рамками (не делал)
api/v1/parcels.pygeo_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 млн строк под триггером истории. Это запись на прод, нужно решение владельца.complex_sources(VEER/Clever, Графит/Гранит) не исправлял: правка просто перестала им верить без подтверждения по имени.Правки по ревью
1. CI красный (run 11737) — верно, исправлено (коммит
33182e7d). Лог подтвердил:5125 passed, 33 skipped, все 5 тестов #2962 пропущены с причиной «нет расширения postgis», гейтНЕУЧТЁННЫЙ ПРОПУСК (5),pytest вернул код 1. Моя фраза «в CI ПТИЦА живой БД нет» была ошибкой: в CI есть plainpostgres:16, иtest_2464_area_bin_unknownтам исполняется..forgejo/workflows/ci.yml: образpostgis/postgis:16-3.4(тот же, что вci-tradein.yml, тот же раннер). Образ сам создаёт расширение вPOSTGRES_DB, проверено локально.CI/GITHUB_ACTIONSskipif выключен. Если PostGIS из CI пропадёт, тест не пропустится, а упадёт с настоящей причиной.skip_allowlist.txt: пропуск объявлен только для машины без базы, с пояснением, где тест идёт.Проверка всех трёх режимов на голове:
Старая голова
b74b02eaна plain postgres:16 с CI=true воспроизводит красный CI:НЕУЧТЁННЫЙ ПРОПУСК (5), rc=1.CI на
21fe2441(run 11743):CI / backend-testssuccess —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. Добавлены кейсы: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_OBJECTIVE_PRICE_FALLBACK_SQLcomplex_sourcessource='objective': 350 строк, 350 complex_id, максимум 1 проект на complex. EXPLAIN ANALYZE, центр ЕКБ, 1 км, по 3 прогона: конкуренты 68–71 мс (было 68–71), ценовой fallback 103 мс (было 103–126).Фальсификация, по одной мутации на
competitors.py, после каждой файл восстановлен,diff -qчистый:Последняя мутация без tie-breaker'ов ломается по плану запроса (порядок хэша), а не гарантированно. Гарантированно ловится предпоследняя.
4. Не сделано, с проверкой:
translate(ё→е)не меняет вердикт ни у одной (0), все 9 отвергнутых объяснены выше. Добавлять без случая не стал.canonical_name. Код не менял.parcels.pygeo_radius_price: заведён #3583.Про Closes #2962. Оба места из issue починены. CI на
21fe2441зелёный (run 11743). Приёмка на проде не раньше 18.09, до неё закрытое issue не считать проверенным.Приёмка на проде (после деплоя, не раньше 18.09.2026)
docker exec gendesign-backend-1 grep -c "name_ok DESC" /app/app/services/site_finder/competitors.py→2. Этой строки нет ни в main, ни в первой версии PR (b74b02ea).POST /api/v1/parcels/66:41:0306109:31/competitors: у «Мичуринский» (obj 51001)velocity_per_monthпорядка 9, а не 684.POST /api/v1/parcels/66:41:0109051:134/competitors: у obj 52199/57442/70175 (VEER PARK)velocity_per_month = 0, аprice_sourceнеobjective.Closes #2962
🤖 Generated with Claude Code