gendesign/backend/app/services/site_finder/supply_layers.py
Light1YT a5cc24dc8a feat(site-finder): supply_layers v2 compute service (#950 EPIC6 step3+4)
Deterministic 3-layer склад предложения (ТЗ §9.3), read-only compute returning
SupplyLayerRow dataclasses for the #950 Step5 worker to UPSERT into supply_layers
(m.125). Mirrors market_metrics.py house style (frozen dataclass, _SQL consts,
graceful-to-empty, pure helpers, CAST(:x AS type)).

- L1 open: objective_lots available lots per district x class (objective/high)
- L2 hidden: SUM max(0, flat_count - free_flats) per district, mapping-free via
  domrf_kn_objects (free_flats m.124) - no domrf<->objective join (domrf_declared,
  medium/low by free_flats coverage). NULL free_flats excluded (no sold column).
- L3 future: domrf multiphase (ready_dt beyond 12mo horizon, ~no listed flats)
  grouped by dev_group_name (domrf_multiphase/low). graddoc_stub deferred to #956.

Latest-snapshot DISTINCT ON guards against cross-snapshot double-count
(domrf_kn_objects is UNIQUE(obj_id, snapshot_date)). Only whitelisted
source/confidence values (m.125 CHECK). 45 unit tests, ruff clean.
2026-06-03 01:33:54 +05:00

480 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""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 []