fix(analytics): align portfolio series to area buckets in quartirography (#1406) #1675
2 changed files with 76 additions and 41 deletions
|
|
@ -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} активных ЖК района/класса (грубее, срок ориентировочный)."
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue