gendesign/backend/app/services/site_finder/pzz_loader.py
lekss361 a3e7d21e3a
fix(pzz_loader): replace bare db.rollback() with SAVEPOINT per-row (#124)
Per code review audit (May 14): transaction correctness bug в
`sync_pzz_zones_to_db()` — inner `db.rollback()` в loop сбрасывал outer
transaction на per-row failures → counts inserted/updated НЕ matched DB state.

## Fix

Wrap UPSERT в loop через `db.begin_nested()` (SAVEPOINT):
- Failure одной row → rollback только её savepoint, outer tx alive
- Successfully inserted rows accumulate в outer tx → commit final
- Outer try/except добавлен для unexpected error → rollback + log + raise
- `finally: db.close()` preserved

Pattern consistent с `domrf_kn.py` (где SAVEPOINT уже работает).

## Preserved

- Function signature + return shape {fetched, inserted, updated, skipped} — unchanged
- Logging contracts
- skipped count теперь корректно включает DB-failure rows (consistent with prior behavior)

## Tests

- No test_pzz_loader.py — skipped
- ruff + AST passed

## Vault

`fixes/Bug_Pzz_Loader_Missing_Savepoint_May14.md` — created (status: resolved).

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-14 23:29:23 +03:00

158 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Импорт ПЗЗ территориальных зон ЕКБ из Росреестр PKK6."""
import asyncio
import json
import logging
import httpx
from sqlalchemy import text
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
PKK6_URL = "https://pkk.rosreestr.ru/arcgis/rest/services/PKK6/ZONES/" "MapServer/5/query"
# bbox ЕКБ: (xmin, ymin, xmax, ymax) в WGS84
EKB_BBOX = (60.5, 56.7, 60.75, 56.95)
async def fetch_pkk6_zones() -> list[dict]:
"""Запросить ВСЕ территориальные зоны для bbox ЕКБ с пагинацией.
PKK6 maxRecordCount = 1000 per request. Пагинируем через resultOffset.
Rate-limit: asyncio.sleep(0.5) между страницами.
"""
xmin, ymin, xmax, ymax = EKB_BBOX
geometry = json.dumps(
{
"xmin": xmin,
"ymin": ymin,
"xmax": xmax,
"ymax": ymax,
"spatialReference": {"wkid": 4326},
}
)
headers = {
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
"Accept": "application/json",
}
all_features: list[dict] = []
offset = 0
page_size = 1000
# verify=False: Росреестр PKK6 self-signed cert. Публичный read-only API,
# MITM risk минимален. follow_redirects=True: PKK6 возвращает 301 на
# некоторые запросы (вероятно legacy URL redirect).
async with httpx.AsyncClient(
timeout=60, headers=headers, verify=False, follow_redirects=True
) as client:
while True:
params = {
"where": "1=1",
"geometry": geometry,
"geometryType": "esriGeometryEnvelope",
"inSR": "4326",
"outSR": "4326",
"outFields": "*",
"f": "geojson",
"resultOffset": offset,
"resultRecordCount": page_size,
}
r = await client.get(PKK6_URL, params=params)
r.raise_for_status()
data = r.json()
features = data.get("features", [])
if not features:
break
all_features.extend(features)
logger.info("PKK6 page offset=%d%d features", offset, len(features))
if len(features) < page_size:
break
offset += page_size
await asyncio.sleep(0.5) # вежливый rate-limit
logger.info("PKK6: total %d zones for EKB bbox", len(all_features))
return all_features
def sync_pzz_zones_to_db() -> dict[str, int]:
"""Загрузить зоны из PKK6 и UPSERT в pzz_zones_ekb.
Возвращает словарь {fetched, inserted, updated, skipped}.
Каждый UPSERT обёрнут в SAVEPOINT (db.begin_nested), чтобы ошибка
одной записи не откатывала всю транзакцию и счётчики оставались
согласованными с тем, что реально записано в DB.
"""
features = asyncio.run(fetch_pkk6_zones())
inserted = 0
updated = 0
skipped = 0
db = SessionLocal()
try:
for f in features:
props = f.get("properties") or {}
geom = f.get("geometry")
if not geom:
skipped += 1
continue
rosreestr_id = props.get("OBJECTID") or props.get("objectid")
zone_code = (
props.get("zone_number")
or props.get("zone_code")
or props.get("identification_number")
or None
)
zone_name = (
props.get("description") or props.get("name") or props.get("zone_name") or None
)
description = props.get("description")
try:
with db.begin_nested(): # SAVEPOINT — откат только этой записи
result = db.execute(
text("""
INSERT INTO pzz_zones_ekb
(rosreestr_id, zone_code, zone_name, description, raw_props, geom)
VALUES (
:rid, :code, :nm, :desc, CAST(:props AS jsonb),
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326))
)
ON CONFLICT (rosreestr_id) DO UPDATE
SET zone_code = EXCLUDED.zone_code,
zone_name = EXCLUDED.zone_name,
description = EXCLUDED.description,
raw_props = EXCLUDED.raw_props,
geom = EXCLUDED.geom,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
"""),
{
"rid": rosreestr_id,
"code": zone_code,
"nm": zone_name,
"desc": description,
"props": json.dumps(props, ensure_ascii=False),
"g": json.dumps(geom),
},
).scalar()
if result:
inserted += 1
else:
updated += 1
except Exception as e:
logger.warning("PZZ insert failed for rosreestr_id=%s: %s", rosreestr_id, e)
skipped += 1
db.commit()
except Exception as e:
db.rollback()
logger.exception("pzz_loader: unexpected error, outer tx rolled back: %s", e)
raise
finally:
db.close()
return {
"fetched": len(features),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
}