fix(ptica): нет сделок за окно — цена не 0 ₽/м², а «нет данных» (#2464-B) #2868

Merged
bot-backend merged 1 commit from fix/2464-layouts-zero-price into main 2026-08-13 12:26:52 +00:00
Collaborator

Что

deals_per_bucket в _INLINE_VELOCITY_SQL оборачивал деление в COALESCE(..., 0):

COALESCE(SUM(a.price_weighted_sum) / NULLIF(SUM(a.deals_window), 0), 0)::numeric(12,2) * 1000.0

Сделок за окно нет → делитель NULL → подставляется 0. Пользователю это читается как
«средняя цена продаж в этой комнатности — 0 ₽/м²».

Обещание в контракте без реализации

Схема уже объявляла честный ответ:

avg_price_per_m2_rub: float | None  # NULL если objective не покрывает obj

и Python-потребитель уже умеет None — пропускает такую строку во взвешенном роллапе
(if row["avg_price_per_m2_rub"] is not None). То есть честная ветка была написана,
а COALESCE делал её недостижимой. Правка не добавляет поведение, а включает
уже заявленное.

Замер (прод, 13.08, окно 6 месяцев)

Сначала посчитал не то: агрегат по всему городу даёт 5 комнатностей, все непустые,
и разницы нет вообще. Но deals_per_bucket группирует по room_bucket после джойна
с проектами в радиусе — значит попадёт ноль или нет, зависит от того, сколько замапленных
проектов рядом с участком. Правильные цифры — по слоям:

проектов с данными за окно                616
пар (проект × комнатность)               2083
из них пустых (deals_window = 0/NULL)     635

проектов, где пуста хотя бы одна комнатность   323  (52%)
проектов, где пусты ВСЕ комнатности             80  (13%)
в среднем пустых комнатностей на проект       1.03

объединяя проекты по два                       255 пустых из 1267  (20%)
объединяя весь город                             0 пустых из 5

Чем беднее окрестность участка, тем чаще выдумывался ноль. Для участка, у которого
в радиусе один проект из тех 80, обнулялись все комнатности.

Отдельно проверил гипотезу из аудита («все слагаемые площади/цены NULL»): строк с
deals_total_count > 0 и NULL-ценой — 0. Механизм другой — пустое окно продаж.

Что НЕ трогаю

avg_area_m2 остаётся с COALESCE(...,0): TopLayoutRow.avg_area_m2: float — не Optional,
честный NULL требует правки схемы, перегенерации типов фронта и решения, что писать
в area_bin (сейчас при нулевой площади молча подставляется "<25", то есть выдумывается
ещё и типоразмер). Вынес в #2867 с теми же замерами, чтобы не тащить контрактную
правку прицепом к однострочнику.

Проверка

  • прод-A/B того же выражения: для непустых комнатностей значения не меняются
    (172470 / 153540 / 146960 / 159240 / 180750 — до и после совпадают), меняются только
    пустые: 0NULL
  • pytest -k layout — 128 passed, 7 skipped
  • ruff check — clean

Честно про тесты: DB-прогона по данным в наборе нет (интеграционный харнесс ПТИЦЫ —
только EXPLAIN), поэтому численная проверка — прод-A/B выше, а не юнит-тест.

Refs #2464

## Что `deals_per_bucket` в `_INLINE_VELOCITY_SQL` оборачивал деление в `COALESCE(..., 0)`: ```sql COALESCE(SUM(a.price_weighted_sum) / NULLIF(SUM(a.deals_window), 0), 0)::numeric(12,2) * 1000.0 ``` Сделок за окно нет → делитель NULL → подставляется **0**. Пользователю это читается как «средняя цена продаж в этой комнатности — 0 ₽/м²». ## Обещание в контракте без реализации Схема **уже** объявляла честный ответ: ```python avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj ``` и Python-потребитель уже умеет None — пропускает такую строку во взвешенном роллапе (`if row["avg_price_per_m2_rub"] is not None`). То есть честная ветка была написана, а `COALESCE` делал её **недостижимой**. Правка не добавляет поведение, а включает уже заявленное. ## Замер (прод, 13.08, окно 6 месяцев) Сначала посчитал не то: агрегат по всему городу даёт 5 комнатностей, все непустые, и разницы нет вообще. Но `deals_per_bucket` группирует по `room_bucket` **после** джойна с проектами в радиусе — значит попадёт ноль или нет, зависит от того, сколько замапленных проектов рядом с участком. Правильные цифры — по слоям: ``` проектов с данными за окно 616 пар (проект × комнатность) 2083 из них пустых (deals_window = 0/NULL) 635 проектов, где пуста хотя бы одна комнатность 323 (52%) проектов, где пусты ВСЕ комнатности 80 (13%) в среднем пустых комнатностей на проект 1.03 объединяя проекты по два 255 пустых из 1267 (20%) объединяя весь город 0 пустых из 5 ``` Чем беднее окрестность участка, тем чаще выдумывался ноль. Для участка, у которого в радиусе один проект из тех 80, обнулялись **все** комнатности. Отдельно проверил гипотезу из аудита («все слагаемые площади/цены NULL»): строк с `deals_total_count > 0` и NULL-ценой — **0**. Механизм другой — пустое окно продаж. ## Что НЕ трогаю `avg_area_m2` остаётся с `COALESCE(...,0)`: `TopLayoutRow.avg_area_m2: float` — не Optional, честный NULL требует правки схемы, перегенерации типов фронта и решения, что писать в `area_bin` (сейчас при нулевой площади молча подставляется `"<25"`, то есть выдумывается ещё и типоразмер). Вынес в **#2867** с теми же замерами, чтобы не тащить контрактную правку прицепом к однострочнику. ## Проверка - [x] прод-A/B того же выражения: для непустых комнатностей значения **не меняются** (172470 / 153540 / 146960 / 159240 / 180750 — до и после совпадают), меняются только пустые: `0` → `NULL` - [x] `pytest -k layout` — 128 passed, 7 skipped - [x] `ruff check` — clean Честно про тесты: DB-прогона по данным в наборе нет (интеграционный харнесс ПТИЦЫ — только EXPLAIN), поэтому численная проверка — прод-A/B выше, а не юнит-тест. Refs #2464
bot-backend added 1 commit 2026-08-13 11:56:11 +00:00
fix(ptica): нет сделок за окно — цена не 0 ₽/м², а «нет данных»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 17m28s
CI / openapi-codegen-check (pull_request) Successful in 2m8s
c799dbf0ab
deals_per_bucket оборачивал деление в COALESCE(...,0), поэтому пустая
комнатность получала среднюю цену 0 вместо NULL.

Схема УЖЕ обещала честный ответ (TopLayoutRow.avg_price_per_m2_rub:
float | None, комментарий «NULL если objective не покрывает obj»), и
Python-потребитель уже умеет None — пропускает такую строку во взвешенном
роллапе. COALESCE делал эту ветку недостижимой: обещание в контракте без
реализации.

Замер 13.08, окно 6 месяцев. Сработает ноль или нет — зависит от того,
сколько замапленных проектов попало в радиус, поэтому по слоям: 616
проектов, 2083 пары (проект × комнатность), пустых 635; хотя бы одна
пустая комнатность у 323 проектов, все пустые — у 80. При объединении по
два пустых 255 из 1267, по всему городу — ноль.

avg_area_m2 намеренно не трогаю: там контракт float (не Optional), нужна
правка схемы + типов фронта + решение по area_bin — отдельно, #2867.

Refs #2464
bot-backend closed this pull request 2026-08-13 12:03:26 +00:00
bot-backend reopened this pull request 2026-08-13 12:03:31 +00:00
Author
Collaborator

openapi-codegen-check покраснел на первом прогоне — не из-за этой правки.

Проверил прямо: дампнул app.openapi() с этой ветки и с main-эквивалента —
байт-в-байт одинаково (348 531 байт обоих). SQL-правка схему API не трогает, так что
«типы фронта устарели» тут физически невозможно.

Настоящая причина — диск VPS был заполнен на 94%, job упал на установке зависимостей.
Тем же дефицитом объясняются красный backend-tests на #2865 и обе сборки деплоя main.
Вычистил кэш старых buildx-билдеров: свободно 13G → 28G. Разбор — #2869.

Прогон перезапущен на разгруженном хосте.

`openapi-codegen-check` покраснел на первом прогоне — **не из-за этой правки**. Проверил прямо: дампнул `app.openapi()` с этой ветки и с main-эквивалента — байт-в-байт одинаково (348 531 байт обоих). SQL-правка схему API не трогает, так что «типы фронта устарели» тут физически невозможно. Настоящая причина — **диск VPS был заполнен на 94%**, job упал на установке зависимостей. Тем же дефицитом объясняются красный `backend-tests` на #2865 и обе сборки деплоя main. Вычистил кэш старых buildx-билдеров: свободно 13G → 28G. Разбор — #2869. Прогон перезапущен на разгруженном хосте.
bot-backend merged commit 53bb769ea5 into main 2026-08-13 12:26:52 +00:00
bot-backend deleted branch fix/2464-layouts-zero-price 2026-08-13 12:26:52 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2868
No description provided.