From 6fe231c10166f81b0f8fac7b5000d984e192e755 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:10:35 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=C2=AB=D0=BF=D0=BE=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B5=20P%=20(Y=20=D0=B8=D0=B7=20N=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=81=D0=BE=D0=B2)=C2=BB?= =?UTF-8?q?=20=E2=80=94=20N=20=D0=BE=D0=B4=D0=BD=D0=BE=20=D0=B8=20=D1=82?= =?UTF-8?q?=D0=BE=20=D0=B6=D0=B5=20=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5=D1=85?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B0=D1=85=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/schemas/parcel.py | 2 +- .../app/services/site_finder/best_layouts.py | 8 ++-- .../tests/api/v1/test_parcel_best_layouts.py | 6 ++- .../services/site_finder/test_best_layouts.py | 38 ++++++++++++++++++- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 75fb6329..e24ec698 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel): """ objects_with_velocity_data: int # число комплексов с velocity-данными - objects_total_in_radius: int # число комплексов (сгруппированных obj_id) + objects_total_in_radius: int # число комплексов после exclude/filter (знаменатель coverage) raw_objects_total: int # сырое число obj_id в радиусе (до группировки) # Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по # нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id — diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index ea7045e5..d2623571 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -1109,9 +1109,6 @@ def get_best_layouts( id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows] all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts] raw_objects_total_in_radius = len(all_obj_ids) - # Число комплексов ДО exclude/filter — знаменатель для empty-ответа (совместимо - # со старой семантикой: пустой ответ отражал полный радиус, а не post-filter 0). - groups_total_pre_filter = len(group_radius_objects(id_row_dicts)) # Применить exclude / filter из request (и к obj_ids, и к строкам для группировки) exclude_set = set(request.exclude_competitor_obj_ids) @@ -1132,13 +1129,16 @@ def get_best_layouts( all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)] # Группируем отфильтрованные obj_id в комплексы — знаменатель coverage. + # objects_total_in_radius во ВСЕХ ветках = len(complex_groups) (после фильтра): + # UI и PDF печатают его как N в «покрытие P% (Y из N комплексов)», а P считается + # по отфильтрованным (#2464). Число до фильтра — в raw_objects_total. complex_groups = group_radius_objects(id_row_dicts) if not all_obj_ids: return _empty_response( radius_km=request.radius_km, time_window=request.time_window, - objects_total_in_radius=groups_total_pre_filter, + objects_total_in_radius=len(complex_groups), raw_objects_total=raw_objects_total_in_radius, ) diff --git a/backend/tests/api/v1/test_parcel_best_layouts.py b/backend/tests/api/v1/test_parcel_best_layouts.py index 2ccaa9ae..c1a96153 100644 --- a/backend/tests/api/v1/test_parcel_best_layouts.py +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -264,8 +264,10 @@ def test_exclude_competitor_obj_ids_filter() -> None: # После исключения obj_id=20 список пуст → пустой ответ assert body["top_layouts"] == [] assert body["data_quality"]["confidence"] == "low" - # objects_total_in_radius = 1 (до исключения) - assert body["data_quality"]["objects_total_in_radius"] == 1 + # objects_total_in_radius — знаменатель coverage, после исключения 0 (#2464); + # число obj_id в радиусе до исключения — raw_objects_total + assert body["data_quality"]["objects_total_in_radius"] == 0 + assert body["data_quality"]["raw_objects_total"] == 1 finally: app.dependency_overrides.clear() diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 6ddc3906..8d571caa 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -433,7 +433,43 @@ def test_exclude_competitor_obj_ids() -> None: resp = get_best_layouts(db, CAD_NUM, req) assert resp.top_layouts == [] - assert resp.data_quality.objects_total_in_radius == 1 + # после исключения комплексов 0 (знаменатель coverage), в радиусе было 1 obj_id + assert resp.data_quality.objects_total_in_radius == 0 + assert resp.data_quality.raw_objects_total == 1 + + +@pytest.mark.parametrize( + ("filter_ids", "vel_obj_ids", "expected_total", "expected_with_data"), + [ + ([99], None, 0, 0), # фильтр не оставил никого → ранний пустой ответ + ([20], None, 1, 0), # остался 1 комплекс без velocity → пустой ответ + ([20], [20], 1, 1), # остался 1 комплекс с velocity → штатная ветка + ], + ids=["all-filtered", "no-velocity", "normal"], +) +def test_objects_total_is_coverage_denominator_in_every_branch( + filter_ids: list[int], + vel_obj_ids: list[int] | None, + expected_total: int, + expected_with_data: int, +) -> None: + """#2464 (best_layouts:1113): objects_total_in_radius значит одно во всех ветках. + + UI и PDF печатают «покрытие P% (Y из N комплексов)», значит N обязан быть + знаменателем P, а P считается по комплексам ПОСЛЕ filter. Раньше ранний пустой + ответ отдавал число ДО фильтра (2), две другие ветки — после. + """ + id_rows = [_obj_id_row(20), _obj_id_row(30)] + vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=vel_obj_ids)] if vel_obj_ids else [] + db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) + resp = get_best_layouts(db, CAD_NUM, _request(filter_competitor_obj_ids=filter_ids)) + + dq = resp.data_quality + assert dq.objects_total_in_radius == expected_total + assert dq.objects_with_velocity_data == expected_with_data + assert dq.raw_objects_total == 2 # число до фильтра — отдельным полем + expected_pct = expected_with_data / expected_total * 100.0 if expected_total else 0.0 + assert dq.velocity_coverage_pct == pytest.approx(expected_pct) # ── Тест: total_sold_in_window совпадает с deals_window ────────────────────── -- 2.45.3