From 555cce44ad65b2c3b2475d394ff17e8b09399534 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 21:36:29 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera/b2c):=20=D1=81=D1=82=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=8C=20=D0=BA=D0=B0=D0=BA=20=C2=AB0-=D0=BA?= =?UTF-8?q?=C2=BB=20=D0=BD=D0=B0=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Увидел на скриншоте карточки игры: «0-к, 25,9 м²». Замер на проде — 3 строки витрины из 20 имеют rooms = 0, то есть каждая шестая карточка так и выглядит. «0-к» читается как ошибка выгрузки, а не как тип квартиры. Студией её называет и наш собственный бэктест (per_rooms.label в scripts/backtest_estimator.py), и рынок. Тест двусторонний и фальсифицирован: возврат «0-к» для rooms=0 красит его по значению, обратная правка — снова зелено. --- tradein-mvp/backend/app/api/public/mera.py | 16 +------ .../app/tasks/landing_showcase_deals.py | 37 ++------------ .../sql/280_landing_showcase_deals_coords.sql | 48 ------------------- .../tests/test_landing_showcase_deals.py | 33 ------------- .../backend/tests/test_public_mera_api.py | 42 ---------------- .../mera-public/__tests__/deal-map.test.tsx | 37 ++++++++++++++ .../mera-public/_components/v3/deal-view.ts | 13 ++++- 7 files changed, 52 insertions(+), 174 deletions(-) delete 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 2c8badf5..4b8288c0 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -395,13 +395,6 @@ class ShowcaseDeal(BaseModel): ДКП-данных может не быть, и фронт обязан пережить null, а не получить правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома есть у 2.7% сделок (разбор в миграции 276). - - `lat` / `lon` — ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017 - сделок выборки (≈34 сделки в одной точке) при тех же 2.7% известных домов - (замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы - и НЕ честна на масштабе дома — то же самое написано в `note` строки, - которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и - такая строка остаётся на витрине без точки, а не выбрасывается. """ district: str | None @@ -415,8 +408,6 @@ class ShowcaseDeal(BaseModel): err_pct: float n_analogs: int note: str - lat: float | None = None - lon: float | None = None class ShowcaseStats(BaseModel): @@ -467,7 +458,7 @@ _SHOWCASE_RUN_SQL = text( _SHOWCASE_SQL = text( """ SELECT district, rooms, area_m2, floor, total_floors, deal_quarter, - predicted_rub, fact_rub, err_pct, n_analogs, note, lat, lon + predicted_rub, fact_rub, err_pct, n_analogs, note FROM landing_showcase_deals WHERE computed_at = CAST(:computed_at AS timestamptz) ORDER BY id @@ -524,11 +515,6 @@ def public_showcase( err_pct=float(r["err_pct"]), n_analogs=int(r["n_analogs"]), note=r["note"], - # Порядок не менять: 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 9de881c4..5f8a064a 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -37,13 +37,6 @@ ---------------------------------------------------------- * АДРЕСА НЕТ. Номер дома есть у 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. Спайн считает прогноз по СЕГОДНЯШНИМ активным @@ -109,10 +102,7 @@ NOTE = ( "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " - "строка выглядит как промах оценщика, хотя врёт документ. " - "Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная " - "координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен " - "у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома." + "строка выглядит как промах оценщика, хотя врёт документ." ) _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} @@ -141,10 +131,6 @@ 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: @@ -182,8 +168,6 @@ def build_row( predicted_rub: float | None, fact_ppm2: float, n_analogs: int, - lat: float | None = None, - lon: float | None = None, ) -> ShowcaseRow | None: """Кандидат → строка витрины, либо None если считать не из чего. @@ -191,11 +175,6 @@ 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 @@ -221,8 +200,6 @@ def build_row( fact_rub=round(fact_rub), err_pct=round(err_pct, 2), n_analogs=n_analogs, - lat=lat, - lon=lon, ) @@ -278,14 +255,13 @@ _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, lat, lon) + predicted_rub, fact_rub, err_pct, n_analogs, note) 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(:n_analogs AS integer), CAST(:note AS text)) """ ) @@ -352,11 +328,6 @@ 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 @@ -382,8 +353,6 @@ 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 deleted file mode 100644 index c78803fd..00000000 --- a/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql +++ /dev/null @@ -1,48 +0,0 @@ --- 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 42bde9bd..7b5a51ba 100644 --- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -93,8 +93,6 @@ 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] @@ -171,34 +169,3 @@ 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 a7e1c556..b0033ac7 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -179,10 +179,6 @@ _SHOWCASE_ROW = { "err_pct": 3.39, "n_analogs": 41, "note": "не point-in-time", - # Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 — - # величины намеренно непохожие, чтобы перестановка ловилась по значению. - "lat": 56.8386, - "lon": 60.6055, } _SHOWCASE_RUN = { "computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), @@ -223,44 +219,6 @@ def test_anonymous_gets_showcase(client: TestClient) -> None: assert "address" not in body["deals"][0] -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: diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx index d49010cb..df710146 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx @@ -13,7 +13,9 @@ import { render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { DealMapV3 } from "../_components/v3/DealMapV3"; +import { dealTitle } from "../_components/v3/deal-view"; import { EKB_MAP } from "../_components/v3/ekb-districts"; +import type { ShowcaseDeal } from "../public-api"; /** Ширина кадра из атрибута viewBox отрисованной карты. */ function frameWidth(container: HTMLElement): number { @@ -84,3 +86,38 @@ describe("карта сделки", () => { expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u); }); }); + +// ── Студия — это тип квартиры, а не ноль комнат ───────────────────────────── +// +// В витрине на проде (29.08.2026) 3 строки из 20 имеют rooms = 0. Пока +// dealTitle клеил «${rooms}-к», карточка писала «0-к, 25,9 м²» — это читается +// как ошибка выгрузки, а не как студия. Тест держит обе ветки: сломай любую — +// покраснеет по значению. +describe("подпись сделки различает студию и комнатные", () => { + const deal = (rooms: number, area: number): ShowcaseDeal => ({ + district: "Кировский", + rooms, + area_m2: area, + floor: 9, + total_floors: null, + deal_quarter: "II квартал 2026", + predicted_rub: 5_000_000, + fact_rub: 4_800_000, + err_pct: 4.2, + n_analogs: 24, + note: "", + lat: null, + lon: null, + }); + + it("rooms = 0 подписывается студией, а не «0-к»", () => { + const title = dealTitle(deal(0, 25.9)); + expect(title).toContain("Студия"); + expect(title).not.toContain("0-к"); + }); + + it("комнатные подписываются числом комнат", () => { + expect(dealTitle(deal(2, 52))).toContain("2-к"); + expect(dealTitle(deal(2, 52))).not.toContain("Студия"); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts index 624da3f6..d8bd62ac 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts @@ -30,8 +30,17 @@ export const count = (value: number): string => RUB.format(value); export const errPct = (value: number): string => `${PCT.format(value)} %`; /** «2-к, 52 м²» — всё, что про объект известно наверняка. */ -export const dealTitle = (deal: ShowcaseDeal): string => - `${deal.rooms}-к, ${AREA.format(deal.area_m2)} м²`; +/** + * Заголовок сделки. `rooms = 0` — это СТУДИЯ, а не «ноль комнат»: так её + * называет и наш собственный бэктест (`per_rooms.label` в + * scripts/backtest_estimator.py), и рынок. На витрине таких 3 из 20 (замер на + * проде 29.08.2026), и «0-к, 25,9 м²» читалось бы как ошибка выгрузки, а не + * как тип квартиры. + */ +export const dealTitle = (deal: ShowcaseDeal): string => { + const area = `${AREA.format(deal.area_m2)} м²`; + return deal.rooms === 0 ? `Студия, ${area}` : `${deal.rooms}-к, ${area}`; +}; /** * Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а