fix recommend page

This commit is contained in:
lekss361 2026-04-29 08:16:22 +03:00
parent 17ad0e18f5
commit 132d57a3f3
3 changed files with 74 additions and 60 deletions

View file

@ -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)

View file

@ -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)."

View file

@ -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",