fix recommend page
This commit is contained in:
parent
17ad0e18f5
commit
132d57a3f3
3 changed files with 74 additions and 60 deletions
|
|
@ -17,7 +17,9 @@ class RecommendMixInput(BaseModel):
|
||||||
months_window: int = Field(default=12, ge=3, le=36)
|
months_window: int = Field(default=12, ge=3, le=36)
|
||||||
# Velocity / pricing scenario knobs (live-tuned client-side; backend just
|
# Velocity / pricing scenario knobs (live-tuned client-side; backend just
|
||||||
# ships base coefficients so frontend can recompute without round-trips).
|
# ships base coefficients so frontend can recompute without round-trips).
|
||||||
price_factor: float = Field(default=1.0, ge=0.5, le=2.0)
|
# 0.01..3.0 = -99%..+200% к рынку. min=0.01 (а не 0) чтобы избежать
|
||||||
|
# деления на 0 / pf^elasticity = ∞ при «бесплатной» цене.
|
||||||
|
price_factor: float = Field(default=1.0, ge=0.01, le=3.0)
|
||||||
target_months: int | None = Field(default=None, ge=3, le=120)
|
target_months: int | None = Field(default=None, ge=3, le=120)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,20 +68,9 @@ export default function RecommendPage() {
|
||||||
});
|
});
|
||||||
}, [data, effectiveShares, input.area_total_m2]);
|
}, [data, effectiveShares, input.area_total_m2]);
|
||||||
|
|
||||||
const totalRevenue = derivedRows.reduce(
|
// totalRevenue и weightedAvgPrice сейчас уже отображаются внутри
|
||||||
(a, r) => a + (r.effective_revenue_rub ?? 0),
|
// RecommendVelocityPanel и headline summary — здесь больше не считаем,
|
||||||
0,
|
// чтобы не дублировать вычисления и держать одну точку правды для KPI.
|
||||||
);
|
|
||||||
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
|
const errMsg = mutation.error
|
||||||
? mutation.error instanceof Error
|
? mutation.error instanceof Error
|
||||||
|
|
@ -190,6 +179,21 @@ export default function RecommendPage() {
|
||||||
/>
|
/>
|
||||||
</Section>
|
</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 — кумулятивная кривая продаж */}
|
{/* Liquidity chart — кумулятивная кривая продаж */}
|
||||||
{hasAllocation ? (
|
{hasAllocation ? (
|
||||||
<Section
|
<Section
|
||||||
|
|
@ -204,43 +208,62 @@ export default function RecommendPage() {
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* District-specific KPIs — что отличается между районами:
|
||||||
|
competitors, sale_graph покрытие, district/class факторы.
|
||||||
|
Раньше тут были city-wide дубли (Сделок 3815 / Weighted /
|
||||||
|
Выручка) — все одинаковые независимо от района. */}
|
||||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||||
<KpiCard label="Район" value={data.scope.district} />
|
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Сделок в выборке"
|
label="Конкуренты в районе"
|
||||||
value={data.scope.total_deals.toString()}
|
value={data.scope.competitors_count.toString()}
|
||||||
hint={`окно ${data.scope.effective_window_months} мес`}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Weighted ₽/м²"
|
|
||||||
value={
|
|
||||||
weightedAvgPrice
|
|
||||||
? Math.round(
|
|
||||||
weightedAvgPrice * priceFactor,
|
|
||||||
).toLocaleString("ru")
|
|
||||||
: "—"
|
|
||||||
}
|
|
||||||
unit="₽"
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Выручка"
|
|
||||||
value={
|
|
||||||
hasAllocation
|
|
||||||
? `${((totalRevenue * priceFactor) / 1_000_000).toFixed(1)}`
|
|
||||||
: "—"
|
|
||||||
}
|
|
||||||
unit="млн ₽"
|
|
||||||
hint={
|
hint={
|
||||||
hasAllocation
|
data.scope.competitors_scope === "district+class"
|
||||||
? `при цене ×${priceFactor.toFixed(2)}`
|
? `строящихся ${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="District factor"
|
||||||
|
value={`×${data.scope.district_factor.toFixed(2)}`}
|
||||||
|
hint={
|
||||||
|
data.scope.district_factor === 1
|
||||||
|
? "median ekb_districts NULL"
|
||||||
|
: "корректировка к city median"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Class multiplier"
|
||||||
|
value={`×${data.scope.class_multiplier.toFixed(2)}`}
|
||||||
|
hint={
|
||||||
|
data.scope.class_multiplier === 1
|
||||||
|
? data.scope.target_class
|
||||||
|
? `нет данных по ${data.scope.target_class}`
|
||||||
|
: "класс не задан"
|
||||||
|
: `корректировка цен ${data.scope.target_class}`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="Распределение по бакетам"
|
title="Распределение по бакетам"
|
||||||
subtitle="Серым — рекомендация Rosreestr-сделок. Синим — твоё текущее распределение (можно подвинуть слайдерами ниже)."
|
subtitle="Серым — рекомендация Rosreestr-сделок. Синим — твоё текущее распределение (можно подвинуть слайдерами выше)."
|
||||||
>
|
>
|
||||||
<RecommendBucketsChart
|
<RecommendBucketsChart
|
||||||
bucketLabels={data.buckets.map((b) => b.bucket)}
|
bucketLabels={data.buckets.map((b) => b.bucket)}
|
||||||
|
|
@ -263,19 +286,6 @@ export default function RecommendPage() {
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : 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
|
<Section
|
||||||
title="Таблица бакетов"
|
title="Таблица бакетов"
|
||||||
subtitle="p25/median/p75 цен — диапазон рыночной медианы. Цена медиана уже скорректирована на район (district_factor) и класс (class_multiplier)."
|
subtitle="p25/median/p75 цен — диапазон рыночной медианы. Цена медиана уже скорректирована на район (district_factor) и класс (class_multiplier)."
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,9 @@ export function RecommendVelocityPanel({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Price factor slider */}
|
{/* Price factor slider — 0.01..3.0 (от -99% до +200%).
|
||||||
|
min=0.01 (а не 0) чтобы избежать pf^elasticity = ∞ при свободной
|
||||||
|
цене. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
|
|
@ -178,8 +180,8 @@ export function RecommendVelocityPanel({
|
||||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Цена ± к рынку</span>
|
<span style={{ fontSize: 13, fontWeight: 600 }}>Цена ± к рынку</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0.85}
|
min={0.01}
|
||||||
max={1.15}
|
max={3.0}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={priceFactor}
|
value={priceFactor}
|
||||||
onChange={(e) => onPriceFactorChange(Number(e.target.value))}
|
onChange={(e) => onPriceFactorChange(Number(e.target.value))}
|
||||||
|
|
@ -224,7 +226,7 @@ export function RecommendVelocityPanel({
|
||||||
{requiredPct}% к рынку).{" "}
|
{requiredPct}% к рынку).{" "}
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onPriceFactorChange(Math.max(0.85, Math.min(1.15, required)))
|
onPriceFactorChange(Math.max(0.01, Math.min(3.0, required)))
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
padding: "2px 8px",
|
padding: "2px 8px",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue