From f3e10a04adde40117128fbda1f21e09655ca31e9 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 28 Jun 2026 14:14:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20API=20=D0=B4=D0=BE=D0=BC=D0=BE?= =?UTF-8?q?=D0=B2=20=C2=AB=D0=B4=D0=BE=D0=BB=D0=B8=20=D0=BA=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D1=80=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D0=B6=D0=B5=C2=BB=20=E2=80=94=20/buildings/sale-share=20?= =?UTF-8?q?(+=20listings,=20summary)=20(#2055)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/buildings.py | 104 ++++++++ tradein-mvp/backend/app/main.py | 3 +- tradein-mvp/backend/app/schemas/buildings.py | 78 ++++++ .../backend/app/services/buildings_query.py | 166 ++++++++++++ .../backend/tests/test_buildings_api.py | 249 ++++++++++++++++++ 5 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/app/api/v1/buildings.py create mode 100644 tradein-mvp/backend/app/schemas/buildings.py create mode 100644 tradein-mvp/backend/app/services/buildings_query.py create mode 100644 tradein-mvp/backend/tests/test_buildings_api.py diff --git a/tradein-mvp/backend/app/api/v1/buildings.py b/tradein-mvp/backend/app/api/v1/buildings.py new file mode 100644 index 00000000..0b32f677 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/buildings.py @@ -0,0 +1,104 @@ +"""Buildings — страница «доля квартир дома в продаже» (мигр. 143). + +Дома вторичного рынка, где active_secondary / flat_count_effective >= threshold%. +Данные берём из готового view v_building_sale_share (логику доли не пересчитываем). + +Endpoints: + - GET /api/v1/buildings/sale-share — список домов + - GET /api/v1/buildings/{house_id}/listings — активные вторичные объявления дома + - GET /api/v1/buildings/sale-share/summary — сводка для слайдера % + coverage +""" + +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.schemas.buildings import ( + BuildingListing, + BuildingSaleShare, + HistogramBucket, + SaleShareSummary, +) +from app.services.buildings_query import ( + HISTOGRAM_BUCKETS, + build_listings_query, + build_sale_share_query, + build_summary_histogram_query, + build_summary_scalars_query, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/sale-share", response_model=list[BuildingSaleShare]) +async def list_sale_share( + db: Annotated[Session, Depends(get_db)], + min_pct: Annotated[float, Query(ge=0, le=1000)] = 5.0, + max_pct: Annotated[float | None, Query(ge=0, le=1000)] = None, + city: Annotated[str | None, Query(max_length=120)] = None, + price_min: Annotated[int | None, Query(ge=0)] = None, + price_max: Annotated[int | None, Query(ge=0)] = None, + year_min: Annotated[int | None, Query(ge=1800, le=2100)] = None, + year_max: Annotated[int | None, Query(ge=1800, le=2100)] = None, + house_type: Annotated[str | None, Query(max_length=40)] = None, + sort: Annotated[ + str, + Query(pattern="^(share_desc|active_desc|exposure_desc|price_asc|price_desc)$"), + ] = "share_desc", + limit: Annotated[int, Query(ge=1, le=1000)] = 200, +) -> list[BuildingSaleShare]: + """Дома вторички с долей квартир в продаже >= min_pct (+ фильтры).""" + sql, args = build_sale_share_query( + min_pct=min_pct, + max_pct=max_pct, + city=city, + price_min=price_min, + price_max=price_max, + year_min=year_min, + year_max=year_max, + house_type=house_type, + sort=sort, + limit=limit, + ) + rows = db.execute(text(sql), args).mappings().all() + return [BuildingSaleShare.model_validate(r) for r in rows] + + +@router.get("/{house_id}/listings", response_model=list[BuildingListing]) +async def list_building_listings( + db: Annotated[Session, Depends(get_db)], + house_id: Annotated[int, Path(ge=1)], +) -> list[BuildingListing]: + """Активные вторичные объявления в одном доме (для drawer), ORDER BY price.""" + sql, args = build_listings_query(house_id) + rows = db.execute(text(sql), args).mappings().all() + return [BuildingListing.model_validate(r) for r in rows] + + +@router.get("/sale-share/summary", response_model=SaleShareSummary) +async def sale_share_summary( + db: Annotated[Session, Depends(get_db)], +) -> SaleShareSummary: + """Сводка распределения sale_share_pct: coverage, max, p95, гистограмма.""" + scalars = db.execute(text(build_summary_scalars_query())).mappings().one() + + hist_rows = db.execute(text(build_summary_histogram_query())).mappings().all() + counts = {str(r["bucket"]): int(r["count"]) for r in hist_rows} + histogram = [HistogramBucket(bucket=b, count=counts.get(b, 0)) for b in HISTOGRAM_BUCKETS] + + return SaleShareSummary( + total_secondary_buildings=int(scalars["total_secondary_buildings"]), + buildings_with_denominator=int(scalars["buildings_with_denominator"]), + coverage_pct=float(scalars["coverage_pct"]), + max_pct=(float(scalars["max_pct"]) if scalars["max_pct"] is not None else None), + p95_pct=(float(scalars["p95_pct"]) if scalars["p95_pct"] is not None else None), + histogram=histogram, + ) diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index ef797fcc..ea1c8b7d 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -23,7 +23,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration -from app.api.v1 import admin, brand, geocode, me, search, trade_in +from app.api.v1 import admin, brand, buildings, geocode, me, search, trade_in from app.core.auth import get_role from app.core.config import settings from app.core.db import SessionLocal @@ -167,5 +167,6 @@ app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) +app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) diff --git a/tradein-mvp/backend/app/schemas/buildings.py b/tradein-mvp/backend/app/schemas/buildings.py new file mode 100644 index 00000000..cab520fe --- /dev/null +++ b/tradein-mvp/backend/app/schemas/buildings.py @@ -0,0 +1,78 @@ +"""Pydantic-схемы страницы «доля квартир дома в продаже» (мигр. 143). + +Источник данных — view v_building_sale_share + базовая таблица listings. +Список домов вторичного рынка, где active_secondary / flat_count_effective >= threshold%. +""" + +from __future__ import annotations + +from datetime import date + +from pydantic import BaseModel, ConfigDict + + +class BuildingSaleShare(BaseModel): + """Один дом из v_building_sale_share для списка /buildings/sale-share.""" + + model_config = ConfigDict(from_attributes=True) + + house_id: int + address: str | None = None + lat: float | None = None + lon: float | None = None + + sale_share_pct: float | None = None + # implausible-матч (active_secondary > знаменателя) — скорее всего коллизия + # адреса при ГАР-матче; не прячем, а маркируем для UI. + over_100: bool = False + + active_secondary: int | None = None + flat_count_effective: int | None = None + gar_match_method: str | None = None + + median_price_rub: int | None = None + median_price_per_m2: int | None = None + avg_days_on_market: float | None = None + + year_built: int | None = None + house_type: str | None = None + total_floors: int | None = None + series_name: str | None = None + is_emergency: bool | None = None + + +class BuildingListing(BaseModel): + """Активное вторичное объявление в доме (click-through drawer).""" + + model_config = ConfigDict(from_attributes=True) + + listing_id: int + source: str + source_url: str | None = None + price_rub: int | None = None + price_per_m2: int | None = None + rooms: int | None = None + area_m2: float | None = None + floor: int | None = None + total_floors: int | None = None + days_on_market: int | None = None + listing_date: date | None = None + address: str | None = None + + +class HistogramBucket(BaseModel): + """Корзина гистограммы распределения sale_share_pct.""" + + bucket: str + count: int + + +class SaleShareSummary(BaseModel): + """Сводка для слайдера % + баннера покрытия (/buildings/sale-share/summary).""" + + total_secondary_buildings: int + buildings_with_denominator: int + coverage_pct: float + max_pct: float | None = None + p95_pct: float | None = None + histogram: list[HistogramBucket] diff --git a/tradein-mvp/backend/app/services/buildings_query.py b/tradein-mvp/backend/app/services/buildings_query.py new file mode 100644 index 00000000..7e0f5101 --- /dev/null +++ b/tradein-mvp/backend/app/services/buildings_query.py @@ -0,0 +1,166 @@ +"""SQL-билдеры страницы «доля квартир дома в продаже» (мигр. 143). + +Читаем готовый view v_building_sale_share — логику доли НЕ пересчитываем. +Все запросы параметризованы (SQLAlchemy text + bind params, CAST(:x AS type) — +psycopg v3). Сортировка — строго по whitelist-колонкам, user-ввод в SQL не +интерполируется. +""" + +from __future__ import annotations + +# share_desc / active_desc / exposure_desc / price_asc / price_desc → безопасный ORDER BY. +# house_id как стабильный tiebreaker для детерминированной пагинации. +_SORT_SQL: dict[str, str] = { + "share_desc": "sale_share_pct DESC NULLS LAST, house_id", + "active_desc": "active_secondary DESC NULLS LAST, house_id", + "exposure_desc": "avg_days_on_market DESC NULLS LAST, house_id", + "price_asc": "median_price_rub ASC NULLS LAST, house_id", + "price_desc": "median_price_rub DESC NULLS LAST, house_id", +} +ALLOWED_SORTS: frozenset[str] = frozenset(_SORT_SQL) + +# Гистограмма распределения sale_share_pct — фиксированный порядок корзин. +HISTOGRAM_BUCKETS: tuple[str, ...] = ( + "0-5", + "5-10", + "10-20", + "20-30", + "30-50", + "50-100", + "100+", +) + +# Колонки view, отдаваемые в список (в порядке SELECT). median_* округляем до +# bigint — percentile_cont отдаёт double precision (может быть x.5), а Pydantic +# int-поле на дробном float падает. +_LIST_COLUMNS = ( + "house_id, " + "COALESCE(short_address, full_address, address) AS address, " + "lat, lon, " + "sale_share_pct, " + "(sale_share_pct > 100) AS over_100, " + "active_secondary, " + "flat_count_effective, " + "gar_match_method, " + "CAST(round(median_price_rub) AS bigint) AS median_price_rub, " + "CAST(round(median_price_per_m2) AS bigint) AS median_price_per_m2, " + "avg_days_on_market, " + "year_built, house_type, total_floors, series_name, is_emergency" +) + + +def build_sale_share_query( + *, + min_pct: float = 5.0, + max_pct: float | None = None, + city: str | None = None, + price_min: int | None = None, + price_max: int | None = None, + year_min: int | None = None, + year_max: int | None = None, + house_type: str | None = None, + sort: str = "share_desc", + limit: int = 200, +) -> tuple[str, dict[str, object]]: + """SELECT домов из v_building_sale_share по фильтрам. + + Всегда требует sale_share_pct IS NOT NULL (строки без знаменателя — только + для coverage в summary, не в списке). + """ + where: list[str] = ["sale_share_pct IS NOT NULL"] + args: dict[str, object] = {} + + where.append("sale_share_pct >= CAST(:min_pct AS numeric)") + args["min_pct"] = min_pct + + if max_pct is not None: + where.append("sale_share_pct <= CAST(:max_pct AS numeric)") + args["max_pct"] = max_pct + + if city: + # case-insensitive substring по всем адресным полям (город может быть в любом). + where.append( + "(COALESCE(short_address, '') || ' ' || COALESCE(full_address, '') " + "|| ' ' || COALESCE(address, '')) ILIKE CAST(:city_like AS text)" + ) + args["city_like"] = f"%{city}%" + + if price_min is not None: + where.append("median_price_rub >= CAST(:price_min AS bigint)") + args["price_min"] = price_min + if price_max is not None: + where.append("median_price_rub <= CAST(:price_max AS bigint)") + args["price_max"] = price_max + + if year_min is not None: + where.append("year_built >= CAST(:year_min AS integer)") + args["year_min"] = year_min + if year_max is not None: + where.append("year_built <= CAST(:year_max AS integer)") + args["year_max"] = year_max + + if house_type: + where.append("house_type = CAST(:house_type AS text)") + args["house_type"] = house_type + + order_sql = _SORT_SQL.get(sort, _SORT_SQL["share_desc"]) + args["limit"] = max(1, min(int(limit), 1000)) + + sql = ( + f"SELECT {_LIST_COLUMNS} " + "FROM v_building_sale_share " + f"WHERE {' AND '.join(where)} " + f"ORDER BY {order_sql} " + "LIMIT CAST(:limit AS integer)" + ) + return sql, args + + +def build_listings_query(house_id: int) -> tuple[str, dict[str, object]]: + """Активные вторичные объявления одного дома (ORDER BY price_rub).""" + sql = ( + "SELECT id AS listing_id, source, source_url, price_rub, price_per_m2, " + "rooms, CAST(area_m2 AS double precision) AS area_m2, floor, total_floors, " + "days_on_market, listing_date, address " + "FROM listings " + "WHERE house_id_fk = CAST(:house_id AS bigint) " + "AND is_active AND listing_segment = 'vtorichka' " + "ORDER BY price_rub ASC NULLS LAST, id" + ) + return sql, {"house_id": house_id} + + +def build_summary_scalars_query() -> str: + """Скаляры сводки: всего домов, с знаменателем, coverage, max, p95.""" + return ( + "SELECT " + "count(*) AS total_secondary_buildings, " + "count(*) FILTER (WHERE sale_share_pct IS NOT NULL) " + "AS buildings_with_denominator, " + "COALESCE(round(" + "100.0 * count(*) FILTER (WHERE sale_share_pct IS NOT NULL) " + "/ NULLIF(count(*), 0), 1), 0) AS coverage_pct, " + "max(sale_share_pct) AS max_pct, " + "percentile_cont(0.95) WITHIN GROUP (ORDER BY sale_share_pct) AS p95_pct " + "FROM v_building_sale_share" + ) + + +def build_summary_histogram_query() -> str: + """Гистограмма sale_share_pct по фиксированным корзинам (только non-null).""" + return ( + "SELECT " + "CASE " + "WHEN sale_share_pct < 5 THEN '0-5' " + "WHEN sale_share_pct < 10 THEN '5-10' " + "WHEN sale_share_pct < 20 THEN '10-20' " + "WHEN sale_share_pct < 30 THEN '20-30' " + "WHEN sale_share_pct < 50 THEN '30-50' " + "WHEN sale_share_pct <= 100 THEN '50-100' " + "ELSE '100+' " + "END AS bucket, " + "count(*) AS count " + "FROM v_building_sale_share " + "WHERE sale_share_pct IS NOT NULL " + "GROUP BY bucket" + ) diff --git a/tradein-mvp/backend/tests/test_buildings_api.py b/tradein-mvp/backend/tests/test_buildings_api.py new file mode 100644 index 00000000..83a2f519 --- /dev/null +++ b/tradein-mvp/backend/tests/test_buildings_api.py @@ -0,0 +1,249 @@ +"""Тесты API «доля квартир дома в продаже» (мигр. 143). + +Two layers: + 1. SQL-билдеры (чистые функции) — shape, CAST(:x AS type), whitelist-сортировка, + clamp лимита, фильтры. БД не нужна. + 2. Contract — минимальный app с замоканным get_db: 200 + правильная форма ответа + (RBAC middleware не монтируем, mirror trade_in_app из test_estimate_idor). +""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock + +# psycopg v3 driver требует DATABASE_URL до импорта app.core.db. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.services.buildings_query import ( + ALLOWED_SORTS, + HISTOGRAM_BUCKETS, + build_listings_query, + build_sale_share_query, + build_summary_histogram_query, + build_summary_scalars_query, +) + +# ── SQL-билдеры ─────────────────────────────────────────────────────────────── + + +def test_sale_share_minimal_defaults(): + sql, args = build_sale_share_query() + assert "FROM v_building_sale_share" in sql + assert "sale_share_pct IS NOT NULL" in sql + assert "sale_share_pct >= CAST(:min_pct AS numeric)" in sql + assert "(sale_share_pct > 100) AS over_100" in sql + assert "COALESCE(short_address, full_address, address) AS address" in sql + assert args["min_pct"] == 5.0 + assert args["limit"] == 200 + # default sort = share_desc + assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql + + +def test_sale_share_all_filters(): + sql, args = build_sale_share_query( + min_pct=10, + max_pct=80, + city="Екатеринбург", + price_min=3_000_000, + price_max=12_000_000, + year_min=1990, + year_max=2015, + house_type="panel", + sort="price_asc", + ) + assert "sale_share_pct <= CAST(:max_pct AS numeric)" in sql + assert "ILIKE CAST(:city_like AS text)" in sql + assert args["city_like"] == "%Екатеринбург%" + assert "median_price_rub >= CAST(:price_min AS bigint)" in sql + assert "median_price_rub <= CAST(:price_max AS bigint)" in sql + assert "year_built >= CAST(:year_min AS integer)" in sql + assert "year_built <= CAST(:year_max AS integer)" in sql + assert "house_type = CAST(:house_type AS text)" in sql + assert "ORDER BY median_price_rub ASC NULLS LAST" in sql + + +def test_sale_share_no_double_colon_cast(): + """psycopg v3: ни одного `:name::type` — только CAST(...).""" + sql, _ = build_sale_share_query(city="x", price_min=1, year_min=2000, house_type="brick") + import re + + assert re.search(r":[a-z_]+::[a-z]", sql) is None + + +def test_sale_share_limit_clamped(): + _, args_hi = build_sale_share_query(limit=99999) + assert args_hi["limit"] == 1000 + _, args_lo = build_sale_share_query(limit=0) + assert args_lo["limit"] == 1 + _, args_neg = build_sale_share_query(limit=-5) + assert args_neg["limit"] == 1 + + +@pytest.mark.parametrize("sort", sorted(ALLOWED_SORTS)) +def test_sale_share_sort_whitelisted(sort: str): + sql, _ = build_sale_share_query(sort=sort) + assert "ORDER BY" in sql + # tiebreaker house_id присутствует во всех сортировках + assert "house_id" in sql.split("ORDER BY", 1)[1] + + +def test_sale_share_unknown_sort_falls_back(): + """Несуществующий sort не интерполируется — fallback на share_desc.""" + sql, _ = build_sale_share_query(sort="DROP TABLE houses") + assert "DROP TABLE" not in sql + assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql + + +def test_listings_query_shape(): + sql, args = build_listings_query(42) + assert "FROM listings" in sql + assert "house_id_fk = CAST(:house_id AS bigint)" in sql + assert "listing_segment = 'vtorichka'" in sql + assert "is_active" in sql + assert "ORDER BY price_rub" in sql + assert "id AS listing_id" in sql + assert args["house_id"] == 42 + + +def test_summary_scalars_query_shape(): + sql = build_summary_scalars_query() + assert "total_secondary_buildings" in sql + assert "FILTER (WHERE sale_share_pct IS NOT NULL)" in sql + assert "NULLIF(count(*), 0)" in sql + assert "percentile_cont(0.95)" in sql + + +def test_summary_histogram_buckets_cover_spec(): + sql = build_summary_histogram_query() + for boundary in ("'0-5'", "'5-10'", "'10-20'", "'20-30'", "'30-50'", "'50-100'", "'100+'"): + assert boundary in sql + assert HISTOGRAM_BUCKETS == ("0-5", "5-10", "10-20", "20-30", "30-50", "50-100", "100+") + + +# ── Contract (минимальный app, замоканный get_db) ───────────────────────────── + + +@pytest.fixture() +def buildings_app() -> FastAPI: + """FastAPI mounting только buildings router (без RBAC middleware).""" + from app.api.v1 import buildings as buildings_module + + application = FastAPI() + application.include_router(buildings_module.router, prefix="/api/v1/buildings") + return application + + +def _client_with_rows(app: FastAPI, *execute_results: object) -> TestClient: + """Override get_db → mock, где db.execute даёт execute_results по порядку.""" + from app.core.db import get_db + + db = MagicMock() + if len(execute_results) == 1: + db.execute.return_value = execute_results[0] + else: + db.execute.side_effect = list(execute_results) + + def _override_db(): + yield db + + app.dependency_overrides[get_db] = _override_db + return TestClient(app) + + +def _mapped(rows: object) -> MagicMock: + m = MagicMock() + m.mappings.return_value.all.return_value = rows + m.mappings.return_value.one.return_value = rows + return m + + +def test_get_sale_share_list_200(buildings_app: FastAPI): + row = { + "house_id": 7, + "address": "ул. Ленина, 1", + "lat": 56.8, + "lon": 60.6, + "sale_share_pct": 12.5, + "over_100": False, + "active_secondary": 3, + "flat_count_effective": 24, + "gar_match_method": None, + "median_price_rub": 5_000_000, + "median_price_per_m2": 95_000, + "avg_days_on_market": 41.0, + "year_built": 1999, + "house_type": "panel", + "total_floors": 9, + "series_name": None, + "is_emergency": False, + } + client = _client_with_rows(buildings_app, _mapped([row])) + resp = client.get("/api/v1/buildings/sale-share?min_pct=10") + assert resp.status_code == 200 + body = resp.json() + assert len(body) == 1 + assert body[0]["house_id"] == 7 + assert body[0]["over_100"] is False + assert body[0]["sale_share_pct"] == 12.5 + + +def test_get_sale_share_empty_when_no_gar(buildings_app: FastAPI): + """ГАР не загружен → пустой список, но 200 (форма, не падение).""" + client = _client_with_rows(buildings_app, _mapped([])) + resp = client.get("/api/v1/buildings/sale-share") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_get_building_listings_200(buildings_app: FastAPI): + row = { + "listing_id": 101, + "source": "avito", + "source_url": "https://avito.ru/101", + "price_rub": 4_900_000, + "price_per_m2": 92_000, + "rooms": 2, + "area_m2": 53.2, + "floor": 4, + "total_floors": 9, + "days_on_market": 30, + "listing_date": "2026-05-01", + "address": "ул. Ленина, 1", + } + client = _client_with_rows(buildings_app, _mapped([row])) + resp = client.get("/api/v1/buildings/7/listings") + assert resp.status_code == 200 + body = resp.json() + assert body[0]["listing_id"] == 101 + assert body[0]["source"] == "avito" + + +def test_get_summary_zero_fills_histogram(buildings_app: FastAPI): + scalars = _mapped( + { + "total_secondary_buildings": 10, + "buildings_with_denominator": 4, + "coverage_pct": 40.0, + "max_pct": 250.0, + "p95_pct": 33.3, + } + ) + hist = _mapped([{"bucket": "0-5", "count": 2}, {"bucket": "100+", "count": 1}]) + client = _client_with_rows(buildings_app, scalars, hist) + resp = client.get("/api/v1/buildings/sale-share/summary") + assert resp.status_code == 200 + body = resp.json() + assert body["total_secondary_buildings"] == 10 + assert body["buildings_with_denominator"] == 4 + assert body["coverage_pct"] == 40.0 + # все 7 корзин присутствуют, недостающие — count 0, порядок фиксирован + assert [b["bucket"] for b in body["histogram"]] == list(HISTOGRAM_BUCKETS) + counts = {b["bucket"]: b["count"] for b in body["histogram"]} + assert counts["0-5"] == 2 + assert counts["100+"] == 1 + assert counts["20-30"] == 0