gendesign/frontend/src/app/analytics/recommend/page.tsx
lekss361 25b73035a1 sprint1: nspd scraper industrialization, per-bucket elasticity, cadastre cross-check, sentry releases
- NSPD-skraper переехал в backend/app/services/scrapers/nspd_kn.py +
  Celery task scrape_nspd_region (beat: 20-е февраля/мая/авг/нояб).
  Redis lock 3h, WAF auto-retry, heartbeat в nspd_scrape_runs.
- Recommend_mix Tier 3: per-bucket elasticity через регрессию по
  «доминирующему bucket» каждого ЖК. Weighted-elasticity для inverse-mode.
  UI показывает разброс эластичностей и переключение regression/fallback.
- Cadastre vs market cross-check: spatial-join cad_buildings →
  ekb_districts_geom; cadastre_vs_market_pct в scope, аномалии
  (>+50% / <-30%) подсвечены в UI.
- Sentry release tracking (#4): IMAGE_TAG → backend/.env.runtime →
  sentry_sdk.init(release=...). Compose v2 env_file optional path.

Schemas: 63_schema_nspd_runs.sql (cad_buildings + nspd_scrape_runs/log
формализуют то, что уже жило в проде через 61_import_nspd_batch.py),
64_v_zk_rosreestr_velocity.sql (refresh с cad_buildings).
2026-04-30 21:51:19 +03:00

383 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState } from "react";
import { KpiCard } from "@/components/analytics/KpiCard";
import { RecommendBucketsChart } from "@/components/analytics/RecommendBucketsChart";
import { RecommendBucketsTable } from "@/components/analytics/RecommendBucketsTable";
import { RecommendComparables } from "@/components/analytics/RecommendComparables";
import { RecommendForm } from "@/components/analytics/RecommendForm";
import { RecommendLiquidityChart } from "@/components/analytics/RecommendLiquidityChart";
import { RecommendRevenueChart } from "@/components/analytics/RecommendRevenueChart";
import { RecommendShareSliders } from "@/components/analytics/RecommendShareSliders";
import { RecommendVelocityPanel } from "@/components/analytics/RecommendVelocityPanel";
import { Section } from "@/components/analytics/Section";
import { useRecommendMix } from "@/lib/analytics-api";
import type { RecommendMixInput } from "@/types/analytics";
const DEFAULT_INPUT: RecommendMixInput = {
district_name: "",
area_total_m2: null,
target_class: null,
months_window: 12,
price_factor: 1.0,
target_months: null,
};
export default function RecommendPage() {
const [input, setInput] = useState<RecommendMixInput>(DEFAULT_INPUT);
const [overrideShares, setOverrideShares] = useState<number[] | null>(null);
// priceFactor живёт отдельно от input.price_factor, чтобы слайдер двигался
// мгновенно (client-side recompute), а не дёргал API на каждое движение.
const [priceFactor, setPriceFactor] = useState(1.0);
const mutation = useRecommendMix();
const data = mutation.data;
// reset slider override whenever a fresh API response arrives
useEffect(() => {
if (data) {
setOverrideShares(null);
setPriceFactor(1.0);
}
}, [data]);
const recommendedShares = useMemo(
() => (data ? data.buckets.map((b) => b.share_pct) : []),
[data],
);
const effectiveShares = overrideShares ?? recommendedShares;
const derivedRows = useMemo(() => {
if (!data) return [];
return data.buckets.map((b, i) => {
const share = effectiveShares[i] ?? b.share_pct;
const areaTotal = input.area_total_m2 ?? null;
let units: number | null = null;
let revenue: number | null = null;
if (areaTotal && b.area_avg_m2 > 0) {
const allocated = areaTotal * (share / 100);
units = Math.max(1, Math.round(allocated / b.area_avg_m2));
revenue = Math.round(units * b.area_avg_m2 * b.price_median_per_m2);
}
return {
...b,
effective_share_pct: share,
effective_units: units,
effective_revenue_rub: revenue,
};
});
}, [data, effectiveShares, input.area_total_m2]);
// totalRevenue и weightedAvgPrice сейчас уже отображаются внутри
// RecommendVelocityPanel и headline summary — здесь больше не считаем,
// чтобы не дублировать вычисления и держать одну точку правды для KPI.
const errMsg = mutation.error
? mutation.error instanceof Error
? mutation.error.message
: String(mutation.error)
: null;
const hasAllocation = !!input.area_total_m2;
return (
<>
<h1 style={{ margin: "8px 0 4px", fontSize: 22 }}>
Рекомендатор квартирографии
</h1>
<p style={{ margin: "0 0 16px", color: "#5b6066", fontSize: 13 }}>
Уровень 1 rule-based: city-wide ДДУ-сделки Свердл (Rosreestr)
распределение по 5 бакетам комнатности с поправкой на район и класс.
Цены, площади, юниты и выручка считаются по медиане сделок за выбранное
окно.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(300px, 360px) 1fr",
gap: 16,
alignItems: "start",
}}
>
<RecommendForm
value={input}
onChange={setInput}
onSubmit={() => mutation.mutate(input)}
isPending={mutation.isPending}
error={errMsg}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{!data ? (
<div
style={{
background: "#fff",
border: "1px dashed #d1d5db",
borderRadius: 12,
padding: 40,
textAlign: "center",
color: "#73767e",
fontSize: 14,
}}
>
Заполни форму слева и нажми «Рассчитать» чтобы увидеть
рекомендацию.
</div>
) : data.scope.error ? (
<div
style={{
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 12,
padding: 20,
color: "#991b1b",
fontSize: 14,
}}
>
{data.summary.warnings[0] ??
"Не удалось рассчитать рекомендацию."}
</div>
) : (
<>
{/* Headline для CEO — одна строка с тремя главными цифрами */}
{data.summary.headline ? (
<div
style={{
background: "#0f172a",
color: "#e2e8f0",
borderRadius: 12,
padding: "14px 18px",
fontSize: 15,
fontWeight: 500,
lineHeight: 1.4,
}}
>
<div>
💼 <strong>«{data.scope.district}</strong>
{data.scope.target_class
? ` · ${data.scope.target_class}`
: ""}
{input.area_total_m2
? ` · ${input.area_total_m2.toLocaleString("ru")} м²`
: ""}
<strong>»:</strong> {data.summary.headline}
</div>
<div
style={{
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid rgba(255,255,255,0.1)",
fontSize: 12,
color: "#94a3b8",
fontWeight: 400,
}}
>
📊
{data.scope.mortgage_rate_pct != null
? ` Средневзв. ИЖК ${data.scope.mortgage_rate_pct.toFixed(2)}% (${data.scope.mortgage_rate_period}, со льготами; рыночная ~20%)`
: " ставка ЦБ нет данных"}
{data.scope.poi_score != null &&
data.scope.poi_score_city_avg != null
? ` · POI ${data.scope.poi_score.toFixed(0)}/${data.scope.poi_score_city_avg.toFixed(0)} (${data.scope.poi_score > data.scope.poi_score_city_avg ? "выше" : "ниже"} среднего)`
: ""}
</div>
</div>
) : null}
{/* Velocity panel — главный калькулятор */}
<Section
title="Цена · Темп · Срок · Ликвидность"
subtitle="Двигай слайдер цены — KPI пересчитываются live по эластичности sale_graph. Введи целевой срок в форме слева — система предложит требуемый коэффициент."
>
<RecommendVelocityPanel
scope={data.scope}
derivedRows={derivedRows}
priceFactor={priceFactor}
onPriceFactorChange={setPriceFactor}
targetMonths={input.target_months ?? null}
hasAllocation={hasAllocation}
/>
</Section>
{/* Mix sliders — сразу под KPI/слайдером цены, чтобы при движении
было видно как меняются метрики и графики live. */}
<Section
title="Подстрой распределение под проект"
subtitle="Двигай слайдер любого бакета — остальные авто-нормируются, чтобы сумма = 100%. KPI выше и графики ниже обновятся live."
>
<RecommendShareSliders
bucketLabels={data.buckets.map((b) => b.bucket)}
shares={effectiveShares}
recommended={recommendedShares}
onChange={setOverrideShares}
onReset={() => setOverrideShares(null)}
/>
</Section>
{/* Liquidity chart — кумулятивная кривая продаж */}
{hasAllocation ? (
<Section
title="Кумулятивные продажи (горизонт 36 мес)"
subtitle="Сколько % инвентаря продастся к месяцу X при текущей цене. Пунктир — горизонт 24 мес = liquidity score."
>
<RecommendLiquidityChart
rows={derivedRows}
priceFactor={priceFactor}
elasticity={data.scope.elasticity}
/>
</Section>
) : null}
{/* District-specific KPIs — что отличается между районами:
competitors, sale_graph покрытие, district/class факторы.
Раньше тут были city-wide дубли (Сделок 3815 / Weighted /
Выручка) — все одинаковые независимо от района. */}
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Конкуренты в районе"
value={data.scope.competitors_count.toString()}
hint={
data.scope.competitors_scope === "district+class"
? `строящихся ${data.scope.target_class || ""}`
: data.scope.competitors_scope === "district"
? "строящихся (любого класса)"
: data.scope.competitors_scope === "region"
? "по региону (нет в районе)"
: "нет данных"
}
/>
<KpiCard
label="Sale_graph покрытие"
value={
data.scope.velocity_source === "sale_graph"
? `${data.scope.velocity_objects} ЖК`
: "fallback"
}
hint={
data.scope.velocity_source === "sale_graph"
? `${data.scope.velocity_observations} точек`
: "rosreestr city-wide"
}
/>
<KpiCard
label="Sold% медиана"
value={
data.scope.saturation_median != null
? `${data.scope.saturation_median.toFixed(0)}%`
: "—"
}
hint={
data.scope.saturation_median == null
? "<5 ЖК с данными"
: data.scope.saturation_median > 50
? `зрелый рынок (${data.scope.saturation_n} ЖК)`
: data.scope.saturation_median < 25
? `свежий, мало распродано (${data.scope.saturation_n} ЖК)`
: `умеренная зрелость (${data.scope.saturation_n} ЖК)`
}
/>
<KpiCard
label="Тренд 6 мес"
value={
data.scope.velocity_trend_ratio != null
? `${data.scope.velocity_trend_ratio > 1 ? "🚀" : data.scope.velocity_trend_ratio < 0.8 ? "❄" : "→"} ×${data.scope.velocity_trend_ratio.toFixed(2)}`
: "—"
}
hint={
data.scope.velocity_trend_ratio == null
? "нет sale_graph"
: data.scope.velocity_trend_ratio > 1.5
? "рынок ускоряется"
: data.scope.velocity_trend_ratio < 0.8
? "остывает"
: "стабильный"
}
/>
<KpiCard
label="Class × District"
value={`×${(data.scope.class_multiplier * data.scope.district_factor * data.scope.poi_factor).toFixed(2)}`}
hint={`Class ×${data.scope.class_multiplier.toFixed(2)} · District ×${data.scope.district_factor.toFixed(2)} · POI ×${data.scope.poi_factor.toFixed(2)}`}
/>
</div>
<Section
title="Распределение по бакетам"
subtitle="Серым — рекомендация Rosreestr-сделок. Синим — твоё текущее распределение (можно подвинуть слайдерами выше)."
>
<RecommendBucketsChart
bucketLabels={data.buckets.map((b) => b.bucket)}
recommended={recommendedShares}
current={effectiveShares}
/>
</Section>
{hasAllocation ? (
<Section
title="Выручка по бакетам"
subtitle="Stacked-bar показывает, где деньги. Большой сегмент 80+ м² часто перевешивает мелкие, даже если их доля по штукам мала."
>
<RecommendRevenueChart
bucketLabels={data.buckets.map((b) => b.bucket)}
revenuesRub={derivedRows.map(
(r) => r.effective_revenue_rub,
)}
/>
</Section>
) : null}
<Section
title="Таблица бакетов"
subtitle="p25/median/p75 цен — диапазон рыночной медианы. Цена медиана уже скорректирована на район (district_factor) и класс (class_multiplier)."
>
<RecommendBucketsTable
rows={derivedRows}
hasAllocation={hasAllocation}
priceFactor={priceFactor}
elasticity={data.scope.elasticity}
/>
</Section>
<Section
title="Comparable ЖК"
subtitle="Топ-5 крупнейших ЖК в этом районе того же класса. Клик — drill-in."
>
<RecommendComparables items={data.comparables} />
</Section>
{data.summary.warnings.length > 0 || data.scope.data_caveat ? (
<Section title="Caveats">
{data.scope.data_caveat ? (
<p
style={{
fontSize: 12,
color: "#5b6066",
marginTop: 0,
}}
>
{data.scope.data_caveat}
</p>
) : null}
{data.summary.warnings.length > 0 ? (
<ul
style={{
fontSize: 12,
color: "#9a6700",
margin: 0,
paddingLeft: 18,
}}
>
{data.summary.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
) : null}
</Section>
) : null}
</>
)}
</div>
</div>
</>
);
}