From 26ae507c114ad4da2906978a1f4cabde3fb467c1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:25:17 +0300 Subject: [PATCH] fix(analytics): align portfolio series to area buckets in quartirography (#1406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch portfolio source from domrf_region_aggregates (room_count_type ONE/TWO/THREE/FOUR) to domrf_flat_area_distribution (area_bucket FROM_0_TO_25…FROM_100), aggregated into the same 5 area buckets as the deals series. Both series now share the same axis and are directly comparable. Frontend portfolioMap simplified to direct bucket key lookup. --- backend/app/services/analytics_queries.py | 93 +++++++++++++------ .../analytics/QuartirographyChart.tsx | 24 ++--- 2 files changed, 76 insertions(+), 41 deletions(-) diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 9abb8021..b542ae66 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -79,46 +79,83 @@ def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]: def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]: - """source: 'portfolio' (что строится) or 'deals' (реально покупают).""" + """source: 'portfolio' (что строится) or 'deals' (реально покупают). + + Оба источника возвращают одни и те же 5 area-бакетов, выровненных с осью + QuartirographyChart ("Студии 15-30" / "1-к 30-45" / "2-к 45-60" / + "3-к 60-80" / "80+ м²"). + + Портфель: domrf_flat_area_distribution (RF-wide snapshot, region_id=0 т.к. + API DOM.РФ игнорирует ?regionId на этом эндпоинте). Бакеты DOM.РФ (FROM_0_TO_25 + … FROM_100) маппируются на chart-бакеты по средней площади: + FROM_0_TO_25 (~22 м²) → "Студии 15-30" + FROM_25_TO_35 (~31 м², мелкие однушки) → "1-к 30-45" + FROM_35_TO_45 (~40 м²) → "1-к 30-45" + FROM_45_TO_55 (~50 м²) → "2-к 45-60" + FROM_55_TO_70 (~62 м²) → "3-к 60-80" + FROM_70_TO_85 (~77 м²) → "3-к 60-80" + FROM_85_TO_100 (~92 м²) → "80+ м²" + FROM_100 (100+ м²) → "80+ м²" + """ if source == "portfolio": rows = ( db.execute( text( """ - SELECT room_count_type, flat_count, area_sqm, percent - FROM domrf_region_aggregates - WHERE region_id = :region_id - AND snapshot_date = ( - SELECT MAX(snapshot_date) - FROM domrf_region_aggregates - WHERE region_id = :region_id - ) - AND room_count_type <> 'TOTAL' - ORDER BY CASE room_count_type - WHEN 'ONE' THEN 1 - WHEN 'TWO' THEN 2 - WHEN 'THREE' THEN 3 - WHEN 'FOUR' THEN 4 - END + WITH latest AS ( + SELECT MAX(snapshot_date) AS snap + FROM domrf_flat_area_distribution + WHERE region_id = 0 + ), + mapped AS ( + SELECT + CASE area_bucket + WHEN 'FROM_0_TO_25' THEN '1-Студия' + WHEN 'FROM_25_TO_35' THEN '2-1-к' + WHEN 'FROM_35_TO_45' THEN '2-1-к' + WHEN 'FROM_45_TO_55' THEN '3-2-к' + WHEN 'FROM_55_TO_70' THEN '4-3-к' + WHEN 'FROM_70_TO_85' THEN '4-3-к' + WHEN 'FROM_85_TO_100' THEN '5-80+ м²' + WHEN 'FROM_100' THEN '5-80+ м²' + END AS chart_bucket, + flat_count, + area_sqm + FROM domrf_flat_area_distribution + CROSS JOIN latest + WHERE region_id = 0 + AND room_count_type = 'TOTAL' + AND snapshot_date = latest.snap + ) + SELECT chart_bucket, + SUM(flat_count)::bigint AS flat_count, + SUM(area_sqm) AS area_sqm + FROM mapped + WHERE chart_bucket IS NOT NULL + GROUP BY chart_bucket + ORDER BY chart_bucket """ ), - {"region_id": region_id}, + {}, ) .mappings() .all() ) + _pretty = { + "1-Студия": "Студии 15-30", + "2-1-к": "1-к 30-45", + "3-2-к": "2-к 45-60", + "4-3-к": "3-к 60-80", + "5-80+ м²": "80+ м²", + } + total_flats = sum(r["flat_count"] or 0 for r in rows) or 1 return [ { - "bucket": { - "ONE": "1-к", - "TWO": "2-к", - "THREE": "3-к", - "FOUR": "4+", - }.get(r["room_count_type"], r["room_count_type"]), - "flat_count": r["flat_count"], + "bucket": _pretty[r["chart_bucket"]], + "flat_count": int(r["flat_count"] or 0), "area_sqm": _f(r["area_sqm"]), - "percent": r["percent"], - "avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None, + "percent": round((r["flat_count"] or 0) * 100 / total_flats, 1), + "avg_area": (_f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None), } for r in rows ] @@ -2632,9 +2669,7 @@ def recommend_mix( else: # Rosreestr fallback aggregate: district+class deals / months / n_comp = # среднерыночный темп одного ЖК района/класса (срок ~12-24 мес). - market_vel_pm = ( - (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0 - ) + market_vel_pm = (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0 warnings.append( f"Нет objective-данных для района/класса — темп по rosreestr ÷ " f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)." diff --git a/frontend/src/components/analytics/QuartirographyChart.tsx b/frontend/src/components/analytics/QuartirographyChart.tsx index 833815f0..469b6447 100644 --- a/frontend/src/components/analytics/QuartirographyChart.tsx +++ b/frontend/src/components/analytics/QuartirographyChart.tsx @@ -23,17 +23,16 @@ export function QuartirographyChart() { "3-к 60-80", "80+ м²", ]; - // DOM.РФ portfolio source (domrf_region_aggregates) has no studio segment - // (room_count_type only ONE/TWO/THREE/FOUR), so "Студии 15-30" has no - // counterpart here. Map it to null → ECharts draws no bar (N/A) instead of - // a misleading 0% grey bar. - const portfolioMap: Record = { - "Студии 15-30": null, - "1-к 30-45": portfolioRows.find((r) => r.bucket === "1-к")?.percent ?? 0, - "2-к 45-60": portfolioRows.find((r) => r.bucket === "2-к")?.percent ?? 0, - "3-к 60-80": portfolioRows.find((r) => r.bucket === "3-к")?.percent ?? 0, - "80+ м²": portfolioRows.find((r) => r.bucket === "4+")?.percent ?? 0, - }; + // Portfolio is now served from domrf_flat_area_distribution with the same + // area bucket keys as the deals series — direct lookup by bucket label. + // If a bucket is absent in the source (e.g. studio data gap), percent + // falls back to null so ECharts draws no bar rather than a misleading 0%. + const portfolioMap: Record = Object.fromEntries( + buckets.map((b) => { + const row = portfolioRows.find((r) => r.bucket === b); + return [b, row != null ? (row.percent ?? null) : null]; + }), + ); const dealsPercents = buckets.map( (b) => dealsRows.find((r) => r.bucket === b)?.percent ?? 0, ); @@ -43,7 +42,8 @@ export function QuartirographyChart() { tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, - valueFormatter: (v: number | null) => (v == null ? "нет данных" : `${v}%`), + valueFormatter: (v: number | null) => + v == null ? "нет данных" : `${v}%`, }, legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] }, grid: { left: 110, right: 32, top: 40, bottom: 28 }, -- 2.45.3