From b7ce4a589ccd0beaf9242944b378502315826a67 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 15:51:43 +0300 Subject: [PATCH] fix(sf-10): velocity LEFT JOIN + velocity_data_available flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace INNER JOIN on objective_complex_mapping with LEFT JOIN approach: competitors without mapping now return velocity=0 with velocity_data_available=False instead of being silently dropped. UI: VelocityBlock shows 'нет данных velocity' badge when flag is False and hides the gauge (meaningless zero). TS type updated (additive optional field, backward compat). Audit (EKB, region_cd=66): mapped=129, unmapped=1387, total=1516. Closes #271 item #10 --- backend/app/services/site_finder/velocity.py | 149 ++++++++++++++---- .../components/site-finder/VelocityBlock.tsx | 132 +++++++++------- frontend/src/types/site-finder.ts | 3 + 3 files changed, 198 insertions(+), 86 deletions(-) diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index daafb289..6bef1af8 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -47,6 +47,9 @@ class VelocityResult: period_end: str # YYYY-MM sample_competitors: list[dict[str, Any]] # top-5 для UI 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]: return { @@ -59,6 +62,7 @@ class VelocityResult: "period": {"start": self.period_start, "end": self.period_end}, "sample_competitors": self.sample_competitors, "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. # deals_total_vol_m2 = DDU + DKP м² за месяц (primary signal). # deals_total_count > 0 — фильтрует месяцы без сделок. + # LEFT JOIN с objective_complex_mapping: конкуренты без маппинга не + # выпадают — они включаются в список но с total_sqm=NULL (→ 0.0). # GROUP BY domrf_obj_id чтобы сохранить совместимость с caller (obj_id key). try: with db.begin_nested(): @@ -172,25 +178,32 @@ def compute_velocity( db.execute( 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, cm.objective_complex_name FROM objective_complex_mapping cm WHERE cm.domrf_obj_id = ANY(:obj_ids) ) SELECT - m.obj_id, + ac.obj_id, SUM(COALESCE(crm.deals_total_vol_m2, crm.deals_total_count * 45.0)) AS total_sqm, - COUNT(DISTINCT crm.report_month) AS months_with_data, - MIN(crm.report_month) AS period_start, - MAX(crm.report_month) AS period_end - FROM objective_corpus_room_month crm - JOIN mapped m - ON m.objective_complex_name = crm.project_name - WHERE crm.report_month >= (CURRENT_DATE - CAST(:window_interval AS interval)) - AND crm.deals_total_count > 0 - GROUP BY m.obj_id + COUNT(DISTINCT crm.report_month) AS months_with_data, + MIN(crm.report_month) AS period_start, + MAX(crm.report_month) AS period_end, + CASE WHEN m.obj_id IS NOT NULL THEN TRUE + ELSE FALSE END AS has_mapping + FROM all_competitors ac + LEFT JOIN mapped m ON m.obj_id = ac.obj_id + LEFT JOIN objective_corpus_room_month crm + 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: 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) ─────────────────────── # Тот же маппинг domrf_obj_id → project_name. Агрегируем по room_bucket # для отображения структуры спроса в UI. @@ -278,46 +329,85 @@ def compute_velocity( for bucket, data in by_bucket_agg.items() } - total_sqm = sum(float(r["total_sqm"] or 0.0) for r in sales_rows) - months_observed = max((int(r["months_with_data"] or 0) for r in sales_rows), default=0) - 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"]] + # Считаем только по строкам с маппингом — unmapped строки дают total_sqm=NULL. + mapped_sales_rows = [r for r in sales_rows if bool(r["has_mapping"])] + + 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_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: ЕКБ-медиана ────────────────────────────────────────────────── ekb_median = ( _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 ──────────────────────────────────── # Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК. # Если в радиусе продаётся N × ekb_median → рынок горячий. # Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2)) # 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 velocity_score = min(1.0, max(0.0, monthly_velocity / denominator)) # ── Step 5: confidence ─────────────────────────────────────────────────── - n_comps = len(comp_rows) + mapped_conf: Literal["high", "medium", "low"] 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: - confidence = "medium" + mapped_conf = "medium" else: - confidence = "low" + mapped_conf = "low" # ── Step 6: top-5 конкурентов по объёму продаж ─────────────────────────── 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( [ @@ -339,12 +429,13 @@ def compute_velocity( monthly_velocity_sqm=monthly_velocity, ekb_median_sqm=ekb_median, velocity_score=velocity_score, - confidence=confidence, + confidence=mapped_conf, months_observed=months_observed, period_start=period_start, period_end=period_end, sample_competitors=sample, by_room_bucket=by_room_bucket, + velocity_data_available=True, ) diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx index ea4ff0c6..9989eb43 100644 --- a/frontend/src/components/site-finder/VelocityBlock.tsx +++ b/frontend/src/components/site-finder/VelocityBlock.tsx @@ -49,6 +49,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { ); } + const dataAvailable = velocity.velocity_data_available !== false; const confColor = CONFIDENCE_COLOR[velocity.confidence]; const scorePct = formatPercent(velocity.velocity_score); const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm; @@ -64,72 +65,89 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { }} > Темп продаж конкурентов - - {CONFIDENCE_LABEL[velocity.confidence]} - +
+ {!dataAvailable && ( + + нет данных velocity + + )} + + {CONFIDENCE_LABEL[velocity.confidence]} + +
- {/* Score gauge */} -
-
- Velocity-score - {scorePct} -
-
+ {/* Score gauge — показываем только если данные есть */} + {dataAvailable && ( +
= 0.66 - ? "#10b981" - : velocity.velocity_score >= 0.33 - ? "#f59e0b" - : "#ef4444", - width: `${velocity.velocity_score * 100}%`, - height: "100%", + display: "flex", + justifyContent: "space-between", + fontSize: 12, + color: "#6b7280", + marginBottom: 4, }} - /> + > + Velocity-score + + {scorePct} + +
+
+
= 0.66 + ? "#10b981" + : velocity.velocity_score >= 0.33 + ? "#f59e0b" + : "#ef4444", + width: `${velocity.velocity_score * 100}%`, + height: "100%", + }} + /> +
+
+ {Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "} + {Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "} + {ratio >= 1 + ? `x${ratio.toFixed(1)} выше` + : `${formatPercent(ratio)} от среднего`} +
-
- {Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "} - {Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "} - {ratio >= 1 - ? `x${ratio.toFixed(1)} выше` - : `${formatPercent(ratio)} от среднего`} -
-
+ )} {/* Period + competitors meta */}
- В радиусе 3 км: {velocity.competitors_count} ЖК · период{" "} - - {velocity.period.start} → {velocity.period.end} - {" "} - ({velocity.months_observed} мес) + В радиусе 3 км: {velocity.competitors_count} ЖК + {dataAvailable && ( + <> + {" "} + · период{" "} + + {velocity.period.start} → {velocity.period.end} + {" "} + ({velocity.months_observed} мес) + + )}
{/* By room bucket aggregate */} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 0ef79e43..46bfdf2a 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -241,6 +241,9 @@ export interface Velocity { period: VelocityPeriod; sample_competitors: VelocityCompetitor[]; by_room_bucket?: Record; + // True если ≥1 конкурент имеет маппинг в objective_complex_mapping. + // False → конкуренты найдены, velocity=0, данных Objective нет. + velocity_data_available?: boolean; } // G5 (#32) — Gate verdict: can_build_mkd