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) {
}}
>