gendesign/backend/tests/api/v1/test_parcel_competitors.py
Light1YT e67cb721bf fix(objective): physflat-dedup current-state consumers + honest n_sold window (#1964)
objective_lots — current-state UPSERT (UNIQUE objective_lot_id, 5 snapshot_date),
но Объектив присваивает ОДНОМУ физлоту (project,corpus,section,floor,lot_number)
несколько lot_id за пере-листинги → таблица раздута ~2.91× (прод: 1.75M квартир-
строк vs 603k физлотов). История — в отдельной objective_lots_history (не трогаем).

STEP 1: миграция 175 — VIEW v_objective_lots_latest (DISTINCT ON physflat-ключ,
последний снапшот). ol.* стабилен (51==51 колонок). DRY-RUN на проде: 603 049
квартир vs 1 753 283 raw.

STEP 2: репойнт current-state консьюмеров objective_lots → v_objective_lots_latest:
- supply_layers._L1_OPEN_SQL (L1 открытое предложение → дефицит-форсайт; прод:
  Юго-Западный комфорт 58 606 → 12 620)
- competitors._SOLD_COUNT_SQL (+ комментарий: COUNT(DISTINCT lot_id) СОХРАНЁН для
  fan-out-защиты маппинга, не COUNT(*) — view гарантирует physflat-дедуп, DISTINCT
  гарантирует mapping-fan-out-safety)
- parcels.py obj_pricing CTE (карточка конкурента units_sold/available)
- special_indices._ARTIFICIAL_DEMAND_SQL
- parcels.py district price block + geo-radius median (sample_size/n)
- concepts._OBJECTIVE_MEDIAN_SQL (гейт n≥10)
- landing KPI3 (% квартир с ценой)
- admin_scrape coverage: `lots` оставлен сырым (ETL-fidelity vs SQLite), добавлен
  `lots_physflat`
#1959 inline-дедуп в market_metrics НЕ рефакторим — добавлен комментарий об общем
physflat-ключе с view.

STEP 3: report_assembler._deal_count теперь = unit_velocity × window_months
(оконные продажи), НЕ кумулятивный n_sold. confidence_engine помечает фактор
«за 6 мес» и гейтит порогами окна (high≥50) — кумулятив (прод EKB ~380 921) делал
гейт бессмысленным и подпись лживой; оконное (~24 876 за 6 мес) честно.

Тесты: +guard'ы (L1/sold-count читают view, deal_count оконный). 963 passed.
2026-06-28 04:27:43 +05:00

635 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты для POST /api/v1/parcels/{cad_num}/competitors (Issue #112).
Mock-based — не требуют живой БД.
Паттерн mock DB: аналогично test_admin_cadastre.py — dependency_overrides[get_db].
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.main import app
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
def _coord_row(lat: float = 56.838, lon: float = 60.605) -> MagicMock:
"""Строка центроида участка."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"lat": lat, "lon": lon}[k]
return r
def _obj_row(
obj_id: int = 1,
distance_m: float = 400.0,
site_status: str = "Строящиеся",
obj_class: str | None = "comfort",
velocity: float = 5.0,
flat_count: int | None = 200,
) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {
"obj_id": obj_id,
"comm_name": f"ЖК-{obj_id}",
"dev_name": "TestDev",
"obj_class": obj_class,
"latitude": 56.838 + distance_m / 1_000_000,
"longitude": 60.605 + distance_m / 1_000_000,
"flat_count": flat_count,
"site_status": site_status,
"distance_m": distance_m,
"velocity_per_month": velocity,
}[k]
return r
def _price_row(obj_id: int, price: float) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "avg_price_per_m2": price}[k]
return r
def _sold_row(obj_id: int, flats_sold: int) -> MagicMock:
"""Строка sold-count (_SOLD_COUNT_SQL, #1926).
flats_sold теперь COUNT проданных лотов из objective_lots.contract_date
(выровнено с MarketMetrics), а не COUNT по domrf_kn_flats.status — последний
заполнен ~0.2% и давал массовый недосчёт.
"""
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "flats_sold": flats_sold}[k]
return r
def _obj_price_row(obj_id: int, price: float) -> MagicMock:
"""Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307)."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "median_price_per_m2": price}[k]
return r
# ── Построение mock DB ────────────────────────────────────────────────────────
def _make_db(
coord: MagicMock | None = None,
obj_rows: list[MagicMock] | None = None,
price_rows: list[MagicMock] | None = None,
sold_rows: list[MagicMock] | None = None,
obj_price_rows: list[MagicMock] | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Порядок вызовов execute (#1926: добавлен отдельный sold-count запрос):
1. centroid query → coord
2. competitors query → obj_rows
3. domrf avg_price query → price_rows
4. sold-count query (_SOLD_COUNT_SQL, objective_lots.contract_date) → sold_rows
(по умолчанию [] = sold неизвестен → flats_sold None, нейтральный stage)
5. objective price fallback (OBJ-3 #307) → obj_price_rows
(вызывается ТОЛЬКО если у части obj_id нет domrf-цены — почти всегда,
т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст)
"""
db = MagicMock()
results: list[MagicMock] = []
for rows, is_first in [
(coord, True),
(obj_rows or [], False),
(price_rows or [], False),
(sold_rows or [], False),
(obj_price_rows or [], False),
]:
result = MagicMock()
if is_first:
# centroid → .mappings().first()
result.mappings.return_value.first.return_value = rows
else:
result.mappings.return_value.all.return_value = rows
results.append(result)
db.execute.side_effect = results
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
# ── Тесты ─────────────────────────────────────────────────────────────────────
def test_competitors_basic() -> None:
"""3 конкурента → корректная форма ответа, сортировка по distance."""
rows = [
_obj_row(obj_id=1, distance_m=200.0, velocity=4.0),
_obj_row(obj_id=2, distance_m=500.0, velocity=6.0),
_obj_row(obj_id=3, distance_m=900.0, velocity=2.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:123/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "competitors" in body
assert "summary" in body
assert len(body["competitors"]) == 3
# первый должен быть ближайшим
assert body["competitors"][0]["obj_id"] == 1
assert body["competitors"][0]["distance_m"] == pytest.approx(200.0)
# все поля присутствуют
first = body["competitors"][0]
for key in (
"obj_id",
"comm_name",
"dev_name",
"obj_class",
"distance_m",
"lat",
"lng",
"stage",
"flats_total",
"flats_sold",
"sold_pct",
"velocity_per_month",
"avg_price_per_m2",
"is_active",
):
assert key in first, f"missing key: {key}"
finally:
app.dependency_overrides.clear()
def test_competitors_summary_calc() -> None:
"""summary: total_competitors, active_count, weighted_avg_velocity корректны.
site_status — реальные русские значения как у домрф (#1213 fix).
«Строящиеся» считаются активными, «Сданные» — нет.
"""
rows = [
_obj_row(obj_id=1, site_status="Строящиеся", velocity=10.0),
_obj_row(obj_id=2, site_status="Строящиеся", velocity=6.0),
_obj_row(obj_id=3, site_status="Сданные", velocity=2.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
summary = resp.json()["summary"]
assert summary["total_competitors"] == 3
assert summary["active_count"] == 2 # sales + construction
# equal flats_total (200 each) → size-weighted AVG == naive: (10+6+2)/3 = 6.0
assert summary["weighted_avg_velocity"] == pytest.approx(6.0)
assert summary["radius_km"] == pytest.approx(1.0)
assert summary["time_window"] == "last_quarter"
finally:
app.dependency_overrides.clear()
def test_competitors_weighted_velocity_by_size() -> None:
"""weighted_avg_velocity взвешен по flats_total — крупный ЖК весит больше (#949)."""
rows = [
_obj_row(obj_id=1, velocity=40.0, flat_count=500),
_obj_row(obj_id=2, velocity=2.0, flat_count=20),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
wav = resp.json()["summary"]["weighted_avg_velocity"]
# size-weighted = (40*500 + 2*20) / (500+20) = 20040/520 ≈ 38.54,
# НЕ наивное среднее (40+2)/2 = 21.0 — доказывает взвешивание по flats_total.
assert wav == pytest.approx(38.54, abs=0.05)
assert wav != pytest.approx(21.0, abs=0.1)
finally:
app.dependency_overrides.clear()
def test_competitors_exclude_obj_ids() -> None:
"""exclude_obj_ids исключает указанные ЖК из результата."""
rows = [
_obj_row(obj_id=1, distance_m=100.0),
_obj_row(obj_id=2, distance_m=200.0),
_obj_row(obj_id=3, distance_m=300.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter", "exclude_obj_ids": [2]},
)
assert resp.status_code == 200, resp.text
ids = [c["obj_id"] for c in resp.json()["competitors"]]
assert 2 not in ids
assert 1 in ids
assert 3 in ids
finally:
app.dependency_overrides.clear()
def test_competitors_obj_class_filter() -> None:
"""obj_class_filter=economy — SQL получает параметр; Python-сторона не ломается."""
rows = [_obj_row(obj_id=10, obj_class="economy")]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter", "obj_class_filter": "economy"},
)
assert resp.status_code == 200, resp.text
comps = resp.json()["competitors"]
assert len(comps) == 1
assert comps[0]["obj_class"] == "economy"
finally:
app.dependency_overrides.clear()
def test_competitors_time_window_velocity() -> None:
"""time_window влияет на velocity_per_month (last_month vs last_year)."""
# Здесь проверяем, что endpoint принимает оба варианта без ошибок
# и возвращает velocity из mock-строки (DB-расчёт мокирован).
rows_month = [_obj_row(obj_id=1, velocity=12.0)]
rows_year = [_obj_row(obj_id=1, velocity=3.0)]
from app.core.db import get_db
# last_month
db_m = _make_db(coord=_coord_row(), obj_rows=rows_month)
app.dependency_overrides[get_db] = _override_db(db_m)
try:
client = TestClient(app)
resp_m = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_month"},
)
assert resp_m.status_code == 200, resp_m.text
finally:
app.dependency_overrides.clear()
# last_year
db_y = _make_db(coord=_coord_row(), obj_rows=rows_year)
app.dependency_overrides[get_db] = _override_db(db_y)
try:
client = TestClient(app)
resp_y = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_year"},
)
assert resp_y.status_code == 200, resp_y.text
v_month = resp_m.json()["competitors"][0]["velocity_per_month"]
v_year = resp_y.json()["competitors"][0]["velocity_per_month"]
# month velocity выше чем year в нашем моке
assert v_month > v_year
finally:
app.dependency_overrides.clear()
def test_competitors_parcel_not_found_404() -> None:
"""Если центроид участка не найден → 404."""
db = _make_db(coord=None) # first() вернёт None
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/99:99:9999999:999/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 404, resp.text
finally:
app.dependency_overrides.clear()
def test_competitors_empty_radius() -> None:
"""Нет конкурентов в радиусе → пустой список + summary с нулями."""
db = _make_db(coord=_coord_row(), obj_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 0.1, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["competitors"] == []
assert body["summary"]["total_competitors"] == 0
assert body["summary"]["active_count"] == 0
assert body["summary"]["weighted_avg_velocity"] == pytest.approx(0.0)
finally:
app.dependency_overrides.clear()
def test_competitors_sold_pct_null() -> None:
"""sold_pct и flats_sold — None, когда sold-count запрос пуст (graceful).
#1926: flats_sold берётся из objective_lots.contract_date (_SOLD_COUNT_SQL).
Если у obj_id нет маппинга в objective_complex_mapping (coverage ~2.5%) →
sold-count строки нет → flats_sold/sold_pct None, stage → нейтраль 0.5.
"""
rows = [_obj_row(obj_id=1, flat_count=200)]
db = _make_db(coord=_coord_row(), obj_rows=rows) # sold_rows default [] → None
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["flats_sold"] is None
assert comp["sold_pct"] is None
assert comp["flats_total"] == 200
finally:
app.dependency_overrides.clear()
def test_competitors_flats_sold_from_objective_lots() -> None:
"""flats_sold/sold_pct заполняются из objective_lots (#1926).
Регрессия аудита #1871/#1926: РАНЬШЕ flats_sold считался из
domrf_kn_flats.status (LIKE '%прод%'), заполненного ~0.2% → массовый
недосчёт и расхождение с MarketMetrics. Теперь источник И предикат — как
в market_metrics._STOCK_SQL (is_sold OR contract_date OR status='продан',
premise_kind='квартира'). Здесь sold-count запрос возвращает 120 проданных
из 200 → sold_pct 60.0%. (SQL-структуру предиката/fan-out/premise_kind
проверяют test_sold_count_sql_* ниже — мок тут SQL не исполняет.)
"""
rows = [_obj_row(obj_id=1, flat_count=200)]
sold_rows = [_sold_row(obj_id=1, flats_sold=120)]
db = _make_db(coord=_coord_row(), obj_rows=rows, sold_rows=sold_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["flats_sold"] == 120
assert comp["sold_pct"] == pytest.approx(60.0)
assert comp["flats_total"] == 200
finally:
app.dependency_overrides.clear()
def test_competitors_avg_price_populated() -> None:
"""avg_price_per_m2 не None если domrf_kn_flats возвращает строки с ценой.
Регрессионный тест для Issue #227: фильтр status='sold' давал 0 строк
(поле status в domrf_kn_flats 99.8% NULL). После фикса — убран, AVG
считается по всем квартирам с known price_per_m2.
"""
rows = [_obj_row(obj_id=1)]
price_rows = [_price_row(obj_id=1, price=150_000.0)]
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(
150_000.0
), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
# OBJ-3 #307: domrf-hit → price_source='domrf'.
assert comp["price_source"] == "domrf"
finally:
app.dependency_overrides.clear()
# ── OBJ-3 (#307) additive fallback из objective_lots ────────────────────────────
def test_competitors_objective_price_fallback_fills_gap() -> None:
"""domrf-цены НЕТ → objective_lots заполняет avg_price_per_m2 (OBJ-3 #307).
Прод 2026-06: domrf_kn_flats.price_per_m2 на последнем snapshot = 0 строк,
поэтому domrf даёт None почти всем. objective_lots (через
objective_complex_mapping) покрывает ~80% — этот fallback восстанавливает цену.
"""
rows = [_obj_row(obj_id=1)]
# domrf price пуст → fallback на objective median.
obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(136_000.0)
assert comp["price_source"] == "objective"
finally:
app.dependency_overrides.clear()
def test_competitors_domrf_price_wins_over_objective() -> None:
"""domrf-цена приоритетна: objective НЕ перетирает существующий domrf-вывод.
Additive-контракт OBJ-3: где domrf есть → не трогаем; objective только пробелы.
"""
rows = [_obj_row(obj_id=1)]
price_rows = [_price_row(obj_id=1, price=150_000.0)]
# objective тоже вернул бы цену, но domrf уже есть → fallback её игнорирует.
obj_price_rows = [_obj_price_row(obj_id=1, price=99_000.0)]
db = _make_db(
coord=_coord_row(),
obj_rows=rows,
price_rows=price_rows,
obj_price_rows=obj_price_rows,
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0)
assert comp["price_source"] == "domrf"
finally:
app.dependency_overrides.clear()
def test_competitors_no_price_anywhere_is_none() -> None:
"""Ни domrf, ни objective → avg_price_per_m2 None, price_source None (graceful)."""
rows = [_obj_row(obj_id=1)]
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] is None
assert comp["price_source"] is None
finally:
app.dependency_overrides.clear()
def test_competitors_is_active_flag() -> None:
"""is_active=True для русского 'Строящиеся' (реальная prod-форма #1213),
False для 'Сданные'/null/прочих."""
rows = [
_obj_row(obj_id=1, site_status="Строящиеся"),
_obj_row(obj_id=2, site_status="Строящиеся"),
_obj_row(obj_id=3, site_status="Сданные"),
]
db = _make_db(coord=_coord_row(), obj_rows=rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
assert comps[1]["is_active"] is True
assert comps[2]["is_active"] is True
assert comps[3]["is_active"] is False
finally:
app.dependency_overrides.clear()
# ── _SOLD_COUNT_SQL структура (#1926 / code-review #1927) ───────────────────────
# Mock-тесты выше НЕ исполняют реальный SQL (db.execute замокан) → не ловят
# fan-out маппинга, отсутствие premise_kind-фильтра и рассинхрон sold-предиката
# с MarketMetrics. Эти проверки — статический guard на текст _SOLD_COUNT_SQL:
# регресс к COUNT(*) / потеря premise_kind / усечение предиката упадёт здесь.
# (Полноценный fan-out-тест с двумя mapping-строками на один obj_id требует
# живой БД — он в tests/integration/test_phantom_columns.py, skip без TEST_DB.)
def _sold_sql_text() -> str:
from app.services.site_finder.competitors import _SOLD_COUNT_SQL
return str(_SOLD_COUNT_SQL)
def test_sold_count_sql_is_fanout_safe() -> None:
"""COUNT(DISTINCT objective_lot_id) — маппинг 1:N по obj_id не задваивает.
objective_complex_mapping UNIQUE = (objective_complex_name, objective_group),
НЕ domrf_obj_id → один obj_id может иметь несколько mapping-строк (то же имя,
разный objective_group) → JOIN по project_name задвоил бы лоты. DISTINCT по
objective_lot_id дедуплицирует на уровне лота. Регресс к COUNT(*) → fail.
#1964: даже после перехода на v_objective_lots_latest (где objective_lot_id
уникален → COUNT(DISTINCT)==COUNT(*) по физлотам) DISTINCT СОХРАНЯЕТСЯ: будущий
fan-out маппинга задвоил бы строки view, и COUNT(*) перестал бы быть fan-out-safe.
"""
sql = _sold_sql_text()
assert (
"COUNT(DISTINCT ol.objective_lot_id)" in sql
), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге"
def test_sold_count_sql_reads_physflat_view() -> None:
"""#1964: sold-count читает physflat-дедуп view, НЕ сырой objective_lots.
Сырой objective_lots раздут ~2.91× (мульти objective_lot_id на один физлот через
пере-листинги) → flats_sold завышался. Репойнт на v_objective_lots_latest даёт по
строке на физический лот. Регресс к `FROM objective_lots` (без _latest) → fail.
"""
sql = _sold_sql_text()
assert "v_objective_lots_latest" in sql, "должен читать physflat-дедуп view (#1964)"
assert "JOIN objective_lots " not in sql, "не должен джойнить сырой objective_lots"
def test_sold_count_sql_filters_premise_kind() -> None:
"""premise_kind-фильтр (как в MarketMetrics._STOCK_SQL) — без него проданные
паркинги/кладовые/нежилое считались бы проданными квартирами (over-count)."""
sql = _sold_sql_text()
assert "ol.premise_kind = :premise_kind" in sql
def test_sold_count_sql_predicate_matches_market_metrics() -> None:
"""sold-предикат побайтово совпадает с MarketMetrics: is_sold OR contract_date
OR status='продан' (Объектив заполняет поля неконсистентно → берём их ИЛИ)."""
sql = _sold_sql_text()
assert "ol.is_sold IS TRUE" in sql
assert "ol.contract_date IS NOT NULL" in sql
assert "LOWER(COALESCE(ol.status, '')) = 'продан'" in sql