v0.1.14 - b2b portal
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user