feat(mera/b2c): координата сделки доезжает до витрины — с записанной границей честности

Карта лэндинга не может показать точку, пока её нет в витрине: район, комнаты,
площадь и квартал в `landing_showcase_deals` есть, координаты не было.

Что сделано: миграция 280 добавляет lat/lon (double precision, NULLABLE),
задача пересчёта переносит их из `deals`, ручка /showcase отдаёт их как
Optional[float].

ГРАНИЦА ЧЕСТНОСТИ, записанная в трёх местах (COMMENT колонок, `note` каждой
строки, докстринг задачи), а не только в голове автора: это ЦЕНТРОИД УЛИЦЫ, а
не дом. Замер на проде 2026-08-29 по той самой выборке, из которой набирается
витрина (deals, city='Екатеринбург', deal_date >= '2025-01-01'): 34 021 сделка,
34 017 с координатой, но РАЗЛИЧНЫХ точек всего 991 — ≈34 сделки в одной точке,
при 2.7% известных номеров дома. Точка верна на масштабе района и улицы и
неверна на масштабе дома; `note` едет на фронт вместе с числами, поэтому
следующий, кто возьмётся зумить карту, об этом споткнётся.

NULLABLE и без отбраковки: строка без координаты остаётся на витрине с
lat=lon=None. Выбрасывать сделку за отсутствие точки — отбор по признаку, не
связанному с качеством оценки, то есть та же порча витрины, которую здесь уже
чинили (порог по величине ошибки).

Тесты двусторонние, каждый проверен фальсификацией — краснеет по ЗНАЧЕНИЮ:
  * перестановка lat/lon в build_row  → 60.6055 == 56.8386
  * `if lat is None: return None`     → None is not None
  * перестановка lat/lon в ручке      → 60.6055 == 56.8386
  * фильтр строк без координат в ручке → len([]) == 1
Перестановку широты и долготы не ловит ни схема, ни тип (обе float), поэтому
в фикстурах намеренно непохожие величины: 56.8386 против 60.6055.

Фронтенд не тронут — его делает следующий шаг.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-29 21:10:02 +05:00
parent f88e06c090
commit cd2a767178
5 changed files with 172 additions and 4 deletions

View file

@ -395,6 +395,13 @@ class ShowcaseDeal(BaseModel):
ДКП-данных может не быть, и фронт обязан пережить null, а не получить
правдоподобную подстановку. Улицы и дома в модели нет вовсе номер дома
есть у 2.7% сделок (разбор в миграции 276).
`lat` / `lon` ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017
сделок выборки (34 сделки в одной точке) при тех же 2.7% известных домов
(замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы
и НЕ честна на масштабе дома то же самое написано в `note` строки,
которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и
такая строка остаётся на витрине без точки, а не выбрасывается.
"""
district: str | None
@ -408,6 +415,8 @@ class ShowcaseDeal(BaseModel):
err_pct: float
n_analogs: int
note: str
lat: float | None = None
lon: float | None = None
class ShowcaseStats(BaseModel):
@ -458,7 +467,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
predicted_rub, fact_rub, err_pct, n_analogs, note, lat, lon
FROM landing_showcase_deals
WHERE computed_at = CAST(:computed_at AS timestamptz)
ORDER BY id
@ -515,6 +524,11 @@ 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
],

View file

@ -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. Спайн считает прогноз по СЕГОДНЯШНИМ активным
@ -102,7 +109,10 @@ NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ."
"строка выглядит как промах оценщика, хотя врёт документ. "
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
"координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен "
"у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома."
)
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
@ -131,6 +141,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:
@ -168,6 +182,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 если считать не из чего.
@ -175,6 +191,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
@ -200,6 +221,8 @@ def build_row(
fact_rub=round(fact_rub),
err_pct=round(err_pct, 2),
n_analogs=n_analogs,
lat=lat,
lon=lon,
)
@ -255,13 +278,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)
predicted_rub, fact_rub, err_pct, n_analogs, note, lat, lon)
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(:n_analogs AS integer), CAST(:note AS text),
CAST(:lat AS double precision), CAST(:lon AS double precision))
"""
)
@ -328,6 +352,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
@ -353,6 +382,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 = {

View file

@ -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;

View file

@ -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, "остальные величины строки не пострадали"

View file

@ -179,6 +179,10 @@ _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),
@ -219,6 +223,44 @@ 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: