180 lines
5.3 KiB
Python
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,
|
|
},
|
|
}
|