Some checks failed
CI / changes (push) Successful in 10s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m52s
CI / backend-tests (push) Failing after 8m55s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m47s
CI / backend-tests (pull_request) Failing after 9m0s
527 lines
28 KiB
Python
527 lines
28 KiB
Python
"""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
|
||
|
||
from app.services.site_finder.district_resolver import resolve_objective_districts
|
||
|
||
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
|
||
|
||
# L2/L3 граница (включительно в L2, исключительно в L3) — половинно-открытые интервалы:
|
||
# L2 (hidden) : ready_dt IS NULL OR ready_dt <= now + L2_NEAR_HORIZON [now, now+12mo]
|
||
# L3 (future) : ready_dt IS NOT NULL AND ready_dt > now + L3_FUTURE_HORIZON (now+12mo, ∞)
|
||
# Оба используют одно и то же значение 12 мес., но по разные стороны границы — нет overlap.
|
||
_L2_NEAR_HORIZON_MONTHS: int = 12 # L2 берёт объекты с ready_dt ≤ сейчас+12мес (или NULL)
|
||
# L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже
|
||
# почти-на-рынке предложение (ловится L1/L2), не «future».
|
||
_L3_FUTURE_HORIZON_MONTHS: int = 12 # L3 берёт объекты с ready_dt > сейчас+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
|
||
# L3-grain (#970, м.128): бакет future-supply по группе застройщика. ЧАСТЬ логического
|
||
# ключа uq_supply_layers_logical → разные dev_group'ы района дают разные ключи и больше
|
||
# не схлопываются (~95.6% потерь L3 до фикса). NULL для L1/L2 (у них dev_group'а нет).
|
||
dev_group_name: 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,
|
||
"dev_group_name": self.dev_group_name,
|
||
"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(:has_district AS boolean) IS FALSE
|
||
OR ol.district = ANY(CAST(:districts 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/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
|
||
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
|
||
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
|
||
_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,
|
||
o.ready_dt
|
||
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
|
||
WHERE ready_dt IS NULL
|
||
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
|
||
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 (
|
||
-- #1212: сначала ВСЕГДА берём последний снапшот на obj_id
|
||
-- (DISTINCT ON + ORDER BY snapshot_date DESC). Внутри CTE — только
|
||
-- стабильные фильтры (region_cd / админ-район). Volatile-фильтры
|
||
-- (ready_dt, free_flats) применяются ПОСЛЕ DISTINCT ON во внешнем
|
||
-- WHERE. Иначе WHERE применяется ДО DISTINCT ON и берётся «последний
|
||
-- снапшот, прошедший фильтр»: проект, когда-то бывший «объявлен,
|
||
-- не продаётся», остаётся в L3 и после открытия продаж (свежий
|
||
-- снапшот с большим free_flats отфильтрован, взят старый) →
|
||
-- двойной счёт с L1/L2, stale flat_count/ready_dt.
|
||
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 (
|
||
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
|
||
WHERE ready_dt IS NOT NULL
|
||
AND ready_dt > CURRENT_DATE + CAST(:horizon AS interval)
|
||
AND COALESCE(free_flats, 0) <= :max_free_flats
|
||
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) с доступными лотами. Пустые данные → []. Не бросает.
|
||
|
||
district резолвится через district_resolver: админ-имя ЕКБ → набор informal микро
|
||
(objective_lots хранит микро, не админ — иначе админ-имя дало бы 0 строк). None →
|
||
EKB-wide (без district-фильтра). L2/L3 ниже НЕ резолвятся: они бьют по
|
||
domrf_kn_objects.district_name, а это УЖЕ админ-вокабуляр (м.57 spatial-join).
|
||
"""
|
||
micros = resolve_objective_districts(db, district)
|
||
params: dict[str, Any] = {
|
||
"premise_kind": premise_kind,
|
||
"has_district": micros is not None,
|
||
"districts": micros if micros is not None else [],
|
||
}
|
||
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"],
|
||
dev_group_name=None, # L1 (open) — у района-агрегата dev_group'а нет
|
||
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,
|
||
"near_horizon": f"{_L2_NEAR_HORIZON_MONTHS} months",
|
||
}
|
||
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,
|
||
dev_group_name=None, # L2 (hidden) — district-агрегат, dev_group'а нет
|
||
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,
|
||
# #970: dev_group_name теперь КЛЮЧ-колонка (uq_supply_layers_logical),
|
||
# не только текст в method → dev_group'ы района не схлопываются на UPSERT.
|
||
dev_group_name=dev_group,
|
||
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:
|
||
# L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
|
||
district_dbg = params.get("district", params.get("districts"))
|
||
logger.exception("supply_layers: L%d query failed (district=%s)", layer, district_dbg)
|
||
return []
|