Merge pull request 'fix(sf-17): rosreestr velocity fallback via cad_quarter (~100% EKB coverage)' (#289) from fix/sf-17-rosreestr-velocity-fallback into main
This commit is contained in:
commit
f0589bcdff
4 changed files with 195 additions and 33 deletions
|
|
@ -2100,10 +2100,18 @@ def analyze_parcel(
|
||||||
# SAVEPOINT защищает outer transaction если velocity SQL падает —
|
# SAVEPOINT защищает outer transaction если velocity SQL падает —
|
||||||
# иначе следующие queries (_geotech_risk и пр.) крашатся
|
# иначе следующие queries (_geotech_risk и пр.) крашатся
|
||||||
# с InFailedSqlTransaction.
|
# с 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
|
velocity_data: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
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:
|
if v_result is not None:
|
||||||
velocity_data = v_result.as_dict()
|
velocity_data = v_result.as_dict()
|
||||||
except Exception as _ve:
|
except Exception as _ve:
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,14 @@ Per #34 D2: утилизация objective_corpus_room_month (еженедель
|
||||||
конкурирующих ЖК в радиусе radius_km от участка, нормированный к
|
конкурирующих ЖК в радиусе radius_km от участка, нормированный к
|
||||||
ЕКБ-медиане по данным Objective.
|
ЕКБ-медиане по данным 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),
|
Foundation: domrf_kn_objects (lat/lon, comm_name, obj_class, region_cd),
|
||||||
objective_complex_mapping (domrf_obj_id ↔ objective_complex_name),
|
objective_complex_mapping (domrf_obj_id ↔ objective_complex_name),
|
||||||
objective_corpus_room_month (project_name, deals_total_vol_m2,
|
objective_corpus_room_month (project_name, deals_total_vol_m2,
|
||||||
deals_total_count, report_month).
|
deals_total_count, report_month).
|
||||||
|
Fallback: rosreestr_deals (quarter_cad_number, deal_count, period_start_date).
|
||||||
|
|
||||||
Linkage: domrf_kn_objects.obj_id
|
Linkage: domrf_kn_objects.obj_id
|
||||||
→ objective_complex_mapping.domrf_obj_id
|
→ objective_complex_mapping.domrf_obj_id
|
||||||
|
|
@ -32,6 +36,10 @@ logger = logging.getLogger(__name__)
|
||||||
# Эмпирика по ЕКБ: ~4 500 м²/мес на один ЖК (apartments, 2024-2025).
|
# Эмпирика по ЕКБ: ~4 500 м²/мес на один ЖК (apartments, 2024-2025).
|
||||||
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0
|
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0
|
||||||
|
|
||||||
|
# Порог: если доля конкурентов с Objective-маппингом < этого значения,
|
||||||
|
# пытаемся rosreestr_fallback.
|
||||||
|
_OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class VelocityResult:
|
class VelocityResult:
|
||||||
|
|
@ -47,9 +55,12 @@ 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;
|
# True если данные есть (objective или rosreestr_fallback).
|
||||||
# False → конкуренты найдены, но данных Objective нет — velocity = 0.
|
# False → нет данных ни из одного источника.
|
||||||
velocity_data_available: bool = True
|
velocity_data_available: bool = True
|
||||||
|
# Источник данных: objective (основной), rosreestr_fallback (по кадастровому кварталу),
|
||||||
|
# none (нет данных).
|
||||||
|
velocity_source: Literal["objective", "rosreestr_fallback", "none"] = "objective"
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -63,6 +74,7 @@ class VelocityResult:
|
||||||
"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,
|
"velocity_data_available": self.velocity_data_available,
|
||||||
|
"velocity_source": self.velocity_source,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,6 +84,7 @@ def compute_velocity(
|
||||||
radius_km: float = 3.0,
|
radius_km: float = 3.0,
|
||||||
obj_class: str | None = None,
|
obj_class: str | None = None,
|
||||||
months_window: int = 6,
|
months_window: int = 6,
|
||||||
|
cad_quarter: str | None = None,
|
||||||
) -> VelocityResult | None:
|
) -> VelocityResult | None:
|
||||||
"""Вычислить velocity-score для участка.
|
"""Вычислить velocity-score для участка.
|
||||||
|
|
||||||
|
|
@ -79,9 +92,14 @@ def compute_velocity(
|
||||||
1. Найти ЖК-конкуренты в радиусе radius_km (через lat/lon ST_DWithin).
|
1. Найти ЖК-конкуренты в радиусе radius_km (через lat/lon ST_DWithin).
|
||||||
2. Взять objective_corpus_room_month за последние months_window месяцев
|
2. Взять objective_corpus_room_month за последние months_window месяцев
|
||||||
через objective_complex_mapping (domrf_obj_id → project_name).
|
через 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.
|
4. Нормировать на ЕКБ-медиану → score 0..1.
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
cad_quarter: кадастровый квартал участка (первые 3 сегмента cad_num,
|
||||||
|
например "66:41:0702048"). Используется только для fallback.
|
||||||
|
|
||||||
Возвращает None если parcel_geom_wkt невалиден или конкурентов нет.
|
Возвращает None если parcel_geom_wkt невалиден или конкурентов нет.
|
||||||
"""
|
"""
|
||||||
# ── Step 1: конкуренты по lat/lon в радиусе ──────────────────────────────
|
# ── Step 1: конкуренты по lat/lon в радиусе ──────────────────────────────
|
||||||
|
|
@ -222,21 +240,21 @@ def compute_velocity(
|
||||||
if not sales_rows:
|
if not sales_rows:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Проверяем: есть ли хотя бы один конкурент с маппингом (has_mapping=True).
|
# ── Step 2a: проверка Objective coverage ─────────────────────────────────
|
||||||
# Если нет — возвращаем velocity=0 с явным флагом velocity_data_available=False,
|
# Считаем: mapped_with_data — конкуренты с маппингом И реальными данными.
|
||||||
# вместо того чтобы отбросить всех конкурентов (старый INNER JOIN поведение).
|
# Если mapped_ratio < _OBJECTIVE_COVERAGE_MIN_RATIO → rosreestr_fallback.
|
||||||
has_any_mapping = any(bool(r["has_mapping"]) for r in sales_rows)
|
n_total_comps = len(obj_ids)
|
||||||
if not has_any_mapping:
|
mapped_with_data = [
|
||||||
logger.info(
|
r for r in sales_rows if bool(r["has_mapping"]) and (r["total_sqm"] or 0.0) > 0
|
||||||
"velocity: %d competitors found but none mapped in objective_complex_mapping;"
|
]
|
||||||
" returning velocity=0 with data_available=False",
|
mapped_ratio = len(mapped_with_data) / n_total_comps if n_total_comps > 0 else 0.0
|
||||||
len(obj_ids),
|
|
||||||
)
|
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)
|
||||||
n_comps = len(comp_rows)
|
sample_no_data = sorted(
|
||||||
sample = [
|
[
|
||||||
{
|
{
|
||||||
"obj_id": oid,
|
"obj_id": oid,
|
||||||
**competitor_meta[oid],
|
**competitor_meta[oid],
|
||||||
|
|
@ -245,7 +263,31 @@ def compute_velocity(
|
||||||
}
|
}
|
||||||
for oid in obj_ids[:5]
|
for oid in obj_ids[:5]
|
||||||
if oid in competitor_meta
|
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(
|
return VelocityResult(
|
||||||
competitors_count=n_comps,
|
competitors_count=n_comps,
|
||||||
monthly_velocity_sqm=0.0,
|
monthly_velocity_sqm=0.0,
|
||||||
|
|
@ -255,9 +297,10 @@ def compute_velocity(
|
||||||
months_observed=0,
|
months_observed=0,
|
||||||
period_start="",
|
period_start="",
|
||||||
period_end="",
|
period_end="",
|
||||||
sample_competitors=sample,
|
sample_competitors=sample_no_data,
|
||||||
by_room_bucket={},
|
by_room_bucket={},
|
||||||
velocity_data_available=False,
|
velocity_data_available=False,
|
||||||
|
velocity_source="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
|
# ── 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_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 ""
|
||||||
|
|
||||||
# ── Step 3: ЕКБ-медиана ──────────────────────────────────────────────────
|
# Если mapped-конкурентов нет данных — partial coverage → fallback.
|
||||||
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:
|
if months_observed == 0 or total_sqm <= 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"velocity: %d competitors found, %d mapped, but no sales data in window;"
|
"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(obj_ids),
|
||||||
len(mapped_sales_rows),
|
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(
|
sample_partial = sorted(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -380,13 +426,14 @@ def compute_velocity(
|
||||||
sample_competitors=sample_partial,
|
sample_competitors=sample_partial,
|
||||||
by_room_bucket={},
|
by_room_bucket={},
|
||||||
velocity_data_available=False,
|
velocity_data_available=False,
|
||||||
|
velocity_source="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев.
|
# Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев.
|
||||||
# Чем больше конкурентов с данными — тем весомее результат.
|
# Чем больше конкурентов с данными — тем весомее результат.
|
||||||
monthly_velocity = total_sqm / months_observed
|
monthly_velocity = total_sqm / months_observed
|
||||||
|
|
||||||
# ── Step 4: нормализация → score 0..1 ────────────────────────────────────
|
# ── Step 3: нормализация → 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))
|
||||||
|
|
@ -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
|
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 4: confidence ───────────────────────────────────────────────────
|
||||||
mapped_conf: Literal["high", "medium", "low"]
|
mapped_conf: Literal["high", "medium", "low"]
|
||||||
if n_comps >= 10 and months_observed >= 5:
|
if n_comps >= 10 and months_observed >= 5:
|
||||||
mapped_conf = "high"
|
mapped_conf = "high"
|
||||||
|
|
@ -405,7 +452,7 @@ def compute_velocity(
|
||||||
else:
|
else:
|
||||||
mapped_conf = "low"
|
mapped_conf = "low"
|
||||||
|
|
||||||
# ── Step 6: top-5 конкурентов по объёму продаж ───────────────────────────
|
# ── Step 5: 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 mapped_sales_rows
|
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,
|
sample_competitors=sample,
|
||||||
by_room_bucket=by_room_bucket,
|
by_room_bucket=by_room_bucket,
|
||||||
velocity_data_available=True,
|
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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataAvailable = velocity.velocity_data_available !== false;
|
const dataAvailable = velocity.velocity_data_available !== false;
|
||||||
|
const isRosreestrFallback = velocity.velocity_source === "rosreestr_fallback";
|
||||||
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;
|
||||||
|
|
@ -71,6 +72,11 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
нет данных velocity
|
нет данных velocity
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isRosreestrFallback && (
|
||||||
|
<span className="bg-amber-50 text-amber-800 text-xs px-2 py-0.5 rounded">
|
||||||
|
Источник: квартальные сделки
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
padding: "2px 8px",
|
padding: "2px 8px",
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,10 @@ export interface Velocity {
|
||||||
// True если ≥1 конкурент имеет маппинг в objective_complex_mapping.
|
// True если ≥1 конкурент имеет маппинг в objective_complex_mapping.
|
||||||
// False → конкуренты найдены, velocity=0, данных Objective нет.
|
// False → конкуренты найдены, velocity=0, данных Objective нет.
|
||||||
velocity_data_available?: boolean;
|
velocity_data_available?: boolean;
|
||||||
|
// SF#17: источник данных velocity.
|
||||||
|
// 'objective' — Objective (основной), 'rosreestr_fallback' — кадастровый квартал,
|
||||||
|
// 'none' — нет данных.
|
||||||
|
velocity_source?: "objective" | "rosreestr_fallback" | "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
// G5 (#32) — Gate verdict: can_build_mkd
|
// G5 (#32) — Gate verdict: can_build_mkd
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue