Лучшие планировки: в «покрытие P% (Y из N комплексов)» N считается одинаково во всех ветках #3570

Merged
bot-backend merged 2 commits from fix/ptica-best-layouts into main 2026-09-17 11:02:16 +00:00
Collaborator

Пункт эпика #2464 best_layouts.py:1113. Эпик этим PR не закрывается: в его списке остаются открытые пункты, например competitors.py:553, который ждёт открытого #2962.

Что было

get_best_layouts отдаёт data_quality.objects_total_in_radius из трёх веток, и считали они это поле по-разному (сверено на origin/main@34642e1d):

ветка значение
фильтр никого не оставил (:1141) groups_total_pre_filter, до exclude/filter
нет velocity (:1231) len(complex_groups), после фильтра
штатная (:1464) groups_total_after_filter, после фильтра

20.08 пункт остановили с пометкой «нужно решение, какой смысл у поля верен». Позже к нему дописали «ОТКЛОНЁН» с доводом «поле описывает радиус, до фильтра», но этот довод противоречит двум веткам из трёх. Дефект остался в коде.

Почему выбран смысл «после фильтра»

Решение принято по тому, как поле используют, а не по его имени. Потребителей у поля два (git grep по репозиторию), и оба выводят его как N в строке про покрытие:

  • frontend/src/components/site-finder/BestLayoutsBlock.tsx:133: «покрытие P% (Y из N комплексов)»;
  • backend/app/services/exporters/layout_tz_pdf.py:201: «Покрытие: Y из N комплексов с данными velocity (P%)».

В штатной ветке P = Y / (комплексы после фильтра). Если в N подставить число до фильтра, строка начнёт противоречить сама себе. Вторая фальсификация ниже это показывает: при таком N пользователь увидел бы «покрытие 100% (1 из 2 комплексов)». Сырое число obj_id в радиусе до фильтра по-прежнему отдаётся в raw_objects_total, так что эта информация не теряется.

Поэтому изменена только ранняя ветка «фильтр никого не оставил»: теперь там len(complex_groups). Штатная ветка и ветка «нет velocity» не менялись.

Если владелец выберет смысл «до фильтра», придётся менять штатную ветку и добавлять отдельное поле для знаменателя покрытия. Сейчас это дёшево: пользователи этой разницы пока не видят (см. ниже).

Что сделано

  • best_layouts.py: убрана groups_total_pre_filter, ранний пустой ответ отдаёт len(complex_groups).
  • schemas/parcel.py: комментарий к полю теперь говорит «после exclude/filter (знаменатель coverage)». Это Python-комментарий, OpenAPI не меняется.
  • Тест test_objects_total_is_coverage_denominator_in_every_branch параметризован по трём веткам (all-filtered / no-velocity / normal). В каждой ветке он проверяет значения: objects_total_in_radius, objects_with_velocity_data, raw_objects_total == 2 и velocity_coverage_pct = Y/N.
  • Два старых теста закрепляли число до фильтра в пустом ответе (test_exclude_competitor_obj_ids, test_exclude_competitor_obj_ids_filter). Теперь они ждут objects_total_in_radius == 0 и raw_objects_total == 1.

Тесты

  • backend: pytest tests/services/site_finder/test_best_layouts.py tests/api/v1/test_parcel_best_layouts.py дал 96 passed, rc=0.
  • Весь сьют backend (TESTING=1 pytest tests/) после rebase на 99f8525b: 5061 passed, 87 skipped, 4 failed, rc=1. Все 4 падения в tests/ops/test_2203_backup_trailer_grep_dashdash.py: на macOS BSD mktemp не знает --suffix=.sql.gz. К этой правке они отношения не имеют.
  • ruff check . — All checks passed (rc=0); ruff format --check по 4 изменённым файлам — already formatted (rc=0).
  • После push в main пришли ещё мержи (#3550–#3563), но все они меняют только tradein-mvp/. Файлы этого PR и backend/ они не трогают.

Фальсификация

1. Вернул исходник best_layouts.py из origin/main, тесты остались новыми. Результат: rc=1, 3 failed, 93 passed:

E       AssertionError: assert 2 == 0
E        +  where 2 = LayoutDataQuality(objects_with_velocity_data=0, objects_total_in_radius=2, raw_objects_total=2, ... velocity_coverage_pct=0.0, confidence='low').objects_total_in_radius
FAILED tests/services/site_finder/test_best_layouts.py::test_exclude_competitor_obj_ids
FAILED tests/services/site_finder/test_best_layouts.py::test_objects_total_is_coverage_denominator_in_every_branch[all-filtered]
FAILED tests/api/v1/test_parcel_best_layouts.py::test_exclude_competitor_obj_ids_filter

2. Перевёл штатную ветку на число до фильтра (вариант «до фильтра» без правки знаменателя). Результат: rc=1, 1 failed, 95 passed:

E       AssertionError: assert 2 == 1
E        +  where 2 = LayoutDataQuality(objects_with_velocity_data=1, objects_total_in_radius=2, raw_objects_total=2, ... velocity_coverage_pct=100.0, confidence='high').objects_total_in_radius
FAILED tests/services/site_finder/test_best_layouts.py::test_objects_total_is_coverage_denominator_in_every_branch[normal]

Это и есть та самая противоречивая строка «100% (1 из 2)». После обеих проверок исходник восстановлен из копии, diff -q расхождений не нашёл.

Приёмка на проде (после деплоя; на 17.09.2026 не проверено)

Пользователь пока не видит эффекта. Фронт не передаёт фильтр: BestLayoutsBlock подключён в MarketTab.tsx:271 и Section3SettingsAndCompetitors.tsx:295 без selectedCompetitorObjIds. По замеру 20.08 из 4094 разборов за 120 суток ни один не передавал exclude/filter_competitor_obj_ids. Поэтому приёмка идёт по коду в контейнере и по живому запросу:

  1. ssh poincare docker exec gendesign-backend-1 grep -c groups_total_pre_filter /app/app/services/site_finder/best_layouts.py должен вернуть 0 (живой контейнер 17.09 вернул 2: объявление и использование на :1141).
  2. POST /api/v1/parcels/{cad}/best-layouts с {"filter_competitor_obj_ids": [-1]} на любом участке, где есть конкуренты: data_quality.objects_total_in_radius == 0, raw_objects_total > 0. До деплоя в objects_total_in_radius приходило число комплексов в радиусе.
  3. Тот же участок без фильтра: objects_total_in_radius совпадает со значением до деплоя.

Refs #2464

🤖 Generated with Claude Code

Пункт эпика #2464 `best_layouts.py:1113`. Эпик этим PR **не закрывается**: в его списке остаются открытые пункты, например `competitors.py:553`, который ждёт открытого #2962. ## Что было `get_best_layouts` отдаёт `data_quality.objects_total_in_radius` из трёх веток, и считали они это поле по-разному (сверено на origin/main@34642e1d): | ветка | значение | |---|---| | фильтр никого не оставил (`:1141`) | `groups_total_pre_filter`, **до** exclude/filter | | нет velocity (`:1231`) | `len(complex_groups)`, после фильтра | | штатная (`:1464`) | `groups_total_after_filter`, после фильтра | 20.08 пункт остановили с пометкой «нужно решение, какой смысл у поля верен». Позже к нему дописали «ОТКЛОНЁН» с доводом «поле описывает радиус, до фильтра», но этот довод противоречит двум веткам из трёх. Дефект остался в коде. ## Почему выбран смысл «после фильтра» Решение принято по тому, как поле используют, а не по его имени. Потребителей у поля два (`git grep` по репозиторию), и оба выводят его как N в строке про покрытие: - `frontend/src/components/site-finder/BestLayoutsBlock.tsx:133`: «покрытие P% (Y из N комплексов)»; - `backend/app/services/exporters/layout_tz_pdf.py:201`: «Покрытие: Y из N комплексов с данными velocity (P%)». В штатной ветке P = Y / (комплексы **после** фильтра). Если в N подставить число до фильтра, строка начнёт противоречить сама себе. Вторая фальсификация ниже это показывает: при таком N пользователь увидел бы «покрытие 100% (1 из 2 комплексов)». Сырое число obj_id в радиусе до фильтра по-прежнему отдаётся в `raw_objects_total`, так что эта информация не теряется. Поэтому изменена только ранняя ветка «фильтр никого не оставил»: теперь там `len(complex_groups)`. Штатная ветка и ветка «нет velocity» не менялись. **Если владелец выберет смысл «до фильтра»**, придётся менять штатную ветку и добавлять отдельное поле для знаменателя покрытия. Сейчас это дёшево: пользователи этой разницы пока не видят (см. ниже). ## Что сделано - `best_layouts.py`: убрана `groups_total_pre_filter`, ранний пустой ответ отдаёт `len(complex_groups)`. - `schemas/parcel.py`: комментарий к полю теперь говорит «после exclude/filter (знаменатель coverage)». Это Python-комментарий, OpenAPI не меняется. - Тест `test_objects_total_is_coverage_denominator_in_every_branch` параметризован по трём веткам (all-filtered / no-velocity / normal). В каждой ветке он проверяет значения: `objects_total_in_radius`, `objects_with_velocity_data`, `raw_objects_total == 2` и `velocity_coverage_pct` = Y/N. - Два старых теста закрепляли число до фильтра в пустом ответе (`test_exclude_competitor_obj_ids`, `test_exclude_competitor_obj_ids_filter`). Теперь они ждут `objects_total_in_radius == 0` и `raw_objects_total == 1`. ## Тесты - `backend`: `pytest tests/services/site_finder/test_best_layouts.py tests/api/v1/test_parcel_best_layouts.py` дал **96 passed**, rc=0. - Весь сьют `backend` (`TESTING=1 pytest tests/`) после rebase на 99f8525b: **5061 passed, 87 skipped, 4 failed**, rc=1. Все 4 падения в `tests/ops/test_2203_backup_trailer_grep_dashdash.py`: на macOS BSD `mktemp` не знает `--suffix=.sql.gz`. К этой правке они отношения не имеют. - `ruff check .` — All checks passed (rc=0); `ruff format --check` по 4 изменённым файлам — already formatted (rc=0). - После push в main пришли ещё мержи (#3550–#3563), но все они меняют только `tradein-mvp/`. Файлы этого PR и `backend/` они не трогают. ## Фальсификация **1. Вернул исходник `best_layouts.py` из origin/main**, тесты остались новыми. Результат: rc=1, `3 failed, 93 passed`: ``` E AssertionError: assert 2 == 0 E + where 2 = LayoutDataQuality(objects_with_velocity_data=0, objects_total_in_radius=2, raw_objects_total=2, ... velocity_coverage_pct=0.0, confidence='low').objects_total_in_radius FAILED tests/services/site_finder/test_best_layouts.py::test_exclude_competitor_obj_ids FAILED tests/services/site_finder/test_best_layouts.py::test_objects_total_is_coverage_denominator_in_every_branch[all-filtered] FAILED tests/api/v1/test_parcel_best_layouts.py::test_exclude_competitor_obj_ids_filter ``` **2. Перевёл штатную ветку на число до фильтра** (вариант «до фильтра» без правки знаменателя). Результат: rc=1, `1 failed, 95 passed`: ``` E AssertionError: assert 2 == 1 E + where 2 = LayoutDataQuality(objects_with_velocity_data=1, objects_total_in_radius=2, raw_objects_total=2, ... velocity_coverage_pct=100.0, confidence='high').objects_total_in_radius FAILED tests/services/site_finder/test_best_layouts.py::test_objects_total_is_coverage_denominator_in_every_branch[normal] ``` Это и есть та самая противоречивая строка «100% (1 из 2)». После обеих проверок исходник восстановлен из копии, `diff -q` расхождений не нашёл. ## Приёмка на проде (после деплоя; на 17.09.2026 не проверено) Пользователь пока не видит эффекта. Фронт не передаёт фильтр: `BestLayoutsBlock` подключён в `MarketTab.tsx:271` и `Section3SettingsAndCompetitors.tsx:295` без `selectedCompetitorObjIds`. По замеру 20.08 из 4094 разборов за 120 суток ни один не передавал `exclude/filter_competitor_obj_ids`. Поэтому приёмка идёт по коду в контейнере и по живому запросу: 1. `ssh poincare docker exec gendesign-backend-1 grep -c groups_total_pre_filter /app/app/services/site_finder/best_layouts.py` должен вернуть `0` (живой контейнер 17.09 вернул `2`: объявление и использование на `:1141`). 2. `POST /api/v1/parcels/{cad}/best-layouts` с `{"filter_competitor_obj_ids": [-1]}` на любом участке, где есть конкуренты: `data_quality.objects_total_in_radius == 0`, `raw_objects_total > 0`. До деплоя в `objects_total_in_radius` приходило число комплексов в радиусе. 3. Тот же участок без фильтра: `objects_total_in_radius` совпадает со значением до деплоя. Refs #2464 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-17 09:26:27 +00:00
fix(ptica): «покрытие P% (Y из N комплексов)» — N одно и то же во всех ветках (#2464)
All checks were successful
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 2m55s
CI / backend-tests (pull_request) Successful in 7m16s
CI Trade-In / changes (pull_request) Successful in 28s
CI / changes (pull_request) Successful in 30s
6fe231c101
best_layouts.py:1113 (эпик #2464). Поле objects_total_in_radius заполнялось
тремя ветками по-разному: ранний пустой ответ («фильтр никого не оставил»)
отдавал число комплексов ДО exclude/filter, ветка «нет velocity» и штатная —
ПОСЛЕ.

Смысл выбран по потребителям, а не по имени поля: BestLayoutsBlock.tsx и
layout_tz_pdf.py печатают его только как N в «покрытие P% (Y из N
комплексов)», а P в штатной ветке считается по отфильтрованным. Число до
фильтра сделало бы строку внутренне противоречивой («100% (1 из 2)»).
Поэтому ранний пустой ответ приведён к len(complex_groups); число obj_id до
фильтра по-прежнему отдаётся в raw_objects_total. Штатная ветка не менялась.

Тест проверяет поле во всех трёх ветках при filter_competitor_obj_ids;
два старых теста, закреплявших число до фильтра в пустом ответе, обновлены.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-17 10:49:15 +00:00
Merge remote-tracking branch 'origin/main' into fix/ptica-best-layouts
All checks were successful
CI / backend-tests (pull_request) Successful in 10m16s
CI Trade-In / changes (pull_request) Successful in 20s
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
CI / openapi-codegen-check (pull_request) Successful in 4m13s
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
cd504c1eb9
bot-backend merged commit 77fdeefc50 into main 2026-09-17 11:02:16 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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#3570
No description provided.