"""Phase 12.0 Hospitality validators.""" from __future__ import annotations import re from datetime import datetime from shared.exceptions import AppError from app.models.types import ( BundleKey, BundleStatus, ConnectorKind, ConnectorStatus, DispatchStatus, KitchenItemStatus, KitchenTicketStatus, LifecycleStatus, MenuStatus, ModifierSelectionMode, ReservationStatus, ServiceRequestKind, ServiceRequestStatus, TableAssignmentStatus, TableStatus, VenueFormat, WaitlistStatus, ) CODE_RE = re.compile(r"^[A-Za-z0-9_\-]{2,50}$") SETTING_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,100}$") FEATURE_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,100}$") def validate_non_empty(value: str | None, *, field: str) -> str: if value is None or not str(value).strip(): raise AppError( f"{field} الزامی است", status_code=422, error_code="validation_error", details={"field": field}, ) return str(value).strip() def validate_code(code: str | None, *, field: str = "code") -> str: cleaned = validate_non_empty(code, field=field).upper() if not CODE_RE.match(cleaned): raise AppError( f"فرمت {field} نامعتبر است", status_code=422, error_code="invalid_code", details={"field": field}, ) return cleaned def validate_setting_key(key: str | None) -> str: cleaned = validate_non_empty(key, field="key") if not SETTING_KEY_RE.match(cleaned): raise AppError( "فرمت key نامعتبر است", status_code=422, error_code="invalid_setting_key", ) return cleaned def validate_feature_key(key: str | None) -> str: cleaned = validate_non_empty(key, field="feature_key") if not FEATURE_KEY_RE.match(cleaned): raise AppError( "فرمت feature_key نامعتبر است", status_code=422, error_code="invalid_feature_key", ) return cleaned def validate_currency_code(code: str) -> str: cleaned = code.strip().upper() if len(cleaned) != 3: raise AppError( "کد ارز باید ۳ حرفی باشد", status_code=422, error_code="invalid_currency_code", ) return cleaned def validate_non_negative_int(value: int | None, *, field: str) -> int | None: if value is None: return None resolved = int(value) if resolved < 0: raise AppError( f"{field} نمی‌تواند منفی باشد", status_code=422, error_code="invalid_amount", details={"field": field}, ) return resolved def validate_positive_int(value: int | None, *, field: str) -> int: if value is None: raise AppError( f"{field} الزامی است", status_code=422, error_code="validation_error", details={"field": field}, ) resolved = int(value) if resolved < 1: raise AppError( f"{field} باید حداقل ۱ باشد", status_code=422, error_code="invalid_amount", details={"field": field}, ) return resolved def _enum_or_error(enum_cls, value, *, error_code: str, message: str): if isinstance(value, enum_cls): return value try: return enum_cls(value) except ValueError as exc: raise AppError(message, status_code=422, error_code=error_code) from exc def validate_lifecycle_status(status: LifecycleStatus | str) -> LifecycleStatus: return _enum_or_error( LifecycleStatus, status, error_code="invalid_lifecycle_status", message="وضعیت نامعتبر است", ) def validate_venue_format(value: VenueFormat | str) -> VenueFormat: return _enum_or_error( VenueFormat, value, error_code="invalid_venue_format", message="فرمت واحد پذیرایی نامعتبر است", ) def validate_bundle_key(value: BundleKey | str) -> BundleKey: return _enum_or_error( BundleKey, value, error_code="invalid_bundle_key", message="کلید بسته نامعتبر است", ) def validate_bundle_status(value: BundleStatus | str) -> BundleStatus: return _enum_or_error( BundleStatus, value, error_code="invalid_bundle_status", message="وضعیت بسته نامعتبر است", ) def validate_menu_status(value: MenuStatus | str) -> MenuStatus: return _enum_or_error( MenuStatus, value, error_code="invalid_menu_status", message="وضعیت منو نامعتبر است", ) def validate_table_status(value: TableStatus | str) -> TableStatus: return _enum_or_error( TableStatus, value, error_code="invalid_table_status", message="وضعیت میز نامعتبر است", ) def ensure_optimistic_version(entity_version: int, expected: int | None) -> None: if expected is None: return if entity_version != expected: raise AppError( "نسخه رکورد تغییر کرده است؛ دوباره تلاش کنید", status_code=409, error_code="version_conflict", details={"expected": expected, "actual": entity_version}, ) def forbid_format_specific_engines(payload: dict) -> None: """Reject payloads that inject format-specific engine keys into foundation.""" forbidden = { "pos_engine", "kitchen_engine", "ordering_engine", "hardcoded_venue_rules", "vendor_sdk", } present = forbidden.intersection(payload.keys()) if present: raise AppError( "موتورهای اختصاصی یا SDK فروشنده در لایه foundation مجاز نیست", status_code=422, error_code="format_specific_forbidden", details={"fields": sorted(present)}, ) TIME_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$") LOCALE_RE = re.compile(r"^[a-z]{2}(-[A-Z]{2})?$") def validate_time_hhmm(value: str, *, field: str) -> str: cleaned = validate_non_empty(value, field=field) if not TIME_RE.match(cleaned): raise AppError( f"فرمت {field} باید HH:MM یا HH:MM:SS باشد", status_code=422, error_code="invalid_time", details={"field": field}, ) if len(cleaned) == 5: return f"{cleaned}:00" return cleaned def validate_time_range(start_time: str, end_time: str) -> tuple[str, str]: start = validate_time_hhmm(start_time, field="start_time") end = validate_time_hhmm(end_time, field="end_time") if end <= start: raise AppError( "زمان پایان باید بعد از زمان شروع باشد", status_code=422, error_code="invalid_time_range", ) return start, end def validate_weekday(value: int | None) -> int | None: if value is None: return None if value < 0 or value > 6: raise AppError( "weekday باید بین ۰ تا ۶ باشد", status_code=422, error_code="invalid_weekday", ) return value def validate_locale(locale: str) -> str: cleaned = validate_non_empty(locale, field="locale") if not LOCALE_RE.match(cleaned): raise AppError( "فرمت locale نامعتبر است", status_code=422, error_code="invalid_locale", ) return cleaned def validate_file_ref(file_ref: str) -> str: cleaned = validate_non_empty(file_ref, field="file_ref") if len(cleaned) > 255: raise AppError( "file_ref بیش از حد طولانی است", status_code=422, error_code="invalid_file_ref", ) return cleaned def validate_modifier_selection( mode: ModifierSelectionMode | str, *, min_select: int, max_select: int | None, is_required: bool, ) -> tuple[ModifierSelectionMode, int, int | None]: resolved = _enum_or_error( ModifierSelectionMode, mode, error_code="invalid_selection_mode", message="حالت انتخاب modifier نامعتبر است", ) min_v = validate_non_negative_int(min_select, field="min_select") or 0 if is_required and min_v < 1: min_v = 1 max_v = None if max_select is not None: max_v = validate_non_negative_int(max_select, field="max_select") if max_v is not None and max_v < min_v: raise AppError( "max_select نمی‌تواند کمتر از min_select باشد", status_code=422, error_code="invalid_selection_bounds", ) return resolved, min_v, max_v # Phase 12.3 — Table Service & Reservations def validate_reservation_status(value: ReservationStatus | str) -> ReservationStatus: return _enum_or_error( ReservationStatus, value, error_code="invalid_reservation_status", message="وضعیت رزرو نامعتبر است", ) def validate_table_assignment_status(value: TableAssignmentStatus | str) -> TableAssignmentStatus: return _enum_or_error( TableAssignmentStatus, value, error_code="invalid_table_assignment_status", message="وضعیت تخصیص میز نامعتبر است", ) def validate_service_request_kind(value: ServiceRequestKind | str) -> ServiceRequestKind: return _enum_or_error( ServiceRequestKind, value, error_code="invalid_service_request_kind", message="نوع درخواست خدمت نامعتبر است", ) def validate_service_request_status(value: ServiceRequestStatus | str) -> ServiceRequestStatus: return _enum_or_error( ServiceRequestStatus, value, error_code="invalid_service_request_status", message="وضعیت درخواست خدمت نامعتبر است", ) def validate_waitlist_status(value: WaitlistStatus | str) -> WaitlistStatus: return _enum_or_error( WaitlistStatus, value, error_code="invalid_waitlist_status", message="وضعیت لیست انتظار نامعتبر است", ) def validate_reservation_window(reserved_from: datetime, reserved_to: datetime) -> tuple[datetime, datetime]: if reserved_to <= reserved_from: raise AppError( "زمان پایان رزرو باید بعد از زمان شروع باشد", status_code=422, error_code="invalid_reservation_window", ) return reserved_from, reserved_to # Phase 12.6 — Kitchen def validate_kitchen_ticket_status(value: KitchenTicketStatus | str) -> KitchenTicketStatus: return _enum_or_error( KitchenTicketStatus, value, error_code="invalid_kitchen_ticket_status", message="وضعیت تیکت آشپزخانه نامعتبر است", ) def validate_kitchen_item_status(value: KitchenItemStatus | str) -> KitchenItemStatus: return _enum_or_error( KitchenItemStatus, value, error_code="invalid_kitchen_item_status", message="وضعیت آیتم آشپزخانه نامعتبر است", ) # Phase 12.7 — Connectors def validate_connector_kind(value: ConnectorKind | str) -> ConnectorKind: return _enum_or_error( ConnectorKind, value, error_code="invalid_connector_kind", message="نوع رابط اتصال نامعتبر است", ) def validate_connector_status(value: ConnectorStatus | str) -> ConnectorStatus: return _enum_or_error( ConnectorStatus, value, error_code="invalid_connector_status", message="وضعیت رابط اتصال نامعتبر است", ) def validate_dispatch_status(value: DispatchStatus | str) -> DispatchStatus: return _enum_or_error( DispatchStatus, value, error_code="invalid_dispatch_status", message="وضعیت ارسال نامعتبر است", )