Files
wyndham-ARR/arr_web/booking_uploads.py
2026-07-31 15:11:42 +08:00

288 lines
9.5 KiB
Python

"""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)