diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 191ed5c4..3a8a233a 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2319,12 +2319,31 @@ def analyze_parcel( -- (303 строки = 303 distinct) → COUNT(*) по дедуп-физлотам корректен. SELECT np.domrf_obj_id, - ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub, + -- #2464-D: границы правдоподобия, как в двух соседних запросах + -- по этой же таблице (BETWEEN 30000 AND 600000) — здесь их не было. + -- Замер 13.08 по проду ЧЕРЕЗ ЭТОТ ЖЕ ПУТЬ (physflat-дедуп + + -- маппинг на domrf_obj_id): вне диапазона 204 лота из 2 279 827, + -- из них 118 в 10 замапленных проектах и 86 — в незамапленных. + -- Эффект сегодня МАЛЫЙ: меняются 6 проектов из 308, худший на + -- 2.4%, market_avg_price (среднее средних) 138 056 → 138 008; + -- NULL не появляется нигде. Ставим границы не ради этих 48 ₽, + -- а потому что среднее считается ПО ПРОЕКТУ и один лот держит + -- группу без ограничения сверху: максимум в таблице — + -- 19 198 429 ₽/м² (ЖК «Дебют»), и он вне экрана только потому, + -- что проект пока не замаплен (замаплено 308 имён из 881, список + -- растёт). Одна строка маппинга — и это число на экране. + -- FILTER, а не WHERE: строки нужны целиком, иначе поедут + -- units_sold / units_available, считающие ВСЕ лоты. + ROUND(AVG(oll.price_per_m2_rub) FILTER ( + WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000 + )::numeric, 0) AS avg_price_per_m2_rub, ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd, COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold, COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available, + -- Считаем ТУ ЖЕ популяцию, что кормит среднее: иначе счётчик + -- обещал бы выборку шире, чем на самом деле участвовала. COUNT(*) FILTER ( - WHERE oll.price_per_m2_rub IS NOT NULL + WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000 ) AS lots_with_price FROM nearby_projects np JOIN obj_lots_latest oll diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 9c6b73e9..aaee692b 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -192,15 +192,30 @@ _INLINE_VELOCITY_SQL = text(""" SELECT a.room_bucket, SUM(a.deals_window) AS deals_window, + -- Здесь COALESCE(...,0) ОСТАЁТСЯ намеренно: TopLayoutRow.avg_area_m2 + -- объявлен как float (не Optional), и NULL ронял бы контракт API. + -- Пустые комнатности получают площадь 0 м², и это тоже неправда — но + -- честный NULL требует правки схемы + перегенерации типов фронта + -- и решения, что писать в area_bin. Отдельным заходом: #2867. COALESCE( SUM(a.area_weighted_sum) / NULLIF(SUM(a.deals_window), 0), 0 )::numeric(10, 2) AS avg_area_m2, - COALESCE( + -- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL → + -- средней цены нет, и это NULL, а не «0 ₽/м²». Схема так и объявлена + -- (TopLayoutRow.avg_price_per_m2_rub: float | None), и Python ниже уже + -- умеет None (пропускает строку во взвешенном роллапе) — но COALESCE + -- делал эту ветку недостижимой. + -- Замер 13.08 по проду, окно 6 месяцев. Сработает ноль или нет — зависит + -- от того, сколько замапленных проектов попало в радиус, поэтому цифры + -- по слоям: у 616 проектов 2083 пары (проект × комнатность), пустых 635; + -- 323 проекта имеют хотя бы одну пустую комнатность, 80 — пустые ВСЕ. + -- При объединении по два пустых остаётся 255 из 1267, по всему городу — + -- ноль. То есть чем беднее окрестность участка, тем чаще выдумывался 0. + ( SUM(a.price_weighted_sum) - / NULLIF(SUM(a.deals_window), 0), - 0 + / NULLIF(SUM(a.deals_window), 0) )::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub, array_agg(DISTINCT a.project_name) AS matched_project_names, MIN(a.window_start) AS window_start, diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index ff2d812b..5c9eaa82 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -406,16 +406,17 @@ def build_beat_schedule() -> dict: # Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц. # kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д. - # Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления. + # Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта + # за ~5 недель полного обновления. # # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession - # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет + # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после # cooldown 24-48h (проверить через targeted test). # schedule["scrape-kn-catalog-objects-weekly"] = { # "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", - # "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC + # "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК # "kwargs": {"region_code": 66, "max_objects": 300}, # "options": {"queue": "celery"}, # } @@ -430,10 +431,10 @@ def build_beat_schedule() -> dict: # свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет. # Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом). # Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку — - # напр. четверг 04:00 UTC. + # напр. четверг 04:00 МСК. # schedule["scrape-kn-catalog-flats-weekly"] = { # "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats", - # "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC + # "schedule": _parse_cron("0 4 * * 4"), # четверг 04:00 МСК # "kwargs": {"region_code": 66, "max_flats": 300}, # "options": {"queue": "celery"}, # } @@ -542,13 +543,20 @@ def build_beat_schedule() -> dict: } # Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings. - # Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК). + # 00:30 МСК ежедневно (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233). + # Комментарий до #2464-H говорил «00:30 UTC = 03:30 МСК» — считал сдвиг дважды, + # оставшись с эпохи UTC-расписания. Факт по логам beat (10-12.08): «Sending due + # task newbuilding-crossload-nightly» в 21:30 UTC = 00:30 МСК, то есть на три + # часа раньше обещанного. + # Расписание НЕ трогаем: на 00:30 МСК ничего не наложено, а сдвиг на 03:30 МСК + # завёл бы задачу прямо в окно tradein-задания newbuilding_enrich (00:00-01:00 UTC + # = 03:00-04:00 МСК), с которым она делит источник — tradein.houses. # Не в job_settings (технический ETL, не требует конфигурации UI). # Идемпотентен через ON CONFLICT (source, ext_house_id). # Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения. schedule["newbuilding-crossload-nightly"] = { "task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload", - "schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК + "schedule": _parse_cron("30 0 * * *"), # 00:30 МСК "options": {"queue": "celery"}, } diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py index d4fd67be..055a2d34 100644 --- a/backend/tests/api/v1/test_analyze_competitors_status.py +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -110,9 +110,9 @@ class TestCompetitorsSortOrder: sorted_rows = sorted(_ROWS_MIXED, key=_sort_key) first = dict(sorted_rows[0].items()) - assert first["site_status"] == "Строящиеся", ( - f"Первый конкурент должен быть 'Строящиеся', " f"но получили '{first['site_status']}'" - ) + assert ( + first["site_status"] == "Строящиеся" + ), f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'" def test_flat_count_desc_would_break_order(self) -> None: """Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми.""" @@ -180,14 +180,44 @@ class TestObjPricingPushdown: #1964: источник агрегатов сменился с сырого objective_lots (alias ol) на physflat-дедуп CTE obj_lots_latest (alias oll) — см. test_obj_pricing_*_physflat ниже. Сами агрегатные выражения и группировка per-obj_id неизменны. + + #2464-D: у среднего цены появились границы правдоподобия (те же, что в двух + соседних запросах по objective_lots) — см. test_price_avg_has_sanity_bounds. """ sql = self._competitor_sql() - assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql + assert "AS avg_price_per_m2_rub" in sql assert "lots_with_price" in sql assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql assert "GROUP BY np.domrf_obj_id" in sql + def test_price_avg_has_sanity_bounds(self) -> None: + """#2464-D: среднее цены считается по лотам в границах правдоподобия. + + Среднее считается ПО ПРОЕКТУ, поэтому один лот держит группу без ограничения + сверху: максимум в objective_lots — 19.2 млн ₽/м² (замер 13.08). Границы + 30000..600000 уже стоят в двух соседних запросах по этой же таблице; здесь + их не было. Дальше значение уходит в market_avg_price и на экран. + """ + sql = self._competitor_sql() + bounds = "WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000" + assert ( + f"AVG(oll.price_per_m2_rub) FILTER ( {bounds} )" in sql + ), "среднее цены должно фильтроваться границами правдоподобия (#2464-D)" + # Тот же набор кормит счётчик выборки — иначе счётчик обещает шире, чем + # реально участвовало в среднем. + assert ( + f"COUNT(*) FILTER ( {bounds} ) AS lots_with_price" in sql + ), "lots_with_price должен считать ту же популяцию, что и среднее" + # FILTER, а не WHERE на CTE: строки нужны целиком, иначе границы цены + # молча урежут счётчики продаж/остатка, которые считают ВСЕ лоты. + assert ( + "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql + ), "units_sold не должен зависеть от границ цены" + assert ( + "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql + ), "units_available не должен зависеть от границ цены" + def test_obj_pricing_dedups_physflat_inline(self) -> None: """#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.