Files
data-entry-app/backend/app/schemas/ordering.py
T

303 lines
9.1 KiB
Python
Raw Normal View History

2026-06-11 23:56:02 +12:00
"""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