288 lines
9.5 KiB
Python
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)
|