ПТИЦА конкуренты: 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

Что было. Для конкурентов вне 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>
This commit is contained in:
bot-backend 2026-09-17 15:05:22 +05:00
parent be1403289a
commit b74b02ea8b
2 changed files with 223 additions and 24 deletions

View file

@ -344,21 +344,23 @@ _COMPETITORS_SQL = text("""
-- важна velocity питает forecast). Спатиальный join дёшев, т.к. -- важна velocity питает forecast). Спатиальный join дёшев, т.к.
-- complexes мал (~1.5k строк с geom) НЕ потому что latest_obj -- complexes мал (~1.5k строк с geom) НЕ потому что latest_obj
-- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT). -- пре-фильтрован (радиус участка применяется ниже, в финальном 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) SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.obj_id,
c.id AS complex_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
FROM latest_obj o FROM latest_obj o
JOIN complexes c JOIN complexes c
ON c.latitude IS NOT NULL ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL AND c.longitude IS NOT NULL
AND c.canonical_name 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( AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
@ -369,6 +371,16 @@ _COMPETITORS_SQL = text("""
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_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
)
WHERE o.comm_name IS NOT NULL WHERE o.comm_name IS NOT NULL
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё spatial-only AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё spatial-only
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping) AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
@ -384,15 +396,19 @@ _COMPETITORS_SQL = text("""
cm.objective_complex_name cm.objective_complex_name
FROM objective_complex_mapping cm FROM objective_complex_mapping cm
UNION UNION
-- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а -- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а
-- (у комплекса может быть несколько корпус-project_name velocity -- (корпуса у Объектива corpus_name внутри проекта, не отдельные имена).
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
SELECT nc.obj_id, SELECT nc.obj_id,
ol.project_name AS objective_complex_name nc.project_name AS objective_complex_name
FROM nearest_cx nc FROM nearest_cx nc
JOIN objective_lots ol -- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
ON ol.complex_id = nc.complex_id -- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит»
AND ol.project_name IS NOT NULL -- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз,
-- без регистра и пунктуации («СтудияПарк» = «Студия Парк»). Фильтр стоит
-- ПОСЛЕ DISTINCT ON: внутри join планировщик считал regexp на всех парах
-- объект × проект (383k) раньше гео-фильтра 3 с против 0.4 с.
WHERE nc.comm_key LIKE '%' || nc.project_key || '%'
OR nc.project_key LIKE '%' || nc.comm_key || '%'
), ),
velocity AS ( velocity AS (
SELECT SELECT
@ -605,9 +621,11 @@ _SOLD_COUNT_SQL = text("""
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает # fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots: # avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name # PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id → # GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и # (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК). # ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и
# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под
# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx).
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ # obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен. # mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
_OBJECTIVE_PRICE_FALLBACK_SQL = text(""" _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
@ -624,16 +642,14 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
nearest_cx AS ( nearest_cx AS (
SELECT DISTINCT ON (o.obj_id) SELECT DISTINCT ON (o.obj_id)
o.obj_id, o.obj_id,
c.id AS complex_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
FROM domrf_kn_objects o FROM domrf_kn_objects o
JOIN complexes c JOIN complexes c
ON c.latitude IS NOT NULL ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL AND c.longitude IS NOT NULL
AND c.canonical_name 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( AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
@ -644,6 +660,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_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
)
WHERE o.obj_id = ANY(:obj_ids) WHERE o.obj_id = ANY(:obj_ids)
AND o.latitude IS NOT NULL AND o.latitude IS NOT NULL
AND o.longitude IS NOT NULL AND o.longitude IS NOT NULL
@ -662,8 +685,11 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
ol.price_per_m2_rub AS price_per_m2_rub ol.price_per_m2_rub AS price_per_m2_rub
FROM nearest_cx nc FROM nearest_cx nc
JOIN objective_lots ol 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 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 || '%'
) )
SELECT SELECT
p.obj_id, p.obj_id,

View file

@ -0,0 +1,173 @@
"""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, иначе skip.
"""
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()
pytestmark = pytest.mark.skipif(not _DB_OK, 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 = 1, 2, 3, 4
_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)""",
"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)
"""INSERT INTO complex_sources VALUES
(10, 'objective', 'Мичуринский'),
(20, 'objective', 'Clever Park'),
(30, '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)""",
"""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)""",
]
@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],
"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_punctuation_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