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

View file

@ -37,6 +37,13 @@
---------------------------------------------------------- ----------------------------------------------------------
* АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка это «район + * АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка это «район +
2-к, 54 м², 5 эт.», и никогда не улица с домом. 2-к, 54 м², 5 эт.», и никогда не улица с домом.
* ТОЧКА НА КАРТЕ ЦЕНТРОИД УЛИЦЫ, НЕ ДОМ. В выборке витрины (ЕКБ,
с 2025-01-01; замер на проде 2026-08-29) 991 различная координата на
34 017 сделок с координатой 34 сделки в одной точке, при 2.7% известных
номеров дома. Точка честна на масштабе района и улицы и НЕ честна на
масштабе дома. Это записано в `note` каждой строки и в COMMENT колонок
(миграция 280), потому что докстринг на фронт не едет, а зумить карту
будет тот, кто его не читал.
* ДНЯ НЕТ. `deals.deal_date` первое число квартала (10 различных значений * ДНЯ НЕТ. `deals.deal_date` первое число квартала (10 различных значений
на всю таблицу), поэтому в витрине только «II квартал 2026». на всю таблицу), поэтому в витрине только «II квартал 2026».
* ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным * ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным
@ -102,7 +109,10 @@ NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ." "строка выглядит как промах оценщика, хотя врёт документ. "
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
"координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен "
"у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома."
) )
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
@ -131,6 +141,10 @@ class ShowcaseRow:
fact_rub: int fact_rub: int
err_pct: float err_pct: float
n_analogs: int n_analogs: int
# Координата сделки — ЦЕНТРОИД УЛИЦЫ (замер и разбор в миграции 280 и в NOTE).
# None штатно: у части сделок координаты нет, подставлять туда нечего.
lat: float | None = None
lon: float | None = None
def completeness(row: ShowcaseRow) -> int: def completeness(row: ShowcaseRow) -> int:
@ -168,6 +182,8 @@ def build_row(
predicted_rub: float | None, predicted_rub: float | None,
fact_ppm2: float, fact_ppm2: float,
n_analogs: int, n_analogs: int,
lat: float | None = None,
lon: float | None = None,
) -> ShowcaseRow | None: ) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо 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: if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0:
return None return None
@ -200,6 +221,8 @@ def build_row(
fact_rub=round(fact_rub), fact_rub=round(fact_rub),
err_pct=round(err_pct, 2), err_pct=round(err_pct, 2),
n_analogs=n_analogs, n_analogs=n_analogs,
lat=lat,
lon=lon,
) )
@ -255,13 +278,14 @@ _INSERT_SQL = text(
""" """
INSERT INTO landing_showcase_deals INSERT INTO landing_showcase_deals
(district, rooms, area_m2, floor, total_floors, deal_quarter, (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 VALUES
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric), (CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
CAST(:floor AS integer), CAST(:total_floors AS integer), CAST(:floor AS integer), CAST(:total_floors AS integer),
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint), CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric), 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, predicted_rub=pr.expected_sold_price,
fact_ppm2=deal.sold_ppm2, fact_ppm2=deal.sold_ppm2,
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, 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: if row is None:
n_incomplete += 1 n_incomplete += 1
@ -353,6 +382,8 @@ def refresh_landing_showcase_deals(
"err_pct": row.err_pct, "err_pct": row.err_pct,
"n_analogs": row.n_analogs, "n_analogs": row.n_analogs,
"note": NOTE, "note": NOTE,
"lat": row.lat,
"lon": row.lon,
}, },
) )
counters = { 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, "predicted_rub": 5_000_000.0,
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0% "fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
"n_analogs": 30, "n_analogs": 30,
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
"lon": 60.6055, # поэтому перестановка ловится по значению.
} }
kwargs.update(over) kwargs.update(over)
return build_row(**kwargs) # type: ignore[arg-type] 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 _build(deal_date=None) is None
assert quarter_label(None) is None assert quarter_label(None) is None
assert quarter_label(date(2026, 7, 1)) == "III квартал 2026" 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, "err_pct": 3.39,
"n_analogs": 41, "n_analogs": 41,
"note": "не point-in-time", "note": "не point-in-time",
# Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 —
# величины намеренно непохожие, чтобы перестановка ловилась по значению.
"lat": 56.8386,
"lon": 60.6055,
} }
_SHOWCASE_RUN = { _SHOWCASE_RUN = {
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), "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] 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( def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was(
client: TestClient, client: TestClient,
) -> None: ) -> None: