feat(site-finder): supply_layers v2 compute service (#950 EPIC6 step3+4) #1004

Merged
Light1YT merged 1 commit from feat/950c-supply-layers-service into main 2026-06-02 20:34:26 +00:00
2 changed files with 985 additions and 0 deletions

View file

@ -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**:
domrfobjective маппинг покрывает ~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) НЕ эмитим: это потребовало бы domrfobjective маппинг
(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 НЕ нужен domrfobjective маппинг (см. docstring
модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки не вычислить;
documented limitation) снижают coverage confidence mediumlow.
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 []

View file

@ -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 == []