fix(sf-10): velocity LEFT JOIN + velocity_data_available flag #284
3 changed files with 198 additions and 86 deletions
|
|
@ -47,6 +47,9 @@ class VelocityResult:
|
||||||
period_end: str # YYYY-MM
|
period_end: str # YYYY-MM
|
||||||
sample_competitors: list[dict[str, Any]] # top-5 для UI
|
sample_competitors: list[dict[str, Any]] # top-5 для UI
|
||||||
by_room_bucket: dict[str, dict[str, Any]] # агрегат по комнатности
|
by_room_bucket: dict[str, dict[str, Any]] # агрегат по комнатности
|
||||||
|
# True если ≥1 конкурент имеет маппинг в objective_complex_mapping;
|
||||||
|
# False → конкуренты найдены, но данных Objective нет — velocity = 0.
|
||||||
|
velocity_data_available: bool = True
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -59,6 +62,7 @@ class VelocityResult:
|
||||||
"period": {"start": self.period_start, "end": self.period_end},
|
"period": {"start": self.period_start, "end": self.period_end},
|
||||||
"sample_competitors": self.sample_competitors,
|
"sample_competitors": self.sample_competitors,
|
||||||
"by_room_bucket": self.by_room_bucket,
|
"by_room_bucket": self.by_room_bucket,
|
||||||
|
"velocity_data_available": self.velocity_data_available,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -165,6 +169,8 @@ def compute_velocity(
|
||||||
# objective_corpus_room_month.
|
# objective_corpus_room_month.
|
||||||
# deals_total_vol_m2 = DDU + DKP м² за месяц (primary signal).
|
# deals_total_vol_m2 = DDU + DKP м² за месяц (primary signal).
|
||||||
# deals_total_count > 0 — фильтрует месяцы без сделок.
|
# deals_total_count > 0 — фильтрует месяцы без сделок.
|
||||||
|
# LEFT JOIN с objective_complex_mapping: конкуренты без маппинга не
|
||||||
|
# выпадают — они включаются в список но с total_sqm=NULL (→ 0.0).
|
||||||
# GROUP BY domrf_obj_id чтобы сохранить совместимость с caller (obj_id key).
|
# GROUP BY domrf_obj_id чтобы сохранить совместимость с caller (obj_id key).
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
|
|
@ -172,25 +178,32 @@ def compute_velocity(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
WITH mapped AS (
|
WITH all_competitors AS (
|
||||||
|
SELECT unnest(CAST(:obj_ids AS int[])) AS obj_id
|
||||||
|
),
|
||||||
|
mapped AS (
|
||||||
SELECT cm.domrf_obj_id AS obj_id,
|
SELECT cm.domrf_obj_id AS obj_id,
|
||||||
cm.objective_complex_name
|
cm.objective_complex_name
|
||||||
FROM objective_complex_mapping cm
|
FROM objective_complex_mapping cm
|
||||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
m.obj_id,
|
ac.obj_id,
|
||||||
SUM(COALESCE(crm.deals_total_vol_m2,
|
SUM(COALESCE(crm.deals_total_vol_m2,
|
||||||
crm.deals_total_count * 45.0)) AS total_sqm,
|
crm.deals_total_count * 45.0)) AS total_sqm,
|
||||||
COUNT(DISTINCT crm.report_month) AS months_with_data,
|
COUNT(DISTINCT crm.report_month) AS months_with_data,
|
||||||
MIN(crm.report_month) AS period_start,
|
MIN(crm.report_month) AS period_start,
|
||||||
MAX(crm.report_month) AS period_end
|
MAX(crm.report_month) AS period_end,
|
||||||
FROM objective_corpus_room_month crm
|
CASE WHEN m.obj_id IS NOT NULL THEN TRUE
|
||||||
JOIN mapped m
|
ELSE FALSE END AS has_mapping
|
||||||
ON m.objective_complex_name = crm.project_name
|
FROM all_competitors ac
|
||||||
WHERE crm.report_month >= (CURRENT_DATE - CAST(:window_interval AS interval))
|
LEFT JOIN mapped m ON m.obj_id = ac.obj_id
|
||||||
AND crm.deals_total_count > 0
|
LEFT JOIN objective_corpus_room_month crm
|
||||||
GROUP BY m.obj_id
|
ON crm.project_name = m.objective_complex_name
|
||||||
|
AND crm.report_month >= (
|
||||||
|
CURRENT_DATE - CAST(:window_interval AS interval))
|
||||||
|
AND crm.deals_total_count > 0
|
||||||
|
GROUP BY ac.obj_id, m.obj_id
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|
@ -209,6 +222,44 @@ def compute_velocity(
|
||||||
if not sales_rows:
|
if not sales_rows:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Проверяем: есть ли хотя бы один конкурент с маппингом (has_mapping=True).
|
||||||
|
# Если нет — возвращаем velocity=0 с явным флагом velocity_data_available=False,
|
||||||
|
# вместо того чтобы отбросить всех конкурентов (старый INNER JOIN поведение).
|
||||||
|
has_any_mapping = any(bool(r["has_mapping"]) for r in sales_rows)
|
||||||
|
if not has_any_mapping:
|
||||||
|
logger.info(
|
||||||
|
"velocity: %d competitors found but none mapped in objective_complex_mapping;"
|
||||||
|
" returning velocity=0 with data_available=False",
|
||||||
|
len(obj_ids),
|
||||||
|
)
|
||||||
|
ekb_median = (
|
||||||
|
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
||||||
|
)
|
||||||
|
n_comps = len(comp_rows)
|
||||||
|
sample = [
|
||||||
|
{
|
||||||
|
"obj_id": oid,
|
||||||
|
**competitor_meta[oid],
|
||||||
|
"total_sqm_period": 0.0,
|
||||||
|
"by_room_bucket": {},
|
||||||
|
}
|
||||||
|
for oid in obj_ids[:5]
|
||||||
|
if oid in competitor_meta
|
||||||
|
]
|
||||||
|
return VelocityResult(
|
||||||
|
competitors_count=n_comps,
|
||||||
|
monthly_velocity_sqm=0.0,
|
||||||
|
ekb_median_sqm=ekb_median,
|
||||||
|
velocity_score=0.0,
|
||||||
|
confidence="low",
|
||||||
|
months_observed=0,
|
||||||
|
period_start="",
|
||||||
|
period_end="",
|
||||||
|
sample_competitors=sample,
|
||||||
|
by_room_bucket={},
|
||||||
|
velocity_data_available=False,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
|
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
|
||||||
# Тот же маппинг domrf_obj_id → project_name. Агрегируем по room_bucket
|
# Тот же маппинг domrf_obj_id → project_name. Агрегируем по room_bucket
|
||||||
# для отображения структуры спроса в UI.
|
# для отображения структуры спроса в UI.
|
||||||
|
|
@ -278,46 +329,85 @@ def compute_velocity(
|
||||||
for bucket, data in by_bucket_agg.items()
|
for bucket, data in by_bucket_agg.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
total_sqm = sum(float(r["total_sqm"] or 0.0) for r in sales_rows)
|
# Считаем только по строкам с маппингом — unmapped строки дают total_sqm=NULL.
|
||||||
months_observed = max((int(r["months_with_data"] or 0) for r in sales_rows), default=0)
|
mapped_sales_rows = [r for r in sales_rows if bool(r["has_mapping"])]
|
||||||
period_start_dates = [r["period_start"] for r in sales_rows if r["period_start"]]
|
|
||||||
period_end_dates = [r["period_end"] for r in sales_rows if r["period_end"]]
|
total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows)
|
||||||
|
months_observed = max((int(r["months_with_data"] or 0) for r in mapped_sales_rows), default=0)
|
||||||
|
period_start_dates = [r["period_start"] for r in mapped_sales_rows if r["period_start"]]
|
||||||
|
period_end_dates = [r["period_end"] for r in mapped_sales_rows if r["period_end"]]
|
||||||
period_start = min(period_start_dates).strftime("%Y-%m") if period_start_dates else ""
|
period_start = min(period_start_dates).strftime("%Y-%m") if period_start_dates else ""
|
||||||
period_end = max(period_end_dates).strftime("%Y-%m") if period_end_dates else ""
|
period_end = max(period_end_dates).strftime("%Y-%m") if period_end_dates else ""
|
||||||
|
|
||||||
if months_observed == 0 or total_sqm <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев.
|
|
||||||
# Чем больше конкурентов с данными — тем весомее результат.
|
|
||||||
monthly_velocity = total_sqm / months_observed
|
|
||||||
|
|
||||||
# ── Step 3: ЕКБ-медиана ──────────────────────────────────────────────────
|
# ── Step 3: ЕКБ-медиана ──────────────────────────────────────────────────
|
||||||
ekb_median = (
|
ekb_median = (
|
||||||
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
||||||
)
|
)
|
||||||
|
|
||||||
|
n_comps = len(comp_rows)
|
||||||
|
|
||||||
|
# Если mapped-конкурентов нет данных — partial coverage → velocity=0.
|
||||||
|
if months_observed == 0 or total_sqm <= 0:
|
||||||
|
logger.info(
|
||||||
|
"velocity: %d competitors found, %d mapped, but no sales data in window;"
|
||||||
|
" returning velocity=0 with data_available=False",
|
||||||
|
len(obj_ids),
|
||||||
|
len(mapped_sales_rows),
|
||||||
|
)
|
||||||
|
sample_partial = sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"obj_id": oid,
|
||||||
|
**competitor_meta[oid],
|
||||||
|
"total_sqm_period": 0.0,
|
||||||
|
"by_room_bucket": {},
|
||||||
|
}
|
||||||
|
for oid in obj_ids
|
||||||
|
if oid in competitor_meta
|
||||||
|
],
|
||||||
|
key=lambda x: x["total_sqm_period"], # type: ignore[arg-type]
|
||||||
|
reverse=True,
|
||||||
|
)[:5]
|
||||||
|
return VelocityResult(
|
||||||
|
competitors_count=n_comps,
|
||||||
|
monthly_velocity_sqm=0.0,
|
||||||
|
ekb_median_sqm=ekb_median,
|
||||||
|
velocity_score=0.0,
|
||||||
|
confidence="low",
|
||||||
|
months_observed=0,
|
||||||
|
period_start="",
|
||||||
|
period_end="",
|
||||||
|
sample_competitors=sample_partial,
|
||||||
|
by_room_bucket={},
|
||||||
|
velocity_data_available=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев.
|
||||||
|
# Чем больше конкурентов с данными — тем весомее результат.
|
||||||
|
monthly_velocity = total_sqm / months_observed
|
||||||
|
|
||||||
# ── Step 4: нормализация → score 0..1 ────────────────────────────────────
|
# ── Step 4: нормализация → score 0..1 ────────────────────────────────────
|
||||||
# Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК.
|
# Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК.
|
||||||
# Если в радиусе продаётся N × ekb_median → рынок горячий.
|
# Если в радиусе продаётся N × ekb_median → рынок горячий.
|
||||||
# Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2))
|
# Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2))
|
||||||
# Cap 2×median = «насыщен». Итоговый score 0..1.
|
# Cap 2×median = «насыщен». Итоговый score 0..1.
|
||||||
n_with_sales = len(sales_rows)
|
# n_with_sales — только mapped конкуренты (у unmapped данных нет).
|
||||||
|
n_with_sales = len(mapped_sales_rows)
|
||||||
denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0
|
denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0
|
||||||
velocity_score = min(1.0, max(0.0, monthly_velocity / denominator))
|
velocity_score = min(1.0, max(0.0, monthly_velocity / denominator))
|
||||||
|
|
||||||
# ── Step 5: confidence ───────────────────────────────────────────────────
|
# ── Step 5: confidence ───────────────────────────────────────────────────
|
||||||
n_comps = len(comp_rows)
|
mapped_conf: Literal["high", "medium", "low"]
|
||||||
if n_comps >= 10 and months_observed >= 5:
|
if n_comps >= 10 and months_observed >= 5:
|
||||||
confidence: Literal["high", "medium", "low"] = "high"
|
mapped_conf = "high"
|
||||||
elif n_comps >= 5 and months_observed >= 3:
|
elif n_comps >= 5 and months_observed >= 3:
|
||||||
confidence = "medium"
|
mapped_conf = "medium"
|
||||||
else:
|
else:
|
||||||
confidence = "low"
|
mapped_conf = "low"
|
||||||
|
|
||||||
# ── Step 6: top-5 конкурентов по объёму продаж ───────────────────────────
|
# ── Step 6: top-5 конкурентов по объёму продаж ───────────────────────────
|
||||||
sales_by_id: dict[int, float] = {
|
sales_by_id: dict[int, float] = {
|
||||||
int(r["obj_id"]): float(r["total_sqm"] or 0.0) for r in sales_rows
|
int(r["obj_id"]): float(r["total_sqm"] or 0.0) for r in mapped_sales_rows
|
||||||
}
|
}
|
||||||
sample = sorted(
|
sample = sorted(
|
||||||
[
|
[
|
||||||
|
|
@ -339,12 +429,13 @@ def compute_velocity(
|
||||||
monthly_velocity_sqm=monthly_velocity,
|
monthly_velocity_sqm=monthly_velocity,
|
||||||
ekb_median_sqm=ekb_median,
|
ekb_median_sqm=ekb_median,
|
||||||
velocity_score=velocity_score,
|
velocity_score=velocity_score,
|
||||||
confidence=confidence,
|
confidence=mapped_conf,
|
||||||
months_observed=months_observed,
|
months_observed=months_observed,
|
||||||
period_start=period_start,
|
period_start=period_start,
|
||||||
period_end=period_end,
|
period_end=period_end,
|
||||||
sample_competitors=sample,
|
sample_competitors=sample,
|
||||||
by_room_bucket=by_room_bucket,
|
by_room_bucket=by_room_bucket,
|
||||||
|
velocity_data_available=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dataAvailable = velocity.velocity_data_available !== false;
|
||||||
const confColor = CONFIDENCE_COLOR[velocity.confidence];
|
const confColor = CONFIDENCE_COLOR[velocity.confidence];
|
||||||
const scorePct = formatPercent(velocity.velocity_score);
|
const scorePct = formatPercent(velocity.velocity_score);
|
||||||
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
|
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
|
||||||
|
|
@ -64,72 +65,89 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SectionLabel>Темп продаж конкурентов</SectionLabel>
|
<SectionLabel>Темп продаж конкурентов</SectionLabel>
|
||||||
<span
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
style={{
|
{!dataAvailable && (
|
||||||
padding: "2px 8px",
|
<span className="bg-slate-100 text-slate-500 text-xs px-2 py-0.5 rounded">
|
||||||
background: confColor.bg,
|
нет данных velocity
|
||||||
color: confColor.fg,
|
</span>
|
||||||
borderRadius: 4,
|
)}
|
||||||
fontSize: 11,
|
<span
|
||||||
fontWeight: 600,
|
style={{
|
||||||
}}
|
padding: "2px 8px",
|
||||||
title={`Уверенность: ${CONFIDENCE_LABEL[velocity.confidence]}`}
|
background: confColor.bg,
|
||||||
>
|
color: confColor.fg,
|
||||||
{CONFIDENCE_LABEL[velocity.confidence]}
|
borderRadius: 4,
|
||||||
</span>
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
title={`Уверенность: ${CONFIDENCE_LABEL[velocity.confidence]}`}
|
||||||
|
>
|
||||||
|
{CONFIDENCE_LABEL[velocity.confidence]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Score gauge */}
|
{/* Score gauge — показываем только если данные есть */}
|
||||||
<div style={{ marginBottom: 12 }}>
|
{dataAvailable && (
|
||||||
<div
|
<div style={{ marginBottom: 12 }}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#6b7280",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>Velocity-score</span>
|
|
||||||
<span style={{ fontWeight: 600, color: "#111827" }}>{scorePct}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#e5e7eb",
|
|
||||||
borderRadius: 4,
|
|
||||||
height: 8,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background:
|
display: "flex",
|
||||||
velocity.velocity_score >= 0.66
|
justifyContent: "space-between",
|
||||||
? "#10b981"
|
fontSize: 12,
|
||||||
: velocity.velocity_score >= 0.33
|
color: "#6b7280",
|
||||||
? "#f59e0b"
|
marginBottom: 4,
|
||||||
: "#ef4444",
|
|
||||||
width: `${velocity.velocity_score * 100}%`,
|
|
||||||
height: "100%",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<span>Velocity-score</span>
|
||||||
|
<span style={{ fontWeight: 600, color: "#111827" }}>
|
||||||
|
{scorePct}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#e5e7eb",
|
||||||
|
borderRadius: 4,
|
||||||
|
height: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
velocity.velocity_score >= 0.66
|
||||||
|
? "#10b981"
|
||||||
|
: velocity.velocity_score >= 0.33
|
||||||
|
? "#f59e0b"
|
||||||
|
: "#ef4444",
|
||||||
|
width: `${velocity.velocity_score * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
||||||
|
{Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
|
||||||
|
{Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "}
|
||||||
|
{ratio >= 1
|
||||||
|
? `x${ratio.toFixed(1)} выше`
|
||||||
|
: `${formatPercent(ratio)} от среднего`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
)}
|
||||||
{Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
|
|
||||||
{Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "}
|
|
||||||
{ratio >= 1
|
|
||||||
? `x${ratio.toFixed(1)} выше`
|
|
||||||
: `${formatPercent(ratio)} от среднего`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Period + competitors meta */}
|
{/* Period + competitors meta */}
|
||||||
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8 }}>
|
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8 }}>
|
||||||
В радиусе 3 км: <b>{velocity.competitors_count}</b> ЖК · период{" "}
|
В радиусе 3 км: <b>{velocity.competitors_count}</b> ЖК
|
||||||
<b>
|
{dataAvailable && (
|
||||||
{velocity.period.start} → {velocity.period.end}
|
<>
|
||||||
</b>{" "}
|
{" "}
|
||||||
({velocity.months_observed} мес)
|
· период{" "}
|
||||||
|
<b>
|
||||||
|
{velocity.period.start} → {velocity.period.end}
|
||||||
|
</b>{" "}
|
||||||
|
({velocity.months_observed} мес)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* By room bucket aggregate */}
|
{/* By room bucket aggregate */}
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,9 @@ export interface Velocity {
|
||||||
period: VelocityPeriod;
|
period: VelocityPeriod;
|
||||||
sample_competitors: VelocityCompetitor[];
|
sample_competitors: VelocityCompetitor[];
|
||||||
by_room_bucket?: Record<string, VelocityBucketStat>;
|
by_room_bucket?: Record<string, VelocityBucketStat>;
|
||||||
|
// True если ≥1 конкурент имеет маппинг в objective_complex_mapping.
|
||||||
|
// False → конкуренты найдены, velocity=0, данных Objective нет.
|
||||||
|
velocity_data_available?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// G5 (#32) — Gate verdict: can_build_mkd
|
// G5 (#32) — Gate verdict: can_build_mkd
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue