From a5cc24dc8ac4724b09e1f9f22659b534503295ad Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 01:33:54 +0500 Subject: [PATCH] feat(site-finder): supply_layers v2 compute service (#950 EPIC6 step3+4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic 3-layer склад предложения (ТЗ §9.3), read-only compute returning SupplyLayerRow dataclasses for the #950 Step5 worker to UPSERT into supply_layers (m.125). Mirrors market_metrics.py house style (frozen dataclass, _SQL consts, graceful-to-empty, pure helpers, CAST(:x AS type)). - L1 open: objective_lots available lots per district x class (objective/high) - L2 hidden: SUM max(0, flat_count - free_flats) per district, mapping-free via domrf_kn_objects (free_flats m.124) - no domrf<->objective join (domrf_declared, medium/low by free_flats coverage). NULL free_flats excluded (no sold column). - L3 future: domrf multiphase (ready_dt beyond 12mo horizon, ~no listed flats) grouped by dev_group_name (domrf_multiphase/low). graddoc_stub deferred to #956. Latest-snapshot DISTINCT ON guards against cross-snapshot double-count (domrf_kn_objects is UNIQUE(obj_id, snapshot_date)). Only whitelisted source/confidence values (m.125 CHECK). 45 unit tests, ruff clean. --- .../app/services/site_finder/supply_layers.py | 480 +++++++++++++++++ .../site_finder/test_supply_layers.py | 505 ++++++++++++++++++ 2 files changed, 985 insertions(+) create mode 100644 backend/app/services/site_finder/supply_layers.py create mode 100644 backend/tests/services/site_finder/test_supply_layers.py diff --git a/backend/app/services/site_finder/supply_layers.py b/backend/app/services/site_finder/supply_layers.py new file mode 100644 index 00000000..7f74eaa7 --- /dev/null +++ b/backend/app/services/site_finder/supply_layers.py @@ -0,0 +1,480 @@ +"""Supply-layers service — детерминированный расчёт 3-слойного склада предложения. + +#950 (Site Finder v2 / GG-форсайт, EPIC 6 «3-layer склад предложения», ТЗ §9.3). +Это **измерительный слой**: считает агрегаты текущего/скрытого/будущего предложения +по districts и ВОЗВРАЩАЕТ их dataclass'ами. В таблицу `supply_layers` (м.125) их +UPSERT-ит worker #950 Step 5 — ЭТОТ сервис НИЧЕГО не пишет (read-only compute). + +Принцип: **детерминированно, без LLM** — чистый set-based SQL + арифметика +(зеркало `market_metrics.py`). NO LLM-импортов нигде. + +Три слоя (ТЗ §9.3 / м.125): + • layer1 (open) — открытое предложение в текущей продаже. Источник: `objective_lots` + (выставленные лоты). source='objective', confidence='high'. + • layer2 (hidden) — скрытый запас застройщика, ЗАЯВЛЕННЫЙ в ПД но НЕ выставленный + сейчас: per dom.рф-объект hidden = max(0, flat_count − free_flats). Обе колонки на + ОДНОЙ строке `domrf_kn_objects` (free_flats — м.124, #950 PR A) → **mapping-free**: + domrf↔objective маппинг покрывает ~2.5% и здесь НЕ нужен (это и есть весь смысл + разблокировки free_flats). source='domrf_declared'. + • layer3 (future) — будущее предложение, ещё НЕ на рынке: multiphase-проекты dom.рф + (ready_dt далеко в будущем + почти нет выставленных квартир). source='domrf_multiphase', + expected_online_date=ready_dt, confidence='low'. + +Whitelisted значения (CHECK в м.125 — иначе worker'ский INSERT падает ГРОМКО): + source ∈ {objective, domrf_declared, domrf_multiphase, graddoc_stub, insider_manual}; + confidence ∈ {high, medium, low}. Этот сервис эмитит только objective / domrf_declared / + domrf_multiphase. Строки graddoc_stub / insider_manual сюда НЕ приходят — они + живут в таблице независимо (поэтому м.125 — TABLE, не MV). + +TODO(#956): источник 'graddoc_stub' (layer3) — граддок-NLP, LLM-фаза, отложена. + insider_manual — ручной ввод. Оба вставляются ВНЕ этого сервиса (см. м.125 docstring). + +Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics): пустые/тонкие данные → +пустой список строк (НЕ crash). Когда строка есть, но сигнал слабый → confidence='low'. + +────────────────────────────────────────────────────────────────────────────── +Колонки, проверенные по реальной схеме (НЕ предполагались): + domrf_kn_objects (43_anton_import + м.57 + м.113 + м.124): + obj_id, snapshot_date, region_cd (66=ЕКБ), district_name (м.57, PostGIS spatial-join, + NULL вне 8 районов ЕКБ), flat_count, free_flats (м.124, NULLABLE — старые снапшоты), + obj_class, square_living, ready_dt, dev_group_name (м.113), site_status. + objective_lots (м.68): objective_lot_id, project_name, district, premise_kind, class + (obj_class, lowercase), is_sold, status, contract_date, area_pd, snapshot_date. + +Известные ограничения (degraded-gracefully): + • L2 fallback: у `domrf_kn_objects` НЕТ колонки `sold` (проверено: 43_anton_import, + м.108-audit). Поэтому при free_flats IS NULL hidden НЕ вычислим (flat_count в одиночку + бессмыслен). Такие объекты ИСКЛЮЧАЮТСЯ из L2 (preferred path из ТЗ — чище, чем + выдуманный sold-proxy) → coverage снижается → confidence падает с medium до low. + • L2 district-resolution: используем domrf_kn_objects.district_name НАПРЯМУЮ (м.57 + spatial-join по lat/long в полигоны ekb_districts_geom). Это самый прямой путь — + БЕЗ complexes/complex_sources join (которые сами строятся из этой же колонки, м.73). + Объекты с district_name IS NULL (вне 8 районов ЕКБ / без координат) в district-grain + L2 НЕ попадают (documented gap). + • Per-ЖК L2 (complex_id grain) НЕ эмитим: это потребовало бы domrf↔objective маппинг + (2.5% покрытие) — district-агрегат и есть deliverable (ТЗ §9.3). +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import date +from typing import Any, Literal + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +Confidence = Literal["high", "medium", "low"] +Source = Literal["objective", "domrf_declared", "domrf_multiphase"] + +# Регион данных (ЕКБ / Свердловская обл.). district_name на domrf_kn_objects заполнен +# spatial-join'ом только для region_cd=66 (м.57) — фильтруем по нему явно. +_EKB_REGION_CODE: int = 66 + +# L2: доля объектов района с заполненным free_flats, выше которой доверяем 'medium'. +# Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations). +_L2_MEDIUM_MIN_COVERAGE: float = 0.6 + +# L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже +# почти-на-рынке предложение (ловится L1/L2), не «future». +_L3_FUTURE_HORIZON_MONTHS: int = 12 + +# L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго +# 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт. +_L3_MAX_FREE_FLATS_NOT_SELLING: int = 5 + + +@dataclass(frozen=True) +class SupplyLayerRow: + """Одна строка склада предложения — зеркало колонок таблицы `supply_layers` (м.125). + + Grain: district_name (primary) + optional obj_class. complex_id здесь всегда None + (per-ЖК grain — это L3-stubs/reviewed-L2, вне этого сервиса). expected_online_date + заполнен только для layer3 (когда предложение выйдет на рынок); для L1/L2 — None. + + source/confidence — ТОЛЬКО whitelisted значения (CHECK м.125). units_estimate / + area_estimate — NULLABLE (None при отсутствии дешёвого сигнала, НЕ 0-как-заглушка). + """ + + layer: Literal[1, 2, 3] + district_name: str | None + complex_id: int | None + obj_class: str | None + units_estimate: int | None + area_estimate: float | None + expected_online_date: date | None + source: Source + confidence: Confidence + method: str + snapshot_date: date | None # None → worker подставит DEFAULT CURRENT_DATE на write + + def as_dict(self) -> dict[str, Any]: + return { + "layer": self.layer, + "district_name": self.district_name, + "complex_id": self.complex_id, + "obj_class": self.obj_class, + "units_estimate": self.units_estimate, + "area_estimate": _round_or_none(self.area_estimate, 1), + "expected_online_date": self.expected_online_date, + "source": self.source, + "confidence": self.confidence, + "method": self.method, + "snapshot_date": self.snapshot_date, + } + + +def _round_or_none(value: float | None, digits: int) -> float | None: + return round(value, digits) if value is not None else None + + +# ────────────────────────────────────────────────────────────────────────────── +# Pure-арифметика — без БД, полностью юнит-тестируемо. Каждая функция graceful. +# ────────────────────────────────────────────────────────────────────────────── + + +def _hidden_units(flat_count: int | None, free_flats: int | None) -> int | None: + """L2 per-объект hidden = max(0, flat_count − free_flats). Clamp ≥ 0 ВСЕГДА. + + None при отсутствии любого из слагаемых — такой объект нельзя посчитать и он + ИСКЛЮЧАЕТСЯ из L2-агрегата (free_flats IS NULL → нет сигнала, sold-колонки нет). + Никогда не эмитим отрицательное hidden (free_flats > flat_count из-за рассинхрона + снапшотов/перепланировки — клампим в 0, не в негатив). + """ + if flat_count is None or free_flats is None: + return None + return max(0, int(flat_count) - int(free_flats)) + + +def _l2_confidence(n_objects_total: int, n_objects_with_free_flats: int) -> Confidence: + """L2 confidence по покрытию free_flats (ТЗ §9.3 + м.124 fallback-note). + + 'medium' когда free_flats заполнен у БОЛЬШИНСТВА объектов района (надёжный hidden); + 'low' когда заполнен у меньшинства (много исключённых NULL-объектов → недооценка + скрытого запаса) или объектов нет вовсе. НИКОГДА 'high' — L2 это оценка по ПД, не + факт продаж (high зарезервирован за L1/наблюдаемым). + """ + if n_objects_total <= 0 or n_objects_with_free_flats <= 0: + return "low" + coverage = n_objects_with_free_flats / n_objects_total + return "medium" if coverage >= _L2_MEDIUM_MIN_COVERAGE else "low" + + +# ────────────────────────────────────────────────────────────────────────────── +# SQL — все bind-параметры через CAST(:x AS type) (psycopg v3; НИКОГДА :x::type). +# ────────────────────────────────────────────────────────────────────────────── + +# ── Layer1: открытое предложение (objective_lots), per район × класс ─────────── +# Доступно = выставлено и НЕ продано. is_sold распознаём И флагом, И статусом 'продан' +# / наличием contract_date (Объектив заполняет неконсистентно — зеркало _STOCK_SQL). +# Группируем по (district, class). class — lowercase в lots (м.68 коммент). +_L1_OPEN_SQL = text( + """ + WITH lots AS ( + SELECT + ol.district, + ol.class AS obj_class, + ol.area_pd, + ( + ol.is_sold IS TRUE + OR ol.contract_date IS NOT NULL + OR LOWER(COALESCE(ol.status, '')) = 'продан' + ) AS sold_now + FROM objective_lots ol + WHERE ol.premise_kind = :premise_kind + AND ( + CAST(:district AS text) IS NULL + OR ol.district = CAST(:district AS text) + ) + ) + SELECT + district, + obj_class, + COUNT(*) FILTER (WHERE NOT sold_now) AS units_available, + COALESCE(SUM(area_pd) FILTER (WHERE NOT sold_now), 0) AS area_available + FROM lots + WHERE district IS NOT NULL + GROUP BY district, obj_class + HAVING COUNT(*) FILTER (WHERE NOT sold_now) > 0 + """ +) + +# ── Layer2: скрытый запас (domrf_kn_objects ТОЛЬКО — mapping-free), per район ─── +# Берём свежий снапшот на obj_id (DISTINCT ON), затем агрегируем Σ max(0, flat_count − +# free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим; +# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с +# free_flats) для confidence. NO join на objective_lots / complexes — намеренно. +_L2_HIDDEN_SQL = text( + """ + WITH latest AS ( + SELECT DISTINCT ON (o.obj_id) + o.obj_id, + o.district_name, + o.flat_count, + o.free_flats + FROM domrf_kn_objects o + WHERE o.region_cd = :region_cd + AND o.district_name IS NOT NULL + AND ( + CAST(:district AS text) IS NULL + OR o.district_name = CAST(:district AS text) + ) + ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST + ) + SELECT + district_name, + COUNT(*) AS n_objects_total, + COUNT(*) FILTER (WHERE free_flats IS NOT NULL) AS n_with_free_flats, + COALESCE(SUM( + GREATEST(0, COALESCE(flat_count, 0) - free_flats) + ) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units + FROM latest + GROUP BY district_name + """ +) + +# ── Layer3: будущее предложение (domrf_kn_objects multiphase) ─────────────────── +# Свежий снапшот на obj_id; «future» = ready_dt дальше горизонта И выставлено ≤ порога +# квартир (free_flats мал/0 → проект объявлен, но ещё не открыт). Группируем по +# (район, dev_group_name) — поздние очереди застройщика. expected_online_date = MIN +# ready_dt группы (ближайший ввод). flat_count Σ как оценка будущего объёма. +_L3_FUTURE_SQL = text( + """ + WITH latest AS ( + SELECT DISTINCT ON (o.obj_id) + o.obj_id, + o.district_name, + o.dev_group_name, + o.obj_class, + o.flat_count, + o.square_living, + o.free_flats, + o.ready_dt + FROM domrf_kn_objects o + WHERE o.region_cd = :region_cd + AND o.district_name IS NOT NULL + AND o.ready_dt IS NOT NULL + AND o.ready_dt > CURRENT_DATE + CAST(:horizon AS interval) + AND COALESCE(o.free_flats, 0) <= :max_free_flats + AND ( + CAST(:district AS text) IS NULL + OR o.district_name = CAST(:district AS text) + ) + ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST + ) + SELECT + district_name, + dev_group_name, + COUNT(*) AS n_objects, + COALESCE(SUM(flat_count), 0) AS units_future, + COALESCE(SUM(square_living), 0) AS area_future, + MIN(ready_dt) AS expected_online_date + FROM latest + GROUP BY district_name, dev_group_name + """ +) + + +def compute_layer1_open( + db: Session, + *, + district: str | None = None, + premise_kind: str = "квартира", + snapshot_date: date | None = None, +) -> list[SupplyLayerRow]: + """Layer1 (open): открытое предложение в продаже, per район × класс. + + Источник — `objective_lots` (выставленные лоты), переиспользует district-stock + паттерн market_metrics. source='objective', confidence='high'. Возвращает по строке + на (district, obj_class) с доступными лотами. Пустые данные → []. Не бросает. + """ + params: dict[str, Any] = {"premise_kind": premise_kind, "district": district} + rows = _safe_rows(db, _L1_OPEN_SQL, params, layer=1) + + out: list[SupplyLayerRow] = [] + for r in rows: + units = int(r["units_available"] or 0) + if units <= 0: + continue + area = float(r["area_available"] or 0.0) + out.append( + SupplyLayerRow( + layer=1, + district_name=r["district"], + complex_id=None, + obj_class=r["obj_class"], + units_estimate=units, + area_estimate=area if area > 0 else None, + expected_online_date=None, + source="objective", + confidence="high", + method="objective_lots: available (not sold) lots per district×class", + snapshot_date=snapshot_date, + ) + ) + logger.info( + "supply_layers L1(open): district=%s premise=%s rows=%d", + district, + premise_kind, + len(out), + ) + return out + + +def compute_layer2_hidden( + db: Session, + *, + district: str | None = None, + snapshot_date: date | None = None, +) -> list[SupplyLayerRow]: + """Layer2 (hidden): скрытый запас застройщика, per район (mapping-free). + + Per dom.рф-объект hidden = max(0, flat_count − free_flats); Σ к району. ОБЕ колонки + на одной строке domrf_kn_objects → НЕ нужен domrf↔objective маппинг (см. docstring + модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки → не вычислить; + documented limitation) — снижают coverage → confidence medium→low. + + source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic). + units_estimate всегда ≥ 0 (clamp). Пустые данные → []. Не бросает. + """ + params: dict[str, Any] = {"region_cd": _EKB_REGION_CODE, "district": district} + rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2) + + out: list[SupplyLayerRow] = [] + for r in rows: + n_total = int(r["n_objects_total"] or 0) + n_with = int(r["n_with_free_flats"] or 0) + # hidden_units приходит из SQL уже клампленным (GREATEST(0, ...)); страхуемся. + hidden = max(0, int(r["hidden_units"] or 0)) + if n_with <= 0: + # Нет ни одного объекта с free_flats → нечего объявлять скрытым (а не «0»). + continue + confidence = _l2_confidence(n_total, n_with) + out.append( + SupplyLayerRow( + layer=2, + district_name=r["district_name"], + complex_id=None, + obj_class=None, + units_estimate=hidden, + area_estimate=None, # площадь скрытого недёшево/ненадёжно вывести — None + expected_online_date=None, + source="domrf_declared", + confidence=confidence, + method=( + f"domrf_kn_objects: Σ max(0, flat_count − free_flats); " + f"coverage {n_with}/{n_total} objects with free_flats" + ), + snapshot_date=snapshot_date, + ) + ) + logger.info( + "supply_layers L2(hidden): district=%s rows=%d (mapping-free, domrf_declared)", + district, + len(out), + ) + return out + + +def compute_layer3_future( + db: Session, + *, + district: str | None = None, + snapshot_date: date | None = None, +) -> list[SupplyLayerRow]: + """Layer3 (future): будущее предложение ещё НЕ на рынке (domrf multiphase). + + Детерминированный скелет: dom.рф-объекты с ready_dt дальше горизонта И почти без + выставленных квартир (объявлены, но не открыты), сгруппированные по (район, + dev_group_name) как поздние очереди застройщика. source='domrf_multiphase', + expected_online_date=MIN(ready_dt), confidence='low'. + + Граддок-stubs (#956, LLM) и insider_manual сюда НЕ приходят — живут в таблице + независимо (см. docstring модуля). Пустые данные → []. Не бросает. + """ + params: dict[str, Any] = { + "region_cd": _EKB_REGION_CODE, + "district": district, + "horizon": f"{_L3_FUTURE_HORIZON_MONTHS} months", + "max_free_flats": _L3_MAX_FREE_FLATS_NOT_SELLING, + } + rows = _safe_rows(db, _L3_FUTURE_SQL, params, layer=3) + + out: list[SupplyLayerRow] = [] + for r in rows: + units = int(r["units_future"] or 0) + area = float(r["area_future"] or 0.0) + dev_group = r["dev_group_name"] + out.append( + SupplyLayerRow( + layer=3, + district_name=r["district_name"], + complex_id=None, + obj_class=None, + units_estimate=units if units > 0 else None, + area_estimate=area if area > 0 else None, + expected_online_date=r["expected_online_date"], + source="domrf_multiphase", + confidence="low", + method=( + "domrf_kn_objects: ready_dt beyond horizon & ~no listed flats; " + f"grouped by dev_group_name={dev_group!r}" + ), + snapshot_date=snapshot_date, + ) + ) + logger.info( + "supply_layers L3(future): district=%s rows=%d (domrf_multiphase)", + district, + len(out), + ) + return out + + +def compute_all_layers( + db: Session, + *, + district: str | None = None, + premise_kind: str = "квартира", + snapshot_date: date | None = None, +) -> list[SupplyLayerRow]: + """Удобный объединённый расчёт L1+L2+L3 для района (для worker'а #950 Step 5). + + Каждый слой graceful-независим: сбой/пустота одного не валит остальные (компонуем + из самих compute_*, которые уже не бросают). Возвращает плоский список строк всех + трёх слоёв (worker UPSERT-ит их по uq_supply_layers_logical). + """ + rows: list[SupplyLayerRow] = [] + rows.extend( + compute_layer1_open( + db, district=district, premise_kind=premise_kind, snapshot_date=snapshot_date + ) + ) + rows.extend(compute_layer2_hidden(db, district=district, snapshot_date=snapshot_date)) + rows.extend(compute_layer3_future(db, district=district, snapshot_date=snapshot_date)) + return rows + + +def _safe_rows( + db: Session, + sql: Any, + params: Mapping[str, Any], + *, + layer: int, +) -> Sequence[Mapping[str, Any]]: + """Выполнить read-query, graceful: на ошибке логируем и возвращаем [] (не бросаем). + + Зеркало market_metrics._query_* — тонкие данные/сбой БД не должны валить расчёт + остальных слоёв или worker'а целиком. + """ + try: + return db.execute(sql, dict(params)).mappings().all() + except Exception: + logger.exception( + "supply_layers: L%d query failed (district=%s)", layer, params.get("district") + ) + return [] diff --git a/backend/tests/services/site_finder/test_supply_layers.py b/backend/tests/services/site_finder/test_supply_layers.py new file mode 100644 index 00000000..eba94c60 --- /dev/null +++ b/backend/tests/services/site_finder/test_supply_layers.py @@ -0,0 +1,505 @@ +"""Unit-тесты supply-layers service (#950 EPIC 6, ТЗ §9.3). + +Чистые тесты (без живой БД): + • pure-арифметика (_hidden_units clamp ≥ 0, _l2_confidence по coverage, + _round_or_none) — hand-built фикстуры, включая thin / NULL / negative-clamp. + • compute_layer1/2/3 через MagicMock-сессию — форма SQL (CAST(:x AS type) не :x::type), + params, whitelisted source/confidence, graceful-on-empty (→ [], no crash) и + КРИТИЧНО L2 mapping-free (никаких objective_lots/complexes в L2-SQL). + +psycopg v3 правило проверяется явно: bind-параметры — CAST(:x AS type). +""" + +from __future__ import annotations + +import os +from datetime import date +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.site_finder.supply_layers import ( + SupplyLayerRow, + _hidden_units, + _l2_confidence, + _round_or_none, + compute_all_layers, + compute_layer1_open, + compute_layer2_hidden, + compute_layer3_future, +) + +# Разрешённые значения CHECK-констрейнтов supply_layers (м.125). Любой эмит ВНЕ +# этих множеств → INSERT worker'а #950 Step5 упадёт ГРОМКО, поэтому тестим явно. +_ALLOWED_SOURCES = {"objective", "domrf_declared", "domrf_multiphase"} +_ALLOWED_CONFIDENCE = {"high", "medium", "low"} + + +# ── pure: _hidden_units (L2 clamp) ──────────────────────────────────────────── + + +class TestHiddenUnits: + def test_basic(self) -> None: + # 100 в ПД − 30 выставлено = 70 скрыто + assert _hidden_units(100, 30) == 70 + + def test_clamp_never_negative(self) -> None: + # free_flats > flat_count (рассинхрон снапшотов) → 0, НИКОГДА не негатив + assert _hidden_units(30, 100) == 0 + + def test_equal_is_zero(self) -> None: + assert _hidden_units(50, 50) == 0 + + def test_zero_free_all_hidden(self) -> None: + # ничего не выставлено → весь ПД скрыт + assert _hidden_units(80, 0) == 80 + + def test_none_flat_count_returns_none(self) -> None: + assert _hidden_units(None, 10) is None + + def test_none_free_flats_returns_none(self) -> None: + # free_flats IS NULL → объект исключается (нет sold-колонки → не вычислить) + assert _hidden_units(100, None) is None + + def test_both_none(self) -> None: + assert _hidden_units(None, None) is None + + +# ── pure: _l2_confidence (coverage) ─────────────────────────────────────────── + + +class TestL2Confidence: + def test_full_coverage_medium(self) -> None: + # все объекты с free_flats → надёжный hidden, но всё равно НЕ high (это оценка ПД) + assert _l2_confidence(10, 10) == "medium" + + def test_majority_coverage_medium(self) -> None: + # 7/10 = 0.7 ≥ 0.6 порог + assert _l2_confidence(10, 7) == "medium" + + def test_minority_coverage_low(self) -> None: + # 3/10 = 0.3 < 0.6 → много исключённых NULL-объектов → недооценка → low + assert _l2_confidence(10, 3) == "low" + + def test_no_objects_low(self) -> None: + assert _l2_confidence(0, 0) == "low" + + def test_zero_with_free_flats_low(self) -> None: + assert _l2_confidence(10, 0) == "low" + + def test_never_high(self) -> None: + # L2 никогда не 'high' — даже при 100% coverage + assert _l2_confidence(1000, 1000) != "high" + + +# ── pure: _round_or_none ────────────────────────────────────────────────────── + + +class TestRoundOrNone: + def test_rounds(self) -> None: + assert _round_or_none(1234.5678, 1) == 1234.6 + + def test_none_passthrough(self) -> None: + assert _round_or_none(None, 1) is None + + +# ── SupplyLayerRow.as_dict ──────────────────────────────────────────────────── + + +class TestSupplyLayerRowAsDict: + def test_serialises_and_rounds_area(self) -> None: + row = SupplyLayerRow( + layer=2, + district_name="Автовокзал", + complex_id=None, + obj_class=None, + units_estimate=70, + area_estimate=1234.5678, + expected_online_date=None, + source="domrf_declared", + confidence="medium", + method="test", + snapshot_date=date(2026, 6, 3), + ) + d = row.as_dict() + assert d["layer"] == 2 + assert d["district_name"] == "Автовокзал" + assert d["units_estimate"] == 70 + assert d["area_estimate"] == 1234.6 + assert d["source"] == "domrf_declared" + assert d["confidence"] == "medium" + assert d["snapshot_date"] == date(2026, 6, 3) + + def test_none_area_survives(self) -> None: + row = SupplyLayerRow( + layer=2, + district_name="X", + complex_id=None, + obj_class=None, + units_estimate=0, + area_estimate=None, + expected_online_date=None, + source="domrf_declared", + confidence="low", + method="test", + snapshot_date=None, + ) + assert row.as_dict()["area_estimate"] is None + + +# ── MagicMock-сессия helpers (зеркало test_market_metrics) ──────────────────── + + +def _mock_db(rows: list[dict]) -> MagicMock: + """Сессия с одним execute → .mappings().all() == rows.""" + db = MagicMock() + result = MagicMock() + result.mappings.return_value.all.return_value = rows + db.execute.return_value = result + return db + + +def _executed_sql(db: MagicMock, call_index: int = 0) -> str: + args, _kwargs = db.execute.call_args_list[call_index] + return str(args[0]) + + +def _executed_params(db: MagicMock, call_index: int = 0) -> dict: + args, _kwargs = db.execute.call_args_list[call_index] + return args[1] + + +def _assert_no_double_colon_cast(sql: str) -> None: + """psycopg v3: запрещён :name::type (SQLAlchemy его молча роняет).""" + import re + + assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}" + + +# ── Layer1 (open) ───────────────────────────────────────────────────────────── + + +class TestLayer1Open: + def test_sql_uses_cast_not_double_colon(self) -> None: + db = _mock_db([]) + compute_layer1_open(db, district="Автовокзал") + sql = _executed_sql(db) + assert "CAST(:district AS text)" in sql + _assert_no_double_colon_cast(sql) + + def test_params(self) -> None: + db = _mock_db([]) + compute_layer1_open(db, district="Автовокзал", premise_kind="квартира") + p = _executed_params(db) + assert p["district"] == "Автовокзал" + assert p["premise_kind"] == "квартира" + + def test_rows_built_with_whitelisted_values(self) -> None: + rows = [ + { + "district": "Автовокзал", + "obj_class": "комфорт", + "units_available": 42, + "area_available": 2100.0, + }, + { + "district": "Автовокзал", + "obj_class": "бизнес", + "units_available": 10, + "area_available": 700.0, + }, + ] + db = _mock_db(rows) + out = compute_layer1_open(db, district="Автовокзал") + assert len(out) == 2 + for r in out: + assert r.layer == 1 + assert r.source == "objective" + assert r.confidence == "high" + assert r.source in _ALLOWED_SOURCES + assert r.confidence in _ALLOWED_CONFIDENCE + assert r.expected_online_date is None + assert r.complex_id is None + first = out[0] + assert first.obj_class == "комфорт" + assert first.units_estimate == 42 + assert first.area_estimate == 2100.0 + + def test_zero_units_skipped(self) -> None: + rows = [ + {"district": "X", "obj_class": "комфорт", "units_available": 0, "area_available": 0} + ] + db = _mock_db(rows) + assert compute_layer1_open(db, district="X") == [] + + def test_zero_area_becomes_none(self) -> None: + rows = [ + {"district": "X", "obj_class": "комфорт", "units_available": 5, "area_available": 0} + ] + db = _mock_db(rows) + out = compute_layer1_open(db, district="X") + assert out[0].area_estimate is None + + def test_empty_graceful(self) -> None: + assert compute_layer1_open(_mock_db([]), district="Пусто") == [] + + def test_query_exception_graceful(self) -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("db down") + assert compute_layer1_open(db, district="Сбой") == [] + + +# ── Layer2 (hidden) — THE CRUX ──────────────────────────────────────────────── + + +class TestLayer2Hidden: + def test_sql_uses_cast_not_double_colon(self) -> None: + db = _mock_db([]) + compute_layer2_hidden(db, district="Автовокзал") + sql = _executed_sql(db) + assert "CAST(:district AS text)" in sql + _assert_no_double_colon_cast(sql) + + def test_mapping_free_no_objective_or_complexes_join(self) -> None: + # КРИТИЧНО (ТЗ §9.3): L2 — mapping-free. domrf↔objective маппинг (2.5%) НЕ нужен, + # т.к. flat_count и free_flats на ОДНОЙ строке domrf_kn_objects (м.124). + db = _mock_db([]) + compute_layer2_hidden(db, district="Автовокзал") + sql = _executed_sql(db).lower() + assert "domrf_kn_objects" in sql + assert "objective_lots" not in sql + assert "objective_complex_mapping" not in sql + assert "complex_sources" not in sql + assert "complexes" not in sql + + def test_sql_clamps_at_zero(self) -> None: + # SQL должен клампить hidden ≥ 0 (GREATEST) — защита от free_flats > flat_count. + db = _mock_db([]) + compute_layer2_hidden(db, district="X") + sql = _executed_sql(db).upper() + assert "GREATEST(0" in sql + + def test_sql_excludes_null_free_flats(self) -> None: + # free_flats IS NULL объекты исключены из суммы (нет sold-колонки → не вычислить). + db = _mock_db([]) + compute_layer2_hidden(db, district="X") + sql = _executed_sql(db) + assert "free_flats IS NOT NULL" in sql + + def test_sql_dedups_latest_snapshot(self) -> None: + # КРИТИЧНО: domrf_kn_objects = UNIQUE(obj_id, snapshot_date), накапливает снапшоты. + # Без DISTINCT ON (obj_id) + snapshot_date DESC наивный SUM умножил бы hidden на + # число снапшотов (cross-snapshot double-count). Этот guard locks-in защиту. + db = _mock_db([]) + compute_layer2_hidden(db, district="X") + sql = _executed_sql(db) + assert "DISTINCT ON (o.obj_id)" in sql + assert "snapshot_date DESC" in sql + + def test_params_region_ekb(self) -> None: + db = _mock_db([]) + compute_layer2_hidden(db, district="Автовокзал") + p = _executed_params(db) + assert p["region_cd"] == 66 + assert p["district"] == "Автовокзал" + + def test_hidden_aggregate_medium_confidence(self) -> None: + rows = [ + { + "district_name": "Автовокзал", + "n_objects_total": 10, + "n_with_free_flats": 9, # 0.9 coverage → medium + "hidden_units": 540, + } + ] + db = _mock_db(rows) + out = compute_layer2_hidden(db, district="Автовокзал") + assert len(out) == 1 + row = out[0] + assert row.layer == 2 + assert row.source == "domrf_declared" + assert row.confidence == "medium" + assert row.units_estimate == 540 + assert row.obj_class is None # district-агрегат класс-agnostic + assert row.area_estimate is None + assert row.expected_online_date is None + assert row.source in _ALLOWED_SOURCES + assert row.confidence in _ALLOWED_CONFIDENCE + + def test_low_coverage_low_confidence(self) -> None: + rows = [ + { + "district_name": "X", + "n_objects_total": 10, + "n_with_free_flats": 2, # 0.2 coverage → low + "hidden_units": 100, + } + ] + db = _mock_db(rows) + out = compute_layer2_hidden(db, district="X") + assert out[0].confidence == "low" + + def test_no_objects_with_free_flats_skipped(self) -> None: + # район где НИ один объект не имеет free_flats → строки нет (а не units=0) + rows = [ + { + "district_name": "X", + "n_objects_total": 5, + "n_with_free_flats": 0, + "hidden_units": 0, + } + ] + db = _mock_db(rows) + assert compute_layer2_hidden(db, district="X") == [] + + def test_units_never_negative_even_if_sql_leaks(self) -> None: + # двойная защита: даже если SQL вернёт негатив, Python клампит в 0 + rows = [ + { + "district_name": "X", + "n_objects_total": 4, + "n_with_free_flats": 4, + "hidden_units": -50, + } + ] + db = _mock_db(rows) + out = compute_layer2_hidden(db, district="X") + assert out[0].units_estimate == 0 + + def test_empty_graceful(self) -> None: + assert compute_layer2_hidden(_mock_db([]), district="Пусто") == [] + + def test_query_exception_graceful(self) -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("db down") + assert compute_layer2_hidden(db, district="Сбой") == [] + + +# ── Layer3 (future) ─────────────────────────────────────────────────────────── + + +class TestLayer3Future: + def test_sql_uses_cast_not_double_colon(self) -> None: + db = _mock_db([]) + compute_layer3_future(db, district="Академический") + sql = _executed_sql(db) + assert "CAST(:district AS text)" in sql + assert "CAST(:horizon AS interval)" in sql + _assert_no_double_colon_cast(sql) + + def test_params(self) -> None: + db = _mock_db([]) + compute_layer3_future(db, district="Академический") + p = _executed_params(db) + assert p["region_cd"] == 66 + assert p["district"] == "Академический" + assert p["horizon"] == "12 months" + assert p["max_free_flats"] == 5 + + def test_sql_dedups_latest_snapshot(self) -> None: + # Тот же anti-double-count guard, что в L2: один объект может иметь несколько + # снапшотов → DISTINCT ON (obj_id) + snapshot_date DESC обязателен до агрегации. + db = _mock_db([]) + compute_layer3_future(db, district="Академический") + sql = _executed_sql(db) + assert "DISTINCT ON (o.obj_id)" in sql + assert "snapshot_date DESC" in sql + + def test_future_row_low_confidence_with_date(self) -> None: + rows = [ + { + "district_name": "Академический", + "dev_group_name": "Брусника", + "n_objects": 3, + "units_future": 900, + "area_future": 45000.0, + "expected_online_date": date(2028, 1, 1), + } + ] + db = _mock_db(rows) + out = compute_layer3_future(db, district="Академический") + assert len(out) == 1 + row = out[0] + assert row.layer == 3 + assert row.source == "domrf_multiphase" + assert row.confidence == "low" + assert row.units_estimate == 900 + assert row.area_estimate == 45000.0 + assert row.expected_online_date == date(2028, 1, 1) + assert row.source in _ALLOWED_SOURCES + assert row.confidence in _ALLOWED_CONFIDENCE + + def test_zero_units_and_area_become_none(self) -> None: + rows = [ + { + "district_name": "X", + "dev_group_name": None, + "n_objects": 1, + "units_future": 0, + "area_future": 0, + "expected_online_date": date(2029, 6, 1), + } + ] + db = _mock_db(rows) + out = compute_layer3_future(db, district="X") + assert out[0].units_estimate is None + assert out[0].area_estimate is None + # строка всё равно эмитится — есть expected_online_date (полезный future-сигнал) + assert out[0].expected_online_date == date(2029, 6, 1) + + def test_empty_graceful(self) -> None: + assert compute_layer3_future(_mock_db([]), district="Пусто") == [] + + def test_query_exception_graceful(self) -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("db down") + assert compute_layer3_future(db, district="Сбой") == [] + + +# ── compute_all_layers (объединённый) ───────────────────────────────────────── + + +class TestComputeAllLayers: + def test_combines_three_layers(self) -> None: + l1 = [ + {"district": "A", "obj_class": "комфорт", "units_available": 5, "area_available": 250.0} + ] + l2 = [ + {"district_name": "A", "n_objects_total": 4, "n_with_free_flats": 4, "hidden_units": 80} + ] + l3 = [ + { + "district_name": "A", + "dev_group_name": "Dev", + "n_objects": 1, + "units_future": 100, + "area_future": 5000.0, + "expected_online_date": date(2028, 1, 1), + } + ] + db = MagicMock() + results = [] + for rows in (l1, l2, l3): + res = MagicMock() + res.mappings.return_value.all.return_value = rows + results.append(res) + db.execute.side_effect = results + + out = compute_all_layers(db, district="A") + layers = sorted(r.layer for r in out) + assert layers == [1, 2, 3] + # все source/confidence whitelisted + assert all(r.source in _ALLOWED_SOURCES for r in out) + assert all(r.confidence in _ALLOWED_CONFIDENCE for r in out) + + def test_one_layer_failing_does_not_kill_others(self) -> None: + # L1 падает, L2/L3 пустые — compute_all не бросает, возвращает [] + db = MagicMock() + l1_fail = RuntimeError("l1 down") + l2_ok = MagicMock() + l2_ok.mappings.return_value.all.return_value = [] + l3_ok = MagicMock() + l3_ok.mappings.return_value.all.return_value = [] + db.execute.side_effect = [l1_fail, l2_ok, l3_ok] + out = compute_all_layers(db, district="A") + assert out == []