feat(forecast): resolve admin district -> micro set in §9.x market/supply/sales filters
/analyze passes the official ЕКБ admin district (ekb_districts polygon, e.g.
'Кировский'), but objective_lots/corpus_room_month store informal micro-districts
('Втузгородок','ЖБИ') -> admin name matched 0 rows -> silent empty forecast.
Add resolve_objective_districts() (site_finder/district_resolver.py) mapping an
admin name to its clean micros via ekb_district_alias (note IS NULL), with
None -> EKB-wide fallback and raw-micro pass-through. Wire into the objective_lots
district filters of market_metrics (§9.2 stock+sales), supply_layers L1 (§9.3),
and sales_series Sources A+B (crm shares the micro vocab, prod-verified),
switching the scalar filter to psycopg3-safe = ANY(CAST(:districts AS text[])).
supply_layers L2/L3 keep the admin name (domrf_kn_objects.district_name is admin vocab).
Prod: Кировский/Ленинский/Орджоникидзевский obj_count 0 -> 32/64/31.
Tests mutation-verified non-vacuous. 192 module tests pass; ruff clean. Refs #969 #949.
This commit is contained in:
parent
b83ffedec3
commit
681a922d99
8 changed files with 662 additions and 30 deletions
|
|
@ -58,6 +58,7 @@ from sqlalchemy.orm import Session
|
||||||
# Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в
|
# Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в
|
||||||
# macro_series, хоть и не в __all__ пакета.
|
# macro_series, хоть и не в __all__ пакета.
|
||||||
from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months
|
from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months
|
||||||
|
from app.services.site_finder.district_resolver import resolve_objective_districts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -357,7 +358,10 @@ _SOURCE_A_SQL = text(
|
||||||
FROM objective_corpus_room_month crm
|
FROM objective_corpus_room_month crm
|
||||||
WHERE crm.report_month >= CAST(:since AS date)
|
WHERE crm.report_month >= CAST(:since AS date)
|
||||||
AND (CAST(:cls AS text) IS NULL OR LOWER(crm.class) = LOWER(CAST(:cls AS text)))
|
AND (CAST(:cls AS text) IS NULL OR LOWER(crm.class) = LOWER(CAST(:cls AS text)))
|
||||||
AND (CAST(:district AS text) IS NULL OR crm.district = CAST(:district AS text))
|
AND (
|
||||||
|
CAST(:has_district AS boolean) IS FALSE
|
||||||
|
OR crm.district = ANY(CAST(:districts AS text[]))
|
||||||
|
)
|
||||||
AND (
|
AND (
|
||||||
CAST(:room_bucket AS text) IS NULL
|
CAST(:room_bucket AS text) IS NULL
|
||||||
OR crm.room_bucket = CAST(:room_bucket AS text)
|
OR crm.room_bucket = CAST(:room_bucket AS text)
|
||||||
|
|
@ -415,8 +419,8 @@ _SOURCE_B_SQL = text(
|
||||||
AND ol.registration_date >= CAST(:since AS date)
|
AND ol.registration_date >= CAST(:since AS date)
|
||||||
AND (CAST(:cls AS text) IS NULL OR LOWER(ol.class) = LOWER(CAST(:cls AS text)))
|
AND (CAST(:cls AS text) IS NULL OR LOWER(ol.class) = LOWER(CAST(:cls AS text)))
|
||||||
AND (
|
AND (
|
||||||
CAST(:district AS text) IS NULL
|
CAST(:has_district AS boolean) IS FALSE
|
||||||
OR ol.district = CAST(:district AS text)
|
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -534,10 +538,14 @@ def _query_source_a(
|
||||||
"(monthly aggregate carries no per-lot price)",
|
"(monthly aggregate carries no per-lot price)",
|
||||||
spec.price_bucket,
|
spec.price_bucket,
|
||||||
)
|
)
|
||||||
|
# district (админ-имя ЕКБ) → набор informal микро (crm.district хранит микро —
|
||||||
|
# тот же вокабуляр, что objective_lots; verified на prod). None → EKB-wide.
|
||||||
|
micros = resolve_objective_districts(db, spec.district)
|
||||||
params = {
|
params = {
|
||||||
"since": since,
|
"since": since,
|
||||||
"cls": spec.obj_class,
|
"cls": spec.obj_class,
|
||||||
"district": spec.district,
|
"has_district": micros is not None,
|
||||||
|
"districts": micros if micros is not None else [],
|
||||||
"room_bucket": spec.room_bucket,
|
"room_bucket": spec.room_bucket,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|
@ -557,11 +565,15 @@ def _query_source_b(
|
||||||
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
|
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
|
||||||
правилом и в БД, и в Python.
|
правилом и в БД, и в Python.
|
||||||
"""
|
"""
|
||||||
|
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
|
||||||
|
# None → EKB-wide (без district-фильтра).
|
||||||
|
micros = resolve_objective_districts(db, spec.district)
|
||||||
params = {
|
params = {
|
||||||
"since": since,
|
"since": since,
|
||||||
"premise_kind": premise_kind,
|
"premise_kind": premise_kind,
|
||||||
"cls": spec.obj_class,
|
"cls": spec.obj_class,
|
||||||
"district": spec.district,
|
"has_district": micros is not None,
|
||||||
|
"districts": micros if micros is not None else [],
|
||||||
"room_bucket": spec.room_bucket,
|
"room_bucket": spec.room_bucket,
|
||||||
"price_bucket": spec.price_bucket,
|
"price_bucket": spec.price_bucket,
|
||||||
# bucket-пороги (зеркало _LARGE_AREA_THRESHOLD_M2 / _PRICE_BAND_*).
|
# bucket-пороги (зеркало _LARGE_AREA_THRESHOLD_M2 / _PRICE_BAND_*).
|
||||||
|
|
|
||||||
144
backend/app/services/site_finder/district_resolver.py
Normal file
144
backend/app/services/site_finder/district_resolver.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""District-resolver — мост между ДВУМЯ районными вокабулярами ЕКБ (Step 2).
|
||||||
|
|
||||||
|
Контекст бага (ground-truthed на prod, см. `data/sql/131_district_vocab_map.sql`):
|
||||||
|
• §9.x market/supply/sales метрики фильтруют `objective_lots.district` (и
|
||||||
|
`objective_corpus_room_month.district`) — это НЕФОРМАЛЬНЫЕ МИКРО-районы ЕКБ
|
||||||
|
(«Втузгородок», «ЖБИ», «Уралмаш» … — 35 distinct значений).
|
||||||
|
• Но `/analyze` резолвит у участка ОФИЦИАЛЬНЫЙ АДМИН-район из `ekb_districts`
|
||||||
|
(8 полигонов: «Кировский», «Ленинский», «Орджоникидзевский» …).
|
||||||
|
• Админ-имя → 0 строк в objective_lots → молча пустой прогноз. Напр.
|
||||||
|
market_metrics('Кировский') → obj_count=0 (а 'Академический' случайно работает,
|
||||||
|
т.к. он есть в ОБОИХ вокабулярах).
|
||||||
|
|
||||||
|
Step 1 (PR #1053, УЖЕ НА ПРОДЕ) добавил таблицу-словарь `ekb_district_alias`
|
||||||
|
(admin_district → micro_district, note). Этот модуль — Step 2: резолвер, который
|
||||||
|
разворачивает админ-имя в список микро-районов, чтобы фильтры §9.x перестали
|
||||||
|
возвращать пустоту.
|
||||||
|
|
||||||
|
Семантика `resolve_objective_districts(db, district)` — см. docstring функции.
|
||||||
|
psycopg v3: все binds через `CAST(:x AS type)`, НИКОГДА `:x::type`. Read-only SELECT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Сентинел «район не определён» — приходит из /analyze, когда участок не попал ни в
|
||||||
|
# один из 8 полигонов ekb_districts (вне ЕКБ / без координат). Трактуем как None →
|
||||||
|
# EKB-wide fallback (без district-фильтра), а не как буквальное имя района.
|
||||||
|
_UNDETERMINED_DISTRICT: str = "не определён"
|
||||||
|
|
||||||
|
# Список 8 официальных АДМИН-районов ЕКБ (district_name из ekb_districts с непустым
|
||||||
|
# geom). Кешируем per-process: он фактически статичен (8 полигонов, меняется только
|
||||||
|
# при пере-импорте границ), а резолвер бьётся per-analyze — лишний round-trip ни к
|
||||||
|
# чему. Lazy-init на первом вызове; сбрасывается через `_reset_admin_cache()` в тестах.
|
||||||
|
_ADMIN_NAMES_CACHE: frozenset[str] | None = None
|
||||||
|
|
||||||
|
# Имена 8 админ-районов: те, у кого в ekb_districts есть непустой geom (= ekb_districts_geom
|
||||||
|
# view). 'не определён' (9-я строка-dimension без geom) сюда НЕ попадает — и не должен.
|
||||||
|
_ADMIN_NAMES_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT district_name
|
||||||
|
FROM ekb_districts
|
||||||
|
WHERE geom IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Чистые ЕКБ-микро для одного админ-района. note IS NULL отсекает cross-city
|
||||||
|
# (Н.Тагил/Каменск, собранные в bucket «Чкаловский») и ambiguous-пометки —
|
||||||
|
# нам нужны только однозначные ЕКБ-микро (см. 131_district_vocab_map.sql).
|
||||||
|
_MICROS_FOR_ADMIN_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT micro_district
|
||||||
|
FROM ekb_district_alias
|
||||||
|
WHERE admin_district = CAST(:d AS text)
|
||||||
|
AND note IS NULL
|
||||||
|
ORDER BY micro_district
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_names(db: Session) -> frozenset[str]:
|
||||||
|
"""Множество 8 официальных админ-имён ЕКБ (с непустым geom), кеш per-process.
|
||||||
|
|
||||||
|
На ошибке БД возвращает пустой set (logged) — резолвер тогда трактует ЛЮБОЙ
|
||||||
|
вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.
|
||||||
|
"""
|
||||||
|
global _ADMIN_NAMES_CACHE
|
||||||
|
if _ADMIN_NAMES_CACHE is not None:
|
||||||
|
return _ADMIN_NAMES_CACHE
|
||||||
|
try:
|
||||||
|
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
|
||||||
|
_ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
|
||||||
|
except Exception:
|
||||||
|
# Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова.
|
||||||
|
logger.exception("district_resolver: admin-names lookup failed")
|
||||||
|
return frozenset()
|
||||||
|
return _ADMIN_NAMES_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_admin_cache() -> None:
|
||||||
|
"""Сбросить per-process кеш админ-имён (для тестов / форс-рефреша границ)."""
|
||||||
|
global _ADMIN_NAMES_CACHE
|
||||||
|
_ADMIN_NAMES_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_objective_districts(db: Session, district: str | None) -> list[str] | None:
|
||||||
|
"""Развернуть district-вход в набор МИКРО-районов для фильтра objective_lots.
|
||||||
|
|
||||||
|
Решает рассинхрон вокабуляров ЕКБ (admin polygons vs informal micro-districts).
|
||||||
|
Возвращаемое значение — контракт для вызывающего фильтра:
|
||||||
|
|
||||||
|
• ``None`` → фильтровать EKB-wide (БЕЗ district-фильтра). Это происходит
|
||||||
|
когда:
|
||||||
|
- вход ``None`` или ``'не определён'`` (участок вне 8
|
||||||
|
полигонов — нечего сужать), ЛИБО
|
||||||
|
- вход — валидное админ-имя, но у него НЕТ чистых алиасов
|
||||||
|
(на практике не случается: все 8 покрыты сидом; страховка).
|
||||||
|
• ``[micro, …]`` → фильтровать ``ol.district = ANY(...)``:
|
||||||
|
- вход — одно из 8 АДМИН-имён → его чистые микро
|
||||||
|
(note IS NULL: cross-city/ambiguous исключены), ЛИБО
|
||||||
|
- вход — НЕ админ-имя (raw МИКРО, переданный внутренним
|
||||||
|
legacy-вызовом) → ``[district]`` как есть (сохраняем
|
||||||
|
прежнее поведение фильтра-по-микро).
|
||||||
|
|
||||||
|
Никогда не возвращает пустой список — вместо «0 строк гарантированно» отдаёт
|
||||||
|
``None`` (EKB-wide), чтобы прогноз не схлопывался молча в пустоту (это и есть
|
||||||
|
суть фикса).
|
||||||
|
|
||||||
|
psycopg v3: bind через CAST(:d AS text). Read-only.
|
||||||
|
"""
|
||||||
|
if district is None or district == _UNDETERMINED_DISTRICT:
|
||||||
|
return None
|
||||||
|
|
||||||
|
admin_names = _admin_names(db)
|
||||||
|
if district not in admin_names:
|
||||||
|
# Raw микро-район (legacy внутренний вызов) — фильтруем по нему как есть.
|
||||||
|
return [district]
|
||||||
|
|
||||||
|
# district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
|
||||||
|
try:
|
||||||
|
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
|
||||||
|
micros = [str(m) for m in rows]
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"district_resolver: micro-lookup failed (admin=%s) — falling back to EKB-wide",
|
||||||
|
district,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not micros:
|
||||||
|
# Админ без чистых алиасов (дыра в словаре) → EKB-wide fallback, НЕ пустой набор.
|
||||||
|
logger.warning(
|
||||||
|
"district_resolver: admin '%s' has no clean micro aliases — EKB-wide fallback",
|
||||||
|
district,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug("district_resolver: admin '%s' → micros %s", district, micros)
|
||||||
|
return micros
|
||||||
|
|
@ -38,6 +38,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.analytics_queries import _elasticity_coef
|
from app.services.analytics_queries import _elasticity_coef
|
||||||
|
from app.services.site_finder.district_resolver import resolve_objective_districts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -260,8 +261,8 @@ _STOCK_SQL = text(
|
||||||
FROM objective_lots ol
|
FROM objective_lots ol
|
||||||
WHERE ol.premise_kind = :premise_kind
|
WHERE ol.premise_kind = :premise_kind
|
||||||
AND (
|
AND (
|
||||||
CAST(:district AS text) IS NULL
|
CAST(:has_district AS boolean) IS FALSE
|
||||||
OR ol.district = CAST(:district AS text)
|
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||||
|
|
@ -300,8 +301,8 @@ _SALES_WINDOW_SQL = text(
|
||||||
FROM objective_lots ol
|
FROM objective_lots ol
|
||||||
WHERE ol.premise_kind = :premise_kind
|
WHERE ol.premise_kind = :premise_kind
|
||||||
AND (
|
AND (
|
||||||
CAST(:district AS text) IS NULL
|
CAST(:has_district AS boolean) IS FALSE
|
||||||
OR ol.district = CAST(:district AS text)
|
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||||
|
|
@ -333,9 +334,17 @@ def compute_market_metrics(
|
||||||
"""
|
"""
|
||||||
obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
|
obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
|
||||||
has_obj_ids = bool(obj_id_list)
|
has_obj_ids = bool(obj_id_list)
|
||||||
|
# Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
|
||||||
|
# реально фильтруется objective_lots. None → EKB-wide (без district-фильтра); это
|
||||||
|
# лечит баг «админ-имя → 0 строк → пустой прогноз» (см. district_resolver).
|
||||||
|
micros = resolve_objective_districts(db, district)
|
||||||
|
has_district = micros is not None
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
"premise_kind": premise_kind,
|
"premise_kind": premise_kind,
|
||||||
"district": district,
|
# has_district=False → district-ветка фильтра отключена (EKB-wide).
|
||||||
|
"has_district": has_district,
|
||||||
|
# ANY(NULL::text[]) валиден; пустой список когда фильтра нет (has_district=False).
|
||||||
|
"districts": micros if micros is not None else [],
|
||||||
"has_obj_ids": has_obj_ids,
|
"has_obj_ids": has_obj_ids,
|
||||||
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
|
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
|
||||||
"obj_ids": obj_id_list,
|
"obj_ids": obj_id_list,
|
||||||
|
|
@ -378,9 +387,10 @@ def compute_market_metrics(
|
||||||
confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)
|
confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"market_metrics: district=%s obj_ids=%d n_lots=%d n_sold=%d n_available=%d "
|
"market_metrics: district=%s micros=%s obj_ids=%d n_lots=%d n_sold=%d "
|
||||||
"obj_count=%d units_sold_window=%d confidence=%s",
|
"n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
|
||||||
district,
|
district,
|
||||||
|
micros,
|
||||||
len(obj_id_list),
|
len(obj_id_list),
|
||||||
n_lots,
|
n_lots,
|
||||||
n_sold_total,
|
n_sold_total,
|
||||||
|
|
@ -417,7 +427,9 @@ def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
|
||||||
try:
|
try:
|
||||||
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
|
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("market_metrics: stock query failed (district=%s)", params.get("district"))
|
logger.exception(
|
||||||
|
"market_metrics: stock query failed (districts=%s)", params.get("districts")
|
||||||
|
)
|
||||||
row = None
|
row = None
|
||||||
if row is None:
|
if row is None:
|
||||||
return {
|
return {
|
||||||
|
|
@ -449,7 +461,7 @@ def _query_sales_window(
|
||||||
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"market_metrics: sales-window query failed (district=%s)", params.get("district")
|
"market_metrics: sales-window query failed (districts=%s)", params.get("districts")
|
||||||
)
|
)
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ from typing import Any, Literal
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services.site_finder.district_resolver import resolve_objective_districts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Confidence = Literal["high", "medium", "low"]
|
Confidence = Literal["high", "medium", "low"]
|
||||||
|
|
@ -192,8 +194,8 @@ _L1_OPEN_SQL = text(
|
||||||
FROM objective_lots ol
|
FROM objective_lots ol
|
||||||
WHERE ol.premise_kind = :premise_kind
|
WHERE ol.premise_kind = :premise_kind
|
||||||
AND (
|
AND (
|
||||||
CAST(:district AS text) IS NULL
|
CAST(:has_district AS boolean) IS FALSE
|
||||||
OR ol.district = CAST(:district AS text)
|
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -296,8 +298,18 @@ def compute_layer1_open(
|
||||||
Источник — `objective_lots` (выставленные лоты), переиспользует district-stock
|
Источник — `objective_lots` (выставленные лоты), переиспользует district-stock
|
||||||
паттерн market_metrics. source='objective', confidence='high'. Возвращает по строке
|
паттерн market_metrics. source='objective', confidence='high'. Возвращает по строке
|
||||||
на (district, obj_class) с доступными лотами. Пустые данные → []. Не бросает.
|
на (district, obj_class) с доступными лотами. Пустые данные → []. Не бросает.
|
||||||
|
|
||||||
|
district резолвится через district_resolver: админ-имя ЕКБ → набор informal микро
|
||||||
|
(objective_lots хранит микро, не админ — иначе админ-имя дало бы 0 строк). None →
|
||||||
|
EKB-wide (без district-фильтра). L2/L3 ниже НЕ резолвятся: они бьют по
|
||||||
|
domrf_kn_objects.district_name, а это УЖЕ админ-вокабуляр (м.57 spatial-join).
|
||||||
"""
|
"""
|
||||||
params: dict[str, Any] = {"premise_kind": premise_kind, "district": district}
|
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)
|
rows = _safe_rows(db, _L1_OPEN_SQL, params, layer=1)
|
||||||
|
|
||||||
out: list[SupplyLayerRow] = []
|
out: list[SupplyLayerRow] = []
|
||||||
|
|
@ -484,7 +496,7 @@ def _safe_rows(
|
||||||
try:
|
try:
|
||||||
return db.execute(sql, dict(params)).mappings().all()
|
return db.execute(sql, dict(params)).mappings().all()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
# L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
|
||||||
"supply_layers: L%d query failed (district=%s)", layer, params.get("district")
|
district_dbg = params.get("district", params.get("districts"))
|
||||||
)
|
logger.exception("supply_layers: L%d query failed (district=%s)", layer, district_dbg)
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ from __future__ import annotations
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
from unittest.mock import MagicMock
|
from collections.abc import Iterator
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
|
@ -45,6 +48,21 @@ from app.services.forecasting.sales_series import (
|
||||||
room_area_bucket_of,
|
room_area_bucket_of,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Резолвер admin→micro (Step 2). Обе SQL-ветки (Source A crm + Source B objective_lots)
|
||||||
|
# теперь резолвят spec.district → набор микро. Патчим на identity raw-микро, чтобы
|
||||||
|
# db.execute не получал лишний resolver-запрос; отдельный класс TestDistrictResolution
|
||||||
|
# проверяет, что резолвнутые микро реально идут в SQL-параметры.
|
||||||
|
_RESOLVE = "app.services.forecasting.sales_series.resolve_objective_districts"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _patch_resolver() -> Iterator[MagicMock]:
|
||||||
|
"""По умолчанию резолвер = identity raw-микро (district→[district], None→None)."""
|
||||||
|
with patch(_RESOLVE) as m:
|
||||||
|
m.side_effect = lambda _db, d: [d] if d is not None else None
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
# ── pure: price_bucket_of ─────────────────────────────────────────────────────
|
# ── pure: price_bucket_of ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -365,7 +383,8 @@ class TestBuildSalesSeriesSourceShape:
|
||||||
sql = _sql_of(db)
|
sql = _sql_of(db)
|
||||||
assert "CAST(:since AS date)" in sql
|
assert "CAST(:since AS date)" in sql
|
||||||
assert "CAST(:cls AS text)" in sql
|
assert "CAST(:cls AS text)" in sql
|
||||||
assert "CAST(:district AS text)" in sql
|
# district теперь резолвится в набор микро → ANY(CAST(:districts AS text[])).
|
||||||
|
assert "CAST(:districts AS text[])" in sql
|
||||||
assert "CAST(:room_bucket AS text)" in sql
|
assert "CAST(:room_bucket AS text)" in sql
|
||||||
# psycopg v3 trap: никаких :name::type.
|
# psycopg v3 trap: никаких :name::type.
|
||||||
assert "::" not in sql
|
assert "::" not in sql
|
||||||
|
|
@ -419,7 +438,10 @@ class TestBuildSalesSeriesSourceShape:
|
||||||
)
|
)
|
||||||
params = _params_of(db)
|
params = _params_of(db)
|
||||||
assert params["cls"] == "Комфорт"
|
assert params["cls"] == "Комфорт"
|
||||||
assert params["district"] == "Уралмаш"
|
# district резолвится в набор микро (identity → ['Уралмаш']) → ANY(:districts).
|
||||||
|
assert params["has_district"] is True
|
||||||
|
assert params["districts"] == ["Уралмаш"]
|
||||||
|
assert "district" not in params
|
||||||
assert params["room_bucket"] == "1"
|
assert params["room_bucket"] == "1"
|
||||||
assert isinstance(params["since"], dt.date)
|
assert isinstance(params["since"], dt.date)
|
||||||
|
|
||||||
|
|
@ -643,3 +665,61 @@ class TestBuildSalesSeriesGraceful:
|
||||||
)
|
)
|
||||||
assert out.n_months == 1 # только текущий месяц
|
assert out.n_months == 1 # только текущий месяц
|
||||||
assert out.units == [0]
|
assert out.units == [0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDistrictResolution:
|
||||||
|
"""Step 2: spec.district (админ-имя ЕКБ) резолвится в МИКРО-набор в SQL-фильтре.
|
||||||
|
|
||||||
|
Оба источника используют МИКРО-вокабуляр: objective_corpus_room_month.district и
|
||||||
|
objective_lots.district несут одни и те же 35 informal-микро (verified на prod) →
|
||||||
|
админ-имя по ним давало 0 строк. Резолвер разворачивает его в чистые микро.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_source_a_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None:
|
||||||
|
_patch_resolver.side_effect = lambda _db, d: (
|
||||||
|
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||||||
|
)
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = _result([])
|
||||||
|
build_sales_series(
|
||||||
|
db,
|
||||||
|
spec=SegmentSpec(district="Кировский"),
|
||||||
|
source="corpus_room_month",
|
||||||
|
months_back=1,
|
||||||
|
)
|
||||||
|
assert _patch_resolver.call_args.args[1] == "Кировский"
|
||||||
|
p = _params_of(db)
|
||||||
|
assert p["has_district"] is True
|
||||||
|
assert p["districts"] == ["Втузгородок", "ЖБИ"]
|
||||||
|
assert "Кировский" not in p["districts"]
|
||||||
|
|
||||||
|
def test_source_b_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None:
|
||||||
|
_patch_resolver.side_effect = lambda _db, d: (
|
||||||
|
["Уралмаш", "Эльмаш"] if d == "Орджоникидзевский" else None
|
||||||
|
)
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = _result([])
|
||||||
|
build_sales_series(
|
||||||
|
db,
|
||||||
|
spec=SegmentSpec(district="Орджоникидзевский"),
|
||||||
|
source="objective_lots",
|
||||||
|
months_back=1,
|
||||||
|
)
|
||||||
|
p = _params_of(db)
|
||||||
|
assert p["has_district"] is True
|
||||||
|
assert p["districts"] == ["Уралмаш", "Эльмаш"]
|
||||||
|
assert "Орджоникидзевский" not in p["districts"]
|
||||||
|
|
||||||
|
def test_resolver_none_drops_district_filter(self, _patch_resolver: MagicMock) -> None:
|
||||||
|
_patch_resolver.side_effect = lambda _db, _d: None
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = _result([])
|
||||||
|
build_sales_series(
|
||||||
|
db,
|
||||||
|
spec=SegmentSpec(district="не определён"),
|
||||||
|
source="objective_lots",
|
||||||
|
months_back=1,
|
||||||
|
)
|
||||||
|
p = _params_of(db)
|
||||||
|
assert p["has_district"] is False
|
||||||
|
assert p["districts"] == []
|
||||||
|
|
|
||||||
222
backend/tests/services/site_finder/test_district_resolver.py
Normal file
222
backend/tests/services/site_finder/test_district_resolver.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""Unit-тесты district-resolver (Step 2 — мост admin↔micro вокабуляров ЕКБ).
|
||||||
|
|
||||||
|
Резолвер лечит баг §9.x: /analyze передаёт ОФИЦИАЛЬНОЕ админ-имя («Кировский»), а
|
||||||
|
objective_lots.district хранит НЕФОРМАЛЬНЫЕ микро («Втузгородок»/«ЖБИ») → админ-имя
|
||||||
|
давало 0 строк → молча пустой прогноз (см. data/sql/131_district_vocab_map.sql).
|
||||||
|
|
||||||
|
Контракт resolve_objective_districts(db, district):
|
||||||
|
• None / 'не определён' → None (EKB-wide, без district-фильтра)
|
||||||
|
• админ-имя с чистыми алиасами → [micro, …] (note IS NULL)
|
||||||
|
• админ-имя без чистых алиасов → None (EKB-wide fallback, НЕ пустой набор)
|
||||||
|
• не-админ (raw микро, legacy) → [district] (фильтр-по-микро как есть)
|
||||||
|
|
||||||
|
Тесты бьют МОК-сессию (без живой БД): резолвер делает максимум 2 execute —
|
||||||
|
admin-names (.scalars().all()) + micro-lookup (.scalars().all()). КРИТИЧНО: тест на
|
||||||
|
админ-вход ОБЯЗАН падать, если резолвер регрессирует к pass-through админ-имени.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.site_finder import district_resolver
|
||||||
|
from app.services.site_finder.district_resolver import (
|
||||||
|
_reset_admin_cache,
|
||||||
|
resolve_objective_districts,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8 официальных админ-районов ЕКБ (district_name из ekb_districts с непустым geom).
|
||||||
|
_ADMIN_NAMES = [
|
||||||
|
"Академический",
|
||||||
|
"Верх-Исетский",
|
||||||
|
"Железнодорожный",
|
||||||
|
"Кировский",
|
||||||
|
"Ленинский",
|
||||||
|
"Октябрьский",
|
||||||
|
"Орджоникидзевский",
|
||||||
|
"Чкаловский",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Чистые микро (note IS NULL) для админов, которые тестим (срез prod-сида 131_*).
|
||||||
|
_CLEAN_MICROS: dict[str, list[str]] = {
|
||||||
|
"Кировский": ["Втузгородок", "ЖБИ"],
|
||||||
|
"Ленинский": ["Автовокзал", "УНЦ", "Юго-Западный"],
|
||||||
|
"Орджоникидзевский": ["Уралмаш", "Эльмаш"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_cache_between_tests() -> None:
|
||||||
|
"""Сбрасывать per-process кеш админ-имён — иначе мок одного теста течёт в другой."""
|
||||||
|
_reset_admin_cache()
|
||||||
|
yield
|
||||||
|
_reset_admin_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db(
|
||||||
|
admin_names: list[str] | None = None,
|
||||||
|
micros: list[str] | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Мок-сессия: 1-й execute → admin-names; 2-й (если есть) → micro-lookup.
|
||||||
|
|
||||||
|
Оба читаются через .scalars().all(). admin_names=None → дефолтные 8.
|
||||||
|
"""
|
||||||
|
names = _ADMIN_NAMES if admin_names is None else admin_names
|
||||||
|
db = MagicMock()
|
||||||
|
admin_result = MagicMock()
|
||||||
|
admin_result.scalars.return_value.all.return_value = names
|
||||||
|
micro_result = MagicMock()
|
||||||
|
micro_result.scalars.return_value.all.return_value = micros if micros is not None else []
|
||||||
|
db.execute.side_effect = [admin_result, micro_result]
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _micro_lookup_param(db: MagicMock) -> str | None:
|
||||||
|
"""Достать bind :d из ВТОРОГО execute (micro-lookup). None если его не было."""
|
||||||
|
if len(db.execute.call_args_list) < 2:
|
||||||
|
return None
|
||||||
|
args, _kwargs = db.execute.call_args_list[1]
|
||||||
|
return args[1]["d"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── None / 'не определён' → None (EKB-wide) ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoneAndUndetermined:
|
||||||
|
def test_none_returns_none(self) -> None:
|
||||||
|
db = _make_db()
|
||||||
|
assert resolve_objective_districts(db, None) is None
|
||||||
|
# ранний выход — БД даже не трогается
|
||||||
|
assert db.execute.call_count == 0
|
||||||
|
|
||||||
|
def test_undetermined_returns_none(self) -> None:
|
||||||
|
db = _make_db()
|
||||||
|
assert resolve_objective_districts(db, "не определён") is None
|
||||||
|
assert db.execute.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── админ-имя → его чистые микро (THE FIX) ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminExpandsToMicros:
|
||||||
|
@pytest.mark.parametrize("admin", list(_CLEAN_MICROS.keys()))
|
||||||
|
def test_admin_expands_to_clean_micros(self, admin: str) -> None:
|
||||||
|
expected = _CLEAN_MICROS[admin]
|
||||||
|
db = _make_db(micros=expected)
|
||||||
|
out = resolve_objective_districts(db, admin)
|
||||||
|
assert out == expected
|
||||||
|
|
||||||
|
def test_does_not_pass_admin_name_through(self) -> None:
|
||||||
|
# РЕГРЕССИОННЫЙ guard: резолвер НЕ должен возвращать [admin] (это и был баг —
|
||||||
|
# админ-имя → 0 строк). Должен вернуть микро, а админ-имя НЕ должно быть в наборе.
|
||||||
|
db = _make_db(micros=["Втузгородок", "ЖБИ"])
|
||||||
|
out = resolve_objective_districts(db, "Кировский")
|
||||||
|
assert out == ["Втузгородок", "ЖБИ"]
|
||||||
|
assert out != ["Кировский"]
|
||||||
|
assert "Кировский" not in out
|
||||||
|
|
||||||
|
def test_micro_lookup_bound_with_admin_name(self) -> None:
|
||||||
|
# micro-lookup параметризован именно админ-именем (CAST(:d AS text)).
|
||||||
|
db = _make_db(micros=["Уралмаш", "Эльмаш"])
|
||||||
|
resolve_objective_districts(db, "Орджоникидзевский")
|
||||||
|
assert _micro_lookup_param(db) == "Орджоникидзевский"
|
||||||
|
|
||||||
|
|
||||||
|
# ── админ без чистых алиасов → None (EKB-wide fallback, НЕ пустой набор) ───────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminNoCleanAliasesFallback:
|
||||||
|
def test_admin_without_clean_micros_returns_none(self) -> None:
|
||||||
|
# Админ в ekb_districts, но micro-lookup пуст (note IS NULL отсёк всё) →
|
||||||
|
# EKB-wide fallback. НИКОГДА не пустой список (иначе гарантированно 0 строк).
|
||||||
|
db = _make_db(micros=[])
|
||||||
|
out = resolve_objective_districts(db, "Чкаловский")
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
def test_never_returns_empty_list(self) -> None:
|
||||||
|
db = _make_db(micros=[])
|
||||||
|
out = resolve_objective_districts(db, "Ленинский")
|
||||||
|
assert out != []
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── raw микро (legacy внутренний вызов) → [district] как есть ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRawMicroPassThrough:
|
||||||
|
def test_raw_micro_returned_as_is(self) -> None:
|
||||||
|
# 'Втузгородок' — НЕ админ-имя → фильтруем по нему как есть (legacy-поведение).
|
||||||
|
db = _make_db()
|
||||||
|
out = resolve_objective_districts(db, "Втузгородок")
|
||||||
|
assert out == ["Втузгородок"]
|
||||||
|
|
||||||
|
def test_raw_micro_does_not_hit_micro_lookup(self) -> None:
|
||||||
|
# raw-микро не разворачивается → второй execute (micro-lookup) НЕ делается.
|
||||||
|
db = _make_db()
|
||||||
|
resolve_objective_districts(db, "Уралмаш")
|
||||||
|
assert db.execute.call_count == 1 # только admin-names
|
||||||
|
|
||||||
|
|
||||||
|
# ── кеш админ-имён ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminNamesCache:
|
||||||
|
def test_admin_names_cached_across_calls(self) -> None:
|
||||||
|
# admin-names читается ОДИН раз и кешируется per-process: второй резолв
|
||||||
|
# raw-микро НЕ делает повторный admin-names запрос.
|
||||||
|
db = MagicMock()
|
||||||
|
admin_result = MagicMock()
|
||||||
|
admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES
|
||||||
|
db.execute.return_value = admin_result # любой вызов вернёт admin-names
|
||||||
|
|
||||||
|
resolve_objective_districts(db, "Уралмаш") # raw micro → 1 execute (admins)
|
||||||
|
resolve_objective_districts(db, "Эльмаш") # raw micro → 0 execute (cache hit)
|
||||||
|
assert db.execute.call_count == 1
|
||||||
|
|
||||||
|
def test_admin_lookup_failure_treats_input_as_raw_micro(self) -> None:
|
||||||
|
# Сбой admin-names lookup → пустой admin-set → ЛЮБОЙ вход трактуется как raw
|
||||||
|
# микро (pass-through), НЕ роняем /analyze. Кеш-от-сбоя не залипает.
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = RuntimeError("db down")
|
||||||
|
out = resolve_objective_districts(db, "Кировский")
|
||||||
|
# admin-set пуст → 'Кировский' не распознан как админ → [district] как есть.
|
||||||
|
assert out == ["Кировский"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── сбой micro-lookup → EKB-wide fallback (не crash) ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicroLookupFailure:
|
||||||
|
def test_micro_lookup_exception_returns_none(self) -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
admin_result = MagicMock()
|
||||||
|
admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES
|
||||||
|
db.execute.side_effect = [admin_result, RuntimeError("micro lookup down")]
|
||||||
|
out = resolve_objective_districts(db, "Кировский")
|
||||||
|
# админ распознан, но lookup упал → EKB-wide (None), а не crash/пустой набор.
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── форма SQL: CAST(:d AS text), без :d::text ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolverSqlShape:
|
||||||
|
def test_micro_sql_uses_cast_not_double_colon(self) -> None:
|
||||||
|
sql = str(district_resolver._MICROS_FOR_ADMIN_SQL)
|
||||||
|
assert "CAST(:d AS text)" in sql
|
||||||
|
assert ":d::" not in sql
|
||||||
|
|
||||||
|
def test_micro_sql_filters_note_is_null(self) -> None:
|
||||||
|
# cross-city/ambiguous (note IS NOT NULL) ОБЯЗАНЫ быть отсечены.
|
||||||
|
sql = str(district_resolver._MICROS_FOR_ADMIN_SQL)
|
||||||
|
assert "note IS NULL" in sql
|
||||||
|
|
||||||
|
def test_admin_sql_filters_non_null_geom(self) -> None:
|
||||||
|
# 8 админов = ekb_districts с непустым geom ('не определён' без geom исключён).
|
||||||
|
sql = str(district_resolver._ADMIN_NAMES_SQL)
|
||||||
|
assert "geom IS NOT NULL" in sql
|
||||||
|
|
@ -14,8 +14,11 @@ psycopg v3 правило проверяется явно: bind-параметр
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
from app.services.site_finder.market_metrics import (
|
from app.services.site_finder.market_metrics import (
|
||||||
|
|
@ -33,6 +36,22 @@ from app.services.site_finder.market_metrics import (
|
||||||
)
|
)
|
||||||
|
|
||||||
_ELAST = "app.services.site_finder.market_metrics._elasticity_coef"
|
_ELAST = "app.services.site_finder.market_metrics._elasticity_coef"
|
||||||
|
# Резолвер admin→micro (Step 2). В shape/thin-data тестах патчим его на identity-обёртку
|
||||||
|
# через _patch_resolver(), чтобы не ходить в БД за admin-names/aliases. Отдельный класс
|
||||||
|
# TestDistrictResolution проверяет, что РЕЗОЛВНУТЫЕ микро реально попадают в SQL-параметры.
|
||||||
|
_RESOLVE = "app.services.site_finder.market_metrics.resolve_objective_districts"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _patch_resolver() -> Iterator[MagicMock]:
|
||||||
|
"""По умолчанию резолвер = identity-обёртка raw-микро (district→[district], None→None).
|
||||||
|
|
||||||
|
Так _mock_db (stock+sales, 2 execute) остаётся валиден — резолвер не трогает db.
|
||||||
|
Тесты, проверяющие admin→micro-разворот, переопределяют .side_effect у этого мока.
|
||||||
|
"""
|
||||||
|
with patch(_RESOLVE) as m:
|
||||||
|
m.side_effect = lambda _db, d: [d] if d is not None else None
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
# ── pure: _monthly_rate ───────────────────────────────────────────────────────
|
# ── pure: _monthly_rate ───────────────────────────────────────────────────────
|
||||||
|
|
@ -338,13 +357,14 @@ class TestComputeMarketMetricsShape:
|
||||||
compute_market_metrics(db, district="Автовокзал")
|
compute_market_metrics(db, district="Автовокзал")
|
||||||
stock_sql = _executed_sql(db, 0)
|
stock_sql = _executed_sql(db, 0)
|
||||||
sales_sql = _executed_sql(db, 1)
|
sales_sql = _executed_sql(db, 1)
|
||||||
# psycopg v3: CAST(:x AS type), НЕ :x::type
|
# psycopg v3: CAST(:x AS type), НЕ :x::type. District-фильтр теперь массив микро.
|
||||||
assert "CAST(:district AS text)" in stock_sql
|
assert "CAST(:districts AS text[])" in stock_sql
|
||||||
assert "CAST(:obj_ids AS bigint[])" in stock_sql
|
assert "CAST(:obj_ids AS bigint[])" in stock_sql
|
||||||
assert "CAST(:overstock_interval AS interval)" in stock_sql
|
assert "CAST(:overstock_interval AS interval)" in stock_sql
|
||||||
|
assert "CAST(:districts AS text[])" in sales_sql
|
||||||
assert "CAST(:window_interval AS interval)" in sales_sql
|
assert "CAST(:window_interval AS interval)" in sales_sql
|
||||||
for sql in (stock_sql, sales_sql):
|
for sql in (stock_sql, sales_sql):
|
||||||
assert ":district::" not in sql
|
assert ":districts::" not in sql
|
||||||
assert ":obj_ids::" not in sql
|
assert ":obj_ids::" not in sql
|
||||||
assert ":window_interval::" not in sql
|
assert ":window_interval::" not in sql
|
||||||
assert ":overstock_interval::" not in sql
|
assert ":overstock_interval::" not in sql
|
||||||
|
|
@ -354,7 +374,11 @@ class TestComputeMarketMetricsShape:
|
||||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||||
compute_market_metrics(db, district="Автовокзал", premise_kind="квартира")
|
compute_market_metrics(db, district="Автовокзал", premise_kind="квартира")
|
||||||
p = _executed_params(db, 0)
|
p = _executed_params(db, 0)
|
||||||
assert p["district"] == "Автовокзал"
|
# district резолвится в набор микро (тут identity → ['Автовокзал']) и идёт в
|
||||||
|
# ANY(:districts); прежнего скалярного :district больше нет.
|
||||||
|
assert p["has_district"] is True
|
||||||
|
assert p["districts"] == ["Автовокзал"]
|
||||||
|
assert "district" not in p
|
||||||
assert p["premise_kind"] == "квартира"
|
assert p["premise_kind"] == "квартира"
|
||||||
assert p["has_obj_ids"] is False
|
assert p["has_obj_ids"] is False
|
||||||
assert p["obj_ids"] == []
|
assert p["obj_ids"] == []
|
||||||
|
|
@ -415,6 +439,67 @@ class TestComputeMarketMetricsShape:
|
||||||
assert m.price_sensitivity_source is None
|
assert m.price_sensitivity_source is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDistrictResolution:
|
||||||
|
"""Step 2: админ-имя ЕКБ резолвится в МИКРО-набор, который реально идёт в SQL-фильтр.
|
||||||
|
|
||||||
|
Баг, который это ловит: раньше админ-имя («Кировский») фильтровало
|
||||||
|
objective_lots.district = 'Кировский' → 0 строк (микро-вокабуляр не содержит
|
||||||
|
админ-имён) → пустой прогноз. Теперь резолвер разворачивает его в чистые микро,
|
||||||
|
и SQL фильтрует ol.district = ANY([микро…]).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_admin_resolves_to_micros_in_sql_params(self, _patch_resolver: MagicMock) -> None:
|
||||||
|
# Резолвер разворачивает 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
|
||||||
|
_patch_resolver.side_effect = lambda _db, d: (
|
||||||
|
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||||||
|
)
|
||||||
|
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
||||||
|
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||||
|
m = compute_market_metrics(db, district="Кировский")
|
||||||
|
|
||||||
|
# резолвер вызван именно с админ-именем
|
||||||
|
_patch_resolver.assert_called_once()
|
||||||
|
assert _patch_resolver.call_args.args[1] == "Кировский"
|
||||||
|
|
||||||
|
# КРИТИЧНО: в SQL-параметры попали МИКРО, а НЕ админ-имя (иначе 0 строк).
|
||||||
|
for call_idx in (0, 1): # stock + sales-window
|
||||||
|
p = _executed_params(db, call_idx)
|
||||||
|
assert p["has_district"] is True
|
||||||
|
assert p["districts"] == ["Втузгородок", "ЖБИ"]
|
||||||
|
assert "Кировский" not in p["districts"]
|
||||||
|
assert "district" not in p # старого скалярного параметра больше нет
|
||||||
|
|
||||||
|
# district на результате всё равно ЭХОИТ входное админ-имя (для отображения).
|
||||||
|
assert m.district == "Кировский"
|
||||||
|
|
||||||
|
def test_resolver_none_drops_district_filter_ekb_wide(self, _patch_resolver: MagicMock) -> None:
|
||||||
|
# Админ без чистых алиасов / 'не определён' → резолвер None → EKB-wide:
|
||||||
|
# has_district=False, district-ветка фильтра отключена (пустой массив-плейсхолдер).
|
||||||
|
_patch_resolver.side_effect = lambda _db, _d: None
|
||||||
|
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
||||||
|
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||||
|
compute_market_metrics(db, district="не определён")
|
||||||
|
p = _executed_params(db, 0)
|
||||||
|
assert p["has_district"] is False
|
||||||
|
assert p["districts"] == []
|
||||||
|
|
||||||
|
def test_does_not_filter_by_admin_name_directly(self, _patch_resolver: MagicMock) -> None:
|
||||||
|
# Anti-vacuous guard: даже если кто-то «починит» резолвер обратно на pass-through
|
||||||
|
# админ-имени, фильтр обязан содержать РЕЗОЛВНУТЫЙ набор. Здесь резолвер отдаёт
|
||||||
|
# микро, и мы проверяем, что admin-имя НЕ просочилось в :districts ни в одном
|
||||||
|
# из двух запросов.
|
||||||
|
_patch_resolver.side_effect = lambda _db, d: (
|
||||||
|
["Уралмаш", "Эльмаш"] if d == "Орджоникидзевский" else None
|
||||||
|
)
|
||||||
|
db = _mock_db(_FULL_STOCK, _FULL_SALES)
|
||||||
|
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||||
|
compute_market_metrics(db, district="Орджоникидзевский")
|
||||||
|
for call_idx in (0, 1):
|
||||||
|
p = _executed_params(db, call_idx)
|
||||||
|
assert p["districts"] == ["Уралмаш", "Эльмаш"]
|
||||||
|
assert "Орджоникидзевский" not in p["districts"]
|
||||||
|
|
||||||
|
|
||||||
class TestComputeMarketMetricsThinData:
|
class TestComputeMarketMetricsThinData:
|
||||||
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
|
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,11 @@ psycopg v3 правило проверяется явно: bind-параметр
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
|
@ -34,6 +37,23 @@ from app.services.site_finder.supply_layers import (
|
||||||
_ALLOWED_SOURCES = {"objective", "domrf_declared", "domrf_multiphase"}
|
_ALLOWED_SOURCES = {"objective", "domrf_declared", "domrf_multiphase"}
|
||||||
_ALLOWED_CONFIDENCE = {"high", "medium", "low"}
|
_ALLOWED_CONFIDENCE = {"high", "medium", "low"}
|
||||||
|
|
||||||
|
# Резолвер admin→micro (Step 2) — патчим в L1-тестах. ТОЛЬКО L1 (open) резолвится
|
||||||
|
# (objective_lots = микро-вокабуляр); L2/L3 бьют domrf_kn_objects.district_name, а это
|
||||||
|
# УЖЕ админ-вокабуляр (м.57 spatial-join) → их district НЕ резолвится.
|
||||||
|
_RESOLVE = "app.services.site_finder.supply_layers.resolve_objective_districts"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _patch_resolver() -> Iterator[MagicMock]:
|
||||||
|
"""По умолчанию резолвер = identity raw-микро (district→[district], None→None).
|
||||||
|
|
||||||
|
Так _mock_db остаётся валиден (резолвер не трогает db). L1-тесты, проверяющие
|
||||||
|
admin→micro-разворот, переопределяют .side_effect.
|
||||||
|
"""
|
||||||
|
with patch(_RESOLVE) as m:
|
||||||
|
m.side_effect = lambda _db, d: [d] if d is not None else None
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
# ── pure: _hidden_units (L2 clamp) ────────────────────────────────────────────
|
# ── pure: _hidden_units (L2 clamp) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -205,14 +225,18 @@ class TestLayer1Open:
|
||||||
db = _mock_db([])
|
db = _mock_db([])
|
||||||
compute_layer1_open(db, district="Автовокзал")
|
compute_layer1_open(db, district="Автовокзал")
|
||||||
sql = _executed_sql(db)
|
sql = _executed_sql(db)
|
||||||
assert "CAST(:district AS text)" in sql
|
# District-фильтр L1 теперь массив резолвнутых микро (ANY), не скаляр.
|
||||||
|
assert "CAST(:districts AS text[])" in sql
|
||||||
_assert_no_double_colon_cast(sql)
|
_assert_no_double_colon_cast(sql)
|
||||||
|
|
||||||
def test_params(self) -> None:
|
def test_params(self) -> None:
|
||||||
db = _mock_db([])
|
db = _mock_db([])
|
||||||
compute_layer1_open(db, district="Автовокзал", premise_kind="квартира")
|
compute_layer1_open(db, district="Автовокзал", premise_kind="квартира")
|
||||||
p = _executed_params(db)
|
p = _executed_params(db)
|
||||||
assert p["district"] == "Автовокзал"
|
# district резолвится в набор микро (identity → ['Автовокзал']) → ANY(:districts).
|
||||||
|
assert p["has_district"] is True
|
||||||
|
assert p["districts"] == ["Автовокзал"]
|
||||||
|
assert "district" not in p
|
||||||
assert p["premise_kind"] == "квартира"
|
assert p["premise_kind"] == "квартира"
|
||||||
|
|
||||||
def test_rows_built_with_whitelisted_values(self) -> None:
|
def test_rows_built_with_whitelisted_values(self) -> None:
|
||||||
|
|
@ -271,6 +295,36 @@ class TestLayer1Open:
|
||||||
assert compute_layer1_open(db, district="Сбой") == []
|
assert compute_layer1_open(db, district="Сбой") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayer1DistrictResolution:
|
||||||
|
"""Step 2: L1 (open) резолвит админ-имя → микро-набор в SQL-фильтре.
|
||||||
|
|
||||||
|
Тот же баг, что в market_metrics: админ-имя по objective_lots.district давало 0
|
||||||
|
строк открытого предложения. Резолвер разворачивает его в чистые микро.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_admin_resolves_to_micros_in_sql_params(self, _patch_resolver: MagicMock) -> None:
|
||||||
|
_patch_resolver.side_effect = lambda _db, d: (
|
||||||
|
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||||||
|
)
|
||||||
|
db = _mock_db([])
|
||||||
|
compute_layer1_open(db, district="Кировский")
|
||||||
|
_patch_resolver.assert_called_once()
|
||||||
|
assert _patch_resolver.call_args.args[1] == "Кировский"
|
||||||
|
p = _executed_params(db)
|
||||||
|
# КРИТИЧНО: фильтр содержит МИКРО, а НЕ админ-имя (иначе 0 строк).
|
||||||
|
assert p["has_district"] is True
|
||||||
|
assert p["districts"] == ["Втузгородок", "ЖБИ"]
|
||||||
|
assert "Кировский" not in p["districts"]
|
||||||
|
|
||||||
|
def test_resolver_none_drops_district_filter(self, _patch_resolver: MagicMock) -> None:
|
||||||
|
_patch_resolver.side_effect = lambda _db, _d: None
|
||||||
|
db = _mock_db([])
|
||||||
|
compute_layer1_open(db, district="не определён")
|
||||||
|
p = _executed_params(db)
|
||||||
|
assert p["has_district"] is False
|
||||||
|
assert p["districts"] == []
|
||||||
|
|
||||||
|
|
||||||
# ── Layer2 (hidden) — THE CRUX ────────────────────────────────────────────────
|
# ── Layer2 (hidden) — THE CRUX ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -325,6 +379,17 @@ class TestLayer2Hidden:
|
||||||
assert p["region_cd"] == 66
|
assert p["region_cd"] == 66
|
||||||
assert p["district"] == "Автовокзал"
|
assert p["district"] == "Автовокзал"
|
||||||
|
|
||||||
|
def test_district_not_resolved_admin_vocab_preserved(self, _patch_resolver: MagicMock) -> None:
|
||||||
|
# КРИТИЧНО: L2 бьёт domrf_kn_objects.district_name (УЖЕ админ-вокабуляр, м.57
|
||||||
|
# spatial-join) → резолвер НЕ вызывается, админ-имя идёт в фильтр как есть.
|
||||||
|
# Иначе резолв в микро дал бы 0 строк (domrf не знает микро-имён).
|
||||||
|
db = _mock_db([])
|
||||||
|
compute_layer2_hidden(db, district="Кировский")
|
||||||
|
_patch_resolver.assert_not_called()
|
||||||
|
p = _executed_params(db)
|
||||||
|
assert p["district"] == "Кировский"
|
||||||
|
assert "districts" not in p
|
||||||
|
|
||||||
def test_hidden_aggregate_medium_confidence(self) -> None:
|
def test_hidden_aggregate_medium_confidence(self) -> None:
|
||||||
rows = [
|
rows = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue