fix: heartbeat-based zombie autoclean (10min) + Phase A heartbeats; ship recommend velocity calculator
Scraper resume: - /admin/scrape/queue autoclean уменьшен с 60min started_at до 10min COALESCE(heartbeat_at, started_at). Не режет валидные long sweeps. - Phase A пишет heartbeat после каждого objStatus fetch (3min Phase A больше не рискует быть отмеченной как zombie при новом 10min threshold). Recommend (Уровень 1 калькулятор): - POST /api/v1/analytics/recommend/mix с velocity baseline (sale_graph), price elasticity (regr_slope/r2 на sale_graph, fallback -1.5), inverse mode (target_months → required price_factor), liquidity score 24mo, headline. - /analytics/recommend: RecommendVelocityPanel (price slider 0.85..1.15 + 4 KPI Чек/Срок/Темп/Ликвидность + методология эластичности), RecommendLiquidityChart (cumulative 0..36 mo с пунктиром на 24). - BucketsTable: +колонки Темп и Срок, цены/выручка масштабируются по priceFactor live. - Все слайдеры и target_months считаются клиентски — никаких round-trip. Fix: SQLAlchemy не парсит :cls::text — заменено на CAST(:cls AS TEXT).
This commit is contained in:
parent
1507575698
commit
7a23aa9edc
12 changed files with 884 additions and 40 deletions
|
|
@ -91,20 +91,21 @@ def queue_status(
|
||||||
|
|
||||||
from app.workers.celery_app import celery_app
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
# 0) Reap zombies: workers killed by SIGKILL (OOM, container restart,
|
# 0) Reap zombies by heartbeat. After Resume_Checkpoint refactor the worker
|
||||||
# deploy mid-run) leave kn_scrape_runs rows stuck in 'running' forever
|
# writes heartbeat_at every ~30 sec (per 10 objects). 10 min без heartbeat
|
||||||
# because the except/finally block never fires. Anything older than
|
# = точно мёртв. Используем COALESCE(heartbeat_at, started_at) чтобы
|
||||||
# 60 minutes without finishing is almost certainly dead.
|
# подхватить и legacy-runs (без heartbeat) старше 10 мин.
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE kn_scrape_runs
|
UPDATE kn_scrape_runs
|
||||||
SET status = 'zombie',
|
SET status = 'zombie',
|
||||||
finished_at = NOW(),
|
finished_at = NOW(),
|
||||||
error = 'auto-marked: no heartbeat 60+ min'
|
error = 'auto-marked: no heartbeat 10+ min'
|
||||||
WHERE status = 'running'
|
WHERE status = 'running'
|
||||||
AND finished_at IS NULL
|
AND finished_at IS NULL
|
||||||
AND started_at < NOW() - INTERVAL '60 minutes'
|
AND COALESCE(heartbeat_at, started_at)
|
||||||
|
< NOW() - INTERVAL '10 minutes'
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -203,4 +203,6 @@ def recommend_mix(
|
||||||
area_total_m2=payload.area_total_m2,
|
area_total_m2=payload.area_total_m2,
|
||||||
target_class=payload.target_class,
|
target_class=payload.target_class,
|
||||||
months_window=payload.months_window,
|
months_window=payload.months_window,
|
||||||
|
price_factor=payload.price_factor,
|
||||||
|
target_months=payload.target_months,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ class RecommendMixInput(BaseModel):
|
||||||
area_total_m2: float | None = Field(default=None, ge=100, le=500_000)
|
area_total_m2: float | None = Field(default=None, ge=100, le=500_000)
|
||||||
target_class: ClassLiteral | None = None
|
target_class: ClassLiteral | None = None
|
||||||
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
|
||||||
|
# ships base coefficients so frontend can recompute without round-trips).
|
||||||
|
price_factor: float = Field(default=1.0, ge=0.5, le=2.0)
|
||||||
|
target_months: int | None = Field(default=None, ge=3, le=120)
|
||||||
|
|
||||||
|
|
||||||
class RecommendBucket(BaseModel):
|
class RecommendBucket(BaseModel):
|
||||||
|
|
@ -28,6 +32,11 @@ class RecommendBucket(BaseModel):
|
||||||
price_p75_per_m2: float
|
price_p75_per_m2: float
|
||||||
units_planned: int | None = None
|
units_planned: int | None = None
|
||||||
revenue_planned_rub: float | None = None
|
revenue_planned_rub: float | None = None
|
||||||
|
# Velocity baseline (units/month for THIS project allocated to this bucket
|
||||||
|
# at price_factor=1.0). Frontend scales by price_factor^elasticity for live
|
||||||
|
# what-if recompute.
|
||||||
|
velocity_per_month: float | None = None
|
||||||
|
months_to_sellout: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class RecommendComparable(BaseModel):
|
class RecommendComparable(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1022,6 +1022,143 @@ def _bucket_distribution(db: Session, region_code: int, months_window: int) -> l
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Industry-default elasticity used when sale_graph regression is not reliable
|
||||||
|
# (n<30 or R²<0.1). Negative because higher price ⇒ slower sales.
|
||||||
|
FALLBACK_ELASTICITY = -1.5
|
||||||
|
|
||||||
|
|
||||||
|
def _velocity_baseline(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
region_code: int,
|
||||||
|
district_name: str,
|
||||||
|
target_class: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Median monthly sales velocity (apartments/month per ЖК) from
|
||||||
|
domrf_kn_sale_graph for objects in the same район+class over last 24 mo.
|
||||||
|
|
||||||
|
Returns dict {realised_per_month_median, realised_per_month_avg,
|
||||||
|
objects_count, observations}. All-None means no data → caller falls back.
|
||||||
|
"""
|
||||||
|
where_class = "AND o.obj_class = :cls" if target_class else ""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"rc": region_code,
|
||||||
|
"dn": district_name,
|
||||||
|
}
|
||||||
|
if target_class:
|
||||||
|
params["cls"] = target_class
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
WITH obj_pool AS (
|
||||||
|
SELECT o.obj_id
|
||||||
|
FROM domrf_kn_objects o
|
||||||
|
WHERE o.region_cd = :rc
|
||||||
|
AND o.addr ILIKE '%' || :dn || '%'
|
||||||
|
{where_class}
|
||||||
|
),
|
||||||
|
sg AS (
|
||||||
|
SELECT sg.obj_id, sg.realised
|
||||||
|
FROM domrf_kn_sale_graph sg
|
||||||
|
JOIN obj_pool p ON p.obj_id = sg.obj_id
|
||||||
|
WHERE sg.type = 'apartments'
|
||||||
|
AND sg.realised IS NOT NULL
|
||||||
|
AND sg.report_month >= NOW() - INTERVAL '24 months'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
AVG(realised) AS avg_pm,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY realised) AS median_pm,
|
||||||
|
COUNT(DISTINCT obj_id) AS objects,
|
||||||
|
COUNT(*) AS observations
|
||||||
|
FROM sg
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"realised_per_month_avg": None,
|
||||||
|
"realised_per_month_median": None,
|
||||||
|
"objects_count": 0,
|
||||||
|
"observations": 0,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"realised_per_month_avg": _f(row["avg_pm"]),
|
||||||
|
"realised_per_month_median": _f(row["median_pm"]),
|
||||||
|
"objects_count": int(row["objects"] or 0),
|
||||||
|
"observations": int(row["observations"] or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _elasticity_coef(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
region_code: int,
|
||||||
|
district_name: str,
|
||||||
|
target_class: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fit log-log regression LN(realised) ~ LN(price_avg) on sale_graph
|
||||||
|
observations for the same район+class. Returns elasticity (slope), R²,
|
||||||
|
n. Falls back to FALLBACK_ELASTICITY if data thin or regression weak."""
|
||||||
|
where_class = "AND o.obj_class = :cls" if target_class else ""
|
||||||
|
params: dict[str, Any] = {"rc": region_code, "dn": district_name}
|
||||||
|
if target_class:
|
||||||
|
params["cls"] = target_class
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
WITH obj_pool AS (
|
||||||
|
SELECT o.obj_id
|
||||||
|
FROM domrf_kn_objects o
|
||||||
|
WHERE o.region_cd = :rc
|
||||||
|
AND o.addr ILIKE '%' || :dn || '%'
|
||||||
|
{where_class}
|
||||||
|
),
|
||||||
|
pts AS (
|
||||||
|
SELECT LN(sg.realised)::float8 AS y,
|
||||||
|
LN(sg.price_avg)::float8 AS x
|
||||||
|
FROM domrf_kn_sale_graph sg
|
||||||
|
JOIN obj_pool p ON p.obj_id = sg.obj_id
|
||||||
|
WHERE sg.type = 'apartments'
|
||||||
|
AND sg.realised IS NOT NULL AND sg.realised > 0
|
||||||
|
AND sg.price_avg IS NOT NULL AND sg.price_avg > 0
|
||||||
|
AND sg.report_month >= NOW() - INTERVAL '36 months'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
regr_slope(y, x) AS slope,
|
||||||
|
regr_r2(y, x) AS r2,
|
||||||
|
COUNT(*) AS n
|
||||||
|
FROM pts
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
n = int(row["n"]) if row and row["n"] is not None else 0
|
||||||
|
slope = _f(row["slope"]) if row else None
|
||||||
|
r2 = _f(row["r2"]) if row else None
|
||||||
|
if n >= 30 and slope is not None and r2 is not None and r2 >= 0.1 and slope < 0:
|
||||||
|
return {
|
||||||
|
"elasticity": round(slope, 4),
|
||||||
|
"r2": round(r2, 4),
|
||||||
|
"n": n,
|
||||||
|
"source": "regression",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"elasticity": FALLBACK_ELASTICITY,
|
||||||
|
"r2": r2 or 0.0,
|
||||||
|
"n": n,
|
||||||
|
"source": "fallback",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def recommend_mix(
|
def recommend_mix(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1030,6 +1167,8 @@ def recommend_mix(
|
||||||
target_class: str | None = None,
|
target_class: str | None = None,
|
||||||
months_window: int = 12,
|
months_window: int = 12,
|
||||||
region_code: int = 66,
|
region_code: int = 66,
|
||||||
|
price_factor: float = 1.0,
|
||||||
|
target_months: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Rule-based квартирография recommender.
|
"""Rule-based квартирография recommender.
|
||||||
|
|
||||||
|
|
@ -1198,6 +1337,103 @@ def recommend_mix(
|
||||||
|
|
||||||
weighted_avg_price = round(weighted_num / weighted_den, 2) if weighted_den > 0 else None
|
weighted_avg_price = round(weighted_num / weighted_den, 2) if weighted_den > 0 else None
|
||||||
|
|
||||||
|
# 5b) Velocity baseline (apartments/month per ЖК) + price elasticity.
|
||||||
|
# Both are required for the live "цена↔темп" calculator on the frontend.
|
||||||
|
vel = _velocity_baseline(
|
||||||
|
db,
|
||||||
|
region_code=region_code,
|
||||||
|
district_name=district_row["district_name"],
|
||||||
|
target_class=target_class,
|
||||||
|
)
|
||||||
|
market_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"]
|
||||||
|
if market_vel_pm is None:
|
||||||
|
# Fallback: derive from city-wide rosreestr deals (distribute per bucket
|
||||||
|
# by share). Coarser, but lets the calculator work anywhere.
|
||||||
|
warnings.append(
|
||||||
|
"Нет sale_graph данных для этого района и класса —"
|
||||||
|
" темп считается по rosreestr-сделкам (грубее)."
|
||||||
|
)
|
||||||
|
market_vel_pm = (total_deals / max(effective_window, 1)) if total_deals else 0.0
|
||||||
|
velocity_source = "rosreestr_fallback"
|
||||||
|
else:
|
||||||
|
velocity_source = "sale_graph"
|
||||||
|
|
||||||
|
elast = _elasticity_coef(
|
||||||
|
db,
|
||||||
|
region_code=region_code,
|
||||||
|
district_name=district_row["district_name"],
|
||||||
|
target_class=target_class,
|
||||||
|
)
|
||||||
|
elasticity = elast["elasticity"]
|
||||||
|
if elast["source"] == "fallback":
|
||||||
|
warnings.append(
|
||||||
|
f"Эластичность цена↔темп взята по умолчанию ({elasticity})"
|
||||||
|
f" — sale_graph даёт n={elast['n']}, R²={round(elast['r2'], 2)}"
|
||||||
|
" (недостаточно для регрессии)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-bucket velocity at price_factor=1.0:
|
||||||
|
# share — рыночная доля бакета по сделкам Rosreestr, market_vel_pm —
|
||||||
|
# темп сопоставимого ЖК. velocity_per_bucket = market_vel_pm × share/100.
|
||||||
|
# При allocation: months_to_sellout = units_planned / (velocity × price_factor^elasticity).
|
||||||
|
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
|
||||||
|
total_units = 0
|
||||||
|
for b in buckets:
|
||||||
|
bucket_velocity = round(market_vel_pm * (b["share_pct"] / 100), 3)
|
||||||
|
b["velocity_per_month"] = bucket_velocity
|
||||||
|
if b["units_planned"] and bucket_velocity > 0:
|
||||||
|
adjusted_velocity = bucket_velocity * pf_pow
|
||||||
|
b["months_to_sellout"] = (
|
||||||
|
round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None
|
||||||
|
)
|
||||||
|
total_units += b["units_planned"]
|
||||||
|
else:
|
||||||
|
b["months_to_sellout"] = None
|
||||||
|
|
||||||
|
# 5c) Inverse mode: target_months → required price_factor.
|
||||||
|
# required_velocity = total_units / target_months
|
||||||
|
# base_velocity_total = sum(bucket_velocity) (at price_factor=1)
|
||||||
|
# required_pf^elasticity = required_velocity / base_velocity_total
|
||||||
|
# → required_pf = (required_velocity / base_velocity_total)^(1/elasticity)
|
||||||
|
required_price_factor: float | None = None
|
||||||
|
if target_months and total_units > 0:
|
||||||
|
base_total_velocity = sum(b["velocity_per_month"] or 0 for b in buckets)
|
||||||
|
if base_total_velocity > 0 and elasticity != 0:
|
||||||
|
required_velocity = total_units / target_months
|
||||||
|
ratio = required_velocity / base_total_velocity
|
||||||
|
try:
|
||||||
|
required_price_factor = round(ratio ** (1.0 / elasticity), 4)
|
||||||
|
except Exception:
|
||||||
|
required_price_factor = None
|
||||||
|
if required_price_factor and required_price_factor < 0.7:
|
||||||
|
warnings.append(
|
||||||
|
f"Целевой срок {target_months} мес требует скидки"
|
||||||
|
f" >{round((1 - required_price_factor) * 100)}% — рассмотри"
|
||||||
|
" сдвиг ассортимента в сторону ликвидных бакетов."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5d) Liquidity score (0-100): % units sold within 24 months.
|
||||||
|
liquidity_24mo: float | None = None
|
||||||
|
if total_units > 0:
|
||||||
|
sold_24mo = 0.0
|
||||||
|
for b in buckets:
|
||||||
|
mts = b["months_to_sellout"]
|
||||||
|
up = b["units_planned"] or 0
|
||||||
|
if up <= 0 or mts is None or mts <= 0:
|
||||||
|
continue
|
||||||
|
frac = min(1.0, 24.0 / mts)
|
||||||
|
sold_24mo += frac * up
|
||||||
|
liquidity_24mo = round(sold_24mo / total_units * 100, 1)
|
||||||
|
|
||||||
|
# 5e) Aggregate KPIs
|
||||||
|
months_to_sellout_total: float | None = None
|
||||||
|
base_total_v = sum(b["velocity_per_month"] or 0 for b in buckets)
|
||||||
|
if total_units > 0 and base_total_v > 0:
|
||||||
|
months_to_sellout_total = round(total_units / (base_total_v * pf_pow), 1)
|
||||||
|
avg_ticket = (
|
||||||
|
round(total_revenue / total_units, 2) if (have_revenue and total_units > 0) else None
|
||||||
|
)
|
||||||
|
|
||||||
# 6) Comparable ЖК — same district (parsed from addr) and class
|
# 6) Comparable ЖК — same district (parsed from addr) and class
|
||||||
cmp_rows = (
|
cmp_rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -1230,6 +1466,20 @@ def recommend_mix(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 7) Headline для CEO — одна строка с тремя главными цифрами
|
||||||
|
headline_parts: list[str] = []
|
||||||
|
if have_revenue:
|
||||||
|
headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽")
|
||||||
|
if months_to_sellout_total:
|
||||||
|
headline_parts.append(f"за ~{int(months_to_sellout_total)} мес")
|
||||||
|
if avg_ticket:
|
||||||
|
headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М ₽")
|
||||||
|
if base_total_v > 0:
|
||||||
|
headline_parts.append(f"темп {round(base_total_v * pf_pow, 1)} кв/мес")
|
||||||
|
if liquidity_24mo is not None:
|
||||||
|
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
||||||
|
headline = " · ".join(headline_parts) if headline_parts else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"scope": {
|
"scope": {
|
||||||
"district": district_row["district_name"],
|
"district": district_row["district_name"],
|
||||||
|
|
@ -1242,6 +1492,19 @@ def recommend_mix(
|
||||||
"effective_window_months": effective_window,
|
"effective_window_months": effective_window,
|
||||||
"region_code": region_code,
|
"region_code": region_code,
|
||||||
"total_deals": total_deals if bucket_rows else 0,
|
"total_deals": total_deals if bucket_rows else 0,
|
||||||
|
"market_velocity_per_month": (
|
||||||
|
round(market_vel_pm, 3) if market_vel_pm is not None else None
|
||||||
|
),
|
||||||
|
"velocity_source": velocity_source,
|
||||||
|
"velocity_observations": vel["observations"],
|
||||||
|
"velocity_objects": vel["objects_count"],
|
||||||
|
"elasticity": elasticity,
|
||||||
|
"elasticity_r2": elast["r2"],
|
||||||
|
"elasticity_n": elast["n"],
|
||||||
|
"elasticity_source": elast["source"],
|
||||||
|
"price_factor_applied": round(price_factor, 4),
|
||||||
|
"required_price_factor": required_price_factor,
|
||||||
|
"target_months": target_months,
|
||||||
"data_caveat": (
|
"data_caveat": (
|
||||||
"MVP: bucket-распределение город-wide (регион 66). Район влияет"
|
"MVP: bucket-распределение город-wide (регион 66). Район влияет"
|
||||||
" только на ценовой коэффициент. v2 добавит per-district demand"
|
" только на ценовой коэффициент. v2 добавит per-district demand"
|
||||||
|
|
@ -1252,6 +1515,11 @@ def recommend_mix(
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_revenue_rub": round(total_revenue, 2) if have_revenue else None,
|
"total_revenue_rub": round(total_revenue, 2) if have_revenue else None,
|
||||||
"weighted_avg_price_per_m2": weighted_avg_price,
|
"weighted_avg_price_per_m2": weighted_avg_price,
|
||||||
|
"total_units_planned": total_units if total_units > 0 else None,
|
||||||
|
"months_to_sellout_total": months_to_sellout_total,
|
||||||
|
"avg_ticket_rub": avg_ticket,
|
||||||
|
"liquidity_score_24mo": liquidity_24mo,
|
||||||
|
"headline": headline,
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
},
|
},
|
||||||
"comparables": [
|
"comparables": [
|
||||||
|
|
|
||||||
|
|
@ -1106,6 +1106,11 @@ async def run_region_sweep(
|
||||||
f"objStatus={status}: получено {len(rows)} объектов",
|
f"objStatus={status}: получено {len(rows)} объектов",
|
||||||
stage="fetch_objects",
|
stage="fetch_objects",
|
||||||
)
|
)
|
||||||
|
# Heartbeat-tick: Phase A для 1516 ЖК идёт ~3 мин, autoclean
|
||||||
|
# пропустит это окно если хотя бы каждые ~1 мин обновлять
|
||||||
|
# heartbeat_at. progress_obj_index пока 0 — snapshot ещё
|
||||||
|
# не сохранён.
|
||||||
|
_checkpoint(db, run_id, 0)
|
||||||
logger.info(
|
logger.info(
|
||||||
"place=%s total objects across %s = %d",
|
"place=%s total objects across %s = %d",
|
||||||
place,
|
place,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import { RecommendBucketsChart } from "@/components/analytics/RecommendBucketsCh
|
||||||
import { RecommendBucketsTable } from "@/components/analytics/RecommendBucketsTable";
|
import { RecommendBucketsTable } from "@/components/analytics/RecommendBucketsTable";
|
||||||
import { RecommendComparables } from "@/components/analytics/RecommendComparables";
|
import { RecommendComparables } from "@/components/analytics/RecommendComparables";
|
||||||
import { RecommendForm } from "@/components/analytics/RecommendForm";
|
import { RecommendForm } from "@/components/analytics/RecommendForm";
|
||||||
|
import { RecommendLiquidityChart } from "@/components/analytics/RecommendLiquidityChart";
|
||||||
import { RecommendRevenueChart } from "@/components/analytics/RecommendRevenueChart";
|
import { RecommendRevenueChart } from "@/components/analytics/RecommendRevenueChart";
|
||||||
import { RecommendShareSliders } from "@/components/analytics/RecommendShareSliders";
|
import { RecommendShareSliders } from "@/components/analytics/RecommendShareSliders";
|
||||||
|
import { RecommendVelocityPanel } from "@/components/analytics/RecommendVelocityPanel";
|
||||||
import { Section } from "@/components/analytics/Section";
|
import { Section } from "@/components/analytics/Section";
|
||||||
import { useRecommendMix } from "@/lib/analytics-api";
|
import { useRecommendMix } from "@/lib/analytics-api";
|
||||||
import type { RecommendMixInput } from "@/types/analytics";
|
import type { RecommendMixInput } from "@/types/analytics";
|
||||||
|
|
@ -18,17 +20,25 @@ const DEFAULT_INPUT: RecommendMixInput = {
|
||||||
area_total_m2: null,
|
area_total_m2: null,
|
||||||
target_class: null,
|
target_class: null,
|
||||||
months_window: 12,
|
months_window: 12,
|
||||||
|
price_factor: 1.0,
|
||||||
|
target_months: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RecommendPage() {
|
export default function RecommendPage() {
|
||||||
const [input, setInput] = useState<RecommendMixInput>(DEFAULT_INPUT);
|
const [input, setInput] = useState<RecommendMixInput>(DEFAULT_INPUT);
|
||||||
const [overrideShares, setOverrideShares] = useState<number[] | null>(null);
|
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 mutation = useRecommendMix();
|
||||||
const data = mutation.data;
|
const data = mutation.data;
|
||||||
|
|
||||||
// reset slider override whenever a fresh API response arrives
|
// reset slider override whenever a fresh API response arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) setOverrideShares(null);
|
if (data) {
|
||||||
|
setOverrideShares(null);
|
||||||
|
setPriceFactor(1.0);
|
||||||
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const recommendedShares = useMemo(
|
const recommendedShares = useMemo(
|
||||||
|
|
@ -141,6 +151,59 @@ export default function RecommendPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Headline для CEO — одна строка с тремя главными цифрами */}
|
||||||
|
{data.summary.headline ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#0f172a",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "14px 18px",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
💼 <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>
|
||||||
|
) : 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>
|
||||||
|
|
||||||
|
{/* Liquidity chart — кумулятивная кривая продаж */}
|
||||||
|
{hasAllocation ? (
|
||||||
|
<Section
|
||||||
|
title="Кумулятивные продажи (горизонт 36 мес)"
|
||||||
|
subtitle="Сколько % инвентаря продастся к месяцу X при текущей цене. Пунктир — горизонт 24 мес = liquidity score."
|
||||||
|
>
|
||||||
|
<RecommendLiquidityChart
|
||||||
|
rows={derivedRows}
|
||||||
|
priceFactor={priceFactor}
|
||||||
|
elasticity={data.scope.elasticity}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||||
<KpiCard label="Район" value={data.scope.district} />
|
<KpiCard label="Район" value={data.scope.district} />
|
||||||
<KpiCard
|
<KpiCard
|
||||||
|
|
@ -152,7 +215,9 @@ export default function RecommendPage() {
|
||||||
label="Weighted ₽/м²"
|
label="Weighted ₽/м²"
|
||||||
value={
|
value={
|
||||||
weightedAvgPrice
|
weightedAvgPrice
|
||||||
? Math.round(weightedAvgPrice).toLocaleString("ru")
|
? Math.round(
|
||||||
|
weightedAvgPrice * priceFactor,
|
||||||
|
).toLocaleString("ru")
|
||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
unit="₽"
|
unit="₽"
|
||||||
|
|
@ -161,13 +226,13 @@ export default function RecommendPage() {
|
||||||
label="Выручка"
|
label="Выручка"
|
||||||
value={
|
value={
|
||||||
hasAllocation
|
hasAllocation
|
||||||
? `${(totalRevenue / 1_000_000).toFixed(1)}`
|
? `${((totalRevenue * priceFactor) / 1_000_000).toFixed(1)}`
|
||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
unit="млн ₽"
|
unit="млн ₽"
|
||||||
hint={
|
hint={
|
||||||
hasAllocation
|
hasAllocation
|
||||||
? "при текущем распределении"
|
? `при цене ×${priceFactor.toFixed(2)}`
|
||||||
: "укажи площадь, чтобы посчитать"
|
: "укажи площадь, чтобы посчитать"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -218,6 +283,8 @@ export default function RecommendPage() {
|
||||||
<RecommendBucketsTable
|
<RecommendBucketsTable
|
||||||
rows={derivedRows}
|
rows={derivedRows}
|
||||||
hasAllocation={hasAllocation}
|
hasAllocation={hasAllocation}
|
||||||
|
priceFactor={priceFactor}
|
||||||
|
elasticity={data.scope.elasticity}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ interface DerivedBucket extends RecommendBucket {
|
||||||
interface Props {
|
interface Props {
|
||||||
rows: DerivedBucket[];
|
rows: DerivedBucket[];
|
||||||
hasAllocation: boolean;
|
hasAllocation: boolean;
|
||||||
|
priceFactor?: number;
|
||||||
|
elasticity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtInt = (n: number | null | undefined) =>
|
const fmtInt = (n: number | null | undefined) =>
|
||||||
|
|
@ -18,7 +20,13 @@ const fmtInt = (n: number | null | undefined) =>
|
||||||
const fmtMln = (rub: number | null | undefined) =>
|
const fmtMln = (rub: number | null | undefined) =>
|
||||||
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)} млн ₽`;
|
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)} млн ₽`;
|
||||||
|
|
||||||
export function RecommendBucketsTable({ rows, hasAllocation }: Props) {
|
export function RecommendBucketsTable({
|
||||||
|
rows,
|
||||||
|
hasAllocation,
|
||||||
|
priceFactor = 1,
|
||||||
|
elasticity = -1.5,
|
||||||
|
}: Props) {
|
||||||
|
const pfPow = priceFactor > 0 ? priceFactor ** elasticity : 1;
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: "auto" }}>
|
<div style={{ overflowX: "auto" }}>
|
||||||
<table
|
<table
|
||||||
|
|
@ -38,7 +46,8 @@ export function RecommendBucketsTable({ rows, hasAllocation }: Props) {
|
||||||
"Цена p25, ₽/м²",
|
"Цена p25, ₽/м²",
|
||||||
"Цена медиана, ₽/м²",
|
"Цена медиана, ₽/м²",
|
||||||
"Цена p75, ₽/м²",
|
"Цена p75, ₽/м²",
|
||||||
...(hasAllocation ? ["Юнитов", "Выручка"] : []),
|
"Темп, кв/мес",
|
||||||
|
...(hasAllocation ? ["Юнитов", "Срок, мес", "Выручка"] : []),
|
||||||
].map((h) => (
|
].map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
|
|
@ -55,33 +64,57 @@ export function RecommendBucketsTable({ rows, hasAllocation }: Props) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r, i) => (
|
{rows.map((r, i) => {
|
||||||
<tr
|
const adjVelocity = (r.velocity_per_month ?? 0) * pfPow;
|
||||||
key={r.bucket}
|
const months =
|
||||||
style={{
|
r.effective_units && adjVelocity > 0
|
||||||
borderBottom: "1px solid #eef0f3",
|
? r.effective_units / adjVelocity
|
||||||
background: i % 2 ? "#fafbfc" : "#fff",
|
: null;
|
||||||
}}
|
const adjPriceMedian = r.price_median_per_m2 * priceFactor;
|
||||||
>
|
const adjP25 = r.price_p25_per_m2 * priceFactor;
|
||||||
<td style={td}>
|
const adjP75 = r.price_p75_per_m2 * priceFactor;
|
||||||
<strong>{r.bucket}</strong>
|
const adjRevenue =
|
||||||
</td>
|
r.effective_revenue_rub != null
|
||||||
<td style={td}>{r.effective_share_pct.toFixed(1)}%</td>
|
? r.effective_revenue_rub * priceFactor
|
||||||
<td style={td}>{fmtInt(r.deal_count)}</td>
|
: null;
|
||||||
<td style={td}>{r.area_avg_m2.toFixed(1)}</td>
|
return (
|
||||||
<td style={td}>{fmtInt(r.price_p25_per_m2)}</td>
|
<tr
|
||||||
<td style={{ ...td, fontWeight: 600 }}>
|
key={r.bucket}
|
||||||
{fmtInt(r.price_median_per_m2)}
|
style={{
|
||||||
</td>
|
borderBottom: "1px solid #eef0f3",
|
||||||
<td style={td}>{fmtInt(r.price_p75_per_m2)}</td>
|
background: i % 2 ? "#fafbfc" : "#fff",
|
||||||
{hasAllocation ? (
|
}}
|
||||||
<>
|
>
|
||||||
<td style={td}>{fmtInt(r.effective_units)}</td>
|
<td style={td}>
|
||||||
<td style={td}>{fmtMln(r.effective_revenue_rub)}</td>
|
<strong>{r.bucket}</strong>
|
||||||
</>
|
</td>
|
||||||
) : null}
|
<td style={td}>{r.effective_share_pct.toFixed(1)}%</td>
|
||||||
</tr>
|
<td style={td}>{fmtInt(r.deal_count)}</td>
|
||||||
))}
|
<td style={td}>{r.area_avg_m2.toFixed(1)}</td>
|
||||||
|
<td style={td}>{fmtInt(adjP25)}</td>
|
||||||
|
<td style={{ ...td, fontWeight: 600 }}>
|
||||||
|
{fmtInt(adjPriceMedian)}
|
||||||
|
</td>
|
||||||
|
<td style={td}>{fmtInt(adjP75)}</td>
|
||||||
|
<td style={td}>
|
||||||
|
{adjVelocity > 0 ? adjVelocity.toFixed(1) : "—"}
|
||||||
|
</td>
|
||||||
|
{hasAllocation ? (
|
||||||
|
<>
|
||||||
|
<td style={td}>{fmtInt(r.effective_units)}</td>
|
||||||
|
<td style={td}>
|
||||||
|
{months == null
|
||||||
|
? "—"
|
||||||
|
: months > 60
|
||||||
|
? "60+"
|
||||||
|
: months.toFixed(1)}
|
||||||
|
</td>
|
||||||
|
<td style={td}>{fmtMln(adjRevenue)}</td>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,30 @@ export function RecommendForm({
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span style={labelStyle}>Целевой срок реализации (опц.)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={3}
|
||||||
|
max={120}
|
||||||
|
step={1}
|
||||||
|
placeholder="например 18"
|
||||||
|
value={value.target_months ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
target_months:
|
||||||
|
e.target.value === "" ? null : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<span style={hintStyle}>
|
||||||
|
Если задан — система предложит требуемый коэффициент к рынку.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!valid || isPending}
|
disabled={!valid || isPending}
|
||||||
|
|
|
||||||
106
frontend/src/components/analytics/RecommendLiquidityChart.tsx
Normal file
106
frontend/src/components/analytics/RecommendLiquidityChart.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { RecommendBucket } from "@/types/analytics";
|
||||||
|
|
||||||
|
import { ChartShell } from "./ChartShell";
|
||||||
|
|
||||||
|
interface DerivedBucket extends RecommendBucket {
|
||||||
|
effective_share_pct: number;
|
||||||
|
effective_units: number | null;
|
||||||
|
effective_revenue_rub: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: DerivedBucket[];
|
||||||
|
priceFactor: number;
|
||||||
|
elasticity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HORIZON_MONTHS = 36;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cumulative-sold curve over 36 months. Per bucket: linear sellout up to
|
||||||
|
* months_to_sellout, then 100%. Aggregate = unit-weighted.
|
||||||
|
*
|
||||||
|
* Marker line at 24 months — that's the liquidity-score reference.
|
||||||
|
*/
|
||||||
|
export function RecommendLiquidityChart({
|
||||||
|
rows,
|
||||||
|
priceFactor,
|
||||||
|
elasticity,
|
||||||
|
}: Props) {
|
||||||
|
const option = useMemo(() => {
|
||||||
|
const pfPow = priceFactor > 0 ? priceFactor ** elasticity : 1;
|
||||||
|
const totalUnits = rows.reduce((a, r) => a + (r.effective_units ?? 0), 0);
|
||||||
|
if (totalUnits === 0) {
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
text: "Укажи площадь — посчитаем сколько и когда продастся",
|
||||||
|
left: "center",
|
||||||
|
top: "middle",
|
||||||
|
textStyle: { color: "#9ca3af", fontSize: 13, fontWeight: 400 },
|
||||||
|
},
|
||||||
|
xAxis: { show: false },
|
||||||
|
yAxis: { show: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const xs = Array.from({ length: HORIZON_MONTHS + 1 }, (_, m) => m);
|
||||||
|
const cumulative = xs.map((m) => {
|
||||||
|
let sold = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const u = r.effective_units ?? 0;
|
||||||
|
if (u <= 0) continue;
|
||||||
|
const v = (r.velocity_per_month ?? 0) * pfPow;
|
||||||
|
if (v <= 0) continue;
|
||||||
|
const monthsTotal = u / v;
|
||||||
|
const f = Math.min(1, m / monthsTotal);
|
||||||
|
sold += f * u;
|
||||||
|
}
|
||||||
|
return Math.round((sold / totalUnits) * 1000) / 10; // %
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
valueFormatter: (v: unknown) =>
|
||||||
|
typeof v === "number" ? `${v.toFixed(1)}%` : "—",
|
||||||
|
},
|
||||||
|
grid: { left: 56, right: 32, top: 24, bottom: 36 },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: xs.map((m) => `${m}`),
|
||||||
|
axisLabel: { formatter: "{value} мес" },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
max: 100,
|
||||||
|
axisLabel: { formatter: "{value}%" },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "Кумулятивные продажи",
|
||||||
|
type: "line",
|
||||||
|
smooth: true,
|
||||||
|
areaStyle: { color: "rgba(29, 78, 216, 0.15)" },
|
||||||
|
lineStyle: { color: "#1d4ed8", width: 2 },
|
||||||
|
symbol: "none",
|
||||||
|
data: cumulative,
|
||||||
|
markLine: {
|
||||||
|
symbol: "none",
|
||||||
|
label: { formatter: "24 мес — горизонт ликвидности" },
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
xAxis: "24",
|
||||||
|
lineStyle: { color: "#9a6700", type: "dashed" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [rows, priceFactor, elasticity]);
|
||||||
|
|
||||||
|
return <ChartShell option={option} height={240} notMerge />;
|
||||||
|
}
|
||||||
309
frontend/src/components/analytics/RecommendVelocityPanel.tsx
Normal file
309
frontend/src/components/analytics/RecommendVelocityPanel.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { RecommendBucket, RecommendMixOutput } from "@/types/analytics";
|
||||||
|
|
||||||
|
interface DerivedBucket extends RecommendBucket {
|
||||||
|
effective_share_pct: number;
|
||||||
|
effective_units: number | null;
|
||||||
|
effective_revenue_rub: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
scope: RecommendMixOutput["scope"];
|
||||||
|
derivedRows: DerivedBucket[];
|
||||||
|
priceFactor: number;
|
||||||
|
onPriceFactorChange: (next: number) => void;
|
||||||
|
targetMonths: number | null;
|
||||||
|
/** Set when user wants the system to suggest required price_factor. */
|
||||||
|
onTargetMonthsApply?: () => void;
|
||||||
|
hasAllocation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtMln = (rub: number | null) =>
|
||||||
|
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live "цена↔темп↔срок↔ликвидность" calculator.
|
||||||
|
*
|
||||||
|
* Все расчёты — клиентские по базовым коэффициентам из API:
|
||||||
|
* velocity_at_pf = velocity_base × price_factor^elasticity
|
||||||
|
* months_to_sellout = units / velocity_at_pf
|
||||||
|
* liquidity = avg(min(1, 24/months) × units) / total_units × 100
|
||||||
|
*
|
||||||
|
* Никаких round-trip к backend на каждое движение слайдера.
|
||||||
|
*/
|
||||||
|
export function RecommendVelocityPanel({
|
||||||
|
scope,
|
||||||
|
derivedRows,
|
||||||
|
priceFactor,
|
||||||
|
onPriceFactorChange,
|
||||||
|
targetMonths,
|
||||||
|
hasAllocation,
|
||||||
|
}: Props) {
|
||||||
|
const elasticity = scope.elasticity;
|
||||||
|
const pfPow = useMemo(
|
||||||
|
() => (priceFactor > 0 ? priceFactor ** elasticity : 1),
|
||||||
|
[priceFactor, elasticity],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aggregate live recompute
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let units = 0;
|
||||||
|
let revenue = 0;
|
||||||
|
let baseVelocity = 0; // sum of bucket velocity at price_factor=1
|
||||||
|
let weightedSold24 = 0; // weighted sum for liquidity
|
||||||
|
for (const r of derivedRows) {
|
||||||
|
const u = r.effective_units ?? 0;
|
||||||
|
units += u;
|
||||||
|
// Revenue scales linearly with price_factor (price_median × pf).
|
||||||
|
const baseRev = r.effective_revenue_rub ?? 0;
|
||||||
|
revenue += baseRev * priceFactor;
|
||||||
|
const v = r.velocity_per_month ?? 0;
|
||||||
|
baseVelocity += v * (r.effective_share_pct / Math.max(r.share_pct, 0.01));
|
||||||
|
const adjustedV = v * pfPow;
|
||||||
|
if (u > 0 && adjustedV > 0) {
|
||||||
|
const months = u / adjustedV;
|
||||||
|
const fracIn24 = Math.min(1, 24 / months);
|
||||||
|
weightedSold24 += fracIn24 * u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tempo = baseVelocity * pfPow;
|
||||||
|
const monthsToSellout = tempo > 0 && units > 0 ? units / tempo : null;
|
||||||
|
const liquidity = units > 0 ? (weightedSold24 / units) * 100 : null;
|
||||||
|
const avgTicket = units > 0 && revenue > 0 ? revenue / units : null;
|
||||||
|
return {
|
||||||
|
units,
|
||||||
|
revenue,
|
||||||
|
tempo,
|
||||||
|
monthsToSellout,
|
||||||
|
liquidity,
|
||||||
|
avgTicket,
|
||||||
|
};
|
||||||
|
}, [derivedRows, priceFactor, pfPow]);
|
||||||
|
|
||||||
|
const liquidityColor =
|
||||||
|
totals.liquidity == null
|
||||||
|
? "#9ca3af"
|
||||||
|
: totals.liquidity >= 70
|
||||||
|
? "#0a7a3a"
|
||||||
|
: totals.liquidity >= 40
|
||||||
|
? "#9a6700"
|
||||||
|
: "#b3261e";
|
||||||
|
|
||||||
|
const monthsLabel =
|
||||||
|
totals.monthsToSellout == null
|
||||||
|
? "—"
|
||||||
|
: totals.monthsToSellout > 60
|
||||||
|
? "60+"
|
||||||
|
: totals.monthsToSellout.toFixed(1);
|
||||||
|
|
||||||
|
// Preview required price_factor if target_months active
|
||||||
|
const required = scope.required_price_factor;
|
||||||
|
const requiredPct =
|
||||||
|
required != null ? Math.round((required - 1) * 100) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Big KPI strip */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BigKpi
|
||||||
|
label="Средний чек"
|
||||||
|
value={fmtMln(totals.avgTicket)}
|
||||||
|
unit="М ₽"
|
||||||
|
/>
|
||||||
|
<BigKpi
|
||||||
|
label="Срок реализации"
|
||||||
|
value={monthsLabel}
|
||||||
|
unit="мес"
|
||||||
|
hint={hasAllocation ? undefined : "укажи площадь"}
|
||||||
|
/>
|
||||||
|
<BigKpi
|
||||||
|
label="Темп продаж"
|
||||||
|
value={totals.tempo > 0 ? totals.tempo.toFixed(1) : "—"}
|
||||||
|
unit="кв/мес"
|
||||||
|
/>
|
||||||
|
<BigKpi
|
||||||
|
label="Ликвидность 24 мес"
|
||||||
|
value={
|
||||||
|
totals.liquidity != null ? `${totals.liquidity.toFixed(0)}` : "—"
|
||||||
|
}
|
||||||
|
unit="/ 100"
|
||||||
|
color={liquidityColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price factor slider */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "180px 1fr 110px",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600 }}>Цена ± к рынку</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.85}
|
||||||
|
max={1.15}
|
||||||
|
step={0.01}
|
||||||
|
value={priceFactor}
|
||||||
|
onChange={(e) => onPriceFactorChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<div style={{ textAlign: "right", fontSize: 14 }}>
|
||||||
|
<span style={{ fontWeight: 700 }}>
|
||||||
|
{priceFactor === 1
|
||||||
|
? "0%"
|
||||||
|
: `${(priceFactor - 1) * 100 > 0 ? "+" : ""}${((priceFactor - 1) * 100).toFixed(0)}%`}
|
||||||
|
</span>
|
||||||
|
<div style={{ fontSize: 11, color: "#73767e" }}>
|
||||||
|
×{priceFactor.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inverse mode hint */}
|
||||||
|
{targetMonths && required != null ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 10,
|
||||||
|
background:
|
||||||
|
requiredPct != null && requiredPct < -15 ? "#fef2f2" : "#f0fdf4",
|
||||||
|
border: `1px solid ${requiredPct != null && requiredPct < -15 ? "#fecaca" : "#bbf7d0"}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Целевой срок {targetMonths} мес</strong> требует price-factor{" "}
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
background: "rgba(0,0,0,0.05)",
|
||||||
|
padding: "1px 5px",
|
||||||
|
borderRadius: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×{required.toFixed(2)}
|
||||||
|
</code>{" "}
|
||||||
|
({requiredPct !== null && requiredPct >= 0 ? "+" : ""}
|
||||||
|
{requiredPct}% к рынку).{" "}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
onPriceFactorChange(Math.max(0.85, Math.min(1.15, required)))
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontSize: 12,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Применить →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Methodology note */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#73767e",
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px dashed #e6e8ec",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Эластичность цена↔темп <strong>{elasticity}</strong> (
|
||||||
|
{scope.elasticity_source === "regression"
|
||||||
|
? `регрессия sale_graph: R²=${scope.elasticity_r2.toFixed(2)}, n=${scope.elasticity_n}`
|
||||||
|
: `по умолчанию — sale_graph недостаточно (n=${scope.elasticity_n})`}
|
||||||
|
). Базовый темп{" "}
|
||||||
|
<strong>{scope.market_velocity_per_month?.toFixed(1) ?? "—"}</strong>{" "}
|
||||||
|
кв/мес (
|
||||||
|
{scope.velocity_source === "sale_graph"
|
||||||
|
? `sale_graph: ${scope.velocity_objects} ЖК / ${scope.velocity_observations} точек`
|
||||||
|
: "fallback на rosreestr-сделки"}
|
||||||
|
). При price ×{priceFactor.toFixed(2)} темп = базовый ×{" "}
|
||||||
|
{priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BigKpi({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
hint,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
unit?: string;
|
||||||
|
hint?: string;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6e8ec",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "12px 14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#5b6066",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: color ?? "#0f172a",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
{unit ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#5b6066",
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{hint ? (
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
|
||||||
|
{hint}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -203,6 +203,8 @@ export interface RecommendMixInput {
|
||||||
area_total_m2: number | null;
|
area_total_m2: number | null;
|
||||||
target_class: RecommendClass | null;
|
target_class: RecommendClass | null;
|
||||||
months_window: number;
|
months_window: number;
|
||||||
|
price_factor?: number;
|
||||||
|
target_months?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecommendBucket {
|
export interface RecommendBucket {
|
||||||
|
|
@ -216,6 +218,8 @@ export interface RecommendBucket {
|
||||||
price_p75_per_m2: number;
|
price_p75_per_m2: number;
|
||||||
units_planned: number | null;
|
units_planned: number | null;
|
||||||
revenue_planned_rub: number | null;
|
revenue_planned_rub: number | null;
|
||||||
|
velocity_per_month: number | null;
|
||||||
|
months_to_sellout: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecommendComparable {
|
export interface RecommendComparable {
|
||||||
|
|
@ -239,6 +243,17 @@ export interface RecommendMixOutput {
|
||||||
effective_window_months: number;
|
effective_window_months: number;
|
||||||
region_code: number;
|
region_code: number;
|
||||||
total_deals: number;
|
total_deals: number;
|
||||||
|
market_velocity_per_month: number | null;
|
||||||
|
velocity_source: "sale_graph" | "rosreestr_fallback";
|
||||||
|
velocity_observations: number;
|
||||||
|
velocity_objects: number;
|
||||||
|
elasticity: number;
|
||||||
|
elasticity_r2: number;
|
||||||
|
elasticity_n: number;
|
||||||
|
elasticity_source: "regression" | "fallback";
|
||||||
|
price_factor_applied: number;
|
||||||
|
required_price_factor: number | null;
|
||||||
|
target_months: number | null;
|
||||||
data_caveat?: string;
|
data_caveat?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -246,6 +261,11 @@ export interface RecommendMixOutput {
|
||||||
summary: {
|
summary: {
|
||||||
total_revenue_rub: number | null;
|
total_revenue_rub: number | null;
|
||||||
weighted_avg_price_per_m2: number | null;
|
weighted_avg_price_per_m2: number | null;
|
||||||
|
total_units_planned: number | null;
|
||||||
|
months_to_sellout_total: number | null;
|
||||||
|
avg_ticket_rub: number | null;
|
||||||
|
liquidity_score_24mo: number | null;
|
||||||
|
headline: string | null;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
};
|
};
|
||||||
comparables: RecommendComparable[];
|
comparables: RecommendComparable[];
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue