303 lines
9.1 KiB
Python
303 lines
9.1 KiB
Python
|
|
"""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
|