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 ──────────────────────