- 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).
383 lines
17 KiB
TypeScript
383 lines
17 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|