Merge pull request 'fix(analytics): align portfolio series to area buckets in quartirography (#1406)' (#1675) from fix/quartirography-area-bucket-1406 into main
Some checks are pending
Deploy / changes (push) Waiting to run
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions

This commit is contained in:
lekss361 2026-06-17 17:44:58 +00:00
commit 3ffba9f8a8
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]]: 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": if source == "portfolio":
rows = ( rows = (
db.execute( db.execute(
text( text(
""" """
SELECT room_count_type, flat_count, area_sqm, percent WITH latest AS (
FROM domrf_region_aggregates SELECT MAX(snapshot_date) AS snap
WHERE region_id = :region_id FROM domrf_flat_area_distribution
AND snapshot_date = ( WHERE region_id = 0
SELECT MAX(snapshot_date) ),
FROM domrf_region_aggregates mapped AS (
WHERE region_id = :region_id SELECT
) CASE area_bucket
AND room_count_type <> 'TOTAL' WHEN 'FROM_0_TO_25' THEN '1-Студия'
ORDER BY CASE room_count_type WHEN 'FROM_25_TO_35' THEN '2-1-к'
WHEN 'ONE' THEN 1 WHEN 'FROM_35_TO_45' THEN '2-1-к'
WHEN 'TWO' THEN 2 WHEN 'FROM_45_TO_55' THEN '3-2-к'
WHEN 'THREE' THEN 3 WHEN 'FROM_55_TO_70' THEN '4-3-к'
WHEN 'FOUR' THEN 4 WHEN 'FROM_70_TO_85' THEN '4-3-к'
END 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() .mappings()
.all() .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 [ return [
{ {
"bucket": { "bucket": _pretty[r["chart_bucket"]],
"ONE": "1-к", "flat_count": int(r["flat_count"] or 0),
"TWO": "2-к",
"THREE": "3-к",
"FOUR": "4+",
}.get(r["room_count_type"], r["room_count_type"]),
"flat_count": r["flat_count"],
"area_sqm": _f(r["area_sqm"]), "area_sqm": _f(r["area_sqm"]),
"percent": r["percent"], "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, "avg_area": (_f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None),
} }
for r in rows for r in rows
] ]
@ -2632,9 +2669,7 @@ def recommend_mix(
else: else:
# Rosreestr fallback aggregate: district+class deals / months / n_comp = # Rosreestr fallback aggregate: district+class deals / months / n_comp =
# среднерыночный темп одного ЖК района/класса (срок ~12-24 мес). # среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
market_vel_pm = ( market_vel_pm = (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
(total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
)
warnings.append( warnings.append(
f"Нет objective-данных для района/класса — темп по rosreestr ÷ " f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)." f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."

View file

@ -23,17 +23,16 @@ export function QuartirographyChart() {
"3-к 60-80", "3-к 60-80",
"80+ м²", "80+ м²",
]; ];
// DOM.РФ portfolio source (domrf_region_aggregates) has no studio segment // Portfolio is now served from domrf_flat_area_distribution with the same
// (room_count_type only ONE/TWO/THREE/FOUR), so "Студии 15-30" has no // area bucket keys as the deals series — direct lookup by bucket label.
// counterpart here. Map it to null → ECharts draws no bar (N/A) instead of // If a bucket is absent in the source (e.g. studio data gap), percent
// a misleading 0% grey bar. // falls back to null so ECharts draws no bar rather than a misleading 0%.
const portfolioMap: Record<string, number | null> = { const portfolioMap: Record<string, number | null> = Object.fromEntries(
"Студии 15-30": null, buckets.map((b) => {
"1-к 30-45": portfolioRows.find((r) => r.bucket === "1-к")?.percent ?? 0, const row = portfolioRows.find((r) => r.bucket === b);
"2-к 45-60": portfolioRows.find((r) => r.bucket === "2-к")?.percent ?? 0, return [b, row != null ? (row.percent ?? null) : null];
"3-к 60-80": portfolioRows.find((r) => r.bucket === "3-к")?.percent ?? 0, }),
"80+ м²": portfolioRows.find((r) => r.bucket === "4+")?.percent ?? 0, );
};
const dealsPercents = buckets.map( const dealsPercents = buckets.map(
(b) => dealsRows.find((r) => r.bucket === b)?.percent ?? 0, (b) => dealsRows.find((r) => r.bucket === b)?.percent ?? 0,
); );
@ -43,7 +42,8 @@ export function QuartirographyChart() {
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
axisPointer: { type: "shadow" }, axisPointer: { type: "shadow" },
valueFormatter: (v: number | null) => (v == null ? "нет данных" : `${v}%`), valueFormatter: (v: number | null) =>
v == null ? "нет данных" : `${v}%`,
}, },
legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] }, legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] },
grid: { left: 110, right: 32, top: 40, bottom: 28 }, grid: { left: 110, right: 32, top: 40, bottom: 28 },