fix(ptica): лоты без площади — своя корзина, а не «<25 м²» (#2464) #2983

Merged
bot-backend merged 1 commit from fix/2464-area-bin-null into main 2026-08-20 17:02:26 +00:00
Collaborator

Дефект

_SUPPLY_ONLY_LOTS_SQL раскладывал лоты без площади в ту же корзину, что и настоящие студии:

CASE
    WHEN area_pd IS NULL THEN '<25'   
    WHEN area_pd < 25    THEN '<25'

Сколько это на проде

Замер 20.08.2026 — последний снапшот на физлот, premise_kind='квартира', не проданные:

в продаже        181 353
без area_pd       11 557   (6.4 %)
реально < 25 м²    7 013

Корзина «<25» = 18 570 лотов, из которых 62 % — неизвестное. Блок «По предложению (без темпа продаж)» показывал долю мелких лотов почти втрое завышенной.

У апартаментов та же картина, но мельче: 31 без площади из 1 235 (2.5 %).

Зеркала у этого правила не было

Комментарий над запросом говорит «area_bin — зеркало layout_signature.area_bin». Проверил обе стороны:

  • layout_signature.area_bin(area_m2: float) — принимает float, NULL-ветки не имеет вовсе;
  • 94_mv_layout_velocity.sql — по площади вообще не группирует.

То есть NULL → '<25' не переносило чужое правило, а придумывало своё.

Правка

Отдельная корзина «н/д».

Исключать такие лоты нельзя — они реально в продаже, и без них предложение занизилось бы на 6.4 %. Медиана площади у корзины «н/д» выйдет NULL (PERCENTILE_CONT игнорирует NULL) — то есть честно «неизвестно», а не выдуманное число.

Схема не меняется: area_bin остаётся str, OpenAPI и типы фронта прежние. В PDF-экспортёре значение попадает в ячейку через _html.escape — новая метка рисуется штатно.

Как проверено

Тест герметичный и прогоняет настоящий SQL: временная таблица objective_lots затеняет боевую в пределах сессии (temp-схема идёт раньше public в search_path), поэтому запрос берётся из модуля дословно, а прод-данные не читаются и не меняются.

  • Двусторонне: против origin/main корзины выходят {'<25': 2, '25-40': 1, '40-60': 1} — конкретное неверное значение (два лота вместо одного), ноль TypeError/ImportError/KeyError.
  • Контроль сохранности: сумма по корзинам равна числу лотов — ловит «починку» через выбрасывание NULL-лотов. Зелёный с обеих сторон.
  • Контроль от переусердствования: обычные корзины 25-40 и 40-60 не меняются. Зелёный с обеих сторон.
  • pytest backend/tests/sql/ — 38 passed (через туннель к проду).
  • Без БД: 4 skipped, код 0 — записи внесены в skip_allowlist.txt с причиной, по образцу соседних DB-тестов.

Что изменится в отчёте после мержа

В блоке «По предложению» у комплексов с неполными данными появится строка «н/д» с прочерком в столбце «Ср. площадь». Доля «<25» упадёт до настоящей. Отчитаюсь числами после деплоя.

Часть эпика #2464.

## Дефект `_SUPPLY_ONLY_LOTS_SQL` раскладывал лоты без площади в ту же корзину, что и настоящие студии: ```sql CASE WHEN area_pd IS NULL THEN '<25' ← WHEN area_pd < 25 THEN '<25' ``` ## Сколько это на проде Замер 20.08.2026 — последний снапшот на физлот, `premise_kind='квартира'`, не проданные: ``` в продаже 181 353 без area_pd 11 557 (6.4 %) реально < 25 м² 7 013 ``` Корзина «<25» = 18 570 лотов, из которых **62 % — неизвестное**. Блок «По предложению (без темпа продаж)» показывал долю мелких лотов почти втрое завышенной. У апартаментов та же картина, но мельче: 31 без площади из 1 235 (2.5 %). ## Зеркала у этого правила не было Комментарий над запросом говорит «area_bin — зеркало layout_signature.area_bin». Проверил обе стороны: - `layout_signature.area_bin(area_m2: float)` — принимает **float**, NULL-ветки не имеет вовсе; - `94_mv_layout_velocity.sql` — по площади вообще не группирует. То есть `NULL → '<25'` не переносило чужое правило, а придумывало своё. ## Правка Отдельная корзина «н/д». Исключать такие лоты нельзя — они реально в продаже, и без них предложение занизилось бы на 6.4 %. Медиана площади у корзины «н/д» выйдет `NULL` (`PERCENTILE_CONT` игнорирует NULL) — то есть честно «неизвестно», а не выдуманное число. Схема не меняется: `area_bin` остаётся `str`, OpenAPI и типы фронта прежние. В PDF-экспортёре значение попадает в ячейку через `_html.escape` — новая метка рисуется штатно. ## Как проверено Тест **герметичный и прогоняет настоящий SQL**: временная таблица `objective_lots` затеняет боевую в пределах сессии (temp-схема идёт раньше `public` в `search_path`), поэтому запрос берётся из модуля дословно, а прод-данные не читаются и не меняются. - **Двусторонне:** против `origin/main` корзины выходят `{'<25': 2, '25-40': 1, '40-60': 1}` — конкретное неверное значение (два лота вместо одного), **ноль** TypeError/ImportError/KeyError. - **Контроль сохранности:** сумма по корзинам равна числу лотов — ловит «починку» через выбрасывание NULL-лотов. Зелёный с обеих сторон. - **Контроль от переусердствования:** обычные корзины 25-40 и 40-60 не меняются. Зелёный с обеих сторон. - `pytest backend/tests/sql/` — 38 passed (через туннель к проду). - Без БД: 4 skipped, код 0 — записи внесены в `skip_allowlist.txt` с причиной, по образцу соседних DB-тестов. ## Что изменится в отчёте после мержа В блоке «По предложению» у комплексов с неполными данными появится строка «н/д» с прочерком в столбце «Ср. площадь». Доля «<25» упадёт до настоящей. Отчитаюсь числами после деплоя. Часть эпика #2464.
bot-backend added 1 commit 2026-08-20 16:42:46 +00:00
fix(ptica): лоты без площади — своя корзина, а не «<25 м²» (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
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 / openapi-codegen-check (pull_request) Successful in 2m27s
CI / backend-tests (pull_request) Successful in 17m13s
1b2a26bb9a
`_SUPPLY_ONLY_LOTS_SQL` раскладывал `area_pd IS NULL` в ту же корзину
`'<25'`, что и настоящие студии. Замер прода 20.08.2026 (последний
снапшот на физлот, premise_kind='квартира', не проданные):

    в продаже            181 353
    без area_pd           11 557   (6.4 %)
    реально < 25 м²        7 013

Корзина «<25» состояла из неизвестного на 62 % и завышала долю мелких
лотов в блоке «По предложению (без темпа продаж)».

Зеркала у такого отображения не было: `layout_signature.area_bin`
принимает float и NULL-ветки не имеет вовсе, а velocity-MV по площади
не группирует — то есть `NULL → '<25'` было выдумкой, а не переносом
чужого правила.

Исключать такие лоты нельзя: они реально в продаже, и без них
предложение занизилось бы на 6.4 %. Поэтому отдельная корзина «н/д».
Медиана площади у неё выйдет NULL (PERCENTILE_CONT игнорирует NULL) —
честно. Схема не меняется: area_bin остаётся str, OpenAPI прежний.

Тест герметичный и прогоняет НАСТОЯЩИЙ SQL: временная таблица
objective_lots затеняет боевую в пределах сессии, запрос берётся из
модуля дословно, прод-данные не читаются.

Двусторонне: против origin/main корзины распределяются как
{'<25': 2, '25-40': 1, '40-60': 1} — конкретное неверное значение, ни
одного TypeError/ImportError. Контроли (сумма лотов сохраняется,
обычные корзины не меняются) зелёные с обеих сторон.

pytest backend/tests/sql/ — 38 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bot-backend merged commit 91a5207641 into main 2026-08-20 17:02:26 +00:00
Author
Collaborator

Проверено на проде

Код в живом контейнере (не по статусу джобы):

gendesign-backend-1 /app/app/services/site_finder/best_layouts.py:427
    WHEN area_pd IS NULL     THEN 'н/д'

Эффект на живых данных — распределение корзин по квартирам в продаже:

40-60      57 353   31.6%
25-40      54 636   30.1%
60-80      35 250   19.4%
80-100     11 757    6.5%
н/д        11 557    6.4%   ← новая корзина
<25         7 013    3.9%
100+        3 787    2.1%

Корзина «<25»: было 18 570 → стало 7 013. Доля мелких лотов в структуре остатков падает с 10.3 % до 3.9 % — то есть больше чем вдвое была не мелкими лотами, а неизвестной площадью.

Ни один лот не потерян: 11 557 переехали в «н/д», сумма по корзинам прежняя. Медиана площади у «н/д» — NULL, как и задумано: PERCENTILE_CONT игнорирует NULL, и в отчёте там будет прочерк, а не выдуманное число.

## Проверено на проде Код в живом контейнере (не по статусу джобы): ``` gendesign-backend-1 /app/app/services/site_finder/best_layouts.py:427 WHEN area_pd IS NULL THEN 'н/д' ``` Эффект на живых данных — распределение корзин по квартирам в продаже: ``` 40-60 57 353 31.6% 25-40 54 636 30.1% 60-80 35 250 19.4% 80-100 11 757 6.5% н/д 11 557 6.4% ← новая корзина <25 7 013 3.9% 100+ 3 787 2.1% ``` **Корзина «<25»: было 18 570 → стало 7 013.** Доля мелких лотов в структуре остатков падает с 10.3 % до 3.9 % — то есть больше чем вдвое была не мелкими лотами, а неизвестной площадью. Ни один лот не потерян: 11 557 переехали в «н/д», сумма по корзинам прежняя. Медиана площади у «н/д» — NULL, как и задумано: `PERCENTILE_CONT` игнорирует NULL, и в отчёте там будет прочерк, а не выдуманное число.
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#2983
No description provided.