fix(analytics): align portfolio series to area buckets in quartirography (#1406) #1675

Merged
lekss361 merged 1 commit from fix/quartirography-area-bucket-1406 into main 2026-06-17 17:44:59 +00:00
2 changed files with 76 additions and 41 deletions

View file

@ -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
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
)
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
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} активных ЖК района/класса (грубее, срок ориентировочный)."

View file

@ -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<string, number | null> = {
"Студии 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<string, number | null> = 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 },