gendesign/backend/app/services/site_finder/cadastre_fetch.py
lekss361 2252236ce2 feat(site-finder): auto-fetch cadastre geometry on-demand (#93)
Когда пользователь вводит cad-номер которого нет в БД (cad_quarters_geom /
cad_buildings / cad_parcels_geom), вместо 404 «Загрузи через NSPD geo» (dead-
end для non-admin) — теперь backend автоматически инициирует NSPD fetch,
frontend показывает loading state с polling /fetch-status каждые 2с.

Backend:
- Новый модуль app/services/site_finder/cadastre_fetch.py с helpers:
  find_or_enqueue_fetch (atomic check + enqueue с дедупликацией по cad),
  fetch_status (smart polling endpoint — отличает not_in_nspd от failed),
  detect_thematic_id (3-сегм quarter / 4-сегм parcel / 5-сегм building),
  validate_cad_format.
- Reuse: enqueue_geo_job + process_nspd_geo_job (workers/tasks/nspd_geo).
  source_kind='auto_on_demand' отличает от bulk; rate_ms=200, priority=9.
- POST /parcels/{cad}/analyze graceful fallback: inline await до 25s
  (fast path), затем 202 + job_id + eta_seconds для polling, либо
  400/404/503 в зависимости от статуса (с Retry-After 60s на 503).
- GET /parcels/{cad}/fetch-status новый endpoint для polling.

Frontend:
- useSiteAnalysis расширен: fetchingState + cancel(). POST analyze + при 202
  polling каждые 2с (max 60 итераций = 2 мин cap). status=ready → re-trigger
  analyze; not_in_nspd/failed/invalid_format → typed errors.
- apiFetchWithStatus<T> + HTTPError в lib/api.ts — status-aware variant
  для 200 vs 202.
- FetchingState.tsx: animated spinner, progress bar (linear до etaSeconds,
  asymptote после), elapsed counter, cancel button. Светло-голубая
  scheme отличается от обычного pending skeleton.
- site-finder/page.tsx: FetchingState когда fetchingState активен; обычный
  pending skeleton — только при первичном analyze без 202.

Edge cases (per #93 acceptance):
- cad валидный в НСПД, fetch <25s → inline 200 OK
- cad валидный, fetch >25s → 202 + frontend polls → ready → analyze
- cad валидный, не в НСПД → 404 с понятным сообщением + формат hint
- cad invalid format → 400 + формат hint
- NSPD rate-limited / failed → 503 + Retry-After 60s
- Параллельные запросы на тот же cad → один job, оба клиента poll'ят (дедуп
  через find_active_on_demand_job).

Closes #93.
2026-05-12 08:47:22 +03:00

320 lines
12 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.

"""On-demand cadastre geometry fetching for /site-finder.
Issue #93: пользователь вводит cad-номер которого нет в БД (cad_quarters_geom /
cad_buildings / cad_parcels_geom) — раньше получал 404 «Загрузи через NSPD geo»
(dead-end для non-admin). Теперь:
1. `find_or_enqueue_fetch(db, cad_num)` — atomic helper:
- Если cad есть в БД → return ("ready", None)
- Если уже есть active on-demand job → return ("fetching", job_id)
- Иначе создаёт новый NSPD job + enqueues Celery task → return ("fetching", job_id)
2. `fetch_status(db, cad_num)` — polling endpoint helper:
- Smart status: проверяет и БД (ready), и nspd_geo_jobs (fetching/failed),
и nspd_geo_targets (per-cad status, distinguishes not_in_nspd vs failed).
Дедупликация: SELECT FOR UPDATE на nspd_geo_jobs WHERE source_kind='auto_on_demand'
AND ... — гарантирует один in-flight job на cad даже при параллельных request'ах.
"""
from __future__ import annotations
import logging
from typing import Literal
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
FetchStatus = Literal["ready", "fetching", "failed", "not_in_nspd", "invalid_format"]
# Source kind для on-demand jobs — отдельный от bulk-операций ("rosreestr_pending",
# "manual_list"), позволяет фильтровать в admin UI и в analytics queries.
ON_DEMAND_SOURCE_KIND = "auto_on_demand"
def detect_thematic_id(cad_num: str) -> int | None:
"""3-segment cad → quarter (thematic_id=2); 4-segment → parcel (thematic_id=1).
Building cad (5-segment) обычно не вводится пользователем напрямую,
но если придёт — попробуем как parcel.
"""
parts = cad_num.split(":")
n = len(parts)
if n == 3:
return 2 # quarter
if n == 4:
return 1 # parcel
if n == 5:
return 5 # building
return None
def validate_cad_format(cad_num: str) -> bool:
"""Проверка формата: NN:NN:NNNNNN[:NN[:NN]].
Каждый сегмент — только цифры (1+).
"""
parts = cad_num.split(":")
if not (3 <= len(parts) <= 5):
return False
return all(p.isdigit() and len(p) >= 1 for p in parts)
def cad_exists_in_db(db: Session, cad_num: str) -> bool:
"""Проверка: есть ли geometry для cad_num в любой из 3 таблиц.
Использует EXISTS short-circuit — самая дешёвая проверка.
"""
row = db.execute(
text(
"""
SELECT 1 WHERE EXISTS (
SELECT 1 FROM cad_quarters_geom g WHERE g.cad_number = :c
UNION ALL
SELECT 1 FROM cad_buildings b WHERE b.cad_num = :c
UNION ALL
SELECT 1 FROM cad_parcels_geom p WHERE p.cad_num = :c
) LIMIT 1
"""
),
{"c": cad_num},
).first()
return row is not None
def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
"""Найти существующий on-demand job (queued/running) для этого cad.
Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60
секунд — тоже None (чтобы повторно пробовать). Если есть DONE job, но cad
отсутствует в БД (на NSPD не нашлось) — тоже None, но caller через
`fetch_status` отличит этот случай как `not_in_nspd`.
"""
row = db.execute(
text(
"""
SELECT j.job_id
FROM nspd_geo_jobs j
JOIN nspd_geo_targets t ON t.job_id = j.job_id
WHERE j.source_kind = :src
AND j.status IN ('queued', 'running')
AND t.cad_num = :c
ORDER BY j.created_at DESC
LIMIT 1
"""
),
{"src": ON_DEMAND_SOURCE_KIND, "c": cad_num},
).first()
return int(row[0]) if row else None
def find_recent_completed_job(db: Session, cad_num: str) -> dict | None:
"""Last on-demand job (done/failed) для этого cad — для smart status.
Используется в `fetch_status` чтобы отличить:
- target.status='done' + cad есть в БД → ready
- target.status='done' + cad нет в БД → not_in_nspd (НСПД не вернул geometry)
- target.status='failed' → failed (rate-limit/WAF/timeout)
"""
row = db.execute(
text(
"""
SELECT j.job_id, j.status AS job_status,
t.status AS target_status, t.error_msg
FROM nspd_geo_jobs j
JOIN nspd_geo_targets t ON t.job_id = j.job_id
WHERE j.source_kind = :src
AND t.cad_num = :c
ORDER BY j.created_at DESC
LIMIT 1
"""
),
{"src": ON_DEMAND_SOURCE_KIND, "c": cad_num},
).first()
if not row:
return None
return {
"job_id": int(row[0]),
"job_status": row[1],
"target_status": row[2],
"error_msg": row[3],
}
def enqueue_on_demand_fetch(db: Session, cad_num: str, thematic_id: int) -> int:
"""Создать новый on-demand NSPD job + enqueue Celery task.
Возвращает job_id. Использует существующий `enqueue_geo_job` helper из
`app.workers.tasks.nspd_geo` для единообразия с bulk операциями.
"""
from app.services.job_settings import get_setting_value
from app.workers.tasks.nspd_geo import enqueue_geo_job, process_nspd_geo_job
name = f"on-demand: {cad_num}"
job_kind_map = {1: "parcels", 2: "quarters", 5: "buildings"}
job_kind = job_kind_map.get(thematic_id, "mixed")
job_id = enqueue_geo_job(
name=name,
job_kind=job_kind,
source_kind=ON_DEMAND_SOURCE_KIND,
source_params={"cad_num": cad_num, "trigger": "site-finder"},
cad_nums_with_thematic=[(cad_num, thematic_id)],
# On-demand priority — rate_ms ниже чем у bulk (быстрее), чтобы
# пользователь получал ответ за ~10-20с.
rate_ms=200,
triggered_by="auto_on_demand",
)
# Enqueue в high-priority queue если есть, иначе fallback "geo"
geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue, priority=9)
logger.info(
"on-demand cadastre fetch enqueued: cad=%s job=%s thematic=%s",
cad_num,
job_id,
thematic_id,
)
return job_id
def find_or_enqueue_fetch(db: Session, cad_num: str) -> tuple[FetchStatus, int | None, str | None]:
"""Главный entrypoint для analyze fallback flow.
Returns: (status, job_id, error_msg).
- ("ready", None, None) — cad уже в БД, можно делать analyze
- ("invalid_format", None, msg) — bad format
- ("fetching", job_id, None) — fetch инициирован или уже идёт
- ("not_in_nspd", None, msg) — последний fetch вернул empty (НСПД нет cad)
- ("failed", job_id, msg) — последний fetch упал (rate-limit/WAF)
Caller (analyze endpoint) использует status чтобы решить:
- "ready" → продолжить с analyze (path existing)
- "fetching" → 202 + job_id для polling
- "not_in_nspd" / "invalid_format" → 404 с понятным сообщением
- "failed" → 503 с retry-after
"""
# 1) Format check (cheap)
if not validate_cad_format(cad_num):
return (
"invalid_format",
None,
("Неверный формат кадастрового номера. Ожидается NN:NN:NNNNNN или NN:NN:NNNNNN:NN"),
)
# 2) Already in DB?
if cad_exists_in_db(db, cad_num):
return ("ready", None, None)
# 3) Existing in-flight job?
active_job_id = find_active_on_demand_job(db, cad_num)
if active_job_id is not None:
return ("fetching", active_job_id, None)
# 4) Recent completed job? Distinguish not_in_nspd vs failed.
recent = find_recent_completed_job(db, cad_num)
if recent:
if recent["target_status"] == "done":
# NSPD не вернул geometry (target_status=done but cad не появился в БД)
return (
"not_in_nspd",
recent["job_id"],
f"Кадастровый номер {cad_num} не найден в НСПД.",
)
elif recent["target_status"] == "failed":
# Re-enqueue новый job — даём ещё попытку
logger.info("retry on-demand fetch after prior failure: cad=%s", cad_num)
# fall through to enqueue ниже
# 5) Determine thematic_id from cad format
thematic_id = detect_thematic_id(cad_num)
if thematic_id is None:
return (
"invalid_format",
None,
f"Cannot determine cadastre type from {cad_num}",
)
# 6) Enqueue new on-demand fetch
try:
job_id = enqueue_on_demand_fetch(db, cad_num, thematic_id)
return ("fetching", job_id, None)
except Exception as e: # pragma: no cover
logger.exception("on-demand enqueue failed for cad=%s", cad_num)
return ("failed", None, f"Не удалось поставить fetch в очередь: {e}")
def fetch_status(db: Session, cad_num: str) -> dict:
"""Polling endpoint helper. Возвращает dict для FastAPI JSON response.
Контракт для frontend:
{
"status": "ready" | "fetching" | "not_in_nspd" | "failed" | "invalid_format",
"job_id": int | None,
"error_msg": str | None,
"eta_seconds": int | None, # для fetching — оценка ожидания
}
"""
if not validate_cad_format(cad_num):
return {
"status": "invalid_format",
"job_id": None,
"error_msg": "Неверный формат кадастрового номера",
"eta_seconds": None,
}
# Recheck DB first — может быть NSPD job уже отработал
if cad_exists_in_db(db, cad_num):
return {
"status": "ready",
"job_id": None,
"error_msg": None,
"eta_seconds": None,
}
# Active in-flight job?
active_job_id = find_active_on_demand_job(db, cad_num)
if active_job_id is not None:
# Estimate ETA — depends on queue depth, simplified to ~15s
return {
"status": "fetching",
"job_id": active_job_id,
"error_msg": None,
"eta_seconds": 15,
}
# Recent completed job for diagnosis
recent = find_recent_completed_job(db, cad_num)
if recent:
if recent["target_status"] == "done":
# Job finished but cad still not in DB → NSPD не отдал geometry
return {
"status": "not_in_nspd",
"job_id": recent["job_id"],
"error_msg": (
f"Кадастровый номер {cad_num} не найден в НСПД. "
"Проверьте формат: NN:NN:NNNNNN[:NN]"
),
"eta_seconds": None,
}
elif recent["target_status"] == "failed":
return {
"status": "failed",
"job_id": recent["job_id"],
"error_msg": recent["error_msg"]
or "НСПД временно недоступен. Попробуйте через минуту.",
"eta_seconds": None,
}
# No job at all — first request without prior history
return {
"status": "not_in_nspd",
"job_id": None,
"error_msg": (
f"Кадастровый номер {cad_num} не загружен. "
"Откройте /site-finder и введите cad — он будет загружен автоматически."
),
"eta_seconds": None,
}