This commit is contained in:
parent
33ecbda689
commit
900802264a
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