add rec ui
This commit is contained in:
parent
ecc0dbafd5
commit
62659887e9
13 changed files with 1027 additions and 50 deletions
|
|
@ -970,6 +970,58 @@ _BUCKET_PRETTY: dict[str, str] = {
|
|||
}
|
||||
|
||||
|
||||
_BUCKET_SQL = text(
|
||||
"""
|
||||
WITH bucketed AS (
|
||||
SELECT CASE
|
||||
WHEN area < 30 THEN '1-Студия'
|
||||
WHEN area < 45 THEN '2-1-к'
|
||||
WHEN area < 60 THEN '3-2-к'
|
||||
WHEN area < 80 THEN '4-3-к'
|
||||
ELSE '5-80+ м²'
|
||||
END AS bucket,
|
||||
area,
|
||||
price_per_sqm
|
||||
FROM rosreestr_deals
|
||||
WHERE region_code = :rc
|
||||
AND doc_type = 'ДДУ'
|
||||
-- realestate_type_code 002001003000 = квартиры (жилые помещения).
|
||||
-- 001 = земельные участки, 002 = нежилые помещения.
|
||||
AND realestate_type_code = '002001003000'
|
||||
AND area > 10
|
||||
AND area <= 200 -- отсечь выбросы (коммерческие площади)
|
||||
AND price_per_sqm BETWEEN 30000 AND 1000000
|
||||
AND period_start_date >= NOW()
|
||||
- (:months_window || ' months')::INTERVAL
|
||||
)
|
||||
SELECT bucket,
|
||||
COUNT(*)::bigint AS deals,
|
||||
AVG(area) AS area_avg,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area) AS area_median,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS price_median,
|
||||
PERCENTILE_CONT(0.25) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS price_p25,
|
||||
PERCENTILE_CONT(0.75) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS price_p75
|
||||
FROM bucketed
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _bucket_distribution(db: Session, region_code: int, months_window: int) -> list[Any]:
|
||||
return list(
|
||||
db.execute(
|
||||
_BUCKET_SQL,
|
||||
{"rc": region_code, "months_window": months_window},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def recommend_mix(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -998,6 +1050,7 @@ def recommend_mix(
|
|||
median_price_per_m2, mean_price_per_m2
|
||||
FROM ekb_districts
|
||||
WHERE district_name ILIKE :dn
|
||||
AND district_name <> 'не определён'
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
|
|
@ -1073,53 +1126,20 @@ def recommend_mix(
|
|||
" коэффициент класса = 1.0"
|
||||
)
|
||||
|
||||
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months
|
||||
bucket_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
WITH bucketed AS (
|
||||
SELECT CASE
|
||||
WHEN area < 30 THEN '1-Студия'
|
||||
WHEN area < 45 THEN '2-1-к'
|
||||
WHEN area < 60 THEN '3-2-к'
|
||||
WHEN area < 80 THEN '4-3-к'
|
||||
ELSE '5-80+ м²'
|
||||
END AS bucket,
|
||||
area,
|
||||
price_per_sqm
|
||||
FROM rosreestr_deals
|
||||
WHERE region_code = :rc
|
||||
AND doc_type = 'ДДУ'
|
||||
-- realestate_type_code 002001003000 = квартиры (жилые помещения).
|
||||
-- 001 = земельные участки, 002 = нежилые помещения.
|
||||
AND realestate_type_code = '002001003000'
|
||||
AND area > 10
|
||||
AND area <= 200 -- отсечь выбросы (коммерческие площади)
|
||||
AND price_per_sqm BETWEEN 30000 AND 1000000
|
||||
AND period_start_date >= NOW()
|
||||
- (:months_window || ' months')::INTERVAL
|
||||
)
|
||||
SELECT bucket,
|
||||
COUNT(*)::bigint AS deals,
|
||||
AVG(area) AS area_avg,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area) AS area_median,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS price_median,
|
||||
PERCENTILE_CONT(0.25) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS price_p25,
|
||||
PERCENTILE_CONT(0.75) WITHIN GROUP
|
||||
(ORDER BY price_per_sqm) AS price_p75
|
||||
FROM bucketed
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
"""
|
||||
),
|
||||
{"rc": region_code, "months_window": months_window},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
|
||||
# Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес
|
||||
# для всех бакетов и проставляем warning. Это даёт более устойчивые медианы.
|
||||
bucket_rows = _bucket_distribution(db, region_code, months_window)
|
||||
effective_window = months_window
|
||||
if months_window < 24 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows):
|
||||
bucket_rows_24 = _bucket_distribution(db, region_code, 24)
|
||||
if bucket_rows_24:
|
||||
bucket_rows = bucket_rows_24
|
||||
effective_window = 24
|
||||
warnings.append(
|
||||
f"Окно расширено до 24 мес: при {months_window} мес хотя бы один"
|
||||
" бакет имел <30 сделок — оценка была бы шумной"
|
||||
)
|
||||
total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1
|
||||
|
||||
# 5) Build buckets with adjusted prices + optional allocation
|
||||
|
|
@ -1158,7 +1178,7 @@ def recommend_mix(
|
|||
if deals < 30:
|
||||
warnings.append(
|
||||
f"Бакет '{_BUCKET_PRETTY.get(bid, bid)}': только {deals} сделок"
|
||||
f" за {months_window} мес — оценка с большой погрешностью"
|
||||
f" за {effective_window} мес — оценка с большой погрешностью"
|
||||
)
|
||||
|
||||
buckets.append(
|
||||
|
|
@ -1219,6 +1239,7 @@ def recommend_mix(
|
|||
"class_multiplier": round(class_multiplier, 4),
|
||||
"target_class": target_class,
|
||||
"months_window": months_window,
|
||||
"effective_window_months": effective_window,
|
||||
"region_code": region_code,
|
||||
"total_deals": total_deals if bucket_rows else 0,
|
||||
"data_caveat": (
|
||||
|
|
|
|||
266
frontend/src/app/analytics/recommend/page.tsx
Normal file
266
frontend/src/app/analytics/recommend/page.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"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 { RecommendRevenueChart } from "@/components/analytics/RecommendRevenueChart";
|
||||
import { RecommendShareSliders } from "@/components/analytics/RecommendShareSliders";
|
||||
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,
|
||||
};
|
||||
|
||||
export default function RecommendPage() {
|
||||
const [input, setInput] = useState<RecommendMixInput>(DEFAULT_INPUT);
|
||||
const [overrideShares, setOverrideShares] = useState<number[] | null>(null);
|
||||
const mutation = useRecommendMix();
|
||||
const data = mutation.data;
|
||||
|
||||
// reset slider override whenever a fresh API response arrives
|
||||
useEffect(() => {
|
||||
if (data) setOverrideShares(null);
|
||||
}, [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]);
|
||||
|
||||
const totalRevenue = derivedRows.reduce(
|
||||
(a, r) => a + (r.effective_revenue_rub ?? 0),
|
||||
0,
|
||||
);
|
||||
const weightedAvgPrice = useMemo(() => {
|
||||
let num = 0;
|
||||
let den = 0;
|
||||
for (const r of derivedRows) {
|
||||
const w = r.effective_share_pct * r.area_avg_m2;
|
||||
num += w * r.price_median_per_m2;
|
||||
den += w;
|
||||
}
|
||||
return den > 0 ? num / den : null;
|
||||
}, [derivedRows]);
|
||||
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<KpiCard label="Район" value={data.scope.district} />
|
||||
<KpiCard
|
||||
label="Сделок в выборке"
|
||||
value={data.scope.total_deals.toString()}
|
||||
hint={`окно ${data.scope.effective_window_months} мес`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Weighted ₽/м²"
|
||||
value={
|
||||
weightedAvgPrice
|
||||
? Math.round(weightedAvgPrice).toLocaleString("ru")
|
||||
: "—"
|
||||
}
|
||||
unit="₽"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Выручка"
|
||||
value={
|
||||
hasAllocation
|
||||
? `${(totalRevenue / 1_000_000).toFixed(1)}`
|
||||
: "—"
|
||||
}
|
||||
unit="млн ₽"
|
||||
hint={
|
||||
hasAllocation
|
||||
? "при текущем распределении"
|
||||
: "укажи площадь, чтобы посчитать"
|
||||
}
|
||||
/>
|
||||
</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="Двигай слайдер любого бакета — остальные авто-нормируются, чтобы сумма = 100%. Цифры в таблице и графиках обновятся live."
|
||||
>
|
||||
<RecommendShareSliders
|
||||
bucketLabels={data.buckets.map((b) => b.bucket)}
|
||||
shares={effectiveShares}
|
||||
recommended={recommendedShares}
|
||||
onChange={setOverrideShares}
|
||||
onReset={() => setOverrideShares(null)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Таблица бакетов"
|
||||
subtitle="p25/median/p75 цен — диапазон рыночной медианы. Цена медиана уже скорректирована на район (district_factor) и класс (class_multiplier)."
|
||||
>
|
||||
<RecommendBucketsTable
|
||||
rows={derivedRows}
|
||||
hasAllocation={hasAllocation}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,6 +30,11 @@ const SECTIONS: { title: string; routes: Route[] }[] = [
|
|||
label: "Карточка ЖК (пример: Парк Победы)",
|
||||
desc: "KPI · sale-chart · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.",
|
||||
},
|
||||
{
|
||||
href: "/analytics/recommend",
|
||||
label: "Рекомендатор квартирографии",
|
||||
desc: "Район + площадь + класс → распределение по 5 бакетам, цены p25/median/p75, выручка, comparable ЖК. Слайдеры под проект.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const TABS = [
|
|||
{ href: "/analytics", label: "Свердл рынок" },
|
||||
{ href: "/analytics/prinzip", label: "PRINZIP" },
|
||||
{ href: "/analytics/developers", label: "Девелоперы" },
|
||||
{ href: "/analytics/recommend", label: "Рекомендатор" },
|
||||
];
|
||||
|
||||
export function AnalyticsNav() {
|
||||
|
|
|
|||
63
frontend/src/components/analytics/RecommendBucketsChart.tsx
Normal file
63
frontend/src/components/analytics/RecommendBucketsChart.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ChartShell } from "./ChartShell";
|
||||
|
||||
interface Props {
|
||||
bucketLabels: string[];
|
||||
recommended: number[];
|
||||
current: number[];
|
||||
}
|
||||
|
||||
export function RecommendBucketsChart({
|
||||
bucketLabels,
|
||||
recommended,
|
||||
current,
|
||||
}: Props) {
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
valueFormatter: (v: unknown) =>
|
||||
typeof v === "number" ? `${v.toFixed(1)}%` : "—",
|
||||
},
|
||||
legend: { data: ["Рекомендация", "Ваше распределение"], top: 0 },
|
||||
grid: { left: 100, right: 24, top: 32, bottom: 32 },
|
||||
xAxis: {
|
||||
type: "value",
|
||||
max: 100,
|
||||
axisLabel: { formatter: "{value}%" },
|
||||
},
|
||||
yAxis: { type: "category", data: bucketLabels, inverse: true },
|
||||
series: [
|
||||
{
|
||||
name: "Рекомендация",
|
||||
type: "bar",
|
||||
data: recommended,
|
||||
itemStyle: { color: "#cbd5e1" },
|
||||
barGap: "-100%",
|
||||
z: 1,
|
||||
},
|
||||
{
|
||||
name: "Ваше распределение",
|
||||
type: "bar",
|
||||
data: current,
|
||||
itemStyle: { color: "#1d4ed8" },
|
||||
z: 2,
|
||||
label: {
|
||||
show: true,
|
||||
position: "right",
|
||||
formatter: (p: { value: number }) =>
|
||||
p.value > 0 ? `${p.value.toFixed(1)}%` : "",
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[bucketLabels, recommended, current],
|
||||
);
|
||||
|
||||
return <ChartShell option={option} height={280} notMerge />;
|
||||
}
|
||||
91
frontend/src/components/analytics/RecommendBucketsTable.tsx
Normal file
91
frontend/src/components/analytics/RecommendBucketsTable.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import type { RecommendBucket } from "@/types/analytics";
|
||||
|
||||
interface DerivedBucket extends RecommendBucket {
|
||||
effective_share_pct: number;
|
||||
effective_units: number | null;
|
||||
effective_revenue_rub: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: DerivedBucket[];
|
||||
hasAllocation: boolean;
|
||||
}
|
||||
|
||||
const fmtInt = (n: number | null | undefined) =>
|
||||
n == null ? "—" : Math.round(n).toLocaleString("ru");
|
||||
const fmtMln = (rub: number | null | undefined) =>
|
||||
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)} млн ₽`;
|
||||
|
||||
export function RecommendBucketsTable({ rows, hasAllocation }: Props) {
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: "#f6f7f9" }}>
|
||||
{[
|
||||
"Бакет",
|
||||
"Доля",
|
||||
"Сделок",
|
||||
"Площадь ср., м²",
|
||||
"Цена p25, ₽/м²",
|
||||
"Цена медиана, ₽/м²",
|
||||
"Цена p75, ₽/м²",
|
||||
...(hasAllocation ? ["Юнитов", "Выручка"] : []),
|
||||
].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
textAlign: "left",
|
||||
fontWeight: 600,
|
||||
borderBottom: "1px solid #e6e8ec",
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr
|
||||
key={r.bucket}
|
||||
style={{
|
||||
borderBottom: "1px solid #eef0f3",
|
||||
background: i % 2 ? "#fafbfc" : "#fff",
|
||||
}}
|
||||
>
|
||||
<td style={td}>
|
||||
<strong>{r.bucket}</strong>
|
||||
</td>
|
||||
<td style={td}>{r.effective_share_pct.toFixed(1)}%</td>
|
||||
<td style={td}>{fmtInt(r.deal_count)}</td>
|
||||
<td style={td}>{r.area_avg_m2.toFixed(1)}</td>
|
||||
<td style={td}>{fmtInt(r.price_p25_per_m2)}</td>
|
||||
<td style={{ ...td, fontWeight: 600 }}>
|
||||
{fmtInt(r.price_median_per_m2)}
|
||||
</td>
|
||||
<td style={td}>{fmtInt(r.price_p75_per_m2)}</td>
|
||||
{hasAllocation ? (
|
||||
<>
|
||||
<td style={td}>{fmtInt(r.effective_units)}</td>
|
||||
<td style={td}>{fmtMln(r.effective_revenue_rub)}</td>
|
||||
</>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const td: React.CSSProperties = { padding: "8px 10px" };
|
||||
86
frontend/src/components/analytics/RecommendComparables.tsx
Normal file
86
frontend/src/components/analytics/RecommendComparables.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import type { RecommendComparable } from "@/types/analytics";
|
||||
|
||||
export function RecommendComparables({
|
||||
items,
|
||||
}: {
|
||||
items: RecommendComparable[];
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#9ca3af", fontSize: 13 }}>
|
||||
Нет comparable ЖК с такими фильтрами.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{items.map((c) => {
|
||||
const sold = c.sold_perc;
|
||||
const soldColor =
|
||||
sold == null
|
||||
? "#9ca3af"
|
||||
: sold >= 50
|
||||
? "#0a7a3a"
|
||||
: sold >= 30
|
||||
? "#9a6700"
|
||||
: "#b3261e";
|
||||
return (
|
||||
<Link
|
||||
key={c.obj_id}
|
||||
href={`/analytics/objects/${c.obj_id}`}
|
||||
style={{
|
||||
border: "1px solid #e6e8ec",
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
background: "#fff",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
display: "block",
|
||||
transition: "border-color 120ms",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
marginBottom: 6,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{c.comm_name ?? `ЖК #${c.obj_id}`}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#5b6066", marginBottom: 8 }}>
|
||||
{c.dev_name ?? "—"}
|
||||
{c.obj_class ? ` · ${c.obj_class}` : ""}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Квартир: <strong>{c.flat_count ?? "—"}</strong>
|
||||
</span>
|
||||
<span style={{ color: soldColor, fontWeight: 600 }}>
|
||||
Sold: {sold == null ? "—" : `${sold.toFixed(0)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
frontend/src/components/analytics/RecommendForm.tsx
Normal file
197
frontend/src/components/analytics/RecommendForm.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"use client";
|
||||
|
||||
import { useDistricts } from "@/lib/analytics-api";
|
||||
import type { RecommendClass, RecommendMixInput } from "@/types/analytics";
|
||||
|
||||
const CLASSES: RecommendClass[] = ["Comfort", "Comfort+", "Business", "Elite"];
|
||||
const MONTHS_OPTIONS = [12, 18, 24];
|
||||
|
||||
interface Props {
|
||||
value: RecommendMixInput;
|
||||
onChange: (next: RecommendMixInput) => void;
|
||||
onSubmit: () => void;
|
||||
isPending: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function RecommendForm({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isPending,
|
||||
error,
|
||||
}: Props) {
|
||||
const districts = useDistricts();
|
||||
const districtOptions = (districts.data ?? []).filter(
|
||||
(d) => d.district_name !== "не определён",
|
||||
);
|
||||
|
||||
const valid = value.district_name.length >= 2;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (valid && !isPending) onSubmit();
|
||||
}}
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e6e8ec",
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Параметры участка</h2>
|
||||
|
||||
<label>
|
||||
<span style={labelStyle}>Район ЕКБ</span>
|
||||
<select
|
||||
value={value.district_name}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, district_name: e.target.value })
|
||||
}
|
||||
style={inputStyle}
|
||||
required
|
||||
>
|
||||
<option value="">— выберите район —</option>
|
||||
{districtOptions.map((d) => (
|
||||
<option key={d.district_name} value={d.district_name}>
|
||||
{d.district_name} · {d.zk_count} ЖК ·{" "}
|
||||
{d.flat_count?.toLocaleString("ru")} кв
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span style={labelStyle}>Площадь продаваемых м² (опц.)</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={100}
|
||||
max={500_000}
|
||||
step={100}
|
||||
placeholder="50000"
|
||||
value={value.area_total_m2 ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
area_total_m2:
|
||||
e.target.value === "" ? null : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<span style={hintStyle}>
|
||||
Без этого поля посчитаем только распределение и цены, без
|
||||
allocation/выручки.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span style={labelStyle}>Класс ЖК (опц.)</span>
|
||||
<select
|
||||
value={value.target_class ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
target_class:
|
||||
e.target.value === ""
|
||||
? null
|
||||
: (e.target.value as RecommendClass),
|
||||
})
|
||||
}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— любой —</option>
|
||||
{CLASSES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span style={labelStyle}>Окно сделок</span>
|
||||
<select
|
||||
value={value.months_window}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, months_window: Number(e.target.value) })
|
||||
}
|
||||
style={inputStyle}
|
||||
>
|
||||
{MONTHS_OPTIONS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
последние {m} мес
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={hintStyle}>
|
||||
При <30 сделок в любом бакете окно автоматически расширяется до 24
|
||||
мес.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!valid || isPending}
|
||||
style={primaryBtn(valid && !isPending)}
|
||||
>
|
||||
{isPending ? "Считаю…" : "Рассчитать"}
|
||||
</button>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 6,
|
||||
color: "#991b1b",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
color: "#5b6066",
|
||||
marginBottom: 4,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
};
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: "8px 10px",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
const hintStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
color: "#73767e",
|
||||
marginTop: 4,
|
||||
};
|
||||
const primaryBtn = (enabled: boolean): React.CSSProperties => ({
|
||||
padding: "10px 18px",
|
||||
background: enabled ? "#1d4ed8" : "#9ca3af",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: enabled ? "pointer" : "not-allowed",
|
||||
marginTop: 4,
|
||||
});
|
||||
48
frontend/src/components/analytics/RecommendRevenueChart.tsx
Normal file
48
frontend/src/components/analytics/RecommendRevenueChart.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ChartShell } from "./ChartShell";
|
||||
|
||||
interface Props {
|
||||
bucketLabels: string[];
|
||||
revenuesRub: (number | null)[];
|
||||
}
|
||||
|
||||
const COLORS = ["#94a3b8", "#1d4ed8", "#0891b2", "#0a7a3a", "#c2410c"];
|
||||
|
||||
export function RecommendRevenueChart({ bucketLabels, revenuesRub }: Props) {
|
||||
const option = useMemo(() => {
|
||||
const valuesMln = revenuesRub.map((v) =>
|
||||
v == null ? 0 : Math.round((v / 1_000_000) * 10) / 10,
|
||||
);
|
||||
const total = valuesMln.reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
valueFormatter: (v: unknown) =>
|
||||
typeof v === "number" ? `${v.toFixed(1)} млн ₽` : "—",
|
||||
},
|
||||
grid: { left: 60, right: 32, top: 24, bottom: 32 },
|
||||
xAxis: { type: "value", axisLabel: { formatter: "{value} млн" } },
|
||||
yAxis: { type: "category", data: ["Выручка"] },
|
||||
series: bucketLabels.map((label, i) => ({
|
||||
name: label,
|
||||
type: "bar",
|
||||
stack: "rev",
|
||||
data: [valuesMln[i] ?? 0],
|
||||
itemStyle: { color: COLORS[i % COLORS.length] },
|
||||
emphasis: { focus: "series" },
|
||||
label: {
|
||||
show: (valuesMln[i] ?? 0) >= total * 0.05,
|
||||
formatter: (p: { value: number; seriesName: string }) =>
|
||||
`${p.seriesName}\n${p.value.toFixed(1)} млн`,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}, [bucketLabels, revenuesRub]);
|
||||
|
||||
return <ChartShell option={option} height={120} notMerge />;
|
||||
}
|
||||
127
frontend/src/components/analytics/RecommendShareSliders.tsx
Normal file
127
frontend/src/components/analytics/RecommendShareSliders.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
interface Props {
|
||||
bucketLabels: string[];
|
||||
shares: number[]; // длина = bucketLabels.length, сумма ≈ 100
|
||||
recommended: number[]; // оригинальные share_pct из API
|
||||
onChange: (next: number[]) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-rescale slider group. Когда пользователь двигает один слайдер до X%,
|
||||
* остальные пересчитываются пропорционально к (100 - X)%, сохраняя их
|
||||
* относительные доли. Если у других нет массы (sum=0) — распределяем поровну.
|
||||
*/
|
||||
export function RecommendShareSliders({
|
||||
bucketLabels,
|
||||
shares,
|
||||
recommended,
|
||||
onChange,
|
||||
onReset,
|
||||
}: Props) {
|
||||
const handle = (idx: number, raw: number) => {
|
||||
const v = Math.max(0, Math.min(100, raw));
|
||||
const next = [...shares];
|
||||
next[idx] = v;
|
||||
const remaining = 100 - v;
|
||||
const others = shares.filter((_, i) => i !== idx);
|
||||
const otherSum = others.reduce((a, b) => a + b, 0);
|
||||
if (otherSum <= 0) {
|
||||
const each = remaining / others.length;
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
if (i !== idx) next[i] = each;
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
if (i !== idx) next[i] = (shares[i] * remaining) / otherSum;
|
||||
}
|
||||
}
|
||||
// round to 1 decimal, fix sum-rounding drift on the changed slider
|
||||
const rounded = next.map((x) => Math.round(x * 10) / 10);
|
||||
const drift = 100 - rounded.reduce((a, b) => a + b, 0);
|
||||
rounded[idx] = Math.round((rounded[idx] + drift) * 10) / 10;
|
||||
onChange(rounded);
|
||||
};
|
||||
|
||||
const dirty = shares.some(
|
||||
(s, i) => Math.abs(s - (recommended[i] ?? 0)) > 0.05,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "#5b6066" }}>
|
||||
Сумма: {shares.reduce((a, b) => a + b, 0).toFixed(1)} %
|
||||
</span>
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={!dirty}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
fontSize: 12,
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 12,
|
||||
background: dirty ? "#fff" : "#f6f7f9",
|
||||
color: dirty ? "#1f2937" : "#9ca3af",
|
||||
cursor: dirty ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
↻ Сбросить к рекомендации
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
{bucketLabels.map((label, i) => {
|
||||
const recValue = recommended[i] ?? 0;
|
||||
const v = shares[i] ?? 0;
|
||||
const delta = v - recValue;
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "140px 1fr 90px",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={v}
|
||||
onChange={(e) => handle(i, Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<div style={{ textAlign: "right", fontSize: 13 }}>
|
||||
<span style={{ fontWeight: 600 }}>{v.toFixed(1)}%</span>
|
||||
{Math.abs(delta) > 0.1 ? (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 11,
|
||||
color: delta > 0 ? "#0a7a3a" : "#b3261e",
|
||||
}}
|
||||
>
|
||||
{delta > 0 ? "+" : ""}
|
||||
{delta.toFixed(1)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "./api";
|
||||
import type {
|
||||
|
|
@ -21,6 +21,8 @@ import type {
|
|||
PrinzipObjectRow,
|
||||
QuartirographyDealsRow,
|
||||
QuartirographyPortfolioRow,
|
||||
RecommendMixInput,
|
||||
RecommendMixOutput,
|
||||
YandexListingsSummary,
|
||||
} from "@/types/analytics";
|
||||
|
||||
|
|
@ -206,3 +208,16 @@ export function useObjectPhotos(objId: number | string, limit = 100) {
|
|||
enabled: !!objId,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
|
||||
|
||||
export function useRecommendMix() {
|
||||
return useMutation({
|
||||
mutationFn: (input: RecommendMixInput) =>
|
||||
apiFetch<RecommendMixOutput>(`${BASE}/recommend/mix`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,63 @@ export interface ObjectPhoto {
|
|||
local_path: string | null;
|
||||
}
|
||||
|
||||
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
|
||||
|
||||
export type RecommendClass = "Comfort" | "Comfort+" | "Business" | "Elite";
|
||||
|
||||
export interface RecommendMixInput {
|
||||
district_name: string;
|
||||
area_total_m2: number | null;
|
||||
target_class: RecommendClass | null;
|
||||
months_window: number;
|
||||
}
|
||||
|
||||
export interface RecommendBucket {
|
||||
bucket: string;
|
||||
share_pct: number;
|
||||
deal_count: number;
|
||||
area_avg_m2: number;
|
||||
area_median_m2: number;
|
||||
price_median_per_m2: number;
|
||||
price_p25_per_m2: number;
|
||||
price_p75_per_m2: number;
|
||||
units_planned: number | null;
|
||||
revenue_planned_rub: number | null;
|
||||
}
|
||||
|
||||
export interface RecommendComparable {
|
||||
obj_id: number;
|
||||
comm_name: string | null;
|
||||
dev_name: string | null;
|
||||
obj_class: string | null;
|
||||
flat_count: number | null;
|
||||
sold_perc: number | null;
|
||||
}
|
||||
|
||||
export interface RecommendMixOutput {
|
||||
scope: {
|
||||
district: string;
|
||||
district_zk_count: number | null;
|
||||
district_median_price_per_m2: number | null;
|
||||
district_factor: number;
|
||||
class_multiplier: number;
|
||||
target_class: string | null;
|
||||
months_window: number;
|
||||
effective_window_months: number;
|
||||
region_code: number;
|
||||
total_deals: number;
|
||||
data_caveat?: string;
|
||||
error?: string;
|
||||
};
|
||||
buckets: RecommendBucket[];
|
||||
summary: {
|
||||
total_revenue_rub: number | null;
|
||||
weighted_avg_price_per_m2: number | null;
|
||||
warnings: string[];
|
||||
};
|
||||
comparables: RecommendComparable[];
|
||||
}
|
||||
|
||||
export interface PrinzipFunnelMonthRow {
|
||||
month: string | null;
|
||||
source: string;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue