Merge pull request 'Конкуренты ПТИЦЫ: скорость продаж и цена для несопоставленных ЖК берутся по своему проекту, а не по чужим под общим complex_id' (#3582) from fix/competitors-bridge into main
All checks were successful
Deploy / deploy (push) Successful in 1m30s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m49s
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m51s
Deploy / build-worker (push) Successful in 4m10s
All checks were successful
Deploy / deploy (push) Successful in 1m30s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m49s
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m51s
Deploy / build-worker (push) Successful in 4m10s
This commit is contained in:
commit
e942acaf12
4 changed files with 348 additions and 34 deletions
|
|
@ -260,10 +260,12 @@ jobs:
|
||||||
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
||||||
# не бежали ни разу с момента написания.
|
# не бежали ни разу с момента написания.
|
||||||
#
|
#
|
||||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
# postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные
|
||||||
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
|
# таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL
|
||||||
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
|
# модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через
|
||||||
# нужен, в отличие от tradein-лэйна.
|
# ST_DWithin по geography, без расширения его не исполнить. На plain
|
||||||
|
# postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не
|
||||||
|
# проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB.
|
||||||
#
|
#
|
||||||
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
||||||
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
||||||
|
|
@ -294,15 +296,14 @@ jobs:
|
||||||
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
||||||
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
||||||
#
|
#
|
||||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
# postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap
|
||||||
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
|
# схемы здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||||
# здесь не нужен вовсе, в отличие от tradein-лэйна.
|
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||||
docker run -d --name "$CI_PG" \
|
docker run -d --name "$CI_PG" \
|
||||||
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
||||||
postgres:16
|
postgis/postgis:16-3.4
|
||||||
|
|
||||||
ready=""
|
ready=""
|
||||||
for _ in $(seq 1 45); do
|
for _ in $(seq 1 45); do
|
||||||
|
|
|
||||||
|
|
@ -344,21 +344,26 @@ _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,
|
||||||
|
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
|
||||||
|
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
|
||||||
|
-- «Парковый» ⊂ «Парковый квартал»)
|
||||||
|
( k.comm_key LIKE '%' || k.project_key || '%'
|
||||||
|
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
|
||||||
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 +374,20 @@ _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
|
||||||
|
)
|
||||||
|
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
|
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)
|
||||||
|
|
@ -376,7 +395,13 @@ _COMPETITORS_SQL = text("""
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
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
|
||||||
) ASC
|
) ASC,
|
||||||
|
-- #2962: 1:1 complex → objective-проект держится только на данных
|
||||||
|
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
|
||||||
|
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
|
||||||
|
-- брал любой, сверка его отвергала, и верный терялся.
|
||||||
|
name_ok DESC,
|
||||||
|
cs.source_id
|
||||||
),
|
),
|
||||||
mapped AS (
|
mapped AS (
|
||||||
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
||||||
|
|
@ -384,15 +409,18 @@ _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
|
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
|
||||||
|
-- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
|
||||||
|
-- планировщик считал regexp на всех парах объект × проект (383k) раньше
|
||||||
|
-- гео-фильтра — 3 с против 0.4 с.
|
||||||
|
WHERE nc.name_ok
|
||||||
),
|
),
|
||||||
velocity AS (
|
velocity AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -605,9 +633,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 +654,17 @@ _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,
|
||||||
|
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
|
||||||
|
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
|
||||||
|
-- «Парковый» ⊂ «Парковый квартал»)
|
||||||
|
( k.comm_key LIKE '%' || k.project_key || '%'
|
||||||
|
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
|
||||||
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 +675,17 @@ _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
|
||||||
|
)
|
||||||
|
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)
|
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
|
||||||
|
|
@ -654,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
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
|
||||||
) ASC
|
) ASC,
|
||||||
|
-- #2962: 1:1 complex → objective-проект держится только на данных
|
||||||
|
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
|
||||||
|
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
|
||||||
|
-- брал любой, сверка его отвергала, и верный терялся.
|
||||||
|
name_ok DESC,
|
||||||
|
cs.source_id
|
||||||
),
|
),
|
||||||
gapfill_price AS (
|
gapfill_price AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -662,8 +710,10 @@ _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.name_ok
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.obj_id,
|
p.obj_id,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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_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_total_lots_preserved
|
||||||
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
|
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) ──────────────────────────────────
|
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
|
||||||
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
||||||
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue