Финмодель брала фиксированное окно продаж 30 мес независимо от рынка (schedule_is_default всегда true). Теперь окно считается из ЛОКАЛЬНОГО темпа поглощения, который уже вычисляется в том же /analyze, но финмодель его игнорировала. Корректность абсорбции (ключевое): velocity.monthly_velocity_sqm — это СУММА поглощения ВСЕГО конкурентного набора в радиусе (м²/мес), НЕ темп одного проекта. Поэтому per-project absorption = monthly_velocity / max(n_with_sales,1) (темп одного типичного локального продавца) — иначе модель считала бы, что новый проект забирает весь рыночный темп (дико оптимистично). Поле project_absorption_sqm_per_month добавлено в VelocityResult (objective-путь); rosreestr-fallback и вырожденные пути → None (поквартальный count без по-проектной декомпозиции не может задавать график). financial.py: окно = clamp(ceil(residential/velocity), MIN=6, MAX=120) при конечной velocity>0; иначе дефолт 30. Эскроу-инвариант сохранён: sales_end=max(sales_start+base, constr_end). Инвариант Σ cashflow == net_profit держится (перенос выручки во времени не меняет сумму). schedule_is_default флипается в false когда график рыночный; новое поле sales_duration_months (реализованное окно) для UI/PDF. Wiring: parcels.py → synthesize_parcel_financial(velocity_sqm_per_month) → compute_financial(market_velocity_sqm_per_month). Generative §1c путь пока передаёт None (out of scope, follow-up). Тесты: +13 (None→дефолт+инвариант; рыночная velocity; клампы MIN/MAX; эскроу; non-finite→fallback; rosreestr→None; инвариант по размерам окна; регресс PR-3 — ровно одна смена знака на коротком окне). Полный бэкенд: 3414 passed, 0 failed. ruff+mypy(strict financial.py) чисто. api-types перегенерены. Code-review: 2× approve, 0 majors (adversarial correctness-lens подтвердил семантику абсорбции, инвариант, не-proxy IRR, клампы, rosreestr-None). Refs #1881 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
538 lines
21 KiB
Python
538 lines
21 KiB
Python
"""Tests for velocity-score service (#34 D2).
|
||
|
||
Mock-based — не требуют живой БД.
|
||
Источник данных — objective_corpus_room_month (мигрировано с domrf_kn_sale_graph).
|
||
Mock shape совместим: sales query возвращает aliases (obj_id, total_sqm,
|
||
months_with_data, period_start, period_end, has_mapping) через LEFT JOIN
|
||
all_competitors + mapped (OBJ-2: ALL competitors included, unmapped has_mapping=False).
|
||
Третий вызов execute — bucket_rows (obj_id, room_bucket, units_sold, sqm_sold).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from app.services.site_finder.velocity import (
|
||
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
VelocityResult,
|
||
_compute_rosreestr_fallback,
|
||
compute_velocity,
|
||
)
|
||
|
||
# Тестовый WKT — небольшой квадрат в центре ЕКБ.
|
||
_PARCEL_WKT = "POINT(60.605 56.838)"
|
||
|
||
|
||
# ── Вспомогательные фабрики mock-строк ────────────────────────────────────────
|
||
|
||
|
||
def _comp_row(obj_id: int, distance_m: float = 500.0) -> MagicMock:
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k: {
|
||
"obj_id": obj_id,
|
||
"comm_name": f"ЖК-{obj_id}",
|
||
"dev_name": "TestDev",
|
||
"obj_class": "комфорт",
|
||
"district_name": "Ленинский",
|
||
"distance_m": distance_m,
|
||
}[k]
|
||
return r
|
||
|
||
|
||
def _sales_row(
|
||
obj_id: int,
|
||
total_sqm: float,
|
||
months: int,
|
||
start: str = "2024-11-01",
|
||
end: str = "2025-04-01",
|
||
has_mapping: bool = True,
|
||
) -> MagicMock:
|
||
r = MagicMock()
|
||
start_d = datetime.date.fromisoformat(start)
|
||
end_d = datetime.date.fromisoformat(end)
|
||
r.__getitem__ = lambda self, k: {
|
||
"obj_id": obj_id,
|
||
"total_sqm": total_sqm,
|
||
"months_with_data": months,
|
||
"period_start": start_d,
|
||
"period_end": end_d,
|
||
# OBJ-2: LEFT JOIN all_competitors — все конкуренты включены,
|
||
# has_mapping=True если есть маппинг в objective_complex_mapping.
|
||
"has_mapping": has_mapping,
|
||
}[k]
|
||
return r
|
||
|
||
|
||
def _bucket_row(obj_id: int, room_bucket: str, units_sold: int, sqm_sold: float) -> MagicMock:
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k: {
|
||
"obj_id": obj_id,
|
||
"room_bucket": room_bucket,
|
||
"units_sold": units_sold,
|
||
"sqm_sold": sqm_sold,
|
||
}[k]
|
||
return r
|
||
|
||
|
||
def _make_db(
|
||
comp_rows: list,
|
||
sales_rows: list,
|
||
bucket_rows: list | None = None,
|
||
) -> MagicMock:
|
||
"""Сконструировать mock Session с тремя последовательными вызовами execute.
|
||
|
||
Порядок: comp_rows → sales_rows → bucket_rows.
|
||
bucket_rows=None → пустой список (bucket query gracefully degraded).
|
||
"""
|
||
db = MagicMock()
|
||
execute_results = []
|
||
for rows in [comp_rows, sales_rows, bucket_rows if bucket_rows is not None else []]:
|
||
result = MagicMock()
|
||
result.mappings.return_value.all.return_value = rows
|
||
execute_results.append(result)
|
||
db.execute.side_effect = execute_results
|
||
return db
|
||
|
||
|
||
# ── Тесты ─────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_no_competitors_returns_none():
|
||
"""Нет ЖК в радиусе → None."""
|
||
db = _make_db(comp_rows=[], sales_rows=[])
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
assert result is None
|
||
|
||
|
||
def test_no_sales_data_returns_none():
|
||
"""ЖК есть, но нет данных objective_corpus_room_month → None."""
|
||
comp_rows = [_comp_row(1), _comp_row(2)]
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=[])
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
assert result is None
|
||
|
||
|
||
def test_healthy_sales_returns_result():
|
||
"""12 конкурентов с нормальными продажами → score в (0,1), confidence='high'."""
|
||
n = 12
|
||
comp_rows = [_comp_row(i, distance_m=300.0 + i * 100) for i in range(1, n + 1)]
|
||
# Каждый ЖК продаёт 4500 м² за 6 мес → 750 м²/мес. Суммарно: 4500*12 = 54000 за 6 мес.
|
||
sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)]
|
||
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert result.competitors_count == n
|
||
assert 0.0 < result.velocity_score <= 1.0
|
||
assert result.confidence == "high"
|
||
assert result.months_observed == 6
|
||
|
||
|
||
def test_few_competitors_low_confidence():
|
||
"""2 конкурента → confidence='low'."""
|
||
comp_rows = [_comp_row(1), _comp_row(2)]
|
||
sales_rows = [_sales_row(1, total_sqm=3000.0, months=2)]
|
||
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert result.confidence == "low"
|
||
|
||
|
||
def test_medium_confidence():
|
||
"""7 конкурентов, 4 месяца → confidence='medium'."""
|
||
n = 7
|
||
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
|
||
sales_rows = [_sales_row(i, total_sqm=4000.0, months=4) for i in range(1, n + 1)]
|
||
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert result.confidence == "medium"
|
||
|
||
|
||
def test_ekb_median_fallback_used_when_none():
|
||
"""Если _get_ekb_median вернул None — используется fallback-константа."""
|
||
comp_rows = [_comp_row(1)]
|
||
sales_rows = [_sales_row(1, total_sqm=9000.0, months=6)]
|
||
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch("app.services.site_finder.velocity._get_ekb_median", return_value=None):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert result.ekb_median_sqm == _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
||
|
||
|
||
def test_score_capped_at_1():
|
||
"""Огромный объём → score не превышает 1.0."""
|
||
comp_rows = [_comp_row(1)]
|
||
# 1 000 000 м² за месяц — абсурдно много
|
||
sales_rows = [_sales_row(1, total_sqm=6_000_000.0, months=6)]
|
||
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert result.velocity_score == pytest.approx(1.0)
|
||
|
||
|
||
def test_score_zero_when_no_sales_sqm():
|
||
"""total_sqm=0 → VelocityResult с velocity_data_available=False, score=0.
|
||
|
||
OBJ-2: функция больше не возвращает None при нулевых продажах —
|
||
возвращает «пустое» состояние (velocity_data_available=False, source='none').
|
||
Rosreestr-fallback пропускается т.к. cad_quarter не передан.
|
||
"""
|
||
comp_rows = [_comp_row(1)]
|
||
# total_sqm=0 — нет продаж → velocity_data_available=False
|
||
sales_rows = [_sales_row(1, total_sqm=0.0, months=5)]
|
||
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert result.velocity_data_available is False
|
||
assert result.velocity_score == pytest.approx(0.0)
|
||
assert result.velocity_source == "none"
|
||
|
||
|
||
def test_as_dict_structure():
|
||
"""as_dict() содержит все ожидаемые ключи, включая by_room_bucket."""
|
||
vr = VelocityResult(
|
||
competitors_count=5,
|
||
monthly_velocity_sqm=3000.0,
|
||
ekb_median_sqm=4500.0,
|
||
velocity_score=0.333,
|
||
confidence="medium",
|
||
months_observed=4,
|
||
period_start="2024-11",
|
||
period_end="2025-02",
|
||
sample_competitors=[],
|
||
by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}},
|
||
velocity_data_available=True,
|
||
velocity_source="objective",
|
||
objective_coverage_pct=66.7,
|
||
proxy_used=False,
|
||
proxy_sqm_per_deal=None,
|
||
)
|
||
d = vr.as_dict()
|
||
assert "competitors_count" in d
|
||
assert "velocity_score" in d
|
||
assert "confidence" in d
|
||
assert "period" in d
|
||
assert d["period"]["start"] == "2024-11"
|
||
assert d["period"]["end"] == "2025-02"
|
||
assert d["velocity_score"] == pytest.approx(0.333, abs=1e-3)
|
||
assert "by_room_bucket" in d
|
||
assert d["by_room_bucket"]["1"]["units"] == 10
|
||
# OBJ-2: поля velocity_data_available и velocity_source
|
||
assert d["velocity_data_available"] is True
|
||
assert d["velocity_source"] == "objective"
|
||
# SF#1871 P2: coverage% + proxy disclosure
|
||
assert d["objective_coverage_pct"] == pytest.approx(66.7)
|
||
assert d["proxy_used"] is False
|
||
assert d["proxy_sqm_per_deal"] is None
|
||
# RANK 1: project_absorption_sqm_per_month присутствует (None по умолчанию).
|
||
assert "project_absorption_sqm_per_month" in d
|
||
assert d["project_absorption_sqm_per_month"] is None
|
||
|
||
|
||
def test_sample_competitors_top5():
|
||
"""sample_competitors содержит не более 5 элементов, отсортированных по убыванию."""
|
||
n = 8
|
||
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
|
||
sales_rows = [_sales_row(i, total_sqm=float(i * 1000), months=5) for i in range(1, n + 1)]
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert len(result.sample_competitors) <= 5
|
||
sqms = [c["total_sqm_period"] for c in result.sample_competitors]
|
||
assert sqms == sorted(sqms, reverse=True)
|
||
|
||
|
||
def test_by_room_bucket_aggregation():
|
||
"""by_room_bucket агрегирует units/sqm поверх всех конкурентов корректно."""
|
||
comp_rows = [_comp_row(1), _comp_row(2)]
|
||
sales_rows = [
|
||
_sales_row(1, total_sqm=3000.0, months=3),
|
||
_sales_row(2, total_sqm=2000.0, months=3),
|
||
]
|
||
bucket_rows = [
|
||
_bucket_row(1, "студия", units_sold=38, sqm_sold=1520.0),
|
||
_bucket_row(1, "1", units_sold=22, sqm_sold=990.0),
|
||
_bucket_row(2, "студия", units_sold=18, sqm_sold=720.0),
|
||
_bucket_row(2, "1", units_sold=13, sqm_sold=585.0),
|
||
]
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows, bucket_rows=bucket_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert "студия" in result.by_room_bucket
|
||
assert "1" in result.by_room_bucket
|
||
# студия: 38+18=56 units, complexes from obj 1 and 2
|
||
assert result.by_room_bucket["студия"]["units"] == 56
|
||
assert result.by_room_bucket["студия"]["complexes_count"] == 2
|
||
# 1-к: 22+13=35 units
|
||
assert result.by_room_bucket["1"]["units"] == 35
|
||
# sqm rounded
|
||
assert result.by_room_bucket["студия"]["sqm"] == pytest.approx(2240.0)
|
||
|
||
|
||
def test_by_room_bucket_empty_when_no_bucket_data():
|
||
"""Если bucket query вернул пустой список — by_room_bucket пустой dict."""
|
||
comp_rows = [_comp_row(1)]
|
||
sales_rows = [_sales_row(1, total_sqm=5000.0, months=5)]
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows, bucket_rows=[])
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert result.by_room_bucket == {}
|
||
|
||
|
||
def test_mapping_confidence_gate_in_sales_query():
|
||
"""OBJ-2 (#307): sales/bucket queries фильтруют objective_complex_mapping
|
||
по confidence — unreviewed low-score auto-matches исключены.
|
||
|
||
Проверяем, что SQL, переданный в db.execute для маппинг-CTE, содержит
|
||
предикат gate (is_reviewed / manual / match_score >= 0.85). Это гарантирует,
|
||
что ~115 fuzzy-trgm строк с is_reviewed=false и score<0.85 не попадают в velocity.
|
||
"""
|
||
comp_rows = [_comp_row(1), _comp_row(2)]
|
||
sales_rows = [
|
||
_sales_row(1, total_sqm=4000.0, months=4),
|
||
_sales_row(2, total_sqm=3000.0, months=4),
|
||
]
|
||
bucket_rows = [_bucket_row(1, "1", units_sold=20, sqm_sold=900.0)]
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows, bucket_rows=bucket_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
# db.execute вызывается 3 раза: comp / sales / bucket. SQL берём из call_args.
|
||
executed_sql = [str(call.args[0]) for call in db.execute.call_args_list]
|
||
# comp-query НЕ трогает mapping; sales (idx 1) и bucket (idx 2) — должны иметь gate.
|
||
mapping_queries = [s for s in executed_sql if "objective_complex_mapping" in s]
|
||
assert len(mapping_queries) >= 2, "ожидаются sales + bucket запросы с mapping"
|
||
for sql in mapping_queries:
|
||
assert "cm.is_reviewed = TRUE" in sql
|
||
assert "cm.match_method = 'manual'" in sql
|
||
assert "cm.match_score >= 0.85" in sql
|
||
|
||
|
||
def test_fallback_constant_is_conservative():
|
||
"""SF#1871 P2 Fix1: fallback-медиана понижена 4500→750 (audit-verified 593-766)."""
|
||
assert _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH == pytest.approx(750.0)
|
||
|
||
|
||
def test_objective_path_surfaces_coverage_no_proxy():
|
||
"""SF#1871 P2 Fix2: objective-путь → proxy_used=False, coverage% посчитан, proxy_sqm=None."""
|
||
n = 6
|
||
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
|
||
sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)]
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert result.velocity_source == "objective"
|
||
assert result.proxy_used is False
|
||
assert result.proxy_sqm_per_deal is None
|
||
# все 6 конкурентов mapped + с данными → coverage 100%
|
||
assert result.objective_coverage_pct == pytest.approx(100.0)
|
||
d = result.as_dict()
|
||
assert d["objective_coverage_pct"] == pytest.approx(100.0)
|
||
assert d["proxy_used"] is False
|
||
assert d["proxy_sqm_per_deal"] is None
|
||
|
||
|
||
def test_rosreestr_fallback_marks_proxy_disclosure():
|
||
"""SF#1871 P2 Fix2: rosreestr_fallback → proxy_used=True, proxy_sqm_per_deal=45.0,
|
||
coverage% пробрасывается из вызывающего кода."""
|
||
row = MagicMock()
|
||
start_d = datetime.date.fromisoformat("2025-01-01")
|
||
end_d = datetime.date.fromisoformat("2025-06-01")
|
||
row.__getitem__ = lambda self, k: {
|
||
"total_deals": 60,
|
||
"period_start": start_d,
|
||
"period_end": end_d,
|
||
}[k]
|
||
result_mock = MagicMock()
|
||
result_mock.mappings.return_value.first.return_value = row
|
||
db = MagicMock()
|
||
db.execute.return_value = result_mock
|
||
|
||
result = _compute_rosreestr_fallback(
|
||
db=db,
|
||
cad_quarter="66:41:0702048",
|
||
months_window=6,
|
||
n_comps=8,
|
||
ekb_median=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
sample_competitors=[],
|
||
objective_coverage_pct=25.0,
|
||
)
|
||
|
||
assert result is not None
|
||
assert result.velocity_source == "rosreestr_fallback"
|
||
assert result.proxy_used is True
|
||
assert result.proxy_sqm_per_deal == pytest.approx(45.0)
|
||
assert result.objective_coverage_pct == pytest.approx(25.0)
|
||
d = result.as_dict()
|
||
assert d["proxy_used"] is True
|
||
assert d["proxy_sqm_per_deal"] == pytest.approx(45.0)
|
||
assert d["objective_coverage_pct"] == pytest.approx(25.0)
|
||
|
||
|
||
def test_objective_path_sets_project_absorption_per_competitor():
|
||
"""RANK 1: objective-путь выставляет project_absorption = monthly_velocity /
|
||
n_with_sales (темп ОДНОГО типового конкурента), а НЕ суммарную скорость района."""
|
||
n = 6
|
||
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
|
||
# Каждый ЖК: 4500 м² за 6 мес = 750 м²/мес. 6 ЖК → суммарно 4500 м²/мес района.
|
||
sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)]
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
# monthly_velocity_sqm = 6 × 750 = 4500 (суммарно по району).
|
||
assert result.monthly_velocity_sqm == pytest.approx(4500.0)
|
||
# project_absorption = 4500 / 6 = 750 (один типовой конкурент) — НЕ 4500.
|
||
assert result.project_absorption_sqm_per_month == pytest.approx(750.0)
|
||
assert result.as_dict()["project_absorption_sqm_per_month"] == pytest.approx(750.0)
|
||
|
||
|
||
def test_objective_path_project_absorption_none_when_no_sales():
|
||
"""RANK 1: если активных продаж нет (monthly_velocity=0) → absorption None."""
|
||
comp_rows = [_comp_row(1)]
|
||
sales_rows = [_sales_row(1, total_sqm=0.0, months=5)]
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
assert result.project_absorption_sqm_per_month is None
|
||
assert result.as_dict()["project_absorption_sqm_per_month"] is None
|
||
|
||
|
||
def test_rosreestr_fallback_project_absorption_is_none():
|
||
"""RANK 1: rosreestr_fallback — квартальный count БЕЗ per-project декомпозиции →
|
||
project_absorption None (per-project absorption там ill-defined, НЕ драйвит DCF)."""
|
||
row = MagicMock()
|
||
start_d = datetime.date.fromisoformat("2025-01-01")
|
||
end_d = datetime.date.fromisoformat("2025-06-01")
|
||
row.__getitem__ = lambda self, k: {
|
||
"total_deals": 60,
|
||
"period_start": start_d,
|
||
"period_end": end_d,
|
||
}[k]
|
||
result_mock = MagicMock()
|
||
result_mock.mappings.return_value.first.return_value = row
|
||
db = MagicMock()
|
||
db.execute.return_value = result_mock
|
||
|
||
result = _compute_rosreestr_fallback(
|
||
db=db,
|
||
cad_quarter="66:41:0702048",
|
||
months_window=6,
|
||
n_comps=8,
|
||
ekb_median=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
sample_competitors=[],
|
||
objective_coverage_pct=25.0,
|
||
)
|
||
|
||
assert result is not None
|
||
assert result.velocity_source == "rosreestr_fallback"
|
||
assert result.project_absorption_sqm_per_month is None
|
||
assert result.as_dict()["project_absorption_sqm_per_month"] is None
|
||
|
||
|
||
def test_sample_competitors_include_by_room_bucket():
|
||
"""sample_competitors каждого элемента содержит by_room_bucket."""
|
||
comp_rows = [_comp_row(1), _comp_row(2)]
|
||
sales_rows = [
|
||
_sales_row(1, total_sqm=6000.0, months=4),
|
||
_sales_row(2, total_sqm=4000.0, months=4),
|
||
]
|
||
bucket_rows = [
|
||
_bucket_row(1, "2", units_sold=30, sqm_sold=1800.0),
|
||
_bucket_row(2, "2", units_sold=20, sqm_sold=1200.0),
|
||
]
|
||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows, bucket_rows=bucket_rows)
|
||
|
||
with patch(
|
||
"app.services.site_finder.velocity._get_ekb_median",
|
||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||
):
|
||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||
|
||
assert result is not None
|
||
for comp in result.sample_competitors:
|
||
assert "by_room_bucket" in comp
|
||
# obj_id=1 had bucket data
|
||
top = result.sample_competitors[0]
|
||
assert top["obj_id"] == 1
|
||
assert top["by_room_bucket"].get("2") == 30
|