"""Request schemas for the B2B ordering portal. Responses are returned as plain dicts (FastAPI encodes them); these models constrain and validate *inputs* only. """ from __future__ import annotations from datetime import datetime from pydantic import BaseModel, Field, field_validator from app.models.ordering import ( FULFILMENT_METHODS, ORDER_STATUSES, PRODUCT_CATEGORIES, ) _CATEGORY_SET = set(PRODUCT_CATEGORIES) _STATUS_SET = set(ORDER_STATUSES) _FULFILMENT_SET = set(FULFILMENT_METHODS) _RULE_TYPES = {"fixed", "contract", "quote"} _STOCK_STATUSES = {"in_stock", "low_stock", "out_of_stock", "made_to_order"} # --- Customer ordering ------------------------------------------------------- class OrderLineInput(BaseModel): product_id: int quantity: float = Field(gt=0) notes: str | None = None class DraftOrderCreate(BaseModel): lines: list[OrderLineInput] = Field(default_factory=list) purchase_order_number: str | None = None delivery_notes: str | None = None requested_delivery_date: datetime | None = None fulfilment_method: str = "delivery" @field_validator("fulfilment_method") @classmethod def _check_fulfilment(cls, value: str) -> str: if value not in _FULFILMENT_SET: raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}") return value class DraftOrderUpdate(BaseModel): lines: list[OrderLineInput] | None = None purchase_order_number: str | None = None delivery_notes: str | None = None requested_delivery_date: datetime | None = None fulfilment_method: str | None = None @field_validator("fulfilment_method") @classmethod def _check_fulfilment(cls, value: str | None) -> str | None: if value is not None and value not in _FULFILMENT_SET: raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}") return value class OrderSubmitRequest(BaseModel): purchase_order_number: str | None = None delivery_notes: str | None = None requested_delivery_date: datetime | None = None fulfilment_method: str | None = None @field_validator("fulfilment_method") @classmethod def _check_fulfilment(cls, value: str | None) -> str | None: if value is not None and value not in _FULFILMENT_SET: raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}") return value # --- Admin: catalogue -------------------------------------------------------- class CategoryCreate(BaseModel): slug: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=255) description: str | None = None sort_order: int = 0 active: bool = True class CategoryUpdate(BaseModel): name: str | None = None description: str | None = None sort_order: int | None = None active: bool | None = None class CatalogueProductCreate(BaseModel): name: str = Field(min_length=1, max_length=255) sku: str = Field(min_length=1, max_length=64) description: str | None = None category: str = "grains" image_url: str | None = None unit_size: str | None = None unit_of_measure: str = "each" min_order_quantity: float = Field(default=1.0, ge=0) base_price: float | None = Field(default=None, ge=0) stock_status: str = "in_stock" active: bool = True requires_quote: bool = False @field_validator("category") @classmethod def _check_category(cls, value: str) -> str: if value not in _CATEGORY_SET: raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}") return value @field_validator("stock_status") @classmethod def _check_stock(cls, value: str) -> str: if value not in _STOCK_STATUSES: raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}") return value class CatalogueProductUpdate(BaseModel): name: str | None = None sku: str | None = None description: str | None = None category: str | None = None image_url: str | None = None unit_size: str | None = None unit_of_measure: str | None = None min_order_quantity: float | None = Field(default=None, ge=0) base_price: float | None = Field(default=None, ge=0) stock_status: str | None = None active: bool | None = None requires_quote: bool | None = None @field_validator("category") @classmethod def _check_category(cls, value: str | None) -> str | None: if value is not None and value not in _CATEGORY_SET: raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}") return value @field_validator("stock_status") @classmethod def _check_stock(cls, value: str | None) -> str | None: if value is not None and value not in _STOCK_STATUSES: raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}") return value class VisibilityUpdate(BaseModel): product_id: int visible: bool # --- Admin: pricing ---------------------------------------------------------- class PriceTierInput(BaseModel): min_quantity: float = Field(gt=0) unit_price: float = Field(ge=0) class PriceListCreate(BaseModel): code: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=255) description: str | None = None active: bool = True class PriceListItemUpsert(BaseModel): product_id: int unit_price: float = Field(ge=0) tiers: list[PriceTierInput] | None = None class CustomerPriceAssignmentUpsert(BaseModel): price_list_id: int | None = None discount_percent: float = Field(default=0.0, ge=0, le=100) class CustomerProductPriceUpsert(BaseModel): product_id: int unit_price: float | None = Field(default=None, ge=0) rule_type: str = "fixed" contract_reference: str | None = None notes: str | None = None active: bool = True tiers: list[PriceTierInput] | None = None @field_validator("rule_type") @classmethod def _check_rule(cls, value: str) -> str: if value not in _RULE_TYPES: raise ValueError(f"rule_type must be one of {sorted(_RULE_TYPES)}") return value # --- Admin: orders ----------------------------------------------------------- class OrderStatusUpdate(BaseModel): to_status: str note: str | None = None @field_validator("to_status") @classmethod def _check_status(cls, value: str) -> str: if value not in _STATUS_SET: raise ValueError(f"to_status must be one of {sorted(_STATUS_SET)}") return value class OrderLineOverride(BaseModel): quantity: float | None = Field(default=None, gt=0) unit_price: float | None = Field(default=None, ge=0) reason: str | None = None class OrderAdminNotesUpdate(BaseModel): admin_notes: str | None = None class ReopenOrderRequest(BaseModel): note: str | None = None # --- Admin: settings --------------------------------------------------------- class NotificationSettingsUpdate(BaseModel): internal_recipients: str | None = None send_customer_confirmation: bool | None = None require_po_number: bool | None = None from_email: str | None = None # --- Admin: customers & users ------------------------------------------------ _CUSTOMER_STATUSES = {"active", "disabled"} _ORDERING_USER_ROLES = {"owner", "buyer", "accounts", "viewer"} class CustomerCreate(BaseModel): name: str = Field(min_length=1, max_length=255) client_code: str = Field(min_length=1, max_length=64) tenant_id: str | None = None notes: str | None = None class CustomerUpdate(BaseModel): name: str | None = None status: str | None = None notes: str | None = None @field_validator("status") @classmethod def _check_status(cls, value: str | None) -> str | None: if value is not None and value not in _CUSTOMER_STATUSES: raise ValueError(f"status must be one of {sorted(_CUSTOMER_STATUSES)}") return value class CustomerUserCreate(BaseModel): full_name: str = Field(min_length=1, max_length=255) email: str = Field(min_length=3, max_length=255) role: str = "buyer" @field_validator("role") @classmethod def _check_role(cls, value: str) -> str: if value not in _ORDERING_USER_ROLES: raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}") return value @field_validator("email") @classmethod def _normalize_email(cls, value: str) -> str: return value.strip().lower() class CustomerUserUpdate(BaseModel): full_name: str | None = None role: str | None = None status: str | None = None @field_validator("role") @classmethod def _check_role(cls, value: str | None) -> str | None: if value is not None and value not in _ORDERING_USER_ROLES: raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}") return value @field_validator("status") @classmethod def _check_status(cls, value: str | None) -> str | None: if value is not None and value not in {"active", "invited", "suspended"}: raise ValueError("status must be one of ['active', 'invited', 'suspended']") return value