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

180 lines
5.3 KiB
Python

"""Contracts and validation for editable Booking Excel extraction drafts."""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from .excel import BookingExcelError
DRAFT_ID_PATTERN = re.compile(r"^bookingdraft-[0-9a-f]{32}$")
MAX_REVIEW_ROOM_TYPE_LENGTH = 128
MAX_REVIEW_DELETE_ITEMS = 50
def validate_draft_id(value: object) -> str:
if not isinstance(value, str) or DRAFT_ID_PATTERN.fullmatch(value) is None:
raise BookingExcelError(
"BOOKING_EXCEL_DRAFT_INVALID",
"Excel 提取草稿编号无效",
400,
)
return value
def validate_review_item_id(value: object) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise BookingExcelError(
"BOOKING_EXCEL_REVIEW_ITEM_INVALID",
"待确认记录编号无效",
400,
)
return value
def validate_review_item_ids(value: object) -> tuple[int, ...]:
if (
not isinstance(value, (list, tuple))
or not value
or len(value) > MAX_REVIEW_DELETE_ITEMS
):
raise BookingExcelError(
"BOOKING_EXCEL_REVIEW_ITEMS_INVALID",
f"请选择 1 至 {MAX_REVIEW_DELETE_ITEMS} 条有效记录",
400,
)
item_ids = tuple(validate_review_item_id(item_id) for item_id in value)
if len(set(item_ids)) != len(item_ids):
raise BookingExcelError(
"BOOKING_EXCEL_REVIEW_ITEMS_INVALID",
"所选记录不能重复",
400,
)
return item_ids
def validate_review_room_type(value: object) -> str:
if not isinstance(value, str):
raise BookingExcelError(
"BOOKING_EXCEL_ROOM_TYPE_INVALID",
"请填写房型",
400,
)
normalized = unicodedata.normalize("NFKC", value).strip()
normalized = re.sub(r"\s+", " ", normalized)
if (
not normalized
or len(normalized) > MAX_REVIEW_ROOM_TYPE_LENGTH
or any(ord(char) < 32 or ord(char) == 127 for char in normalized)
):
raise BookingExcelError(
"BOOKING_EXCEL_ROOM_TYPE_INVALID",
"请填写有效房型",
400,
)
return normalized
def validate_review_quantity(value: object) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 9999:
raise BookingExcelError(
"BOOKING_EXCEL_QUANTITY_INVALID",
"房间数量必须是 1 至 9999 的整数",
400,
)
return value
@dataclass(frozen=True)
class BookingDraftSummary:
draft_id: str
filename: str
status: str
source_rows: int
worksheet_count: int
distinct_group_codes: int
extracted_items: int
confirmed_items: int
pending_items: int
deleted_items: int
confirmed_room_quantity: int
created_at: Optional[datetime]
updated_at: Optional[datetime]
def to_dict(self) -> dict[str, object]:
return {
"draft_id": self.draft_id,
"filename": self.filename,
"status": self.status,
"source_rows": self.source_rows,
"worksheet_count": self.worksheet_count,
"distinct_group_codes": self.distinct_group_codes,
"extracted_items": self.extracted_items,
"confirmed_items": self.confirmed_items,
"pending_items": self.pending_items,
"deleted_items": self.deleted_items,
"confirmed_room_quantity": self.confirmed_room_quantity,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@dataclass(frozen=True)
class BookingDraftItem:
item_id: int
draft_id: str
worksheet: str
row_no: int
item_no: int
tour_code: str
room_type_raw: str
room_type_code: Optional[str]
quantity: int
review_status: str
automatic: bool
source_fragment: str
updated_at: Optional[datetime]
def to_dict(self) -> dict[str, object]:
return {
"item_id": self.item_id,
"draft_id": self.draft_id,
"worksheet": self.worksheet,
"row_no": self.row_no,
"item_no": self.item_no,
"tour_code": self.tour_code,
"room_type_raw": self.room_type_raw,
"room_type": self.room_type_code,
"quantity": self.quantity,
"review_status": self.review_status,
"automatic": self.automatic,
"source_fragment": self.source_fragment,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@dataclass(frozen=True)
class BookingDraftPage:
summary: BookingDraftSummary
items: tuple[BookingDraftItem, ...]
total: int
limit: int
offset: int
def to_dict(self) -> dict[str, object]:
return {
"summary": self.summary.to_dict(),
"items": [item.to_dict() for item in self.items],
"pagination": {
"total": self.total,
"limit": self.limit,
"offset": self.offset,
"has_previous": self.offset > 0,
"has_next": self.offset + len(self.items) < self.total,
},
}