fix(sf-10): velocity LEFT JOIN + velocity_data_available flag #284

Merged
lekss361 merged 1 commit from fix/sf-10-velocity-left-join-v2 into main 2026-05-17 13:00:19 +00:00
3 changed files with 198 additions and 86 deletions

View file

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

View file

@ -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)} м²/мес (медиана ЕКБ) &middot;{" "}
{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)} м²/мес (медиана ЕКБ) &middot;{" "}
{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> ЖК &middot; период{" "} В радиусе 3 км: <b>{velocity.competitors_count}</b> ЖК
<b> {dataAvailable && (
{velocity.period.start} &rarr; {velocity.period.end} <>
</b>{" "} {" "}
({velocity.months_observed} мес) &middot; период{" "}
<b>
{velocity.period.start} &rarr; {velocity.period.end}
</b>{" "}
({velocity.months_observed} мес)
</>
)}
</div> </div>
{/* By room bucket aggregate */} {/* By room bucket aggregate */}

View file

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