From a2f4b62974bb43614eaa47d15ebf5501716be7c3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 28 Jun 2026 20:21:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20=D0=B2=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=20%=20=C2=AB=D0=B7=D0=B0=2045=20=D0=B4=D0=BD=D0=B5=D0=B9?= =?UTF-8?q?=C2=BB=20+=20zhkh-=D0=B7=D0=BD=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=20slot=20=E2=80=94=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=D0=B0=20=D0=B2=20?= =?UTF-8?q?/buildings/sale-share?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/buildings.py | 28 ++-- tradein-mvp/backend/app/schemas/buildings.py | 18 ++- .../backend/app/services/buildings_query.py | 81 +++++++---- .../sql/146_sale_share_45d_and_zhkh_denom.sql | 132 ++++++++++++++++++ .../backend/tests/test_buildings_api.py | 110 ++++++++++++++- 5 files changed, 324 insertions(+), 45 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/146_sale_share_45d_and_zhkh_denom.sql diff --git a/tradein-mvp/backend/app/api/v1/buildings.py b/tradein-mvp/backend/app/api/v1/buildings.py index 0b32f677..6539721b 100644 --- a/tradein-mvp/backend/app/api/v1/buildings.py +++ b/tradein-mvp/backend/app/api/v1/buildings.py @@ -51,11 +51,14 @@ async def list_sale_share( 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)$"), + Query( + pattern="^(share_desc|share_45d_desc|active_desc|count_45d_desc" + "|exposure_desc|price_asc|price_desc)$" + ), ] = "share_desc", limit: Annotated[int, Query(ge=1, le=1000)] = 200, ) -> list[BuildingSaleShare]: - """Дома вторички с долей квартир в продаже >= min_pct (+ фильтры).""" + """Дома вторички, где ЛЮБОЙ из двух % (now / 45д) >= min_pct (+ фильтры).""" sql, args = build_sale_share_query( min_pct=min_pct, max_pct=max_pct, @@ -87,18 +90,25 @@ async def list_building_listings( async def sale_share_summary( db: Annotated[Session, Depends(get_db)], ) -> SaleShareSummary: - """Сводка распределения sale_share_pct: coverage, max, p95, гистограмма.""" + """Сводка распределения обоих % (now + 45д): 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] + def _histogram(column: str) -> list[HistogramBucket]: + rows = db.execute(text(build_summary_histogram_query(column))).mappings().all() + counts = {str(r["bucket"]): int(r["count"]) for r in rows} + return [HistogramBucket(bucket=b, count=counts.get(b, 0)) for b in HISTOGRAM_BUCKETS] + + def _opt_float(key: str) -> float | None: + return float(scalars[key]) if scalars[key] is not None else None 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, + max_pct=_opt_float("max_pct"), + p95_pct=_opt_float("p95_pct"), + max_pct_45d=_opt_float("max_pct_45d"), + p95_pct_45d=_opt_float("p95_pct_45d"), + histogram=_histogram("sale_share_pct"), + histogram_45d=_histogram("sale_share_pct_45d"), ) diff --git a/tradein-mvp/backend/app/schemas/buildings.py b/tradein-mvp/backend/app/schemas/buildings.py index cab520fe..69f2a796 100644 --- a/tradein-mvp/backend/app/schemas/buildings.py +++ b/tradein-mvp/backend/app/schemas/buildings.py @@ -22,11 +22,16 @@ class BuildingSaleShare(BaseModel): lon: float | None = None sale_share_pct: float | None = None - # implausible-матч (active_secondary > знаменателя) — скорее всего коллизия - # адреса при ГАР-матче; не прячем, а маркируем для UI. + # 2-й процент: distinct vtorichka за последние 45д (активные ИЛИ недавно + # закрытые) / знаменатель. Дом проходит фильтр по ЛЮБОМУ из двух процентов. + sale_share_pct_45d: float | None = None + # implausible-матч (числитель > знаменателя по любому из %) — скорее всего + # коллизия адреса при ГАР-матче; не прячем, а маркируем для UI. over_100: bool = False active_secondary: int | None = None + # distinct vtorichka-листинги (активные ИЛИ закрытые) за окно 45д. + listings_45d: int | None = None flat_count_effective: int | None = None gar_match_method: str | None = None @@ -68,11 +73,18 @@ class HistogramBucket(BaseModel): class SaleShareSummary(BaseModel): - """Сводка для слайдера % + баннера покрытия (/buildings/sale-share/summary).""" + """Сводка для слайдера % + баннера покрытия (/buildings/sale-share/summary). + + buildings_with_denominator/coverage считают дома с непустым ЛЮБЫМ из двух %. + max/p95 и histogram — отдельно для каждого процента (now + 45д). + """ total_secondary_buildings: int buildings_with_denominator: int coverage_pct: float max_pct: float | None = None p95_pct: float | None = None + max_pct_45d: float | None = None + p95_pct_45d: float | None = None histogram: list[HistogramBucket] + histogram_45d: list[HistogramBucket] diff --git a/tradein-mvp/backend/app/services/buildings_query.py b/tradein-mvp/backend/app/services/buildings_query.py index 7e0f5101..faa3e529 100644 --- a/tradein-mvp/backend/app/services/buildings_query.py +++ b/tradein-mvp/backend/app/services/buildings_query.py @@ -1,25 +1,34 @@ -"""SQL-билдеры страницы «доля квартир дома в продаже» (мигр. 143). +"""SQL-билдеры страницы «доля квартир дома в продаже» (мигр. 143; окно 45д мигр. 146). Читаем готовый view v_building_sale_share — логику доли НЕ пересчитываем. +Два процента на дом: + - sale_share_pct — active_secondary / flat_count_effective (только активные); + - sale_share_pct_45d — distinct vtorichka за последние 45д (активные ИЛИ недавно + закрытые) / flat_count_effective. +Дом проходит фильтр, если ЛЮБОЙ из двух процентов в диапазоне [min_pct, max_pct]. + Все запросы параметризованы (SQLAlchemy text + bind params, CAST(:x AS type) — -psycopg v3). Сортировка — строго по whitelist-колонкам, user-ввод в SQL не -интерполируется. +psycopg v3). Сортировка/колонка гистограммы — строго по whitelist, user-ввод в SQL +не интерполируется. """ from __future__ import annotations -# share_desc / active_desc / exposure_desc / price_asc / price_desc → безопасный ORDER BY. -# house_id как стабильный tiebreaker для детерминированной пагинации. +# share_desc / share_45d_desc / active_desc / count_45d_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", + "share_45d_desc": "sale_share_pct_45d DESC NULLS LAST, house_id", "active_desc": "active_secondary DESC NULLS LAST, house_id", + "count_45d_desc": "listings_45d 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", @@ -30,16 +39,21 @@ HISTOGRAM_BUCKETS: tuple[str, ...] = ( "100+", ) +# Колонки view, по которым можно строить гистограмму (whitelist против инъекции). +_HISTOGRAM_COLUMNS: frozenset[str] = frozenset({"sale_share_pct", "sale_share_pct_45d"}) + # Колонки view, отдаваемые в список (в порядке SELECT). median_* округляем до # bigint — percentile_cont отдаёт double precision (может быть x.5), а Pydantic -# int-поле на дробном float падает. +# int-поле на дробном float падает. over_100 — implausible-флаг по любому из %. _LIST_COLUMNS = ( "house_id, " "COALESCE(short_address, full_address, address) AS address, " "lat, lon, " "sale_share_pct, " - "(sale_share_pct > 100) AS over_100, " + "sale_share_pct_45d, " + "(sale_share_pct > 100 OR sale_share_pct_45d > 100) AS over_100, " "active_secondary, " + "listings_45d, " "flat_count_effective, " "gar_match_method, " "CAST(round(median_price_rub) AS bigint) AS median_price_rub, " @@ -64,18 +78,23 @@ def build_sale_share_query( ) -> tuple[str, dict[str, object]]: """SELECT домов из v_building_sale_share по фильтрам. - Всегда требует sale_share_pct IS NOT NULL (строки без знаменателя — только - для coverage в summary, не в списке). + Дом проходит, если ЛЮБОЙ из двух процентов (now / 45д) попадает в диапазон + [min_pct, max_pct]. NULL-процент (неправдоподобный знаменатель) не проходит + сравнение, поэтому отдельный `IS NOT NULL` не нужен — рядки без обоих % выпадают. """ - where: list[str] = ["sale_share_pct IS NOT NULL"] + where: list[str] = [] args: dict[str, object] = {} - where.append("sale_share_pct >= CAST(:min_pct AS numeric)") + # Каждая ветка требует, чтобы ИМЕННО её % был в диапазоне; building qualifies + # on either. Без max_pct сводится к `(pct_now>=min OR pct_45d>=min)`. args["min_pct"] = min_pct - + qual_now = "sale_share_pct >= CAST(:min_pct AS numeric)" + qual_45d = "sale_share_pct_45d >= CAST(:min_pct AS numeric)" if max_pct is not None: - where.append("sale_share_pct <= CAST(:max_pct AS numeric)") args["max_pct"] = max_pct + qual_now += " AND sale_share_pct <= CAST(:max_pct AS numeric)" + qual_45d += " AND sale_share_pct_45d <= CAST(:max_pct AS numeric)" + where.append(f"(({qual_now}) OR ({qual_45d}))") if city: # case-insensitive substring по всем адресным полям (город может быть в любом). @@ -131,36 +150,44 @@ def build_listings_query(house_id: int) -> tuple[str, dict[str, object]]: def build_summary_scalars_query() -> str: - """Скаляры сводки: всего домов, с знаменателем, coverage, max, p95.""" + """Скаляры сводки: всего домов, с знаменателем (любой %), coverage, max/p95 обоих %.""" return ( "SELECT " "count(*) AS total_secondary_buildings, " - "count(*) FILTER (WHERE sale_share_pct IS NOT NULL) " + "count(*) FILTER (WHERE sale_share_pct IS NOT NULL OR sale_share_pct_45d IS NOT NULL) " "AS buildings_with_denominator, " "COALESCE(round(" - "100.0 * count(*) FILTER (WHERE sale_share_pct IS NOT NULL) " + "100.0 * count(*) FILTER " + "(WHERE sale_share_pct IS NOT NULL OR sale_share_pct_45d 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 " + "percentile_cont(0.95) WITHIN GROUP (ORDER BY sale_share_pct) AS p95_pct, " + "max(sale_share_pct_45d) AS max_pct_45d, " + "percentile_cont(0.95) WITHIN GROUP (ORDER BY sale_share_pct_45d) AS p95_pct_45d " "FROM v_building_sale_share" ) -def build_summary_histogram_query() -> str: - """Гистограмма sale_share_pct по фиксированным корзинам (только non-null).""" +def build_summary_histogram_query(column: str = "sale_share_pct") -> str: + """Гистограмма по фиксированным корзинам (только non-null) для одного из двух %. + + column — whitelist {sale_share_pct, sale_share_pct_45d}; чужое → fallback на + sale_share_pct (user-ввод в SQL не интерполируется). + """ + col = column if column in _HISTOGRAM_COLUMNS else "sale_share_pct" 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' " + f"WHEN {col} < 5 THEN '0-5' " + f"WHEN {col} < 10 THEN '5-10' " + f"WHEN {col} < 20 THEN '10-20' " + f"WHEN {col} < 30 THEN '20-30' " + f"WHEN {col} < 50 THEN '30-50' " + f"WHEN {col} <= 100 THEN '50-100' " "ELSE '100+' " "END AS bucket, " "count(*) AS count " "FROM v_building_sale_share " - "WHERE sale_share_pct IS NOT NULL " + f"WHERE {col} IS NOT NULL " "GROUP BY bucket" ) diff --git a/tradein-mvp/backend/data/sql/146_sale_share_45d_and_zhkh_denom.sql b/tradein-mvp/backend/data/sql/146_sale_share_45d_and_zhkh_denom.sql new file mode 100644 index 00000000..ebeddbbf --- /dev/null +++ b/tradein-mvp/backend/data/sql/146_sale_share_45d_and_zhkh_denom.sql @@ -0,0 +1,132 @@ +-- 146_sale_share_45d_and_zhkh_denom.sql +-- +-- CONTEXT: страница «доля квартир дома в продаже» получает ВТОРОЙ процент — +-- trailing-45-дневное окно — и второй источник знаменателя (ГИС ЖКХ). +-- +-- Было (мигр. 143/145): один метрик sale_share_pct = +-- active_vtorichka_listings / flat_count_effective (только АКТИВНЫЕ объявления). +-- Стало: добавляем sale_share_pct_45d = +-- distinct vtorichka-листинги (активные ИЛИ недавно закрытые), увиденные за +-- последние 45 дней / flat_count_effective. Дом попадает в выборку, если ЛЮБОЙ +-- из двух процентов проходит порог (фильтрация — на стороне API). +-- +-- Мы храним ~27k неактивных листингов с last_seen_at (история скрейпа ~36 дней), +-- поэтому окно 45д сейчас ≈ «всё, что когда-либо видели» — это ожидаемо и нормально +-- (по мере накопления истории окно станет действительно скользящим). +-- +-- Второй источник знаменателя: houses.zhkh_flat_count (ГИС ЖКХ) — заполняется +-- ОТДЕЛЬНЫМ loader'ом позже; здесь только schema-slot + приоритет в COALESCE +-- (после ГАР, перед total_units/flat_count). +-- +-- WHAT: +-- 1. houses.zhkh_* — provenance-колонки матча ГИС ЖКХ → houses +-- (zhkh_flat_count, zhkh_house_guid, zhkh_matched_at). Partial-индекс на +-- zhkh_flat_count WHERE NOT NULL. +-- 2. v_building_sale_share — добавляем listings_45d + sale_share_pct_45d, +-- zhkh как 2-й приоритет знаменателя, WHERE расширяем на churn-only дома +-- (0 активных, но есть листинги за 45д). +-- +-- DEPENDENCIES: 143 (view + houses.gar_*), 144 (canon match populates gar_flat_count), +-- 145 (плаузибилити-гейт знаменателя). listings.dedup_hash / listings.last_seen_at — +-- существующие колонки (dedup-merge мигр. 135 + scrape-tracking). +-- +-- SAFETY / IDEMPOTENCY: только ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, +-- CREATE OR REPLACE VIEW. Повторный прогон — no-op. Деплой-раннер гонит файл через +-- psql -v ON_ERROR_STOP=on БЕЗ --single-transaction → транзакцию открывает САМ файл +-- (BEGIN/COMMIT ниже), как 143/145. +-- +-- NB по нумерации: 143–145 заняты building_sale_share_*; следующий свободный +-- sequential = 146 (проверено `ls tradein-mvp/backend/data/sql | grep '^14'`). +-- +-- Deploy order: после 145_building_sale_share_plausible_denom.sql. + +BEGIN; + +-- ───────────────────────────────────────────────────────────────────── +-- 1. houses — provenance-колонки 2-го источника знаменателя (ГИС ЖКХ) +-- ───────────────────────────────────────────────────────────────────── +ALTER TABLE houses ADD COLUMN IF NOT EXISTS zhkh_flat_count int; +ALTER TABLE houses ADD COLUMN IF NOT EXISTS zhkh_house_guid text; +ALTER TABLE houses ADD COLUMN IF NOT EXISTS zhkh_matched_at timestamptz; + +-- partial: индексируем только реально заматченные дома (zhkh_flat_count populated). +CREATE INDEX IF NOT EXISTS houses_zhkh_flat_count_idx + ON houses (zhkh_flat_count) + WHERE zhkh_flat_count IS NOT NULL; + +-- ───────────────────────────────────────────────────────────────────── +-- 2. v_building_sale_share — два процента (now + 45д) + zhkh-знаменатель +-- flat_count_effective = COALESCE(ГАР, ЖКХ, total_units, flat_count). +-- sale_share_pct = active_secondary / denom (как было). +-- sale_share_pct_45d = distinct vtorichka за 45д / denom (новое). +-- Оба процента — под одним плаузибилити-гейтом (denom>=GREATEST(floors,8) AND +-- числитель<=denom), иначе NULL. WHERE расширен на churn-only дома. +-- ───────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW v_building_sale_share AS + WITH listing_agg AS ( + SELECT l.house_id_fk AS house_id, + count(*) FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text) AS active_secondary, + count(DISTINCT l.dedup_hash) FILTER ( + WHERE l.listing_segment = 'vtorichka'::text + AND l.last_seen_at >= (now() - interval '45 days') + ) AS listings_45d, + percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_rub::double precision)) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_rub IS NOT NULL) AS median_price_rub, + percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_per_m2::double precision)) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_per_m2 IS NOT NULL) AS median_price_per_m2, + avg(l.days_on_market) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.days_on_market IS NOT NULL) AS avg_days_on_market + FROM listings l + WHERE l.house_id_fk IS NOT NULL + GROUP BY l.house_id_fk + ) + SELECT h.id AS house_id, + h.short_address, + h.full_address, + h.address, + h.lat, + h.lon, + h.year_built, + h.house_type, + h.total_floors, + h.series_name, + h.is_emergency, + COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) AS flat_count_effective, + h.gar_flat_count, + h.gar_match_method, + la.active_secondary, + la.median_price_rub, + la.median_price_per_m2, + la.avg_days_on_market, + CASE + WHEN COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + >= GREATEST(COALESCE(h.total_floors, 0), 8) + AND la.active_secondary <= COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + THEN round(100.0 * la.active_secondary::numeric + / COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1) + ELSE NULL::numeric + END AS sale_share_pct, + la.listings_45d, + CASE + WHEN COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + >= GREATEST(COALESCE(h.total_floors, 0), 8) + AND la.listings_45d <= COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + THEN round(100.0 * la.listings_45d::numeric + / COALESCE(h.gar_flat_count, h.zhkh_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1) + ELSE NULL::numeric + END AS sale_share_pct_45d + FROM houses h + JOIN listing_agg la ON la.house_id = h.id + WHERE h.geom IS NOT NULL AND (la.active_secondary > 0 OR la.listings_45d > 0); + +COMMENT ON VIEW v_building_sale_share IS + 'Per-building rollup вторички для «доли квартир дома в продаже» (мигр. 143; знаменатель — ' + 'ГАР canon-match мигр. 144; 2-й источник ЖКХ + окно 45д мигр. 146). flat_count_effective = ' + 'COALESCE(gar_flat_count, zhkh_flat_count, NULLIF(total_units,0), NULLIF(flat_count,0)). ' + 'sale_share_pct = active_secondary/denom; sale_share_pct_45d = count(DISTINCT dedup_hash) ' + 'vtorichka за last_seen_at>=now()-45d / denom (активные ИЛИ недавно закрытые). Оба под ' + 'плаузибилити-гейтом (denom>=GREATEST(total_floors,8) AND числитель<=denom; мигр. 145), ' + 'иначе NULL. Фильтр: geom NOT NULL AND (active_secondary>0 OR listings_45d>0) — churn-only ' + 'дома (0 активных, но есть листинги за 45д) тоже видны.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_buildings_api.py b/tradein-mvp/backend/tests/test_buildings_api.py index 83a2f519..fd5cd6f1 100644 --- a/tradein-mvp/backend/tests/test_buildings_api.py +++ b/tradein-mvp/backend/tests/test_buildings_api.py @@ -34,9 +34,14 @@ from app.services.buildings_query import ( 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 + # building qualifies on EITHER % — OR-фильтр, без жёсткого IS NOT NULL. assert "sale_share_pct >= CAST(:min_pct AS numeric)" in sql - assert "(sale_share_pct > 100) AS over_100" in sql + assert "sale_share_pct_45d >= CAST(:min_pct AS numeric)" in sql + assert "OR (sale_share_pct_45d >= CAST(:min_pct AS numeric))" in sql + # оба % выставлены в SELECT; over_100 — по любому из них. + assert "sale_share_pct_45d, " in sql + assert "listings_45d, " in sql + assert "(sale_share_pct > 100 OR sale_share_pct_45d > 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 @@ -44,6 +49,24 @@ def test_sale_share_minimal_defaults(): assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql +def test_sale_share_or_filter_includes_churn_only(): + """45d-only дом (0 активных, но sale_share_pct_45d>=min) проходит через OR-ветку.""" + sql, _ = build_sale_share_query(min_pct=10) + # обе ветки порога присутствуют, объединены через OR в одной скобке + assert "((sale_share_pct >= CAST(:min_pct AS numeric))" in sql + assert "(sale_share_pct_45d >= CAST(:min_pct AS numeric)))" in sql + # жёсткого `sale_share_pct IS NOT NULL` больше нет — он отрезал бы 45d-only дома + assert "sale_share_pct IS NOT NULL" not in sql + + +def test_sale_share_max_pct_bounds_both_metrics(): + """max_pct ограничивает КАЖДУЮ ветку отдельно (per-metric [min,max]).""" + sql, args = build_sale_share_query(min_pct=5, max_pct=40) + assert "sale_share_pct <= CAST(:max_pct AS numeric)" in sql + assert "sale_share_pct_45d <= CAST(:max_pct AS numeric)" in sql + assert args["max_pct"] == 40 + + def test_sale_share_all_filters(): sql, args = build_sale_share_query( min_pct=10, @@ -113,9 +136,36 @@ def test_listings_query_shape(): 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 + # знаменатель/coverage — по ЛЮБОМУ из двух % + assert "FILTER (WHERE sale_share_pct IS NOT NULL OR sale_share_pct_45d IS NOT NULL)" in sql assert "NULLIF(count(*), 0)" in sql assert "percentile_cont(0.95)" in sql + # max/p95 для обоих процентов + assert "max(sale_share_pct_45d) AS max_pct_45d" in sql + assert "AS p95_pct_45d" in sql + + +def test_summary_histogram_column_whitelisted(): + """build_summary_histogram_query: только whitelist-колонка, чужое → fallback.""" + sql_now = build_summary_histogram_query("sale_share_pct") + assert "WHERE sale_share_pct IS NOT NULL" in sql_now + sql_45d = build_summary_histogram_query("sale_share_pct_45d") + assert "WHEN sale_share_pct_45d < 5 THEN '0-5'" in sql_45d + assert "WHERE sale_share_pct_45d IS NOT NULL" in sql_45d + # инъекция в имя колонки не проходит — fallback на sale_share_pct + sql_bad = build_summary_histogram_query("sale_share_pct; DROP TABLE houses") + assert "DROP TABLE" not in sql_bad + assert "WHERE sale_share_pct IS NOT NULL" in sql_bad + + +def test_new_sorts_whitelisted(): + """share_45d_desc / count_45d_desc добавлены в whitelist и дают корректный ORDER BY.""" + assert "share_45d_desc" in ALLOWED_SORTS + assert "count_45d_desc" in ALLOWED_SORTS + sql_share_45d, _ = build_sale_share_query(sort="share_45d_desc") + assert "ORDER BY sale_share_pct_45d DESC NULLS LAST" in sql_share_45d + sql_count_45d, _ = build_sale_share_query(sort="count_45d_desc") + assert "ORDER BY listings_45d DESC NULLS LAST" in sql_count_45d def test_summary_histogram_buckets_cover_spec(): @@ -169,8 +219,10 @@ def test_get_sale_share_list_200(buildings_app: FastAPI): "lat": 56.8, "lon": 60.6, "sale_share_pct": 12.5, + "sale_share_pct_45d": 16.7, "over_100": False, "active_secondary": 3, + "listings_45d": 4, "flat_count_effective": 24, "gar_match_method": None, "median_price_rub": 5_000_000, @@ -190,6 +242,41 @@ def test_get_sale_share_list_200(buildings_app: FastAPI): assert body[0]["house_id"] == 7 assert body[0]["over_100"] is False assert body[0]["sale_share_pct"] == 12.5 + assert body[0]["sale_share_pct_45d"] == 16.7 + assert body[0]["listings_45d"] == 4 + + +def test_get_sale_share_churn_only_row(buildings_app: FastAPI): + """Дом с 0 активных, но 45d-листингами проходит OR-фильтр и несёт оба поля.""" + row = { + "house_id": 99, + "address": "Удельная, 1/1", + "lat": 56.9, + "lon": 60.7, + "sale_share_pct": 0.0, + "sale_share_pct_45d": 22.7, + "over_100": False, + "active_secondary": 0, + "listings_45d": 5, + "flat_count_effective": 22, + "gar_match_method": "canon", + "median_price_rub": None, + "median_price_per_m2": None, + "avg_days_on_market": None, + "year_built": 1975, + "house_type": "brick", + "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&sort=share_45d_desc") + assert resp.status_code == 200 + body = resp.json() + assert body[0]["house_id"] == 99 + assert body[0]["active_secondary"] == 0 + assert body[0]["sale_share_pct_45d"] == 22.7 + assert body[0]["listings_45d"] == 5 def test_get_sale_share_empty_when_no_gar(buildings_app: FastAPI): @@ -229,21 +316,32 @@ def test_get_summary_zero_fills_histogram(buildings_app: FastAPI): "total_secondary_buildings": 10, "buildings_with_denominator": 4, "coverage_pct": 40.0, - "max_pct": 250.0, + "max_pct": 95.0, "p95_pct": 33.3, + "max_pct_45d": 98.0, + "p95_pct_45d": 38.1, } ) + # handler делает 3 запроса: scalars, histogram(now), histogram(45d). hist = _mapped([{"bucket": "0-5", "count": 2}, {"bucket": "100+", "count": 1}]) - client = _client_with_rows(buildings_app, scalars, hist) + hist_45d = _mapped([{"bucket": "5-10", "count": 3}, {"bucket": "50-100", "count": 1}]) + client = _client_with_rows(buildings_app, scalars, hist, hist_45d) 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 body["max_pct_45d"] == 98.0 + assert body["p95_pct_45d"] == 38.1 + # обе гистограммы: все 7 корзин, недостающие — count 0, порядок фиксирован assert [b["bucket"] for b in body["histogram"]] == list(HISTOGRAM_BUCKETS) + assert [b["bucket"] for b in body["histogram_45d"]] == 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 + counts_45d = {b["bucket"]: b["count"] for b in body["histogram_45d"]} + assert counts_45d["5-10"] == 3 + assert counts_45d["50-100"] == 1 + assert counts_45d["0-5"] == 0