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)
|
||||
# Velocity / pricing scenario knobs (live-tuned client-side; backend just
|
||||
# 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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -68,20 +68,9 @@ export default function RecommendPage() {
|
|||
});
|
||||
}, [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]);
|
||||
// totalRevenue и weightedAvgPrice сейчас уже отображаются внутри
|
||||
// RecommendVelocityPanel и headline summary — здесь больше не считаем,
|
||||
// чтобы не дублировать вычисления и держать одну точку правды для KPI.
|
||||
|
||||
const errMsg = mutation.error
|
||||
? mutation.error instanceof Error
|
||||
|
|
@ -190,6 +179,21 @@ export default function RecommendPage() {
|
|||
/>
|
||||
</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
|
||||
|
|
@ -204,43 +208,62 @@ export default function RecommendPage() {
|
|||
</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.district} />
|
||||
<KpiCard
|
||||
label="Сделок в выборке"
|
||||
value={data.scope.total_deals.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="млн ₽"
|
||||
label="Конкуренты в районе"
|
||||
value={data.scope.competitors_count.toString()}
|
||||
hint={
|
||||
hasAllocation
|
||||
? `при цене ×${priceFactor.toFixed(2)}`
|
||||
: "укажи площадь, чтобы посчитать"
|
||||
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="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>
|
||||
|
||||
<Section
|
||||
title="Распределение по бакетам"
|
||||
subtitle="Серым — рекомендация Rosreestr-сделок. Синим — твоё текущее распределение (можно подвинуть слайдерами ниже)."
|
||||
subtitle="Серым — рекомендация Rosreestr-сделок. Синим — твоё текущее распределение (можно подвинуть слайдерами выше)."
|
||||
>
|
||||
<RecommendBucketsChart
|
||||
bucketLabels={data.buckets.map((b) => b.bucket)}
|
||||
|
|
@ -263,19 +286,6 @@ export default function RecommendPage() {
|
|||
</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)."
|
||||
|
|
|
|||
|
|
@ -165,7 +165,9 @@ export function RecommendVelocityPanel({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Price factor slider */}
|
||||
{/* Price factor slider — 0.01..3.0 (от -99% до +200%).
|
||||
min=0.01 (а не 0) чтобы избежать pf^elasticity = ∞ при свободной
|
||||
цене. */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
|
|
@ -178,8 +180,8 @@ export function RecommendVelocityPanel({
|
|||
<span style={{ fontSize: 13, fontWeight: 600 }}>Цена ± к рынку</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.85}
|
||||
max={1.15}
|
||||
min={0.01}
|
||||
max={3.0}
|
||||
step={0.01}
|
||||
value={priceFactor}
|
||||
onChange={(e) => onPriceFactorChange(Number(e.target.value))}
|
||||
|
|
@ -224,7 +226,7 @@ export function RecommendVelocityPanel({
|
|||
{requiredPct}% к рынку).{" "}
|
||||
<button
|
||||
onClick={() =>
|
||||
onPriceFactorChange(Math.max(0.85, Math.min(1.15, required)))
|
||||
onPriceFactorChange(Math.max(0.01, Math.min(3.0, required)))
|
||||
}
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue