diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py
index e1809cbc..f76ec80b 100644
--- a/backend/app/api/v1/parcels.py
+++ b/backend/app/api/v1/parcels.py
@@ -2100,10 +2100,18 @@ def analyze_parcel(
# SAVEPOINT защищает outer transaction если velocity SQL падает —
# иначе следующие queries (_geotech_risk и пр.) крашатся
# с InFailedSqlTransaction.
+ # SF#17: передаём cad_quarter для rosreestr_fallback (первые 3 сегмента cad_num).
+ _cad_parts = cad_num.split(":")
+ _cad_quarter = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else None
velocity_data: dict[str, Any] | None = None
try:
with db.begin_nested():
- v_result = compute_velocity(db, parcel_geom_wkt=geom_wkt, radius_km=3.0)
+ v_result = compute_velocity(
+ db,
+ parcel_geom_wkt=geom_wkt,
+ radius_km=3.0,
+ cad_quarter=_cad_quarter,
+ )
if v_result is not None:
velocity_data = v_result.as_dict()
except Exception as _ve:
diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py
index 6bef1af8..a7e266a6 100644
--- a/backend/app/services/site_finder/velocity.py
+++ b/backend/app/services/site_finder/velocity.py
@@ -6,10 +6,14 @@ Per #34 D2: утилизация objective_corpus_room_month (еженедель
конкурирующих ЖК в радиусе radius_km от участка, нормированный к
ЕКБ-медиане по данным Objective.
+Fallback (SF#17): если Objective coverage <50% конкурентов в радиусе,
+использует rosreestr_deals JOIN по cad_quarter участка (100% coverage по кварталам).
+
Foundation: domrf_kn_objects (lat/lon, comm_name, obj_class, region_cd),
objective_complex_mapping (domrf_obj_id ↔ objective_complex_name),
objective_corpus_room_month (project_name, deals_total_vol_m2,
deals_total_count, report_month).
+Fallback: rosreestr_deals (quarter_cad_number, deal_count, period_start_date).
Linkage: domrf_kn_objects.obj_id
→ objective_complex_mapping.domrf_obj_id
@@ -32,6 +36,10 @@ logger = logging.getLogger(__name__)
# Эмпирика по ЕКБ: ~4 500 м²/мес на один ЖК (apartments, 2024-2025).
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0
+# Порог: если доля конкурентов с Objective-маппингом < этого значения,
+# пытаемся rosreestr_fallback.
+_OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50
+
@dataclass(frozen=True)
class VelocityResult:
@@ -47,9 +55,12 @@ 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.
+ # True если данные есть (objective или rosreestr_fallback).
+ # False → нет данных ни из одного источника.
velocity_data_available: bool = True
+ # Источник данных: objective (основной), rosreestr_fallback (по кадастровому кварталу),
+ # none (нет данных).
+ velocity_source: Literal["objective", "rosreestr_fallback", "none"] = "objective"
def as_dict(self) -> dict[str, Any]:
return {
@@ -63,6 +74,7 @@ class VelocityResult:
"sample_competitors": self.sample_competitors,
"by_room_bucket": self.by_room_bucket,
"velocity_data_available": self.velocity_data_available,
+ "velocity_source": self.velocity_source,
}
@@ -72,6 +84,7 @@ def compute_velocity(
radius_km: float = 3.0,
obj_class: str | None = None,
months_window: int = 6,
+ cad_quarter: str | None = None,
) -> VelocityResult | None:
"""Вычислить velocity-score для участка.
@@ -79,9 +92,14 @@ def compute_velocity(
1. Найти ЖК-конкуренты в радиусе radius_km (через lat/lon ST_DWithin).
2. Взять objective_corpus_room_month за последние months_window месяцев
через objective_complex_mapping (domrf_obj_id → project_name).
- 3. Посчитать суммарный объём deals_total_vol_m2.
+ 3. Если Objective coverage < 50% конкурентов → rosreestr_fallback:
+ считаем сделки DDU/ДКП в cad_quarter участка за окно.
4. Нормировать на ЕКБ-медиану → score 0..1.
+ Параметры:
+ cad_quarter: кадастровый квартал участка (первые 3 сегмента cad_num,
+ например "66:41:0702048"). Используется только для fallback.
+
Возвращает None если parcel_geom_wkt невалиден или конкурентов нет.
"""
# ── Step 1: конкуренты по lat/lon в радиусе ──────────────────────────────
@@ -222,21 +240,21 @@ 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 = [
+ # ── Step 2a: проверка Objective coverage ─────────────────────────────────
+ # Считаем: mapped_with_data — конкуренты с маппингом И реальными данными.
+ # Если mapped_ratio < _OBJECTIVE_COVERAGE_MIN_RATIO → rosreestr_fallback.
+ n_total_comps = len(obj_ids)
+ mapped_with_data = [
+ r for r in sales_rows if bool(r["has_mapping"]) and (r["total_sqm"] or 0.0) > 0
+ ]
+ mapped_ratio = len(mapped_with_data) / n_total_comps if n_total_comps > 0 else 0.0
+
+ ekb_median = (
+ _get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
+ )
+ n_comps = len(comp_rows)
+ sample_no_data = sorted(
+ [
{
"obj_id": oid,
**competitor_meta[oid],
@@ -245,7 +263,31 @@ def compute_velocity(
}
for oid in obj_ids[:5]
if oid in competitor_meta
- ]
+ ],
+ key=lambda x: x["distance_m"], # type: ignore[index]
+ )
+
+ if mapped_ratio < _OBJECTIVE_COVERAGE_MIN_RATIO:
+ logger.info(
+ "velocity: objective coverage %.0f%% (<%d%%) for %d competitors;"
+ " trying rosreestr_fallback cad_quarter=%s",
+ mapped_ratio * 100,
+ int(_OBJECTIVE_COVERAGE_MIN_RATIO * 100),
+ n_total_comps,
+ cad_quarter,
+ )
+ rr_result = _compute_rosreestr_fallback(
+ db=db,
+ cad_quarter=cad_quarter,
+ months_window=months_window,
+ n_comps=n_comps,
+ ekb_median=ekb_median,
+ sample_competitors=sample_no_data,
+ )
+ if rr_result is not None:
+ return rr_result
+ # Rosreestr тоже пуст — возвращаем none-state.
+ logger.info("velocity: rosreestr_fallback also empty for cad_quarter=%s", cad_quarter)
return VelocityResult(
competitors_count=n_comps,
monthly_velocity_sqm=0.0,
@@ -255,9 +297,10 @@ def compute_velocity(
months_observed=0,
period_start="",
period_end="",
- sample_competitors=sample,
+ sample_competitors=sample_no_data,
by_room_bucket={},
velocity_data_available=False,
+ velocity_source="none",
)
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
@@ -339,21 +382,24 @@ def compute_velocity(
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 ""
- # ── 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.
+ # Если mapped-конкурентов нет данных — partial coverage → fallback.
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",
+ " trying rosreestr_fallback",
len(obj_ids),
len(mapped_sales_rows),
)
+ rr_result = _compute_rosreestr_fallback(
+ db=db,
+ cad_quarter=cad_quarter,
+ months_window=months_window,
+ n_comps=n_comps,
+ ekb_median=ekb_median,
+ sample_competitors=sample_no_data,
+ )
+ if rr_result is not None:
+ return rr_result
sample_partial = sorted(
[
{
@@ -380,13 +426,14 @@ def compute_velocity(
sample_competitors=sample_partial,
by_room_bucket={},
velocity_data_available=False,
+ velocity_source="none",
)
# Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев.
# Чем больше конкурентов с данными — тем весомее результат.
monthly_velocity = total_sqm / months_observed
- # ── Step 4: нормализация → score 0..1 ────────────────────────────────────
+ # ── Step 3: нормализация → score 0..1 ────────────────────────────────────
# Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК.
# Если в радиусе продаётся N × ekb_median → рынок горячий.
# Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2))
@@ -396,7 +443,7 @@ def compute_velocity(
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 ───────────────────────────────────────────────────
+ # ── Step 4: confidence ───────────────────────────────────────────────────
mapped_conf: Literal["high", "medium", "low"]
if n_comps >= 10 and months_observed >= 5:
mapped_conf = "high"
@@ -405,7 +452,7 @@ def compute_velocity(
else:
mapped_conf = "low"
- # ── Step 6: top-5 конкурентов по объёму продаж ───────────────────────────
+ # ── Step 5: top-5 конкурентов по объёму продаж ───────────────────────────
sales_by_id: dict[int, float] = {
int(r["obj_id"]): float(r["total_sqm"] or 0.0) for r in mapped_sales_rows
}
@@ -436,6 +483,103 @@ def compute_velocity(
sample_competitors=sample,
by_room_bucket=by_room_bucket,
velocity_data_available=True,
+ velocity_source="objective",
+ )
+
+
+def _compute_rosreestr_fallback(
+ db: Session,
+ cad_quarter: str | None,
+ months_window: int,
+ n_comps: int,
+ ekb_median: float,
+ sample_competitors: list[dict[str, Any]],
+) -> VelocityResult | None:
+ """Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.
+
+ Считает суммарное число сделок DDU/ДКП в кадастровом квартале за окно months_window.
+ Velocity = deal_count / months_window (сделок/мес). Нет разбивки по room_bucket
+ (rosreestr не даёт комнатность).
+
+ Возвращает None если cad_quarter не задан или данных нет.
+ """
+ if not cad_quarter:
+ return None
+
+ try:
+ with db.begin_nested():
+ row = (
+ db.execute(
+ text(
+ """
+ SELECT
+ SUM(deal_count) AS total_deals,
+ MIN(period_start_date) AS period_start,
+ MAX(period_start_date) AS period_end
+ FROM rosreestr_deals
+ WHERE quarter_cad_number = :cad_quarter
+ AND period_start_date >= (CURRENT_DATE - CAST(:window_interval AS interval))
+ AND doc_type IN ('ДДУ', 'ДКП')
+ """
+ ),
+ {
+ "cad_quarter": cad_quarter,
+ "window_interval": f"{months_window} months",
+ },
+ )
+ .mappings()
+ .first()
+ )
+ except Exception:
+ logger.warning("velocity: rosreestr_fallback query failed for cad_quarter=%s", cad_quarter)
+ return None
+
+ if row is None or not row["total_deals"] or int(row["total_deals"]) == 0:
+ return None
+
+ total_deals = int(row["total_deals"])
+ # Сделок/мес — грубый аналог velocity (без м², только count).
+ # Умножаем на 45 м² (эмпирика) для совместимости с м²/мес единицами.
+ avg_area_per_deal = 45.0 # м² — консервативная оценка для апартаментов ЕКБ
+ monthly_velocity_sqm = (total_deals * avg_area_per_deal) / months_window
+
+ # Нормализация относительно ekb_median (один ЖК × 2).
+ velocity_score = min(1.0, max(0.0, monthly_velocity_sqm / (ekb_median * 2.0)))
+
+ # Confidence — rosreestr данные менее детализированы, чем Objective.
+ rr_confidence: Literal["high", "medium", "low"]
+ if total_deals >= 50:
+ rr_confidence = "medium" # max medium для rosreestr — нет комнатности
+ else:
+ rr_confidence = "low"
+
+ period_start_date = row["period_start"]
+ period_end_date = row["period_end"]
+ period_start = period_start_date.strftime("%Y-%m") if period_start_date else ""
+ period_end = period_end_date.strftime("%Y-%m") if period_end_date else ""
+
+ logger.info(
+ "velocity: rosreestr_fallback success cad_quarter=%s"
+ " total_deals=%d window=%dm velocity=%.1f sqm/mon",
+ cad_quarter,
+ total_deals,
+ months_window,
+ monthly_velocity_sqm,
+ )
+
+ return VelocityResult(
+ competitors_count=n_comps,
+ monthly_velocity_sqm=monthly_velocity_sqm,
+ ekb_median_sqm=ekb_median,
+ velocity_score=velocity_score,
+ confidence=rr_confidence,
+ months_observed=months_window,
+ period_start=period_start,
+ period_end=period_end,
+ sample_competitors=sample_competitors,
+ by_room_bucket={}, # rosreestr не даёт room_bucket
+ velocity_data_available=True,
+ velocity_source="rosreestr_fallback",
)
diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx
index 9989eb43..615fe7fa 100644
--- a/frontend/src/components/site-finder/VelocityBlock.tsx
+++ b/frontend/src/components/site-finder/VelocityBlock.tsx
@@ -50,6 +50,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
}
const dataAvailable = velocity.velocity_data_available !== false;
+ const isRosreestrFallback = velocity.velocity_source === "rosreestr_fallback";
const confColor = CONFIDENCE_COLOR[velocity.confidence];
const scorePct = formatPercent(velocity.velocity_score);
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
@@ -71,6 +72,11 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
нет данных velocity
)}
+ {isRosreestrFallback && (
+
+ Источник: квартальные сделки
+
+ )}