From 2390eec7408d28f5ec6ab6891791936b9ad2b0c8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 22:47:46 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera/b2c):=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BE=D1=80=D0=B4=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=D1=8B=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BA=D0=B8,=20=D1=81?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B8=D0=BC=20=D0=B6=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=20=D0=BF=D1=80=D0=BE=20=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Коммит 555cce44 обещал в сообщении одно — подписать студию вместо «0-к», — а сделал ещё и другое: удалил миграцию 280_landing_showcase_deals_coords.sql и откатил правки mera.py, landing_showcase_deals.py и двух тестов из коммита cd2a7671. Заметил не я: об этом написал агент, которому потом достался номер 281 и который увидел дыру на 280. ПРИЧИНА. `git commit` фиксирует ИНДЕКС ЦЕЛИКОМ, а не то, что было добавлено последним `git add`. В индексе главного рабочего дерева лежали staged-удаления, оставшиеся от параллельных агентов (они работают в своих worktree, но индекс основного дерева переживает переключения веток). Я добавил два файла, а закоммитил вместе с ними чужие удаления. Что восстановлено: миграция 280 целиком, поля lat/lon в ShowcaseDeal, перенос координат в пересчёте витрины, оба теста. Обе величины нужны и не заменяют друг друга: схема улицы (281) закрывает 92% сделок, координата (280) — карту района для остальных 8%. Конфликты сведены вручную: механическое «оставить обе стороны» задвоило список колонок в INSERT и в SELECT, что тесты бы пропустили, а прод — нет. Проверено: 5085 passed, 35 skipped; миграции 275-281 без дыры; список колонок INSERT сверен со списком значений программно (15 и 15, порядок совпадает). --- tradein-mvp/backend/app/api/public/mera.py | 18 ++++++- .../app/tasks/landing_showcase_deals.py | 33 ++++++++++++- .../sql/280_landing_showcase_deals_coords.sql | 48 +++++++++++++++++++ .../tests/test_landing_showcase_deals.py | 33 +++++++++++++ .../backend/tests/test_public_mera_api.py | 42 ++++++++++++++++ 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 673a2f88..7b8c4d85 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -414,6 +414,15 @@ class ShowcaseDeal(BaseModel): положить (lon, lat) в её систему координат, то есть нарисовать точку дома невозможно даже случайно. Схема готовая, а не геометрия, потому что GeoJSON того же окна — 7-8 КБ на строку против 2.8-2.9 КБ схемы (замер 2026-08-29). + правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома + есть у 2.7% сделок (разбор в миграции 276). + + `lat` / `lon` — ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017 + сделок выборки (≈34 сделки в одной точке) при тех же 2.7% известных домов + (замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы + и НЕ честна на масштабе дома — то же самое написано в `note` строки, + которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и + такая строка остаётся на витрине без точки, а не выбрасывается. """ district: str | None @@ -429,6 +438,8 @@ class ShowcaseDeal(BaseModel): note: str street_name: str | None street_scheme: dict | None + lat: float | None = None + lon: float | None = None class ShowcaseStats(BaseModel): @@ -480,7 +491,7 @@ _SHOWCASE_SQL = text( """ SELECT district, rooms, area_m2, floor, total_floors, deal_quarter, predicted_rub, fact_rub, err_pct, n_analogs, note, - street_name, street_scheme + lat, lon, street_name, street_scheme FROM landing_showcase_deals WHERE computed_at = CAST(:computed_at AS timestamptz) ORDER BY id @@ -539,6 +550,11 @@ def public_showcase( note=r["note"], street_name=r["street_name"], street_scheme=r["street_scheme"], + # Порядок не менять: lat — широта (~56.8 в ЕКБ), lon — долгота + # (~60.6). Перепутанные местами координаты остаются валидными + # float и уедут на карту точкой в другой стране. + lat=(float(r["lat"]) if r["lat"] is not None else None), + lon=(float(r["lon"]) if r["lon"] is not None else None), ) for r in rows ], diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index 23630632..40aaed0d 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -37,6 +37,13 @@ ---------------------------------------------------------- * АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка — это «район + 2-к, 54 м², 5 эт.», и никогда не улица с домом. + * ТОЧКА НА КАРТЕ — ЦЕНТРОИД УЛИЦЫ, НЕ ДОМ. В выборке витрины (ЕКБ, + с 2025-01-01; замер на проде 2026-08-29) 991 различная координата на + 34 017 сделок с координатой — ≈34 сделки в одной точке, при 2.7% известных + номеров дома. Точка честна на масштабе района и улицы и НЕ честна на + масштабе дома. Это записано в `note` каждой строки и в COMMENT колонок + (миграция 280), потому что докстринг на фронт не едет, а зумить карту + будет тот, кто его не читал. * ДНЯ НЕТ. `deals.deal_date` — первое число квартала (10 различных значений на всю таблицу), поэтому в витрине только «II квартал 2026». * ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным @@ -115,6 +122,9 @@ NOTE = ( "строка выглядит как промах оценщика, хотя врёт документ. " "Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома " "есть у 2.7% строк, поэтому дом не показан и показан быть не может." + "Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная " + "координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен " + "у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома." ) _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} @@ -143,6 +153,10 @@ class ShowcaseRow: fact_rub: int err_pct: float n_analogs: int + # Координата сделки — ЦЕНТРОИД УЛИЦЫ (замер и разбор в миграции 280 и в NOTE). + # None штатно: у части сделок координаты нет, подставлять туда нечего. + lat: float | None = None + lon: float | None = None def completeness(row: ShowcaseRow) -> int: @@ -180,6 +194,8 @@ def build_row( predicted_rub: float | None, fact_ppm2: float, n_analogs: int, + lat: float | None = None, + lon: float | None = None, ) -> ShowcaseRow | None: """Кандидат → строка витрины, либо None если считать не из чего. @@ -187,6 +203,11 @@ def build_row( цены продажи; квартал сделки неизвестен; нет площади или цены сделки (делить не на что). Величина отклонения причиной НЕ является ни при каких значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля. + + ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на + витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание + сделки из-за отсутствия координаты — отбор по признаку, не связанному с + качеством оценки, то есть та же порча витрины, что и отбор по ошибке. """ if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0: return None @@ -212,6 +233,8 @@ def build_row( fact_rub=round(fact_rub), err_pct=round(err_pct, 2), n_analogs=n_analogs, + lat=lat, + lon=lon, ) @@ -268,13 +291,14 @@ _INSERT_SQL = text( INSERT INTO landing_showcase_deals (district, rooms, area_m2, floor, total_floors, deal_quarter, predicted_rub, fact_rub, err_pct, n_analogs, note, - street_name, street_scheme) + lat, lon, street_name, street_scheme) VALUES (CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric), CAST(:floor AS integer), CAST(:total_floors AS integer), CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint), CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric), CAST(:n_analogs AS integer), CAST(:note AS text), + CAST(:lat AS double precision), CAST(:lon AS double precision), CAST(:street_name AS text), CAST(:street_scheme AS jsonb)) """ ) @@ -363,6 +387,11 @@ def refresh_landing_showcase_deals( predicted_rub=pr.expected_sold_price, fact_ppm2=deal.sold_ppm2, n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, + # Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота + # (~60.6). Перепутать местами — это точка в другой стране, и никакой + # тип этого не поймает: обе величины float. + lat=deal.lat, + lon=deal.lon, ) if row is None: n_incomplete += 1 @@ -392,6 +421,8 @@ def refresh_landing_showcase_deals( "err_pct": row.err_pct, "n_analogs": row.n_analogs, "note": NOTE, + "lat": row.lat, + "lon": row.lon, }, ) counters = { diff --git a/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql b/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql new file mode 100644 index 00000000..c78803fd --- /dev/null +++ b/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql @@ -0,0 +1,48 @@ +-- 280: координата сделки в витрине лэндинга — чтобы карта могла показать точку. +-- +-- ЧТО ЭТО ЗА КООРДИНАТА. Не координата ДОМА. Замер по той самой выборке, +-- из которой набирается витрина (deals, city='Екатеринбург', deal_date >= +-- '2025-01-01'; прод, 2026-08-29): +-- +-- 34 021 сделка, из них 34 017 с координатой (без неё — 4), +-- но РАЗЛИЧНЫХ точек (lat, lon) всего 991 → ≈34 сделки на одну точку, +-- номер дома в адресе известен у 2.7% сделок. +-- +-- Тридцать четыре сделки в одной точке при 2.7% известных домов — это не дома, +-- это центроиды улиц (тот же разбор, из-за которого в миграции 276 нет колонки +-- адреса). Отсюда граница честности: точка верна на масштабе района и улицы и +-- НЕВЕРНА на масштабе дома. Карта, зумящаяся до дома, покажет 34 разные сделки +-- в одном подъезде — это будет враньё интерфейса, а не данных. +-- +-- Поэтому: то же самое написано в `note` каждой строки витрины (note едет на +-- фронт вместе с числами, а комментарий к колонке — нет), и это единственное +-- место, которое увидит следующий, кто возьмётся зумить карту. +-- +-- NULLABLE — потому что у части сделок координаты нет (4 из 34 021 в замере +-- выше). Подставлять туда центр города или район значило бы поставить точку +-- там, где её в данных нет. Строка без координаты остаётся на витрине: +-- выбрасывать сделку из-за отсутствия точки — это отбор по признаку, не +-- связанному с качеством оценки, ровно то, что в этой витрине уже чинили. + +BEGIN; +-- Конвенция проекта (#2752): ADD COLUMN на существующей таблице берёт +-- ACCESS EXCLUSIVE и без lock_timeout встанет в очередь за чужой сессией, +-- утащив за собой запросы приложения. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE landing_showcase_deals + ADD COLUMN IF NOT EXISTS lat double precision, + ADD COLUMN IF NOT EXISTS lon double precision; + +COMMENT ON COLUMN landing_showcase_deals.lat IS + 'Широта сделки — ЦЕНТРОИД УЛИЦЫ, не дом. Замер 2026-08-29 по выборке ' + 'витрины (ЕКБ, с 2025-01-01): 991 различная точка на 34 017 сделок с ' + 'координатой (≈34 сделки на точку), номер дома известен у 2.7%. Честно ' + 'на масштабе района/улицы, НЕ честно на масштабе дома. NULL = координаты ' + 'нет (4 сделки из 34 021); строка при этом остаётся на витрине.'; + +COMMENT ON COLUMN landing_showcase_deals.lon IS + 'Долгота сделки — ЦЕНТРОИД УЛИЦЫ, не дом; см. COMMENT на lat: 991 точка ' + 'на 34 017 сделок, дом известен у 2.7%. NULL = координаты нет.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py index 7b5a51ba..42bde9bd 100644 --- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -93,6 +93,8 @@ def _build(**over: object) -> ShowcaseRow | None: "predicted_rub": 5_000_000.0, "fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0% "n_analogs": 30, + "lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи, + "lon": 60.6055, # поэтому перестановка ловится по значению. } kwargs.update(over) return build_row(**kwargs) # type: ignore[arg-type] @@ -169,3 +171,34 @@ def test_unknown_quarter_is_not_invented() -> None: assert _build(deal_date=None) is None assert quarter_label(None) is None assert quarter_label(date(2026, 7, 1)) == "III квартал 2026" + + +# ── Координата: центроид улицы, но обязана доехать и обязана не перепутаться ── + + +def test_coords_are_carried_through_and_not_swapped() -> None: + """Широта остаётся широтой. Обе величины float, тип перестановку не поймает. + + Ломать так: поменять в `build_row` местами `lat=lat` и `lon=lon` — тест + покраснеет по ЗНАЧЕНИЮ (56.8386 против 60.6055), а не по форме. + """ + row = _build() + assert row is not None + assert row.lat == 56.8386, "широта уехала не в ту колонку" + assert row.lon == 60.6055, "долгота уехала не в ту колонку" + + +def test_row_without_coords_stays_on_showcase() -> None: + """Нет точки — строка всё равно на витрине, с lat=lon=None. + + Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не + связанному с качеством оценки: та же порча витрины, что и отбор по + величине ошибки, просто по другому полю. Карта переживёт строку без точки. + + Ломать так: добавить в `build_row` `if lat is None or lon is None: return + None` — тест покраснеет на None вместо строки. + """ + row = _build(lat=None, lon=None) + assert row is not None, "сделка выброшена из витрины за отсутствие координаты" + assert row.lat is None and row.lon is None, "координата придумана там, где её нет" + assert row.fact_rub == 5_000_000, "остальные величины строки не пострадали" diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 3ea6d9aa..33a8ead9 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -189,6 +189,10 @@ _SHOWCASE_ROW = { "water": [], "labels": [{"t": "улица Чкалова", "x": 431.2, "y": 88.0}], }, + # Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 — + # величины намеренно непохожие, чтобы перестановка ловилась по значению. + "lat": 56.8386, + "lon": 60.6055, } _SHOWCASE_RUN = { "computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), @@ -247,6 +251,44 @@ def test_showcase_row_without_street_stays_null(client: TestClient) -> None: assert body["deals"][0]["street_scheme"] is None +def test_showcase_carries_coords_without_swapping_them(client: TestClient) -> None: + """Координата доезжает до JSON и не переставлена местами. + + Обе величины float, поэтому перестановку не поймает ни схема, ни тип — + только значение: 56.8 (широта ЕКБ) против 60.6 (долгота). Ломать так: + поменять местами `lat=` и `lon=` в сборке `ShowcaseDeal` — покраснеет здесь. + + Точка при этом — ЦЕНТРОИД УЛИЦЫ, а не дом (991 различная координата на + 34 017 сделок выборки); ровно это написано в `note`, который едет рядом. + """ + client.app.dependency_overrides[get_db] = lambda: _showcase_db() + + deal = client.get(f"{PREFIX}/showcase").json()["deals"][0] + assert deal["lat"] == 56.8386, "широта приехала не в то поле" + assert deal["lon"] == 60.6055, "долгота приехала не в то поле" + + +def test_showcase_row_without_coords_reaches_the_client_as_null( + client: TestClient, +) -> None: + """Сделка без координаты доезжает до ответа целиком, с lat=lon=null. + + Карта переживёт строку без точки; выбрасывание такой сделки было бы + отбором по признаку, не связанному с качеством оценки. Подстановка центра + города вместо отсутствующей точки — тем более враньё. + + Ломать так: заменить null на любую подстановку или отфильтровать строку в + ручке — покраснеет по значению, а не по форме. + """ + row = {**_SHOWCASE_ROW, "lat": None, "lon": None} + client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row]) + + deals = client.get(f"{PREFIX}/showcase").json()["deals"] + assert len(deals) == 1, "строка без координаты выброшена из витрины" + assert deals[0]["lat"] is None and deals[0]["lon"] is None + assert deals[0]["fact_rub"] == 5_900_000, "остальные величины строки не пострадали" + + def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was( client: TestClient, ) -> None: