Compare commits

..

1 commit

280 changed files with 3199 additions and 25386 deletions

View file

@ -27,7 +27,7 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md``.forgejo/workflows/deploy-obsidian.yml`
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh``.github/workflows/deploy-obsidian.yml`
- `docs/**` alone → НЕ триггерит деплой
## После изменения .env на VPS

View file

@ -9,14 +9,6 @@ name: Deploy Obsidian
#
# Не пересобирает никаких Docker-образов (CouchDB официальный с DockerHub).
# Не трогает main-стек (backend / frontend / postgres / worker / beat / caddy).
#
# ИСТОРИЯ (2026-07-05): жил в .github/workflows/ с момента миграции с GitHub
# (16.05.2026), помечен в README как «остался на GitHub» — но живого зеркала
# на github.com с настроенными секретами не оказалось: 0 запусков за всю
# историю Forgejo Actions (12000+ прогонов остальных workflow), контейнер
# не пересоздавался с 17.05 до ручного SSH-фикса 04.07. Перенесён сюда —
# единственная директория, которую реально исполняет этот инстанс.
# См. issue #2416.
on:
push:
@ -25,7 +17,7 @@ on:
- "docker-compose.obsidian.yml"
- "scripts/setup-couchdb.sh"
- "docs/obsidian-livesync.md"
- ".forgejo/workflows/deploy-obsidian.yml"
- ".github/workflows/deploy-obsidian.yml"
workflow_dispatch:
concurrency:

View file

@ -161,7 +161,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*
- [`.github/workflows/deploy-obsidian.yml`](.github/workflows/deploy-obsidian.yml) — obsidian (**остался на GitHub**): триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты).
**Forgejo Secrets / Variables:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе).

View file

@ -70,7 +70,7 @@ users:
admin: admin
kopylov: pilot
user1: pilot
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
user2: pilot
user3: pilot
user4: pilot
user5: pilot

View file

@ -1152,7 +1152,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
)
from app.workers.tasks.etl_newbuilding_crossload import etl_newbuilding_crossload
result = etl_newbuilding_crossload.apply_async(kwargs={"triggered_by": "manual"})
result = etl_newbuilding_crossload.apply_async()
return {"task_id": result.id, "queued_at": "now"}
@ -1789,20 +1789,6 @@ def trigger_ekburg_permits(
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
_WAF_COOLDOWN_GUARD_MSG = (
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
"передай i_understand_waf_risk=true в теле запроса."
)
class TriggerKnCatalogObjectsRequest(BaseModel):
region_code: int = Field(default=66, ge=1, le=99)
max_objects: int | None = Field(default=None, ge=1, le=2000)
@ -1814,14 +1800,6 @@ class TriggerKnCatalogObjectsRequest(BaseModel):
"что уже скраплено сегодня."
),
)
i_understand_waf_risk: bool = Field(
default=False,
description=(
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper бьёт по "
"тому же /сервисы/* BrowserSession path family, что вызвал бан."
),
)
@router.post("/kn-catalog-objects")
@ -1838,14 +1816,7 @@ def trigger_kn_catalog_objects(
- max_objects=None дефолтный лимит таска (300).
- max_objects=3 smoke-тест.
- force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
re-trigger без явного подтверждения оператора запрещён.
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
kwargs: dict[str, Any] = {
@ -1863,65 +1834,3 @@ def trigger_kn_catalog_objects(
"force": payload.force,
"queued_at": "now",
}
class TriggerKnCatalogFlatsRequest(BaseModel):
region_code: int = Field(default=66, ge=1, le=99)
max_flats: int | None = Field(default=None, ge=1, le=5000)
force: bool = Field(
default=False,
description=(
"True — игнорировать фильтр свежести ('catalog_updated_at свежий') и "
"грузить ВСЕ квартиры последнего snapshot с непустым catalog_url_hash "
"('Загрузить все'). По умолчанию пропускает то, что скраплено < 30 дней назад."
),
)
i_understand_waf_risk: bool = Field(
default=False,
description=(
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper ездит по "
"тому же /сервисы/* BrowserSession path family, что и catalog-objects."
),
)
@router.post("/kn-catalog-flats")
def trigger_kn_catalog_flats(
payload: TriggerKnCatalogFlatsRequest,
) -> dict[str, Any]:
"""Manual trigger для catalog-FLAT scraper (#2442): цена/статус/отделка/потолки/
дата обновления + plan-изображения квартир из SSR-страницы каталога.
Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL. До тех пор пока
#2442 Task 1 (elemId → catalog_url_hash) не задеплоен и свежий kn-sweep не
наполнил hash вернёт 0 обработанных строк (ожидаемо, не баг).
- max_flats=None дефолтный лимит таска (300).
- max_flats=3 smoke-тест.
- force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
того же WAF-бана.
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
kwargs: dict[str, Any] = {
"region_code": payload.region_code,
"force": payload.force,
}
if payload.max_flats is not None:
kwargs["max_flats"] = payload.max_flats
result = scrape_kn_catalog_flats.apply_async(kwargs=kwargs)
return {
"task_id": result.id,
"region_code": payload.region_code,
"max_flats": payload.max_flats,
"force": payload.force,
"queued_at": "now",
}

View file

@ -857,20 +857,6 @@ _NEIGHBORS_SUMMARY_SQL = text("""
ORDER BY distance_m ASC
LIMIT 30
),
neighbors_total AS (
-- #2464 cluster B: честный COUNT(*) по ВСЕЙ 100м-выборке — БЕЗ LIMIT 30
-- (тот же WHERE, что у `neighbors` выше). count_buildings_100m раньше
-- считался как len(neighbors), тихо капаясь на 30 даже когда соседей
-- в радиусе больше.
SELECT COUNT(*) AS n
FROM cad_buildings b
WHERE ST_DWithin(
b.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
100
)
AND b.cad_num != CAST(:our_cad AS text)
),
overlap_rows AS (
SELECT cad_num,
building_name,
@ -898,8 +884,7 @@ _NEIGHBORS_SUMMARY_SQL = text("""
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
FROM overlap_rows o),
'[]'::json
) AS overlap_rows,
(SELECT n FROM neighbors_total) AS neighbors_total_count
) AS overlap_rows
""")
@ -916,11 +901,6 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
один сетевой round-trip (~47ms) на каждый analyze сами вычисления не
меняются. Формат возвращаемого dict идентичен прежнему.
#2464 cluster B: `count_buildings_100m` — честный COUNT(*) по всей 100м-выборке
(CTE `neighbors_total`, БЕЗ LIMIT), а не len(neighbors) (капалось на LIMIT 30).
`neighbors_truncated` True если в радиусе больше соседей, чем показано в
списке `neighbors`.
SQL module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
"""
@ -935,9 +915,6 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
)
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
# #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) —
# НЕ len(neighbor_rows), которое капалось на 30 даже когда соседей больше.
neighbors_total_count = int(row["neighbors_total_count"]) if row else 0
except Exception as e:
logger.warning("neighbors query failed: %s", e)
return {"data_available": False, "note": f"neighbors query failed: {e}"}
@ -982,46 +959,36 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
]
has_existing = len(overlap_buildings) > 0
# neighbor_rows — до 30 (SQL CTE `neighbors` LIMIT), список ниже дополнительно
# режется до 20 для payload. `neighbors_truncated` сравнивает честный total с
# тем, что РЕАЛЬНО показано (len(neighbors_list)) — а не с промежуточным
# neighbor_rows (до 30) — иначе флаг мог соврать False на 21..30 соседях,
# где список уже обрезан до 20, а total ещё не превысил len(neighbor_rows).
neighbors_list = [
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
]
return {
"data_available": True,
"radius_m": 100,
# #2464 cluster B: честный total (neighbors_total CTE, без LIMIT) — НЕ
# len(neighbor_rows) (капалось на LIMIT 30 у CTE `neighbors`).
"count_buildings_100m": neighbors_total_count,
"neighbors_truncated": neighbors_total_count > len(neighbors_list),
"count_buildings_100m": len(neighbor_rows),
"avg_floors_100m": avg_floors,
"max_floors_100m": max_floors,
"median_cost_per_m2_100m": median_cost,
"neighbors": neighbors_list,
"neighbors": [
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
],
"has_existing_buildings": has_existing,
"overlap_buildings": overlap_buildings,
"note": (
@ -1190,70 +1157,6 @@ def _compute_confidence(
}
def _build_market_pulse(
competitor_rows: list[dict[str, Any]],
competitors_total: int,
velocity_data: dict[str, Any] | None,
) -> dict[str, Any]:
"""OBJ-3 aggregate: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
в `competitor_rows` (детальный список для карты/карточек), но исключаются
из расчётов рыночных метрик (market_avg_price_per_m2, top_sellers).
#2464 cluster B: `competitors_total` — честный total (передаётся вызывающим
кодом из отдельного COUNT(*) БЕЗ LIMIT, см. `analyze_parcel` 5a), НЕ
len(competitor_rows) (которое капалось на LIMIT 20 запроса конкурентов, и
заодно раздувало `coverage_pct` competitors_priced/20 вместо
competitors_priced/true_total). PURE вызывающий код передаёт честный total
отдельно, эта функция его не пересчитывает.
"""
competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
competitors_priced = len(competitors_with_price)
if competitors_with_price:
prices = [float(c["avg_price_per_m2_rub"]) for c in competitors_with_price]
market_avg_price = round(sum(prices) / len(prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
with_sales = [
c
for c in competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
top_sellers = sorted(
with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in top_sellers
]
else:
market_avg_price = None
top_sellers_list = []
coverage_pct = (
round(competitors_priced * 100.0 / competitors_total, 1) if competitors_total > 0 else 0.0
)
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
return {
"avg_velocity_m2": avg_velocity,
"market_avg_price_per_m2": market_avg_price,
"competitors_total": competitors_total,
"competitors_with_price": competitors_priced,
"coverage_pct": coverage_pct,
"top_sellers": top_sellers_list,
}
# #93 — on-demand cadastre fetch tuning constants.
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
#
@ -2353,34 +2256,6 @@ def analyze_parcel(
.all()
)
# 5a) #2464 cluster B: честный COUNT(*) конкурентов в радиусе 3км — БЕЗ LIMIT.
# competitor_rows выше капнут `LIMIT 20` (топ-20 ближайших/строящихся для карты и
# детального списка) — market_pulse.competitors_total ниже раньше = len(competitor_rows),
# тихо капаясь на 20 даже когда в радиусе ЖК больше. Лёгкий COUNT переиспользует
# ТОТ ЖЕ latest_obj + ST_DWithin фильтр, но БЕЗ obj_lots_latest/obj_pricing джойнов
# (нужны только для цен детального списка, не для total) — на порядок дешевле
# полного запроса конкурентов (см. #1964 EXPLAIN про стоимость lots-дедупа).
_competitors_total_true = (
db.execute(
text("""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT COUNT(*) FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
"""),
{"wkt": geom_wkt},
).scalar()
or 0
)
# 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес
# в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
# + по кварталам сдачи. Константы — см. PIPELINE_* выше.
@ -3805,12 +3680,56 @@ def analyze_parcel(
except Exception as _ve:
logger.warning("velocity compute failed for %s: %s", cad_num, _ve)
# #2464 cluster B: competitors_total — честный total из отдельного COUNT(*)
# (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса
# конкурентов. См. _build_market_pulse docstring для полной логики агрегата.
market_pulse: dict[str, Any] = _build_market_pulse(
competitor_rows, int(_competitors_total_true), velocity_data
# OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
# Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
# в competitor list для карты, но исключаются из расчётов рыночных метрик.
_competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
_competitors_total = len(competitor_rows)
_competitors_priced = len(_competitors_with_price)
if _competitors_with_price:
_prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price]
_market_avg_price = round(sum(_prices) / len(_prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
_with_sales = [
c
for c in _competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
_top_sellers = sorted(
_with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
_top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in _top_sellers
]
else:
_market_avg_price = None
_top_sellers_list = []
_coverage_pct = (
round(_competitors_priced * 100.0 / _competitors_total, 1)
if _competitors_total > 0
else 0.0
)
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
_avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
market_pulse: dict[str, Any] = {
"avg_velocity_m2": _avg_velocity,
"market_avg_price_per_m2": _market_avg_price,
"competitors_total": _competitors_total,
"competitors_with_price": _competitors_priced,
"coverage_pct": _coverage_pct,
"top_sellers": _top_sellers_list,
}
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается

View file

@ -120,18 +120,13 @@ class Settings(BaseSettings):
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
#
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch().
# Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2
# (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat /
# catalog-object scrapers) тоже переиспользуют эти же значения — они бьют
# по тому же /сервисы/* path family, что и вызвало WAF hard-ban 2026-05-24
# (#2443). nspd и прочие скраперы вне этого path family продолжают
# использовать модульный дефолт _BROWSER_CONCURRENCY=8 без изменений.
# 2 — эмпирически безопасный потолок против volume-бана.
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch()
# ТОЛЬКО для KN-sweep BrowserSession (другие скраперы — nspd/catalog —
# продолжают использовать модульный дефолт _BROWSER_CONCURRENCY=8 без
# изменений). 2 — эмпирически безопасный потолок против volume-бана.
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
scrape_kn_browser_concurrency: int = 2
# Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog-
# flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта
# Окно случайной паузы (мс) между запросами KN-sweep. Шире дефолта
# (6001500), чтобы размазать запросы во времени и не триггерить rate-ban.
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
# ширим до 12003000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.

View file

@ -65,11 +65,6 @@ class ConnectionPointsSummary(BaseModel):
in_protection_zone: bool
protection_zones_intersecting: int
total_structures_in_radius: int
# Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без
# LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL).
# ADDITIVE (default False) — старые клиенты не ломаются.
protection_zones_truncated: bool = False
structures_truncated: bool = False
class ConnectionPointsResponse(BaseModel):
@ -105,10 +100,6 @@ class UtilityInfrastructureSummary(BaseModel):
nearest_distance_m: float | None
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
nearest_by_kind: dict[str, float | None]
# Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же
# ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200).
# ADDITIVE (default False) — старые клиенты не ломаются.
features_truncated: bool = False
class UtilityInfrastructureResponse(BaseModel):

View file

@ -504,15 +504,6 @@ def developer_history(
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
# #2464 cluster E: без дедупа каждый ЖК возвращался ×N раз (одна строка на
# snapshot_date, ретенции нет) — портфель девелопера раздувался в разы (прод-замер
# ~7.6-9.0× per-developer). DISTINCT ON (obj_id) + snapshot_date DESC NULLS LAST —
# latest-snapshot-per-obj_id (тот же паттерн, что cmp_rows/latest_obj CTE в
# recommend_mix ниже в этом файле). dev_id — стабильный идентификатор
# (не меняется между снапшотами одного ЖК) → фильтруется ДО DISTINCT ON, как
# region_cd/district_name в сиблингах. Итоговый ORDER BY ready_dt вынесен во внешний
# SELECT: DISTINCT ON требует, чтобы его собственный ORDER BY начинался с ключа
# дедупа (obj_id, snapshot_date), не с ready_dt.
rows = (
db.execute(
text(
@ -520,15 +511,8 @@ def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
SELECT obj_id, comm_name, addr, region_cd, flat_count,
square_living, ready_dt, obj_class, escrow,
problem_flag, latitude, longitude, is_ekb
FROM (
SELECT DISTINCT ON (obj_id)
obj_id, comm_name, addr, region_cd, flat_count,
square_living, ready_dt, obj_class, escrow,
problem_flag, latitude, longitude, is_ekb
FROM domrf_kn_objects
WHERE dev_id = :dev
ORDER BY obj_id, snapshot_date DESC NULLS LAST
) latest
FROM domrf_kn_objects
WHERE dev_id = :dev
ORDER BY ready_dt DESC NULLS LAST
"""
),
@ -1813,38 +1797,12 @@ def _active_competitors_count(
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
def _q(where_extras: str, params: dict[str, Any]) -> int:
# #2464 cluster E: domrf_kn_objects хранит МНОЖЕСТВО snapshot_date на obj_id
# (UNIQUE(obj_id, snapshot_date), ретенции нет) — прод-замер 4090 строк / 482
# distinct obj_id для site_status='Строящиеся' (8.49× инфляция). Наивный
# COUNT(*) считал каждый исторический снапшот отдельно. Fix: DISTINCT ON
# (obj_id) + snapshot_date DESC NULLS LAST даёт latest-snapshot-per-obj_id
# (сиблинг-паттерн: _L3_FUTURE_SQL #1212 в site_finder/supply_layers.py).
#
# ВСЕ волатильные предикаты применяются ПОСЛЕ DISTINCT ON (во внешнем WHERE),
# а не внутри CTE — иначе DISTINCT ON вернул бы «последний снапшот, ПРОШЕДШИЙ
# фильтр», а не истинно последний снапшот объекта. Прод-замер per-obj_id
# variance across snapshots (authoritative, coordinator 2026-07-08):
# dev_id=0, region_cd=0 (СТАБИЛЬНЫ) · district_name=1, obj_class=29,
# site_status=11 (ВОЛАТИЛЬНЫ).
# → В CTE остаётся ТОЛЬКО region_cd (стабилен + ограничивает стоимость дедупа
# как partition-scope). {where_extras} (district_name/obj_class) и
# site_status='Строящиеся' — во внешнем WHERE, на истинно-последней строке.
# Пример бага, который это чинит: ЖК сменил класс Комфорт→Бизнес; при
# фильтре obj_class внутри CTE взяли бы старый Комфорт-снапшот и посчитали
# его активным Комфортом, хотя его актуальный класс уже Бизнес.
n = db.execute(
text(
f"""
WITH latest AS (
SELECT DISTINCT ON (obj_id)
obj_id, site_status, district_name,
obj_class, obj_class_fallback
FROM domrf_kn_objects
WHERE region_cd = :rc
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT COUNT(*) FROM latest
WHERE site_status = 'Строящиеся'
SELECT COUNT(*) FROM domrf_kn_objects
WHERE region_cd = :rc
AND site_status = 'Строящиеся'
{where_extras}
"""
),
@ -2275,28 +2233,14 @@ def _competitors_two_dim(
db.execute(
text(
f"""
WITH latest AS (
-- #2464 cluster E: сначала истинно-последний снапшот на obj_id,
-- ТОЛЬКО по стабильному region_cd (variance=0). Волатильные
-- предикаты (site_status=11, district_name=1, obj_class=29 per
-- прод-замер) НЕ внутри DISTINCT ON, иначе взяли бы «последний
-- снапшот, прошедший фильтр», а не истинно последний (ЖК,
-- сменивший класс/район/статус, засчитался бы по устаревшему
-- снапшоту). Зеркалит _q() в _active_competitors_count выше.
SELECT DISTINCT ON (obj_id)
obj_id, latitude, longitude, district_name,
site_status, obj_class, obj_class_fallback
WITH active AS (
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name
FROM domrf_kn_objects
WHERE region_cd = :rc
ORDER BY obj_id, snapshot_date DESC NULLS LAST
),
active AS (
-- Волатильные фильтры на истинно-последней строке.
SELECT obj_id, latitude, longitude, district_name
FROM latest
WHERE site_status = 'Строящиеся'
AND site_status = 'Строящиеся'
AND district_name = :dn
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
),
centroid AS (
SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt

View file

@ -89,8 +89,6 @@ _NO_DATA = "нет данных"
# Кап видимых строк тепло/вода-таблиц (зеркало _HEAT_ROW_CAP / _WATER_ROW_CAP в HTML).
_HEAT_ROW_CAP = 15
_WATER_ROW_CAP = 25
# Кап видимых строк таблицы разрешений §6 (зеркало _PERMITS_ROW_CAP в HTML).
_PERMITS_ROW_CAP = 10
# Титул документа + заголовки секций (зеркало _TITLE_* в full_report_html).
_TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА"
@ -1010,12 +1008,6 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
`permits_nearby.get_permits_nearby`). Пусто / total_count=0 честная плашка-фраза
(полное предложение, не аббревиатура). Зеркало `_build_permits_nearby` в
full_report_html.
#2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby
считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30. Здесь
список дополнительно режется до `_PERMITS_ROW_CAP` (10) для компактности раньше
это резалось МОЛЧА. Дисклоузим разницу `total_count - показано` строкой
«и ещё N », как тепло/вода-таблицы выше (`_build_connection_capacity`).
"""
nearby = _as_dict(result.get("permits_nearby"))
total = nearby.get("total_count")
@ -1038,7 +1030,7 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
_add_kv_table(doc, kv)
rows: list[list[Any]] = []
for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]:
for item in _as_list(nearby.get("items"))[:10]:
data = _as_dict(item)
rows.append(
[
@ -1048,11 +1040,6 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
data.get("distance_m"),
]
)
hidden = total - len(rows)
if hidden > 0:
rows.append(
[f"… и ещё {hidden} записей (полный список в веб-версии §6)", _DASH, _DASH, _DASH]
)
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
_add_data_table(doc, headers, rows)

View file

@ -96,8 +96,6 @@ _NON_EKB_GENERIC: tuple[str, ...] = (
# Кап видимых строк тепло/вода-таблиц (сверх — «и ещё K систем …»).
_HEAT_ROW_CAP = 15
_WATER_ROW_CAP = 25
# Кап видимых строк таблицы разрешений §6 (сверх — «и ещё K записей …»).
_PERMITS_ROW_CAP = 10
# Усечение длинных бюрократических имён систем.
_SYSTEM_NAME_MAX = 120
@ -1497,13 +1495,6 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
`result` analyze-payload (НЕ forecast): читает `permits_nearby` (см.
`permits_nearby.get_permits_nearby`). Пусто / total_count=0 честная плашка-фраза
(полное предложение, не аббревиатура). Все динамические строки через `html.escape`.
#2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby
считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30
(`items_truncated`). Здесь список дополнительно режется до `_PERMITS_ROW_CAP`
(10) для компактности PDF раньше это резалось МОЛЧА. Дисклоузим разницу
`total_count - показано` строкой «и ещё N », как тепло/вода-таблицы выше
(`_build_connection_capacity`).
"""
nearby = _as_dict(result.get("permits_nearby"))
total = nearby.get("total_count")
@ -1523,7 +1514,7 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
]
rows: list[list[Any]] = []
for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]:
for item in _as_list(nearby.get("items"))[:10]:
data = _as_dict(item)
rows.append(
[
@ -1533,16 +1524,6 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
data.get("distance_m"),
]
)
hidden = total - len(rows)
if hidden > 0:
rows.append(
[
f"… и ещё {hidden} записей (полный список в веб-версии §6)",
_DASH,
_DASH,
_DASH,
]
)
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
return _kv_table(kv) + _data_table(headers, rows)

View file

@ -32,9 +32,7 @@ from typing import Any
logger = logging.getLogger(__name__)
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
# Шесть секций зеркалят excel.py (#991); §13.7 «Уверенность» — доп. секция, портирована
# из report_docx/report_md для parity между экспортёрами (audit epic #2445 item C2;
# excel.py её пока не несёт — отдельный gap, вне scope этой правки).
# Тот же набор из шести содержательных секций, что рисует excel.py (#991).
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
_TITLE_SUMMARY: str = "Сводка"
@ -43,7 +41,6 @@ _TITLE_FUTURE_MARKET: str = "Будущий рынок"
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
_TITLE_SCENARIOS: str = "Сценарии"
_TITLE_SCORING: str = "Скоринг"
_TITLE_CONFIDENCE: str = "Уверенность"
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
@ -311,21 +308,15 @@ def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
def _build_summary(report: dict[str, Any]) -> str:
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст.
Уровень уверенности здесь только сводный badge (`overall_confidence`), зеркало
report_docx/report_md._build_summary. Полный разбор (rationale + факторы-драйверы)
живёт в отдельной секции §13.7 «Уверенность» (`_build_confidence`) раньше
(до parity-фикса #2445 C2) он дублировался здесь тонкой 2-строчной таблицей, что
расходилось с docx/md и не переживало dict-значный фактор; убрано, чтобы не было
двух версий одних и тех же данных в одном документе.
"""
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст."""
exec_summary = _as_dict(report.get("exec_summary"))
meta = _as_dict(report.get("meta"))
confidence = _as_dict(report.get("confidence"))
headline = exec_summary.get("headline")
verdict = exec_summary.get("verdict")
key_numbers = _as_dict(exec_summary.get("key_numbers"))
factors = _as_dict(confidence.get("factors"))
cad = _esc(meta.get("cad_num"))
district = _esc(meta.get("district"))
@ -338,6 +329,10 @@ def _build_summary(report: dict[str, Any]) -> str:
("Сформировано", meta.get("generated_at")),
("Версия схемы", meta.get("schema_version")),
]
confidence_pairs: list[tuple[str, Any]] = [
("Уровень", _level_ru(confidence.get("level"))),
("Обоснование", confidence.get("rationale")),
]
overall_conf = _esc(_level_ru(exec_summary.get("overall_confidence")))
return f"""
@ -356,6 +351,12 @@ def _build_summary(report: dict[str, Any]) -> str:
<h3>Ключевые числа</h3>
{_dict_kv_table(key_numbers)}
<h3>Уверенность отчёта</h3>
{_kv_table(confidence_pairs)}
<h3>Факторы уверенности</h3>
{_dict_kv_table(factors)}
<h3>Контекст</h3>
{_kv_table(context_pairs)}
</div>
@ -623,43 +624,6 @@ def _build_scoring(report: dict[str, Any]) -> str:
<h3>Специальные индексы</h3>
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
</div>
"""
def _build_confidence(report: dict[str, Any]) -> str:
"""§13.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица).
Parity fix (audit epic #2445 item C2): report_docx/report_md уже несли эту секцию
(§22.7/§13.7) PDF был единственным экспортёром без нее. Портировано 1-в-1 (та же
4-колоночная таблица «Фактор/Значение/Уровень/Комментарий»), но через HTML-примитивы
report_pdf (`_data_table`), а не python-docx/Markdown API.
"""
confidence = _as_dict(report.get("confidence"))
level = _level_ru(confidence.get("level"))
rationale = confidence.get("rationale")
factors = _as_dict(confidence.get("factors"))
# Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive:
# если значение — dict, раскладываем на value/level/note; иначе кладём как есть
# (зеркало report_docx._build_confidence / report_md._build_confidence).
factor_rows: list[list[Any]] = []
for name, payload in factors.items():
if isinstance(payload, dict):
factor_rows.append(
[name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")]
)
else:
factor_rows.append([name, payload, _DASH, _DASH])
return f"""
<div class="section" id="confidence">
<h2>{html.escape(_TITLE_CONFIDENCE)}</h2>
<span class="badge">Уровень: {_esc(level)}</span>
<p class="verdict">{_esc(rationale)}</p>
<h3>Факторы уверенности</h3>
{_data_table(["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)}
<div class="footer">{html.escape(_FOOTER_NOTE)}</div>
</div>
@ -667,8 +631,7 @@ def _build_confidence(report: dict[str, Any]) -> str:
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
# `_SHEET_BUILDERS` у excel.py + report_docx/report_md — Сводка → Рынок сейчас →
# Будущий рынок → Продукт ТЗ → Сценарии → Скоринг → Уверенность §13.7, #2445 C2).
# `_SHEET_BUILDERS` у excel.py — тот же набор из шести содержательных секций §13).
_SECTION_BUILDERS: tuple[Any, ...] = (
_build_summary,
_build_market_now,
@ -676,12 +639,11 @@ _SECTION_BUILDERS: tuple[Any, ...] = (
_build_product_tz,
_build_scenarios,
_build_scoring,
_build_confidence,
)
def _build_html(report: dict[str, Any]) -> str:
"""Склеить HTML-документ из семи секций §13 (+ §13.7 Уверенность). PURE. Graceful."""
"""Склеить HTML-документ из шести секций §13. PURE (только строки). Graceful."""
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
return f"""<!DOCTYPE html>
<html lang="ru">
@ -704,10 +666,10 @@ def export_report_pdf(report: Any) -> bytes:
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
рынок / Продукт ТЗ / Сценарии / Скоринг / Уверенность §13.7 parity с
report_docx/report_md, #2445 C2). Шапки таблиц с заливкой, RU-метки, числа
округлены, None "". На блоке «Сводка» заметный ADVISORY-маркер (отчёт
советующий). ВСЕ динамические строки экранируются `html.escape`.
рынок / Продукт ТЗ / Сценарии / Скоринг тот же набор, что и `export_report_xlsx`).
Шапки таблиц с заливкой, RU-метки, числа округлены, None "". На блоке «Сводка»
заметный ADVISORY-маркер (отчёт советующий). ВСЕ динамические строки экранируются
`html.escape`.
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/

View file

@ -371,20 +371,12 @@ def _month_grid(start: date, end: date) -> list[date]:
def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}.
SAVEPOINT (#2464 cluster A finding #2): `db` — общая §22-сессия отчёта; при сбое
этого запроса БЕЗ SAVEPOINT транзакция Postgres остаётся aborted и следующие
запросы get_monthly_macro (inflation, mortgage) + все ПОСЛЕДУЮЩИЕ §9.x-слои на
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
SAVEPOINT (ROLLBACK TO SAVEPOINT), внешняя транзакция остаётся рабочей.
"""
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}."""
try:
with db.begin_nested():
rows = db.execute(
_KEY_RATE_MONTHLY_SQL,
{"itype": "key_rate", "region": "rf", "since": since},
).all()
rows = db.execute(
_KEY_RATE_MONTHLY_SQL,
{"itype": "key_rate", "region": "rf", "since": since},
).all()
except Exception:
logger.exception("get_monthly_macro: key_rate query failed")
return {}
@ -397,14 +389,9 @@ def _query_inflation_monthly(db: Session, *, since: date) -> dict[date, float]:
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) берём как есть
через reuse get_macro_series (свой SQL не пишем). _month_start страховка.
Сбой/пустой ряд {} (НЕ crash), inflation_yoy тогда None по всей сетке.
SAVEPOINT (#2464 cluster A finding #2): см. `_query_key_rate_monthly` — та же
общая §22-сессия, тот же риск отравления транзакции для последующих запросов
(mortgage-поля + §9.x-слои). `with db.begin_nested():` изолирует сбой в SAVEPOINT.
"""
try:
with db.begin_nested():
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
except Exception:
logger.exception("get_monthly_macro: inflation_yoy query failed")
return {}
@ -417,20 +404,11 @@ def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date,
Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу
в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда
не валит остальные (graceful: пустой подсловарь).
SAVEPOINT (#2464 cluster A finding #2): все 5 полей читаются на ОДНОЙ `db`-Session
(get_monthly_macro вызывается внутри общей §22-сессии отчёта). Без SAVEPOINT сбой
ОДНОГО поля оставляет транзакцию Postgres aborted каждое СЛЕДУЮЩЕЕ поле в этом
же цикле тоже падает (хотя его данные были бы доступны), а `except` здесь молча
отдаёт [] по каждому, маскируя каскад под «нормальную» построчную деградацию.
`with db.begin_nested():` SAVEPOINT на КАЖДОЕ поле: сбой откатывает только его
SAVEPOINT (ROLLBACK TO SAVEPOINT), сессия остаётся рабочей для следующего поля.
"""
out: dict[str, dict[date, float]] = {}
for indicator_type, field in _MORTGAGE_FIELDS:
try:
with db.begin_nested():
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
except Exception:
logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type)
series = []

View file

@ -124,7 +124,7 @@ def _primary_horizon(horizons: Sequence[int]) -> int:
return horizons[0] if horizons else _PREFERRED_PRIMARY_HORIZON
def _safe_call(label: str, db: Session, fn: Any) -> Any:
def _safe_call(label: str, fn: Any) -> Any:
"""Вызвать §9.x-сервис graceful: сбой → None + logger.exception (не crash отчёта).
Зеркало product_scoring._safe_call: любой §9.x-слой может бросить (тонкие данные / нет
@ -133,29 +133,15 @@ def _safe_call(label: str, db: Session, fn: Any) -> Any:
широкий Exception (изоляция одного слоя от отчёта) с ОБЯЗАТЕЛЬНЫМ logger.exception
НЕ молчаливое глотание. §9.x уже graceful внутри; это belt-and-suspenders на шве.
SAVEPOINT (#2464 cluster A finding #1): все §9.x-слои шарят ОДИН `db`-Session на
отчёт (module docstring `forecast_request_cache.py`). Без обёртки сбойный
`db.execute` внутри слоя оставляет транзакцию Postgres в состоянии aborted
(«current transaction is aborted, commands ignored until end of transaction
block») КАЖДЫЙ последующий слой на той же сессии тоже падает, хотя его данные
были бы доступны. `with db.begin_nested():` заводит SAVEPOINT НА ВЕСЬ вызов слоя
(слой может делать несколько `db.execute` внутри себя например §9.6 внутри
§9.8/§11); при исключении SAVEPOINT откатывается автоматически (ROLLBACK TO
SAVEPOINT), внешняя транзакция остаётся рабочей для следующего слоя. НЕ
`db.rollback()` тот откатил бы ВСЮ внешнюю транзакцию (см. `backend.md` §
SAVEPOINT pattern, established anti-pattern).
Args:
label: имя слоя для лога (диагностика какой §9.x-вызов деградировал).
db: общая §22-сессия (для SAVEPOINT вокруг вызова слоя).
fn: нулевой-аргумент thunk вокруг §9.x-вызова.
Returns:
Результат `fn()` или None при исключении.
"""
try:
with db.begin_nested():
return fn()
return fn()
except Exception:
logger.exception("orchestrator: §9.x layer %s failed → section degraded", label)
return None
@ -348,25 +334,21 @@ def _build_site_finder_report_impl(
# ── 2. §9.x-слои, каждый graceful через _safe_call (ГЕТЕРОГЕННЫЕ сигнатуры) ──
market_metrics = _safe_call(
"market_metrics",
db,
lambda: compute_market_metrics(db, district=district, premise_kind=_PREMISE_KIND),
)
supply_rows = _safe_call(
"supply_layers",
db,
lambda: compute_all_layers(db, district=district, premise_kind=_PREMISE_KIND),
)
supply_layers = _summarize_supply_layers(supply_rows)
future_supply = _safe_call(
"future_supply",
db,
lambda: compute_future_supply_pressure(
db, district=district, horizon_months=primary, premise_kind=_PREMISE_KIND
),
)
forecasts = _safe_call(
"demand_supply_forecast",
db,
lambda: compute_demand_supply_forecast(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
@ -377,7 +359,6 @@ def _build_site_finder_report_impl(
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
scenarios_result = _safe_call(
"scenarios",
db,
lambda: compute_scenarios(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
@ -390,21 +371,18 @@ def _build_site_finder_report_impl(
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
product_scores = _safe_call(
"score_card",
db,
lambda: compute_score_card(
db, spec=spec, district=district, cad_num=cad_num, horizon_months=primary
),
)
special_indices = _safe_call(
"special_indices",
db,
lambda: compute_special_indices(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
recommendation_overlay = _safe_call(
"forecast_overlay",
db,
lambda: build_forecast_overlay(
db,
district=district,
@ -415,7 +393,7 @@ def _build_site_finder_report_impl(
)
# ── Макро-свежесть (audit MEDIUM): только лог; проводка в отчёт — 3b ─────────
macro = _safe_call("monthly_macro", db, lambda: get_monthly_macro(db))
macro = _safe_call("monthly_macro", lambda: get_monthly_macro(db))
macro_as_of = _macro_as_of(macro)
if macro_as_of is not None:
logger.info(

View file

@ -835,15 +835,13 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
compute_poi_weighted_top7. Нет геометрии / нет POI / сбой None (infra_fit unavailable).
"""
try:
with db.begin_nested():
coords = (
db.execute(
_PARCEL_CENTROID_SQL,
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
)
.mappings()
.first()
coords = (
db.execute(
_PARCEL_CENTROID_SQL, {"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)}
)
.mappings()
.first()
)
except Exception:
logger.exception(
"product_scoring: centroid lookup failed (cad_num=%s) → infra n/a", cad_num
@ -852,10 +850,9 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
if not coords or coords.get("lat") is None or coords.get("lon") is None:
return None
try:
with db.begin_nested():
response = compute_poi_weighted_top7(
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
)
response = compute_poi_weighted_top7(
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
)
except Exception:
logger.exception("product_scoring: poi_score failed (cad_num=%s) → infra n/a", cad_num)
return None

View file

@ -549,12 +549,6 @@ def _query_source_a(
Graceful {} при сбое/пустых данных. price_bucket в spec для Source A
игнорируется (агрегат не несёт per-lot цены) фиксируется логом.
SAVEPOINT (#2464 cluster A finding #4): `db` — общая §22-сессия отчёта (может
переиспользоваться другими §9.x-слоями после этого вызова). Без SAVEPOINT сбойный
`db.execute` оставляет транзакцию Postgres aborted все ПОСЛЕДУЮЩИЕ запросы на
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
SAVEPOINT при сбое, оставляя внешнюю транзакцию рабочей.
"""
if spec.price_bucket is not None:
logger.info(
@ -573,8 +567,7 @@ def _query_source_a(
"room_bucket": spec.room_bucket,
}
try:
with db.begin_nested():
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
except Exception:
logger.exception("build_sales_series: source A query failed")
return {}
@ -589,9 +582,6 @@ def _query_source_b(
Graceful {} при сбое/пустых данных. Передаёт bucket-пороги/-метки в SQL
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
правилом и в БД, и в Python.
SAVEPOINT (#2464 cluster A finding #4): см. `_query_source_a` — та же общая
§22-сессия, тот же риск отравления транзакции для последующих слоёв/запросов.
"""
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
# None → EKB-wide (без district-фильтра).
@ -623,8 +613,7 @@ def _query_source_b(
"p_unknown": PRICE_BUCKET_UNKNOWN,
}
try:
with db.begin_nested():
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
except Exception:
logger.exception("build_sales_series: source B query failed")
return {}

View file

@ -994,29 +994,16 @@ def _query_parcel_centroid(db: Session, *, cad_num: str) -> tuple[float, float]
Нет геометрии / сбой None (гео-веса упадут на floor; overlap считается по остальным
осям, индекс НЕ деградирует целиком). Параметризовано (psycopg v3). Детерминированно.
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): этот helper
зовётся из `_build_cannibalization` builder, обёрнутый внешним SAVEPOINT в
`compute_special_indices._run`. БЕЗ собственного `db.begin_nested():` сбойный
`db.execute` тут проглатывается локально ( None), оставляя транзакцию Postgres
aborted; тогда ВНЕШНИЙ `_run`-savepoint выходит «нормально» и делает RELEASE
SAVEPOINT, а RELEASE в aborted-tx САМ падает (Postgres в aborted допускает только
ROLLBACK / ROLLBACK TO SAVEPOINT) внешний except ловит уже RELEASE-ошибку, но
транзакция так и не откачена отравление каскадит в следующий §25-индекс. Savepoint
ИМЕННО ЗДЕСЬ (в точке перехвата) откатывает сбой (ROLLBACK TO SAVEPOINT), поэтому
внешний RELEASE проходит. Тот же принцип, что saturation.py: savepoint у db.execute
в точке, где ошибка ловится.
"""
try:
with db.begin_nested():
row = (
db.execute(
_PARCEL_CENTROID_SQL,
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
)
.mappings()
.first()
row = (
db.execute(
_PARCEL_CENTROID_SQL,
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
)
.mappings()
.first()
)
except Exception:
logger.exception("cannibalization: centroid query failed for cad_num=%s", cad_num)
return None
@ -1670,20 +1657,9 @@ def compute_special_indices(
segment = spec.as_dict()
def _run(key: str, builder: Any) -> SpecialIndex:
"""Выполнить builder одного индекса в собственном try/except (graceful).
SAVEPOINT (#2464 cluster A finding #3): все шесть builder'ов делят ОДНУ `db`-
Session (общая сессия §22-отчёта). Без SAVEPOINT сбойный `db.execute` внутри
builder'а оставляет транзакцию Postgres aborted — КАЖДЫЙ следующий индекс на
той же сессии тоже падает (хотя его данные были бы доступны), что маскируется
под шесть независимых деградаций. `with db.begin_nested():` заводит SAVEPOINT
на ВЕСЬ builder() (некоторые builder'ы делают несколько db.execute внутри
себя) сбой откатывает только его SAVEPOINT, сессия остаётся рабочей для
следующего индекса.
"""
"""Выполнить builder одного индекса в собственном try/except (graceful)."""
try:
with db.begin_nested():
return builder() # type: ignore[no-any-return]
return builder() # type: ignore[no-any-return]
except Exception:
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
logger.exception(

View file

@ -66,7 +66,9 @@ def _strategy_label(strategy: str) -> str:
def _teap_table(variants: Sequence[ConceptVariant]) -> str:
"""HTML-таблица ТЭП по всем вариантам (строки — показатели, колонки — стратегии)."""
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
headers = "".join(
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
)
rows: list[tuple[str, list[str]]] = [
("Пятно застройки, кв.м", [_fmt_int(v.teap.built_area_sqm) for v in variants]),
("Общая площадь (GFA), кв.м", [_fmt_int(v.teap.total_floor_area_sqm) for v in variants]),
@ -105,7 +107,9 @@ def _fmt_irr(financial: FinancialModel) -> str:
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF)."""
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
headers = "".join(
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
)
rows: list[tuple[str, list[str]]] = [
(
"Выручка — жильё, млн руб",
@ -184,13 +188,9 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
"НДС — реализация жилья и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ), "
"входной НДС по СМР встроен в себестоимость; НДС начисляется на нежилые части — "
"машиноместа и коммерцию/офисы 1-го этажа (встроенный НДС в добавленной стоимости "
"каждой части). Налог на прибыль — 25% (с 2025). Цены и себестоимость — рыночные "
"ориентиры. Коммерция/офисы 1-го этажа учтены по нормативной доле от общей площади "
"и продаются с умеренной наценкой к цене жилья того же класса; себестоимость СМР "
"нежилого — по той же ставке, что и жильё (отдельной строки затрат нет, повторного "
"учёта в затратах нет).</p>"
"входной НДС по СМР встроен в себестоимость; НДС начисляется только на нежилую часть "
"(машиноместа). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
"— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
"</body></html>"
)

View file

@ -30,7 +30,6 @@ from dataclasses import dataclass
from typing import Any
from pyproj import CRS, Transformer
from pyproj.exceptions import CRSError
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
from shapely.geometry.base import BaseGeometry
from shapely.ops import transform as shapely_transform
@ -54,12 +53,6 @@ MIN_BUILDABLE_AREA_SQM: float = 50.0
# удержать время в бюджете (<=10 c/вариант). MVP-упрощение.
MAX_GRID_CELLS: int = 20_000
# Валидные диапазоны WGS84 lon/lat (градусы). Используются, чтобы отловить участок,
# присланный в проекции (метры, напр. МСК-66/UTM), ДО того как _metric_transformers
# соберёт AEQD с lat_0/lon_0 далеко за пределами градусов -> pyproj.CRSError.
_LON_RANGE: tuple[float, float] = (-180.0, 180.0)
_LAT_RANGE: tuple[float, float] = (-90.0, 90.0)
# WGS84 (вход контракта).
_WGS84 = CRS.from_epsg(4326)
@ -177,27 +170,6 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
return polygon
def _assert_wgs84_bounds(polygon: Polygon) -> None:
"""Проверить, что bbox полигона лежит в валидных диапазонах WGS84 lon/lat.
Root-cause фикс: фронтовый баг / плохой геокодер / demo-payload иногда шлёт
``parcel_geojson`` в проекции (метры МСК-66/UTM, напр. [500000, 6200000]) вместо
WGS84 lon/lat. Такой полигон синтаксически валиден (это просто Polygon), но
дальше он бы дошёл до :func:`_metric_transformers`, где centroid.y (~6_200_000)
подставится в ``+lat_0=`` -> pyproj.CRS.from_proj4 упадёт CRSError (opaque 500).
Ловим здесь, ДО построения проекции, с понятным сообщением (422).
"""
minx, miny, maxx, maxy = polygon.bounds
lon_ok = _LON_RANGE[0] <= minx and maxx <= _LON_RANGE[1]
lat_ok = _LAT_RANGE[0] <= miny and maxy <= _LAT_RANGE[1]
if not (lon_ok and lat_ok):
raise ParcelGeometryError(
"parcel_geojson coordinates out of WGS84 lon/lat range "
f"(lon∈{_LON_RANGE}, lat∈{_LAT_RANGE}, got bounds={polygon.bounds}) — "
"coordinates appear to be projected (e.g. МСК-66/UTM metres), not WGS84 lon/lat"
)
def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transformer]:
"""Построить пару трансформеров WGS84<->метрический AEQD вокруг центроида участка.
@ -205,16 +177,10 @@ def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transform
координат и точен на масштабе квартала не нужен выбор UTM-зоны.
"""
centroid = polygon_wgs84.centroid
try:
metric_crs = CRS.from_proj4(
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
)
except CRSError as exc:
# Belt-and-suspenders: _assert_wgs84_bounds должен был отловить это раньше,
# но на случай иной координатной патологии не даём CRSError утечь наружу
# непойманным 500 — это невалидная геометрия участка, т.е. 422.
raise ParcelGeometryError(f"cannot build metric CRS for parcel centroid: {exc}") from exc
metric_crs = CRS.from_proj4(
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
)
to_metric = Transformer.from_crs(_WGS84, metric_crs, always_xy=True)
to_wgs84 = Transformer.from_crs(metric_crs, _WGS84, always_xy=True)
return to_metric, to_wgs84
@ -287,11 +253,9 @@ def parse_parcel(
"""Stage 1a: ConceptInput -> :class:`Parcel` (метрика + buildable + grid).
Raises:
ParcelGeometryError: полигон невалиден, координаты не в WGS84 lon/lat, или
пятно застройки вырождается.
ParcelGeometryError: полигон невалиден или пятно застройки вырождается.
"""
polygon_wgs84 = _parse_polygon(payload.parcel_geojson)
_assert_wgs84_bounds(polygon_wgs84)
to_metric, to_wgs84 = _metric_transformers(polygon_wgs84)
def _fwd(xs: Any, ys: Any) -> tuple[Any, Any]:

View file

@ -33,19 +33,15 @@ Wiring (отдельный PR):
from __future__ import annotations
import asyncio
import json
import logging
import re
from datetime import date
from html.parser import HTMLParser
from typing import Any
from urllib.parse import urljoin
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.scrapers.flat_plans import download_plan_image, upsert_flat_plans
from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep
logger = logging.getLogger(__name__)
@ -91,51 +87,6 @@ _STATUS_KW_RE = re.compile(
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
# ── plan-image extraction ─────────────────────────────────────────────────────
#
# Страница квартиры каталога DOM.РФ — Next.js SSR (тот же стек что и страница
# объекта, см. domrf_catalog_object.py). План квартиры рендерится через next/image,
# поэтому реальный URL картинки живёт в JSON-блоке <script id="__NEXT_DATA__">
# внутри pageProps, а НЕ в готовом <img src>. Стратегия extraction (в порядке
# приоритета, best-effort — DOM.РФ может переименовать ключи без уведомления):
#
# 1. __NEXT_DATA__ pageProps: ищем значение под одним из кандидат-ключей
# (planImage / planImageUrl / layoutImage / floorPlan / scheme / image / photo).
# 2. Fallback: bare <img>, чей src/alt/class намекают на планировку
# (plan / layout / scheme / планировк), либо <img> рядом с текстом «Планировка».
#
# URL нормализуется в абсолютный через urljoin(BASE_URL, ...): DOM.РФ отдаёт файлы
# и абсолютными (https://xn--80az8a.../api/ext/file/... — см. documents.py), и
# относительными (/_next/image?url=..., /api/ext/file/...). Мусорные картинки
# (data:-inline base64, captcha, лого застройщика, миниатюры) отсекаются.
# Ключи в pageProps, под которыми может лежать URL планировки. Проверяются по
# порядку; первое непустое строковое значение с image-like URL выигрывает.
_NEXT_PLAN_KEY_RE = re.compile(
r"(?:plan|layout|scheme|планировк|floor)",
re.IGNORECASE,
)
# Признак «это картинка» в значении: расширение файла ИЛИ file-API DOM.РФ.
_IMAGE_URL_RE = re.compile(
r"(?:\.(?:png|jpe?g|webp|svg)(?:\?|$)|/api/ext/file/|/_next/image)",
re.IGNORECASE,
)
# Токены, по которым отсекаем НЕ-планировочные картинки (лого, аватары, иконки,
# captcha, миниатюры, банеры). Проверяются по src/alt/class.
_IMAGE_NOISE_RE = re.compile(
r"(?:logo|avatar|icon|captcha|robot|sprite|banner|placeholder|thumb|"
r"favicon|badge|marker|pin)",
re.IGNORECASE,
)
# Признак «это планировка» в атрибутах <img> (src / alt / class).
_IMAGE_PLAN_HINT_RE = re.compile(
r"(?:plan|layout|scheme|планировк)",
re.IGNORECASE,
)
def _classify_status_kw(matched_text: str) -> str | None:
"""Классифицировать совпавшее ключевое слово в STATUS_*.
@ -266,19 +217,8 @@ class _TextCollector(HTMLParser):
# Список записей: (class_hint, full_text)
self.blocks: list[tuple[str, str]] = []
self._buf: list[str] = []
# Отдельный, аддитивный сбор <img>-атрибутов (issue #2440): нужен для
# extraction plan_image_url как fallback к __NEXT_DATA__. НЕ влияет на
# text-extraction — img остаётся void-тегом, фрейм в стек не пушится.
# Формат: (attrs_dict, index_в_blocks) — index привязывает картинку к
# позиции в потоке текстовых блоков для proximity-поиска «Планировка».
self.images: list[tuple[dict[str, str], int]] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag == "img":
# Собираем атрибуты img ДО early-return (void-тег): index = текущая
# длина blocks, т.е. картинка «привязана» к ближайшему предыдущему
# закрытому текстовому блоку. Text-extraction ниже не меняется.
self.images.append(({k: (v or "") for k, v in attrs}, len(self.blocks)))
if tag in self._VOID_TAGS:
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
attr_dict = {k: (v or "") for k, v in attrs}
@ -326,134 +266,6 @@ def _find_text_near(
return None
def _normalize_plan_url(url: str | None) -> str | None:
"""Нормализовать URL картинки планировки в абсолютный, отсеять мусор.
- data:-inline (base64) отбрасываем (это не удалённый файл, часто captcha/robot).
- Относительный путь ('/api/ext/file/...', '/_next/image?url=...') absolute
через urljoin(BASE_URL, ...).
- Пустое/None None.
"""
if not url:
return None
u = url.strip()
if not u or u.lower().startswith("data:"):
return None
if u.startswith("//"):
# protocol-relative → https
return "https:" + u
if u.startswith("http://") or u.startswith("https://"):
return u
return urljoin(BASE_URL + "/", u.lstrip("/"))
def _extract_plan_from_next_data(html: str) -> str | None:
"""Извлечь URL планировки из <script id="__NEXT_DATA__"> pageProps.
Страница квартиры каталога Next.js SSR: план рендерится через next/image,
URL картинки живёт в JSON-блоке. Рекурсивно обходим pageProps и ищем строковое
значение под ключом, матчащим _NEXT_PLAN_KEY_RE, чьё значение выглядит как
image-URL (_IMAGE_URL_RE) и НЕ является шумом (_IMAGE_NOISE_RE).
Возвращает первый подходящий URL (нормализованный) или None.
"""
match = re.search(
r'<script\s+id=["\']__NEXT_DATA__["\'][^>]*>(.+?)</script>',
html,
re.DOTALL,
)
if not match:
return None
try:
blob = json.loads(match.group(1).strip())
except (json.JSONDecodeError, ValueError):
return None
page_props = blob.get("props", {}).get("pageProps")
root: Any = page_props if isinstance(page_props, dict) else blob
# BFS по вложенному dict/list; ключ+значение проверяем на plan-hint.
stack: list[Any] = [root]
seen = 0
while stack and seen < 20_000: # cap: защита от патологически глубокого JSON
node = stack.pop()
seen += 1
if isinstance(node, dict):
for key, value in node.items():
if (
isinstance(value, str)
and _NEXT_PLAN_KEY_RE.search(str(key))
and _IMAGE_URL_RE.search(value)
and not _IMAGE_NOISE_RE.search(value)
):
normalized = _normalize_plan_url(value)
if normalized:
return normalized
if isinstance(value, dict | list):
stack.append(value)
elif isinstance(node, list):
stack.extend(node)
return None
def _extract_plan_from_imgs(
images: list[tuple[dict[str, str], int]], blocks: list[tuple[str, str]]
) -> str | None:
"""Fallback: выбрать <img> планировки из собранных _TextCollector.images.
Приоритет:
1. <img>, чей src/alt/class содержит plan/layout/scheme/планировк прямое
совпадение, самый надёжный сигнал.
2. <img> рядом (в потоке DOM) с текстовым блоком «Планировка».
Шумовые картинки (лого/иконки/captcha/миниатюры) отсекаются по _IMAGE_NOISE_RE,
inline data:-картинки и пустые src через _normalize_plan_url.
"""
# Уровень 1: явный plan-hint в атрибутах.
for attrs, _idx in images:
src = attrs.get("src", "")
alt = attrs.get("alt", "")
cls = attrs.get("class", "")
haystack = f"{src} {alt} {cls}"
if _IMAGE_NOISE_RE.search(haystack):
continue
if _IMAGE_PLAN_HINT_RE.search(haystack):
normalized = _normalize_plan_url(src)
if normalized:
return normalized
# Уровень 2: <img>, чей index стоит сразу после блока «Планировка».
plan_block_indices = [
i for i, (_cls, text) in enumerate(blocks) if re.search(r"планировк", text, re.IGNORECASE)
]
if plan_block_indices:
for attrs, idx in images:
src = attrs.get("src", "")
if _IMAGE_NOISE_RE.search(f"{src} {attrs.get('alt', '')} {attrs.get('class', '')}"):
continue
# картинка «принадлежит» блоку «Планировка», если её index в пределах
# ±2 текстовых блоков от лейбла.
if any(abs(idx - pbi) <= 2 for pbi in plan_block_indices):
normalized = _normalize_plan_url(src)
if normalized:
return normalized
return None
def extract_plan_image_url(html: str, collector: _TextCollector) -> str | None:
"""Извлечь URL картинки планировки из SSR-HTML страницы квартиры.
Two-strategy (issue #2440):
1. __NEXT_DATA__ pageProps (primary DOM.РФ Next.js SSR).
2. bare <img> fallback (по plan-hint атрибутам либо proximity к «Планировка»).
Возвращает абсолютный URL или None. Best-effort структура DOM.РФ хрупкая.
"""
url = _extract_plan_from_next_data(html)
if url:
return url
return _extract_plan_from_imgs(collector.images, collector.blocks)
def parse_catalog_flat(html: str) -> dict[str, Any]:
"""Извлечь поля из SSR-HTML страницы квартиры DOM.РФ.
@ -465,7 +277,6 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
- ceiling_height_m (float) Высота потолков в метрах
- section_no (int) Номер подъезда / секции
- catalog_updated_at (date) Дата обновления информации на странице
- plan_image_url (str) URL картинки планировки (issue #2440)
Парсинг хрупкий по природе (SSR HTML DOM.РФ меняется без уведомлений).
Все extraction best-effort KeyError/AttributeError обёрнуты внутри.
@ -657,14 +468,6 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
except ValueError:
pass
# ── Шаг 4: plan_image_url (issue #2440) ──────────────────────────────────
# __NEXT_DATA__ pageProps (primary) → bare <img> fallback. collector.images
# собран на Шаге 1 тем же feed(). None если план не найден (напр. проданная
# квартира без планировки на странице).
plan_url = extract_plan_image_url(html, collector)
if plan_url:
result["plan_image_url"] = plan_url
logger.debug(
"parse_catalog_flat: extracted fields=%s",
list(result.keys()),
@ -741,63 +544,16 @@ def upsert_catalog_data(
# ── Per-flat scrape orchestration ─────────────────────────────────────────────
async def _handle_plan_image(
db: Session,
session: BrowserSession,
ods_id: str,
obj_id: int,
plan_url: str,
snapshot_date: date,
outcome: dict[str, Any],
) -> None:
"""Записать URL планировки в flat_plans, скачать бинарник, дописать метаданные.
Порядок (issue #2440):
1. upsert_flat_plans с одним только plan_image_url URL зафиксирован даже
если скачивание упадёт (частичный прогресс не теряется).
2. download_plan_image reuse BrowserSession (cookies/TLS/throttle).
3. повторный upsert_flat_plans с local_path/размерами COALESCE внутри
upsert защищает уже скачанные файлы от перезаписи.
Мутирует outcome['plan_downloaded']. Ошибки логируются внутри download/upsert,
наружу не пробрасываются планировка не должна ронять catalog-scrape квартиры.
"""
plan_row: dict[str, Any] = {
"ods_id": ods_id,
"obj_id": obj_id,
"plan_image_url": plan_url,
}
upsert_flat_plans(db, obj_id, [plan_row], snapshot_date)
meta = await download_plan_image(session, plan_url, ods_id, obj_id)
if meta:
outcome["plan_downloaded"] = True
upsert_flat_plans(db, obj_id, [{**plan_row, **meta}], snapshot_date)
async def scrape_one_flat(
session: BrowserSession,
db: Session,
ods_id: str,
catalog_url_hash: str,
obj_id: int | None = None,
snapshot_date: date | None = None,
) -> dict[str, Any]:
"""Scrape одной квартиры: fetch HTML → parse → upsert (+ планировка).
"""Scrape одной квартиры: fetch HTML → parse → upsert.
Помимо catalog-полей (price/status/finishing/...) извлекает plan_image_url
(issue #2440): пишет URL в domrf_kn_flat_plans через upsert_flat_plans, затем
скачивает бинарник (download_plan_image) и повторным upsert'ом дописывает
local_path/размеры (COALESCE-защита уже скачанных файлов внутри upsert).
Скачивание inline в рамках того же BrowserSession: reuse cookies/TLS,
download_binary несёт свой лёгкий throttle (jitter 200500 мс), отдельная
beat-задача не заводится (требование #2440). obj_id/snapshot_date нужны для
записи в flat_plans; если не переданы plan-upsert пропускается с warning.
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated,
plan_found, plan_downloaded}. Ошибки fetch/parse/download логируются, не
бросаются caller обрабатывает результат.
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated}.
Ошибки fetch/parse логируются, не бросаются caller обрабатывает результат.
"""
outcome: dict[str, Any] = {
"ods_id": ods_id,
@ -805,8 +561,6 @@ async def scrape_one_flat(
"success": False,
"fields_extracted": 0,
"updated": False,
"plan_found": False,
"plan_downloaded": False,
"error": None,
}
@ -836,19 +590,6 @@ async def scrape_one_flat(
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
# ── plan_image_url → domrf_kn_flat_plans (issue #2440) ───────────────────
plan_url = data.get("plan_image_url")
if plan_url:
outcome["plan_found"] = True
if obj_id is not None and snapshot_date is not None:
await _handle_plan_image(db, session, ods_id, obj_id, plan_url, snapshot_date, outcome)
else:
logger.warning(
"catalog scrape ods_id=%s: plan_image_url found but obj_id/snapshot_date "
"missing — flat_plans upsert skipped",
ods_id,
)
# success отражает прохождение пайплайна fetch+parse без исключения; реально
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
@ -876,12 +617,11 @@ async def scrape_catalog_batch(
region_code: int = 66,
headed: bool = False,
load_state: str | None = None,
snapshot_date: date | None = None,
) -> dict[str, Any]:
"""Scrape пачки квартир каталога DOM.РФ.
`flats` список dict'ов с ключами {ods_id, catalog_url_hash [, obj_id]}.
Типовой источник: SELECT ods_id, obj_id, catalog_url_hash FROM domrf_kn_flats
`flats` список dict'ов с ключами {ods_id, catalog_url_hash}.
Типовой источник: SELECT ods_id, catalog_url_hash FROM domrf_kn_flats
WHERE catalog_url_hash IS NOT NULL
AND (catalog_updated_at IS NULL OR catalog_updated_at < NOW() - INTERVAL '30 days')
LIMIT :batch_size.
@ -889,25 +629,18 @@ async def scrape_catalog_batch(
Использует один BrowserSession на весь пакет (bootstrapped 1 раз).
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
obj_id/snapshot_date нужны для записи планировки в domrf_kn_flat_plans (issue
#2440). snapshot_date по умолчанию = сегодня (дата первичного обнаружения URL).
Returns:
{total, success, updated, failed, fields_total, plans_found, plans_downloaded}
{total, success, updated, failed, fields_total}
- success: прошли fetch+parse без исключения
- updated: реально затронули строку в БД (issue #1610) — отражает
фактическое число записанных квартир, в отличие от success
- plans_found / plans_downloaded: планировки найдены / скачаны (#2440)
"""
snap = snapshot_date or date.today()
stats: dict[str, Any] = {
"total": len(flats),
"success": 0,
"updated": 0,
"failed": 0,
"fields_total": 0,
"plans_found": 0,
"plans_downloaded": 0,
}
if not flats:
@ -915,10 +648,9 @@ async def scrape_catalog_batch(
return stats
logger.info(
"scrape_catalog_batch: starting %d flats region=%d snapshot_date=%s",
"scrape_catalog_batch: starting %d flats region=%d",
len(flats),
region_code,
snap,
)
async with BrowserSession(
@ -927,42 +659,21 @@ async def scrape_catalog_batch(
load_state=load_state,
# auth=None — страницы каталога публичные, Basic auth не нужен
auth=None,
# #2445 D2 anti-ban: тот же /сервисы/* path family, что вызвал DOM.РФ WAF
# hard-ban 2026-05-24 (#2443). Без явного override BrowserSession тихо
# наследует модульный дефолт stealth.py (concurrency=8, jitter 600-1500ms).
# Переиспользуем throttled настройки, принятые scrape_kn.py после #1945
# (settings.scrape_kn_* — общий anti-ban рычаг, НЕ специфичный для KN-sweep,
# несмотря на имя).
concurrency=settings.scrape_kn_browser_concurrency,
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
) as session:
# Warm-up: WAF DOM.РФ (2026-05-24) требует session cookies для SSR-страниц.
# Зеркалит scrape_catalog_objects. Idempotent на весь batch.
await session.warm_up()
for flat in flats:
ods_id = flat.get("ods_id", "")
catalog_url_hash = flat.get("catalog_url_hash", "")
obj_id_raw = flat.get("obj_id")
obj_id = int(obj_id_raw) if obj_id_raw is not None else None
if not ods_id or not catalog_url_hash:
logger.warning("scrape_catalog_batch: skip flat with missing ods_id/hash: %r", flat)
stats["failed"] += 1
continue
outcome = await scrape_one_flat(
session, db, ods_id, catalog_url_hash, obj_id=obj_id, snapshot_date=snap
)
outcome = await scrape_one_flat(session, db, ods_id, catalog_url_hash)
if outcome["success"]:
stats["success"] += 1
stats["fields_total"] += outcome["fields_extracted"]
if outcome["updated"]:
stats["updated"] += 1
if outcome.get("plan_found"):
stats["plans_found"] += 1
if outcome.get("plan_downloaded"):
stats["plans_downloaded"] += 1
else:
stats["failed"] += 1
@ -978,14 +689,11 @@ async def scrape_catalog_batch(
raise
logger.info(
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d "
"fields_total=%d plans_found=%d plans_downloaded=%d",
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
stats["total"],
stats["success"],
stats["updated"],
stats["failed"],
stats["fields_total"],
stats["plans_found"],
stats["plans_downloaded"],
)
return stats

View file

@ -24,7 +24,6 @@ from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep
logger = logging.getLogger(__name__)
@ -441,16 +440,6 @@ async def scrape_catalog_objects(
region_code=region_code,
# Страницы каталога публичные — Basic auth не нужен
auth=None,
# #2445 D2 anti-ban: этот scraper ходит по тому же /сервисы/* path family,
# что вызвал DOM.РФ WAF hard-ban 2026-05-24 (#2443). Без явного override
# BrowserSession тихо наследует модульный дефолт stealth.py (concurrency=8,
# jitter 600-1500ms) — тот самый throttle-less профиль, что и раньше
# триггерил volume-ban. Переиспользуем throttled настройки, принятые
# scrape_kn.py после #1945 (settings.scrape_kn_* — общий anti-ban рычаг,
# НЕ специфичный для KN-sweep, несмотря на имя).
concurrency=settings.scrape_kn_browser_concurrency,
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
) as session:
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies.
# DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих cookies.

View file

@ -432,14 +432,6 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8]
flat_id = int.from_bytes(digest, "big") % (2**63 - 1)
# catalog_url_hash — тот же elemId (hash-часть URL квартиры в каталоге DOM.РФ):
# https://наш.дом.рф/сервисы/каталог-квартир/квартира/{catalog_url_hash}.
# Читаем ЗДЕСЬ (а не только в fallback-ветке flat_id выше) — при наличии flatId
# ветка fallback не выполняется и elem остаётся непрочитанным. Пустую строку
# нормализуем в None: `or None` зеркалит трактовку `if elem:` в fallback-ветке
# и общий null-handling соседних optional-полей (_g отдаёт None при отсутствии).
catalog_url_hash = _g(row, "elemId") or None
price_rub = _g(row, "price")
price_per_m2 = _g(row, "pricePerSquareMeter")
total_area = _g(row, "totalArea")
@ -484,10 +476,7 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
"ceiling_height_m": None, # заполнит catalog scraper 22d
"key_handover_dt": None, # заполнит catalog scraper 22d
"catalog_updated_at": None, # заполнит catalog scraper 22d
# catalog_url_hash приходит из kn-API (elemId) уже здесь — нужен catalog
# scraper'у (22d) для построения URL страницы квартиры. Ранее хардкодился
# в None, из-за чего flat-catalog scraper не мог выбрать ни одной строки.
"catalog_url_hash": catalog_url_hash,
"catalog_url_hash": None, # заполнит catalog scraper 22d
}
@ -535,29 +524,14 @@ UPSERT_OBJECT_SQL = text(
:snapshot_date
)
ON CONFLICT (obj_id, snapshot_date) DO UPDATE SET
-- #2464 cluster E: hobj_id/short_addr/dev_inn/region_cd/floor_min/floor_max/
-- problem_flag/green_house были в INSERT (populated every call by
-- _norm_object) но пропущены в DO UPDATE SET re-run скрапера для
-- уже существующего (obj_id, snapshot_date) оставлял эти 8 колонок stale.
-- Все 8 direct EXCLUDED (не COALESCE): freshly computed каждый вызов,
-- в т.ч. problem_flag должен ОБНУЛЯТЬСЯ, если проблема на объекте снята
-- (COALESCE зафиксировал бы устаревший "проблемный" статус навсегда).
hobj_id = EXCLUDED.hobj_id,
comm_name = EXCLUDED.comm_name,
addr = EXCLUDED.addr,
short_addr = EXCLUDED.short_addr,
region_cd = EXCLUDED.region_cd,
dev_id = EXCLUDED.dev_id,
dev_name = EXCLUDED.dev_name,
dev_inn = EXCLUDED.dev_inn,
floor_min = EXCLUDED.floor_min,
floor_max = EXCLUDED.floor_max,
flat_count = EXCLUDED.flat_count,
square_living = EXCLUDED.square_living,
ready_dt = EXCLUDED.ready_dt,
problem_flag = EXCLUDED.problem_flag,
site_status = EXCLUDED.site_status,
green_house = EXCLUDED.green_house,
escrow = EXCLUDED.escrow,
obj_class = COALESCE(EXCLUDED.obj_class, domrf_kn_objects.obj_class),
wall_type = COALESCE(EXCLUDED.wall_type, domrf_kn_objects.wall_type),
@ -936,9 +910,6 @@ UPSERT_PHOTO_SQL = text(
:local_path, :thumb_path, :downloaded_at
)
ON CONFLICT (obj_id, obj_file_id) DO UPDATE SET
-- #2464 cluster F: build_type в INSERT column list (populated every call)
-- но пропущен здесь re-run скрапера оставлял build_type stale.
build_type = EXCLUDED.build_type,
ord_num = EXCLUDED.ord_num,
photo_url = EXCLUDED.photo_url,
photo_dttm = EXCLUDED.photo_dttm,

View file

@ -33,36 +33,15 @@ Issue #297 sub-task 22c.
from __future__ import annotations
import io
import logging
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
from datetime import date
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from datetime import date
from app.services.scrapers.stealth import BrowserSession
logger = logging.getLogger(__name__)
# Базовая директория для скачанных бинарников планировок. Зеркалит конвенцию
# domrf_kn.py:PHOTOS_DIR_DEFAULT (data/raw/domrf_photos) — тот же raw-корень,
# отдельная папка. Файлы: {base}/{obj_id}/{safe_ods_id}.{ext}.
FLAT_PLANS_DIR_DEFAULT = Path("data/raw/domrf_flat_plans")
# Разрешённые расширения картинок планировок. png — дефолт (DOM.РФ отдаёт планы
# растром), svg на всякий случай (векторные схемы встречаются).
_ALLOWED_PLAN_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".svg")
# Замена небезопасных для имени файла символов в ods_id (напр. "65136/1/1.4.3").
_ODS_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
# ── payload parsing ───────────────────────────────────────────────────────────
@ -233,131 +212,30 @@ def upsert_flat_plans(
return inserted
# ── image download ────────────────────────────────────────────────────────────
# ── download stub ─────────────────────────────────────────────────────────────
def _safe_ods_filename(ods_id: str) -> str:
"""Превратить ods_id (напр. '65136/1/1.4.3') в безопасное имя файла.
Слэши и прочие небезопасные символы '_'. Результат используется как
basename в {base_dir}/{obj_id}/{safe}.{ext}.
"""
return _ODS_UNSAFE_RE.sub("_", ods_id).strip("_") or "plan"
def _ext_from_url(url: str) -> str:
"""Определить расширение файла по URL планировки. Default .png.
DOM.РФ отдаёт планы преимущественно PNG; расширение берём из path, если
оно в белом списке, иначе .png (file-API URL /api/ext/file/... часто без
расширения контент-тип уточнится по факту, но png безопасный дефолт).
"""
path = urlsplit(url).path.lower()
for ext in _ALLOWED_PLAN_EXTS:
if path.endswith(ext):
return ".jpg" if ext == ".jpeg" else ext
return ".png"
def _image_dims(data: bytes) -> tuple[int | None, int | None]:
"""Получить (width_px, height_px) картинки через Pillow. None при ошибке.
Pillow (PIL) уже зависимость репо (pyproject: pillow>=10.4.0, используется в
app.services.photos.thumbs). SVG Pillow не парсит вернёт (None, None),
это ОК (размеры для векторных планов необязательны).
"""
try:
from PIL import Image
with Image.open(io.BytesIO(data)) as im:
return int(im.width), int(im.height)
except Exception as exc:
logger.debug("flat plan image dims parse failed: %s", exc)
return None, None
async def download_plan_image(
session: BrowserSession,
def download_plan_image_stub(
plan_image_url: str,
ods_id: str,
obj_id: int,
base_dir: Path | None = None,
) -> dict[str, Any] | None:
"""Скачать бинарник планировки квартиры и вернуть метаданные для upsert.
dest_dir: str | None = None,
) -> str:
"""Заглушка для скачивания бинарника планировки.
Реиспользует BrowserSession.download_binary тот же паттерн что download_photos
в domrf_kn.py: тянет файл через browser context (те же cookies / TLS-fingerprint,
что прошли WAF), с лёгким throttle (jitter 200500 мс встроен в download_binary).
Файл сохраняется в {base_dir}/{obj_id}/{safe_ods_id}.{ext}. Если файл уже есть
на диске повторно НЕ качаем (idempotent, экономит WAF-бюджет), но всё равно
возвращаем метаданные (размеры/размер читаются с диска).
Реальная реализация отдельный Celery task (22d-track, issue #299).
До реализации бросает NotImplementedError, чтобы случайный вызов
не прошёл незаметно.
Args:
session: Активный BrowserSession (bootstrapped + warm_up).
plan_image_url: Абсолютный URL картинки планировки.
plan_image_url: URL картинки планировки.
ods_id: Идентификатор квартиры (для имени файла).
obj_id: ID объекта DOM.РФ (подпапка).
base_dir: Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT).
dest_dir: Директория для сохранения (None = MEDIA_ROOT/flat_plans/).
Returns:
dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at
для передачи в upsert_flat_plans. None при ошибке скачивания.
Raises:
NotImplementedError: всегда до реализации.
"""
root = base_dir or FLAT_PLANS_DIR_DEFAULT
obj_dir = root / str(obj_id)
ext = _ext_from_url(plan_image_url)
local = obj_dir / f"{_safe_ods_filename(ods_id)}{ext}"
# Idempotent: уже скачан — читаем метаданные с диска, не жжём WAF-запрос.
if local.exists() and local.stat().st_size > 0:
data = local.read_bytes()
width_px, height_px = _image_dims(data)
logger.debug("flat plan already on disk ods_id=%s path=%s", ods_id, local)
return {
"local_path": str(local),
"width_px": width_px,
"height_px": height_px,
"size_bytes": local.stat().st_size,
"downloaded_at": datetime.now(UTC),
}
try:
data = await session.download_binary(plan_image_url)
except Exception as exc:
logger.warning(
"download_plan_image failed ods_id=%s obj_id=%s url=%s: %s",
ods_id,
obj_id,
plan_image_url,
exc,
)
return None
if not data:
logger.warning("download_plan_image empty body ods_id=%s url=%s", ods_id, plan_image_url)
return None
obj_dir.mkdir(parents=True, exist_ok=True)
try:
local.write_bytes(data)
except OSError as exc:
logger.warning("download_plan_image write failed ods_id=%s path=%s: %s", ods_id, local, exc)
return None
width_px, height_px = _image_dims(data)
logger.info(
"download_plan_image ok ods_id=%s obj_id=%s bytes=%d dims=%sx%s",
ods_id,
obj_id,
len(data),
width_px,
height_px,
raise NotImplementedError(
"download_plan_image_stub: скачивание планировок не реализовано. "
"Реальный downloader — отдельный Celery task (issue #299, 22d-track). "
f"URL={plan_image_url!r} ods_id={ods_id!r} dest_dir={dest_dir!r}"
)
return {
"local_path": str(local),
"width_px": width_px,
"height_px": height_px,
"size_bytes": len(data),
"downloaded_at": datetime.now(UTC),
}

View file

@ -19,21 +19,12 @@ searchMap отдаёт в properties **только {id}** — название/
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
WAF-hygiene (#2445 item D3): sibling-скрапер без backoff словил бан IP по WAF
DOM.РФ 24.05.2026 (#2443) — okn-mk.mkrf.ru имеет тот же risk shape (гос. CMS,
десятки последовательных запросов без паузы). Идиома пэйсинга/backoff/ретраев
портирована из ``gisogd66.py`` (``_RETRY_BACKOFF_S`` + сон между попытками) и
``objective.py`` (явная обработка 429/Retry-After): rate-limit между запросами
(``_wait_rate_limit``), экспоненциальный backoff на 429/5xx с капом
(``_do_request``/``_do_detail_request``), уважение заголовка Retry-After.
"""
from __future__ import annotations
import logging
import re
import time
import urllib.parse
from typing import Any
@ -51,47 +42,6 @@ _DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show"
_REQUEST_TIMEOUT = 30.0
_DETAIL_TIMEOUT = 10.0
# ── WAF-hygiene: пэйсинг + backoff (#2445 item D3) ────────────────────────────
# Вежливая пауза между последовательными запросами (мс) — тот же порядок величины,
# что и у objective.py (_DEFAULT_RATE_MS=500) / nspd_client.py (DEFAULT_RATE_MS=600).
_RATE_LIMIT_MS = 400
# Ретраи на 429/5xx (helper делает суммарно до 1 + _MAX_RETRIES попыток).
_MAX_RETRIES = 3
# Экспоненциальный backoff на 429/5xx: base * 2**attempt, капается _MAX_BACKOFF_S
# (аналогично objective.py: 30 * 2**attempt, cap 300s — здесь эндпоинт легче,
# поэтому база/потолок меньше, но идиома та же).
_RETRY_BACKOFF_BASE_S = 2.0
_MAX_BACKOFF_S = 60.0
# process-локальная метка времени последнего запроса (обеих эндпоинтов) —
# rate-limit общий, чтобы 33 list-запроса и до 1500 detail-запросов не долбили
# WAF без пауз даже при чередовании вызовов.
_last_request_ts: float = 0.0
def _wait_rate_limit() -> None:
"""Выдержать паузу ``_RATE_LIMIT_MS`` с последнего запроса к okn-mk.mkrf.ru.
Общий для searchMap и detail обе цели один и тот же WAF-хост.
"""
global _last_request_ts
elapsed_ms = (time.time() - _last_request_ts) * 1000.0
if elapsed_ms < _RATE_LIMIT_MS:
time.sleep((_RATE_LIMIT_MS - elapsed_ms) / 1000.0)
_last_request_ts = time.time()
def _backoff_seconds(attempt: int, retry_after_hdr: str | None) -> float:
"""Секунды ожидания перед повтором после 429/5xx.
Retry-After (если валиден) уважается первым, но капается _MAX_BACKOFF_S
аналогично objective.py. Иначе экспоненциальный backoff от attempt (0-based).
"""
if retry_after_hdr and retry_after_hdr.isdigit():
return min(float(retry_after_hdr), _MAX_BACKOFF_S)
return min(_RETRY_BACKOFF_BASE_S * (2**attempt), _MAX_BACKOFF_S)
# Browser headers -- required: without User-Agent the server returns 403.
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его
# автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется.
@ -149,7 +99,7 @@ def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]:
Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0].
"""
form_data = _build_form_data(address, category_type)
features = _do_request(form_data, verify=True, attempt=0)
features = _do_request(form_data, verify=True)
return features
@ -173,37 +123,19 @@ def _do_request(
form_data: list[tuple[str, str]],
*,
verify: bool,
attempt: int = 0,
) -> list[dict[str, Any]]:
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False.
WAF-hygiene: rate-limit перед каждой попыткой (``_wait_rate_limit``), на 429/5xx
экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз (аналогично objective.py).
"""
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False."""
# urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами
# (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple]
# конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает
# «expected bytes, tuple found». content= передаёт тело как raw str.
body = urllib.parse.urlencode(form_data)
_wait_rate_limit()
try:
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client:
resp = client.post(
_SEARCHMAP_URL,
content=body,
)
if resp.status_code == 429 or resp.status_code >= 500:
if attempt < _MAX_RETRIES:
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
logger.warning(
"okn_egrkn_client: HTTP %d от ЕГРКН (попытка %d/%d), retry через %.1fs",
resp.status_code,
attempt + 1,
_MAX_RETRIES,
wait,
)
time.sleep(wait)
return _do_request(form_data, verify=verify, attempt=attempt + 1)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc)
@ -213,7 +145,7 @@ def _do_request(
logger.warning(
"okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc
)
return _do_request(form_data, verify=False, attempt=attempt)
return _do_request(form_data, verify=False)
logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc)
return []
except Exception as exc:
@ -343,36 +275,17 @@ def fetch_okn_detail(source_id: str) -> dict[str, Any]:
"""
if not source_id:
return {}
return _do_detail_request(source_id, verify=True, attempt=0)
return _do_detail_request(source_id, verify=True)
def _do_detail_request(source_id: str, *, verify: bool, attempt: int = 0) -> dict[str, Any]:
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap.
WAF-hygiene: rate-limit перед каждой попыткой (общий с searchMap один WAF-хост),
на 429/5xx экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз.
"""
def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap."""
params = {"id": source_id}
_wait_rate_limit()
try:
with httpx.Client(
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
) as client:
resp = client.get(_DETAIL_URL, params=params)
if resp.status_code == 429 or resp.status_code >= 500:
if attempt < _MAX_RETRIES:
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
logger.warning(
"okn_egrkn_client: detail HTTP %d source_id=%s "
"(попытка %d/%d), retry через %.1fs",
resp.status_code,
source_id,
attempt + 1,
_MAX_RETRIES,
wait,
)
time.sleep(wait)
return _do_detail_request(source_id, verify=verify, attempt=attempt + 1)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.warning(
@ -386,7 +299,7 @@ def _do_detail_request(source_id: str, *, verify: bool, attempt: int = 0) -> dic
logger.warning(
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
)
return _do_detail_request(source_id, verify=False, attempt=attempt)
return _do_detail_request(source_id, verify=False)
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
return {}
except Exception as exc:

View file

@ -5,12 +5,6 @@ HTML server-rendered (нет JS-рендеринга), httpx достаточн
TLS: сайт использует российский УЦ verify=False (аналогично ekburg_permits #242).
Politeness (#2445 D4): пауза ``_RATE_S`` перед каждым запросом (search / карточка
документа / PDF) тот же идиом, что nspd_client/objective/gisogd66 (300-500мс). Этот
VPS уже словил WAF-бан от DOM.РФ 24.05.2026 (#2443) за непейсованные последовательные
запросы; pravo.gov66.ru сайт той же формы (гос-портал region, строгий WAF). Ретрай с
backoff на 429/5xx идиом gisogd66._get_json.
Публичный API:
search_documents(query, law_type, since) list[dict]
get_document_pdf_url(doc_id) str | None
@ -21,7 +15,6 @@ from __future__ import annotations
import logging
import re
import time
from typing import Any
import httpx
@ -35,14 +28,6 @@ _SEARCH_URL = f"{_BASE_URL}/search/"
# Тайм-аут щедрый: сервер иногда медленный.
_TIMEOUT = httpx.Timeout(60.0, connect=15.0)
# Politeness pacing (#2445 D4): пауза ПЕРЕД каждым запросом к pravo.gov66.ru — тот же
# идиом (300-500мс), что nspd_client/objective/gisogd66. Не непейсованный WAF-риск,
# аналогичный DOM.РФ-бану #2443.
_RATE_S = 0.4
# Ретраи на 429/5xx: экспоненциальный backoff, суммарно до 1 + _MAX_RETRIES попыток.
_MAX_RETRIES = 2
_RETRY_BACKOFF_S = 2.0
# Числовые коды типов документов (select#id_law_type на сайте).
# Текстовое значение «Постановление» — неверный параметр, сервер не матчит → 0 хитов.
_LAW_TYPE_POSTANOVLENIE = "1" # Постановление Правительства Свердловской области
@ -56,43 +41,6 @@ _RE_PDF_PATH = re.compile(r"/media/pravo/(?!ecp/)[^\s\"']+\.pdf", re.IGNORECASE)
_RE_PDF_ECP_PATH = re.compile(r"/media/pravo/ecp/[^\s\"']+\.pdf", re.IGNORECASE)
def _get_with_backoff(
client: httpx.Client,
url: str,
*,
params: dict[str, str] | None = None,
) -> httpx.Response:
"""GET с politeness-паузой перед запросом + backoff-ретраем на 429/5xx.
Пауза ``_RATE_S`` ПЕРЕД каждой попыткой (в т.ч. первой): пейсинг
последовательных запросов к pravo.gov66.ru, не только ретраев (#2445 D4).
429/5xx ретраятся до ``_MAX_RETRIES`` раз с экспоненциальным backoff;
прочие статусы (включая 4xx кроме 429) сразу ``raise_for_status()``.
"""
last_exc: Exception | None = None
for attempt in range(_MAX_RETRIES + 1):
time.sleep(_RATE_S)
resp = client.get(url, params=params)
if resp.status_code == 429 or resp.status_code >= 500:
last_exc = httpx.HTTPStatusError(
f"{resp.status_code} on {url}", request=resp.request, response=resp
)
if attempt >= _MAX_RETRIES:
raise last_exc
logger.warning(
"pravo_gov66_client: %s (попытка %d) на %s — backoff",
resp.status_code,
attempt + 1,
url,
)
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
continue
resp.raise_for_status()
return resp
assert last_exc is not None
raise last_exc
def search_documents(
query: str,
law_type: str = _LAW_TYPE_POSTANOVLENIE,
@ -117,7 +65,8 @@ def search_documents(
try:
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
resp = _get_with_backoff(client, _SEARCH_URL, params=params)
resp = client.get(_SEARCH_URL, params=params)
resp.raise_for_status()
html = resp.text
except Exception as exc:
logger.error("pravo_gov66_client.search_documents: ошибка сети q=%r: %s", query, exc)
@ -168,7 +117,8 @@ def get_document_pdf_url(doc_id: str) -> str | None:
doc_url = f"{_BASE_URL}/{doc_id}/"
try:
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
resp = _get_with_backoff(client, doc_url)
resp = client.get(doc_url)
resp.raise_for_status()
html = resp.text
except Exception as exc:
logger.error(
@ -201,10 +151,6 @@ def _extract_pdf_url(html: str) -> str | None:
def fetch_pdf(url: str) -> bytes:
"""Загружает PDF по URL.
Пейсинг + backoff на 429/5xx через ``_get_with_backoff`` (#2445 D4) — caller
(reservation_ingest) НЕ должен добавлять свой sleep перед этим вызовом, пауза
уже внутри клиента.
Args:
url: абсолютный URL PDF-файла.
@ -215,7 +161,8 @@ def fetch_pdf(url: str) -> bytes:
httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully).
"""
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
resp = _get_with_backoff(client, url)
resp = client.get(url)
resp.raise_for_status()
return resp.content

View file

@ -33,11 +33,9 @@ USER_AGENT = (
# Максимум одновременных in-page fetch() на одну BrowserSession;
# поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant.
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется nspd/прочими скраперами вне /сервисы/* path
# family. KN-sweep с 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2; после
# WAF hard-ban 2026-05-24 (#2443) domrf_catalog.py / domrf_catalog_object.py — тот же
# /сервисы/* path family — тоже переопределяют на throttled настройки (#2445 D2).
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется catalog/nspd/прочими скраперами. KN-sweep с
# 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2.
_BROWSER_CONCURRENCY = 8
# Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через

View file

@ -971,31 +971,28 @@ def _fetch_supply_only_layouts(
Returns:
Список SupplyOnlyLayoutRow (может быть пустым). Ошибка SQL не роняет ответ
логируется, savepoint откатывается, возвращается [] (блок аддитивен).
логируется, откатывается транзакция, возвращается [] (блок аддитивен).
"""
if not project_names:
return []
try:
# SAVEPOINT — db shared с caller (GET .../best-layouts / §22-отчёт,
# Depends(get_db)). Голый db.rollback() тут НЕЛЬЗЯ: он откатил бы ВСЮ outer
# tx (всю несохранённую работу этого запроса) и orphan'ит outer
# SessionTransaction (см. velocity.py:170). begin_nested откатывает ТОЛЬКО
# этот savepoint — outer tx остаётся usable, блок аддитивно деградирует в [].
with db.begin_nested():
rows = (
db.execute(
_SUPPLY_ONLY_LOTS_SQL,
{
"names": project_names,
"premise_kind": _SUPPLY_ONLY_PREMISE_KIND,
"price_min": _SUPPLY_ONLY_PRICE_MIN,
"price_max": _SUPPLY_ONLY_PRICE_MAX,
},
)
.mappings()
.all()
rows = (
db.execute(
_SUPPLY_ONLY_LOTS_SQL,
{
"names": project_names,
"premise_kind": _SUPPLY_ONLY_PREMISE_KIND,
"price_min": _SUPPLY_ONLY_PRICE_MIN,
"price_max": _SUPPLY_ONLY_PRICE_MAX,
},
)
.mappings()
.all()
)
except Exception:
# Аддитивный блок — не роняем весь ответ. rollback: следующего execute нет,
# но транзакция в aborted-state сломала бы commit/дальнейшую работу caller'а.
db.rollback()
logger.exception(
"best_layouts: supply-only lots query failed for %d projects — empty block",
len(project_names),
@ -1123,37 +1120,28 @@ def get_best_layouts(
# больше не теряет сделки, привязанные к 1-й через mapping).
core_group_to_project: dict[str, str] = {}
try:
# SAVEPOINT — db shared с caller (GET .../best-layouts / §22-отчёт,
# Depends(get_db)). Три db.execute здесь перемежаются с ЧИСТО-Python
# match_groups_to_projects / _merged_project_name_pool (без db) → оборачиваем
# весь блок одним savepoint. Голый db.rollback() НЕЛЬЗЯ: он откатил бы ВСЮ
# outer tx + orphan'ит outer SessionTransaction (см. velocity.py:170).
# begin_nested откатывает только этот savepoint → следующий execute (velocity
# SQL, Step 3) не упадёт InFailedSqlTransaction, «fallback на mapping-only»
# состоится.
with db.begin_nested():
corpus_names = [str(row[0]) for row in db.execute(_OBJECTIVE_PROJECT_NAMES_SQL).all()]
lots_names = [
str(row[0]) for row in db.execute(_OBJECTIVE_LOTS_PROJECT_NAMES_SQL).all()
]
project_names = _merged_project_name_pool(corpus_names, lots_names)
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
preliminary = match_groups_to_projects(complex_groups, project_names, None)
candidate_names = sorted(set(preliminary.values()))
developers_by_project: dict[str, list[str]] = {}
if candidate_names:
developers_by_project = {
str(row[0]): [str(d) for d in (row[1] or []) if d]
for row in db.execute(
_OBJECTIVE_DEVELOPERS_SQL, {"names": candidate_names}
).all()
}
core_group_to_project = match_groups_to_projects(
complex_groups, project_names, developers_by_project
)
corpus_names = [str(row[0]) for row in db.execute(_OBJECTIVE_PROJECT_NAMES_SQL).all()]
lots_names = [str(row[0]) for row in db.execute(_OBJECTIVE_LOTS_PROJECT_NAMES_SQL).all()]
project_names = _merged_project_name_pool(corpus_names, lots_names)
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
preliminary = match_groups_to_projects(complex_groups, project_names, None)
candidate_names = sorted(set(preliminary.values()))
developers_by_project: dict[str, list[str]] = {}
if candidate_names:
developers_by_project = {
str(row[0]): [str(d) for d in (row[1] or []) if d]
for row in db.execute(_OBJECTIVE_DEVELOPERS_SQL, {"names": candidate_names}).all()
}
core_group_to_project = match_groups_to_projects(
complex_groups, project_names, developers_by_project
)
except Exception:
# Не роняем весь ответ — core-attribution аддитивна к mapping-источнику.
# rollback: при реальной DB-ошибке транзакция в aborted-state — без отката
# следующий execute (velocity SQL) упал бы InFailedSqlTransaction, и
# «fallback на mapping-only» не состоялся бы.
db.rollback()
logger.exception(
"best_layouts: core-attribution failed for cad_num=%s — fallback to mapping-only",
cad_num,
@ -1216,24 +1204,18 @@ def get_best_layouts(
# 0 мес» / «% продано —». Per-object latest snapshot встроен в _SUPPLY_BATCH_SQL
# (flats_latest CTE) → отдельный pre-compute MAX больше не нужен.
try:
# SAVEPOINT — db shared с caller (GET .../best-layouts, Depends(get_db), sync
# get_best_layouts мостится через run_in_threadpool). Голый db.rollback() тут
# НЕЛЬЗЯ — orphan'ит outer SessionTransaction (см. velocity.py:170); begin_nested
# откатывает только эту savepoint, оставляя db usable для Step 9b
# (_fetch_supply_only_layouts) на этой же сессии.
with db.begin_nested():
supply_rows = (
db.execute(
_SUPPLY_BATCH_SQL,
{
"center_lon": center_lon,
"center_lat": center_lat,
"radius_m": radius_m,
},
)
.mappings()
.all()
supply_rows = (
db.execute(
_SUPPLY_BATCH_SQL,
{
"center_lon": center_lon,
"center_lat": center_lat,
"radius_m": radius_m,
},
)
.mappings()
.all()
)
except Exception:
logger.warning("best_layouts: supply query failed, supply=0 fallback")
supply_rows = []

View file

@ -449,42 +449,16 @@ _COMPETITORS_SQL = text("""
# scraper UPSERT per snapshot). Без фильтра snapshot_date AVG усреднял
# ИСТОРИЮ цен (stale на растущем рынке). Берём ТОЛЬКО latest snapshot — зеркало
# паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON.
#
# === PER-OBJECT snapshot fix (#2445 A5, byte-identical bug к #1956 в best_layouts) ===
# domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в СВОЙ день,
# UNIQUE-снимок (obj_id, snapshot_date), см. migration 174_domrf_kn_flats_obj_
# snapshot_idx.sql. Прежний фильтр брал ОДНУ глобальную дату
# `f.snapshot_date = MAX(snapshot_date по всей таблице)` — на этой единственной
# глобал-max дате присутствует обычно лишь 1 объект (скрейпнутый последним) →
# у ВСЕХ остальных конкурентов в радиусе 0 строк flats → avg_price_per_m2=NULL
# → _price_similarity(None, ...) молча возвращает нейтраль 0.5, тихо деградируя
# relevance-ранжирование конкурентов без единого флага.
# Фикс (зеркало best_layouts.flats_latest, #1956): flats_latest берёт для КАЖДОГО
# obj_id его СОБСТВЕННЫЙ последний снимок (DISTINCT ON (f.obj_id) ... ORDER BY
# f.obj_id, snapshot_date DESC, id DESC), затем self-join к domrf_kn_flats по
# (obj_id, snapshot_date) достаёт квартиры именно этого снимка. AVG считается
# per-object на СВЕЖЕЙ дате объекта, а не на единственной глобал-max дате.
# Слоение с OBJ-3 (#307) не меняется: этот запрос остаётся ПЕРВЫМ источником
# (domrf), _OBJECTIVE_PRICE_FALLBACK_SQL — additive fallback ТОЛЬКО для obj_id,
# у которых и после фикса нет domrf-цены (см. get_competitors шаг 4a). Ожидаемый
# эффект: domrf возвращает реальные цены для БОЛЬШЕ конкурентов → fallback нужен
# реже, но композиция (missing_price_ids / price_source) не меняется.
_AVG_PRICE_SQL = text("""
WITH flats_latest AS (
SELECT DISTINCT ON (f.obj_id)
f.obj_id, f.snapshot_date
FROM domrf_kn_flats f
WHERE f.obj_id = ANY(:obj_ids)
ORDER BY f.obj_id, f.snapshot_date DESC, f.id DESC
)
SELECT
f.obj_id,
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
AS avg_price_per_m2
FROM flats_latest fl
JOIN domrf_kn_flats f
ON f.obj_id = fl.obj_id
AND f.snapshot_date = fl.snapshot_date
FROM domrf_kn_flats f
WHERE f.obj_id = ANY(:obj_ids)
AND f.snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_flats
)
GROUP BY f.obj_id
""")
@ -799,12 +773,7 @@ def get_competitors(
avg_price_map: dict[int, float] = {}
price_source_map: dict[int, str] = {}
try:
# SAVEPOINT — db shared с caller (POST .../competitors, Depends(get_db)). Голый
# db.rollback() orphan'ит outer SessionTransaction (velocity.py:170) — тем более
# здесь ЕЩЁ ДВА db.execute идут следом в этой же функции (sold-count,
# objective-fallback) на том же db; без savepoint сбой здесь отравил бы их тоже.
with db.begin_nested():
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
for r in price_rows:
oid = int(r["obj_id"])
price = _row_get(r, "avg_price_per_m2")
@ -823,16 +792,11 @@ def get_competitors(
# flats_sold → None → нейтральный stage.
sold_count_map: dict[int, int] = {}
try:
# SAVEPOINT — db shared, см. avg_price блок выше (иначе отравляет objective
# price fallback ниже на том же db).
with db.begin_nested():
sold_rows = (
db.execute(
_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND}
)
.mappings()
.all()
)
sold_rows = (
db.execute(_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND})
.mappings()
.all()
)
for r in sold_rows:
oid = int(r["obj_id"])
sold = _row_get(r, "flats_sold")
@ -848,21 +812,17 @@ def get_competitors(
missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map]
if missing_price_ids:
try:
# SAVEPOINT — db shared, см. avg_price блок выше. Последний из трёх
# db.execute в get_competitors — тоже под savepoint, чтобы дальнейшие
# запросы analyze на этом же db (persist_analysis_run и т.п.) не отравились.
with db.begin_nested():
obj_price_rows = (
db.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL,
{
"obj_ids": missing_price_ids,
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
)
.mappings()
.all()
obj_price_rows = (
db.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL,
{
"obj_ids": missing_price_ids,
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
)
.mappings()
.all()
)
for r in obj_price_rows:
oid = int(r["obj_id"])
price = _row_get(r, "median_price_per_m2")

View file

@ -260,29 +260,25 @@ def _query_gas_city_grs(db: Session) -> dict:
"""
outlet_counts = _query_gas_outlet_counts(db)
try:
# SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)).
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, outer tx остаётся usable.
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT grs_name,
SUM(design_capacity_th_m3_h) AS design_sum,
SUM(free_capacity_th_m3_h) AS free_sum,
AVG(free_capacity_pct) AS pct_avg,
MAX(upgrade_due) AS upgrade_due,
COUNT(*) AS outputs_count
FROM gas_grs_capacity
WHERE grs_name ILIKE '%свердловск%'
OR grs_name ILIKE '%екатеринбург%'
GROUP BY grs_name
ORDER BY grs_name
""")
)
.mappings()
.all()
rows = (
db.execute(
text("""
SELECT grs_name,
SUM(design_capacity_th_m3_h) AS design_sum,
SUM(free_capacity_th_m3_h) AS free_sum,
AVG(free_capacity_pct) AS pct_avg,
MAX(upgrade_due) AS upgrade_due,
COUNT(*) AS outputs_count
FROM gas_grs_capacity
WHERE grs_name ILIKE '%свердловск%'
OR grs_name ILIKE '%екатеринбург%'
GROUP BY grs_name
ORDER BY grs_name
""")
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 181 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
@ -337,27 +333,25 @@ def _query_gas_outlet_counts(db: Session) -> dict:
Таблицы может не быть (миграция 184 не применена) все нули (graceful, аддитивно).
"""
try:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
row = (
db.execute(
text("""
SELECT COUNT(*) AS outlets_total,
COUNT(*) FILTER (
WHERE free_capacity_mln_m3 < 0
) AS outlets_deficit,
COUNT(*) FILTER (
WHERE free_capacity_needs_calc
) AS outlets_needs_calc
FROM gas_grs_outlet_points
WHERE period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
""")
)
.mappings()
.first()
row = (
db.execute(
text("""
SELECT COUNT(*) AS outlets_total,
COUNT(*) FILTER (
WHERE free_capacity_mln_m3 < 0
) AS outlets_deficit,
COUNT(*) FILTER (
WHERE free_capacity_needs_calc
) AS outlets_needs_calc
FROM gas_grs_outlet_points
WHERE period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
""")
)
.mappings()
.first()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
@ -390,41 +384,39 @@ def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
distance_m, lat, lon}] отсортировано по distance ASC.
"""
try:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
free_capacity_needs_calc AS needs_calc,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM gas_grs_outlet_points
WHERE geom IS NOT NULL
AND period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT CAST(:limit AS int)
"""),
{
"wkt": parcel_wkt,
"radius_m": _GAS_OUTLET_RADIUS_M,
"limit": _GAS_OUTLET_LIMIT,
},
)
.mappings()
.all()
rows = (
db.execute(
text("""
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
free_capacity_needs_calc AS needs_calc,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM gas_grs_outlet_points
WHERE geom IS NOT NULL
AND period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT CAST(:limit AS int)
"""),
{
"wkt": parcel_wkt,
"radius_m": _GAS_OUTLET_RADIUS_M,
"limit": _GAS_OUTLET_LIMIT,
},
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
@ -459,25 +451,23 @@ def _query_heat_latest(db: Session) -> dict:
"total_reserve_gcal_h": float|None}.
"""
try:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
FROM heat_system_reserves h
WHERE h.period IS NOT NULL
AND h.period = (
SELECT MAX(h2.period) FROM heat_system_reserves h2
WHERE h2.period IS NOT NULL
AND h2.org = h.org
)
ORDER BY h.org, h.system_name
""")
)
.mappings()
.all()
rows = (
db.execute(
text("""
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
FROM heat_system_reserves h
WHERE h.period IS NOT NULL
AND h.period = (
SELECT MAX(h2.period) FROM heat_system_reserves h2
WHERE h2.period IS NOT NULL
AND h2.org = h.org
)
ORDER BY h.org, h.system_name
""")
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 182 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.

View file

@ -251,19 +251,14 @@ def get_overlaps_for_scoring(
"""
_parcel_cad = parcel_cad or ""
try:
# SAVEPOINT — db shared с caller (POST .../analyze, Depends(get_db)). Голый
# db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, оставляя db usable для
# остальных запросов analyze_parcel на этой же сессии.
with db.begin_nested():
rows = (
db.execute(
text(_OVERLAPS_SQL),
{"wkt": parcel_geom_wkt, "user_id": user_id, "parcel_cad": _parcel_cad},
)
.mappings()
.all()
rows = (
db.execute(
text(_OVERLAPS_SQL),
{"wkt": parcel_geom_wkt, "user_id": user_id, "parcel_cad": _parcel_cad},
)
.mappings()
.all()
)
return [
{
"id": int(r["id"]),

View file

@ -142,22 +142,14 @@ def get_developer_attribution(
- БД-ошибке, включая ещё не задеплоенную миграцию 149 (analyze не падает).
"""
try:
# SAVEPOINT — db получена от caller (shared request-scoped Session,
# analyze_parcel в parcels.py). Голый db.rollback() здесь НЕЛЬЗЯ — он
# orphan'ит outer SessionTransaction и роняет persist_analysis_run сразу
# после (см. velocity.py:170 / connection_capacity_lookup._query_nearby_
# network_zones). Ошибка внутри begin_nested() откатывает ТОЛЬКО эту
# savepoint до propagate — outer-транзакция остаётся usable для
# последующих db.execute (#2464 cluster A finding 1).
with db.begin_nested():
rows = (
db.execute(
_DEVELOPER_FOR_PARCEL_SQL,
{"cad_num": cad_num, "radius": radius_m},
)
.mappings()
.all()
rows = (
db.execute(
_DEVELOPER_FOR_PARCEL_SQL,
{"cad_num": cad_num, "radius": radius_m},
)
.mappings()
.all()
)
except (OperationalError, ProgrammingError, DataError) as exc:
# Миграция 149 ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning(

View file

@ -68,22 +68,12 @@ def _admin_names(db: Session) -> frozenset[str]:
На ошибке БД возвращает пустой set (logged) резолвер тогда трактует ЛЮБОЙ
вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze.
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): резолвер
зовётся §22-форсайт-билдерами (sales_series `_query_source_a/_b`,
`_query_artificial_demand`) на ОБЩЕЙ сессии отчёта, которые сами обёрнуты во
внешние SAVEPOINT'ы. БЕЗ `db.begin_nested():` здесь сбойный `db.execute`
проглатывается ( frozenset()), оставляя транзакцию aborted; тогда внешний
SAVEPOINT при RELEASE падает (RELEASE запрещён в aborted-tx) и отравление
каскадит. Savepoint в точке перехвата откатывает сбой (ROLLBACK TO SAVEPOINT),
сессия остаётся рабочей.
"""
global _ADMIN_NAMES_CACHE
if _ADMIN_NAMES_CACHE is not None:
return _ADMIN_NAMES_CACHE
try:
with db.begin_nested():
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
rows = db.execute(_ADMIN_NAMES_SQL).scalars().all()
_ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r)
except Exception:
# Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова.
@ -132,13 +122,8 @@ def resolve_objective_districts(db: Session, district: str | None) -> list[str]
return [district]
# district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро.
# SAVEPOINT на внутреннем swallow-сайте (#2464 cluster A, RELEASE-trap): см.
# `_admin_names` — тот же общий-сессионный вызов из §22-билдеров под внешним
# SAVEPOINT. `db.begin_nested():` откатывает сбой в точке перехвата, чтобы
# внешний RELEASE не упал в aborted-tx (RELEASE запрещён в aborted).
try:
with db.begin_nested():
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all()
micros = [str(m) for m in rows]
except Exception:
logger.exception(

View file

@ -74,7 +74,6 @@ def _future_supply_key(
"""
return (district, horizon_months, premise_kind)
Confidence = Literal["high", "medium", "low"]
# Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД),
@ -440,12 +439,7 @@ def _query_supply_rows(db: Session, params: Mapping[str, Any]) -> Sequence[Mappi
(worker ещё не наполнил м.125) или сбой БД не должны валить расчёт индекса.
"""
try:
# SAVEPOINT — db shared с caller (compute_future_supply_pressure в цепочке
# §22-форсайта / analyze). Голый db.rollback() orphan'ит outer SessionTransaction
# (см. velocity.py:170) — begin_nested откатывает только эту savepoint, оставляя
# db usable для последующих db.execute на этой же сессии.
with db.begin_nested():
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
except Exception:
logger.exception(
"future_supply: supply-layers view query failed (district=%s)",

View file

@ -81,22 +81,20 @@ def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
[] при отсутствии пересечений или если таблица пуста/не существует.
"""
try:
with db.begin_nested():
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
return [
{
"num_oks_krt": row.num_oks_krt,
"status": row.status,
"full_name": row.full_name,
"area": float(row.area) if row.area is not None else None,
}
for row in rows
]
except Exception as exc: # таблица не существует / DB-сбой → graceful
logger.warning("ird_analyze: _krt_at DB query failed: %s", exc)
return []
return [
{
"num_oks_krt": row.num_oks_krt,
"status": row.status,
"full_name": row.full_name,
"area": float(row.area) if row.area is not None else None,
}
for row in rows
]
def _zone_regulation_at(
db: Session, client: EKBGeoportalClient, lon: float, lat: float

View file

@ -130,8 +130,7 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
return []
try:
with db.begin_nested():
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_krt_requisites: таблица недоступна, skip: %s", exc)
return []
@ -174,10 +173,9 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
# Нет exact-матча — geom-only поиск по КРТ-топонимам
try:
with db.begin_nested():
no_match_rows = (
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
)
no_match_rows = (
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
)
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_krt_requisites: no-match fallback недоступен: %s", exc)
return []

View file

@ -263,7 +263,9 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
# ── competition + raw demand-velocity ← market_metrics (ОДИН вызов, два сигнала) ─
try:
metrics = compute_market_metrics(db, district=district, window_months=_MARKET_WINDOW_MONTHS)
metrics = compute_market_metrics(
db, district=district, window_months=_MARKET_WINDOW_MONTHS
)
# competition ← overstock_index (доступное/затоваренное конкурирующее
# предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с
# velocity и инвертирован относительно «свободной конкуренции»).
@ -298,15 +300,6 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
logger.exception(
"location_indices: infra (POI) failed (district=%s) → infra None", district
)
# Plain rollback OK here (unlike begin_nested at the pass-2 upsert loop below):
# compute_location_indices только вызывается из refresh_locations, единственный
# caller которой — Celery-таск location_refresh.py, открывающий СВОЙ SessionLocal()
# (owned session, не shared request/report-scoped). На момент этого блока (pass 1,
# read-only) в транзакции ещё нет незакоммиченных write'ов — откатывать нечего,
# rollback() не сиротит внешний SessionTransaction (в отличие от velocity.py:170).
# Без rollback аборченная Postgres-транзакция валит СЛЕДУЮЩИЙ район в цикле pass 1
# (см. #2464) — как и все db.execute() в pass 2 upsert.
db.rollback()
logger.info(
"location_indices: district=%s infra=%s competition=%s raw_velocity=%s future_supply=%s",

View file

@ -613,13 +613,7 @@ def compute_market_metrics(
def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
"""Текущий сток. На ошибке/пустых данных → все счётчики 0 (graceful)."""
try:
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
# / analyze). Голый db.rollback() orphan'ит outer SessionTransaction (см.
# velocity.py:170) — begin_nested откатывает только эту savepoint, outer tx
# остаётся usable для последующих db.execute (_query_sales_window/_price_sensitivity)
# на этой же сессии.
with db.begin_nested():
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
except Exception:
logger.exception(
"market_metrics: stock query failed (districts=%s)", params.get("districts")
@ -656,12 +650,7 @@ def _query_sales_window(
затирать прежние счётчики. На ошибке/пусто (0, 0.0, {}).
"""
try:
# SAVEPOINT — db shared с caller (см. _query_stock выше). CRITICAL: без него
# сбой здесь оставляет транзакцию aborted, и следующий db.execute в этой же
# цепочке (_price_sensitivity → _elasticity_coef) падает "current transaction
# is aborted" вместо честного graceful-fallback.
with db.begin_nested():
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
except Exception:
logger.exception(
"market_metrics: sales-window query failed (districts=%s)", params.get("districts")
@ -711,19 +700,14 @@ def _price_sensitivity(
micros = resolve_objective_districts(db, district)
elasticity_window = max(window_months, 24)
try:
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
# / analyze). _elasticity_coef делает свой db.execute внутри без try/except —
# оборачиваем ВЕСЬ вызов, чтобы сбой откатывал только эту savepoint (не outer
# SessionTransaction, см. velocity.py:170), и db оставалась usable дальше.
with db.begin_nested():
elast = _elasticity_coef(
db,
region_code=_EKB_REGION_CODE,
district_name=district,
target_class=None,
elasticity_window_months=elasticity_window,
districts=micros if micros is not None else [],
)
elast = _elasticity_coef(
db,
region_code=_EKB_REGION_CODE,
district_name=district,
target_class=None,
elasticity_window_months=elasticity_window,
districts=micros if micros is not None else [],
)
except Exception:
logger.exception(
"market_metrics: elasticity reuse failed (district=%s micros=%s)",
@ -893,12 +877,7 @@ def compute_offer_price_trend(
"price_max": _OFFER_PRICE_MAX,
}
try:
# SAVEPOINT — db shared с caller (POST .../analyze / §22-отчёт, Depends(get_db)).
# 8с-гвард (parcels.py) может отменить этот запрос (QueryCanceled) → без savepoint
# транзакция остаётся aborted и следующий db.execute падает "current transaction
# is aborted". begin_nested откатывает только этот savepoint, db остаётся usable.
with db.begin_nested():
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
except Exception as exc:
# Ожидаемый путь: 8с-гвард (parcels.py) отменяет запрос на холодном кэше
# (~2/сутки, профилирование 2026-07-04) — блок аддитивный, analyze успешен.

View file

@ -74,11 +74,10 @@ def parcel_okn_objects(
if not parcel_wkt:
return []
try:
with db.begin_nested():
rows = db.execute(
_OKN_DWITHIN_SQL,
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
).fetchall()
rows = db.execute(
_OKN_DWITHIN_SQL,
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
).fetchall()
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_okn_objects: okn_objects недоступна, skip: %s", exc)
return []

View file

@ -169,12 +169,7 @@ def _query_current(db: Session) -> list[OwnProject]:
)
return []
try:
# SAVEPOINT — db shared с caller (get_own_portfolio в цепочке §25.3 / analyze).
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, оставляя db usable для
# _query_future (следующий вызов на этой же сессии в get_own_portfolio).
with db.begin_nested():
rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
except Exception:
logger.exception("own_portfolio: current (domrf) query failed → []")
return []
@ -205,11 +200,7 @@ def _query_future(db: Session) -> list[OwnProject]:
Сбой/отсутствие таблицы (cold-start до миграции 148) [] (НЕ crash).
"""
try:
# SAVEPOINT — db shared с caller (см. _query_current выше). begin_nested
# откатывает только эту savepoint (НЕ голый db.rollback(), см. velocity.py:170),
# оставляя db usable для остальных потребителей той же сессии (§25.3 / analyze).
with db.begin_nested():
rows = db.execute(_FUTURE_SQL).mappings().all()
rows = db.execute(_FUTURE_SQL).mappings().all()
except Exception:
logger.exception("own_portfolio: future (own_planned_project) query failed → []")
return []

View file

@ -45,12 +45,7 @@ def parcel_pat_subzones(db: Session, parcel_wkt: str | None) -> list[dict[str, A
return []
try:
# SAVEPOINT — db shared с caller (analyze_parcel, shared request-scoped Session).
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, не мешая persist_analysis_run
# и прочим db.execute дальше по /analyze (#2464 cluster A finding 7).
with db.begin_nested():
rows = db.execute(_PAT_INTERSECTS_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
rows = db.execute(_PAT_INTERSECTS_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / структура изменилась — graceful degrade.
logger.warning("parcel_pat_subzones: pat_subzones недоступна, skip: %s", exc)

View file

@ -109,8 +109,7 @@ def parcel_ppt_tep(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
if not parcel_wkt:
return []
try:
with db.begin_nested():
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning("parcel_ppt_tep: ekb_ppt_tep / planning_projects недоступна, skip: %s", exc)

View file

@ -137,12 +137,11 @@ def resolve_cad_for_domrf(
cad_num search).
"""
try:
with db.begin_nested():
row = (
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
.mappings()
.first()
)
row = (
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
.mappings()
.first()
)
except Exception:
# SQL/PostGIS-сбой — не наш профиль данных. Логируем и отдаём None
# (один конкурент без parking_ratio лучше, чем 500 на весь lazy-endpoint).

View file

@ -355,32 +355,31 @@ def _get_zoning(
if layer_counts is not None and layer_counts.get("territorial_zones_count", 1) == 0:
return None
try:
with db.begin_nested():
row = db.execute(
text(
"""
SELECT feat.value->'properties' AS zone_props
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'territorial_zones'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
row = db.execute(
text(
"""
SELECT feat.value->'properties' AS zone_props
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'territorial_zones'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
ST_Centroid(ST_GeomFromText(:wkt, 4326))
)
LIMIT 1
"""
),
{"q": quarter, "wkt": parcel_wkt},
).first()
4326
),
ST_Centroid(ST_GeomFromText(:wkt, 4326))
)
LIMIT 1
"""
),
{"q": quarter, "wkt": parcel_wkt},
).first()
if row is None:
return None
@ -454,46 +453,45 @@ def _get_zouit_overlaps(
# geography для корректных м². Делитель — площадь участка; guard от деления на
# ноль через NULLIF(...,0) → NULL → coverage_pct None в Python. Zone-4326
# выражение совпадает с ST_Intersects ниже (transform 3857→4326).
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'zouit_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
4326
),
4326
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'zouit_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
ST_GeomFromText(:wkt, 4326)
)
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
4326
),
ST_GeomFromText(:wkt, 4326)
)
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -549,32 +547,31 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
# схлопывает в одну, оставляя наименьший id (ORDER BY reg_numb_border, id).
# reg_numb_border — TEXT NOT NULL (0 NULL на проде), так что рег-номер есть у
# каждой строки и дедуп идёт по реальному идентификатору зоны.
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT DISTINCT ON (reg_numb_border)
type_zone,
category_name,
name_by_doc AS name,
reg_numb_border,
id AS zouit_id,
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson,
ST_Area(
ST_Intersection(
CAST(geom AS geometry),
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY reg_numb_border, id
"""
),
{"wkt": parcel_wkt},
).fetchall()
rows = db.execute(
text(
"""
SELECT DISTINCT ON (reg_numb_border)
type_zone,
category_name,
name_by_doc AS name,
reg_numb_border,
id AS zouit_id,
ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson,
ST_Area(
ST_Intersection(
CAST(geom AS geometry),
ST_GeomFromText(:wkt, 4326)
)::geography
) / NULLIF(
ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0
) AS coverage_pct
FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY reg_numb_border, id
"""
),
{"wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -637,28 +634,11 @@ def _get_engineering_nearby(
if layer_counts is not None and layer_counts.get("engineering_count", 1) == 0:
return []
try:
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'engineering_structures'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -666,15 +646,31 @@ def _get_engineering_nearby(
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 20
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _ENGINEERING_RADIUS_M},
).fetchall()
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'engineering_structures'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 20
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _ENGINEERING_RADIUS_M},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -754,54 +750,53 @@ def _get_risk_zones(
if layer_counts is not None and layer_counts.get("risks_count", 1) == 0:
return []
try:
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
)
) AS intersection_area_sqm
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'risk_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Area(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
)
) AS intersection_area_sqm
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'risk_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
ST_GeomFromText(:wkt, 4326)
)
ORDER BY layer, intersection_area_sqm DESC NULLS LAST
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
4326
),
ST_GeomFromText(:wkt, 4326)
)
ORDER BY layer, intersection_area_sqm DESC NULLS LAST
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -875,38 +870,21 @@ def _get_opportunity_parcels(
if layer_counts is not None and layer_counts.get("opportunity_count", 1) == 0:
return []
try:
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'opportunity_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
rows = db.execute(
text(
"""
SELECT feat.value->>'layer' AS layer,
feat.value->'properties' AS props,
ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -914,15 +892,31 @@ def _get_opportunity_parcels(
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 30
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M},
).fetchall()
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' LIKE 'opportunity_%'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 30
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -980,20 +974,30 @@ def _get_red_lines(
if layer_counts is not None and layer_counts.get("red_lines_count", 1) == 0:
return []
try:
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Intersects(
rows = db.execute(
text(
"""
SELECT ST_AsText(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)
) AS geom_wkt,
ST_Intersects(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
ST_GeomFromText(:wkt, 4326)
) AS does_intersect,
ST_Length(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -1002,36 +1006,9 @@ def _get_red_lines(
4326
),
ST_GeomFromText(:wkt, 4326)
) AS does_intersect,
ST_Length(
ST_Intersection(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
),
ST_GeomFromText(:wkt, 4326)
)::geography
) AS intersection_length_m,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'red_lines'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
)::geography
) AS intersection_length_m,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
@ -1039,15 +1016,31 @@ def _get_red_lines(
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography,
:nearby_m
)
ORDER BY distance_m ASC
LIMIT 50
"""
),
{"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M},
).fetchall()
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'red_lines'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
AND ST_DWithin(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography,
:nearby_m
)
ORDER BY distance_m ASC
LIMIT 50
"""
),
{"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M},
).fetchall()
result: list[dict[str, Any]] = []
for r in rows:
@ -1145,8 +1138,6 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
"in_protection_zone": False,
"protection_zones_intersecting": 0,
"total_structures_in_radius": 0,
"protection_zones_truncated": False,
"structures_truncated": False,
},
"dump_available": False,
"dump_fetched_at": fetched_at_iso,
@ -1175,25 +1166,20 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
_trigger_harvest(quarter)
return _empty_unavailable(dump_fetched_at)
structures, structures_total = _get_engineering_structures_by_boundary(
db, quarter, parcel_wkt, radius_m
)
zouit_overlaps, zouit_total = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m)
zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
nearest_dist: float | None = structures[0]["distance_to_boundary_m"] if structures else None
protection_count = len(zouit_overlaps)
return {
"engineering_structures": structures,
"zouit_engineering_overlaps": zouit_overlaps,
"summary": {
"nearest_structure_distance_m": nearest_dist,
"in_protection_zone": zouit_total > 0,
# Epic #2445 A1: истинные счётчики по ПОЛНОЙ выборке (COUNT(*) без LIMIT),
# НЕ len(list) — раньше тихо капались на 100/50 (LIMIT в SQL row-fetch).
"protection_zones_intersecting": zouit_total,
"total_structures_in_radius": structures_total,
"protection_zones_truncated": zouit_total > len(zouit_overlaps),
"structures_truncated": structures_total > len(structures),
"in_protection_zone": protection_count > 0,
"protection_zones_intersecting": protection_count,
"total_structures_in_radius": len(structures),
},
"dump_available": True,
"dump_fetched_at": dump_fetched_at,
@ -1241,12 +1227,36 @@ def _get_parcel_wkt(db: Session, cad_num: str) -> str | None:
return None
_ENGINEERING_STRUCTURES_ITEMS_LIMIT = 50
def _get_engineering_structures_by_boundary(
db: Session,
quarter: str,
parcel_wkt: str,
radius_m: int,
) -> list[dict[str, Any]]:
"""Engineering structures из dump в radius_m от boundary участка.
# Условие радиус-выборки engineering_structures — используется И в row-fetch (с ORDER BY +
# LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на 50, но
# summary.total_structures_in_radius обязан считаться по ВСЕЙ выборке, не по len(list)).
_ENGINEERING_STRUCTURES_WHERE = """
Использует ST_Distance к boundary (не centroid) для корректного расстояния.
Geometry трансформируется из EPSG:3857 (хранение dump) 4326.
"""
try:
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'engineering_structures'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
@ -1261,67 +1271,12 @@ _ENGINEERING_STRUCTURES_WHERE = """
ST_GeomFromText(:wkt, 4326)::geography,
:radius_m
)
"""
def _get_engineering_structures_by_boundary(
db: Session,
quarter: str,
parcel_wkt: str,
radius_m: int,
) -> tuple[list[dict[str, Any]], int]:
"""Engineering structures из dump в radius_m от boundary участка.
Использует ST_Distance к boundary (не centroid) для корректного расстояния.
Geometry трансформируется из EPSG:3857 (хранение dump) 4326.
Returns:
(items, total_count) items ограничен _ENGINEERING_STRUCTURES_ITEMS_LIMIT
(ближайшие), total_count ЧЕСТНЫЙ COUNT(*) по всей радиус-выборке (epic #2445
A1: раньше summary.total_structures_in_radius = len(items) тихо капался на 50).
"""
params = {"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m}
try:
with db.begin_nested():
total_count = (
db.execute(
text(
"SELECT COUNT(*)"
" FROM nspd_quarter_dumps d,"
" jsonb_array_elements(d.features_json) AS feat(value)"
" WHERE d.quarter_cad = :q" + _ENGINEERING_STRUCTURES_WHERE
),
params,
).scalar()
or 0
)
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json,
ST_Distance(
ST_Transform(
ST_SetSRID(
ST_GeomFromGeoJSON(feat.value->>'geometry'),
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q"""
+ _ENGINEERING_STRUCTURES_WHERE
+ """
ORDER BY distance_m ASC
LIMIT :items_limit
"""
),
{**params, "items_limit": _ENGINEERING_STRUCTURES_ITEMS_LIMIT},
).fetchall()
ORDER BY distance_m ASC
LIMIT 50
"""
),
{"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m},
).fetchall()
except (OperationalError, ProgrammingError, DataError) as e:
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
logger.warning(
@ -1329,7 +1284,7 @@ def _get_engineering_structures_by_boundary(
quarter,
e,
)
return [], 0
return []
result: list[dict[str, Any]] = []
for r in rows:
@ -1394,7 +1349,7 @@ def _get_engineering_structures_by_boundary(
"source": "nspd_36328",
}
)
return result, int(total_count)
return result
def _clean_struct_str(value: Any) -> str | None:
@ -1435,12 +1390,24 @@ def _build_structure_characteristics(opts: dict[str, Any]) -> str | None:
return " · ".join(parts) if parts else None
_ZOUIT_ENGINEERING_ITEMS_LIMIT = 100
def _get_zouit_engineering_overlaps(
db: Session,
quarter: str,
parcel_wkt: str,
) -> list[dict[str, Any]]:
"""ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок.
# Условие пересечения zouit_engineering с участком — используется И в row-fetch (с
# ORDER BY + LIMIT), И в честном COUNT(*) БЕЗ LIMIT (epic #2445 A1: список капается на
# 100, но summary.protection_zones_intersecting обязан считаться по ВСЕЙ выборке).
_ZOUIT_ENGINEERING_WHERE = """
Использует слой 'zouit_engineering' из dump.
"""
try:
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q
AND feat.value->>'layer' = 'zouit_engineering'
AND (feat.value->'geometry') IS NOT NULL
AND feat.value->>'geometry' != 'null'
@ -1454,58 +1421,11 @@ _ZOUIT_ENGINEERING_WHERE = """
),
ST_GeomFromText(:wkt, 4326)
)
"""
def _get_zouit_engineering_overlaps(
db: Session,
quarter: str,
parcel_wkt: str,
) -> tuple[list[dict[str, Any]], int]:
"""ZOUIT engineering (cat 37578) — охранные зоны, пересекающие участок.
Использует слой 'zouit_engineering' из dump.
Returns:
(items, total_count) items ограничен _ZOUIT_ENGINEERING_ITEMS_LIMIT,
детерминированно отсортирован (epic #2445 A1: раньше не было ORDER BY перед
LIMIT 100 недетерминированные 100 строк на каждый запрос). total_count
ЧЕСТНЫЙ COUNT(*) по всей intersects-выборке (раньше summary.
protection_zones_intersecting = len(items) тихо капался на 100).
"""
params = {"q": quarter, "wkt": parcel_wkt}
try:
with db.begin_nested():
total_count = (
db.execute(
text(
"SELECT COUNT(*)"
" FROM nspd_quarter_dumps d,"
" jsonb_array_elements(d.features_json) AS feat(value)"
" WHERE d.quarter_cad = :q" + _ZOUIT_ENGINEERING_WHERE
),
params,
).scalar()
or 0
)
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT feat.value->'properties' AS props,
feat.value->>'geometry' AS geom_json
FROM nspd_quarter_dumps d,
jsonb_array_elements(d.features_json) AS feat(value)
WHERE d.quarter_cad = :q"""
+ _ZOUIT_ENGINEERING_WHERE
+ """
ORDER BY feat.value->'properties'->>'reg_numb_border' NULLS LAST,
feat.value->>'geometry'
LIMIT :items_limit
"""
),
{**params, "items_limit": _ZOUIT_ENGINEERING_ITEMS_LIMIT},
).fetchall()
LIMIT 100
"""
),
{"q": quarter, "wkt": parcel_wkt},
).fetchall()
except (OperationalError, ProgrammingError, DataError) as e:
# DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR).
logger.warning(
@ -1513,7 +1433,7 @@ def _get_zouit_engineering_overlaps(
quarter,
e,
)
return [], 0
return []
result: list[dict[str, Any]] = []
for r in rows:
@ -1546,7 +1466,7 @@ def _get_zouit_engineering_overlaps(
"source": "nspd_37578",
}
)
return result, int(total_count)
return result
# ── Harvest trigger ───────────────────────────────────────────────────────────

View file

@ -99,8 +99,7 @@ def parcel_riasurt_gate(
return dict(RIASURT_EMPTY_GATE)
try:
with db.begin_nested():
rows = db.execute(_RIASURT_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
rows = db.execute(_RIASURT_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError, DataError) as exc:
logger.warning("parcel_riasurt_gate: таблица/запрос недоступны, skip: %s", exc)
return dict(RIASURT_EMPTY_GATE)

View file

@ -237,15 +237,7 @@ def compute_district_saturation(db: Session, district_name: str) -> dict[str, An
psycopg v3: bind через CAST(:dn AS text). Read-only.
"""
try:
# SAVEPOINT — db shared с caller (analyze_parcel, parcels.py). Caller УЖЕ
# оборачивает вызов этой функции в свой db.begin_nested(), но это НЕ спасает:
# если db.execute здесь падает и мы её же тут глотаем (return None, не re-raise),
# исключение никогда не долетает до caller-savepoint → её ROLLBACK TO SAVEPOINT
# никогда не срабатывает, а сам Postgres-connection уже aborted. Нужна СОБСТВЕННАЯ
# savepoint здесь — откатывается на выходе из `with` ДО того, как исключение
# поймано ниже, оставляя outer-транзакцию (caller'а) usable.
with db.begin_nested():
row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first()
row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first()
except Exception:
logger.exception("saturation: query failed (district=%s)", district_name)
return None

View file

@ -46,9 +46,6 @@ Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics): пус
м.108-audit). Поэтому при free_flats IS NULL hidden НЕ вычислим (flat_count в одиночку
бессмыслен). Такие объекты ИСКЛЮЧАЮТСЯ из L2 (preferred path из ТЗ чище, чем
выдуманный sold-proxy) coverage снижается confidence падает с medium до low.
Тот же exclusion И при flat_count IS NULL (#2445-A3: заполняется независимо от
free_flats, м.124) иначе GREATEST(0, COALESCE(flat_count,0) free_flats) молча
давал 0 «скрытых» для объекта с генуинно неизвестным flat_count, раздувая coverage.
L2 district-resolution: используем domrf_kn_objects.district_name НАПРЯМУЮ (м.57
spatial-join по lat/long в полигоны ekb_districts_geom). Это самый прямой путь
БЕЗ complexes/complex_sources join (которые сами строятся из этой же колонки, м.73).
@ -247,21 +244,6 @@ _L1_OPEN_SQL = text(
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
#
# #2445-A3 (audit): flat_count ТОЖЕ nullable и заполняется НЕЗАВИСИМО от free_flats
# (м.124 — "NULL = не заполнено"). Раньше фильтр гейтил ТОЛЬКО free_flats IS NOT NULL:
# при flat_count IS NULL (частично забэкфилленный снапшот) GREATEST(0, COALESCE(flat_count,
# 0) - free_flats) = GREATEST(0, -free_flats) = 0 — объект молча давал 0 hidden units,
# ХОТЯ его истинный hidden НЕИЗВЕСТЕН (не 0), И при этом всё равно попадал в
# n_with_free_flats → раздувал coverage-деноминатор (denominator inflation). Зеркалит баг,
# которого уже избегает pure-хелпер _hidden_units() (возвращает None при flat_count IS
# NULL) — но тот хелпер не вызывается из этого SQL-пути (только из тестов), поэтому баг
# жил в проде. Fix: гейтим ОБА (COUNT-деноминатор и SUM) на
# flat_count IS NOT NULL AND free_flats IS NOT NULL — объект с любым из двух NULL
# полностью исключается (как из числителя coverage, так и из hidden_units), а не
# засчитывается как "0 скрытых". Сиблинг-конвенция: 60_v_zk_rosreestr_velocity.sql /
# 64_v_zk_rosreestr_velocity.sql уже гейтят `o.flat_count IS NOT NULL AND o.flat_count > 0`
# на этой же таблице.
#
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
@ -286,14 +268,10 @@ _L2_HIDDEN_SQL = text(
SELECT
district_name,
COUNT(*) AS n_objects_total,
COUNT(*) FILTER (
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
) AS n_with_free_flats,
COUNT(*) FILTER (WHERE free_flats IS NOT NULL) AS n_with_free_flats,
COALESCE(SUM(
GREATEST(0, flat_count - free_flats)
) FILTER (
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
), 0) AS hidden_units
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)
@ -419,11 +397,8 @@ def compute_layer2_hidden(
Per dom.рф-объект hidden = max(0, flat_count free_flats); Σ к району. ОБЕ колонки
на одной строке domrf_kn_objects НЕ нужен domrfobjective маппинг (см. docstring
модуля). Объекты с free_flats IS NULL ИЛИ flat_count IS NULL полностью исключаются
(#2445-A3: обе колонки nullable и заполняются независимо — объект с известным
free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой
объект НЕ считается «покрытым» и не входит в coverage-числитель) снижают coverage
confidence mediumlow.
модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки не вычислить;
documented limitation) снижают coverage confidence mediumlow.
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
units_estimate всегда 0 (clamp). Пустые данные []. Не бросает.
@ -438,16 +413,11 @@ def compute_layer2_hidden(
out: list[SupplyLayerRow] = []
for r in rows:
n_total = int(r["n_objects_total"] or 0)
# n_with_free_flats (SQL) = объекты, где ОБЕ free_flats и flat_count известны
# (#2445-A3) — это единственные объекты, для которых hidden вообще вычислим.
# Имя колонки оставлено как есть (ripple в SQL/тестах/logger); семантика теперь
# "покрыто и вычислимо", не только "free_flats заполнен".
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:
# Нет ни одного объекта с ВЫЧИСЛИМЫМ hidden (оба flat_count и free_flats
# известны) → нечего объявлять скрытым (а не «0»).
# Нет ни одного объекта с free_flats → нечего объявлять скрытым (а не «0»).
continue
confidence = _l2_confidence(n_total, n_with)
out.append(
@ -464,7 +434,7 @@ def compute_layer2_hidden(
confidence=confidence,
method=(
f"domrf_kn_objects: Σ max(0, flat_count free_flats); "
f"coverage {n_with}/{n_total} objects with flat_count & free_flats known"
f"coverage {n_with}/{n_total} objects with free_flats"
),
snapshot_date=snapshot_date,
)
@ -570,18 +540,9 @@ def _safe_rows(
Зеркало market_metrics._query_* тонкие данные/сбой БД не должны валить расчёт
остальных слоёв или worker'а целиком.
SAVEPOINT (``db.begin_nested()``): ``compute_all_layers`` вызывает эту функцию 3×
(L1/L2/L3) на ОДНОЙ Session без savepoint сбой L1 отравил бы транзакцию и L2/L3
молча получили бы [] тоже (плюс любой db.execute ПОСЛЕ compute_all_layers на этом же
db, включая caller'а — worker owns SessionLocal(), но при вызове из shared
request-scoped db, напр. forecasting/orchestrator.build_site_finder_report,
отравился бы весь оставшийся запрос). Голый db.rollback() здесь НЕЛЬЗЯ см.
velocity.py:170.
"""
try:
with db.begin_nested():
return db.execute(sql, dict(params)).mappings().all()
return db.execute(sql, dict(params)).mappings().all()
except Exception:
# L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
district_dbg = params.get("district", params.get("districts"))

View file

@ -657,30 +657,9 @@ def get_nearby_utility_infrastructure(
if parcel_wkt is None:
raise ValueError(f"Участок {cad_num!r} не найден в БД")
# Условие радиус-выборки — общее для честного COUNT(*) (без LIMIT) и row-fetch
# (с ORDER BY + LIMIT). Epic #2445 A2: summary.total_features раньше = len(features),
# тихо капался на `limit` (default 200) даже когда в радиусе фич больше.
where_clause = """
WHERE ST_DWithin(
u.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
"""
params = {"wkt": parcel_wkt, "radius_m": radius_m}
total_features = (
db.execute(
text("SELECT COUNT(*) FROM osm_utility_infrastructure_ekb u" + where_clause),
params,
).scalar()
or 0
)
rows = (
db.execute(
text(
"""
text("""
SELECT osm_id, osm_type, infrastructure_kind, name, source_tag,
u.tags AS tags,
ST_Distance(
@ -688,14 +667,16 @@ def get_nearby_utility_infrastructure(
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_AsGeoJSON(u.geom) AS geojson
FROM osm_utility_infrastructure_ekb u"""
+ where_clause
+ """
FROM osm_utility_infrastructure_ekb u
WHERE ST_DWithin(
u.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT :lim
"""
),
{**params, "lim": limit},
"""),
{"wkt": parcel_wkt, "radius_m": radius_m, "lim": limit},
)
.mappings()
.all()
@ -725,15 +706,11 @@ def get_nearby_utility_infrastructure(
}
)
total_features_int = int(total_features)
return {
"features": features,
"summary": {
# Epic #2445 A2: честный COUNT(*) по всей радиус-выборке — НЕ len(features)
# (которое капалось на `limit`, default 200).
"total_features": total_features_int,
"total_features": len(features),
"nearest_distance_m": nearest_overall,
"nearest_by_kind": nearest_by_kind,
"features_truncated": total_features_int > len(features),
},
}

View file

@ -95,11 +95,6 @@ class VelocityResult:
proxy_used: bool = False
# proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none.
proxy_sqm_per_deal: float | None = None
# Audit #2445 A4: доля report_month-строк ЕКБ-бенчмарка (_get_ekb_median), где
# deals_total_vol_m2 IS NULL и объём фабриковался через count×45.0 (тот же proxy,
# что и rosreestr_fallback, но внутри city-wide нормализующей медианы). 0.0 —
# медиана целиком из реальных deals_total_vol_m2 (или бенчмарк не считался/fallback-константа).
ekb_median_proxy_fraction: float = 0.0
# RANK 1 (DCF velocity schedule): абсорбция ОДНОГО типового конкурента района,
# м²/мес = monthly_velocity_sqm / max(n_with_sales, 1). Это НЕ суммарная скорость
# района (monthly_velocity_sqm), а темп выбытия одного участника рынка — им и
@ -108,14 +103,6 @@ class VelocityResult:
# per-project absorption там ill-defined и НЕ должен драйвить график) и на
# degenerate/zero путях.
project_absorption_sqm_per_month: float | None = None
# #2464 cluster B: True когда запрос конкурентов (Step 1, `LIMIT 200`) упёрся
# в лимит — competitors_count тогда "минимум 200" (реальных ЖК в радиусе может
# быть больше), а не честный total. Тот же silent-cap класс, что чинили в
# utility_infrastructure_loader (`features_truncated`, epic #2445 A2); здесь —
# флаг, а не второй COUNT(*): 200 конкурентов-ЖК в 3км радиусе — крайне
# редкий edge (плотная застройка), доп. geo-запрос на каждый analyze того не
# стоит — честная disclosure флагом дешевле.
competitors_truncated: bool = False
def as_dict(self) -> dict[str, Any]:
return {
@ -133,13 +120,11 @@ class VelocityResult:
"objective_coverage_pct": self.objective_coverage_pct,
"proxy_used": self.proxy_used,
"proxy_sqm_per_deal": self.proxy_sqm_per_deal,
"ekb_median_proxy_fraction": round(self.ekb_median_proxy_fraction, 3),
"project_absorption_sqm_per_month": (
round(self.project_absorption_sqm_per_month, 1)
if self.project_absorption_sqm_per_month is not None
else None
),
"competitors_truncated": self.competitors_truncated,
}
@ -322,20 +307,10 @@ def compute_velocity(
# SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными).
coverage_pct = round(mapped_ratio * 100, 1)
# Audit #2445 A4: _get_ekb_median возвращает (median, proxy_row_fraction) — доля
# строк бенчмарка, где объём фабриковался через count*45.0. При DB-fallback
# (None — нет данных) proxy_fraction неизвестна → 0.0 (используем calibrated
# константу, а не contaminated медиану, так что disclosure не нужен).
_ekb_median_result = _get_ekb_median(db, months_window=months_window)
if _ekb_median_result is not None:
ekb_median, ekb_median_proxy_fraction = _ekb_median_result
else:
ekb_median = _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
ekb_median_proxy_fraction = 0.0
ekb_median = (
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
)
n_comps = len(comp_rows)
# #2464 cluster B: Step 1 query капнут `LIMIT 200` (см. выше) — если выборка
# уперлась в лимит, реальных конкурентов в радиусе может быть больше.
n_comps_truncated = n_comps >= 200
sample_no_data = sorted(
[
{
@ -367,8 +342,6 @@ def compute_velocity(
ekb_median=ekb_median,
sample_competitors=sample_no_data,
objective_coverage_pct=coverage_pct,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
competitors_truncated=n_comps_truncated,
)
if rr_result is not None:
return rr_result
@ -390,9 +363,7 @@ def compute_velocity(
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
competitors_truncated=n_comps_truncated,
)
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
@ -499,8 +470,6 @@ def compute_velocity(
ekb_median=ekb_median,
sample_competitors=sample_no_data,
objective_coverage_pct=coverage_pct,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
competitors_truncated=n_comps_truncated,
)
if rr_result is not None:
return rr_result
@ -534,9 +503,7 @@ def compute_velocity(
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
competitors_truncated=n_comps_truncated,
)
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
@ -613,9 +580,7 @@ def compute_velocity(
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
project_absorption_sqm_per_month=project_absorption,
competitors_truncated=n_comps_truncated,
)
@ -627,8 +592,6 @@ def _compute_rosreestr_fallback(
ekb_median: float,
sample_competitors: list[dict[str, Any]],
objective_coverage_pct: float = 0.0,
ekb_median_proxy_fraction: float = 0.0,
competitors_truncated: bool = False,
) -> VelocityResult | None:
"""Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.
@ -718,18 +681,13 @@ def _compute_rosreestr_fallback(
objective_coverage_pct=objective_coverage_pct,
proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется)
proxy_sqm_per_deal=avg_area_per_deal,
# Audit #2445 A4: пробрасываем proxy-долю бенчмарка (ekb_median посчитан
# ДО вызова этого fallback) — даже здесь denominator может быть частично
# contaminated count*45.0-строками.
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
# RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции —
# per-project absorption ill-defined → None, НЕ драйвит DCF-график.
project_absorption_sqm_per_month=None,
competitors_truncated=competitors_truncated,
)
def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float] | None:
def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
"""ЕКБ-wide медиана monthly velocity (м²/мес) per ЖК — benchmark.
Источник: objective_corpus_room_month (актуальные данные, обновляется еженедельно).
@ -739,24 +697,6 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float]
Учитываются только ЖК с 3 месяцами данных за окно (стабильный сигнал).
Fallback к _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH если нет данных в БД.
Audit #2445 A4: report_month-строки с deals_total_count>0, но
deals_total_vol_m2 IS NULL, фабрикуют объём через COALESCE(..., count*45.0)
тот же proxy-приём, что и в rosreestr_fallback (avg_area_per_deal), только
здесь он был необъявленным и контаминировал city-wide бенчмарк, к которому
нормализуется velocity_score КАЖДОГО участка. Строки не исключаются (иначе
продающийся, но без м²-данных ЖК просто выпадает из бенчмарка тоже
искажение), но доля таких строк считается и возвращается вызывающему коду
для disclosure (см. VelocityResult.ekb_median_proxy_fraction), а не
проглатывается молча как чистые данные.
Возвращает (median, proxy_row_fraction) либо None если данных нет.
proxy_row_fraction доля (project, room, month)-строк (среди вошедших в
медиану project'ов, ≥3 отчётных месяца), где объём взят из count*45.0,
а не из реального deals_total_vol_m2. Считается на уровне строк
objective_corpus_room_month (таблица хранит строку per project×room×month
см. data/sql/100_fix_mv_layout_velocity_weighted_avg.sql), НЕ на уровне
report_month, чтобы не менять исходный HAVING-гейт «3 месяца данных».
"""
try:
with db.begin_nested():
@ -764,38 +704,23 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float]
db.execute(
text(
"""
WITH source_rows AS (
WITH per_project AS (
SELECT
project_name,
report_month,
COALESCE(deals_total_vol_m2,
deals_total_count * 45.0) AS row_sqm,
(deals_total_vol_m2 IS NULL) AS is_proxy_row
SUM(COALESCE(deals_total_vol_m2,
deals_total_count * 45.0)) AS total_sqm,
COUNT(DISTINCT report_month) AS months_data
FROM objective_corpus_room_month
WHERE report_month >= (CURRENT_DATE - CAST(:window_interval AS interval))
AND deals_total_count > 0
),
per_project AS (
SELECT
project_name,
SUM(row_sqm) AS total_sqm,
COUNT(DISTINCT report_month) AS months_data,
COUNT(*) AS rows_data,
COUNT(*) FILTER (WHERE is_proxy_row) AS proxy_rows
FROM source_rows
GROUP BY project_name
HAVING COUNT(DISTINCT report_month) >= 3
),
per_project_velocity AS (
SELECT total_sqm / months_data AS velocity,
rows_data,
proxy_rows
SELECT total_sqm / months_data AS velocity
FROM per_project
)
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median,
COALESCE(SUM(proxy_rows), 0) AS proxy_rows_total,
COALESCE(SUM(rows_data), 0) AS rows_total
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median
FROM per_project_velocity
"""
),
@ -809,10 +734,6 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float]
# SAVEPOINT auto-rollback'нут
return None
if row is None or row["median"] is None:
return None
rows_total = int(row["rows_total"] or 0)
proxy_rows_total = int(row["proxy_rows_total"] or 0)
proxy_fraction = (proxy_rows_total / rows_total) if rows_total > 0 else 0.0
return float(row["median"]), proxy_fraction
if row and row["median"] is not None:
return float(row["median"])
return None

View file

@ -470,15 +470,9 @@ def backfill_ekb_zone_regulations(
upserted += 1
except Exception as exc:
# Per-zone изоляция: один сбойный фетч/апсёрт не должен ронять весь прогон.
# rollback() (не begin_nested): db здесь — СОБСТВЕННАЯ сессия таска
# backfill_zone_regulations.py (SessionLocal() + try/finally close), не
# shared request-scoped Session — orphan'ить нечего, следующая итерация
# цикла (get_cached_zone_regulation / upsert_zone_regulation) на этом же db
# иначе поймает "current transaction is aborted" после сбойного db.commit()
# выше (#2464 cluster A finding 6; сиблинг — write_db.rollback() в
# get_or_fetch_zone_regulation, тоже owned-сессия).
db.rollback()
logger.warning("backfill_ekb_zone_regulations: зона idx=%s failed: %s", zone_index, exc)
logger.warning(
"backfill_ekb_zone_regulations: зона idx=%s failed: %s", zone_index, exc
)
failed += 1
continue

View file

@ -420,24 +420,6 @@ def build_beat_schedule() -> dict:
# "options": {"queue": "celery"},
# }
# Catalog-FLAT scrape (#2442 Task 2) — наполняет per-flat catalog-поля
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
#
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 UTC.
# schedule["scrape-kn-catalog-flats-weekly"] = {
# "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
# "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC
# "kwargs": {"region_code": 66, "max_flats": 300},
# "options": {"queue": "celery"},
# }
# Quarter price index refresh (#762) — monthly on the 5th at 05:00 MSK.
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
# Chains: mv_quarter_price_per_m2 → mv_quarter_price_index (in order, both CONCURRENTLY).

View file

@ -50,7 +50,6 @@ celery_app = Celery(
include=[
"app.workers.tasks.scrape_kn",
"app.workers.tasks.scrape_kn_catalog_objects",
"app.workers.tasks.scrape_kn_catalog_flats",
"app.workers.tasks.refresh_analytics",
"app.workers.tasks.scrape_objective",
"app.workers.tasks.objective_etl",

View file

@ -3,116 +3,25 @@
Расписание: 03:30 МСК (hardcoded в beat_schedule, как cbr/poi/supply-layers).
Идемпотентен: UPSERT ON CONFLICT (source, ext_house_id).
Если TRADEIN_DATABASE_URL не задан warn-log, возвращает {"disabled": True}.
Breadcrumb (issue #2445 D6): прогон логируется в objective_scrape_runs (тот же
журнал, что используют соседние nightly/weekly-таски см.
app/workers/tasks/objective_etl.py) с group_name='newbuilding-crossload', чтобы
сбой был виден оператору в admin-таблице логов, а не только в Celery FAILURE +
GlitchTip. Таблица переиспользуется как есть без новой миграции; поля
rows_lots/rows_history/requests_count не про Objective-домен здесь, а про
source_rows/upserted/skipped cross-load'а (см. комментарии у _finish_run).
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.etl.newbuilding_crossload import run_crossload
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
_GROUP_NAME = "newbuilding-crossload"
def _start_run(db: Session, triggered_by: str) -> int:
"""Создать breadcrumb-строку в objective_scrape_runs, статус 'running'."""
row = db.execute(
text(
"""
INSERT INTO objective_scrape_runs (group_name, triggered_by, status)
VALUES (:gn, :tb, 'running')
RETURNING run_id
"""
),
{"gn": _GROUP_NAME, "tb": triggered_by},
).scalar_one()
db.commit()
return int(row)
def _finish_run(
db: Session, run_id: int, *, status: str, error: str | None = None, **counts: int
) -> None:
"""Обновить breadcrumb-строку финальным статусом ('done' | 'failed') + счётчиками."""
sets = ["finished_at = NOW()", "status = :status", "error = :error"]
params: dict[str, Any] = {"rid": run_id, "status": status, "error": error}
for k, v in counts.items():
sets.append(f"{k} = :{k}")
params[k] = v
db.execute(
text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
params,
)
db.commit()
@celery_app.task(name="tasks.etl_newbuilding_crossload.etl_newbuilding_crossload")
def etl_newbuilding_crossload(triggered_by: str = "beat") -> dict:
def etl_newbuilding_crossload() -> dict:
"""Cross-load tradein.houses → gendesign.newbuilding_listings (#976).
Вызывается beat'ом в 03:30 МСК и вручную через POST /api/v1/admin/scrape/newbuilding-crossload.
Args:
triggered_by: 'beat' | 'manual' | 'api' для журнала objective_scrape_runs.
Breadcrumb-строка в objective_scrape_runs логирует start/success/failure
на исключении из run_crossload() помечает run 'failed' и ре-рейзит, чтобы
Celery ушёл в FAILURE и GlitchTip продолжил ловить сбой (issue #2445 D6).
"""
db = SessionLocal()
run_id: int | None = None
try:
run_id = _start_run(db, triggered_by)
logger.info("task etl_newbuilding_crossload: start run_id=%s", run_id)
result = run_crossload(db=db)
if result.get("disabled"):
_finish_run(db, run_id, status="done", error="TRADEIN_DATABASE_URL not set")
logger.info("task etl_newbuilding_crossload: done (disabled) run_id=%s", run_id)
return {"run_id": run_id, **result}
_finish_run(
db,
run_id,
status="done",
error=None,
rows_history=result.get("source_rows", 0),
rows_lots=result.get("upserted", 0),
requests_count=result.get("skipped", 0),
)
logger.info("task etl_newbuilding_crossload: done run_id=%s%s", run_id, result)
return {"run_id": run_id, **result}
except Exception as e:
logger.exception("task etl_newbuilding_crossload: failed run_id=%s: %s", run_id, e)
if run_id is not None:
try:
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
except Exception:
# Breadcrumb-flush не должен скрыть исходное исключение — Celery
# всё равно должен уйти в FAILURE + GlitchTip его поймает.
logger.exception(
"task etl_newbuilding_crossload: failed to write failure breadcrumb "
"for run_id=%s",
run_id,
)
raise
finally:
db.close()
logger.info("task etl_newbuilding_crossload: start")
result = run_crossload()
logger.info("task etl_newbuilding_crossload: done — %s", result)
return result

View file

@ -151,59 +151,42 @@ def ingest_izyatie_ocr() -> dict[str, int]:
logger.info("ingest_izyatie_ocr: загружаем %r%s", doc_title[:60], doc_url)
# Весь per-document body (fetch → OCR → extract → upsert → commit) — в
# одном try/except. Сбой ЛЮБОГО шага (битый PDF на fetch, Tesseract-crash
# на OCR, баг парсера на неожиданном OCR-тексте, ошибка upsert/commit)
# логируется и переходит к следующему документу вместо падения всей
# еженедельной пачки (issue #2445 D5). db.rollback() откатывает
# незакоммиченные изменения текущей итерации, чтобы они не отравили
# commit следующего документа.
try:
pdf_bytes = fetch_pdf(doc_url)
ocr_text = ocr_pdf_text(pdf_bytes)
if not ocr_text.strip():
logger.warning(
"ingest_izyatie_ocr: OCR вернул пустой текст для %s "
"(tesseract не установлен, битый PDF или чистый лист?)",
doc_url,
)
processed_docs += 1
continue
records = extract_izyatie_records(ocr_text, doc_title, doc_url)
logger.info(
"ingest_izyatie_ocr: %r%d кад-номеров",
doc_title[:60],
len(records),
)
if not records:
processed_docs += 1
continue
count = _upsert_records(db, records)
db.commit()
total_parcels += count
processed_docs += 1
except Exception as exc:
logger.warning(
"ingest_izyatie_ocr: пропуск документа %r (%s): %s",
doc_title[:60],
logger.error(
"ingest_izyatie_ocr: не удалось загрузить PDF %s: %s",
doc_url,
exc,
)
try:
db.rollback()
except Exception as rollback_exc:
logger.warning(
"ingest_izyatie_ocr: rollback после сбоя документа %s тоже упал: %s",
doc_url,
rollback_exc,
)
continue
ocr_text = ocr_pdf_text(pdf_bytes)
if not ocr_text.strip():
logger.warning(
"ingest_izyatie_ocr: OCR вернул пустой текст для %s "
"(tesseract не установлен, битый PDF или чистый лист?)",
doc_url,
)
processed_docs += 1
continue
records = extract_izyatie_records(ocr_text, doc_title, doc_url)
logger.info(
"ingest_izyatie_ocr: %r%d кад-номеров",
doc_title[:60],
len(records),
)
if not records:
processed_docs += 1
continue
count = _upsert_records(db, records)
db.commit()
total_parcels += count
processed_docs += 1
finally:
db.close()

View file

@ -25,13 +25,6 @@ UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, raw_props, fe
``db.begin_nested()``.
Beat: еженедельно в воскресенье в 04:30 МСК до ird-harvest (понедельник 05:00).
WAF-hygiene (#2445 item D3): пэйсинг и 429/5xx backoff между HTTP-запросами живут
в ``okn_egrkn_client`` (``_wait_rate_limit`` общий для 33 list-проходов и до
``_DETAIL_FETCH_LIMIT`` detail-запросов, один WAF-хост). Здесь, в
``_enrich_with_detail``, circuit breaker: ``_DETAIL_FAIL_BREAKER`` детейл-фейлов
ПОДРЯД прерываем detail-loop (не долбим WAF до конца лимита при устойчивом бане),
аналогично ``gisogd66.py`` (``_CARD_FAIL_BREAKER``).
"""
from __future__ import annotations
@ -96,12 +89,6 @@ _DETAIL_FETCH_LIMIT = 1500
# Логгировать прогресс detail-fetch каждые N объектов.
_DETAIL_PROGRESS_EVERY = 100
# Circuit breaker (#2445 item D3, аналогично gisogd66._CARD_FAIL_BREAKER): столько
# detail-фейлов ПОДРЯД (raise ИЛИ пустой detail считается "не подряд", см. ниже)
# → прерываем detail-loop. WAF-бан обычно даёт устойчивую серию ошибок; долбить
# оставшиеся до ~1500 detail-запросов бессмысленно и рискованно для IP-репутации.
_DETAIL_FAIL_BREAKER = 20
_UPSERT_SQL = text("""
INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at)
VALUES (
@ -158,7 +145,6 @@ def _enrich_with_detail(
*,
detail_fetcher: Any = fetch_okn_detail,
limit: int = _DETAIL_FETCH_LIMIT,
fail_breaker: int = _DETAIL_FAIL_BREAKER,
) -> int:
"""Обогатить raw_props detail-полями (name, address, …).
@ -168,25 +154,15 @@ def _enrich_with_detail(
Записи без detail-полей сохраняются с тем же ``raw_props={id}`` детали
могут быть недоступны (объект удалён из реестра / detail-endpoint лежит).
Пэйсинг и 429/5xx backoff на уровне HTTP-клиента (``okn_egrkn_client``),
общий rate-limit между списком и detail-запросами. Circuit breaker здесь
(#2445 item D3): ``fail_breaker`` вызовов ``detail_fetcher`` ПОДРЯД,
завершившихся исключением (сетевая ошибка / устойчивый WAF-бан после
исчерпания ретраев клиента) прерываем цикл, не долбим оставшийся лимит.
Успешный вызов (даже с пустым detail объект без карточки) сбрасывает
счётчик подряд-фейлов.
Args:
to_upsert: список row-dict для UPSERT (мутируется in-place).
detail_fetcher: callable source_id dict (для DI в тестах).
limit: максимум detail-вызовов за прогон.
fail_breaker: детейл-фейлов ПОДРЯД до прерывания цикла.
Returns:
Число фич, для которых detail-поля были успешно подмёрджены.
"""
enriched = 0
consecutive_fail = 0
total = min(len(to_upsert), limit)
for idx, row in enumerate(to_upsert[:limit], 1):
try:
@ -198,19 +174,6 @@ def _enrich_with_detail(
exc,
)
detail = {}
consecutive_fail += 1
if consecutive_fail >= fail_breaker:
logger.error(
"okn_objects_sync: %d detail-фейлов подряд — прерываю "
"detail-loop (устойчивый бан/сбой WAF), обработано %d/%d",
fail_breaker,
idx,
total,
)
break
continue
else:
consecutive_fail = 0
if not detail:
continue
@ -261,10 +224,6 @@ def sync_okn_objects() -> dict[str, int]:
# Очередь для UPSERT: row-dict для исполнения _UPSERT_SQL.
to_upsert: list[dict[str, Any]] = []
# Пэйсинг между 33 list-запросами (#2445 item D3): fetch_okn_points → _do_request
# → okn_egrkn_client._wait_rate_limit перед КАЖДЫМ POST (общий rate-limit с
# detail-запросами, один WAF-хост okn-mk.mkrf.ru). Ничего дополнительно спать
# здесь не нужно — пэйсинг/backoff инкапсулированы в клиенте.
for address, category_type in _PASSES:
category = _CATEGORY_MAP[category_type]
logger.info(

View file

@ -10,17 +10,12 @@ Beat НЕ настроен: постановления выходят редко
celery call tasks.reservation_ingest.ingest_reservations
Conventions (зеркало ird_harvest.py / backend.md):
Session lifecycle (#2445 D4): PDF fetch происходит БЕЗ открытой сессии — pravo.gov66
иногда медленный (таймаут до 60с), держать connection-pool slot на всё время внешнего
запроса нельзя. Короткоживущий ``SessionLocal()`` открывается ТОЛЬКО на запись строк
одного документа (open upsert commit close per item), не на весь цикл.
SessionLocal() + try/finally close.
SAVEPOINT per-row (``with db.begin_nested():``) битая строка не валит батч.
CAST(:x AS type) НИКОГДА :x::text (psycopg v3).
ON CONFLICT (cad_num, act_number) обновляет метаданные + fetched_at.
httpx.Client (не requests). Сбой одного дока не валит прогон.
q-запросы 2-3 слова (движок pravo.gov66 строгий AND/фраза, 1 или 4+ 0 хитов).
Pacing/backoff к pravo.gov66.ru внутри pravo_gov66_client (_get_with_backoff),
НЕ здесь: каждый search/page/pdf запрос уже пейсится на уровне клиента.
"""
from __future__ import annotations
@ -102,11 +97,8 @@ def _fetch_pdf_safe(pdf_url: str) -> bytes | None:
return None
def _upsert_records_in_session(db: Session, records_meta: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation на уже открытой сессии.
Возвращает число upsert'нутых строк.
"""
def _upsert_records(db: Session, records_meta: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation. Возвращает число обработанных строк."""
count = 0
for row in records_meta:
try:
@ -122,27 +114,6 @@ def _upsert_records_in_session(db: Session, records_meta: list[dict[str, Any]])
return count
def _upsert_records(records_meta: list[dict[str, Any]]) -> int:
"""Открывает короткоживущую сессию ТОЛЬКО на запись одного документа → commit → close.
Session lifecycle (#2445 D4): вызывается ПОСЛЕ внешнего PDF-fetch, никогда во время
него держать открытую транзакцию/connection-pool slot на время сетевого запроса
к pravo.gov66.ru (таймаут до 60с) недопустимо (см. WAF-инцидент #2443).
"""
if not records_meta:
return 0
db: Session = SessionLocal()
try:
count = _upsert_records_in_session(db, records_meta)
db.commit()
return count
except Exception:
db.rollback()
raise
finally:
db.close()
def _enumerate_documents(since: str | None = None) -> list[dict[str, Any]]:
"""Обходит все поисковые запросы и возвращает дедуплицированный список документов.
@ -211,111 +182,117 @@ def ingest_reservations(
def _ingest_explicit_docs(docs: list[dict[str, Any]]) -> dict[str, int]:
"""Ингестирует явно переданный список документов (back-compat с ручным вызовом).
Session lifecycle (#2445 D4): PDF fetch (сетевой, может занять до 60с) выполняется
БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою короткоживущую сессию
только на запись строк одного документа (commit+close сразу после).
"""
"""Ингестирует явно переданный список документов (back-compat с ручным вызовом)."""
total_parcels = 0
for doc in docs:
url = doc.get("url", "")
act_number = doc.get("act_number")
basis_act = doc.get("basis_act", "")
doc_purpose = doc.get("purpose")
doc_kind = doc.get("kind", "изъятие")
db: Session = SessionLocal()
try:
for doc in docs:
url = doc.get("url", "")
act_number = doc.get("act_number")
basis_act = doc.get("basis_act", "")
doc_purpose = doc.get("purpose")
doc_kind = doc.get("kind", "изъятие")
logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)
logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)
pdf_bytes = _fetch_pdf_safe(url) # без открытой сессии — только сетевой запрос
if pdf_bytes is None:
logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
continue
pdf_bytes = _fetch_pdf_safe(url)
if pdf_bytes is None:
logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
continue
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(explicit): url=%s%d КН", url, len(records))
if not records:
continue
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(explicit): url=%s%d КН", url, len(records))
if not records:
continue
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind or doc_kind,
"basis_act": basis_act,
"act_number": rec.act_number or act_number,
"act_date": rec.act_date,
"purpose": rec.purpose or doc_purpose,
"doc_url": url,
"source": "page_pdf",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind or doc_kind,
"basis_act": basis_act,
"act_number": rec.act_number or act_number,
"act_date": rec.act_date,
"purpose": rec.purpose or doc_purpose,
"doc_url": url,
"source": "page_pdf",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
count = _upsert_records(db, rows)
db.commit()
total_parcels += count
logger.info(
"ingest_reservations(explicit): готово docs=%d parcels=%d",
len(docs),
total_parcels,
)
finally:
db.close()
logger.info(
"ingest_reservations(explicit): готово docs=%d parcels=%d",
len(docs),
total_parcels,
)
return {"docs": len(docs), "parcels": total_parcels}
def _ingest_enumerated_docs(enumerated: list[dict[str, Any]]) -> dict[str, int]:
"""Ингестирует документы, найденные через enumerate с pravo.gov66.
Session lifecycle (#2445 D4): оба сетевых вызова на документ (get_document_pdf_url,
fetch_pdf) выполняются БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою
короткоживущую сессию только на запись строк одного документа.
"""
"""Ингестирует документы, найденные через enumerate с pravo.gov66."""
total_parcels = 0
fetched_docs = 0
for hit in enumerated:
doc_id: str = hit["doc_id"]
title: str = hit.get("title", "")
doc_page_url: str = hit["url"]
db: Session = SessionLocal()
try:
for hit in enumerated:
doc_id: str = hit["doc_id"]
title: str = hit.get("title", "")
doc_page_url: str = hit["url"]
logger.info("ingest_reservations(enum): doc_id=%s title=%r", doc_id, title)
logger.info("ingest_reservations(enum): doc_id=%s title=%r", doc_id, title)
pdf_url = get_document_pdf_url(doc_id) # сеть, без открытой сессии
if pdf_url is None:
logger.warning(
"ingest_reservations(enum): doc_id=%s — PDF не найден на странице", doc_id
)
continue
pdf_url = get_document_pdf_url(doc_id)
if pdf_url is None:
logger.warning(
"ingest_reservations(enum): doc_id=%s — PDF не найден на странице", doc_id
)
continue
pdf_bytes = _fetch_pdf_safe(pdf_url) # сеть, без открытой сессии
if pdf_bytes is None:
logger.warning("ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id)
continue
pdf_bytes = _fetch_pdf_safe(pdf_url)
if pdf_bytes is None:
logger.warning(
"ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id
)
continue
fetched_docs += 1
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(enum): doc_id=%s%d КН", doc_id, len(records))
if not records:
continue
fetched_docs += 1
records = extract_from_pdf(pdf_bytes)
logger.info("ingest_reservations(enum): doc_id=%s%d КН", doc_id, len(records))
if not records:
continue
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind,
"basis_act": title,
"act_number": rec.act_number,
"act_date": rec.act_date,
"purpose": rec.purpose,
"doc_url": doc_page_url,
"source": "pravo_gov66",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
total_parcels += _upsert_records(rows) # short-lived session: open→upsert→commit→close
rows = [
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind,
"basis_act": title,
"act_number": rec.act_number,
"act_date": rec.act_date,
"purpose": rec.purpose,
"doc_url": doc_page_url,
"source": "pravo_gov66",
"raw_excerpt": rec.raw_excerpt,
}
for rec in records
]
count = _upsert_records(db, rows)
db.commit()
total_parcels += count
logger.info(
"ingest_reservations(enum): готово fetched=%d parcels=%d",
fetched_docs,
total_parcels,
)
finally:
db.close()
logger.info(
"ingest_reservations(enum): готово fetched=%d parcels=%d",
fetched_docs,
total_parcels,
)
return {"docs": fetched_docs, "parcels": total_parcels}

View file

@ -1,214 +0,0 @@
"""Celery task: periodic catalog-FLAT scrape для DOM.РФ (issue #2442 Task 2).
Дополняет per-flat catalog-поля (price_rub, price_per_m2, status, finishing_type,
ceiling_height_m, section_no, catalog_updated_at) + plan-изображения квартир в
domrf_kn_flats из SSR-страниц каталога:
https://наш.дом.рф/сервисы/каталог-квартир/квартира/{catalog_url_hash}
kn-API отдаёт цену для меньшинства квартир (~8.5% NULL-free); остальные поля
(отделка, потолки, дата обновления) живут только на публичной странице квартиры.
Оркестрация здесь: выборка batch'а из БД + вызов `scrape_catalog_batch`, которая
сама держит один BrowserSession + встроенный jitter_sleep (rate-limit/stealth).
Selector logic (аналог object-level scraper):
- catalog_url_hash IS NOT NULL берём только строки, где kn-sweep уже записал
реальный hash. До #2442 Task 1 + свежего kn-sweep это 0 строк (hash хардкодился
в NULL) пустой batch ожидаем/не баг.
- catalog_updated_at IS NULL квартира ещё не скрапилась из каталога, грузим.
- catalog_updated_at < CURRENT_DATE - INTERVAL '30 days' данные протухли, refresh.
- force=True игнорирует «уже свежий» фильтр, грузит все строки последнего
snapshot с непустым hash.
Sequencing dependency (важно): эта задача НЕ дублирует object-level scrape и
начнёт возвращать непустые batch'и ТОЛЬКО после того, как:
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) WAF-cooldown на
VPS IP; включается вручную после проверки targeted-тестом.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import date
from typing import Any
from sqlalchemy import text
from app.core.db import SessionLocal
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Выбор квартир на catalog-скрап одним запросом (obj-scraper-паттерн: obj_id+snapshot
# атомарно, здесь ods_id+catalog_url_hash). snapshot_date = MAX(...) для того же
# региона фиксирует последний snapshot и не тянет исторические строки — UPSERT в
# scrape_catalog_batch пишет `WHERE ods_id = :ods_id` (без snapshot-скоупа), поэтому
# отбираем только актуальный snapshot, чтобы не скрапить устаревшие ods_id.
#
# catalog_url_hash IS NOT NULL — жёсткое условие: без hash URL страницы не собрать.
# Пока Task 1 не задеплоен и kn-sweep не наполнил hash — 0 строк (ожидаемо).
#
# Фильтр свежести (:force = false): catalog_updated_at IS NULL (никогда не скрапили)
# ИЛИ старше 30 дней. При :force = true фильтр снимается — грузим все строки
# последнего snapshot с непустым hash.
#
# LIMIT :max_flats: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
# max_flats=None (force «Загрузить все» без явного потолка) грузим ВСЕ подходящие
# строки, а не молча режем до _DEFAULT_MAX_FLATS.
_SELECT_TARGETS_SQL = text(
"""
SELECT ods_id, catalog_url_hash, snapshot_date
FROM domrf_kn_flats
WHERE region_cd = :region_code
AND catalog_url_hash IS NOT NULL
AND ods_id IS NOT NULL
AND (
CAST(:force AS boolean)
OR catalog_updated_at IS NULL
OR catalog_updated_at < CURRENT_DATE - CAST(:stale_days AS integer)
)
AND snapshot_date = (
SELECT MAX(snapshot_date)
FROM domrf_kn_flats
WHERE region_cd = :region_code
)
ORDER BY catalog_updated_at NULLS FIRST
LIMIT :max_flats
"""
)
# Лимит по умолчанию если max_flats не задан явно (обычный beat/ad-hoc pass).
_DEFAULT_MAX_FLATS = 300
# Считаем данные протухшими старше этого числа дней (refresh-порог).
_STALE_DAYS = 30
@celery_app.task(
bind=True,
name="tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
time_limit=3600,
)
def scrape_kn_catalog_flats(
self: Any,
region_code: int = 66,
max_flats: int | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Periodic catalog-flat scrape (цены/отделка/планировки квартир из SSR каталога).
Args:
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
max_flats: Максимум квартир за один run. Если не задан: при force=True
лимита нет (грузим все), при force=False _DEFAULT_MAX_FLATS (300).
force: Если True игнорирует фильтр свежести («catalog_updated_at свежий»)
и (при не заданном max_flats) снимает лимит, грузя ВСЕ квартиры
последнего snapshot с непустым catalog_url_hash (admin «Загрузить все»).
По умолчанию False пропускает то, что скраплено < 30 дней назад, и
режет batch до 300.
Returns:
dict с ключами: region_code, force, snapshot_date, flats_count + поля из
scrape_catalog_batch (total, success, updated, failed, fields_total).
Concurrency:
No Redis lock consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
маловероятно. Если случится:
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
Add Redis lock если WAF блокирует наблюдается или beat включается на overlap.
"""
from app.services.scrapers.domrf_catalog import scrape_catalog_batch
# Явный max_flats всегда уважается. Без него:
# force=True («Загрузить все») → лимита нет (limit=None → SQL LIMIT NULL = все строки);
# force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_FLATS.
if max_flats is not None:
limit: int | None = max_flats
elif force:
limit = None
else:
limit = _DEFAULT_MAX_FLATS
db = SessionLocal()
try:
rows = (
db.execute(
_SELECT_TARGETS_SQL,
{
"region_code": region_code,
"max_flats": limit,
"force": force,
"stale_days": _STALE_DAYS,
},
)
.mappings()
.all()
)
flats: list[dict[str, Any]] = [
{"ods_id": r["ods_id"], "catalog_url_hash": r["catalog_url_hash"]} for r in rows
]
except Exception as exc:
logger.error("scrape_kn_catalog_flats: failed to fetch flats: %s", exc)
db.close()
raise
if not flats:
logger.info(
"scrape_kn_catalog_flats: nothing to do for region=%d (force=%s) — "
"likely no catalog_url_hash populated yet (see #2442 sequencing)",
region_code,
force,
)
db.close()
return {
"region_code": region_code,
"force": force,
"flats_count": 0,
"total": 0,
"success": 0,
"updated": 0,
"failed": 0,
"fields_total": 0,
}
# snapshot_date из первой строки — все строки одинаковые (WHERE snapshot_date =
# MAX(snapshot_date)). Атомарно: один SELECT, без race с kn-scraper.
snapshot_date_val: date = rows[0]["snapshot_date"]
logger.info(
"scrape_kn_catalog_flats: region=%d snapshot_date=%s flats=%d limit=%s force=%s",
region_code,
snapshot_date_val,
len(flats),
"ALL" if limit is None else limit,
force,
)
try:
stats = asyncio.run(
scrape_catalog_batch(
db=db,
flats=flats,
region_code=region_code,
)
)
except Exception as exc:
logger.error("scrape_kn_catalog_flats: scrape failed: %s", exc)
raise
finally:
db.close()
result: dict[str, Any] = {
"region_code": region_code,
"force": force,
"snapshot_date": str(snapshot_date_val),
"flats_count": len(flats),
**stats,
}
logger.info("scrape_kn_catalog_flats done: %s", result)
return result

View file

@ -1,108 +0,0 @@
"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
smoke-тест/re-trigger не углубил бан.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects"
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
result = MagicMock()
result.id = task_id
return result
def test_kn_catalog_objects_refuses_without_override() -> None:
"""Без i_understand_waf_risk (default False) — 400, task НЕ dispatched."""
with patch(
"app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async"
) as mock_apply_async:
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
mock_apply_async.assert_not_called()
def test_kn_catalog_objects_dispatches_with_override() -> None:
"""С i_understand_waf_risk=true — таск dispatched как раньше."""
with patch(
"app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async",
return_value=_mock_apply_async_result("obj-task-1"),
) as mock_apply_async:
resp = TestClient(app).post(
OBJECTS_ENDPOINT,
json={"region_code": 66, "max_objects": 3, "i_understand_waf_risk": True},
)
assert resp.status_code == 200
body = resp.json()
assert body["task_id"] == "obj-task-1"
assert body["max_objects"] == 3
mock_apply_async.assert_called_once()
call_kwargs = mock_apply_async.call_args[1]["kwargs"]
assert call_kwargs["region_code"] == 66
assert call_kwargs["max_objects"] == 3
def test_kn_catalog_flats_refuses_without_override() -> None:
"""Без i_understand_waf_risk (default False) — 400, task НЕ dispatched."""
with patch(
"app.workers.tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats.apply_async"
) as mock_apply_async:
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
mock_apply_async.assert_not_called()
def test_kn_catalog_flats_dispatches_with_override() -> None:
"""С i_understand_waf_risk=true — таск dispatched как раньше."""
with patch(
"app.workers.tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats.apply_async",
return_value=_mock_apply_async_result("flat-task-1"),
) as mock_apply_async:
resp = TestClient(app).post(
FLATS_ENDPOINT,
json={"region_code": 66, "max_flats": 3, "i_understand_waf_risk": True},
)
assert resp.status_code == 200
body = resp.json()
assert body["task_id"] == "flat-task-1"
assert body["max_flats"] == 3
mock_apply_async.assert_called_once()
call_kwargs = mock_apply_async.call_args[1]["kwargs"]
assert call_kwargs["region_code"] == 66
assert call_kwargs["max_flats"] == 3
def test_kn_catalog_objects_explicit_false_still_refuses() -> None:
"""i_understand_waf_risk=false явно (не только default) — тоже 400."""
with patch(
"app.workers.tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects.apply_async"
) as mock_apply_async:
resp = TestClient(app).post(
OBJECTS_ENDPOINT, json={"region_code": 66, "i_understand_waf_risk": False}
)
assert resp.status_code == 400
mock_apply_async.assert_not_called()

View file

@ -7,26 +7,26 @@
Стратегия mock: аналогична test_analyze_market_price.py DB mock через
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
Порядок db.execute calls в analyze_parcel (с #29 G2, #2464 cluster B):
Порядок db.execute calls в analyze_parcel (с #29 G2):
0. UNION ALL geom + source .mappings().first()
1. WKT query .mappings().first()
2. District .mappings().first()
3. POI rows .mappings().all()
4. Competitor rows .mappings().all()
5. competitors_total honest COUNT(*) .scalar() NEW #2464 cluster B
6. Pipeline rows .mappings().all()
7. Centroid lat/lon .mappings().first()
8. Noise rows .mappings().all()
9. Hydrology .mappings().all()
10. Utilities .mappings().all()
11. parcel_meta (cad_parcels) .mappings().first() NEW #29 G2
12. Market trend .mappings().first()
13. Zoning (begin_nested) .mappings().first()
14. Success recommendation (begin_nested) .mappings().all()
15. Market price (begin_nested) .mappings().first()
16. Recent permits (begin_nested) .mappings().all()
17. _geotech_risk (industrial count) .scalar()
18. _neighbors_summary (single statement, neighbors+overlap+total) .mappings().first()
5. Pipeline rows .mappings().all()
6. Centroid lat/lon .mappings().first()
7. Noise rows .mappings().all()
8. Hydrology .mappings().all()
9. Utilities .mappings().all()
10. parcel_meta (cad_parcels) .mappings().first() NEW #29 G2
11. Market trend .mappings().first()
12. Zoning (begin_nested) .mappings().first()
13. Success recommendation (begin_nested) .mappings().all()
14. Market price (begin_nested) .mappings().first()
15. Recent permits (begin_nested) .mappings().all()
16. _geotech_risk (industrial count) .scalar()
17. _neighbors_summary (neighbor_rows) .mappings().all()
18. _neighbors_summary (overlap_row) .mappings().first()
"""
from __future__ import annotations
@ -82,23 +82,20 @@ def _make_db_for_analyze(
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor rows
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("all", []), # 6: pipeline rows
("first", centroid_row), # 7: centroid
("all", []), # 8: noise rows
("all", []), # 9: hydrology rows
("all", []), # 10: utilities rows
("first", pm_mock), # 11: parcel_meta ← #29 G2
("first", None), # 12: market trend
("first", None), # 13: zoning (begin_nested)
("all", []), # 14: success recommendation (begin_nested)
("first", None), # 15: market price (begin_nested)
("all", []), # 16: recent permits (begin_nested)
("scalar", 0), # 17: geotech_risk
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
("first", pm_mock), # 10: parcel_meta ← #29 G2
("first", None), # 11: market trend
("first", None), # 12: zoning (begin_nested)
("all", []), # 13: success recommendation (begin_nested)
("first", None), # 14: market price (begin_nested)
("all", []), # 15: recent permits (begin_nested)
("scalar", 0), # 16: geotech_risk
("all", []), # 17: neighbors
("first", None), # 18: overlap
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:

View file

@ -125,24 +125,21 @@ def _make_db_for_analyze() -> MagicMock:
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor rows
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("all", []), # 6: pipeline rows
("first", centroid_row), # 7: centroid
("all", []), # 8: noise rows
("all", []), # 9: hydrology rows
("all", []), # 10: utilities rows
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
# (далее nspd_zoning merge не ходит в db.execute — резолвер замокан)
("first", None), # 11: parcel_meta
("first", None), # 12: market trend
("first", None), # 13: zoning (begin_nested)
("all", []), # 14: success recommendation
("first", None), # 15: market price
("all", []), # 16: recent permits
("scalar", 0), # 17: geotech_risk
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
("first", None), # 10: parcel_meta
("first", None), # 11: market trend
("first", None), # 12: zoning (begin_nested)
("all", []), # 13: success recommendation
("first", None), # 14: market price
("all", []), # 15: recent permits
("scalar", 0), # 16: geotech_risk
("all", []), # 17: neighbors
("first", None), # 18: overlap
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:

View file

@ -277,23 +277,20 @@ def _make_db_for_analyze() -> MagicMock:
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor rows
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("all", []), # 6: pipeline rows
("first", centroid_row), # 7: centroid
("all", []), # 8: noise rows
("all", []), # 9: hydrology rows
("all", []), # 10: utilities rows
("first", None), # 11: parcel_meta
("first", None), # 12: market trend
("first", None), # 13: zoning (begin_nested)
("all", []), # 14: success recommendation (begin_nested)
("first", None), # 15: market price (begin_nested)
("all", []), # 16: recent permits (begin_nested)
("scalar", 0), # 17: geotech_risk
# 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
("first", None), # 10: parcel_meta
("first", None), # 11: market trend
("first", None), # 12: zoning (begin_nested)
("all", []), # 13: success recommendation (begin_nested)
("first", None), # 14: market price (begin_nested)
("all", []), # 15: recent permits (begin_nested)
("scalar", 0), # 16: geotech_risk
("all", []), # 17: neighbors
("first", None), # 18: overlap
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
@ -602,94 +599,3 @@ def test_service_update_custom_poi_no_commit_when_not_found() -> None:
db.commit.assert_not_called()
assert result is None
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
#
# get_overlaps_for_scoring: db shared с caller (POST .../analyze, Depends(get_db)).
# Раньше db.execute-сбой ловился в bare ``except Exception as e`` без
# SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
# следующий db.execute на этой же сессии (остальные шаги analyze_parcel) падает
# "current transaction is aborted". Фикс — db.execute обёрнут в
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
# на той же mock-сессии.
def _overlap_row(data: dict[str, Any]) -> MagicMock:
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
return m
def test_get_overlaps_for_scoring_returns_shaped_rows() -> None:
"""Happy-path: db.execute успешен → список dict с ожидаемыми ключами."""
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
row = _overlap_row(
{
"id": 42,
"name": "Мой парк",
"category": "park",
"weight": 2.0,
"lon": 60.607,
"lat": 56.840,
"distance_m": 500.0,
}
)
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = [row]
out = get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION, parcel_cad="66:41:0:1")
assert out == [
{
"id": 42,
"name": "Мой парк",
"category": "park",
"weight": 2.0,
"lon": 60.607,
"lat": 56.840,
"distance_m": 500.0,
}
]
def test_get_overlaps_for_scoring_uses_begin_nested_savepoint() -> None:
"""Фикс: db.execute обёрнут в db.begin_nested(), не голый вызов."""
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION)
assert db.begin_nested.call_count == 1
def test_get_overlaps_for_scoring_query_failure_degrades_to_empty_list() -> None:
"""Сбой db.execute → [] (graceful), не крашит analyze_parcel."""
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
def test_get_overlaps_for_scoring_session_usable_after_failure() -> None:
"""db.execute сбоит один раз, но db остаётся usable для следующего вызова на
этой же сессии (имитирует custom-POI overlaps остальные шаги analyze_parcel
на одной shared Session)."""
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]

View file

@ -1,226 +0,0 @@
"""Тесты: epic #2464 cluster B — silent-cap honesty fixes в analyze_parcel.
Mock-based не требуют живой БД.
Покрывает:
1. `_build_market_pulse` (market_pulse.competitors_total / coverage_pct)
раньше competitors_total = len(competitor_rows) (LIMIT 20 в SQL), теперь
принимает честный total отдельным параметром (см. analyze_parcel 5a
COUNT(*) БЕЗ LIMIT) и считает coverage_pct против него.
2. `_neighbors_summary` (count_buildings_100m / neighbors_truncated)
раньше count_buildings_100m = len(neighbor_rows) (LIMIT 30 в CTE
`neighbors`), теперь честный total из CTE `neighbors_total` (БЕЗ LIMIT).
Reference fix pattern: `features_truncated` в
`app/services/site_finder/utility_infrastructure_loader.py` (epic #2445 A2).
"""
from __future__ import annotations
from unittest.mock import MagicMock
from app.api.v1 import parcels as parcels_module
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
def _competitor_row(
obj_id: int,
avg_price_per_m2_rub: float | None,
units_sold: int | None = None,
) -> dict:
return {
"obj_id": obj_id,
"comm_name": f"ЖК-{obj_id}",
"dev_name": "TestDev",
"avg_price_per_m2_rub": avg_price_per_m2_rub,
"units_sold": units_sold,
}
# ── _build_market_pulse: competitors_total / coverage_pct honesty ────────────
class TestBuildMarketPulseHonesty:
"""market_pulse.competitors_total / coverage_pct — честный total (#2464 cluster B)."""
def test_below_cap_total_matches_len(self) -> None:
"""Ниже LIMIT (мало конкурентов в радиусе) — true total == len(rows), zero
behavior change: старое поведение (len(competitor_rows)) совпадает с новым."""
rows = [_competitor_row(i, 150_000.0) for i in range(1, 6)] # 5 конкурентов
pulse = parcels_module._build_market_pulse(rows, competitors_total=5, velocity_data=None)
assert pulse["competitors_total"] == 5
assert pulse["competitors_with_price"] == 5
assert pulse["coverage_pct"] == 100.0
def test_at_cap_total_is_honest_not_len_of_rows(self) -> None:
"""#2464 cluster B core regression: competitor_rows капнут LIMIT 20 (детальный
список), но реальных ЖК в радиусе 45 (честный COUNT). competitors_total ДОЛЖЕН
отражать 45, а НЕ len(rows)=20 (старый баг silent cap репортился как total)."""
rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)] # ровно LIMIT 20
true_total = 45
pulse = parcels_module._build_market_pulse(
rows, competitors_total=true_total, velocity_data=None
)
assert pulse["competitors_total"] == true_total
assert pulse["competitors_total"] != len(
rows
), "regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)"
def test_coverage_pct_computed_against_true_total_not_capped_list(self) -> None:
"""coverage_pct = priced / TRUE total — раньше делилось на len(rows) (капнутый
список), что давало ложные 100% покрытия даже когда реальное покрытие ниже."""
# Все 20 показанных строк имеют цену → раньше coverage_pct считался бы 100%.
rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)]
true_total = 40 # реально в радиусе 40 ЖК, из них с ценой только эти 20.
pulse = parcels_module._build_market_pulse(
rows, competitors_total=true_total, velocity_data=None
)
assert pulse["competitors_with_price"] == 20
# 20/40*100 = 50.0, НЕ 100.0 (старая формула len(rows)-денутый would дать 100%).
assert pulse["coverage_pct"] == 50.0
def test_zero_competitors_honest_zero_coverage(self) -> None:
"""Нет конкурентов в радиусе → coverage_pct=0.0 (не деление на ноль)."""
pulse = parcels_module._build_market_pulse([], competitors_total=0, velocity_data=None)
assert pulse["competitors_total"] == 0
assert pulse["competitors_with_price"] == 0
assert pulse["coverage_pct"] == 0.0
assert pulse["market_avg_price_per_m2"] is None
assert pulse["top_sellers"] == []
def test_unpriced_competitors_excluded_from_market_avg_but_counted_in_total(self) -> None:
"""ЖК без маппинга (avg_price_per_m2_rub=None) остаются в competitor_rows
(детальный список для карты), но не влияют на market_avg_price_per_m2."""
rows = [
_competitor_row(1, 100_000.0),
_competitor_row(2, None),
_competitor_row(3, 200_000.0),
]
pulse = parcels_module._build_market_pulse(rows, competitors_total=3, velocity_data=None)
assert pulse["competitors_with_price"] == 2
assert pulse["market_avg_price_per_m2"] == 150_000
def test_velocity_data_feeds_avg_velocity_m2(self) -> None:
"""avg_velocity_m2 берётся из velocity_data.monthly_velocity_sqm, если есть."""
rows = [_competitor_row(1, 100_000.0)]
pulse = parcels_module._build_market_pulse(
rows, competitors_total=1, velocity_data={"monthly_velocity_sqm": 750.5}
)
assert pulse["avg_velocity_m2"] == 750.5
def test_top_sellers_top5_sorted_by_units_sold(self) -> None:
"""top_sellers — топ-5 по units_sold среди приценённых конкурентов."""
rows = [_competitor_row(i, 100_000.0, units_sold=i * 10) for i in range(1, 8)]
pulse = parcels_module._build_market_pulse(rows, competitors_total=7, velocity_data=None)
assert len(pulse["top_sellers"]) == 5
sold = [s["units_sold"] for s in pulse["top_sellers"]]
assert sold == sorted(sold, reverse=True)
assert sold[0] == 70 # obj_id=7 продал больше всех
# ── _neighbors_summary: count_buildings_100m / neighbors_truncated honesty ───
def _neighbor_row(cad_num: str, distance_m: float = 10.0) -> dict:
return {
"cad_num": cad_num,
"building_name": None,
"floors": None,
"year_built": None,
"cost_value": None,
"area": None,
"readable_address": None,
"purpose": None,
"status": None,
"distance_m": distance_m,
}
def _make_neighbors_db(neighbors_json: list, neighbors_total_count: int, overlap_json=None):
row = {
"neighbors": neighbors_json,
"overlap_rows": overlap_json or [],
"neighbors_total_count": neighbors_total_count,
}
result = MagicMock()
result.mappings.return_value.first.return_value = row
db = MagicMock()
db.execute.return_value = result
return db
class TestNeighborsSummaryHonesty:
"""count_buildings_100m / neighbors_truncated — честный total (#2464 cluster B)."""
def test_below_cap_count_matches_len_no_truncation(self) -> None:
"""Ниже LIMIT 30 — true total == len(neighbors), neighbors_truncated=False
(zero behavior change для типичного случая)."""
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(5)]
db = _make_neighbors_db(neighbors, neighbors_total_count=5)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert summary["data_available"] is True
assert summary["count_buildings_100m"] == 5
assert summary["neighbors_truncated"] is False
def test_at_cap_count_is_honest_total_not_len_of_list(self) -> None:
"""#2464 cluster B core regression: `neighbors` CTE капнут LIMIT 30, но
реальных соседей в радиусе 47 (честный COUNT из `neighbors_total`).
count_buildings_100m ДОЛЖЕН отражать 47, а НЕ len(neighbors)=30."""
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)] # ровно LIMIT 30
true_total = 47
db = _make_neighbors_db(neighbors, neighbors_total_count=true_total)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert summary["count_buildings_100m"] == true_total
assert summary["count_buildings_100m"] != len(
neighbors
), "regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)"
assert summary["neighbors_truncated"] is True
def test_neighbors_list_itself_unaffected_by_count_fix(self) -> None:
"""Constraint: детальный список `neighbors` (payload для UI, уже капнут до 20
в существующем коде отдельно от SQL LIMIT 30) остаётся прежним по составу/
порядку фикс трогает только count/флаг, НЕ список."""
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)]
db = _make_neighbors_db(neighbors, neighbors_total_count=47)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert len(summary["neighbors"]) == 20
assert summary["neighbors"][0]["cad_num"] == "66:41:0000000:0"
def test_neighbors_truncated_reflects_actual_shown_list_not_intermediate_30(self) -> None:
"""neighbors_truncated должен сравнивать честный total с тем, что РЕАЛЬНО
показано в `neighbors` (капнуто до 20), а НЕ с промежуточным neighbor_rows
(до 30 из SQL) иначе флаг может ложно молчать (False) на 21..30-м соседе,
где список уже обрезан до 20, а SQL ещё не упёрся в свой LIMIT 30."""
# SQL вернул 25 строк (не уперся в LIMIT 30) — но список для UI режется до 20.
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(25)]
db = _make_neighbors_db(neighbors, neighbors_total_count=25)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert len(summary["neighbors"]) == 20
assert summary["count_buildings_100m"] == 25
# 25 (total) > 20 (реально показано) → truncated=True, даже хотя SQL LIMIT 30
# не был исчерпан.
assert summary["neighbors_truncated"] is True
def test_query_failure_degrades_gracefully(self) -> None:
"""DB-ошибка → data_available=False, note с текстом ошибки (unchanged behavior)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert summary == {"data_available": False, "note": "neighbors query failed: boom"}

View file

@ -545,109 +545,6 @@ def test_competitors_no_price_anywhere_is_none() -> None:
app.dependency_overrides.clear()
# ── SAVEPOINT-регрессия (#2464 cluster A finding 3) ──────────────────────────
def test_competitors_avg_price_failure_does_not_poison_later_queries() -> None:
"""avg_price query падает → sold-count и objective-fallback (следом на ТОЙ ЖЕ
Session) всё равно отрабатывают, а не ловят "current transaction is aborted".
Раньше avg_price/sold-count/objective-fallback ловили db.execute-сбой без
SAVEPOINT (db.begin_nested()) сбой ЛЮБОГО из трёх отравлял shared db для
ОСТАЛЬНЫХ двух (и для persist_analysis_run дальше по /analyze). Фикс оборачивает
каждый в свою begin_nested(); здесь ловим именно avg_price и проверяем, что
objective-fallback (третий execute) всё равно наполняет цену.
"""
coord_result = MagicMock()
coord_result.mappings.return_value.first.return_value = _coord_row()
obj_result = MagicMock()
obj_result.mappings.return_value.all.return_value = [_obj_row(obj_id=1)]
sold_result = MagicMock()
sold_result.mappings.return_value.all.return_value = []
obj_price_result = MagicMock()
obj_price_result.mappings.return_value.all.return_value = [
_obj_price_row(obj_id=1, price=142_000.0)
]
db = MagicMock()
db.execute.side_effect = [
coord_result,
obj_result,
RuntimeError("simulated avg_price DB failure"), # avg_price query сбоит
sold_result,
obj_price_result, # objective-fallback ДОЛЖЕН всё равно выполниться
]
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
# avg_price сбойнул (без domrf-цены), но objective-fallback наполнил её —
# это возможно ТОЛЬКО если сбой avg_price не отравил сессию для fallback.
assert comp["avg_price_per_m2"] == pytest.approx(142_000.0)
assert comp["price_source"] == "objective"
# Все 5 запланированных execute реально дошли (sold-count и fallback не
# были пропущены/не упали каскадом из-за отравленной сессии).
assert db.execute.call_count == 5
# avg_price/sold-count/objective-fallback — каждый под своим SAVEPOINT.
assert db.begin_nested.call_count == 3
finally:
app.dependency_overrides.clear()
def test_competitors_multi_object_radius_yields_per_object_prices() -> None:
"""Регрессия #2445 A5 (byte-identical к best_layouts #1956): несколько
конкурентов в радиусе КАЖДЫЙ получает свою domrf-цену, а не «1 объект на
global-max снимке, у остальных NULL».
Мок здесь не исполняет реальный SQL (_AVG_PRICE_SQL строковые инварианты
см. test_competitors_avg_price_sql.py), но фиксирует ОЖИДАЕМЫЙ КОНТРАКТ:
_make_db может вернуть price_rows для ВСЕХ obj_id разом (per-object latest
snapshot делает это возможным на живой БД) раньше баг гарантировал, что
price_rows содержал не более 1 строки на весь набор конкурентов.
"""
rows = [
_obj_row(obj_id=1, distance_m=100.0),
_obj_row(obj_id=2, distance_m=200.0),
_obj_row(obj_id=3, distance_m=300.0),
]
price_rows = [
_price_row(obj_id=1, price=140_000.0),
_price_row(obj_id=2, price=155_000.0),
_price_row(obj_id=3, price=162_000.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
assert len(comps) == 3
expected = {1: 140_000.0, 2: 155_000.0, 3: 162_000.0}
for obj_id, price in expected.items():
assert comps[obj_id]["avg_price_per_m2"] == pytest.approx(price), (
f"obj_id={obj_id}: все конкуренты должны получить СВОЮ domrf-цену, "
"не only-one-object-on-global-max-snapshot регрессия"
)
assert comps[obj_id]["price_source"] == "domrf"
finally:
app.dependency_overrides.clear()
def test_competitors_is_active_flag() -> None:
"""is_active=True для русского 'Строящиеся' (реальная prod-форма #1213),
False для 'Сданные'/null/прочих."""

View file

@ -1,19 +1,14 @@
"""Тесты pravo_gov66_client — offline, без живой сети.
Покрывают: парсинг search-HTML (doc_id/title/url), парсинг страницы документа
(PDF-ссылка), fallback на ecp-вариант, отсутствие PDF, pacing/backoff (#2445 D4)
между последовательными запросами к pravo.gov66.ru.
(PDF-ссылка), fallback на ecp-вариант, отсутствие PDF.
"""
from __future__ import annotations
import httpx
import pytest
from app.services.scrapers.pravo_gov66_client import (
_LAW_TYPE_POSTANOVLENIE,
_extract_pdf_url,
_get_with_backoff,
_parse_search_html,
)
@ -174,96 +169,3 @@ class TestExtractPdfUrl:
url = _extract_pdf_url(_DOC_HTML_ECP_ONLY)
assert url is not None
assert url.startswith("https://www.pravo.gov66.ru/")
# ── Тесты pacing/backoff (#2445 D4) — offline через httpx.MockTransport ───────
def _client_with_responses(responses: list[httpx.Response]) -> httpx.Client:
"""httpx.Client на MockTransport, отдающий responses по очереди (без реальной сети)."""
calls = iter(responses)
def _handler(request: httpx.Request) -> httpx.Response:
return next(calls)
return httpx.Client(transport=httpx.MockTransport(_handler))
class TestGetWithBackoff:
def test_sleeps_before_request(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Пейсинг: time.sleep вызывается ПЕРЕД запросом, даже на первой/единственной попытке."""
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses([httpx.Response(200, text="ok")])
_get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
assert len(sleeps) == 1
def test_paces_between_successive_fetches(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Несколько последовательных fetch → time.sleep между КАЖДЫМ (не только ретраи)."""
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses(
[
httpx.Response(200, text="a"),
httpx.Response(200, text="b"),
httpx.Response(200, text="c"),
]
)
for i in range(3):
_get_with_backoff(client, f"https://www.pravo.gov66.ru/{i}/")
assert len(sleeps) == 3
def test_retries_on_429_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""429 → backoff sleep + ретрай → 200 успешно, без исключения."""
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses(
[httpx.Response(429, text="rate limited"), httpx.Response(200, text="ok")]
)
resp = _get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
assert resp.status_code == 200
# 1 pacing-sleep перед 1й попыткой + 1 backoff-sleep перед ретраем + 1 pacing перед 2й.
assert len(sleeps) >= 2
def test_retries_on_5xx_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses(
[httpx.Response(503, text="unavailable"), httpx.Response(200, text="ok")]
)
resp = _get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
assert resp.status_code == 200
def test_raises_after_exhausting_retries_on_429(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("app.services.scrapers.pravo_gov66_client.time.sleep", lambda s: None)
# _MAX_RETRIES=2 → до 3 попыток, все 429.
client = _client_with_responses([httpx.Response(429, text="x") for _ in range(3)])
with pytest.raises(httpx.HTTPStatusError):
_get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
def test_4xx_other_than_429_raises_immediately_without_retry(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""404 не ретраится — сразу raise_for_status(), задача не тратит время на 3 попытки."""
sleeps: list[float] = []
monkeypatch.setattr(
"app.services.scrapers.pravo_gov66_client.time.sleep",
lambda s: sleeps.append(s),
)
client = _client_with_responses([httpx.Response(404, text="not found")])
with pytest.raises(httpx.HTTPStatusError):
_get_with_backoff(client, "https://www.pravo.gov66.ru/1/")
# Только 1 pacing-sleep перед единственной попыткой, никаких backoff-ретраев.
assert len(sleeps) == 1

View file

@ -479,11 +479,7 @@ def test_permits_nearby_non_empty_summary_and_list() -> None:
def test_permits_nearby_items_capped_at_ten() -> None:
"""items[:10] — показываются первые 10 строк документов (зеркало HTML).
#2464 cluster B: сверх 10 добавляется honest-строка «и ещё N записей», поэтому
таблица = шапка + 10 документов + 1 disclosure = 12 строк.
"""
"""items[:10] — таблица документов не превышает шапку + 10 строк (зеркало HTML)."""
items = [
{
"doc_group": "RS",
@ -505,76 +501,15 @@ def test_permits_nearby_items_capped_at_ten() -> None:
}
doc = _new_doc()
_build_permits_nearby(doc, result)
# Вторая таблица (документы): шапка + 10 показанных + 1 disclosure-строка.
# Вторая таблица (документы): шапка + не более 10 строк.
docs_table = doc.tables[1]
assert len(docs_table.rows) == 12
assert len(docs_table.rows) == 11
text = _all_text(doc)
assert "РНС-0" in text
assert "РНС-9" in text
assert "РНС-10" not in text
def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
"""#2464 cluster B: total_count (честный COUNT апстрима, БЕЗ SQL LIMIT) > показанных
10 honest-строка «и ещё N записей (полный список в веб-версии §6)», а не молчаливый
срез как тепло/вода-таблицы §3 (`_build_connection_capacity`)."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС-{i}",
"date_doc": "2025-01-01",
"approved_organization": "Орган",
"distance_m": float(i),
}
for i in range(12)
]
result = {
"permits_nearby": {
# total_count=15 > показанных 10 → hidden = 5 (апстрим уже капнул items на 30,
# здесь режем до 10; disclosure считается от честного total_count).
"total_count": 15,
"rs_count": 15,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
}
}
doc = _new_doc()
_build_permits_nearby(doc, result)
text = _all_text(doc)
assert "… и ещё 5 записей (полный список в веб-версии §6)" in text
def test_permits_nearby_no_disclosure_when_all_shown() -> None:
"""total_count <= 10 (всё влезло) → БЕЗ строки «и ещё» (zero behavior change для
маленьких выборок): таблица = шапка + N документов, без disclosure-строки."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС-{i}",
"date_doc": "2025-01-01",
"approved_organization": "Орган",
"distance_m": float(i),
}
for i in range(7)
]
result = {
"permits_nearby": {
"total_count": 7,
"rs_count": 7,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
}
}
doc = _new_doc()
_build_permits_nearby(doc, result)
docs_table = doc.tables[1]
assert len(docs_table.rows) == 8 # шапка + 7, без disclosure
text = _all_text(doc)
assert "и ещё" not in text
def test_section_6_wires_permits_nearby_from_analyze() -> None:
"""_build_section_6(doc, forecast, result) прокидывает result → блок разрешений виден."""
doc = _new_doc()

View file

@ -468,67 +468,6 @@ def test_permits_nearby_org_name_escaped() -> None:
assert "<b>hack</b>" not in out
def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
"""#2464 cluster B: >10 items (upstream cap) → «и ещё N записей» дисклоузится,
вместо молчаливого среза до 10 (total_count честный total апстрима,
permits_nearby.get_permits_nearby считает его COUNT'ом БЕЗ SQL LIMIT)."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС{i}",
"date_doc": "2025-01-01",
"approved_organization": "Администрация",
"distance_m": float(i * 10),
}
for i in range(12)
]
result = {
"permits_nearby": {
"total_count": 15, # > len(items)=12: апстрим уже капнул items на 30,
# здесь ещё раз режем до 10 — итоговый hidden = 15-10 = 5.
"rs_count": 15,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
"items_truncated": True,
}
}
out = _build_permits_nearby(result)
assert "РНС №9" in out # 10-й показанный (index 9, row cap=10)
assert "РНС №10" not in out # 11-й — за кэпом, НЕ показан напрямую
assert "и ещё 5 записей" in out
assert "веб-версии §6" in out
def test_permits_nearby_no_truncation_note_when_all_shown() -> None:
"""total_count совпадает с показанными (<=10) → БЕЗ приписки «и ещё» (zero behavior
change для маленьких выборок, как раньше)."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС{i}",
"date_doc": "2025-01-01",
"approved_organization": "Администрация",
"distance_m": float(i * 10),
}
for i in range(7)
]
result = {
"permits_nearby": {
"total_count": 7,
"rs_count": 7,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
"items_truncated": False,
}
}
out = _build_permits_nearby(result)
assert "и ещё" not in out
for i in range(7):
assert f"РНС{i}" in out
def test_section_6_wires_permits_nearby_from_analyze() -> None:
"""analyze_result прокинут в §6 → блок разрешений рядом виден в part_b HTML."""
analyze = {

View file

@ -24,7 +24,6 @@ import pytest
from app.services.exporters.report_pdf import (
_ADVISORY_MARKER,
_TITLE_CONFIDENCE,
_TITLE_FUTURE_MARKET,
_TITLE_MARKET_NOW,
_TITLE_PRODUCT_TZ,
@ -142,8 +141,7 @@ _skip_if_no_weasyprint = pytest.mark.skipif(
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
)
# Семь ожидаемых заголовков блоков (по одному на содержательную секцию §13, включая
# §13.7 «Уверенность» — parity fix #2445 C2, зеркало _EXPECTED_TITLES в test_report_docx/md).
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
_EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_SUMMARY,
_TITLE_MARKET_NOW,
@ -151,7 +149,6 @@ _EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_PRODUCT_TZ,
_TITLE_SCENARIOS,
_TITLE_SCORING,
_TITLE_CONFIDENCE,
)
@ -360,101 +357,12 @@ class TestContractKeysRendered:
assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
def test_confidence_factors_rendered(self) -> None:
# confidence.factors должны попасть в HTML (ключ→значение) через §13.7-секцию.
# confidence.factors должны попасть в HTML (ключ→значение).
html_str = _build_html(_normalize(_full_report()))
assert "Факторы уверенности" in html_str
assert "data_coverage" in html_str
# ── Parity fix #2445 C2: §13.7 «Уверенность» — дедикейтед секция (было только в docx/md) ─
# report_docx.py / report_md.py уже несли полную секцию с 4-колоночной таблицей
# (Фактор/Значение/Уровень/Комментарий) и defensive dict-vs-flat разбором факторов;
# PDF был единственным экспортёром без неё (только тонкий 2-строчный уровень/rationale
# в «Сводке» + плоский dict-дамп факторов). Эти тесты ловят регресс парности.
class TestConfidenceSectionParity:
def test_confidence_section_title_present(self) -> None:
# Секция §13.7 присутствует как отдельный заголовок (не только внутри «Сводки»).
html_str = _build_html(_normalize(_full_report()))
assert _TITLE_CONFIDENCE in html_str
assert 'id="confidence"' in html_str
def test_confidence_section_has_four_column_table(self) -> None:
# 4-колоночная таблица факторов — зеркало report_docx/report_md._build_confidence.
html_str = _build_html(_normalize(_full_report()))
assert "Фактор" in html_str
assert "Значение" in html_str
assert "Уровень" in html_str
assert "Комментарий" in html_str
def test_confidence_level_and_rationale_rendered(self) -> None:
report = SiteFinderReport(
confidence=ReportConfidence(
level="medium",
rationale="Источники advisory-capped; данные средней плотности.",
factors={"data_coverage": 0.6},
),
)
html_str = _build_html(_normalize(report))
assert "средняя" in html_str # _level_ru("medium")
assert "Источники advisory-capped" in html_str
def test_dict_shaped_factor_renders_without_crashing(self) -> None:
# Ровно баг, от которого защищает docx isinstance(payload, dict)-ветка: значение
# фактора — САМО dict {value, level, note} (реальная форма §15/#990, см. frontend
# mock parcel-forecast.json), а не плоский скаляр. Плоский _dict_kv_table в старой
# версии PDF-«Сводки» такое не ожидал — падал/рендерил мусор. Новая §13.7-секция
# (_build_confidence) разбирает dict-payload как в report_docx/report_md.
report = SiteFinderReport(
confidence=ReportConfidence(
level="low",
rationale="Low потому что мало истории.",
factors={
"history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
"analog_count": {"value": 32, "level": "high", "note": "32 ЖК — достаточно"},
"advisory_capped": False, # плоский (не-dict) фактор — тоже должен уцелеть
},
),
)
# Не должно бросить исключение (регресс-guard); плюс проверяем реальный рендер.
html_str = _build_html(_normalize(report))
assert "history_months" in html_str
assert "6 мес — мало" in html_str
assert "analog_count" in html_str
assert "advisory_capped" in html_str
@_skip_if_no_weasyprint
def test_dict_shaped_factor_produces_valid_pdf(self) -> None:
# То же самое, но конец-в-конец через WeasyPrint (не только HTML-строка).
report = SiteFinderReport(
confidence=ReportConfidence(
level="low",
rationale="Low потому что мало истории.",
factors={
"history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
},
),
)
payload = export_report_pdf(report)
assert payload.startswith(b"%PDF")
assert len(payload) > 0
def test_summary_no_longer_duplicates_confidence_rationale(self) -> None:
# Тонкий 2-строчный «уровень+rationale» блок убран из «Сводки» — теперь живёт
# только в §13.7, чтобы не было двух версий одних и тех же данных в документе.
# Rationale-текст должен встретиться РОВНО один раз (в секции confidence).
report = SiteFinderReport(
confidence=ReportConfidence(
level="medium",
rationale="Уникальная-строка-обоснования-для-проверки-дублей",
factors={"data_coverage": 0.6},
),
)
html_str = _build_html(_normalize(report))
assert html_str.count("Уникальная-строка-обоснования-для-проверки-дублей") == 1
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────

View file

@ -13,25 +13,18 @@ psycopg v3 правило проверяется явно: bind-параметр
from __future__ import annotations
import contextlib
import datetime as dt
import os
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.forecasting.macro_series import (
_CONFOUNDER_DATES,
_MORTGAGE_FIELDS,
_REGIME_EPS_PP,
MonthlyMacro,
_carry_forward,
_month_grid,
_query_inflation_monthly,
_query_key_rate_monthly,
_query_mortgage_monthly,
_shift_months,
classify_regime,
get_monthly_macro,
@ -483,110 +476,3 @@ class TestGetMonthlyMacroGraceful:
out = get_monthly_macro(db, months_back=1)
assert len(out) == 2
assert all(m.mortgage_rate_weighted is None for m in out)
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #2, `_query_mortgage_monthly`): сбой ОДНОГО
# mortgage-поля НЕ должен «отравить» общую §22-сессию для остальных 4 полей / для
# последующих §9.x-слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()`
# сбойный `db.execute` (внутри `get_macro_series`) оставляет транзакцию Postgres
# aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeMortgageResult:
"""Мини-результат db.execute() — покрывает `.all()` (см. `get_macro_series`)."""
def all(self) -> list[Any]:
return []
class _PoisonableSession:
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
`execute()` бросает РОВНО один раз (на первое поле) и, как реальный Postgres,
«отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже
несвязанный) тоже бросает `current transaction is aborted`. `begin_nested()`
зеркалит `Session.begin_nested()`: при исключении откатывает ТОЛЬКО SAVEPOINT
(сбрасывает poison-флаг) и пробрасывает исключение дальше как настоящий
`ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (откатил бы всю внешнюю
транзакцию).
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeMortgageResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated mortgage field query failure")
return _FakeMortgageResult()
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для следующего поля.
self.poisoned = False
raise
class TestQueryMortgageMonthlySavepoint:
"""`_query_mortgage_monthly` оборачивает КАЖДОЕ поле в `db.begin_nested()`."""
def test_first_field_failure_leaves_session_usable_for_later_fields(self) -> None:
db = _PoisonableSession()
out = _query_mortgage_monthly(db, since=dt.date(2024, 1, 1))
# все 5 полей присутствуют в результате (контракт функции не изменён).
assert set(out.keys()) == {field for _itype, field in _MORTGAGE_FIELDS}
# сбойное (первое) поле → пустой fallback-словарь, НЕ crash.
first_field = _MORTGAGE_FIELDS[0][1]
assert out[first_field] == {}
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбойного поля проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
class TestQueryKeyRateMonthlySavepoint:
"""`_query_key_rate_monthly` оборачивает db.execute в `db.begin_nested()`.
Тот же poisoning-класс, что и mortgage-цикл: key_rate ПЕРВЫЙ запрос
get_monthly_macro; без SAVEPOINT его сбой отравил бы транзакцию для inflation +
mortgage + всех последующих §9.x-слоёв на общей §22-сессии.
"""
def test_db_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = _query_key_rate_monthly(db, since=dt.date(2024, 1, 1))
# сбой запроса → пустой ресэмпл (fallback, НЕ crash).
assert out == {}
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
class TestQueryInflationMonthlySavepoint:
"""`_query_inflation_monthly` оборачивает db.execute (через get_macro_series) в SAVEPOINT.
Тот же poisoning-класс: inflation ВТОРОЙ запрос get_monthly_macro; без SAVEPOINT
его сбой отравил бы транзакцию для mortgage-полей + последующих §9.x-слоёв.
"""
def test_db_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = _query_inflation_monthly(db, since=dt.date(2024, 1, 1))
# сбой запроса → пустой ряд (fallback, НЕ crash).
assert out == {}
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")

View file

@ -371,101 +371,3 @@ class TestNormalizeDistrict:
# list / int / прочие неподдерживаемые типы → None + warning.
assert _normalize_district([1, 2, 3]) is None
assert _normalize_district(42) is None
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #1, orchestrator.py `_safe_call`): один сбойный
# §9.x-слой НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ слоёв. Без
# `db.begin_nested()` сбойный `db.execute` внутри слоя оставляет транзакцию Postgres
# aborted («current transaction is aborted, commands ignored until end of
# transaction block») — каждый ПОСЛЕДУЮЩИЙ слой на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeResult:
"""Мини-результат db.execute() — покрывает `.mappings().first()`/`.all()`."""
def mappings(self) -> _FakeResult:
return self
def first(self) -> None:
return None
def all(self) -> list[Any]:
return []
class _PoisonableSession:
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
`execute()` бросает РОВНО один раз (на первый вызов) и, как реальный Postgres,
«отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже
совершенно несвязанный) тоже бросает `current transaction is aborted`.
`begin_nested()` зеркалит `Session.begin_nested()`: при исключении откатывает
ТОЛЬКО SAVEPOINT (сбрасывает poison-флаг) и пробрасывает исключение дальше как
настоящий `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (который откатил бы
всю внешнюю транзакцию established anti-pattern, `backend.md`).
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated §9.x layer query failure")
return _FakeResult()
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для следующего слоя.
self.poisoned = False
raise
class TestSafeCallSavepointDoesNotPoisonSession:
"""`_safe_call` оборачивает вызов слоя в `db.begin_nested()` (SAVEPOINT)."""
def test_layer_db_failure_leaves_session_usable_for_later_layers(self) -> None:
db = _PoisonableSession()
mocks = _patched_layers()
# market_metrics — единственный слой в этом тесте, реально трогающий `db`:
# его мок сам вызывает db.execute (через _PoisonableSession бросает один раз).
# Остальные слои остаются sentinel-моками из _patched_layers() (db не трогают).
mocks["compute_market_metrics"] = MagicMock(
side_effect=lambda *a, **kw: db.execute("SELECT 1")
)
captured: dict[str, Any] = {}
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
captured.update(kwargs)
return SiteFinderReport()
with _patch_layers(mocks, _fake_assemble):
report = build_site_finder_report(
db,
analyze=_analyze([{"obj_class": "Комфорт"}]),
cad_num="66:41:0702048:27",
district="Ленинский",
horizons=[6, 12, 18],
)
assert isinstance(report, SiteFinderReport)
# упавший слой → None в слоте (fallback), остальные слои — sentinel'ы, целы.
assert captured["market_metrics"] is None
assert captured["scenarios"] == ["SC"]
assert captured["forecasts"] == ["F6", "F12"]
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбойного слоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")

View file

@ -40,7 +40,6 @@ from app.services.forecasting.product_scoring import (
_count_positive_usp,
_min_confidence,
_overlay_confidence,
_poi_weight_sum,
_quarter_from_cad,
_saturating,
_score_commercial,
@ -992,98 +991,3 @@ class TestMortgageSensitivityViaRegimeAdapter:
reg_mock.assert_not_called()
assert card.scores["mortgage_sensitivity"].value is None
assert card.scores["mortgage_sensitivity"].confidence == "low"
# ── #2464 cluster A: _poi_weight_sum SAVEPOINT-регрессия (session-poisoning) ────
# ОБА db.execute-сайта внутри _poi_weight_sum раньше ловили сбой БЕЗ SAVEPOINT/rollback:
# 1) центроид-lookup (db.execute(_PARCEL_CENTROID_SQL, ...));
# 2) compute_poi_weighted_top7(...) — poi_score.py сам делает raw db.execute БЕЗ своего
# try/except, поэтому сбой ПРОБРАСЫВАЕТСЯ наверх и глотается вторым except здесь.
# На реальном Postgres любой из них отравляет §22-report session (compute_score_card делит
# одну db-сессию с остальными §22-скорерами): следующий db.execute на ТОЙ ЖЕ сессии падал
# "current transaction is aborted". Фикс: ОБА сайта обёрнуты в db.begin_nested() (swallow
# site для poi — это второй except _poi_weight_sum, там и ставим savepoint).
class TestPoiWeightSumSavepoint:
def test_centroid_query_failure_returns_none(self) -> None:
"""DB-сбой на центроид-lookup → None (fallback → infra_fit unavailable)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("connection refused")
assert _poi_weight_sum(db, cad_num=_CAD) is None
def test_uses_begin_nested_savepoint(self) -> None:
"""Фикс: центроид-запрос обёрнут в db.begin_nested(), не голый db.execute."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
_poi_weight_sum(db, cad_num=_CAD)
assert db.begin_nested.call_count == 1
def test_session_usable_after_centroid_failure(self) -> None:
"""execute сбоит на 1-м вызове (центроид), db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует §22 shared report-session."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _poi_weight_sum(db, cad_num=_CAD) is None
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_poi_call_failure_returns_none(self) -> None:
"""Второй сайт: центроид резолвится, но compute_poi_weighted_top7 бросает →
None (документированный fallback, infra_fit unavailable)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, side_effect=RuntimeError("poi_score raw db.execute failed")):
assert _poi_weight_sum(db, cad_num=_CAD) is None
def test_uses_begin_nested_for_poi_call(self) -> None:
"""Фикс второго сайта: вызов compute_poi_weighted_top7 обёрнут в db.begin_nested().
При успешном центроиде оба сайта под savepoint begin_nested вызван дважды."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, return_value=_poi_response_stub([0.01])):
_poi_weight_sum(db, cad_num=_CAD)
assert db.begin_nested.call_count == 2
def test_session_usable_after_poi_failure(self) -> None:
"""poi_score (второй сайт) бросает → db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует §22 shared report-session
(без savepoint здесь была бы 'current transaction is aborted' на следующем скоре)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, side_effect=RuntimeError("poi boom")):
assert _poi_weight_sum(db, cad_num=_CAD) is None
# begin_nested вызван для обоих сайтов (центроид успешен, poi сбоит внутри savepoint).
assert db.begin_nested.call_count == 2
# Следующий (не связанный) вызов на той же сессии отрабатывает — не отравлена.
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_centroid_success_then_poi_computed(self) -> None:
"""Happy path: центроид резолвится → compute_poi_weighted_top7 вызван, Σ weight отдан.
Оба db-сайта под savepoint begin_nested вызван дважды."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, return_value=_poi_response_stub([0.03, 0.02])):
out = _poi_weight_sum(db, cad_num=_CAD)
assert out == pytest.approx(0.05)
assert db.begin_nested.call_count == 2

View file

@ -15,12 +15,10 @@ psycopg v3 правило проверяется явно: bind-параметр
from __future__ import annotations
import contextlib
import datetime as dt
import math
import os
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@ -669,85 +667,6 @@ class TestBuildSalesSeriesGraceful:
assert out.units == [0]
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #4, `_query_source_a`/`_query_source_b`): сбой
# db.execute НЕ должен «отравить» общую §22-сессию для ПОСЛЕДУЮЩИХ слоёв/запросов,
# переиспользующих ту же `db`-Session. Без `db.begin_nested()` сбойный `db.execute`
# оставляет транзакцию Postgres aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии
# тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeSalesResult:
"""Мини-результат db.execute() — покрывает `.mappings().all()`."""
def mappings(self) -> _FakeSalesResult:
return self
def all(self) -> list[Any]:
return []
class _PoisonableSession:
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
`execute()` бросает РОВНО один раз и, как реальный Postgres, «отравляет» сессию:
БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже несвязанный) тоже
бросает `current transaction is aborted`. `begin_nested()` зеркалит
`Session.begin_nested()`: при исключении откатывает ТОЛЬКО SAVEPOINT (сбрасывает
poison-флаг) и пробрасывает исключение дальше как настоящий `ROLLBACK TO
SAVEPOINT`, НЕ общий `db.rollback()` (откатил бы всю внешнюю транзакцию).
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeSalesResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated sales query failure")
return _FakeSalesResult()
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для последующих запросов.
self.poisoned = False
raise
class TestSourceQuerySavepointDoesNotPoisonSession:
"""`_query_source_a`/`_query_source_b` оборачивают db.execute в SAVEPOINT."""
def test_source_a_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = build_sales_series(db, spec=SegmentSpec(), source="corpus_room_month", months_back=1)
# сбой запроса → zero-filled сетка, low confidence (fallback, НЕ crash).
assert out.confidence == "low"
assert out.units == [0, 0]
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
def test_source_b_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = build_sales_series(db, spec=SegmentSpec(), source="objective_lots", months_back=1)
assert out.confidence == "low"
assert out.units == [0, 0]
db.execute("SELECT 1")
class TestDistrictResolution:
"""Step 2: spec.district (админ-имя ЕКБ) резолвится в МИКРО-набор в SQL-фильтре.

View file

@ -16,7 +16,6 @@ test_market_metrics.
from __future__ import annotations
import contextlib
from datetime import date
from typing import Any
from unittest.mock import MagicMock, patch
@ -66,7 +65,6 @@ from app.services.forecasting.special_indices import (
_price_bucket_to_band,
_price_overlap,
_query_artificial_demand,
_query_parcel_centroid,
_timing_overlap,
_unit_mix_similarity,
_void_index,
@ -1572,178 +1570,6 @@ class TestComputeSpecialIndicesGraceful:
assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.25)
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #3, `_run` внутри `compute_special_indices`):
# сбой ОДНОГО §25-индекса НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ
# индексов/слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()`
# сбойный `db.execute` (внутри `_query_artificial_demand`) оставляет транзакцию
# Postgres aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeIndicesResult:
"""Мини-результат db.execute() — покрывает `.mappings().first()`."""
def mappings(self) -> _FakeIndicesResult:
return self
def first(self) -> None:
return None
class _PoisonableSession:
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
`execute()` бросает РОВНО один раз (Artificial Demand SQL) и, как реальный
Postgres, «отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()`
(даже несвязанный) тоже бросает `current transaction is aborted`.
`begin_nested()` зеркалит `Session.begin_nested()`: при исключении откатывает
ТОЛЬКО SAVEPOINT (сбрасывает poison-флаг) и пробрасывает исключение дальше как
настоящий `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()`.
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeIndicesResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated artificial_demand query failure")
return _FakeIndicesResult()
@contextlib.contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для следующего индекса.
self.poisoned = False
raise
class TestRunSavepointDoesNotPoisonSession:
"""`_run` (внутренний gate compute_special_indices) оборачивает builder в SAVEPOINT."""
def test_artificial_demand_db_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
# district=None/cad_num=None → Cannibalization/Competitor Strength деградируют
# БЕЗ обращения к db (см. test_no_cad_num_degrades_competitor_indices); own
# portfolio патчим в [] (own_developer_ids-гейт не задействуем) — единственный
# builder, реально трогающий db в этом тесте, это Artificial Demand.
with _full_stack_patch(), patch(f"{_MOD}.get_own_portfolio", return_value=[]):
card = compute_special_indices(db, spec=_SPEC, district=None, cad_num=None)
# упавший индекс деградировал в fallback (НЕ крашнул карточку).
assert card.indices[KEY_ARTIFICIAL_DEMAND].method == _METHOD_UNAVAILABLE
assert card.indices[KEY_ARTIFICIAL_DEMAND].value is None
assert len(card.indices) == 6
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбойного индекса проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
# ──────────────────────────────────────────────────────────────────────────────
# RELEASE-SAVEPOINT trap (#2464 cluster A, second-order): `_query_parcel_centroid` —
# ВНУТРЕННИЙ helper, зовётся из `_build_cannibalization` ПОД внешним SAVEPOINT
# `_run`. Если он глотает db-сбой БЕЗ своего savepoint, tx остаётся aborted; внешний
# `_run`-savepoint выходит «нормально» → RELEASE SAVEPOINT → RELEASE в aborted-tx САМ
# падает (Postgres допускает лишь ROLLBACK / ROLLBACK TO SAVEPOINT) → внешний except
# ловит RELEASE-ошибку, но tx не откачена → отравление каскадит в следующий индекс.
# Тест воспроизводит ИМЕННО RELEASE-trap: `_ReleaseTrapSession` моделирует, что
# RELEASE в aborted-tx падает (без внутреннего savepoint тест краснеет).
# ──────────────────────────────────────────────────────────────────────────────
class _FakeReleaseResult:
"""Мини-результат db.execute() — покрывает `.mappings().first()`."""
def mappings(self) -> _FakeReleaseResult:
return self
def first(self) -> None:
return None
class _ReleaseTrapSession:
"""Fake Session, моделирующая Postgres SAVEPOINT/RELEASE/ROLLBACK в aborted-tx.
`execute()` бросает РОВНО раз (помечает tx aborted). `begin_nested()`:
ВХОД в poisoned-tx SAVEPOINT запрещён бросает;
ВЫХОД с исключением ROLLBACK TO SAVEPOINT (снимает poison), пробрасывает;
ВЫХОД нормально RELEASE SAVEPOINT, и если poisoned RELEASE ПАДАЕТ (trap).
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeReleaseResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if not self._raised:
self._raised = True
self.poisoned = True
raise RuntimeError("simulated centroid query failure")
return _FakeReleaseResult()
@contextlib.contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
try:
yield
except Exception:
self.poisoned = False # ROLLBACK TO SAVEPOINT
raise
else:
if self.poisoned:
# RELEASE SAVEPOINT в aborted-tx ПАДАЕТ — тот самый trap.
raise RuntimeError(
"current transaction is aborted, "
"commands ignored until end of transaction block"
)
def _outer_savepoint_call(db: Any, fn: Any, *, fallback: Any) -> Any:
"""Зеркало `_run`/`_safe_call`: fn() под ВНЕШНИМ savepoint (RELEASE на норм-выходе)."""
try:
with db.begin_nested():
return fn()
except Exception:
return fallback
class TestReleaseSavepointTrapParcelCentroid:
"""`_query_parcel_centroid` НЕ отравляет внешний SAVEPOINT (RELEASE-trap)."""
def test_centroid_failure_survives_outer_release(self) -> None:
db = _ReleaseTrapSession() # первый execute (centroid) падает
result = _outer_savepoint_call(
db,
lambda: _query_parcel_centroid(db, cad_num="66:41:0303161:123"),
fallback="OUTER_FALLBACK",
)
# centroid проглочен → его fallback (None), НЕ внешний OUTER_FALLBACK: значит
# внутренний savepoint откатил сбой и внешний RELEASE прошёл чисто.
assert result is None
# КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
db.execute("SELECT 1")
# ──────────────────────────────────────────────────────────────────────────────
# §25.3 Cannibalization dispatch — TRUE own-portfolio vs PROXY fallback
# ──────────────────────────────────────────────────────────────────────────────

View file

@ -116,33 +116,6 @@ def test_concepts_degenerate_parcel_returns_422() -> None:
assert response.status_code == 422
def test_concepts_projected_coordinates_returns_422_not_500() -> None:
# Регресс эпик #2445 B1: parcel_geojson в проекции (МСК-66/UTM-like метры) вместо
# WGS84 lon/lat раньше падал неперехваченным pyproj.CRSError -> opaque 500. Должен
# быть чистый 422 (ParcelGeometryError), как и для прочих невалидных геометрий.
projected = {
"type": "Polygon",
"coordinates": [
[
[500_000.0, 6_200_000.0],
[500_450.0, 6_200_000.0],
[500_450.0, 6_200_310.0],
[500_000.0, 6_200_310.0],
[500_000.0, 6_200_000.0],
]
],
}
response = _post(
{
"parcel_geojson": projected,
"housing_class": "comfort",
"target_floors": 9,
"development_type": "mid_rise",
}
)
assert response.status_code == 422, response.text
def test_concepts_response_matches_contract_keys() -> None:
response = _post(
{

View file

@ -119,25 +119,6 @@ def test_pdf_html_build_graceful_on_empty() -> None:
assert "нет вариантов" in html
def test_pdf_caveat_does_not_contradict_office_revenue_row() -> None:
"""Эпик #2445 C1: каскад включает нежилую выручку/НДС — caveat не должен это отрицать.
Payload ``comfort`` (office_share=0.05 в teap.py), поэтому строка "Выручка —
нежилое (1-й этаж)" в таблице ненулевая: caveat ниже НЕ может утверждать, что
коммерция/офисы "не учитываются" или что НДС "только машиноместа".
"""
variants = geometry.generate(_payload())
html = pdf._build_html(variants)
assert "Выручка — нежилое (1-й этаж), млн руб" in html
assert "НДС (нежилое: паркинг + коммерция), млн руб" in html
# Старые (стейл) формулировки, отрицающие модель нежилого, — не должны вернуться.
assert "не учитываются" not in html
assert "только на нежилую часть" not in html
assert "только машиноместа" not in html
# Caveat честно описывает нежилое как учтённое (не «нет в ТЭП»).
assert "нет в ТЭП" not in html
@pytest.mark.skipif(
not _weasyprint_available(),
reason="WeasyPrint native libs unavailable on this host",

View file

@ -117,24 +117,6 @@ def test_linestring_rejected() -> None:
geometry.parse_parcel(payload)
def test_projected_coordinates_rejected_with_clear_message() -> None:
# Регресс эпик #2445 B1: координаты в проекции (МСК-66/UTM-like метры, напр.
# [500000, 6200000]) вместо WGS84 lon/lat — синтаксически валидный Polygon, но
# centroid.y (~6_200_000) далеко за пределами ±90° -> раньше падало неперехваченным
# pyproj.exceptions.CRSError внутри _metric_transformers (opaque 500). Теперь
# ловится ДО построения проекции с понятным ParcelGeometryError (-> 422).
projected = [
[500_000.0, 6_200_000.0],
[500_450.0, 6_200_000.0],
[500_450.0, 6_200_310.0],
[500_000.0, 6_200_310.0],
[500_000.0, 6_200_000.0],
]
payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [projected]})
with pytest.raises(ParcelGeometryError, match="WGS84"):
geometry.parse_parcel(payload)
# Второй, заведомо меньший контур (~90 m x 45 m) поодаль от основного участка.
_SMALL_CONTOUR = [
[60.610, 56.840],

View file

@ -1,72 +0,0 @@
"""Тест для #2445 D2: scrape_catalog_batch() (per-flat catalog scraper, #2442)
должен конструировать BrowserSession с throttled concurrency/jitter
(settings.scrape_kn_*), а НЕ с модульным дефолтом stealth.py (concurrency=8,
jitter 600-1500ms).
Этот scraper ходит по тому же /сервисы/* path family, что вызвал DOM.РФ WAF
hard-ban 2026-05-24 (#2443) — без явного override BrowserSession тихо унаследовал
бы дефолт, углубляя риск повторного бана.
"""
from __future__ import annotations
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.core.config import settings
class _FakeBrowserSession:
"""Async context manager stub — избегаем реального Playwright/network."""
def __init__(self, *args: object, **kwargs: object) -> None:
self.init_kwargs = kwargs
self.warm_up = AsyncMock()
async def __aenter__(self) -> _FakeBrowserSession:
return self
async def __aexit__(self, *exc: object) -> None:
return None
@pytest.mark.asyncio
async def test_scrape_catalog_batch_passes_throttled_browsersession_kwargs() -> None:
from app.services.scrapers import domrf_catalog as mod
captured: dict[str, object] = {}
def _factory(*args: object, **kwargs: object) -> _FakeBrowserSession:
captured.update(kwargs)
return _FakeBrowserSession(*args, **kwargs)
db = MagicMock()
flats = [{"ods_id": "abc123", "catalog_url_hash": "hash1", "obj_id": 1}]
fake_outcome = {
"success": True,
"fields_extracted": 3,
"updated": True,
"plan_found": False,
"plan_downloaded": False,
}
with (
patch.object(mod, "BrowserSession", side_effect=_factory) as mock_cls,
patch.object(mod, "scrape_one_flat", new=AsyncMock(return_value=fake_outcome)),
):
stats = await mod.scrape_catalog_batch(
db=db,
flats=flats,
region_code=66,
snapshot_date=date(2026, 5, 17),
)
mock_cls.assert_called_once()
assert captured["concurrency"] == settings.scrape_kn_browser_concurrency
assert captured["jitter_min_ms"] == settings.scrape_kn_request_jitter_min_ms
assert captured["jitter_max_ms"] == settings.scrape_kn_request_jitter_max_ms
# Sanity: эти throttled значения — НЕ модульный дефолт stealth.py.
assert captured["concurrency"] != 8
assert stats["success"] == 1

View file

@ -1,63 +0,0 @@
"""Тест для #2445 D2: scrape_catalog_objects() должен конструировать BrowserSession
с throttled concurrency/jitter (settings.scrape_kn_*), а НЕ с модульным дефолтом
stealth.py (concurrency=8, jitter 600-1500ms).
Этот scraper ходит по тому же /сервисы/* path family, что вызвал DOM.РФ WAF
hard-ban 2026-05-24 (#2443) — без явного override BrowserSession тихо унаследовал
бы дефолт, углубляя риск повторного бана.
"""
from __future__ import annotations
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.core.config import settings
class _FakeBrowserSession:
"""Async context manager stub — избегаем реального Playwright/network."""
def __init__(self, *args: object, **kwargs: object) -> None:
self.init_kwargs = kwargs
self.warm_up = AsyncMock()
async def __aenter__(self) -> _FakeBrowserSession:
return self
async def __aexit__(self, *exc: object) -> None:
return None
@pytest.mark.asyncio
async def test_scrape_catalog_objects_passes_throttled_browsersession_kwargs() -> None:
from app.services.scrapers import domrf_catalog_object as mod
captured: dict[str, object] = {}
def _factory(*args: object, **kwargs: object) -> _FakeBrowserSession:
captured.update(kwargs)
return _FakeBrowserSession(*args, **kwargs)
db = MagicMock()
with (
patch.object(mod, "BrowserSession", side_effect=_factory) as mock_cls,
patch.object(mod, "scrape_catalog_object", new=AsyncMock(return_value=True)),
):
stats = await mod.scrape_catalog_objects(
db=db,
obj_ids=[65136],
snapshot_date=date(2026, 5, 17),
region_code=66,
)
mock_cls.assert_called_once()
assert captured["concurrency"] == settings.scrape_kn_browser_concurrency
assert captured["jitter_min_ms"] == settings.scrape_kn_request_jitter_min_ms
assert captured["jitter_max_ms"] == settings.scrape_kn_request_jitter_max_ms
# Sanity: эти throttled значения — НЕ модульный дефолт stealth.py.
assert captured["concurrency"] != 8
assert stats["succeeded"] == 1

View file

@ -1,211 +0,0 @@
"""Тесты для extraction plan_image_url из страницы квартиры DOM.РФ (issue #2440).
Покрывает:
- __NEXT_DATA__ pageProps (primary): URL плана под plan-hint ключом.
- bare <img> fallback: plan-hint в атрибутах / proximity к «Планировка».
- нормализация URL (относительный absolute; data:-inline / шум отбрасываются).
- интеграция с parse_catalog_flat: ключ plan_image_url в результате.
- _TextCollector НЕ ломает text-extraction при наличии <img> (регрессия #1608).
Фикстуры инлайновые (минимальный реалистичный HTML), без live-fetch DOM.РФ
каталог-квартир это Next.js SSR (тот же стек что страница объекта в
domrf_catalog_object.py, где план живёт в __NEXT_DATA__, а не в <img src>).
"""
from __future__ import annotations
import json
import pytest
from app.services.scrapers.domrf_catalog import (
_normalize_plan_url,
_TextCollector,
extract_plan_image_url,
parse_catalog_flat,
)
from app.services.scrapers.stealth import BASE_URL
# Абсолютный file-API URL DOM.РФ (реальный паттерн, см. documents.py:17).
PLAN_ABS_URL = f"{BASE_URL}/api/ext/file/plan-12345.png"
# Относительный next/image URL (Next.js image optimizer).
PLAN_REL_NEXT = "/_next/image?url=%2Fapi%2Fext%2Ffile%2Fplan-777.png&w=1200&q=75"
def _html_with_next_data(page_props: dict) -> str:
"""SSR HTML с __NEXT_DATA__ блоком, содержащим pageProps."""
blob = json.dumps({"props": {"pageProps": page_props}})
return (
"<!doctype html><html><head>"
f'<script id="__NEXT_DATA__" type="application/json">{blob}</script>'
"</head><body>"
"<h1>Квартира 2-комнатная</h1>"
"<div>4 500 000 ₽</div>"
"</body></html>"
)
def _collector_for(html: str) -> _TextCollector:
c = _TextCollector()
c.feed(html)
return c
# ── __NEXT_DATA__ primary path ────────────────────────────────────────────────
def test_plan_from_next_data_plan_image_key() -> None:
"""URL под ключом planImage в pageProps → извлекается (absolute)."""
html = _html_with_next_data({"planImage": PLAN_ABS_URL, "buildingClass": "Комфорт"})
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_plan_from_next_data_layout_key_nested() -> None:
"""URL под вложенным ключом layoutImageUrl → извлекается (BFS обход)."""
html = _html_with_next_data(
{"flat": {"info": {"layoutImageUrl": PLAN_ABS_URL}}, "price": 4_500_000}
)
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_plan_from_next_data_relative_url_normalized() -> None:
"""Относительный next/image URL → абсолютный через BASE_URL."""
html = _html_with_next_data({"floorPlanUrl": PLAN_REL_NEXT})
got = extract_plan_image_url(html, _collector_for(html))
assert got is not None
assert got.startswith(BASE_URL)
assert "plan-777" in got
def test_plan_from_next_data_ignores_non_plan_keys() -> None:
"""Картинка под НЕ-plan ключом (developerLogo) не должна ловиться."""
html = _html_with_next_data(
{"developerLogo": f"{BASE_URL}/api/ext/file/logo.png", "buildingClass": "Бизнес"}
)
assert extract_plan_image_url(html, _collector_for(html)) is None
def test_plan_from_next_data_ignores_noise_url() -> None:
"""plan-ключ, но URL содержит logo/thumb (шум) → отбрасывается."""
html = _html_with_next_data({"planThumb": f"{BASE_URL}/api/ext/file/plan-thumb.png"})
# 'thumb' в URL → _IMAGE_NOISE_RE → None
assert extract_plan_image_url(html, _collector_for(html)) is None
# ── bare <img> fallback ───────────────────────────────────────────────────────
def test_plan_from_img_class_hint() -> None:
"""Нет __NEXT_DATA__: <img class='flat-plan__image'> → извлекается по class."""
html = (
"<!doctype html><html><body>"
f'<img class="flat-plan__image" alt="" src="{PLAN_ABS_URL}">'
"</body></html>"
)
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_plan_from_img_src_hint() -> None:
"""<img> без plan-класса, но src содержит 'plan' → извлекается."""
html = f'<!doctype html><html><body><img src="{PLAN_ABS_URL}"></body></html>'
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_plan_from_img_proximity_to_label() -> None:
"""<img> без plan-hint в атрибутах, но рядом с блоком «Планировка»."""
plain = f"{BASE_URL}/api/ext/file/imgabc.png"
html = (
"<!doctype html><html><body>"
"<div>Планировка</div>"
f'<img alt="" src="{plain}">'
"</body></html>"
)
got = extract_plan_image_url(html, _collector_for(html))
assert got == plain
def test_plan_from_img_skips_logo_and_captcha() -> None:
"""Лого/captcha <img> игнорируются, даже если стоят первыми."""
html = (
"<!doctype html><html><body>"
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
'<img class="robot" src="data:image/jpeg;base64,AAAA">'
"<div>Планировка квартиры</div>"
f'<img class="plan-view" src="{PLAN_ABS_URL}">'
"</body></html>"
)
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
def test_no_plan_returns_none() -> None:
"""Нет ни __NEXT_DATA__ плана, ни plan-img → None."""
html = (
"<!doctype html><html><body>"
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
"<p>Площадь 45 м²</p>"
"</body></html>"
)
assert extract_plan_image_url(html, _collector_for(html)) is None
# ── URL normalization ─────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"raw,expected_prefix",
[
(PLAN_ABS_URL, PLAN_ABS_URL),
("/api/ext/file/plan.png", f"{BASE_URL}/api/ext/file/plan.png"),
("//cdn.example/plan.png", "https://cdn.example/plan.png"),
],
)
def test_normalize_plan_url_variants(raw: str, expected_prefix: str) -> None:
assert _normalize_plan_url(raw) == expected_prefix
@pytest.mark.parametrize("raw", ["", None, "data:image/png;base64,AAAA", " "])
def test_normalize_plan_url_rejects_junk(raw: str | None) -> None:
assert _normalize_plan_url(raw) is None
# ── integration: parse_catalog_flat ───────────────────────────────────────────
def test_parse_catalog_flat_includes_plan_image_url() -> None:
"""parse_catalog_flat кладёт plan_image_url в результат при наличии плана."""
html = _html_with_next_data({"planImage": PLAN_ABS_URL, "price": 4_500_000})
result = parse_catalog_flat(html)
assert result.get("plan_image_url") == PLAN_ABS_URL
def test_parse_catalog_flat_omits_plan_when_absent() -> None:
"""Нет плана → ключ plan_image_url отсутствует (а не None)."""
html = "<!doctype html><html><body><p>Площадь 45 м²</p></body></html>"
result = parse_catalog_flat(html)
assert "plan_image_url" not in result
# ── regression: <img> capture must not break text-extraction (#1608) ──────────
def test_img_capture_preserves_text_blocks() -> None:
"""Наличие <img> (void-тег) не рассинхронизирует стек — текст блоков цел."""
html = (
"<!doctype html><html><body>"
'<div class="price-block">4 500 000 ₽</div>'
f'<img src="{PLAN_ABS_URL}">'
'<div class="status-badge">Продана</div>'
"</body></html>"
)
collector = _collector_for(html)
texts = [t for _cls, t in collector.blocks]
assert any("4 500 000" in t for t in texts)
assert any("Продана" in t for t in texts)
# и картинка захвачена в отдельный список
assert len(collector.images) == 1
assert collector.images[0][0].get("src") == PLAN_ABS_URL
# parse_catalog_flat всё ещё корректно извлекает status/price при наличии img
result = parse_catalog_flat(html)
assert result.get("status") == "sold"
assert result.get("price_rub") == 4_500_000

View file

@ -55,34 +55,3 @@ class TestFlatIdFallbackDeterministic:
out = _norm_flat({"elemId": "x" * 1000}, region_cd=66)
assert out["id"] is not None
assert 0 <= out["id"] < 2**63
class TestCatalogUrlHash:
"""catalog_url_hash ОБЯЗАН пробрасываться из elemId (#2442 Task 1).
Ранее хардкодился в None flat-catalog scraper выбирал 0 строк
(WHERE catalog_url_hash IS NOT NULL). Теперь берётся из того же elemId,
что и fallback flat_id, и живёт независимо от того, пришёл ли flatId.
"""
def test_hash_populated_from_elem_id(self) -> None:
row = {"elemId": "abc-hash-123"}
out = _norm_flat(row, region_cd=66)
assert out["catalog_url_hash"] == "abc-hash-123"
def test_hash_populated_even_when_flat_id_present(self) -> None:
# flatId присутствует → fallback-ветка flat_id НЕ выполняется, но hash
# всё равно должен прочитаться (регрессия, которую чинит Task 1).
row = {"flatId": 999, "elemId": "hash-with-flatid"}
out = _norm_flat(row, region_cd=66)
assert out["id"] == 999
assert out["catalog_url_hash"] == "hash-with-flatid"
def test_hash_none_when_elem_id_absent(self) -> None:
out = _norm_flat({"flatId": 1}, region_cd=66)
assert out["catalog_url_hash"] is None
def test_hash_empty_string_normalized_to_none(self) -> None:
# Пустую строку трактуем как отсутствие (не пишем "" в БД).
out = _norm_flat({"flatId": 1, "elemId": ""}, region_cd=66)
assert out["catalog_url_hash"] is None

View file

@ -1,107 +0,0 @@
"""#2464 cluster E + F — UPSERT DO UPDATE SET column-omission regression tests.
Both UPSERT_OBJECT_SQL and UPSERT_PHOTO_SQL had columns populated on every
INSERT (fresh scrape values, see _norm_object / catalog scraper) but silently
omitted from `ON CONFLICT ... DO UPDATE SET`. Re-running the scraper for an
already-seen (obj_id, snapshot_date) / (obj_id, obj_file_id) row left those
columns stale forever (INSERT wins once, DO UPDATE never refreshes them).
SQL-string assertions against the module-level `text(...)` constants no live
DB needed; the INSERT column list is the source of truth for "must this column
also be kept fresh on conflict", so each test also cross-checks against the
INSERT list to prevent the same class of omission from creeping back in for a
newly-added column.
"""
from __future__ import annotations
import re
from app.services.scrapers.domrf_kn import UPSERT_OBJECT_SQL, UPSERT_PHOTO_SQL
_OBJECT_PREVIOUSLY_OMITTED = [
"problem_flag",
"green_house",
"floor_min",
"floor_max",
"hobj_id",
"short_addr",
"dev_inn",
"region_cd",
]
def _insert_columns(sql: str, table: str) -> list[str]:
"""Extract the `INSERT INTO <table> (col1, col2, ...)` column list."""
m = re.search(rf"INSERT INTO {table}\s*\(([^)]+)\)", sql)
assert m, f"could not locate INSERT INTO {table} (...) column list"
return [c.strip() for c in m.group(1).split(",")]
def _do_update_set_columns(sql: str) -> set[str]:
"""Extract every `col = ` LHS from the `DO UPDATE SET` clause."""
m = re.search(r"DO UPDATE SET(.*)", sql, re.DOTALL)
assert m, "could not locate ON CONFLICT ... DO UPDATE SET clause"
body = m.group(1)
return set(re.findall(r"(?:^|,)\s*(\w+)\s*=", body, re.MULTILINE))
class TestUpsertObjectSqlDoUpdateSet:
def test_previously_omitted_columns_now_present(self) -> None:
sql = str(UPSERT_OBJECT_SQL)
updated = _do_update_set_columns(sql)
missing = [c for c in _OBJECT_PREVIOUSLY_OMITTED if c not in updated]
assert not missing, f"still missing from DO UPDATE SET: {missing}"
def test_previously_omitted_columns_use_direct_excluded_not_coalesce(self) -> None:
# These 8 are freshly computed every scrape call (per _norm_object) --
# COALESCE would permanently pin a stale value once ever set (most
# visibly wrong for problem_flag: a resolved problem must be able to
# clear back to NULL, not get stuck "still problematic" forever).
sql = " ".join(str(UPSERT_OBJECT_SQL).split())
for col in _OBJECT_PREVIOUSLY_OMITTED:
assert (
f"{col} = EXCLUDED.{col}" in sql
), f"{col} should be direct `= EXCLUDED.{col}` (not COALESCE):\n{sql}"
# guard against a COALESCE(EXCLUDED.col, ...) formulation, which would
# permanently pin a stale value once ever set instead of refreshing it.
assert (
f"COALESCE(EXCLUDED.{col}," not in sql
), f"{col} must not be wrapped in COALESCE (needs to be able to clear to NULL)"
def test_all_insert_columns_covered_by_do_update_set_or_conflict_target(self) -> None:
# Locks in the class of bug (INSERT populates a column, DO UPDATE SET
# silently drops it) so it can't recur for a column added later.
sql = str(UPSERT_OBJECT_SQL)
insert_cols = set(_insert_columns(sql, "domrf_kn_objects"))
updated_cols = _do_update_set_columns(sql)
conflict_target = {"obj_id", "snapshot_date"} # immutable conflict key, not updated
missing = insert_cols - updated_cols - conflict_target
assert not missing, f"INSERT columns missing from DO UPDATE SET: {sorted(missing)}"
def test_no_double_colon_cast(self) -> None:
sql = str(UPSERT_OBJECT_SQL)
assert re.search(r":[a-z_]+::[a-z]", sql) is None
class TestUpsertPhotoSqlDoUpdateSet:
def test_build_type_now_present(self) -> None:
sql = str(UPSERT_PHOTO_SQL)
updated = _do_update_set_columns(sql)
assert "build_type" in updated
def test_build_type_uses_direct_excluded(self) -> None:
sql = " ".join(str(UPSERT_PHOTO_SQL).split())
assert "build_type = EXCLUDED.build_type" in sql
def test_all_insert_columns_covered_by_do_update_set_or_conflict_target(self) -> None:
sql = str(UPSERT_PHOTO_SQL)
insert_cols = set(_insert_columns(sql, "domrf_kn_photos"))
updated_cols = _do_update_set_columns(sql)
conflict_target = {"obj_id", "obj_file_id"}
missing = insert_cols - updated_cols - conflict_target
assert not missing, f"INSERT columns missing from DO UPDATE SET: {sorted(missing)}"
def test_no_double_colon_cast(self) -> None:
sql = str(UPSERT_PHOTO_SQL)
assert re.search(r":[a-z_]+::[a-z]", sql) is None

View file

@ -1,136 +0,0 @@
"""Тесты для download_plan_image() из flat_plans.py (issue #2440).
Покрывает:
- успешное скачивание: файл на диске, width/height/size_bytes через Pillow.
- idempotency: повторный вызов не качает заново (download_binary не дёргается).
- ошибка скачивания None, файл не создаётся.
- _safe_ods_filename / _ext_from_url helpers.
Использует фейковый BrowserSession с in-memory PNG (Pillow генерит 3x2 картинку)
без сети и без реального Playwright.
"""
from __future__ import annotations
import io
from pathlib import Path
from typing import Any
import pytest
from PIL import Image
from app.services.scrapers.flat_plans import (
_ext_from_url,
_safe_ods_filename,
download_plan_image,
)
def _png_bytes(w: int = 3, h: int = 2) -> bytes:
buf = io.BytesIO()
Image.new("RGB", (w, h), (10, 20, 30)).save(buf, format="PNG")
return buf.getvalue()
class _FakeSession:
"""Минимальный BrowserSession-дублёр: download_binary отдаёт заданные байты."""
def __init__(self, payload: bytes | None, raise_exc: Exception | None = None) -> None:
self._payload = payload
self._raise = raise_exc
self.calls = 0
async def download_binary(self, url: str) -> bytes:
self.calls += 1
if self._raise is not None:
raise self._raise
assert self._payload is not None
return self._payload
# ── helpers ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"ods,expected",
[
("65136/1/1.4.3", "65136_1_1.4.3"),
("plain", "plain"),
("a//b", "a_b"),
("///", "plan"),
],
)
def test_safe_ods_filename(ods: str, expected: str) -> None:
assert _safe_ods_filename(ods) == expected
@pytest.mark.parametrize(
"url,expected",
[
("https://x/plan.png", ".png"),
("https://x/plan.JPG", ".jpg"),
("https://x/plan.jpeg", ".jpg"),
("https://x/api/ext/file/abc", ".png"), # no ext → default png
("https://x/plan.svg", ".svg"),
],
)
def test_ext_from_url(url: str, expected: str) -> None:
assert _ext_from_url(url) == expected
# ── download ──────────────────────────────────────────────────────────────────
def _read(path: str) -> bytes:
"""Sync file read — вынесено из async-теста, чтобы не тревожить ASYNC240."""
return Path(path).read_bytes()
def _exists(path: Path) -> bool:
return path.exists()
async def test_download_writes_file_and_dims(tmp_path: Path) -> None:
png = _png_bytes(3, 2)
sess: Any = _FakeSession(png)
meta = await download_plan_image(
sess, "https://x/api/ext/file/plan1.png", "65136/1/1.4.3", 65136, base_dir=tmp_path
)
assert meta is not None
assert meta["width_px"] == 3
assert meta["height_px"] == 2
assert meta["size_bytes"] == len(png)
assert meta["downloaded_at"] is not None
p = Path(meta["local_path"])
assert _read(str(p)) == png
# путь: {base}/{obj_id}/{safe_ods}.png
assert p.parent.name == "65136"
assert p.name == "65136_1_1.4.3.png"
async def test_download_idempotent_skips_second_fetch(tmp_path: Path) -> None:
png = _png_bytes(4, 4)
sess: Any = _FakeSession(png)
m1 = await download_plan_image(sess, "https://x/plan.png", "1/2/3", 42, base_dir=tmp_path)
assert m1 is not None
assert sess.calls == 1
# второй вызов: файл уже на диске → download_binary НЕ дёргается
m2 = await download_plan_image(sess, "https://x/plan.png", "1/2/3", 42, base_dir=tmp_path)
assert m2 is not None
assert sess.calls == 1 # не увеличилось
assert m2["local_path"] == m1["local_path"]
assert m2["size_bytes"] == len(png)
async def test_download_failure_returns_none(tmp_path: Path) -> None:
sess: Any = _FakeSession(None, raise_exc=RuntimeError("binary http 403"))
meta = await download_plan_image(sess, "https://x/plan.png", "9/9/9", 99, base_dir=tmp_path)
assert meta is None
# файл не создан
assert not _exists(tmp_path / "99")
async def test_download_empty_body_returns_none(tmp_path: Path) -> None:
sess: Any = _FakeSession(b"")
meta = await download_plan_image(sess, "https://x/plan.png", "9/9/9", 99, base_dir=tmp_path)
assert meta is None

View file

@ -174,7 +174,7 @@ class TestExtractIzyatieRecords:
def test_raw_excerpt_for_first_record_only(self) -> None:
"""raw_excerpt заполняется только для первой записи."""
text = "Изъять: 66:41:0101001:123 участок 1;\n66:41:0101001:456 участок 2."
text = "Изъять: 66:41:0101001:123 участок 1;\n" "66:41:0101001:456 участок 2."
records = extract_izyatie_records(text, self._DOC_TITLE, self._DOC_URL)
assert len(records) == 2
assert records[0]["raw_excerpt"] is not None
@ -506,201 +506,3 @@ class TestIngestIzyatieOcr:
# Первый документ пропущен (ошибка), второй обработан (пустой OCR).
assert result["docs"] == 1
assert result["parcels"] == 0
def test_ocr_failure_on_one_doc_does_not_abort_batch(self) -> None:
"""OCR (Tesseract) падает на документе 2 из 3 — документы 1 и 3
всё равно обрабатываются и коммитятся (issue #2445 D5).
Раньше только fetch_pdf был защищён try/except падение
ocr_pdf_text пробрасывалось из цикла и валило остаток пачки.
"""
from app.workers.tasks.izyatie_ocr_ingest import ingest_izyatie_ocr
mock_docs = [
{"title": "Документ 1", "url": "https://example.com/file/1"},
{"title": "Документ 2 (сломанный OCR)", "url": "https://example.com/file/2"},
{"title": "Документ 3", "url": "https://example.com/file/3"},
]
def ocr_side_effect(pdf_bytes: bytes) -> str:
if pdf_bytes == b"pdf-2":
raise Exception("Tesseract crashed on malformed PDF")
return "66:41:0101001:123"
def fetch_side_effect(url: str) -> bytes:
return b"pdf-" + url.rsplit("/", 1)[-1].encode()
mock_records = [
{
"cad_num": "66:41:0101001:123",
"reservation_kind": "изъятие",
"basis_act": "test",
"act_number": None,
"act_date": None,
"purpose": None,
"doc_url": "https://example.com/file/x",
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
}
]
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
with (
patch(
"app.services.scrapers.izyatie_client.list_izyatie_documents",
return_value=mock_docs,
),
patch(
"app.services.scrapers.izyatie_client.fetch_pdf",
side_effect=fetch_side_effect,
),
patch(
"app.services.scrapers.izyatie_ocr.ocr_pdf_text",
side_effect=ocr_side_effect,
),
patch(
"app.services.scrapers.izyatie_ocr.extract_izyatie_records",
return_value=mock_records,
),
patch("app.workers.tasks.izyatie_ocr_ingest.SessionLocal", return_value=mock_db),
):
result = ingest_izyatie_ocr()
# Документ 2 упал на OCR и не засчитан; 1 и 3 успешно обработаны и закоммичены.
assert result["docs"] == 2
assert result["parcels"] == 2
# db.commit() вызывался ровно для 1 и 3 (документ 2 не дошёл до commit).
assert mock_db.commit.call_count == 2
# rollback() вызван один раз — после сбоя документа 2.
assert mock_db.rollback.call_count == 1
def test_extract_failure_on_one_doc_does_not_abort_batch(self) -> None:
"""Баг парсера в extract_izyatie_records на документе 2 из 3 — документы
1 и 3 всё равно обрабатываются (issue #2445 D5)."""
from app.workers.tasks.izyatie_ocr_ingest import ingest_izyatie_ocr
mock_docs = [
{"title": "Документ 1", "url": "https://example.com/file/1"},
{"title": "Документ 2 (баг парсера)", "url": "https://example.com/file/2"},
{"title": "Документ 3", "url": "https://example.com/file/3"},
]
good_record = [
{
"cad_num": "66:41:0101001:999",
"reservation_kind": "изъятие",
"basis_act": "test",
"act_number": None,
"act_date": None,
"purpose": None,
"doc_url": "https://example.com/file/x",
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
}
]
def extract_side_effect(ocr_text: str, doc_title: str, doc_url: str) -> list[dict]:
if doc_url.endswith("/2"):
raise ValueError("unexpected OCR text shape crashed the parser")
return good_record
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
with (
patch(
"app.services.scrapers.izyatie_client.list_izyatie_documents",
return_value=mock_docs,
),
patch(
"app.services.scrapers.izyatie_client.fetch_pdf",
return_value=b"fake pdf",
),
patch(
"app.services.scrapers.izyatie_ocr.ocr_pdf_text",
return_value="66:41:0101001:999",
),
patch(
"app.services.scrapers.izyatie_ocr.extract_izyatie_records",
side_effect=extract_side_effect,
),
patch("app.workers.tasks.izyatie_ocr_ingest.SessionLocal", return_value=mock_db),
):
result = ingest_izyatie_ocr()
assert result["docs"] == 2
assert result["parcels"] == 2
assert mock_db.commit.call_count == 2
assert mock_db.rollback.call_count == 1
def test_upsert_failure_on_one_doc_does_not_abort_batch(self) -> None:
"""db.commit()/upsert падает на документе 2 из 3 — документы 1 и 3
всё равно обрабатываются и коммитятся, а session откатывается после
сбоя вместо отравления следующего commit (issue #2445 D5)."""
from app.workers.tasks.izyatie_ocr_ingest import ingest_izyatie_ocr
mock_docs = [
{"title": "Документ 1", "url": "https://example.com/file/1"},
{"title": "Документ 2 (сбой commit)", "url": "https://example.com/file/2"},
{"title": "Документ 3", "url": "https://example.com/file/3"},
]
record = [
{
"cad_num": "66:41:0101001:1",
"reservation_kind": "изъятие",
"basis_act": "test",
"act_number": None,
"act_date": None,
"purpose": None,
"doc_url": "https://example.com/file/x",
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
}
]
commit_calls = 0
def commit_side_effect() -> None:
nonlocal commit_calls
commit_calls += 1
if commit_calls == 2:
raise Exception("db commit failed: connection reset")
mock_db = MagicMock()
mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
mock_db.commit.side_effect = commit_side_effect
with (
patch(
"app.services.scrapers.izyatie_client.list_izyatie_documents",
return_value=mock_docs,
),
patch(
"app.services.scrapers.izyatie_client.fetch_pdf",
return_value=b"fake pdf",
),
patch(
"app.services.scrapers.izyatie_ocr.ocr_pdf_text",
return_value="66:41:0101001:1",
),
patch(
"app.services.scrapers.izyatie_ocr.extract_izyatie_records",
return_value=record,
),
patch("app.workers.tasks.izyatie_ocr_ingest.SessionLocal", return_value=mock_db),
):
result = ingest_izyatie_ocr()
# Документ 2 не засчитан (commit упал), 1 и 3 обработаны успешно.
assert result["docs"] == 2
assert result["parcels"] == 2
# commit() вызван 3 раза (для каждого документа), 2-й вызов упал.
assert mock_db.commit.call_count == 3
# rollback() вызван ровно один раз — после сбойного commit документа 2.
assert mock_db.rollback.call_count == 1

View file

@ -8,9 +8,6 @@
- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=).
- fetch_okn_detail: HTML карточки {name, address, }; graceful при ошибках.
- _parse_detail_html: <dl>/<dt>/<dd> и <tr>/<td> структура парсится; маркеры label.
- WAF-hygiene (#2445 item D3): rate-limit sleep между запросами (общий searchMap+detail),
экспоненциальный backoff на 429/5xx, Retry-After учитывается и капается, ретраи
исчерпываются на устойчивой ошибке без raise.
"""
from __future__ import annotations
@ -112,9 +109,6 @@ def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock:
resp.status_code = status
resp.json.return_value = payload
resp.raise_for_status = MagicMock()
# headers по умолчанию пуст (dict, не MagicMock) — иначе resp.headers.get(...)
# в backoff-коде вернул бы MagicMock вместо None и сломал бы Retry-After parsing.
resp.headers = {}
if status >= 400:
import httpx
@ -130,8 +124,6 @@ def _mock_html_response(html: str, status: int = 200) -> MagicMock:
resp.status_code = status
resp.text = html
resp.raise_for_status = MagicMock()
# См. _mock_response — headers должен быть настоящим dict, не MagicMock.
resp.headers = {}
if status >= 400:
import httpx
@ -178,21 +170,16 @@ def test_fetch_okn_points_empty_features() -> None:
def test_fetch_okn_points_http_error_returns_empty() -> None:
"""HTTP 500 → пустой список (не raise) после исчерпания ретраев backoff."""
"""HTTP 500 → пустой список (не raise)."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response({}, status=500)
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
result = fetch_okn_points("Свердловская", 1)
assert result == []
# 500 ретраится _MAX_RETRIES раз перед сдачей — backoff-sleep вызывается.
assert mock_sleep.call_count >= 1
def test_fetch_okn_points_invalid_json_returns_empty() -> None:
@ -450,16 +437,13 @@ def test_fetch_okn_detail_returns_parsed_fields_on_success() -> None:
def test_fetch_okn_detail_http_error_returns_empty() -> None:
"""HTTP 500 на detail → пустой dict, не raise (после исчерпания ретраев backoff)."""
"""HTTP 500 на detail → пустой dict, не raise."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response("", status=500)
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep"),
):
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
result = fetch_okn_detail("001")
assert result == {}
@ -498,149 +482,3 @@ def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
assert len(clients) == 2
assert clients[0]["verify"] is True
assert clients[1]["verify"] is False
# ── WAF-hygiene: rate-limit + 429/5xx backoff (#2445 item D3) ────────────────
def test_fetch_okn_points_sleeps_between_requests_for_rate_limit() -> None:
"""Второй вызов fetch_okn_points почти сразу после первого → time.sleep вызван
(rate-limit держит паузу между последовательными запросами к WAF-хосту).
"""
payload = _make_geojson_response([_make_feature("rl-001", 56.83, 60.61)])
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response(payload)
import app.services.scrapers.okn_egrkn_client as client_mod
# Форсируем "последний запрос был только что" — следующий вызов обязан поспать.
client_mod._last_request_ts = client_mod.time.time()
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
fetch_okn_points("Екатеринбург", 1)
mock_sleep.assert_called_once()
(slept_s,) = mock_sleep.call_args.args
assert 0 < slept_s <= client_mod._RATE_LIMIT_MS / 1000.0
def test_fetch_okn_detail_shares_rate_limit_with_searchmap() -> None:
"""Rate-limit общий между searchMap и detail — один WAF-хост okn-mk.mkrf.ru."""
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
import app.services.scrapers.okn_egrkn_client as client_mod
client_mod._last_request_ts = client_mod.time.time()
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
fetch_okn_detail("test-id")
mock_sleep.assert_called_once()
def test_do_request_backs_off_on_429_then_succeeds() -> None:
"""429 → экспоненциальный backoff (time.sleep) → retry → успех на второй попытке."""
features = [_make_feature("429-001", 56.83, 60.61)]
payload = _make_geojson_response(features)
responses = [_mock_response({}, status=429), _mock_response(payload)]
# Первая ответка raise_for_status не должна вызываться — код читает status_code
# напрямую и уходит в backoff-branch до raise_for_status().
call_count = {"n": 0}
def _make_client(**kwargs: Any) -> MagicMock:
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
def _post(url: str, **post_kwargs: Any) -> MagicMock:
resp = responses[call_count["n"]]
call_count["n"] += 1
return resp
mc.post.side_effect = _post
return mc
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Екатеринбург", 1)
assert len(result) == 1
assert result[0]["id"] == "429-001"
assert call_count["n"] == 2
# Backoff перед повтором вызван хотя бы раз (плюс возможные rate-limit sleeps).
assert mock_sleep.call_count >= 1
def test_do_request_gives_up_after_max_retries_on_persistent_429() -> None:
"""Устойчивый 429 → ретраи исчерпаны (_MAX_RETRIES), возврат [] без raise."""
import app.services.scrapers.okn_egrkn_client as client_mod
mock_client = MagicMock()
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_client.post.return_value = _mock_response({}, status=429)
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Екатеринбург", 1)
assert result == []
# attempt=0..._MAX_RETRIES-1 включительно ретраятся → _MAX_RETRIES backoff-sleeps
# (плюс rate-limit sleeps на каждую из _MAX_RETRIES+1 попыток).
assert mock_sleep.call_count >= client_mod._MAX_RETRIES
def test_do_request_retry_after_header_respected_and_capped() -> None:
"""Retry-After заголовок уважается для расчёта backoff (капается _MAX_BACKOFF_S)."""
import app.services.scrapers.okn_egrkn_client as client_mod
huge_retry_after = str(int(client_mod._MAX_BACKOFF_S) + 500)
resp_429 = _mock_response({}, status=429)
resp_429.headers = {"Retry-After": huge_retry_after}
resp_ok = _mock_response(_make_geojson_response([_make_feature("ra-001", 56.83, 60.61)]))
resp_ok.headers = {}
responses = [resp_429, resp_ok]
call_count = {"n": 0}
def _make_client(**kwargs: Any) -> MagicMock:
mc = MagicMock()
mc.__enter__ = MagicMock(return_value=mc)
mc.__exit__ = MagicMock(return_value=False)
def _post(url: str, **post_kwargs: Any) -> MagicMock:
resp = responses[call_count["n"]]
call_count["n"] += 1
return resp
mc.post.side_effect = _post
return mc
with (
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
):
result = fetch_okn_points("Екатеринбург", 1)
assert len(result) == 1
# Backoff-sleep вызывался хотя бы раз с длительностью <= _MAX_BACKOFF_S
# (Retry-After капается потолком, а не используется как есть).
backoff_calls = [c.args[0] for c in mock_sleep.call_args_list]
assert any(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)
assert all(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)

View file

@ -40,7 +40,6 @@ from app.services.site_finder.best_layouts import (
MAX_BUCKET_SHARE_PCT,
NAMELESS_CLUSTER_M,
_cap_and_redistribute,
_fetch_supply_only_layouts,
get_best_layouts,
group_radius_objects,
match_groups_to_projects,
@ -1225,193 +1224,3 @@ def test_supply_only_sql_uses_price_sanity_and_on_sale_filter() -> None:
assert "'studio'" in sql_text and "'4+'" in sql_text
# psycopg3: CAST(:x AS type), НЕ :x::type
assert "::text[]" not in sql_text.replace("CAST(:names AS text[])", "")
# ── SAVEPOINT regression (#2464 cluster A) ────────────────────────────────────
#
# Step 5 (_SUPPLY_BATCH_SQL) db shared с caller (GET .../best-layouts,
# Depends(get_db); sync get_best_layouts мостится через run_in_threadpool). Раньше
# db.execute-сбой ловился в bare ``except Exception`` без SAVEPOINT/rollback — на
# реальном Postgres это отравляет db shared с caller: следующий db.execute на этой
# же сессии (напр. Step 9b _fetch_supply_only_layouts) падает "current transaction
# is aborted". Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не
# эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful
# supply=0 fallback при сбое, (2) что begin_nested() реально вызван вокруг именно
# этого db.execute, (3) что db остаётся usable для последующих вызовов на этой же
# mock-сессии (get_best_layouts не крашит, ранжирование по-прежнему строится).
def test_supply_query_failure_degrades_to_zero_supply() -> None:
"""Сбой Step 5 (_SUPPLY_BATCH_SQL) → supply_units_in_radius=0 для всех строк,
остальной pipeline (velocity/ranking) не ломается."""
id_rows = [_obj_id_row(1)]
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
real_dispatch = db.execute.side_effect
def _execute_raise_on_supply(stmt, params=None, *args, **kwargs):
if stmt is _SUPPLY_BATCH_SQL:
raise RuntimeError("simulated DB failure")
return real_dispatch(stmt, params, *args, **kwargs)
db.execute.side_effect = _execute_raise_on_supply
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].supply_units_in_radius == 0
assert resp.top_layouts[0].sold_pct_of_supply is None
assert resp.top_layouts[0].is_oversold is False
# velocity per-month всё ещё честно посчитан (сбой изолирован в supply-блоке).
assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
def test_supply_query_uses_begin_nested_savepoint() -> None:
"""Фикс: Step 5 запрос обёрнут в db.begin_nested(), не голый db.execute."""
id_rows = [_obj_id_row(1)]
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
supply_rows = [_supply_row("2", "40-60", 80)]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
req = _request(time_window="last_quarter")
get_best_layouts(db, CAD_NUM, req)
assert db.begin_nested.call_count >= 1
def test_supply_query_failure_session_usable_for_supply_only_step() -> None:
"""db остаётся usable после Step 5-сбоя: Step 9b (_fetch_supply_only_layouts,
отдельный db.execute на той же сессии) по-прежнему выполняется без исключения.
Сетап зеркалит test_supply_only_group_without_velocity_appears_in_block: две
группы, «Траектория» с velocity top_layouts, «Символ» без velocity должна
дойти до Step 9b (_fetch_supply_only_layouts) на той же db, что и Step 5.
"""
id_rows = [
_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60),
_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601),
]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])]
supply_only_rows = [_supply_only_row("Символ", room_bucket="1", area_bin="25-40")]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
project_names=["Траектория", "Символ"],
developers_by_project={},
supply_only_rows=supply_only_rows,
)
real_dispatch = db.execute.side_effect
def _execute_raise_on_supply(stmt, params=None, *args, **kwargs):
if stmt is _SUPPLY_BATCH_SQL:
raise RuntimeError("simulated DB failure")
return real_dispatch(stmt, params, *args, **kwargs)
db.execute.side_effect = _execute_raise_on_supply
req = _request(time_window="last_quarter")
# Не должно бросить InFailedSqlTransaction-подобное исключение — Step 9b
# выполняется на той же db без проблем (в mock — просто control-flow guard).
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts[0].supply_units_in_radius == 0
# Step 9b реально выполнился на этой же db — supply-only блок не пуст.
assert len(resp.supply_only_layouts) == 1
assert resp.supply_only_layouts[0].project_name == "Символ"
# ── SAVEPOINT regression: ex-bare-rollback sites (#2464 cluster A) ──────────────
#
# ДВА места в best_layouts.py раньше делали ГОЛЫЙ db.rollback() на SHARED сессии в
# except после сбоя db.execute — это ХУЖЕ простого swallow: bare rollback на общей
# сессии откатывает ВСЮ outer-транзакцию /analyze-запроса (всю несохранённую работу
# ранее в этом же запросе) и orphan'ит outer SessionTransaction (velocity.py:170).
# Фикс — savepoint: db.execute обёрнут в ``with db.begin_nested():``, а bare
# db.rollback() УДАЛЁН (auto-rollback savepoint'а откатывает только сбойный statement,
# не весь запрос). Тесты проверяют: (a) документированный fallback сохранён, (b)
# begin_nested() использован И db.rollback() НЕ вызван (весь смысл — outer tx выживает),
# (c) follow-up db.execute на той же сессии проходит.
class TestFetchSupplyOnlyLayoutsSavepoint:
"""Ex-rollback site #1: _fetch_supply_only_layouts (Step 9b)."""
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_fetch_supply_only_layouts(db, ["ЖК Резерв"])
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_and_no_bare_rollback(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
out = _fetch_supply_only_layouts(db, ["ЖК Резерв"])
assert out == [] # документированный fallback
assert db.begin_nested.call_count == 1 # savepoint использован
db.rollback.assert_not_called() # bare rollback удалён — outer tx выживает
def test_empty_project_names_skips_db_entirely(self) -> None:
# Пустой вход → SQL не выполняется, savepoint не открывается (штатный ранний []).
db = MagicMock()
assert _fetch_supply_only_layouts(db, []) == []
db.execute.assert_not_called()
db.begin_nested.assert_not_called()
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _fetch_supply_only_layouts(db, ["ЖК Резерв"]) == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_core_attribution_failure_uses_savepoint_no_bare_rollback() -> None:
"""Ex-rollback site #2: Step 2b core-attribution db.execute сбоит → SAVEPOINT
(не bare rollback) get_best_layouts деградирует в mapping-only.
Раньше bare db.rollback() здесь откатил бы ВСЮ outer tx /analyze-запроса. Теперь
begin_nested откатывает только сбойный savepoint, а velocity через obj_id (mapping)
по-прежнему считается на той же сессии top_layouts не пуст, outer tx выживает.
"""
id_rows = [_obj_id_row(1)]
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
supply_rows = [_supply_row("2", "40-60", 80)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
project_names=["Символ"],
developers_by_project={},
)
real_dispatch = db.execute.side_effect
def _raise_on_project_names(stmt, params=None, *args, **kwargs):
# Первый из трёх Step 2b запросов — сбой на нём отравил бы сессию без savepoint.
if stmt is _OBJECTIVE_PROJECT_NAMES_SQL:
raise RuntimeError("simulated DB failure")
return real_dispatch(stmt, params, *args, **kwargs)
db.execute.side_effect = _raise_on_project_names
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
# savepoint использован, bare rollback НЕ вызван (outer tx /analyze выживает).
assert db.begin_nested.call_count >= 1
db.rollback.assert_not_called()
# fallback на mapping-only: velocity через obj_id всё равно посчитан на той же
# сессии (Step 3 velocity execute не заблокирован aborted-транзакцией).
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].room_bucket == "2"
assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
# core-match не дал имён (сбой) → matched-only supply-блок пуст, но ответ валиден.
assert resp.data_quality.groups_matched_by_core == 0

View file

@ -1,76 +0,0 @@
"""Пин SQL-формы competitors._AVG_PRICE_SQL (эпик #2445, пункт A5).
Byte-identical бага к best_layouts._SUPPLY_BATCH_SQL (#1956): domrf_kn_flats —
ПО-ОБЪЕКТНЫЙ time-series (каждый ЖК скрейпится в свой день, UNIQUE(obj_id,
snapshot_date), migration 174_domrf_kn_flats_obj_snapshot_idx.sql). Прежний
запрос фильтровал по ОДНОЙ глобальной дате
`f.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_flats)` на этой
единственной дате обычно присутствует лишь 1 объект (скрейпнутый последним),
поэтому у ВСЕХ остальных конкурентов в радиусе получалось 0 строк flats
avg_price_per_m2 = NULL _price_similarity(None, ...) молча возвращал
нейтраль 0.5, тихо деградируя relevance-ранжирование без единого флага.
FIX (порт #1956 в этот файл): flats_latest CTE — DISTINCT ON (f.obj_id) ...
ORDER BY f.obj_id, snapshot_date DESC, id DESC берёт для КАЖДОГО obj_id его
СОБСТВЕННЫЙ последний снимок, затем self-join к domrf_kn_flats по
(obj_id, snapshot_date) достаёт квартиры именно этого снимка.
Эти тесты строковые инварианты на скомпилированном SQL (без живой БД), чтобы
будущий рефактор случайно не вернул глобал-max-date регрессию.
"""
from __future__ import annotations
import os
import re
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.site_finder.competitors import _AVG_PRICE_SQL
_SQL = str(_AVG_PRICE_SQL)
_SQL_FLAT = re.sub(r"\s+", " ", _SQL).lower()
def test_avg_price_uses_flats_latest_per_object_cte() -> None:
"""flats_latest CTE обязателен — без него avg_price снова считается на
единственной глобал-max дате (регрессия #2445 A5, byte-identical к #1956)."""
assert "flats_latest" in _SQL_FLAT, "нет flats_latest CTE → возврат к global-max-date багу"
assert "distinct on (f.obj_id)" in _SQL_FLAT, "flats не дедупятся per-object"
def test_avg_price_orders_latest_snapshot_first() -> None:
"""ORDER BY последним снапшотом вперёд (snapshot_date DESC, id DESC) — DISTINCT ON
берёт первую строку группы собственный последний снимок объекта."""
assert "snapshot_date desc" in _SQL_FLAT
assert "id desc" in _SQL_FLAT
m = re.search(r"order by f\.obj_id, f\.snapshot_date desc, f\.id desc", _SQL_FLAT)
assert m is not None, "DISTINCT ON должен сортировать per obj_id, затем latest snapshot"
def test_avg_price_no_global_max_snapshot_date() -> None:
"""Регрессия #2445 A5: единственный глобальный MAX(snapshot_date) по всей
таблице не должен вернуться это и есть исходный баг (1 объект на весь
global-max день, остальные competitors получают avg_price=NULL)."""
assert "select max(snapshot_date) from domrf_kn_flats" not in _SQL_FLAT
assert "(select max(snapshot_date)" not in _SQL_FLAT
def test_avg_price_joins_flats_by_obj_id_and_snapshot_date() -> None:
"""Self-join к domrf_kn_flats обязан фильтровать И по obj_id, И по
snapshot_date дедуплицированного flats_latest иначе снова fan-out по
истории снимков объекта (см. #1210)."""
assert re.search(r"join\s+domrf_kn_flats\s+f\b", _SQL_FLAT)
assert "f.obj_id = fl.obj_id" in _SQL_FLAT
assert "f.snapshot_date = fl.snapshot_date" in _SQL_FLAT
def test_avg_price_keeps_price_not_null_filter() -> None:
"""AVG(...) FILTER (WHERE price_per_m2 IS NOT NULL) сохранён (Issue #112/227
status='sold' фильтр убран, но NULL-цены по-прежнему исключаются из AVG)."""
assert "avg(f.price_per_m2) filter (where f.price_per_m2 is not null)" in _SQL_FLAT
def test_avg_price_no_double_colon_cast() -> None:
"""psycopg v3 / backend.md: никаких :name::type (игнорируется парсером)."""
assert not re.search(r":[a-z_]+::[a-z]", _SQL)

View file

@ -1,138 +0,0 @@
"""SAVEPOINT-регрессия для connection_capacity_lookup.py (#2464 cluster A finding 2).
gas/heat/gas-outlet query helpers (_query_gas_city_grs, _query_gas_outlet_counts,
_query_gas_outlet_points, _query_heat_latest) когда-то ловили db.execute-сбой в
bare ``except Exception`` без SAVEPOINT/rollback на реальном Postgres это отравляло
db shared с caller (GET .../connection-capacity, Depends(get_db)): следующий db.execute
в этом же запросе падал с "current transaction is aborted". Сиблинг
``_query_nearby_network_zones`` (в этом же файле) уже был правильным (db.begin_nested())
эти тесты проверяют, что остальные функции приведены в соответствие.
MagicMock-сессия не эмулирует реальный aborted-transaction Postgres тесты
проверяют (1) graceful fallback при сбое, (2) что begin_nested() реально вызван
вокруг db.execute (SAVEPOINT-обёртка присутствует в коде), (3) что db остаётся
usable для следующего вызова на том же mock-сессии (call_count растёт, ошибка не
проглатывает control flow).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.site_finder.connection_capacity_lookup import (
_query_gas_city_grs,
_query_gas_outlet_counts,
_query_gas_outlet_points,
_query_heat_latest,
)
_WKT = "POLYGON((60.8 56.7, 60.9 56.7, 60.9 56.8, 60.8 56.8, 60.8 56.7))"
def _mock_db_raising_once(final_rows: list[dict] | None = None) -> MagicMock:
"""db.execute кидает на 1-м вызове, на 2-м успешно отдаёт final_rows."""
db = MagicMock()
ok_result = MagicMock()
ok_result.mappings.return_value.all.return_value = final_rows or []
ok_result.mappings.return_value.first.return_value = (final_rows or [None])[0]
db.execute.side_effect = [RuntimeError("simulated DB failure"), ok_result]
return db
class TestGasCityGrsSavepoint:
def test_query_failure_degrades_gracefully(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("gas_grs_capacity недоступна")
out = _query_gas_city_grs(db)
assert out["city_grs"] == []
assert out["total_free_th_m3_h"] is None
def test_uses_begin_nested_savepoint(self) -> None:
"""Фикс: query обёрнут в db.begin_nested(), не голый db.execute."""
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
db.execute.return_value.mappings.return_value.first.return_value = None
_query_gas_city_grs(db)
# 2 execute на функцию: _query_gas_outlet_counts (city-level счётчики) +
# сам gas_grs_capacity query — оба теперь под begin_nested.
assert db.begin_nested.call_count >= 1
def test_session_usable_after_failure(self) -> None:
"""db.execute сбоит один раз (city_grs), но db остаётся usable для
следующего (не связанного) вызова на этой же сессии."""
db = _mock_db_raising_once()
# outlet_counts вызывается ПЕРВЫМ внутри _query_gas_city_grs — заставим его
# тоже отдать пустой результат нормально, а основной query — кинуть.
db.execute.side_effect = [
MagicMock(
mappings=MagicMock(return_value=MagicMock(first=lambda: None))
), # outlet_counts
RuntimeError("main query failed"), # gas_grs_capacity query
]
out = _query_gas_city_grs(db)
assert out["city_grs"] == []
# Следующий вызов на том же db.execute отрабатывает (session не отравлена).
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.side_effect = None
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestGasOutletCountsSavepoint:
def test_query_failure_degrades_to_zeros(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("gas_grs_outlet_points недоступна")
out = _query_gas_outlet_counts(db)
assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
_query_gas_outlet_counts(db)
assert db.begin_nested.call_count == 1
class TestGasOutletPointsSavepoint:
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("gas_grs_outlet_points (радиус) недоступна")
assert _query_gas_outlet_points(db, _WKT) == []
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_query_gas_outlet_points(db, _WKT)
assert db.begin_nested.call_count == 1
class TestHeatLatestSavepoint:
def test_query_failure_degrades_gracefully(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("heat_system_reserves недоступна")
out = _query_heat_latest(db)
assert out == {"systems": [], "total_reserve_gcal_h": None}
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_query_heat_latest(db)
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self) -> None:
"""execute сбоит один раз, db остаётся usable для следующего вызова —
имитирует heat gas_outlet_points nearby_network_zones в
get_connection_capacity() на одной shared Session."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
out = _query_heat_latest(db)
assert out["systems"] == []
db.execute.side_effect = None
ok_result = MagicMock()
ok_result.mappings.return_value.all.return_value = [{"marker": "next-query-ok"}]
db.execute.return_value = ok_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "next-query-ok"}]

View file

@ -289,33 +289,3 @@ def test_unexpected_error_also_degrades_to_none() -> None:
db.execute.side_effect = RuntimeError("boom")
result = get_developer_attribution(db, "66:41:0303161:1")
assert result is None
# ── SAVEPOINT-регрессия (#2464 cluster A finding 1) ──────────────────────────
def test_db_error_uses_savepoint_and_session_stays_usable() -> None:
"""Фикс: db.execute обёрнут в db.begin_nested() (SAVEPOINT), НЕ голый rollback.
db здесь shared request-scoped Session (Depends(get_db) в parcels.py analyze_parcel);
без SAVEPOINT сбой fn_developer_for_parcel отравил бы persist_analysis_run сразу
после (parcels.py:4116). Мокаем db.execute: 1-й вызов (внутри
get_developer_attribution) кидает, 2-й (имитация persist_analysis_run следом на
том же db) отрабатывает нормально.
"""
db = MagicMock()
ok_result = MagicMock()
ok_result.mappings.return_value.all.return_value = [{"marker": "persist-ok"}]
db.execute.side_effect = [
ProgrammingError("stmt", {}, Exception("fn_developer_for_parcel does not exist")),
ok_result,
]
result = get_developer_attribution(db, "66:41:0303161:1")
assert result is None
assert db.begin_nested.call_count == 1 # SAVEPOINT вокруг резолвера
# Сессия осталась usable для следующего (не связанного) db.execute.
next_call = db.execute("SELECT 1")
assert next_call.mappings().all() == [{"marker": "persist-ok"}]
assert db.execute.call_count == 2

View file

@ -17,10 +17,7 @@ admin-names (.scalars().all()) + micro-lookup (.scalars().all()). КРИТИЧН
from __future__ import annotations
import contextlib
import os
from collections.abc import Callable, Iterator
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -29,7 +26,6 @@ import pytest
from app.services.site_finder import district_resolver
from app.services.site_finder.district_resolver import (
_admin_names,
_reset_admin_cache,
resolve_objective_districts,
)
@ -224,129 +220,3 @@ class TestResolverSqlShape:
# 8 админов = ekb_districts с непустым geom ('не определён' без geom исключён).
sql = str(district_resolver._ADMIN_NAMES_SQL)
assert "geom IS NOT NULL" in sql
# ──────────────────────────────────────────────────────────────────────────────
# RELEASE-SAVEPOINT trap (#2464 cluster A, second-order): резолвер зовётся §22-
# билдерами (sales_series `_query_source_a/_b`, `_query_artificial_demand`) на общей
# сессии отчёта ПОД внешним SAVEPOINT. Если внутренний swallow (_admin_names /
# micro-lookup) съедает db-сбой БЕЗ своего savepoint, транзакция остаётся aborted;
# внешний savepoint выходит «нормально» и делает RELEASE SAVEPOINT, который САМ падает
# в aborted-tx (Postgres допускает лишь ROLLBACK / ROLLBACK TO SAVEPOINT) — внешний
# except ловит RELEASE-ошибку, но tx так и не откачена → отравление каскадит. Fix =
# savepoint В ТОЧКЕ ПЕРЕХВАТА. Тест воспроизводит ИМЕННО RELEASE-trap (не плоский
# swallow): `_ReleaseTrapSession` моделирует, что RELEASE в aborted-tx падает.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeResult:
"""Мини-результат db.execute() — покрывает `.scalars().all()` и `.mappings().first()`."""
def __init__(self, rows: list[Any]) -> None:
self._rows = list(rows)
def scalars(self) -> _FakeResult:
return self
def mappings(self) -> _FakeResult:
return self
def all(self) -> list[Any]:
return list(self._rows)
def first(self) -> Any:
return self._rows[0] if self._rows else None
class _ReleaseTrapSession:
"""Fake Session, моделирующая Postgres SAVEPOINT/RELEASE/ROLLBACK в aborted-tx.
- `execute()` на `fail_on_call`-м вызове бросает РОВНО раз и помечает tx aborted
(poisoned). Пока poisoned любой execute бросает 'current transaction is aborted'.
- `begin_nested()` моделирует SAVEPOINT:
ВХОД в poisoned-tx SAVEPOINT запрещён бросает (как реальный Postgres);
ВЫХОД с исключением ROLLBACK TO SAVEPOINT (снимает poison), пробрасывает;
ВЫХОД нормально RELEASE SAVEPOINT, и если tx poisoned RELEASE ПАДАЕТ
(это и есть RELEASE-trap; без внутреннего savepoint тест краснеет).
"""
def __init__(
self, *, fail_on_call: int = 1, results: dict[int, list[Any]] | None = None
) -> None:
self.poisoned = False
self.calls = 0
self.fail_on_call = fail_on_call
self._results = results or {}
def execute(self, *args: Any, **kwargs: Any) -> _FakeResult:
self.calls += 1
if self.poisoned:
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
if self.calls == self.fail_on_call:
self.poisoned = True
raise RuntimeError("simulated inner helper query failure")
return _FakeResult(self._results.get(self.calls, []))
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
if self.poisoned:
# SAVEPOINT запрещён в aborted-tx.
raise RuntimeError(
"current transaction is aborted, commands ignored until end of transaction block"
)
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — разрешён в aborted, снимает poison.
self.poisoned = False
raise
else:
if self.poisoned:
# RELEASE SAVEPOINT в aborted-tx ПАДАЕТ — тот самый trap.
raise RuntimeError(
"current transaction is aborted, "
"commands ignored until end of transaction block"
)
def _outer_savepoint_call(db: Any, fn: Callable[[], Any], *, fallback: Any) -> Any:
"""Зеркало `_run`/`_safe_call`: обернуть fn() во ВНЕШНИЙ savepoint (RELEASE на норм-выходе).
Именно этот RELEASE падает в aborted-tx, если внутренний helper отравил сессию без
своего savepoint возвращается `fallback` (сигнал, что trap сработал).
"""
try:
with db.begin_nested():
return fn()
except Exception:
return fallback
class TestReleaseSavepointTrap:
"""Внутренние swallow-сайты резолвера НЕ отравляют внешний SAVEPOINT (RELEASE-trap)."""
def test_admin_names_failure_survives_outer_release(self) -> None:
_reset_admin_cache()
db = _ReleaseTrapSession(fail_on_call=1) # admin-names lookup падает
result = _outer_savepoint_call(db, lambda: _admin_names(db), fallback="OUTER_FALLBACK")
# _admin_names вернул СВОЙ fallback (пустой frozenset) — внешний RELEASE прошёл,
# значит внутренний savepoint откатил сбой (иначе result был бы OUTER_FALLBACK).
assert result == frozenset()
# КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
db.execute("SELECT 1")
def test_micro_lookup_failure_survives_outer_release(self) -> None:
_reset_admin_cache()
# call 1 = admin-names (успех, "Кировский" распознан); call 2 = micro-lookup (падает).
db = _ReleaseTrapSession(fail_on_call=2, results={1: ["Кировский"]})
result = _outer_savepoint_call(
db,
lambda: resolve_objective_districts(db, "Кировский"),
fallback="OUTER_FALLBACK",
)
# micro-lookup проглочен → EKB-wide fallback (None), НЕ внешний OUTER_FALLBACK.
assert result is None
# КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
db.execute("SELECT 1")

View file

@ -37,7 +37,6 @@ from app.services.site_finder.future_supply import (
_monthly_absorption_units,
_months_between,
_months_of_pressure,
_query_supply_rows,
_round_or_none,
_saturating_index,
compute_future_supply_pressure,
@ -636,61 +635,3 @@ class TestComputeGraceful:
res = compute_future_supply_pressure(db, district=None)
assert isinstance(res, FutureSupplyPressure)
assert res.district is None
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
#
# db shared с caller (compute_future_supply_pressure в цепочке §22-форсайта /
# analyze). Раньше _query_supply_rows ловил db.execute-сбой в bare
# ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это отравляет
# db shared с caller: следующий db.execute на этой же сессии (напр.
# compute_market_metrics, вызываемый следом в compute_future_supply_pressure)
# падает "current transaction is aborted". Фикс — db.execute обёрнут в
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
# на той же mock-сессии.
class TestQuerySupplyRowsSavepoint:
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_query_supply_rows(db, {"district": "X", "names": ["X"]})
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []
def test_session_usable_after_failure(self) -> None:
"""db.execute сбоит один раз (supply-layers view), но db остаётся usable
для следующего вызова на этой же сессии (имитирует
_query_supply_rows compute_market_metrics в compute_future_supply_pressure)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestComputeFutureSupplyPressureSessionUsableAfterFailure:
"""End-to-end: view-запрос сбоит → SAVEPOINT (не bare rollback) → reused
compute_market_metrics (отдельный db.execute на той же сессии) не задет."""
def test_view_failure_does_not_poison_market_metrics_call(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("view gone")
with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)) as (mm, _rs):
res = compute_future_supply_pressure(db, district="Сбой")
assert db.begin_nested.call_count >= 1
# compute_market_metrics (reused) вызван несмотря на сбой view-запроса —
# сессия не отравлена (в реальном Postgres это была бы "aborted transaction").
assert mm.called
assert res.hidden_units == 0
assert res.index == 0.0

View file

@ -286,71 +286,6 @@ class TestComputeLocationIndices:
assert idx.raw_unit_velocity == 13.0
# ── 2b. infra/POI swallow site: session-poisoning regression (#2464 cluster A) ──
# _district_poi_score/_city_avg_poi_score (analytics_queries.py) do db.execute with no
# try/except of their own; compute_location_indices' bare `except Exception` used to
# swallow a failure WITHOUT rolling back. On real Postgres that leaves the transaction
# "aborted", poisoning every later db.execute on the SAME session — here that's the
# REMAINING districts in refresh_locations' pass-1 loop (all on one Session).
#
# Fix = plain db.rollback() (NOT begin_nested SAVEPOINT): compute_location_indices'
# only caller is refresh_locations, whose only caller (location_refresh.py Celery task)
# opens its OWN SessionLocal() — an owned, not request/report-shared, session. At this
# point in pass 1 no writes are pending yet (all UPSERTs happen later in pass 2), so
# rollback() has nothing to lose and doesn't orphan an outer SessionTransaction (unlike
# velocity.py:170). This is DIFFERENT from the pass-2 upsert loop below, which MUST use
# begin_nested (SAVEPOINT) — a plain rollback there would revert already-upserted rows
# from earlier districts in the same uncommitted transaction.
class TestInfraPoiRollbackRegression:
def test_infra_failure_rolls_back_and_falls_back_to_none(self) -> None:
db = MagicMock()
with (
patch(_MM, return_value=_metrics(0.5, 20.0)),
patch(_FSP, return_value=_fsp(0.4)),
patch(_DPOI, side_effect=RuntimeError("current transaction is aborted")),
patch(_CPOI, return_value=10.0),
):
idx = compute_location_indices(db, "Кировский")
# documented fallback: infra_index None, остальные индексы (isolated per-block)
# всё равно посчитаны.
assert idx.infra_index is None
assert idx.competition_index == 0.5
assert idx.future_supply_index == 0.4
db.rollback.assert_called_once()
# Pass-1 (read-only) — begin_nested здесь НЕ нужен (тот используется только
# в pass-2 upsert loop, где rollback() потерял бы предыдущие районы).
db.begin_nested.assert_not_called()
def test_session_usable_for_next_district_after_infra_failure(self) -> None:
# Симулирует refresh_locations pass 1: район 1 сбоит на infra, район 2 идёт
# следом на ТОЙ ЖЕ session. Без rollback (до фикса) реальный Postgres отдал бы
# "current transaction is aborted" на district 2 (session-poisoning, #2464).
db = MagicMock()
calls = {"n": 0}
def _dpoi_side_effect(_db: object, *, district_name: str) -> float:
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("db boom on district 1")
return 8.0
with (
patch(_MM, return_value=_metrics(0.5, 20.0)),
patch(_FSP, return_value=_fsp(0.4)),
patch(_DPOI, side_effect=_dpoi_side_effect),
patch(_CPOI, return_value=10.0),
):
idx1 = compute_location_indices(db, "Кировский")
idx2 = compute_location_indices(db, "Ленинский")
assert idx1.infra_index is None
db.rollback.assert_called_once()
# Второй район на той же сессии успешно считает infra (8/10 = 0.8) — session
# осталась usable после rollback (не отравлена).
assert idx2.infra_index == 0.8
# ── 3. refresh_locations: idempotent upsert + SAVEPOINT + counters ──────────────
@ -400,7 +335,9 @@ class TestRefreshLocations:
)
with (
patch(_ADMIN, return_value=districts),
patch("app.services.site_finder.locations.compute_location_indices", return_value=ns),
patch(
"app.services.site_finder.locations.compute_location_indices", return_value=ns
),
):
first = refresh_locations(_refresh_db())
second = refresh_locations(_refresh_db())
@ -532,7 +469,9 @@ class TestRefreshDemandCityRelative:
# ── Hard correctness edge case #1: ВСЕ velocities None → ВСЕ demand None ────
def test_all_velocities_none_yields_all_demand_none(self) -> None:
captured = self._run({"Кировский": None, "Ленинский": None, "Академический": None})
captured = self._run(
{"Кировский": None, "Ленинский": None, "Академический": None}
)
# Нет опоры → demand None у всех. НИКОГДА не подменяем 0.
for d in ("Кировский", "Ленинский", "Академический"):
assert captured[d]["demand_index"] is None
@ -541,7 +480,9 @@ class TestRefreshDemandCityRelative:
def test_mixed_none_and_present(self) -> None:
# Опора = MAX среди НЕ-None (= 200). Район без данных → demand None; районы с
# данными нормируются относительно 200, без учёта None в опоре.
captured = self._run({"Кировский": 200.0, "Ленинский": 50.0, "Академический": None})
captured = self._run(
{"Кировский": 200.0, "Ленинский": 50.0, "Академический": None}
)
assert captured["Академический"]["demand_index"] is None # нет выборки
assert captured["Кировский"]["demand_index"] == 1.0 # сам горячий (=опора)
assert captured["Ленинский"]["demand_index"] == 0.25 # 50 / 200
@ -561,6 +502,8 @@ class TestRefreshDemandCityRelative:
def test_all_equal_velocities_all_one(self) -> None:
# Все одинаково горячи → опора = их общее значение → 1.0 у каждого.
captured = self._run({"Кировский": 90.0, "Ленинский": 90.0, "Академический": 90.0})
captured = self._run(
{"Кировский": 90.0, "Ленинский": 90.0, "Академический": 90.0}
)
for d in ("Кировский", "Ленинский", "Академический"):
assert captured[d]["demand_index"] == 1.0

View file

@ -33,8 +33,6 @@ from app.services.site_finder.market_metrics import (
_months_of_supply,
_overstock_index,
_price_sensitivity,
_query_sales_window,
_query_stock,
_room_bucket,
_sell_through_pct,
compute_market_metrics,
@ -1012,161 +1010,3 @@ class TestOfferPriceTrendCompute:
assert d["delta_pct"] == 3.3
assert d["lots_latest"] == 12
assert d["complexes_covered"] == 1
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
#
# db shared с caller (compute_market_metrics в цепочке §22-форсайта / analyze).
# Раньше _query_stock/_query_sales_window/_price_sensitivity ловили db.execute-сбой
# в bare ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это
# отравляет db shared с caller: следующий db.execute на этой же сессии падает
# "current transaction is aborted". Фикс — db.execute (и, для _price_sensitivity,
# весь вызов _elasticity_coef) обёрнут в ``with db.begin_nested():``. MagicMock не
# эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful
# fallback при сбое, (2) что begin_nested() реально вызван, (3) что db остаётся
# usable для следующего вызова на той же mock-сессии.
class TestQueryStockSavepoint:
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
_query_stock(db, {"districts": ["X"]})
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_zero_counts(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
out = _query_stock(db, {"districts": ["X"]})
assert out == {
"n_lots": 0,
"n_sold": 0,
"n_available": 0,
"obj_count": 0,
"n_long_unsold": 0,
}
def test_session_usable_after_failure(self) -> None:
"""db.execute сбоит один раз (stock), но db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует
_query_stock _query_sales_window _price_sensitivity chain."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
out = _query_stock(db, {"districts": ["X"]})
assert out["n_lots"] == 0
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestQuerySalesWindowSavepoint:
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_query_sales_window(db, {"districts": ["X"]})
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
units, area, by_room = _query_sales_window(db, {"districts": ["X"]})
assert (units, area, by_room) == (0, 0.0, {})
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
units, _area, _by_room = _query_sales_window(db, {"districts": ["X"]})
assert units == 0
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestPriceSensitivitySavepoint:
"""_price_sensitivity делает db.execute КОСВЕННО (через _elasticity_coef) —
SAVEPOINT обёрнут вокруг всего вызова, а не голого db.execute."""
def test_uses_begin_nested_savepoint(self, _patch_resolver: MagicMock) -> None:
_patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
db = MagicMock()
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
_price_sensitivity(db, district="Автовокзал", window_months=6)
assert db.begin_nested.call_count == 1
def test_elasticity_failure_still_degrades_gracefully(self, _patch_resolver: MagicMock) -> None:
_patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
db = MagicMock()
with patch(_ELAST, side_effect=RuntimeError("simulated DB failure")):
value, source = _price_sensitivity(db, district="Автовокзал", window_months=6)
assert (value, source) == (None, None)
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self, _patch_resolver: MagicMock) -> None:
"""Сбой elasticity-цепочки не отравляет db — следующий вызов на этой же
сессии (например, offer-price-trend) отрабатывает нормально."""
_patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
db = MagicMock()
with patch(_ELAST, side_effect=RuntimeError("boom")):
_price_sensitivity(db, district="Автовокзал", window_months=6)
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestComputeMarketMetricsSessionUsableAfterFirstFailure:
"""End-to-end (via compute_market_metrics): stock-запрос сбоит → SAVEPOINT
(не bare rollback) sales-window запрос по-прежнему выполняется на той же
сессии (не блокируется "current transaction is aborted")."""
def test_stock_failure_does_not_poison_sales_window(self, _patch_resolver: MagicMock) -> None:
db = MagicMock()
sales_result = MagicMock()
sales_result.mappings.return_value.all.return_value = _FULL_SALES
db.execute.side_effect = [RuntimeError("boom on stock"), sales_result]
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
m = compute_market_metrics(db, district="Автовокзал", window_months=6)
assert db.begin_nested.call_count >= 1
assert m.n_lots == 0 # stock fallback (graceful, не crash)
assert m.confidence == "low"
# sales-window execute РЕАЛЬНО выполнился на той же db (2-й вызов не был
# заблокирован сбоем 1-го) — session осталась usable после stock-savepoint.
assert db.execute.call_count >= 2
class TestOfferPriceTrendSavepoint:
"""compute_offer_price_trend: db shared с caller (POST .../analyze / §22-отчёт).
db.execute обёрнут в begin_nested 8с-гвард QueryCanceled не отравляет сессию."""
def test_uses_begin_nested_savepoint(self) -> None:
db = _mock_offer_db([])
compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_and_no_bare_rollback(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("query canceled (8s guard)")
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert res.points == [] # документированный fallback
assert res.has_data is False
assert db.begin_nested.call_count == 1
db.rollback.assert_not_called() # никакого bare rollback на shared сессии
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("db down")
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert res.points == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]

View file

@ -1,107 +0,0 @@
"""Тесты parcel_okn_objects (#1066, #1159) — ОКН-объекты рядом с участком.
БД не дёргается: фейковая сессия с .execute().fetchall() (позиционные колонки).
Проверяем happy path, graceful на отсутствие WKT/таблицы, и SAVEPOINT-регрессию
(#2464 cluster A): db.execute обёрнут в begin_nested, сбой не отравляет сессию.
"""
from __future__ import annotations
import contextlib
from typing import Any
from sqlalchemy.exc import OperationalError
from app.services.site_finder.okn_lookup import parcel_okn_objects
_WKT = "POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))"
_OKN_ROW = (
"66-001", # source_id
"федеральный", # category
42, # distance_m
"Дом купца Иванова", # name
"ул. Ленина, 1", # address
)
class _FakeDB:
"""Фейковая сессия: execute().fetchall() (позиционные строки) + begin_nested()."""
def __init__(self, rows: list[tuple[Any, ...]] | Exception) -> None:
self._rows = rows
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
if isinstance(self._rows, Exception):
raise self._rows
class _Result:
def __init__(self, rows: list[tuple[Any, ...]]) -> None:
self._rows = rows
def fetchall(self) -> list[tuple[Any, ...]]:
return self._rows
return _Result(self._rows)
def test_returns_okn_objects() -> None:
"""Один ОКН-объект в радиусе → проброс полей."""
db = _FakeDB([_OKN_ROW])
out = parcel_okn_objects(db, _WKT)
assert len(out) == 1
r = out[0]
assert r["source_id"] == "66-001"
assert r["category"] == "федеральный"
assert r["distance_m"] == 42
assert r["name"] == "Дом купца Иванова"
assert r["address"] == "ул. Ленина, 1"
def test_empty_wkt_returns_empty() -> None:
"""parcel_wkt=None → [] без вызова DB."""
db = _FakeDB([_OKN_ROW])
assert parcel_okn_objects(db, None) == []
assert db.begin_nested_calls == 0
def test_no_objects_in_radius_returns_empty() -> None:
"""Нет объектов в радиусе → []."""
assert parcel_okn_objects(_FakeDB([]), _WKT) == []
def test_missing_table_is_graceful() -> None:
"""okn_objects ещё не задеплоена (OperationalError) → [] (analyze не падает)."""
db = _FakeDB(OperationalError("stmt", {}, Exception("relation okn_objects does not exist")))
assert parcel_okn_objects(db, _WKT) == []
def test_unexpected_exception_is_graceful() -> None:
"""Любая неожиданная ошибка БД → [] (analyze не падает)."""
db = _FakeDB(RuntimeError("connection refused"))
assert parcel_okn_objects(db, _WKT) == []
# ── #2464 cluster A: SAVEPOINT-регрессия (session-poisoning) ────────────────────
def test_uses_savepoint_and_session_reusable_after_failure() -> None:
"""db.execute обёрнут в begin_nested (SAVEPOINT) — сбой не отравляет сессию:
follow-up вызов на ТОЙ ЖЕ db-сессии успевает (не "current transaction is aborted")."""
db = _FakeDB(RuntimeError("connection refused"))
result = parcel_okn_objects(db, _WKT)
assert result == []
assert db.begin_nested_calls == 1
db._rows = [_OKN_ROW]
result2 = parcel_okn_objects(db, _WKT)
assert len(result2) == 1
assert result2[0]["source_id"] == "66-001"
assert db.begin_nested_calls == 2

View file

@ -23,12 +23,7 @@ from typing import Any
from unittest.mock import MagicMock
from app.services.site_finder import own_portfolio
from app.services.site_finder.own_portfolio import (
OwnProject,
_query_current,
_query_future,
get_own_portfolio,
)
from app.services.site_finder.own_portfolio import OwnProject, get_own_portfolio
def _row(data: dict[str, Any]) -> MagicMock:
@ -277,81 +272,3 @@ def test_future_query_failure_returns_empty_gracefully(monkeypatch) -> None:
out = get_own_portfolio(db)
assert out == []
# ── SAVEPOINT regression (#2464 cluster A) ───────────────────────────────────────
#
# db shared с caller (get_own_portfolio в цепочке §25.3 / analyze). Раньше
# _query_current/_query_future ловили db.execute-сбой в bare ``except Exception``
# без SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
# следующий db.execute на этой же сессии падает "current transaction is aborted".
# Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не эмулирует
# реальный aborted-transaction Postgres — тесты проверяют (1) graceful fallback при
# сбое, (2) что begin_nested() реально вызван вокруг db.execute, (3) что db остаётся
# usable для следующего вызова на той же mock-сессии.
class TestQueryCurrentSavepoint:
def test_uses_begin_nested_savepoint(self, monkeypatch) -> None:
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.return_value = _result([])
_query_current(db)
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_list(self, monkeypatch) -> None:
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert _query_current(db) == []
def test_session_usable_after_failure(self, monkeypatch) -> None:
"""db.execute сбоит один раз, но db остаётся usable для следующего вызова
на этой же сессии (имитирует _query_current _query_future в
get_own_portfolio на одной shared Session)."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _query_current(db) == []
db.execute.side_effect = None
next_result = _result([{"marker": "ok"}])
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestQueryFutureSavepoint:
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
_query_future(db)
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert _query_future(db) == []
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _query_future(db) == []
db.execute.side_effect = None
next_result = _result([{"marker": "ok"}])
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_get_own_portfolio_current_failure_savepoint_isolated(monkeypatch) -> None:
"""End-to-end (via get_own_portfolio): current db.execute сбоит → SAVEPOINT
(не bare rollback) future по-прежнему считается на той же сессии."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = [RuntimeError("boom on domrf"), _result([_future_row()])]
out = get_own_portfolio(db)
assert db.begin_nested.call_count >= 1
assert len(out) == 1
assert out[0].source == "future"

View file

@ -8,7 +8,6 @@ from __future__ import annotations
import importlib
import pathlib
from contextlib import contextmanager
from typing import Any
import pytest
@ -212,10 +211,6 @@ class _Result:
class _FakeDB:
"""begin_nested — реальный (не-swallow) CM: зеркалит SAVEPOINT-фикс #2464
cluster A finding 7 (parcel_pat_subzones); исключение из db.execute должно
долетать до except в проде, не глушиться на выходе из ``with``."""
def __init__(
self,
rows: list[dict[str, Any]] | None = None,
@ -223,14 +218,8 @@ class _FakeDB:
) -> None:
self._rows = rows or []
self._raise = raise_exc
self.execute_calls = 0
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
self.execute_calls += 1
if self._raise is not None:
raise self._raise
return _Result(self._rows)
@ -281,27 +270,6 @@ def test_pat_lookup_graceful_programming_error() -> None:
assert result == []
def test_pat_lookup_db_error_leaves_session_usable_for_next_query() -> None:
"""#2464 cluster A finding 7: db shared с caller (analyze_parcel) — сбой здесь
не должен отравлять session для persist_analysis_run и прочих db.execute дальше
по /analyze. Симулируем: execute кидает ОДИН раз, затем следующий (не связанный)
execute на ТОЙ ЖЕ session отрабатывает нормально."""
from sqlalchemy.exc import OperationalError
db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("relation does not exist")))
result = parcel_pat_subzones(db, _WKT)
assert result == []
assert db.execute_calls == 1
# Сессия осталась usable: сбрасываем "raise" (имитируя, что savepoint откатился
# и не поломала outer-транзакцию) и проверяем, что db.execute снова отрабатывает.
db._raise = None
db._rows = [{"subzone_no": 3, "name": "N", "restriction": "R", "aerodrome": "A"}]
next_result = parcel_pat_subzones(db, _WKT)
assert next_result == [{"subzone_no": 3, "name": "N", "restriction": "R", "aerodrome": "A"}]
assert db.execute_calls == 2
# ── Резолв пути к JSON (#1150) ──────────────────────────────────────────────

View file

@ -7,13 +7,11 @@ happy path + graceful-фоллбэки (здание не найдено / не
from __future__ import annotations
import contextlib
from typing import Any
from unittest.mock import MagicMock, patch
from app.schemas.nspd_bulk import ObjectsListing
from app.services.scrapers.nspd_client import NspdLiteWafError
from app.services.site_finder.premises_lookup import resolve_cad_for_domrf
MODULE = "app.services.site_finder.premises_lookup"
@ -75,7 +73,9 @@ def test_get_building_premises_snake_case_objdoc() -> None:
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)),
):
mock_client_cls.return_value.search_by_cad.return_value = _search_result({"objdoc_id": 777})
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
{"objdoc_id": 777}
)
out = get_building_premises("66:41:0106036:183")
assert out is listing
@ -148,60 +148,3 @@ def test_get_building_premises_waf_returns_none() -> None:
assert out is None
mock_run.assert_not_called()
# ── #2464 cluster A: resolve_cad_for_domrf SAVEPOINT-регрессия ──────────────────
# db.execute внутри resolve_cad_for_domrf раньше глотал сбой БЕЗ SAVEPOINT/rollback —
# на реальном Postgres это отравляет shared session (lazy-эндпоинт может дёргать
# resolve_cad_for_domrf на несколько конкурентов подряд, ОДНА и та же db-сессия):
# следующий db.execute падал "current transaction is aborted". Фикс: db.execute
# обёрнут в db.begin_nested().
class _ResolveCadFakeDB:
"""Фейковая сессия: execute().mappings().first() + begin_nested()."""
def __init__(self, row: dict[str, Any] | Exception | None) -> None:
self._row = row
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any]) -> Any:
if isinstance(self._row, Exception):
raise self._row
row = self._row
class _Result:
def mappings(self) -> _Result:
return self
def first(self) -> dict[str, Any] | None:
return row
return _Result()
def test_resolve_cad_for_domrf_db_failure_returns_none() -> None:
"""DB-сбой → None (документированный fallback), analyze не падает."""
db = _ResolveCadFakeDB(RuntimeError("connection refused"))
assert resolve_cad_for_domrf(db, lat=56.8, lon=60.6) is None # type: ignore[arg-type]
def test_resolve_cad_for_domrf_uses_savepoint_and_session_reusable_after_failure() -> None:
"""begin_nested обёрнут вокруг db.execute — сбой не отравляет сессию: follow-up
вызов resolve_cad_for_domrf на ТОЙ ЖЕ db-сессии успевает."""
db = _ResolveCadFakeDB(RuntimeError("boom"))
out = resolve_cad_for_domrf(db, lat=56.8, lon=60.6) # type: ignore[arg-type]
assert out is None
assert db.begin_nested_calls == 1
db._row = {"cad_num": "66:41:0106036:183", "objdoc_id": 40995027, "distance_m": 12.5}
out2 = resolve_cad_for_domrf(db, lat=56.8, lon=60.6) # type: ignore[arg-type]
assert out2 is not None
assert out2.cad_num == "66:41:0106036:183"
assert db.begin_nested_calls == 2

View file

@ -1,224 +0,0 @@
"""SAVEPOINT-регрессия для quarter_dump_lookup.py (#2464 cluster A).
_get_zoning / _get_zouit_overlaps / _get_cad_zouit_overlaps / _get_engineering_nearby /
_get_risk_zones / _get_opportunity_parcels / _get_red_lines / _get_engineering_structures_
by_boundary когда-то ловили db.execute-сбой в bare ``except Exception`` (или
``except (OperationalError, ProgrammingError, DataError)``) БЕЗ SAVEPOINT/rollback на
реальном Postgres это отравляет db shared с caller'ом (`get_quarter_dump_data(db, ...)` →
вызывается из `/analyze` эндпоинта, `Depends(get_db)`): следующий `db.execute` в этом же
запросе (сиблинг `_get_*` хелпер, либо downstream `get_or_fetch_zone_regulation`, либо
`begin_nested` "9f cad_parcels" блок) падает с "current transaction is aborted" /
PendingRollbackError. Референс-корректный сиблинг `connection_capacity_lookup.py::
_query_nearby_network_zones` (уже был обёрнут в `db.begin_nested()`).
MagicMock-сессия не эмулирует реальный aborted-transaction Postgres тесты проверяют
(1) graceful fallback при сбое (документированный контракт функции не меняется),
(2) что begin_nested() реально вызван вокруг db.execute (SAVEPOINT-обёртка присутствует
в коде), (3) что db остаётся usable для следующего вызова на той же mock-сессии ошибка
не проглатывает control flow и не роняет остальную сессию.
Покрывает представительное подмножество (per #2464 audit): _get_zoning, _get_zouit_overlaps,
_get_engineering_structures_by_boundary и _get_zouit_engineering_overlaps (последние два
2 db.execute на функцию, каждый под своей savepoint, epic #2445 A1 honest-count + row-fetch).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from sqlalchemy.exc import OperationalError
from app.services.site_finder.quarter_dump_lookup import (
_get_engineering_structures_by_boundary,
_get_zoning,
_get_zouit_engineering_overlaps,
_get_zouit_overlaps,
)
def _op_error(msg: str) -> OperationalError:
"""OperationalError фабрика — единственный тип, который ловит except
(OperationalError, ProgrammingError, DataError) в _get_engineering_structures_
by_boundary (bare RuntimeError туда НЕ провалится это намеренно узкий except)."""
return OperationalError(msg, {}, Exception(msg))
_QUARTER = "66:41:0204016"
_WKT = "POLYGON((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))"
class TestGetZoningSavepoint:
"""_get_zoning: db.execute().first(), except Exception → return None."""
def test_query_failure_degrades_to_none(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("nspd_quarter_dumps query failed")
assert _get_zoning(db, _QUARTER, _WKT) is None
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.first.return_value = None
_get_zoning(db, _QUARTER, _WKT)
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self) -> None:
"""db.execute сбоит один раз, но db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует sibling _get_*
хелпер, вызванный сразу после в get_quarter_dump_data()."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
result = _get_zoning(db, _QUARTER, _WKT)
assert result is None
# Следующий вызов на том же db.execute отрабатывает (сессия не отравлена).
db.execute.side_effect = None
next_result = MagicMock()
next_result.first.return_value = ("marker",)
db.execute.return_value = next_result
assert db.execute("SELECT 1").first() == ("marker",)
class TestGetZouitOverlapsSavepoint:
"""_get_zouit_overlaps: db.execute().fetchall(), except Exception → return []."""
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("nspd zouit query failed")
result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3})
assert result == []
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.fetchall.return_value = []
_get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3})
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self) -> None:
"""Сбой на dump-path (zouit_count>0) не отравляет сессию для следующего
sibling-запроса (например _get_engineering_nearby сразу после)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3})
assert result == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.fetchall.return_value = [("marker",)]
db.execute.return_value = next_result
assert db.execute("SELECT 1").fetchall() == [("marker",)]
class TestGetEngineeringStructuresByBoundarySavepoint:
"""_get_engineering_structures_by_boundary: 2 db.execute (COUNT + row-fetch) в одном
try, except (OperationalError, ProgrammingError, DataError) return ([], 0).
Каждый db.execute нуждается в СОБСТВЕННОЙ savepoint (RELEASE SAVEPOINT trap): если
COUNT сбоит, ROLLBACK TO SAVEPOINT должен откатить только COUNT, не всю функцию
и наоборот, если COUNT прошёл, а row-fetch сбоил, savepoint row-fetch'а изолирует
именно его сбой.
"""
def test_count_query_failure_degrades_to_empty(self) -> None:
db = MagicMock()
db.execute.side_effect = _op_error("COUNT(*) query failed")
items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert items == []
assert total == 0
def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None:
"""COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 42
db.execute.side_effect = [count_result, _op_error("row-fetch failed")]
items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert items == []
assert total == 0
def test_uses_two_separate_begin_nested_savepoints(self) -> None:
"""COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested)."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 0
rows_result = MagicMock()
rows_result.fetchall.return_value = []
db.execute.side_effect = [count_result, rows_result]
_get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert db.begin_nested.call_count == 2
def test_session_usable_after_count_failure(self) -> None:
"""COUNT сбоит → db остаётся usable для следующего sibling-запроса
(например _get_zouit_engineering_overlaps сразу после в get_connection_points)."""
db = MagicMock()
db.execute.side_effect = _op_error("boom")
items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert (items, total) == ([], 0)
db.execute.side_effect = None
next_result = MagicMock()
next_result.fetchall.return_value = [("marker",)]
db.execute.return_value = next_result
assert db.execute("SELECT 1").fetchall() == [("marker",)]
class TestGetZouitEngineeringOverlapsSavepoint:
"""_get_zouit_engineering_overlaps: 2 db.execute (COUNT + row-fetch) в одном try,
except (OperationalError, ProgrammingError, DataError) return ([], 0).
Идентичная 2-запросная форма _get_engineering_structures_by_boundary вызывается
сразу после него в get_connection_points() на той же shared session, поэтому каждый
db.execute обёрнут в свою savepoint (RELEASE SAVEPOINT trap, epic #2445 A1).
"""
def test_count_query_failure_degrades_to_empty(self) -> None:
db = MagicMock()
db.execute.side_effect = _op_error("COUNT(*) query failed")
items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert items == []
assert total == 0
def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None:
"""COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 137
db.execute.side_effect = [count_result, _op_error("row-fetch failed")]
items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert items == []
assert total == 0
def test_uses_two_separate_begin_nested_savepoints(self) -> None:
"""COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested)."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 0
rows_result = MagicMock()
rows_result.fetchall.return_value = []
db.execute.side_effect = [count_result, rows_result]
_get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert db.begin_nested.call_count == 2
def test_session_usable_after_failure(self) -> None:
"""Сбой не отравляет сессию — get_connection_points после этого хелпера ещё
строит summary и (в /analyze-контексте) сессия shared с downstream-запросами."""
db = MagicMock()
db.execute.side_effect = _op_error("boom")
items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert (items, total) == ([], 0)
db.execute.side_effect = None
next_result = MagicMock()
next_result.fetchall.return_value = [("marker",)]
db.execute.return_value = next_result
assert db.execute("SELECT 1").fetchall() == [("marker",)]

View file

@ -6,7 +6,6 @@
from __future__ import annotations
import contextlib
from typing import Any
from sqlalchemy.exc import ProgrammingError
@ -58,13 +57,6 @@ class _Result:
class _FakeDB:
def __init__(self, rows: list[_Row] | Exception) -> None:
self._rows = rows
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
if isinstance(self._rows, Exception):
@ -152,18 +144,3 @@ def test_gate_empty_wkt_returns_empty() -> None:
db = _FakeDB([])
gate = parcel_riasurt_gate(db, None, "66:35:0101001:5") # type: ignore[arg-type]
assert gate["applicable"] is False
def test_gate_uses_savepoint_and_session_reusable_after_failure() -> None:
"""#2464 cluster A: db.execute обёрнут в begin_nested (SAVEPOINT) — сбой не
отравляет сессию. Follow-up вызов на ТОЙ ЖЕ db-инстансе успевает."""
db = _FakeDB(ProgrammingError("stmt", {}, Exception("no table")))
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
assert gate == riasurt_lookup.RIASURT_EMPTY_GATE
assert db.begin_nested_calls == 1
# follow-up на той же сессии (таблица теперь "доступна").
db._rows = []
gate2 = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
assert gate2["applicable"] is True
assert db.begin_nested_calls == 2

View file

@ -379,51 +379,6 @@ class TestLayer2Hidden:
sql = _executed_sql(db)
assert "free_flats IS NOT NULL" in sql
def test_sql_excludes_null_flat_count_from_both_filters(self) -> None:
# #2445-A3 (audit fix): flat_count IS NULL — заполняется НЕЗАВИСИМО от free_flats
# (м.124 "NULL = не заполнено"). Раньше GREATEST(0, COALESCE(flat_count,0) -
# free_flats) молча давало 0 hidden для объекта с генуинно неизвестным
# flat_count, И этот объект всё равно засчитывался в n_with_free_flats (гейт был
# только free_flats IS NOT NULL) → раздутый coverage-деноминатор. Fix гейтит ОБЕ
# агрегатные колонки (COUNT-денoминатор и SUM) на flat_count IS NOT NULL тоже —
# зеркалит _hidden_units() (возвращает None при flat_count IS None) и sibling
# convention 60_v_zk_rosreestr_velocity.sql / 64_v_zk_rosreestr_velocity.sql
# ("o.flat_count IS NOT NULL AND o.flat_count > 0" на этой же таблице).
db = _mock_db([])
compute_layer2_hidden(db, district="X")
sql = _executed_sql(db)
norm = " ".join(sql.split())
# Оба FILTER-предложения (n_with_free_flats COUNT и hidden_units SUM) должны
# гейтить flat_count IS NOT NULL наравне с free_flats IS NOT NULL.
assert (
norm.count("flat_count IS NOT NULL") == 2
), f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
assert (
norm.count("free_flats IS NOT NULL") == 2
), f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
def test_row_with_null_flat_count_and_positive_free_flats_excluded_end_to_end(
self,
) -> None:
# End-to-end (mocked-row) mirror of test_none_flat_count_returns_none, but at the
# SQL-aggregate boundary: simulate what the CORRECTED SQL would return for a
# district containing exactly one object with free_flats=5 but flat_count IS NULL
# (partially-backfilled snapshot). That object must be excluded from BOTH
# n_with_free_flats (coverage numerator) and hidden_units (SUM) by the SQL itself
# — so the row the Python layer receives already reflects n_with=0, hidden=0 for
# a district containing only that one ambiguous object.
rows = [
{
"district_name": "X",
"n_objects_total": 1, # the one flat_count-NULL object still counts here
"n_with_free_flats": 0, # excluded: flat_count IS NULL despite free_flats=5
"hidden_units": 0, # excluded from SUM too — NOT GREATEST(0, -5) miscounted
}
]
db = _mock_db(rows)
# n_with <= 0 → whole row skipped (no fabricated "0 hidden" district row).
assert compute_layer2_hidden(db, district="X") == []
def test_sql_dedups_latest_snapshot(self) -> None:
# КРИТИЧНО: domrf_kn_objects = UNIQUE(obj_id, snapshot_date), накапливает снапшоты.
# Без DISTINCT ON (obj_id) + snapshot_date DESC наивный SUM умножил бы hidden на
@ -704,25 +659,3 @@ class TestComputeAllLayers:
db.execute.side_effect = [l1_fail, l2_ok, l3_ok]
out = compute_all_layers(db, district="A")
assert out == []
def test_l1_failure_uses_savepoint_not_bare_rollback(self) -> None:
"""#2464 cluster A finding 5: _safe_rows оборачивает db.execute в begin_nested()
(не голый db.rollback()) L1 падает, но L2/L3 execute на ТОЙ ЖЕ Session должны
быть достижимы (session не отравлена/не orphan'ена outer-транзакция)."""
db = MagicMock()
l1_fail = RuntimeError("l1 down")
l2_ok = MagicMock()
l2_ok.mappings.return_value.all.return_value = [
{"district_name": "A", "n_objects_total": 1, "n_with_free_flats": 1, "hidden_units": 1}
]
l3_ok = MagicMock()
l3_ok.mappings.return_value.all.return_value = []
db.execute.side_effect = [l1_fail, l2_ok, l3_ok]
out = compute_all_layers(db, district="A")
# L1 деградировал в [], но L2 (следующий вызов на этом же db) успешно вернул строку —
# begin_nested() внутри _safe_rows не даёт сбою L1 отравить L2/L3.
assert db.execute.call_count == 3
assert db.begin_nested.call_count == 3 # SAVEPOINT на каждый из L1/L2/L3
assert any(r.layer == 2 for r in out)

View file

@ -1,380 +0,0 @@
"""#2464 cluster E — domrf_kn_objects snapshot dedup regression tests.
domrf_kn_objects retains MULTIPLE historical rows per obj_id (one per
snapshot_date, UNIQUE(obj_id, snapshot_date), no retention). Prod measurement:
4090 rows / 482 distinct obj_id for site_status='Строящиеся' (8.49x inflation).
Three read-side queries counted/returned every retained snapshot instead of the
latest one per obj_id:
- _active_competitors_count (via its _q closure)
- developer_portfolio
- _competitors_two_dim (its `active` CTE)
All fixed with the established repo dedup pattern: DISTINCT ON (obj_id) ...
ORDER BY obj_id, snapshot_date DESC NULLS LAST (sibling: _L3_FUTURE_SQL #1212 in
app/services/site_finder/supply_layers.py, cmp_rows/latest_obj CTE in
analytics_queries.recommend_mix).
VOLATILE-FILTER PLACEMENT (authoritative prod measurement, coordinator
2026-07-08 per-obj_id variance across snapshots): dev_id=0, region_cd=0
(STABLE) vs district_name=1, obj_class=29, site_status=11 (VOLATILE). Volatile
predicates MUST be applied to the true-latest row (outer/downstream WHERE), NOT
pre-filtered inside the DISTINCT ON CTE else DISTINCT ON returns "the latest
snapshot that still MATCHED the filter", not the object's true latest snapshot
(a ЖК that sold out / changed class / moved districts would be miscounted by a
stale snapshot). Only the stable region_cd stays inside the CTE as the dedup
partition-scope. dev_id (developer_portfolio) is likewise stable kept in-CTE.
Mock-based mirrors the SQL-shape assertion convention used in
tests/services/site_finder/test_supply_layers.py (TestLayer2Hidden.
test_sql_dedups_latest_snapshot) since there's no SQLite stand-in for
Postgres-only `DISTINCT ON` syntax and no throwaway-schema DML fixture in this
repo's integration-test conventions (tests/integration/conftest.py is
EXPLAIN-only / read-only against a real tunnel DB safe for plan-checks, not
for INSERT fixtures). A second class (`TestDedupAlgorithmSpec`) hand-replicates
the exact dedup+filter semantics the SQL implements in pure Python and asserts
the two behaviors the epic calls out explicitly: multi-snapshot obj_id counted
once, and a sold-out object with only an OLD 'Строящиеся' snapshot excluded.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from app.services.analytics_queries import _active_competitors_count, developer_portfolio
# ── shared helpers (mirrors test_supply_layers.py _mock_db/_executed_sql) ─────
def _mock_scalar_db(value: int) -> MagicMock:
"""Session stand-in: every db.execute(...).scalar() returns `value`."""
db = MagicMock()
db.execute.return_value.scalar.return_value = value
return db
def _mock_mapping_db(rows: list[dict[str, Any]]) -> MagicMock:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = rows
return db
def _executed_sql(db: MagicMock, call_index: int = 0) -> str:
args, _kwargs = db.execute.call_args_list[call_index]
return str(args[0])
def _executed_params(db: MagicMock, call_index: int = 0) -> dict:
args, _kwargs = db.execute.call_args_list[call_index]
return args[1]
def _split_latest_cte(sql: str) -> tuple[str, str]:
"""Split whitespace-normalized SQL into (CTE body, outer body).
CTE body = `WITH latest AS ( ... )` up to the `)` that closes the CTE,
which is the first `)` after the ORDER BY feeding DISTINCT ON. Outer body =
everything after. Lets tests assert WHICH side a predicate lives on.
"""
norm = " ".join(sql.split())
cte_open = norm.index("WITH latest AS (")
order_by_idx = norm.index("ORDER BY obj_id, snapshot_date DESC NULLS LAST")
cte_close = norm.index(")", order_by_idx)
return norm[cte_open:cte_close], norm[cte_close:]
# ── _active_competitors_count SQL shape ────────────────────────────────────
class TestActiveCompetitorsCountSqlShape:
def test_dedups_via_distinct_on_obj_id_latest_snapshot(self) -> None:
# n never reaches the >=2 early-return threshold with scalar()==0, so
# all 3 cascade tiers execute — call_index 0 is tier1 (district+class).
db = _mock_scalar_db(0)
_active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
sql = _executed_sql(db, 0)
norm = " ".join(sql.split())
assert "DISTINCT ON (obj_id)" in norm
assert "ORDER BY obj_id, snapshot_date DESC NULLS LAST" in norm
def test_site_status_filter_is_volatile_applied_after_distinct_on(self) -> None:
# Regression guard for the exact bug shape #2464 warns about: a
# sold-out object whose ONLY 'Строящиеся' row is an old snapshot must
# not be counted. That requires site_status to be filtered on the
# OUTER (post-DISTINCT-ON) query, never inside the `latest` CTE's
# WHERE — else DISTINCT ON would pick "the latest snapshot that still
# matched site_status='Строящиеся'" instead of the object's true
# latest snapshot. (Prod per-obj_id variance: site_status=11.)
db = _mock_scalar_db(0)
_active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
cte_body, outer_body = _split_latest_cte(_executed_sql(db, 0))
# site_status legitimately appears in the CTE's SELECT list (it's the
# column DISTINCT ON needs to expose) -- what must NOT appear is a
# filter predicate on it inside the CTE's WHERE.
assert (
"site_status = 'Строящиеся'" not in cte_body
), f"site_status must not pre-filter the DISTINCT ON CTE (volatile field):\n{cte_body}"
assert "site_status = 'Строящиеся'" in outer_body
def test_district_and_class_are_volatile_applied_after_distinct_on(self) -> None:
# Companion to the site_status guard: authoritative prod measurement
# (coordinator 2026-07-08) — per-obj_id variance across snapshots is
# district_name=1 and obj_class=29 (NON-zero → VOLATILE), unlike
# dev_id/region_cd (0 → stable). So district_name/obj_class predicates
# must ALSO be applied on the true-latest row (outer WHERE), never
# pre-filtered inside the DISTINCT ON CTE — else a ЖК that changed
# class/district gets counted by a stale snapshot. Tier-1
# (district+class) is call_index 0 when target_class is given.
db = _mock_scalar_db(0)
_active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
cte_body, outer_body = _split_latest_cte(_executed_sql(db, 0))
# CTE WHERE must scope on ONLY the stable region_cd — no volatile predicate.
assert (
"district_name = :dn" not in cte_body
), f"district_name (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
assert (
"COALESCE(obj_class, obj_class_fallback) = :cls" not in cte_body
), f"obj_class (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
# Both live in the outer WHERE, applied to the deduped true-latest row.
assert "district_name = :dn" in outer_body
assert "COALESCE(obj_class, obj_class_fallback) = :cls" in outer_body
def test_cte_where_scopes_only_stable_region_cd(self) -> None:
# Belt-and-suspenders: the DISTINCT ON CTE's WHERE partition-scope is
# exactly `region_cd = :rc` and nothing else (all volatile predicates
# relocated). Guards against a future re-introduction of a volatile
# pre-filter that would silently pick a stale-but-matching snapshot.
db = _mock_scalar_db(0)
_active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
cte_body, _outer = _split_latest_cte(_executed_sql(db, 0))
# extract the CTE's WHERE clause (between WHERE and the feeding ORDER BY,
# which _split_latest_cte leaves at the tail of the CTE body).
where_start = cte_body.index("WHERE ") + len("WHERE ")
where_end = cte_body.index("ORDER BY", where_start)
where_clause = cte_body[where_start:where_end].strip()
assert (
where_clause == "region_cd = :rc"
), f"CTE WHERE must scope on ONLY stable region_cd, got: {where_clause!r}"
def test_no_double_colon_cast(self) -> None:
import re
db = _mock_scalar_db(0)
_active_competitors_count(db, region_code=66, district_name="Ленинский", target_class=None)
sql = _executed_sql(db, 0)
assert re.search(r":[a-z_]+::[a-z]", sql) is None
def test_cascade_still_stops_early_when_tier_hits_threshold(self) -> None:
# Sanity: fallback cascade logic (n>=2 -> stop) is untouched by the dedup fix.
db = _mock_scalar_db(5)
n, scope = _active_competitors_count(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
assert n == 5
assert scope == "district+class"
assert db.execute.call_count == 1
# ── developer_portfolio SQL shape ──────────────────────────────────────────
class TestDeveloperPortfolioSqlShape:
def test_dedups_via_distinct_on_obj_id_latest_snapshot(self) -> None:
db = _mock_mapping_db([])
developer_portfolio(db, "6208_0")
sql = _executed_sql(db, 0)
norm = " ".join(sql.split())
assert "DISTINCT ON (obj_id)" in norm
assert "ORDER BY obj_id, snapshot_date DESC NULLS LAST" in norm
def test_preserves_final_ready_dt_ordering(self) -> None:
# DISTINCT ON's own ORDER BY must start with (obj_id, snapshot_date) —
# the caller-facing ready_dt ordering has to live in the OUTER SELECT.
db = _mock_mapping_db([])
developer_portfolio(db, "6208_0")
sql = _executed_sql(db, 0)
norm = " ".join(sql.split())
assert norm.rstrip().endswith("ORDER BY ready_dt DESC NULLS LAST")
def test_dev_id_filter_unchanged(self) -> None:
db = _mock_mapping_db([])
developer_portfolio(db, "6208_0")
params = _executed_params(db, 0)
assert params == {"dev": "6208_0"}
def test_returned_columns_unchanged(self) -> None:
# Fix must not add/drop columns from the caller-facing shape.
rows = [
{
"obj_id": 1,
"comm_name": "ЖК Тест",
"addr": "ул. Тест, 1",
"region_cd": 66,
"flat_count": 100,
"square_living": 5000.0,
"ready_dt": None,
"obj_class": "Комфорт",
"escrow": True,
"problem_flag": None,
"latitude": 56.8,
"longitude": 60.6,
"is_ekb": True,
}
]
db = _mock_mapping_db(rows)
out = developer_portfolio(db, "6208_0")
assert len(out) == 1
assert set(out[0].keys()) == {
"obj_id",
"comm_name",
"addr",
"region_cd",
"flat_count",
"square_living",
"ready_dt",
"obj_class",
"escrow",
"problem_flag",
"lat",
"lon",
"is_ekb",
}
# ── _competitors_two_dim `active`/`latest` CTE shape ───────────────────────
def _mock_two_dim_db(radius_n: int, district_only_n: int) -> MagicMock:
"""Session stand-in for _competitors_two_dim: call 0 = centroid lookup
(returns a non-null WKT so the main query runs), call 1 = the radius/
district aggregate. radius_n/district_only_n chosen so total_weighted >= 1
keeps execution off the single-dim fallback path (which would add calls)."""
db = MagicMock()
centroid_res = MagicMock()
centroid_res.mappings.return_value.first.return_value = {"centroid_wkt": "POINT(60.6 56.8)"}
main_res = MagicMock()
main_res.mappings.return_value.first.return_value = {
"radius_n": radius_n,
"district_only_n": district_only_n,
}
db.execute.side_effect = [centroid_res, main_res]
return db
class TestCompetitorsTwoDimActiveCte:
"""#2464 sibling: _competitors_two_dim's `active` CTE had site_status /
district_name / obj_class ALL inside its DISTINCT ON same latest-matching-
not-latest bug. Fix dedups to true-latest per obj_id first (region_cd-only
CTE scope), then applies the volatile predicates."""
def _main_sql(self, db: MagicMock) -> str:
# call_index 1 is the radius/district aggregate (0 is the centroid lookup).
return " ".join(_executed_sql(db, 1).split())
def test_dedups_via_distinct_on_before_volatile_filters(self) -> None:
from app.services.analytics_queries import _competitors_two_dim
db = _mock_two_dim_db(radius_n=3, district_only_n=0)
_competitors_two_dim(db, region_code=66, district_name="Ленинский", target_class="Комфорт")
sql = self._main_sql(db)
assert "DISTINCT ON (obj_id)" in sql
assert "ORDER BY obj_id, snapshot_date DESC NULLS LAST" in sql
def test_volatile_filters_are_post_distinct_on(self) -> None:
from app.services.analytics_queries import _competitors_two_dim
db = _mock_two_dim_db(radius_n=3, district_only_n=0)
_competitors_two_dim(db, region_code=66, district_name="Ленинский", target_class="Комфорт")
sql = self._main_sql(db)
# Split the DISTINCT ON CTE (`latest`) from everything downstream. Its
# WHERE must scope on ONLY stable region_cd; site_status/district_name/
# obj_class predicates live in the downstream `active` CTE.
latest_open = sql.index("WITH latest AS (")
order_by_idx = sql.index("ORDER BY obj_id, snapshot_date DESC NULLS LAST")
latest_close = sql.index(")", order_by_idx)
latest_body = sql[latest_open:latest_close]
downstream = sql[latest_close:]
assert "site_status = 'Строящиеся'" not in latest_body
assert "district_name = :dn" not in latest_body
assert "COALESCE(obj_class, obj_class_fallback) = :cls" not in latest_body
assert "site_status = 'Строящиеся'" in downstream
assert "district_name = :dn" in downstream
assert "COALESCE(obj_class, obj_class_fallback) = :cls" in downstream
def test_output_shape_preserved(self) -> None:
from app.services.analytics_queries import _competitors_two_dim
db = _mock_two_dim_db(radius_n=3, district_only_n=2)
radius_n, district_only_n, total_weighted, scope = _competitors_two_dim(
db, region_code=66, district_name="Ленинский", target_class="Комфорт"
)
assert radius_n == 3
assert district_only_n == 2
# 3*1.0 + 2*0.6 = 4.2
assert total_weighted == 4.2
assert scope == "district_2d"
# ── Pure-Python spec replica: exact dedup + volatile-filter algorithm ─────
#
# MagicMock can't execute real SQL (and DISTINCT ON has no SQLite equivalent),
# so this class locks in the ALGORITHM the SQL is required to implement:
# "take the row with MAX(snapshot_date) per obj_id, THEN filter on that row's
# site_status" — as opposed to the buggy "filter rows on site_status, then
# take MAX(snapshot_date) among survivors". It's a spec/regression guard, not
# a live-DB execution test (see module docstring for why).
def _latest_snapshot_active_count(rows: list[dict[str, Any]]) -> int:
"""Reference implementation of what the fixed `_q` SQL computes."""
latest_by_obj: dict[int, dict[str, Any]] = {}
for row in rows:
obj_id = row["obj_id"]
cur = latest_by_obj.get(obj_id)
if cur is None or (row["snapshot_date"] or "") > (cur["snapshot_date"] or ""):
latest_by_obj[obj_id] = row
return sum(1 for r in latest_by_obj.values() if r["site_status"] == "Строящиеся")
class TestDedupAlgorithmSpec:
def test_multi_snapshot_same_obj_id_counted_once(self) -> None:
rows = [
{"obj_id": 1, "snapshot_date": "2026-04-27", "site_status": "Строящиеся"},
{"obj_id": 1, "snapshot_date": "2026-05-25", "site_status": "Строящиеся"},
{"obj_id": 1, "snapshot_date": "2026-06-28", "site_status": "Строящиеся"},
]
assert _latest_snapshot_active_count(rows) == 1
def test_sold_out_object_with_old_active_snapshot_not_counted(self) -> None:
# obj_id=2's LATEST snapshot says "Реализован" (sold out) even though
# older snapshots said "Строящиеся" — must NOT count as active.
rows = [
{"obj_id": 2, "snapshot_date": "2026-04-27", "site_status": "Строящиеся"},
{"obj_id": 2, "snapshot_date": "2026-05-25", "site_status": "Строящиеся"},
{"obj_id": 2, "snapshot_date": "2026-06-28", "site_status": "Реализован"},
]
assert _latest_snapshot_active_count(rows) == 0
def test_mixed_objects_only_latest_active_ones_counted(self) -> None:
rows = [
# obj 1: still active across all 3 snapshots -> counts once.
{"obj_id": 1, "snapshot_date": "2026-04-27", "site_status": "Строящиеся"},
{"obj_id": 1, "snapshot_date": "2026-06-28", "site_status": "Строящиеся"},
# obj 2: sold out on the latest snapshot -> excluded.
{"obj_id": 2, "snapshot_date": "2026-04-27", "site_status": "Строящиеся"},
{"obj_id": 2, "snapshot_date": "2026-06-28", "site_status": "Реализован"},
# obj 3: single snapshot, active -> counts once.
{"obj_id": 3, "snapshot_date": "2026-06-28", "site_status": "Строящиеся"},
]
assert _latest_snapshot_active_count(rows) == 2

View file

@ -11,8 +11,6 @@ skip geoportal при отсутствии centroid.
from __future__ import annotations
import contextlib
from types import SimpleNamespace
from typing import Any
from app.services.site_finder import ird_analyze
@ -670,63 +668,3 @@ def test_geoportal_client_uses_settings_timeout(monkeypatch: Any) -> None:
assert constructed == [7]
# ---------------------------------------------------------------------------
# _krt_at SAVEPOINT-регрессия (#2464 cluster A, session-poisoning)
# ---------------------------------------------------------------------------
#
# db.execute внутри _krt_at раньше глотал сбой БЕЗ SAVEPOINT/rollback — на реальном
# Postgres это отравляет shared report-session (build_ird_analyze_block ходит по
# ОДНОЙ db-сессии через несколько lookup'ов): следующий db.execute на той же сессии
# падал "current transaction is aborted". Фикс: db.execute обёрнут в db.begin_nested().
class _KrtAtFetchResult:
def __init__(self, rows: list[SimpleNamespace]) -> None:
self._rows = rows
def fetchall(self) -> list[SimpleNamespace]:
return self._rows
class _KrtAtFakeDB:
"""Фейковая сессия для юнит-теста _krt_at: execute().fetchall() + begin_nested()."""
def __init__(self, rows_or_exc: list[SimpleNamespace] | Exception) -> None:
self._rows_or_exc = rows_or_exc
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any]) -> _KrtAtFetchResult:
if isinstance(self._rows_or_exc, Exception):
raise self._rows_or_exc
return _KrtAtFetchResult(self._rows_or_exc)
def test_krt_at_query_failure_returns_empty_list() -> None:
"""DB-сбой (таблица не задеплоена / QueryCanceled) → [] (documented fallback)."""
db = _KrtAtFakeDB(RuntimeError("relation ekb_krt_geometry does not exist"))
assert ird_analyze._krt_at(db, 60.6, 56.8) == []
def test_krt_at_uses_savepoint_and_session_reusable_after_failure() -> None:
"""begin_nested обёрнут вокруг db.execute — сбой не отравляет сессию: follow-up
вызов _krt_at на ТОЙ ЖЕ db-сессии успевает."""
db = _KrtAtFakeDB(RuntimeError("boom"))
result = ird_analyze._krt_at(db, 60.6, 56.8)
assert result == []
assert db.begin_nested_calls == 1
db._rows_or_exc = [
SimpleNamespace(num_oks_krt=7, status="В работе", full_name="КРТ ул. Тверитина", area=3.5)
]
result2 = ird_analyze._krt_at(db, 60.6, 56.8)
assert len(result2) == 1
assert result2[0]["num_oks_krt"] == 7
assert result2[0]["area"] == 3.5
assert db.begin_nested_calls == 2

View file

@ -11,7 +11,6 @@ DB/сеть не дёргаются: мокаем db.execute через FakeDB.
from __future__ import annotations
import contextlib
from decimal import Decimal
from typing import Any
@ -50,13 +49,6 @@ class _FakeDB:
self._second = second_rows or []
self._raise_first = raise_first
self._raise_second = raise_second
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
self._calls += 1
@ -182,51 +174,3 @@ def test_no_overlap_at_all_returns_empty() -> None:
db = _FakeDB(first_rows=[], second_rows=[])
result = parcel_krt_requisites(db, _WKT)
assert result == []
# ── #2464 cluster A: SAVEPOINT-регрессия (session-poisoning) ────────────────────
# Оба db.execute (exact-матч + no-match fallback) раньше глотали сбой БЕЗ SAVEPOINT/
# rollback — на реальном Postgres это отравляет shared session отчёта: следующий
# db.execute на ТОЙ ЖЕ сессии падает "current transaction is aborted". Фикс: оба
# сайта обёрнуты в db.begin_nested().
def test_first_query_uses_savepoint_and_session_reusable_after_failure() -> None:
"""Сбой на первом (exact-матч) запросе: begin_nested использован, [] отдан, и
follow-up вызов на ТОЙ ЖЕ db-сессии успевает (не отравлен)."""
db = _FakeDB(raise_first=OperationalError("stmt", {}, Exception("relation does not exist")))
result = parcel_krt_requisites(db, _WKT)
assert result == []
assert db.begin_nested_calls == 1
# follow-up на той же сессии (первый запрос теперь отдаёт exact-матч). Сбрасываем
# _calls — _FakeDB считает execute() 1-based ОТ НАЧАЛА каждого прогона.
db._calls = 0
db._raise_first = None
db._first = [_EXACT_ROW]
result2 = parcel_krt_requisites(db, _WKT)
assert len(result2) == 1
assert result2[0]["matched"] is True
assert db.begin_nested_calls == 2
def test_second_query_uses_savepoint_and_session_reusable_after_failure() -> None:
"""Сбой на втором (no-match fallback) запросе: begin_nested использован, [] отдан,
и follow-up вызов на ТОЙ ЖЕ db-сессии успевает (не отравлен)."""
db = _FakeDB(
first_rows=[],
raise_second=OperationalError("stmt", {}, Exception("relation does not exist")),
)
result = parcel_krt_requisites(db, _WKT)
assert result == []
# begin_nested вызван для обоих запросов (первый успешен, второй сбоит).
assert db.begin_nested_calls == 2
# follow-up на той же сессии: и первый, и второй запрос теперь успешны.
db._calls = 0
db._raise_second = None
db._second = [{"pp_id": 42, "pp_full_name": "no-match", "dmd_actual_year": 2023}]
result2 = parcel_krt_requisites(db, _WKT)
assert len(result2) == 1
assert result2[0]["matched"] is False
assert db.begin_nested_calls == 4

Some files were not shown because too many files have changed in this diff Show more