Compare commits
1 commit
main
...
feat/trade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5004d5dc |
280 changed files with 3199 additions and 25386 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -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 (не в репе).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: считается
|
||||
|
|
|
|||
|
|
@ -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. Шире дефолта
|
||||
# (600–1500), чтобы размазать запросы во времени и не триггерить rate-ban.
|
||||
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
|
||||
# ширим до 1200–3000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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: частичный/
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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 200–500 мс), отдельная
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 200–500 мс встроен в 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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ширит его через
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 успешен.
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 → НЕ нужен domrf↔objective маппинг (см. docstring
|
||||
модуля). Объекты с free_flats IS NULL ИЛИ flat_count IS NULL полностью исключаются
|
||||
(#2445-A3: обе колонки nullable и заполняются независимо — объект с известным
|
||||
free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой
|
||||
объект НЕ считается «покрытым» и не входит в coverage-числитель) — снижают coverage
|
||||
→ confidence medium→low.
|
||||
модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки → не вычислить;
|
||||
documented limitation) — снижают coverage → confidence medium→low.
|
||||
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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/прочих."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 без падения ─────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-фильтре.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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"}]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",)]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue