feat(site-finder): supply_layers v2 compute service (#950 EPIC6 step3+4) #1004
2 changed files with 985 additions and 0 deletions
480
backend/app/services/site_finder/supply_layers.py
Normal file
480
backend/app/services/site_finder/supply_layers.py
Normal 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**:
|
||||||
|
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 []
|
||||||
505
backend/tests/services/site_finder/test_supply_layers.py
Normal file
505
backend/tests/services/site_finder/test_supply_layers.py
Normal 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 == []
|
||||||
Loading…
Add table
Reference in a new issue