"""Authenticated Booking Excel extraction and review coordinator.""" from __future__ import annotations import os import tempfile import uuid from dataclasses import dataclass from pathlib import Path from typing import Dict, Optional, Protocol from arr_ingestion.contracts import ArtifactRef, IngestionError from arr_storage.store import ManagedObjectStore from booking_ingestion.excel import ( BookingExcelError, ExcelDocument, parse_excel_bytes, validate_excel_filename, ) from booking_ingestion.excel_postgres import ( BookingExcelRepositoryError, BookingSourceSummary, ) from booking_ingestion.excel_review import ( BookingDraftItem, BookingDraftPage, validate_review_item_ids, ) from .contracts import PortalError class BookingSourceRepository(Protocol): def current_source(self) -> Optional[BookingSourceSummary]: ... def current_draft( self, *, limit: int = 50, offset: int = 0, ) -> Optional[BookingDraftPage]: ... def create_draft( self, draft_id: str, source: ArtifactRef, document: ExcelDocument, *, limit: int = 50, ) -> BookingDraftPage: ... def update_item( self, draft_id: str, item_id: int, room_type_code: str, quantity: int, ) -> BookingDraftItem: ... def delete_item(self, draft_id: str, item_id: int) -> None: ... def delete_items(self, draft_id: str, item_ids: object) -> None: ... def discard_draft(self, draft_id: str) -> None: ... def activate_draft(self, draft_id: str) -> BookingSourceSummary: ... class BookingSourceCoordinator(Protocol): def current(self) -> Optional[Dict[str, object]]: ... def draft(self, limit: int = 50, offset: int = 0) -> Optional[Dict[str, object]]: ... def submit(self, original_filename: str, payload: bytes) -> Dict[str, object]: ... def update_item( self, draft_id: str, item_id: int, room_type: str, quantity: int, ) -> Dict[str, object]: ... def delete_item(self, draft_id: str, item_id: int) -> Dict[str, object]: ... def delete_items(self, draft_id: str, item_ids: object) -> Dict[str, object]: ... def discard(self, draft_id: str) -> Dict[str, object]: ... def activate(self, draft_id: str) -> Dict[str, object]: ... class UnavailableBookingSourceCoordinator: @staticmethod def _unavailable() -> PortalError: return PortalError( "BOOKING_EXCEL_SERVICE_UNAVAILABLE", "Excel 数据源服务暂未就绪", 503, ) def current(self) -> Optional[Dict[str, object]]: raise self._unavailable() def draft(self, limit: int = 50, offset: int = 0) -> Optional[Dict[str, object]]: _ = (limit, offset) raise self._unavailable() def submit(self, original_filename: str, payload: bytes) -> Dict[str, object]: _ = (original_filename, payload) raise self._unavailable() def update_item( self, draft_id: str, item_id: int, room_type: str, quantity: int, ) -> Dict[str, object]: _ = (draft_id, item_id, room_type, quantity) raise self._unavailable() def delete_item(self, draft_id: str, item_id: int) -> Dict[str, object]: _ = (draft_id, item_id) raise self._unavailable() def delete_items(self, draft_id: str, item_ids: object) -> Dict[str, object]: _ = (draft_id, item_ids) raise self._unavailable() def discard(self, draft_id: str) -> Dict[str, object]: _ = draft_id raise self._unavailable() def activate(self, draft_id: str) -> Dict[str, object]: _ = draft_id raise self._unavailable() @dataclass class ProgramBookingSourceCoordinator: object_store: ManagedObjectStore repository: BookingSourceRepository def current(self) -> Optional[Dict[str, object]]: try: summary = self.repository.current_source() return summary.to_dict() if summary is not None else None except BookingExcelRepositoryError as error: raise PortalError(error.code, error.safe_message, error.status) from None def draft(self, limit: int = 50, offset: int = 0) -> Optional[Dict[str, object]]: try: page = self.repository.current_draft(limit=limit, offset=offset) return page.to_dict() if page is not None else None except BookingExcelError as error: raise PortalError(error.code, error.safe_message, error.status) from None except BookingExcelRepositoryError as error: raise PortalError(error.code, error.safe_message, error.status) from None def submit(self, original_filename: str, payload: bytes) -> Dict[str, object]: try: filename = validate_excel_filename(original_filename) document = parse_excel_bytes(payload) draft_id = "bookingdraft-" + uuid.uuid4().hex with tempfile.TemporaryDirectory(prefix="arr-booking-excel-") as temporary: source_path = Path(temporary) / "booking-source.xlsx" self._write_private(source_path, payload) stored = self.object_store.upload_committed( job_id=draft_id, attempt_no=1, role="booking_source", source=source_path, original_filename=filename, expected_sha256=document.source_sha256, expected_byte_size=document.source_byte_size, ) page = self.repository.create_draft( draft_id, stored.to_artifact_ref(), document, ) return page.to_dict() except PortalError: raise except BookingExcelError as error: raise PortalError(error.code, error.safe_message, error.status) from None except BookingExcelRepositoryError as error: raise PortalError(error.code, error.safe_message, error.status) from None except IngestionError as error: retryable = error.retryable or error.code.startswith("OBJECT_") raise PortalError( error.code, error.safe_message, 503 if retryable else 422, ) from None except Exception: raise PortalError( "BOOKING_EXCEL_PROCESSING_FAILED", "Excel 文件未能完成提取", 503, ) from None def update_item( self, draft_id: str, item_id: int, room_type: str, quantity: int, ) -> Dict[str, object]: try: return self.repository.update_item( draft_id, item_id, room_type, quantity, ).to_dict() except BookingExcelError as error: raise PortalError(error.code, error.safe_message, error.status) from None except BookingExcelRepositoryError as error: raise PortalError(error.code, error.safe_message, error.status) from None def delete_item(self, draft_id: str, item_id: int) -> Dict[str, object]: result = self.delete_items(draft_id, [item_id]) return {"draft_id": draft_id, "item_id": item_id, "deleted": result["deleted"]} def delete_items(self, draft_id: str, item_ids: object) -> Dict[str, object]: try: validated_ids = validate_review_item_ids(item_ids) self.repository.delete_items(draft_id, validated_ids) return { "draft_id": draft_id, "item_ids": list(validated_ids), "deleted_count": len(validated_ids), "deleted": True, } except BookingExcelError as error: raise PortalError(error.code, error.safe_message, error.status) from None except BookingExcelRepositoryError as error: raise PortalError(error.code, error.safe_message, error.status) from None def discard(self, draft_id: str) -> Dict[str, object]: try: self.repository.discard_draft(draft_id) return {"draft_id": draft_id, "discarded": True} except BookingExcelError as error: raise PortalError(error.code, error.safe_message, error.status) from None except BookingExcelRepositoryError as error: raise PortalError(error.code, error.safe_message, error.status) from None def activate(self, draft_id: str) -> Dict[str, object]: try: return self.repository.activate_draft(draft_id).to_dict() except BookingExcelError as error: raise PortalError(error.code, error.safe_message, error.status) from None except BookingExcelRepositoryError as error: raise PortalError(error.code, error.safe_message, error.status) from None @staticmethod def _write_private(path: Path, payload: bytes) -> None: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) try: with os.fdopen(descriptor, "wb") as target: descriptor = -1 target.write(payload) target.flush() os.fsync(target.fileno()) finally: if descriptor >= 0: os.close(descriptor)