"""Parser и upsert для PDF-документов DOM.РФ (декларации, разрешения, проектная, отчётность, прочее). Issue #297, sub-task 22i. Источник данных: /сервисы/api/object/{obj_id}/documents (per-object endpoint, аналогичный /infrastructure и /photos — не входит в bulk kn/object list). Ключи payload (наблюдаемые через chrome-devtools, структура DOM.РФ kn-API v1): [ { "docTypeId": 1, # int — тип по справочнику DOM.РФ "docTypeName": "Декларация", # text "docNum": "66-001686-Д", # text или null "postedDate": "2024-03-15", # date string "YYYY-MM-DD" или null "fileUrl": "https://xn--80az8a.../api/ext/file/...", "fileSize": 1234567, # bytes, может быть null }, ... ] Поле doc_type нормализуется к одному из канонических значений: декларация / разрешение / проектная / отчётность / прочее NOTE: download PDF-файлов — отдельная Celery-задача (future PR). Этот модуль только парсит и upsert-ит метаданные в domrf_kn_documents. """ from __future__ import annotations import logging from datetime import date, datetime from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) # ── Canonical doc_type mapping ──────────────────────────────────────────────── _DOC_TYPE_MAP: dict[str, str] = { # Exact substrings → canonical type (case-insensitive, checked in order). "деклар": "декларация", "разреш": "разрешение", "проект": "проектная", "отчёт": "отчётность", "отчет": "отчётность", # without ё } _DOC_TYPE_FALLBACK = "прочее" _UPSERT_DOC_SQL = text( """ INSERT INTO domrf_kn_documents (obj_id, doc_type, doc_num, posted_at, file_url, size_bytes) VALUES (:obj_id, :doc_type, :doc_num, :posted_at, :file_url, :size_bytes) ON CONFLICT (obj_id, doc_type, doc_num, file_url) DO UPDATE SET size_bytes = EXCLUDED.size_bytes, local_path = COALESCE(domrf_kn_documents.local_path, EXCLUDED.local_path), downloaded_at = COALESCE(domrf_kn_documents.downloaded_at, EXCLUDED.downloaded_at), scraped_at = NOW() """ ) # ── helpers ─────────────────────────────────────────────────────────────────── def _canonical_doc_type(raw_type: str | None) -> str: """Нормализовать docTypeName DOM.РФ к одному из канонических значений.""" if not raw_type: return _DOC_TYPE_FALLBACK lower = raw_type.strip().lower() for substr, canonical in _DOC_TYPE_MAP.items(): if substr in lower: return canonical return _DOC_TYPE_FALLBACK def _parse_date(v: Any) -> date | None: """Coerce date-like value to date. Accepts 'YYYY-MM-DD' or datetime.""" if v is None or v == "": return None if isinstance(v, date) and not isinstance(v, datetime): return v if isinstance(v, datetime): return v.date() if isinstance(v, str): s = v.strip() for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d-%m-%Y"): try: return datetime.strptime(s, fmt).date() except ValueError: pass return None # ── Public API ──────────────────────────────────────────────────────────────── def extract_documents(raw_payload: list[dict[str, Any]]) -> list[dict[str, Any]]: """Извлечь список документов из payload endpoint /object/{obj_id}/documents. Возвращает список dict, готовых для передачи в upsert_documents: obj_id — передаётся отдельно в upsert_documents, здесь NOT SET doc_type — канонический тип (декларация/разрешение/…) doc_num — номер документа или None posted_at — дата публикации или None file_url — URL PDF (обязательно, строки без URL пропускаются) size_bytes — размер файла в байтах или None Записи без file_url пропускаются с warning. """ result: list[dict[str, Any]] = [] for item in raw_payload: if not isinstance(item, dict): continue file_url: str | None = item.get("fileUrl") or item.get("file_url") or None if not file_url or not isinstance(file_url, str) or not file_url.startswith("http"): logger.warning("domrf documents: skipping item without valid fileUrl: %s", item) continue raw_type = item.get("docTypeName") or item.get("doc_type_name") or None doc_num_raw = item.get("docNum") or item.get("doc_num") or None doc_num = str(doc_num_raw).strip() if doc_num_raw is not None else None size_raw = item.get("fileSize") or item.get("file_size") or None try: size_bytes: int | None = int(size_raw) if size_raw is not None else None except (TypeError, ValueError): size_bytes = None result.append( { "doc_type": _canonical_doc_type(raw_type), "doc_num": doc_num or None, "posted_at": _parse_date(item.get("postedDate") or item.get("posted_date")), "file_url": file_url.strip(), "size_bytes": size_bytes, } ) return result def upsert_documents(db: Session, obj_id: int, docs: list[dict[str, Any]]) -> tuple[int, int]: """INSERT/UPDATE документов объекта в domrf_kn_documents. Использует SAVEPOINT (begin_nested) per-row чтобы один битый URL не откатывал всю транзакцию. Возвращает (inserted_or_updated, skipped). """ if not docs: return 0, 0 ok = 0 skip = 0 for doc in docs: params: dict[str, Any] = { "obj_id": obj_id, "doc_type": doc["doc_type"], "doc_num": doc.get("doc_num"), "posted_at": doc.get("posted_at"), "file_url": doc["file_url"], "size_bytes": doc.get("size_bytes"), } try: with db.begin_nested(): db.execute(_UPSERT_DOC_SQL, params) ok += 1 except Exception as exc: logger.warning( "upsert document obj=%s url=%s failed: %s", obj_id, doc.get("file_url"), exc, ) skip += 1 db.commit() logger.info("domrf documents obj=%s: upserted=%d skipped=%d", obj_id, ok, skip) return ok, skip # ── Stub for future Celery download task ────────────────────────────────────── def download_document_stub(obj_id: int, doc_id: int, file_url: str) -> None: """Placeholder для будущей Celery-задачи download_domrf_documents. Скачивает PDF, сохраняет в data/raw/domrf_docs/{obj_id}/{filename}, обновляет domrf_kn_documents.local_path + downloaded_at. Реализация — отдельный PR (future task). """ raise NotImplementedError("PDF download not implemented in 22i. See issue #297 future PR.")