v0.1.14 - b2b portal
This commit is contained in:
@@ -184,6 +184,29 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
|
||||
return dependency
|
||||
|
||||
|
||||
def require_ordering_admin_session(
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthSession:
|
||||
"""Internal-side authorization for managing the ordering portal.
|
||||
|
||||
Accepts the single Lean admin (``role == "admin"``) or an internal Hunter
|
||||
user holding ``manage`` on the ordering module. Client/customer users are
|
||||
rejected — they use the customer-facing ``require_client_module_access``
|
||||
dependency instead.
|
||||
"""
|
||||
if session.role == "admin":
|
||||
return session
|
||||
if session.role == "internal":
|
||||
permissions = session.module_permissions or {}
|
||||
if not has_access_level(permissions.get("ordering"), "manage"):
|
||||
log_security_event("authz.denied", role=session.role, module="ordering", access_level="manage")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires manage access")
|
||||
return session
|
||||
log_security_event("authz.denied", role=session.role, module="ordering", required="admin")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires internal admin access")
|
||||
|
||||
|
||||
def require_client_access_manager_session(
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
"""Customer-facing B2B ordering API.
|
||||
|
||||
Every endpoint is gated by the ``ordering`` module and scoped to the caller's
|
||||
own company (``tenant_id`` + ``client_account_id``). Customers can never see
|
||||
another company's catalogue visibility, prices, or orders.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import AuthSession, require_client_module_access
|
||||
from app.db.session import get_db
|
||||
from app.models.ordering import CatalogueProduct, Order, OrderLine
|
||||
from app.schemas.ordering import (
|
||||
DraftOrderCreate,
|
||||
DraftOrderUpdate,
|
||||
OrderLineInput,
|
||||
OrderSubmitRequest,
|
||||
)
|
||||
from app.services import ordering_service as svc
|
||||
from app.services.order_notifications import send_order_submitted_notifications
|
||||
from app.services.order_pdf import OrderPdfUnavailableError, build_order_confirmation_pdf
|
||||
from app.services.ordering_pricing import resolve_price
|
||||
|
||||
router = APIRouter(prefix="/api/ordering", tags=["ordering"])
|
||||
|
||||
|
||||
# --- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_active_customer(db: Session, session: AuthSession):
|
||||
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
|
||||
svc.ensure_customer_active(account)
|
||||
return account
|
||||
|
||||
|
||||
def _get_visible_product(db: Session, session: AuthSession, product_id: int) -> CatalogueProduct:
|
||||
hidden = svc.visible_product_ids_for_customer(
|
||||
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
|
||||
)
|
||||
product = db.scalar(
|
||||
select(CatalogueProduct).where(
|
||||
CatalogueProduct.id == product_id,
|
||||
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
|
||||
CatalogueProduct.active.is_(True),
|
||||
)
|
||||
)
|
||||
if product is None or product.id in hidden:
|
||||
raise HTTPException(status_code=404, detail="Product not available")
|
||||
return product
|
||||
|
||||
|
||||
def _load_own_order(db: Session, session: AuthSession, order_id: int) -> Order:
|
||||
order = db.scalar(
|
||||
select(Order)
|
||||
.where(
|
||||
Order.id == order_id,
|
||||
Order.tenant_id == svc.ORDERING_TENANT,
|
||||
Order.client_account_id == session.client_account_id,
|
||||
)
|
||||
.options(selectinload(Order.lines))
|
||||
)
|
||||
if order is None:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
return order
|
||||
|
||||
|
||||
def _rebuild_lines(db: Session, session: AuthSession, order: Order, lines: list[OrderLineInput]) -> None:
|
||||
"""Replace an order's lines with freshly priced lines (server-side pricing).
|
||||
|
||||
Validates product availability and minimum order quantity. Raises 422 on
|
||||
invalid input.
|
||||
"""
|
||||
order.lines.clear()
|
||||
db.flush()
|
||||
for index, line_input in enumerate(lines):
|
||||
product = _get_visible_product(db, session, line_input.product_id)
|
||||
if line_input.quantity < product.min_order_quantity:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"{product.name}: minimum order quantity is "
|
||||
f"{product.min_order_quantity:g} {product.unit_of_measure}"
|
||||
),
|
||||
)
|
||||
resolution = resolve_price(
|
||||
db,
|
||||
client_account_id=session.client_account_id,
|
||||
product=product,
|
||||
quantity=line_input.quantity,
|
||||
)
|
||||
order.lines.append(
|
||||
OrderLine(
|
||||
tenant_id=svc.ORDERING_TENANT,
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
product_sku=product.sku,
|
||||
quantity=line_input.quantity,
|
||||
unit_price=resolution.unit_price,
|
||||
requires_quote=resolution.requires_quote,
|
||||
price_source=resolution.price_source,
|
||||
price_rule_id=resolution.price_rule_id,
|
||||
discount_percent=resolution.discount_percent,
|
||||
sort_order=index,
|
||||
notes=line_input.notes,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
svc.recompute_order_totals(order)
|
||||
|
||||
|
||||
# --- Catalogue ---------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/catalogue")
|
||||
def list_catalogue(
|
||||
category: str | None = Query(default=None),
|
||||
q: str | None = Query(default=None),
|
||||
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_active_customer(db, session)
|
||||
products = svc.list_visible_products(
|
||||
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
|
||||
)
|
||||
if category:
|
||||
products = [p for p in products if p.category == category]
|
||||
if q:
|
||||
needle = q.lower()
|
||||
products = [
|
||||
p
|
||||
for p in products
|
||||
if needle in p.name.lower()
|
||||
or needle in p.sku.lower()
|
||||
or (p.description and needle in p.description.lower())
|
||||
]
|
||||
return [
|
||||
svc.serialize_product(p, db=db, client_account_id=session.client_account_id)
|
||||
for p in products
|
||||
]
|
||||
|
||||
|
||||
@router.get("/catalogue/{product_id}")
|
||||
def get_catalogue_product(
|
||||
product_id: int,
|
||||
quantity: float = Query(default=1.0, gt=0),
|
||||
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_active_customer(db, session)
|
||||
product = _get_visible_product(db, session, product_id)
|
||||
return svc.serialize_product(
|
||||
product, db=db, client_account_id=session.client_account_id, quantity=quantity
|
||||
)
|
||||
|
||||
|
||||
# --- Orders ------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/orders")
|
||||
def list_orders(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = (
|
||||
select(Order)
|
||||
.where(
|
||||
Order.tenant_id == svc.ORDERING_TENANT,
|
||||
Order.client_account_id == session.client_account_id,
|
||||
)
|
||||
.options(selectinload(Order.lines))
|
||||
.order_by(Order.created_at.desc())
|
||||
)
|
||||
orders = db.scalars(stmt).all()
|
||||
if status_filter == "draft":
|
||||
orders = [o for o in orders if o.status == "draft"]
|
||||
elif status_filter == "submitted":
|
||||
orders = [o for o in orders if o.status != "draft"]
|
||||
return [svc.serialize_order(o, for_admin=False) for o in orders]
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}")
|
||||
def get_order(
|
||||
order_id: int,
|
||||
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
order = _load_own_order(db, session, order_id)
|
||||
return svc.serialize_order(order, for_admin=False)
|
||||
|
||||
|
||||
@router.post("/orders", status_code=status.HTTP_201_CREATED)
|
||||
def create_draft_order(
|
||||
payload: DraftOrderCreate,
|
||||
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_active_customer(db, session)
|
||||
order = Order(
|
||||
tenant_id=svc.ORDERING_TENANT,
|
||||
client_account_id=session.client_account_id,
|
||||
status="draft",
|
||||
created_by_user_id=session.user_id,
|
||||
created_by_name=session.name,
|
||||
purchase_order_number=payload.purchase_order_number,
|
||||
delivery_notes=payload.delivery_notes,
|
||||
requested_delivery_date=payload.requested_delivery_date,
|
||||
fulfilment_method=payload.fulfilment_method,
|
||||
)
|
||||
db.add(order)
|
||||
db.flush()
|
||||
_rebuild_lines(db, session, order, payload.lines)
|
||||
svc.record_status_change(
|
||||
db, order, to_status="draft", actor_type="customer", actor_name=session.name, note="Draft created"
|
||||
)
|
||||
svc.audit_order_event(
|
||||
db, session=session, order=order, action="order.created", summary="Draft order created."
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return svc.serialize_order(order, for_admin=False)
|
||||
|
||||
|
||||
@router.patch("/orders/{order_id}")
|
||||
def update_draft_order(
|
||||
order_id: int,
|
||||
payload: DraftOrderUpdate,
|
||||
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
order = _load_own_order(db, session, order_id)
|
||||
if order.status not in svc.CUSTOMER_EDITABLE_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This order has been submitted and can no longer be edited. Ask an admin to reopen it.",
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
|
||||
if field_name in changes:
|
||||
setattr(order, field_name, changes[field_name])
|
||||
if payload.lines is not None:
|
||||
_rebuild_lines(db, session, order, payload.lines)
|
||||
svc.audit_order_event(
|
||||
db, session=session, order=order, action="order.updated", summary="Draft order updated."
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return svc.serialize_order(order, for_admin=False)
|
||||
|
||||
|
||||
@router.delete("/orders/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_draft_order(
|
||||
order_id: int,
|
||||
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
order = _load_own_order(db, session, order_id)
|
||||
if order.status != "draft":
|
||||
raise HTTPException(status_code=409, detail="Only draft orders can be deleted")
|
||||
db.delete(order)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/submit")
|
||||
def submit_order(
|
||||
order_id: int,
|
||||
payload: OrderSubmitRequest,
|
||||
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = _require_active_customer(db, session)
|
||||
order = _load_own_order(db, session, order_id)
|
||||
if order.status != "draft":
|
||||
raise HTTPException(status_code=409, detail="Only draft orders can be submitted")
|
||||
if not order.lines:
|
||||
raise HTTPException(status_code=422, detail="Cannot submit an empty order")
|
||||
|
||||
# Apply any last-minute header changes supplied on submit.
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
|
||||
if field_name in changes and changes[field_name] is not None:
|
||||
setattr(order, field_name, changes[field_name])
|
||||
|
||||
# Required PO number, if the tenant configured it.
|
||||
from app.services.order_notifications import get_or_create_settings
|
||||
|
||||
settings = get_or_create_settings(db, order.tenant_id)
|
||||
if settings.require_po_number and not (order.purchase_order_number or "").strip():
|
||||
raise HTTPException(status_code=422, detail="A purchase order number is required to submit this order")
|
||||
|
||||
# Re-resolve and freeze the exact price used at submission time.
|
||||
line_inputs = [
|
||||
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
|
||||
for line in order.lines
|
||||
]
|
||||
_rebuild_lines(db, session, order, line_inputs)
|
||||
|
||||
order.order_number = svc.next_order_number(db, order.tenant_id)
|
||||
order.submitted_at = datetime.utcnow()
|
||||
svc.record_status_change(
|
||||
db, order, to_status="submitted", actor_type="customer", actor_name=session.name, note="Submitted by customer"
|
||||
)
|
||||
svc.audit_order_event(
|
||||
db, session=session, order=order, action="order.submitted",
|
||||
summary=f"Order {order.order_number} submitted (subtotal ex GST {order.subtotal_ex_gst:.2f}).",
|
||||
)
|
||||
|
||||
notifications = send_order_submitted_notifications(db, order)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
result = svc.serialize_order(order, for_admin=False)
|
||||
result["notifications"] = [
|
||||
{"channel": n.channel, "recipients": n.recipients, "delivered": n.delivered, "detail": n.detail}
|
||||
for n in notifications
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/reorder", status_code=status.HTTP_201_CREATED)
|
||||
def reorder(
|
||||
order_id: int,
|
||||
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_active_customer(db, session)
|
||||
source = _load_own_order(db, session, order_id)
|
||||
new_order = Order(
|
||||
tenant_id=svc.ORDERING_TENANT,
|
||||
client_account_id=session.client_account_id,
|
||||
status="draft",
|
||||
created_by_user_id=session.user_id,
|
||||
created_by_name=session.name,
|
||||
fulfilment_method=source.fulfilment_method,
|
||||
)
|
||||
db.add(new_order)
|
||||
db.flush()
|
||||
line_inputs = [
|
||||
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
|
||||
for line in source.lines
|
||||
]
|
||||
# Skip lines whose product is no longer available rather than failing.
|
||||
available: list[OrderLineInput] = []
|
||||
for line_input in line_inputs:
|
||||
product = db.scalar(
|
||||
select(CatalogueProduct).where(
|
||||
CatalogueProduct.id == line_input.product_id,
|
||||
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
|
||||
CatalogueProduct.active.is_(True),
|
||||
)
|
||||
)
|
||||
if product is not None and line_input.quantity >= product.min_order_quantity:
|
||||
available.append(line_input)
|
||||
_rebuild_lines(db, session, new_order, available)
|
||||
svc.record_status_change(
|
||||
db, new_order, to_status="draft", actor_type="customer", actor_name=session.name,
|
||||
note=f"Reordered from {source.order_number or source.id}",
|
||||
)
|
||||
svc.audit_order_event(
|
||||
db, session=session, order=new_order, action="order.reordered",
|
||||
summary=f"Draft created by reordering from {source.order_number or source.id}.",
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(new_order)
|
||||
return svc.serialize_order(new_order, for_admin=False)
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}/confirmation.pdf")
|
||||
def order_confirmation_pdf(
|
||||
order_id: int,
|
||||
session: AuthSession = Depends(require_client_module_access("ordering")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
order = _load_own_order(db, session, order_id)
|
||||
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
|
||||
try:
|
||||
pdf_bytes = build_order_confirmation_pdf(order, account)
|
||||
except OrderPdfUnavailableError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
filename = f"order-{order.order_number or order.id}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,9 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
|
||||
"edit_mixes": ("mix_master", "edit"),
|
||||
"view_throughput": ("operations_throughput", "view"),
|
||||
"edit_throughput": ("operations_throughput", "edit"),
|
||||
"view_ordering": ("ordering", "view"),
|
||||
"edit_ordering": ("ordering", "edit"),
|
||||
"manage_ordering": ("ordering", "manage"),
|
||||
"view_scenarios": ("scenarios", "view"),
|
||||
"edit_scenarios": ("scenarios", "edit"),
|
||||
"manage_client_access": ("client_access", "manage"),
|
||||
|
||||
@@ -42,6 +42,21 @@ TENANT_TABLES = {
|
||||
"freight_cost_rules": None,
|
||||
"throughput_products": None,
|
||||
"production_throughput_entries": None,
|
||||
# B2B ordering portal
|
||||
"product_categories": None,
|
||||
"catalogue_products": None,
|
||||
"customer_product_visibility": None,
|
||||
"price_lists": None,
|
||||
"price_list_items": None,
|
||||
"customer_price_assignments": None,
|
||||
"customer_product_prices": None,
|
||||
"price_tiers": None,
|
||||
"orders": None,
|
||||
"order_lines": None,
|
||||
"order_status_history": None,
|
||||
"order_attachments": None,
|
||||
"notification_settings": None,
|
||||
"xero_sync_log": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ from app.api.dashboard import router as dashboard_router
|
||||
from app.api.editor import router as editor_router
|
||||
from app.api.mix_calculator import router as mix_calculator_router
|
||||
from app.api.mixes import router as mixes_router
|
||||
from app.api.ordering import router as ordering_router
|
||||
from app.api.ordering_admin import router as ordering_admin_router
|
||||
from app.api.powerbi import router as powerbi_router
|
||||
from app.api.product_costing import router as product_costing_router
|
||||
from app.api.products import router as products_router
|
||||
@@ -204,6 +206,8 @@ app.include_router(product_costing_router)
|
||||
app.include_router(products_router)
|
||||
app.include_router(scenarios_router)
|
||||
app.include_router(throughput_router)
|
||||
app.include_router(ordering_router)
|
||||
app.include_router(ordering_admin_router)
|
||||
app.include_router(powerbi_router)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,22 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
|
||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
||||
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.ordering import (
|
||||
CatalogueProduct,
|
||||
CustomerPriceAssignment,
|
||||
CustomerProductPrice,
|
||||
CustomerProductVisibility,
|
||||
NotificationSetting,
|
||||
Order,
|
||||
OrderAttachment,
|
||||
OrderLine,
|
||||
OrderStatusHistory,
|
||||
PriceList,
|
||||
PriceListItem,
|
||||
PriceTier,
|
||||
ProductCategory,
|
||||
XeroSyncLog,
|
||||
)
|
||||
from app.models.product import Product, ProductIngredient
|
||||
from app.models.product_costing import (
|
||||
ProductCostBagInput,
|
||||
@@ -17,14 +33,28 @@ from app.models.scenario import CostingResult, Scenario
|
||||
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||
|
||||
__all__ = [
|
||||
"CatalogueProduct",
|
||||
"ClientAccount",
|
||||
"ClientAccessAuditEvent",
|
||||
"ClientFeatureAccess",
|
||||
"ClientUser",
|
||||
"ClientUserModulePermission",
|
||||
"CostingResult",
|
||||
"CustomerPriceAssignment",
|
||||
"CustomerProductPrice",
|
||||
"CustomerProductVisibility",
|
||||
"FreightCostRule",
|
||||
"Mix",
|
||||
"NotificationSetting",
|
||||
"Order",
|
||||
"OrderAttachment",
|
||||
"OrderLine",
|
||||
"OrderStatusHistory",
|
||||
"PriceList",
|
||||
"PriceListItem",
|
||||
"PriceTier",
|
||||
"ProductCategory",
|
||||
"XeroSyncLog",
|
||||
"MixCalculatorSession",
|
||||
"MixCalculatorSessionLine",
|
||||
"MixIngredient",
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
"""B2B ordering portal data model.
|
||||
|
||||
This module backs the private customer ordering portal. It deliberately reuses
|
||||
the existing tenant/customer primitives rather than introducing parallel ones:
|
||||
|
||||
* A **customer/company** is an existing :class:`ClientAccount`.
|
||||
* A **customer user** is an existing :class:`ClientUser` (owner/buyer/viewer/
|
||||
accounts roles map onto the existing ``ordering`` module access levels).
|
||||
|
||||
Everything here is private: every row is tenant-scoped, and customer-facing
|
||||
rows are additionally scoped to a ``client_account_id``. The catalogue itself is
|
||||
global to the seller (one tenant), with per-customer visibility and pricing
|
||||
layered on top.
|
||||
|
||||
Money is stored GST-exclusive (see ``CLAUDE.MD`` costing conventions) as floats,
|
||||
matching the rest of the costing platform.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
# --- Controlled vocabularies -------------------------------------------------
|
||||
# Plain string constants (not DB enums) keep SQLite migrations trivial and match
|
||||
# the existing model style.
|
||||
|
||||
PRODUCT_CATEGORIES = (
|
||||
"grains",
|
||||
"premixed",
|
||||
"bags",
|
||||
"bulk_loads",
|
||||
"custom_blends",
|
||||
"services",
|
||||
)
|
||||
|
||||
# Full internal order lifecycle. Customers see a simplified subset (see
|
||||
# ``CUSTOMER_VISIBLE_STATUS`` in the ordering service).
|
||||
ORDER_STATUSES = (
|
||||
"draft",
|
||||
"submitted",
|
||||
"under_review",
|
||||
"confirmed",
|
||||
"sent_to_xero",
|
||||
"in_production",
|
||||
"ready_for_pickup",
|
||||
"dispatched",
|
||||
"completed",
|
||||
"cancelled",
|
||||
)
|
||||
|
||||
# How a resolved unit price was derived. Stored on each order line so future
|
||||
# reporting can always explain which rule applied.
|
||||
PRICE_SOURCES = (
|
||||
"fixed", # CustomerProductPrice with rule_type fixed
|
||||
"contract", # CustomerProductPrice with rule_type contract
|
||||
"price_list", # PriceListItem via an assigned price list
|
||||
"tiered", # a quantity tier won over the base rate
|
||||
"base", # catalogue list price (optionally with customer discount)
|
||||
"quote", # manual quote required; no automatic price
|
||||
)
|
||||
|
||||
FULFILMENT_METHODS = ("delivery", "pickup")
|
||||
|
||||
|
||||
class ProductCategory(Base):
|
||||
__tablename__ = "product_categories"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_product_category_slug"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
slug: Mapped[str] = mapped_column(String(64), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class CatalogueProduct(Base):
|
||||
"""A private catalogue product. Named ``CatalogueProduct`` to avoid clashing
|
||||
with the existing costing ``Product`` (a separate concern)."""
|
||||
|
||||
__tablename__ = "catalogue_products"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "sku", name="uq_catalogue_product_sku"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
sku: Mapped[str] = mapped_column(String(64), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(64), default="grains", index=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
unit_size: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
unit_of_measure: Mapped[str] = mapped_column(String(64), default="each")
|
||||
min_order_quantity: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
# Base/list price, GST-exclusive. Used as the fallback price source and as
|
||||
# the basis for a customer discount percentage.
|
||||
base_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
stock_status: Mapped[str] = mapped_column(String(32), default="in_stock")
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class CustomerProductVisibility(Base):
|
||||
"""Per-customer override of catalogue visibility.
|
||||
|
||||
Products are visible to every customer by default (the catalogue is
|
||||
"standard globally"). A row here with ``visible=False`` hides a product from
|
||||
one customer; ``visible=True`` is an explicit allow (no-op unless a future
|
||||
default flips to opt-in).
|
||||
"""
|
||||
|
||||
__tablename__ = "customer_product_visibility"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_visibility"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||
visible: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PriceList(Base):
|
||||
__tablename__ = "price_lists"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_price_list_code"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
code: Mapped[str] = mapped_column(String(64), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
items: Mapped[list["PriceListItem"]] = relationship(
|
||||
back_populates="price_list",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class PriceListItem(Base):
|
||||
__tablename__ = "price_list_items"
|
||||
__table_args__ = (UniqueConstraint("price_list_id", "product_id", name="uq_price_list_item"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
price_list_id: Mapped[int] = mapped_column(ForeignKey("price_lists.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||
unit_price: Mapped[float] = mapped_column(Float)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
price_list: Mapped[PriceList] = relationship(back_populates="items")
|
||||
|
||||
|
||||
class CustomerPriceAssignment(Base):
|
||||
"""Links a customer to an assigned price list and a default discount."""
|
||||
|
||||
__tablename__ = "customer_price_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_account_id", name="uq_customer_price_assignment"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
price_list_id: Mapped[int | None] = mapped_column(ForeignKey("price_lists.id"), nullable=True, index=True)
|
||||
# Default percentage discount applied to base prices for this customer when
|
||||
# no more specific rule (fixed/contract/price-list) applies. 0..100.
|
||||
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
price_list: Mapped[PriceList | None] = relationship()
|
||||
|
||||
|
||||
class CustomerProductPrice(Base):
|
||||
"""A fixed or contract price for a specific customer + product. Highest
|
||||
priority pricing rule."""
|
||||
|
||||
__tablename__ = "customer_product_prices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_price"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# "fixed" | "contract" | "quote" (quote forces a manual-quote workflow for
|
||||
# this customer+product even if the product itself doesn't require one).
|
||||
rule_type: Mapped[str] = mapped_column(String(32), default="fixed")
|
||||
contract_reference: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PriceTier(Base):
|
||||
"""Quantity break for a pricing source. Exactly one of
|
||||
``customer_product_price_id`` / ``price_list_item_id`` is set."""
|
||||
|
||||
__tablename__ = "price_tiers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
customer_product_price_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("customer_product_prices.id"), nullable=True, index=True
|
||||
)
|
||||
price_list_item_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("price_list_items.id"), nullable=True, index=True
|
||||
)
|
||||
# Tier applies when ordered quantity >= min_quantity. The highest qualifying
|
||||
# min_quantity wins.
|
||||
min_quantity: Mapped[float] = mapped_column(Float)
|
||||
unit_price: Mapped[float] = mapped_column(Float)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
# Human-friendly order reference, assigned on submit (e.g. ORD-000123).
|
||||
order_number: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
|
||||
|
||||
# Who created / submitted it (ClientUser ids; nullable for admin-created).
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True)
|
||||
created_by_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Customer-supplied order details.
|
||||
purchase_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
delivery_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
requested_delivery_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
fulfilment_method: Mapped[str] = mapped_column(String(32), default="delivery")
|
||||
|
||||
# Admin/internal handling.
|
||||
admin_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reopened: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Cached totals (authoritative figures live on lines; this is for listing).
|
||||
subtotal_ex_gst: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Xero integration tracking.
|
||||
xero_status: Mapped[str] = mapped_column(String(32), default="not_sent")
|
||||
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
lines: Mapped[list["OrderLine"]] = relationship(
|
||||
back_populates="order",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="OrderLine.sort_order",
|
||||
)
|
||||
status_history: Mapped[list["OrderStatusHistory"]] = relationship(
|
||||
back_populates="order",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="OrderStatusHistory.created_at",
|
||||
)
|
||||
attachments: Mapped[list["OrderAttachment"]] = relationship(
|
||||
back_populates="order",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class OrderLine(Base):
|
||||
__tablename__ = "order_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||
|
||||
# Snapshot of product identity at order time (so later catalogue edits don't
|
||||
# rewrite historical orders).
|
||||
product_name: Mapped[str] = mapped_column(String(255))
|
||||
product_sku: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
quantity: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
|
||||
# Authoritative unit price, GST-exclusive, computed by the backend pricing
|
||||
# engine and frozen at submission. Null only while quote-only.
|
||||
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
line_total: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Pricing provenance — enough to explain which rule applied in reporting.
|
||||
price_source: Mapped[str] = mapped_column(String(32), default="base")
|
||||
price_rule_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
# Admin override of the resolved unit price before confirmation.
|
||||
admin_override_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
admin_override_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="lines")
|
||||
|
||||
|
||||
class OrderStatusHistory(Base):
|
||||
__tablename__ = "order_status_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
to_status: Mapped[str] = mapped_column(String(32))
|
||||
actor_type: Mapped[str] = mapped_column(String(32), default="system")
|
||||
actor_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="status_history")
|
||||
|
||||
|
||||
class OrderAttachment(Base):
|
||||
__tablename__ = "order_attachments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
# "purchase_order" | "confirmation_pdf" | "other"
|
||||
kind: Mapped[str] = mapped_column(String(32), default="other")
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="attachments")
|
||||
|
||||
|
||||
class NotificationSetting(Base):
|
||||
"""Per-tenant notification configuration for the ordering portal."""
|
||||
|
||||
__tablename__ = "notification_settings"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", name="uq_notification_settings_tenant"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
# Comma-separated internal recipients notified on new order submissions.
|
||||
internal_recipients: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
send_customer_confirmation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
require_po_number: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class XeroSyncLog(Base):
|
||||
__tablename__ = "xero_sync_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
# "queued" | "success" | "failed"
|
||||
status: Mapped[str] = mapped_column(String(32), default="queued")
|
||||
request_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
@@ -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
|
||||
@@ -1285,6 +1285,23 @@ def seed_startup_basics():
|
||||
logger.info("Product costing module seeded: %s", product_costing_report)
|
||||
db.commit()
|
||||
|
||||
# The ordering-portal seed (catalogue + the Riverside Stockfeeds test
|
||||
# customer and its buyer user) runs in its own transaction so it always
|
||||
# commits on a fresh Postgres deploy, independent of the workbook/costing
|
||||
# seeds above. It is idempotent — safe on every boot.
|
||||
seed_ordering_basics()
|
||||
|
||||
|
||||
def seed_ordering_basics():
|
||||
"""Seed the B2B ordering portal (catalogue + test customer). Idempotent."""
|
||||
from app.seed_ordering import seed_ordering
|
||||
|
||||
with SessionLocal() as db:
|
||||
ordering_report = seed_ordering(db)
|
||||
if any(ordering_report.values()):
|
||||
logger.info("Ordering portal seeded: %s", ordering_report)
|
||||
db.commit()
|
||||
|
||||
|
||||
def seed_all():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -31,6 +31,9 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
|
||||
("edit_mixes", "Create and edit mix master recipes"),
|
||||
("view_throughput", "View operations throughput"),
|
||||
("edit_throughput", "Create and edit operations throughput entries"),
|
||||
("view_ordering", "View the B2B customer ordering portal and orders"),
|
||||
("edit_ordering", "Manage catalogue, pricing, and process customer orders"),
|
||||
("manage_ordering", "Full ordering administration: customers, pricing, order lifecycle, Xero"),
|
||||
("view_scenarios", "View scenario planning"),
|
||||
("edit_scenarios", "Create, run, approve, and reject scenarios"),
|
||||
("manage_client_access", "Manage client accounts, users, feature access, and exports"),
|
||||
@@ -58,6 +61,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
||||
"edit_mixes",
|
||||
"view_throughput",
|
||||
"edit_throughput",
|
||||
"view_ordering",
|
||||
"edit_ordering",
|
||||
"manage_ordering",
|
||||
"view_scenarios",
|
||||
"edit_scenarios",
|
||||
"manage_client_access",
|
||||
@@ -93,6 +99,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
||||
"edit_mixes",
|
||||
"view_throughput",
|
||||
"edit_throughput",
|
||||
"view_ordering",
|
||||
"edit_ordering",
|
||||
"manage_ordering",
|
||||
],
|
||||
},
|
||||
"lean": {
|
||||
@@ -110,6 +119,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
||||
"edit_mixes",
|
||||
"view_throughput",
|
||||
"edit_throughput",
|
||||
"view_ordering",
|
||||
"edit_ordering",
|
||||
"manage_ordering",
|
||||
"view_scenarios",
|
||||
"edit_scenarios",
|
||||
"manage_client_access",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Idempotent seed data for the B2B ordering portal.
|
||||
|
||||
Creates a small demo catalogue, a demo ordering customer + buyer user, a price
|
||||
list, and a customer-specific price so the acceptance-criteria flow works
|
||||
out of the box. Safe to run on every startup — it no-ops once seeded.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
|
||||
from app.models.ordering import (
|
||||
CatalogueProduct,
|
||||
CustomerPriceAssignment,
|
||||
CustomerProductPrice,
|
||||
NotificationSetting,
|
||||
PriceList,
|
||||
PriceListItem,
|
||||
ProductCategory,
|
||||
)
|
||||
from app.services.client_access_service import (
|
||||
MODULE_INDEX,
|
||||
ensure_user_module_permissions,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("data_entry_app.seed")
|
||||
|
||||
ORDERING_TENANT = settings.client_tenant_id
|
||||
|
||||
_CATEGORIES = [
|
||||
("grains", "Grains", 10),
|
||||
("premixed", "Premixed Products", 20),
|
||||
("bags", "Bags", 30),
|
||||
("bulk_loads", "Bulk Loads", 40),
|
||||
("custom_blends", "Custom Blends", 50),
|
||||
("services", "Services & Delivery", 60),
|
||||
]
|
||||
|
||||
_PRODUCTS = [
|
||||
# (name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote)
|
||||
("Cracked Maize", "GRN-MAIZE-20", "grains", "20kg bag", "20kg", 1, 24.50, "in_stock", False),
|
||||
("Whole Barley", "GRN-BARLEY-20", "grains", "20kg bag", "20kg", 1, 21.00, "in_stock", False),
|
||||
("Layer Premix", "PMX-LAYER-20", "premixed", "20kg bag", "20kg", 1, 32.75, "in_stock", False),
|
||||
("Calf Starter Premix", "PMX-CALF-20", "premixed", "20kg bag", "20kg", 5, 38.40, "low_stock", False),
|
||||
("Bulka Bag (1T)", "BAG-BULKA-1T", "bags", "each", "1 tonne", 1, 9.50, "in_stock", False),
|
||||
("Bulk Maize Load", "BLK-MAIZE-T", "bulk_loads", "tonne", "per tonne", 1, 685.00, "made_to_order", False),
|
||||
("Custom Horse Blend", "CST-HORSE-20", "custom_blends", "20kg bag", "20kg", 10, None, "made_to_order", True),
|
||||
("Delivery (per pallet)", "SVC-DELIVERY", "services", "pallet", "1 pallet", 1, 45.00, "in_stock", False),
|
||||
]
|
||||
|
||||
|
||||
def seed_ordering(db: Session) -> dict[str, int]:
|
||||
created = {"categories": 0, "products": 0, "customer": 0, "pricing": 0}
|
||||
|
||||
# Categories
|
||||
existing_categories = {
|
||||
c.slug for c in db.scalars(select(ProductCategory).where(ProductCategory.tenant_id == ORDERING_TENANT)).all()
|
||||
}
|
||||
for slug, name, sort_order in _CATEGORIES:
|
||||
if slug in existing_categories:
|
||||
continue
|
||||
db.add(ProductCategory(tenant_id=ORDERING_TENANT, slug=slug, name=name, sort_order=sort_order))
|
||||
created["categories"] += 1
|
||||
|
||||
# Catalogue products
|
||||
existing_skus = {
|
||||
p.sku for p in db.scalars(select(CatalogueProduct).where(CatalogueProduct.tenant_id == ORDERING_TENANT)).all()
|
||||
}
|
||||
product_by_sku: dict[str, CatalogueProduct] = {}
|
||||
for name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote in _PRODUCTS:
|
||||
if sku in existing_skus:
|
||||
continue
|
||||
product = CatalogueProduct(
|
||||
tenant_id=ORDERING_TENANT,
|
||||
name=name,
|
||||
sku=sku,
|
||||
category=category,
|
||||
unit_of_measure=uom,
|
||||
unit_size=unit_size,
|
||||
min_order_quantity=moq,
|
||||
base_price=base_price,
|
||||
stock_status=stock,
|
||||
requires_quote=requires_quote,
|
||||
)
|
||||
db.add(product)
|
||||
product_by_sku[sku] = product
|
||||
created["products"] += 1
|
||||
db.flush()
|
||||
|
||||
# Notification settings row
|
||||
if db.scalar(select(NotificationSetting).where(NotificationSetting.tenant_id == ORDERING_TENANT)) is None:
|
||||
db.add(
|
||||
NotificationSetting(
|
||||
tenant_id=ORDERING_TENANT,
|
||||
internal_recipients=settings.admin_email,
|
||||
send_customer_confirmation=True,
|
||||
require_po_number=False,
|
||||
from_email=settings.admin_email,
|
||||
)
|
||||
)
|
||||
|
||||
# Demo ordering customer + buyer user
|
||||
demo = db.scalar(select(ClientAccount).where(ClientAccount.client_code == "RIVERSIDE"))
|
||||
if demo is None:
|
||||
demo = ClientAccount(
|
||||
tenant_id="riverside-stockfeeds",
|
||||
name="Riverside Stockfeeds",
|
||||
client_code="RIVERSIDE",
|
||||
status="active",
|
||||
notes="Demo B2B ordering customer",
|
||||
)
|
||||
db.add(demo)
|
||||
db.flush()
|
||||
created["customer"] += 1
|
||||
|
||||
info = MODULE_INDEX["ordering"]
|
||||
db.add(
|
||||
ClientFeatureAccess(
|
||||
tenant_id=demo.tenant_id,
|
||||
client_account_id=demo.id,
|
||||
feature_key="ordering",
|
||||
feature_name=info["module_name"],
|
||||
feature_group=info["module_group"],
|
||||
description=info["description"],
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
buyer = ClientUser(
|
||||
tenant_id=demo.tenant_id,
|
||||
client_account_id=demo.id,
|
||||
full_name="Riverside Buyer",
|
||||
email="buyer@riverside.example",
|
||||
role="buyer",
|
||||
status="active",
|
||||
is_new_user=False,
|
||||
)
|
||||
db.add(buyer)
|
||||
db.flush()
|
||||
ensure_user_module_permissions(db, buyer)
|
||||
|
||||
# A customer-specific contract price + a small discount on the rest.
|
||||
maize = db.scalar(
|
||||
select(CatalogueProduct).where(
|
||||
CatalogueProduct.tenant_id == ORDERING_TENANT, CatalogueProduct.sku == "GRN-MAIZE-20"
|
||||
)
|
||||
)
|
||||
if maize is not None:
|
||||
db.add(
|
||||
CustomerProductPrice(
|
||||
tenant_id=ORDERING_TENANT,
|
||||
client_account_id=demo.id,
|
||||
product_id=maize.id,
|
||||
unit_price=22.00,
|
||||
rule_type="contract",
|
||||
contract_reference="2026 supply agreement",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
CustomerPriceAssignment(
|
||||
tenant_id=ORDERING_TENANT,
|
||||
client_account_id=demo.id,
|
||||
price_list_id=None,
|
||||
discount_percent=5.0,
|
||||
)
|
||||
)
|
||||
created["pricing"] += 1
|
||||
|
||||
return created
|
||||
@@ -21,6 +21,7 @@ MODULE_CATALOG = (
|
||||
("products", "Products", "pricing", "Review finished product pricing"),
|
||||
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
|
||||
("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"),
|
||||
("ordering", "Ordering Portal", "commerce", "Browse the private catalogue, build and submit B2B orders"),
|
||||
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
|
||||
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
|
||||
)
|
||||
@@ -79,15 +80,25 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
|
||||
def default_access_level_for_role(role: str, module_key: str) -> str:
|
||||
normalized = role.strip().lower()
|
||||
if normalized == "superadmin":
|
||||
return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput"} else "edit"
|
||||
return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput", "ordering"} else "edit"
|
||||
if normalized == "admin":
|
||||
if module_key in {"mix_calculator", "operations_throughput"}:
|
||||
if module_key in {"mix_calculator", "operations_throughput", "ordering"}:
|
||||
return "manage"
|
||||
return "edit" if module_key != "client_access" else "none"
|
||||
if normalized == "operator":
|
||||
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput"} else "none"
|
||||
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput", "ordering"} else "none"
|
||||
if normalized == "viewer":
|
||||
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput"} else "none"
|
||||
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput", "ordering"} else "none"
|
||||
# --- B2B ordering-portal customer roles ---------------------------------
|
||||
# Ordering customers are scoped to the ordering module (plus a dashboard
|
||||
# view). They never see costing/admin modules.
|
||||
if normalized == "owner":
|
||||
return "manage" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
|
||||
if normalized == "buyer":
|
||||
return "edit" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
|
||||
if normalized == "accounts":
|
||||
# Accounts users review orders/invoicing but don't place orders.
|
||||
return "view" if module_key in {"ordering", "dashboard"} else "none"
|
||||
return "none"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Order notification service (stub interface).
|
||||
|
||||
Emits the two notifications the ordering spec requires when an order is
|
||||
submitted:
|
||||
|
||||
* a confirmation to the customer, and
|
||||
* an internal notification to configured recipients.
|
||||
|
||||
Real email delivery is intentionally **stubbed** behind this interface: no SMTP
|
||||
provider is configured in dev/alpha, so notifications are logged and returned as
|
||||
structured results. To go live, implement :func:`_deliver_email` (TODO) — no
|
||||
caller changes needed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.client_access import ClientAccount, ClientUser
|
||||
from app.models.ordering import NotificationSetting, Order
|
||||
|
||||
logger = logging.getLogger("data_entry_app.ordering")
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationResult:
|
||||
channel: str # "customer" | "internal"
|
||||
recipients: list[str]
|
||||
subject: str
|
||||
delivered: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session, tenant_id: str) -> NotificationSetting:
|
||||
settings = db.scalar(
|
||||
select(NotificationSetting).where(NotificationSetting.tenant_id == tenant_id)
|
||||
)
|
||||
if settings is None:
|
||||
settings = NotificationSetting(tenant_id=tenant_id)
|
||||
db.add(settings)
|
||||
db.flush()
|
||||
return settings
|
||||
|
||||
|
||||
def _internal_recipients(settings: NotificationSetting) -> list[str]:
|
||||
if not settings.internal_recipients:
|
||||
return []
|
||||
return [part.strip() for part in settings.internal_recipients.split(",") if part.strip()]
|
||||
|
||||
|
||||
def _customer_recipients(db: Session, order: Order) -> list[str]:
|
||||
users = db.scalars(
|
||||
select(ClientUser).where(
|
||||
ClientUser.client_account_id == order.client_account_id,
|
||||
ClientUser.status == "active",
|
||||
)
|
||||
).all()
|
||||
return [user.email for user in users if user.email]
|
||||
|
||||
|
||||
def _deliver_email(*, to: list[str], subject: str, body: str, from_email: str | None) -> bool:
|
||||
"""Deliver an email. Stubbed — logs instead of sending.
|
||||
|
||||
TODO (go-live): integrate the chosen provider (SMTP / SendGrid / SES) using
|
||||
credentials from environment variables. Return True only on confirmed
|
||||
delivery.
|
||||
"""
|
||||
logger.info("ordering.email.stub to=%s subject=%s", to, subject)
|
||||
return False # stub: nothing actually sent
|
||||
|
||||
|
||||
def send_order_submitted_notifications(db: Session, order: Order) -> list[NotificationResult]:
|
||||
"""Send customer confirmation + internal notification for a submitted order."""
|
||||
settings = get_or_create_settings(db, order.tenant_id)
|
||||
customer = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
|
||||
customer_name = customer.name if customer else "Customer"
|
||||
order_ref = order.order_number or f"#{order.id}"
|
||||
results: list[NotificationResult] = []
|
||||
|
||||
if settings.send_customer_confirmation:
|
||||
to = _customer_recipients(db, order)
|
||||
subject = f"Order {order_ref} received"
|
||||
body = (
|
||||
f"Thank you — we have received order {order_ref} for {customer_name}.\n"
|
||||
f"Lines: {len(order.lines)}. Subtotal (ex GST): {order.subtotal_ex_gst:.2f}.\n"
|
||||
"Our team will review and confirm shortly."
|
||||
)
|
||||
delivered = _deliver_email(to=to, subject=subject, body=body, from_email=settings.from_email) if to else False
|
||||
results.append(
|
||||
NotificationResult(
|
||||
channel="customer",
|
||||
recipients=to,
|
||||
subject=subject,
|
||||
delivered=delivered,
|
||||
detail="stubbed" if not delivered else "sent",
|
||||
)
|
||||
)
|
||||
|
||||
internal = _internal_recipients(settings)
|
||||
subject = f"New order {order_ref} from {customer_name}"
|
||||
body = (
|
||||
f"New order {order_ref} submitted by {order.created_by_name or 'a customer user'}.\n"
|
||||
f"Customer: {customer_name}. Lines: {len(order.lines)}. "
|
||||
f"Subtotal (ex GST): {order.subtotal_ex_gst:.2f}."
|
||||
)
|
||||
delivered = _deliver_email(to=internal, subject=subject, body=body, from_email=settings.from_email) if internal else False
|
||||
results.append(
|
||||
NotificationResult(
|
||||
channel="internal",
|
||||
recipients=internal,
|
||||
subject=subject,
|
||||
delivered=delivered,
|
||||
detail="no recipients configured" if not internal else ("stubbed" if not delivered else "sent"),
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Order confirmation PDF generation (reportlab)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from app.models.client_access import ClientAccount
|
||||
from app.models.ordering import Order
|
||||
|
||||
|
||||
class OrderPdfUnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _fmt_money(value: float | None) -> str:
|
||||
return "Quote" if value is None else f"${value:,.2f}"
|
||||
|
||||
|
||||
def build_order_confirmation_pdf(order: Order, customer: ClientAccount | None) -> bytes:
|
||||
try:
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
except ModuleNotFoundError as exc: # pragma: no cover
|
||||
raise OrderPdfUnavailableError(
|
||||
"PDF generation is unavailable because 'reportlab' is not installed."
|
||||
) from exc
|
||||
|
||||
buffer = BytesIO()
|
||||
page_width, page_height = A4
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
margin = 40
|
||||
y = page_height - margin
|
||||
|
||||
pdf.setFont("Helvetica-Bold", 18)
|
||||
pdf.drawString(margin, y, "Order Confirmation")
|
||||
y -= 24
|
||||
|
||||
pdf.setFont("Helvetica", 10)
|
||||
order_ref = order.order_number or f"#{order.id}"
|
||||
pdf.drawString(margin, y, f"Order: {order_ref}")
|
||||
pdf.drawRightString(page_width - margin, y, datetime.utcnow().strftime("%d %b %Y %H:%M UTC"))
|
||||
y -= 14
|
||||
pdf.drawString(margin, y, f"Customer: {customer.name if customer else order.client_account_id}")
|
||||
y -= 14
|
||||
pdf.drawString(margin, y, f"Status: {order.status.replace('_', ' ').title()}")
|
||||
y -= 14
|
||||
if order.purchase_order_number:
|
||||
pdf.drawString(margin, y, f"PO Number: {order.purchase_order_number}")
|
||||
y -= 14
|
||||
pdf.drawString(margin, y, f"Fulfilment: {order.fulfilment_method.title()}")
|
||||
y -= 14
|
||||
if order.requested_delivery_date:
|
||||
pdf.drawString(margin, y, f"Requested date: {order.requested_delivery_date.strftime('%d %b %Y')}")
|
||||
y -= 14
|
||||
y -= 6
|
||||
|
||||
# Table header
|
||||
pdf.setFont("Helvetica-Bold", 9)
|
||||
pdf.setFillColor(colors.HexColor("#22362d"))
|
||||
pdf.drawString(margin, y, "Product")
|
||||
pdf.drawString(margin + 220, y, "SKU")
|
||||
pdf.drawRightString(margin + 330, y, "Qty")
|
||||
pdf.drawRightString(margin + 420, y, "Unit (ex GST)")
|
||||
pdf.drawRightString(page_width - margin, y, "Line total")
|
||||
y -= 6
|
||||
pdf.setStrokeColor(colors.HexColor("#cccccc"))
|
||||
pdf.line(margin, y, page_width - margin, y)
|
||||
y -= 14
|
||||
|
||||
pdf.setFont("Helvetica", 9)
|
||||
pdf.setFillColor(colors.black)
|
||||
for line in order.lines:
|
||||
if y < margin + 60:
|
||||
pdf.showPage()
|
||||
y = page_height - margin
|
||||
pdf.setFont("Helvetica", 9)
|
||||
unit = line.admin_override_price if line.admin_override_price is not None else line.unit_price
|
||||
line_total = None if unit is None else round(unit * line.quantity, 2)
|
||||
pdf.drawString(margin, y, (line.product_name or "")[:38])
|
||||
pdf.drawString(margin + 220, y, (line.product_sku or "")[:16])
|
||||
pdf.drawRightString(margin + 330, y, f"{line.quantity:g}")
|
||||
pdf.drawRightString(margin + 420, y, _fmt_money(unit))
|
||||
pdf.drawRightString(page_width - margin, y, _fmt_money(line_total))
|
||||
y -= 14
|
||||
|
||||
y -= 4
|
||||
pdf.line(margin, y, page_width - margin, y)
|
||||
y -= 16
|
||||
pdf.setFont("Helvetica-Bold", 10)
|
||||
pdf.drawRightString(margin + 420, y, "Subtotal (ex GST)")
|
||||
pdf.drawRightString(page_width - margin, y, _fmt_money(order.subtotal_ex_gst))
|
||||
y -= 20
|
||||
|
||||
if order.requires_quote:
|
||||
pdf.setFont("Helvetica-Oblique", 9)
|
||||
pdf.setFillColor(colors.HexColor("#8a5a00"))
|
||||
pdf.drawString(margin, y, "This order contains quote-only items. Final pricing will be confirmed by our team.")
|
||||
y -= 14
|
||||
|
||||
if order.delivery_notes:
|
||||
pdf.setFont("Helvetica", 9)
|
||||
pdf.setFillColor(colors.black)
|
||||
pdf.drawString(margin, y, f"Notes: {order.delivery_notes[:90]}")
|
||||
|
||||
pdf.setFont("Helvetica-Oblique", 8)
|
||||
pdf.setFillColor(colors.HexColor("#888888"))
|
||||
pdf.drawString(margin, margin - 12, "Prices exclude GST. This is an order confirmation, not a tax invoice.")
|
||||
|
||||
pdf.showPage()
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Customer-specific pricing engine for the B2B ordering portal.
|
||||
|
||||
The backend is the single source of truth for prices — the frontend never
|
||||
computes a final price. :func:`resolve_price` returns a fully-attributed
|
||||
:class:`PriceResolution` so every order line, and any future report, can explain
|
||||
exactly which rule produced the number.
|
||||
|
||||
Resolution priority (highest wins):
|
||||
|
||||
1. **Quote-only** — the product requires a manual quote, or the customer has a
|
||||
``quote`` price rule for it. No automatic price.
|
||||
2. **Fixed / contract** — a :class:`CustomerProductPrice` for this customer +
|
||||
product (quantity tiers may refine it).
|
||||
3. **Price list** — a :class:`PriceListItem` from the customer's assigned price
|
||||
list (quantity tiers may refine it).
|
||||
4. **Base + discount** — the catalogue list price, optionally reduced by the
|
||||
customer's default discount percentage.
|
||||
5. **Quote fallback** — no resolvable price ⇒ treated as quote-only.
|
||||
|
||||
All prices are GST-exclusive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ordering import (
|
||||
CatalogueProduct,
|
||||
CustomerPriceAssignment,
|
||||
CustomerProductPrice,
|
||||
PriceListItem,
|
||||
PriceTier,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PriceResolution:
|
||||
unit_price: float | None
|
||||
price_source: str # one of ordering.PRICE_SOURCES
|
||||
price_rule_id: int | None
|
||||
discount_percent: float
|
||||
requires_quote: bool
|
||||
label: str
|
||||
|
||||
def line_total(self, quantity: float) -> float | None:
|
||||
if self.unit_price is None:
|
||||
return None
|
||||
return round(self.unit_price * quantity, 4)
|
||||
|
||||
|
||||
_SOURCE_LABELS = {
|
||||
"fixed": "Fixed price",
|
||||
"contract": "Contract price",
|
||||
"price_list": "Price list",
|
||||
"tiered": "Tiered price",
|
||||
"base": "List price",
|
||||
"quote": "Quote required",
|
||||
}
|
||||
|
||||
|
||||
def _best_tier_price(
|
||||
db: Session,
|
||||
*,
|
||||
customer_product_price_id: int | None = None,
|
||||
price_list_item_id: int | None = None,
|
||||
quantity: float,
|
||||
) -> float | None:
|
||||
"""Return the unit price of the highest qualifying quantity tier, if any."""
|
||||
stmt = select(PriceTier).where(PriceTier.min_quantity <= quantity)
|
||||
if customer_product_price_id is not None:
|
||||
stmt = stmt.where(PriceTier.customer_product_price_id == customer_product_price_id)
|
||||
elif price_list_item_id is not None:
|
||||
stmt = stmt.where(PriceTier.price_list_item_id == price_list_item_id)
|
||||
else:
|
||||
return None
|
||||
tiers = db.scalars(stmt.order_by(PriceTier.min_quantity.desc())).all()
|
||||
return tiers[0].unit_price if tiers else None
|
||||
|
||||
|
||||
def _quote(label: str = "Quote required") -> PriceResolution:
|
||||
return PriceResolution(
|
||||
unit_price=None,
|
||||
price_source="quote",
|
||||
price_rule_id=None,
|
||||
discount_percent=0.0,
|
||||
requires_quote=True,
|
||||
label=label,
|
||||
)
|
||||
|
||||
|
||||
def get_customer_assignment(
|
||||
db: Session, *, client_account_id: int
|
||||
) -> CustomerPriceAssignment | None:
|
||||
return db.scalar(
|
||||
select(CustomerPriceAssignment).where(
|
||||
CustomerPriceAssignment.client_account_id == client_account_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def resolve_price(
|
||||
db: Session,
|
||||
*,
|
||||
client_account_id: int,
|
||||
product: CatalogueProduct,
|
||||
quantity: float,
|
||||
) -> PriceResolution:
|
||||
"""Resolve the GST-exclusive unit price for a customer + product + quantity."""
|
||||
quantity = max(quantity, 0.0)
|
||||
|
||||
# 1. Product-level manual-quote flag.
|
||||
if product.requires_quote:
|
||||
return _quote()
|
||||
|
||||
# 2. Customer-specific fixed/contract/quote rule.
|
||||
cpp = db.scalar(
|
||||
select(CustomerProductPrice).where(
|
||||
CustomerProductPrice.client_account_id == client_account_id,
|
||||
CustomerProductPrice.product_id == product.id,
|
||||
CustomerProductPrice.active.is_(True),
|
||||
)
|
||||
)
|
||||
if cpp is not None:
|
||||
if cpp.rule_type == "quote":
|
||||
return _quote()
|
||||
tier_price = _best_tier_price(
|
||||
db, customer_product_price_id=cpp.id, quantity=quantity
|
||||
)
|
||||
if tier_price is not None:
|
||||
return PriceResolution(
|
||||
unit_price=round(tier_price, 4),
|
||||
price_source="tiered",
|
||||
price_rule_id=cpp.id,
|
||||
discount_percent=0.0,
|
||||
requires_quote=False,
|
||||
label=_SOURCE_LABELS["tiered"],
|
||||
)
|
||||
if cpp.unit_price is not None:
|
||||
source = "contract" if cpp.rule_type == "contract" else "fixed"
|
||||
return PriceResolution(
|
||||
unit_price=round(cpp.unit_price, 4),
|
||||
price_source=source,
|
||||
price_rule_id=cpp.id,
|
||||
discount_percent=0.0,
|
||||
requires_quote=False,
|
||||
label=_SOURCE_LABELS[source],
|
||||
)
|
||||
|
||||
assignment = get_customer_assignment(db, client_account_id=client_account_id)
|
||||
|
||||
# 3. Assigned price list.
|
||||
if assignment is not None and assignment.price_list_id is not None:
|
||||
pli = db.scalar(
|
||||
select(PriceListItem).where(
|
||||
PriceListItem.price_list_id == assignment.price_list_id,
|
||||
PriceListItem.product_id == product.id,
|
||||
)
|
||||
)
|
||||
if pli is not None:
|
||||
tier_price = _best_tier_price(
|
||||
db, price_list_item_id=pli.id, quantity=quantity
|
||||
)
|
||||
if tier_price is not None:
|
||||
return PriceResolution(
|
||||
unit_price=round(tier_price, 4),
|
||||
price_source="tiered",
|
||||
price_rule_id=pli.id,
|
||||
discount_percent=0.0,
|
||||
requires_quote=False,
|
||||
label=_SOURCE_LABELS["tiered"],
|
||||
)
|
||||
return PriceResolution(
|
||||
unit_price=round(pli.unit_price, 4),
|
||||
price_source="price_list",
|
||||
price_rule_id=pli.id,
|
||||
discount_percent=0.0,
|
||||
requires_quote=False,
|
||||
label=_SOURCE_LABELS["price_list"],
|
||||
)
|
||||
|
||||
# 4. Base price (+ optional customer discount).
|
||||
if product.base_price is not None:
|
||||
discount = assignment.discount_percent if assignment else 0.0
|
||||
discount = min(max(discount, 0.0), 100.0)
|
||||
unit_price = round(product.base_price * (1.0 - discount / 100.0), 4)
|
||||
label = _SOURCE_LABELS["base"]
|
||||
if discount:
|
||||
label = f"List price less {discount:g}%"
|
||||
return PriceResolution(
|
||||
unit_price=unit_price,
|
||||
price_source="base",
|
||||
price_rule_id=product.id,
|
||||
discount_percent=discount,
|
||||
requires_quote=False,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# 5. No resolvable price ⇒ quote.
|
||||
return _quote("No price set — quote required")
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Shared ordering-portal business logic: lifecycle, visibility, totals,
|
||||
serialization, and audit.
|
||||
|
||||
Customer isolation is enforced by callers passing ``tenant_id`` /
|
||||
``client_account_id`` into every query; this module never widens that scope.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.core.config import settings
|
||||
from app.models.client_access import ClientAccount
|
||||
from app.models.ordering import (
|
||||
CatalogueProduct,
|
||||
CustomerProductVisibility,
|
||||
Order,
|
||||
OrderLine,
|
||||
OrderStatusHistory,
|
||||
)
|
||||
from app.services.client_access_service import record_audit_event
|
||||
from app.services.ordering_pricing import resolve_price
|
||||
|
||||
# The ordering catalogue is "standard globally" — it lives under a single
|
||||
# seller/ordering tenant, while individual customers (ClientAccounts) may have
|
||||
# their own tenant_id. Customer isolation therefore keys on the globally-unique
|
||||
# ``client_account_id``, NOT on the customer's session tenant. Admin and
|
||||
# customer code both store/read ordering data under ORDERING_TENANT.
|
||||
ORDERING_TENANT = settings.client_tenant_id
|
||||
|
||||
# --- Lifecycle ---------------------------------------------------------------
|
||||
|
||||
# Full internal lifecycle → the simplified status a customer sees.
|
||||
CUSTOMER_VISIBLE_STATUS = {
|
||||
"draft": "draft",
|
||||
"submitted": "submitted",
|
||||
"under_review": "processing",
|
||||
"confirmed": "confirmed",
|
||||
"sent_to_xero": "confirmed",
|
||||
"in_production": "processing",
|
||||
"ready_for_pickup": "ready",
|
||||
"dispatched": "dispatched",
|
||||
"completed": "completed",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
|
||||
# Statuses an admin may move an order into from a given status. Cancellation is
|
||||
# allowed from any non-terminal status (handled separately).
|
||||
ALLOWED_ADMIN_TRANSITIONS: dict[str, set[str]] = {
|
||||
"draft": {"submitted", "cancelled"},
|
||||
"submitted": {"under_review", "confirmed", "cancelled"},
|
||||
"under_review": {"confirmed", "submitted", "cancelled"},
|
||||
"confirmed": {"sent_to_xero", "in_production", "cancelled"},
|
||||
"sent_to_xero": {"in_production", "cancelled"},
|
||||
"in_production": {"ready_for_pickup", "dispatched", "cancelled"},
|
||||
"ready_for_pickup": {"dispatched", "completed", "cancelled"},
|
||||
"dispatched": {"completed", "cancelled"},
|
||||
"completed": set(),
|
||||
"cancelled": set(),
|
||||
}
|
||||
|
||||
# Customers may only edit an order while it is a draft.
|
||||
CUSTOMER_EDITABLE_STATUSES = {"draft"}
|
||||
|
||||
|
||||
def can_admin_transition(from_status: str, to_status: str) -> bool:
|
||||
if to_status == "cancelled" and from_status not in {"completed", "cancelled"}:
|
||||
return True
|
||||
return to_status in ALLOWED_ADMIN_TRANSITIONS.get(from_status, set())
|
||||
|
||||
|
||||
def record_status_change(
|
||||
db: Session,
|
||||
order: Order,
|
||||
*,
|
||||
to_status: str,
|
||||
actor_type: str,
|
||||
actor_name: str | None,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
OrderStatusHistory(
|
||||
tenant_id=order.tenant_id,
|
||||
order_id=order.id,
|
||||
from_status=order.status,
|
||||
to_status=to_status,
|
||||
actor_type=actor_type,
|
||||
actor_name=actor_name,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
order.status = to_status
|
||||
|
||||
|
||||
def next_order_number(db: Session, tenant_id: str) -> str:
|
||||
# Cheap monotonic-ish numbering based on max id; good enough for v1.
|
||||
last_id = db.scalar(select(Order.id).order_by(Order.id.desc()).limit(1)) or 0
|
||||
return f"ORD-{last_id + 1:06d}"
|
||||
|
||||
|
||||
# --- Product visibility ------------------------------------------------------
|
||||
|
||||
|
||||
def visible_product_ids_for_customer(db: Session, *, tenant_id: str, client_account_id: int) -> set[int]:
|
||||
"""Ids the customer is explicitly *hidden* from (opt-out model)."""
|
||||
rows = db.scalars(
|
||||
select(CustomerProductVisibility.product_id).where(
|
||||
CustomerProductVisibility.client_account_id == client_account_id,
|
||||
CustomerProductVisibility.tenant_id == tenant_id,
|
||||
CustomerProductVisibility.visible.is_(False),
|
||||
)
|
||||
).all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
def list_visible_products(
|
||||
db: Session, *, tenant_id: str, client_account_id: int
|
||||
) -> list[CatalogueProduct]:
|
||||
hidden = visible_product_ids_for_customer(
|
||||
db, tenant_id=tenant_id, client_account_id=client_account_id
|
||||
)
|
||||
products = db.scalars(
|
||||
select(CatalogueProduct)
|
||||
.where(CatalogueProduct.tenant_id == tenant_id, CatalogueProduct.active.is_(True))
|
||||
.order_by(CatalogueProduct.category, CatalogueProduct.name)
|
||||
).all()
|
||||
return [p for p in products if p.id not in hidden]
|
||||
|
||||
|
||||
# --- Totals ------------------------------------------------------------------
|
||||
|
||||
|
||||
def effective_unit_price(line: OrderLine) -> float | None:
|
||||
if line.admin_override_price is not None:
|
||||
return line.admin_override_price
|
||||
return line.unit_price
|
||||
|
||||
|
||||
def recompute_order_totals(order: Order) -> None:
|
||||
subtotal = 0.0
|
||||
requires_quote = False
|
||||
for line in order.lines:
|
||||
unit = effective_unit_price(line)
|
||||
if line.requires_quote or unit is None:
|
||||
requires_quote = True
|
||||
line.line_total = None
|
||||
continue
|
||||
line.line_total = round(unit * line.quantity, 4)
|
||||
subtotal += line.line_total
|
||||
order.subtotal_ex_gst = round(subtotal, 4)
|
||||
order.requires_quote = requires_quote
|
||||
|
||||
|
||||
# --- Serialization -----------------------------------------------------------
|
||||
|
||||
|
||||
def serialize_product(
|
||||
product: CatalogueProduct,
|
||||
*,
|
||||
db: Session | None = None,
|
||||
client_account_id: int | None = None,
|
||||
quantity: float = 1.0,
|
||||
) -> dict:
|
||||
"""Serialize a catalogue product. When ``db`` + ``client_account_id`` are
|
||||
given, attach the resolved customer-specific price + provenance."""
|
||||
data = {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"sku": product.sku,
|
||||
"description": product.description,
|
||||
"category": product.category,
|
||||
"image_url": product.image_url,
|
||||
"unit_size": product.unit_size,
|
||||
"unit_of_measure": product.unit_of_measure,
|
||||
"min_order_quantity": product.min_order_quantity,
|
||||
"stock_status": product.stock_status,
|
||||
"active": product.active,
|
||||
"requires_quote": product.requires_quote,
|
||||
"base_price": product.base_price,
|
||||
"created_at": product.created_at,
|
||||
}
|
||||
if db is not None and client_account_id is not None:
|
||||
resolution = resolve_price(
|
||||
db, client_account_id=client_account_id, product=product, quantity=quantity
|
||||
)
|
||||
data["price"] = {
|
||||
"unit_price": resolution.unit_price,
|
||||
"price_source": resolution.price_source,
|
||||
"price_rule_id": resolution.price_rule_id,
|
||||
"discount_percent": resolution.discount_percent,
|
||||
"requires_quote": resolution.requires_quote,
|
||||
"label": resolution.label,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def serialize_order_line(line: OrderLine, *, for_admin: bool) -> dict:
|
||||
data = {
|
||||
"id": line.id,
|
||||
"product_id": line.product_id,
|
||||
"product_name": line.product_name,
|
||||
"product_sku": line.product_sku,
|
||||
"quantity": line.quantity,
|
||||
"unit_price": effective_unit_price(line),
|
||||
"line_total": line.line_total,
|
||||
"requires_quote": line.requires_quote,
|
||||
"price_source": line.price_source,
|
||||
"discount_percent": line.discount_percent,
|
||||
"notes": line.notes,
|
||||
}
|
||||
if for_admin:
|
||||
data.update(
|
||||
{
|
||||
"resolved_unit_price": line.unit_price,
|
||||
"admin_override_price": line.admin_override_price,
|
||||
"admin_override_reason": line.admin_override_reason,
|
||||
"price_rule_id": line.price_rule_id,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def serialize_order(order: Order, *, for_admin: bool) -> dict:
|
||||
status = order.status if for_admin else CUSTOMER_VISIBLE_STATUS.get(order.status, order.status)
|
||||
data = {
|
||||
"id": order.id,
|
||||
"order_number": order.order_number,
|
||||
"status": status,
|
||||
"client_account_id": order.client_account_id,
|
||||
"created_by_name": order.created_by_name,
|
||||
"purchase_order_number": order.purchase_order_number,
|
||||
"delivery_notes": order.delivery_notes,
|
||||
"requested_delivery_date": order.requested_delivery_date,
|
||||
"fulfilment_method": order.fulfilment_method,
|
||||
"subtotal_ex_gst": order.subtotal_ex_gst,
|
||||
"requires_quote": order.requires_quote,
|
||||
"submitted_at": order.submitted_at,
|
||||
"created_at": order.created_at,
|
||||
"updated_at": order.updated_at,
|
||||
"editable": order.status in CUSTOMER_EDITABLE_STATUSES,
|
||||
"lines": [serialize_order_line(line, for_admin=for_admin) for line in order.lines],
|
||||
}
|
||||
if for_admin:
|
||||
data.update(
|
||||
{
|
||||
"raw_status": order.status,
|
||||
"admin_notes": order.admin_notes,
|
||||
"reopened": order.reopened,
|
||||
"xero_status": order.xero_status,
|
||||
"xero_invoice_id": order.xero_invoice_id,
|
||||
"status_history": [
|
||||
{
|
||||
"id": h.id,
|
||||
"from_status": h.from_status,
|
||||
"to_status": h.to_status,
|
||||
"actor_type": h.actor_type,
|
||||
"actor_name": h.actor_name,
|
||||
"note": h.note,
|
||||
"created_at": h.created_at,
|
||||
}
|
||||
for h in order.status_history
|
||||
],
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
# --- Audit -------------------------------------------------------------------
|
||||
|
||||
|
||||
def audit_order_event(
|
||||
db: Session,
|
||||
*,
|
||||
session: AuthSession,
|
||||
order: Order,
|
||||
action: str,
|
||||
summary: str,
|
||||
target_id: int | None = None,
|
||||
) -> None:
|
||||
"""Record an ordering audit event onto the shared audit trail."""
|
||||
actor_type = "lean_admin" if session.role in {"admin", "internal"} else "customer"
|
||||
record_audit_event(
|
||||
db,
|
||||
tenant_id=order.tenant_id,
|
||||
client_account_id=order.client_account_id,
|
||||
actor_type=actor_type,
|
||||
actor_name=session.name or "",
|
||||
actor_email=session.email or "",
|
||||
actor_role=session.client_role or session.role,
|
||||
action=action,
|
||||
target_type="order",
|
||||
target_id=target_id if target_id is not None else order.id,
|
||||
module_key="ordering",
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def get_customer_account(db: Session, *, client_account_id: int) -> ClientAccount | None:
|
||||
# client_account_id is a global primary key; a customer's own tenant_id may
|
||||
# differ from ORDERING_TENANT, so we look up by id alone.
|
||||
return db.scalar(select(ClientAccount).where(ClientAccount.id == client_account_id))
|
||||
|
||||
|
||||
def ensure_customer_active(account: ClientAccount | None) -> None:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
if account is None:
|
||||
raise HTTPException(status_code=404, detail="Customer account not found")
|
||||
if account.status != "active":
|
||||
raise HTTPException(status_code=403, detail="This customer account is disabled")
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Xero integration layer for the ordering portal.
|
||||
|
||||
This is a **clean, stubbed interface**. No Xero credentials are hard-coded — all
|
||||
configuration comes from environment variables (``XERO_*``). When credentials
|
||||
are absent (the default in dev/alpha), the service runs in "stub" mode: it
|
||||
records what *would* be sent and returns a deterministic fake invoice id so the
|
||||
rest of the order lifecycle can be exercised end-to-end.
|
||||
|
||||
To go live, implement the real HTTP calls inside :func:`_submit_to_xero_api`
|
||||
(see the TODOs) — no caller needs to change.
|
||||
|
||||
Service responsibilities (per spec):
|
||||
* Map a customer (ClientAccount) to a Xero contact.
|
||||
* Map catalogue products to Xero item codes.
|
||||
* Create a draft invoice equivalent for a confirmed order.
|
||||
* Record Xero response ids/status (via XeroSyncLog, written by the caller).
|
||||
* Handle failures gracefully (never raise into the request path).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.client_access import ClientAccount
|
||||
from app.models.ordering import Order
|
||||
|
||||
|
||||
@dataclass
|
||||
class XeroConfig:
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
tenant_id: str | None = None
|
||||
base_url: str = "https://api.xero.com/api.xro/2.0"
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret and self.tenant_id)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "XeroConfig":
|
||||
return cls(
|
||||
client_id=os.getenv("XERO_CLIENT_ID") or None,
|
||||
client_secret=os.getenv("XERO_CLIENT_SECRET") or None,
|
||||
tenant_id=os.getenv("XERO_TENANT_ID") or None,
|
||||
base_url=os.getenv("XERO_API_BASE_URL", "https://api.xero.com/api.xro/2.0"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class XeroSubmissionResult:
|
||||
status: str # "success" | "failed"
|
||||
xero_invoice_id: str | None
|
||||
message: str
|
||||
request_summary: str
|
||||
stubbed: bool = False
|
||||
line_items: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def map_customer_to_contact(customer: ClientAccount) -> dict:
|
||||
"""Map a customer account onto a Xero contact payload."""
|
||||
return {
|
||||
# TODO: persist and reuse a real Xero ContactID once the contact has
|
||||
# been created/matched in Xero. For now we key on the client code.
|
||||
"ContactNumber": customer.client_code,
|
||||
"Name": customer.name,
|
||||
}
|
||||
|
||||
|
||||
def map_product_to_item_code(product_sku: str) -> str:
|
||||
"""Map a catalogue SKU onto a Xero item code.
|
||||
|
||||
TODO: support an explicit SKU→Xero item-code mapping table if the codes
|
||||
diverge. Today the SKU is used directly.
|
||||
"""
|
||||
return product_sku
|
||||
|
||||
|
||||
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
|
||||
"""Build the Xero draft-invoice payload for a confirmed order."""
|
||||
line_items = []
|
||||
for line in order.lines:
|
||||
if line.requires_quote or line.unit_price is None:
|
||||
# Quote-only lines can't carry a price; skip until quoted.
|
||||
continue
|
||||
unit_price = line.admin_override_price if line.admin_override_price is not None else line.unit_price
|
||||
line_items.append(
|
||||
{
|
||||
"ItemCode": map_product_to_item_code(line.product_sku),
|
||||
"Description": line.product_name,
|
||||
"Quantity": line.quantity,
|
||||
"UnitAmount": unit_price,
|
||||
"AccountCode": os.getenv("XERO_SALES_ACCOUNT_CODE", "200"),
|
||||
# Prices are GST-exclusive throughout the platform.
|
||||
"TaxType": os.getenv("XERO_TAX_TYPE", "OUTPUT"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"Type": "ACCREC",
|
||||
"Status": "DRAFT",
|
||||
"Contact": map_customer_to_contact(customer),
|
||||
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
|
||||
"LineAmountTypes": "Exclusive",
|
||||
"LineItems": line_items,
|
||||
}
|
||||
|
||||
|
||||
def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResult:
|
||||
"""Real Xero submission. Stubbed until credentials/endpoints are wired.
|
||||
|
||||
TODO (go-live):
|
||||
1. Obtain an OAuth2 token (client-credentials or stored refresh token).
|
||||
2. POST ``payload`` to ``{config.base_url}/Invoices`` with the
|
||||
``Xero-tenant-id`` header set to ``config.tenant_id``.
|
||||
3. Parse ``Invoices[0].InvoiceID`` from the response.
|
||||
4. Map non-2xx responses onto ``status="failed"`` with the error body.
|
||||
"""
|
||||
summary = f"{len(payload.get('LineItems', []))} line(s) for {payload['Contact']['Name']}"
|
||||
# Real call would go here. Intentionally not implemented yet.
|
||||
return XeroSubmissionResult(
|
||||
status="failed",
|
||||
xero_invoice_id=None,
|
||||
message="Xero live submission is not implemented yet (stub interface).",
|
||||
request_summary=summary,
|
||||
stubbed=False,
|
||||
line_items=payload.get("LineItems", []),
|
||||
)
|
||||
|
||||
|
||||
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult:
|
||||
"""Submit a confirmed order to Xero, or stub it when unconfigured.
|
||||
|
||||
Never raises — failures are returned as ``status="failed"`` results so the
|
||||
order lifecycle can record the attempt and continue.
|
||||
"""
|
||||
config = XeroConfig.from_env()
|
||||
payload = build_invoice_payload(order, customer)
|
||||
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
|
||||
|
||||
if not config.configured:
|
||||
# Stub mode: deterministic fake invoice id so downstream flows work.
|
||||
fake_id = f"STUB-INV-{order.id:06d}"
|
||||
return XeroSubmissionResult(
|
||||
status="success",
|
||||
xero_invoice_id=fake_id,
|
||||
message="Xero not configured — order recorded in stub mode.",
|
||||
request_summary=summary,
|
||||
stubbed=True,
|
||||
line_items=payload["LineItems"],
|
||||
)
|
||||
|
||||
try:
|
||||
return _submit_to_xero_api(config, payload)
|
||||
except Exception as exc: # pragma: no cover - defensive: never break the request path
|
||||
return XeroSubmissionResult(
|
||||
status="failed",
|
||||
xero_invoice_id=None,
|
||||
message=f"Xero submission error: {exc}",
|
||||
request_summary=summary,
|
||||
stubbed=False,
|
||||
line_items=payload["LineItems"],
|
||||
)
|
||||
|
||||
|
||||
def xero_status_snapshot() -> dict:
|
||||
config = XeroConfig.from_env()
|
||||
return {
|
||||
"configured": config.configured,
|
||||
"mode": "live" if config.configured else "stub",
|
||||
"base_url": config.base_url,
|
||||
"checked_at": datetime.utcnow().isoformat(),
|
||||
"missing_env": [
|
||||
name
|
||||
for name, value in (
|
||||
("XERO_CLIENT_ID", config.client_id),
|
||||
("XERO_CLIENT_SECRET", config.client_secret),
|
||||
("XERO_TENANT_ID", config.tenant_id),
|
||||
)
|
||||
if not value
|
||||
],
|
||||
}
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-entry-app-backend"
|
||||
version = "0.1.12"
|
||||
version = "0.1.14"
|
||||
description = "Costing platform MVP backend"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""End-to-end ordering flow test exercising the acceptance criteria.
|
||||
|
||||
Drives the real FastAPI app via TestClient against an in-memory database, using
|
||||
directly-issued auth tokens (bypassing the password-based login endpoints).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.access import INTERNAL_USER_SUBJECT
|
||||
from app.core.config import settings
|
||||
from app.core.security import issue_token
|
||||
from app.db.session import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.access import User
|
||||
from app.models.client_access import ClientUser
|
||||
from app.seed_access import seed_access
|
||||
from app.services import ordering_service as svc
|
||||
|
||||
ORDERING_TENANT = svc.ORDERING_TENANT
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
def override_get_db():
|
||||
db = TestingSession()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app) as test_client:
|
||||
test_client._session_factory = TestingSession # type: ignore[attr-defined]
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _customer_headers(db_session_factory, *, account_id: int) -> dict[str, str]:
|
||||
db = db_session_factory()
|
||||
user = db.scalar(select(ClientUser).where(ClientUser.client_account_id == account_id))
|
||||
payload = {
|
||||
"name": user.full_name,
|
||||
"email": user.email,
|
||||
"role": "client",
|
||||
"tenant_id": user.tenant_id,
|
||||
"client_role": user.role,
|
||||
"user_id": user.id,
|
||||
"client_account_id": user.client_account_id,
|
||||
}
|
||||
db.close()
|
||||
token = issue_token(payload)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_full_b2b_ordering_flow(client):
|
||||
admin = _admin_headers()
|
||||
|
||||
# 1. Admin creates a customer.
|
||||
r = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Acme Farms", "client_code": "ACME"})
|
||||
assert r.status_code == 201, r.text
|
||||
customer = r.json()
|
||||
customer_id = customer["id"]
|
||||
|
||||
# 2. Admin creates a user for that customer.
|
||||
r = client.post(
|
||||
f"/api/ordering-admin/customers/{customer_id}/users",
|
||||
headers=admin,
|
||||
json={"full_name": "Buyer Bob", "email": "bob@acme.test", "role": "buyer"},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
# 3. Admin creates products.
|
||||
r = client.post(
|
||||
"/api/ordering-admin/products",
|
||||
headers=admin,
|
||||
json={"name": "Maize 1T", "sku": "MAIZE-1T", "category": "grains", "base_price": 100.0, "min_order_quantity": 1},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
product = r.json()
|
||||
product_id = product["id"]
|
||||
|
||||
r = client.post(
|
||||
"/api/ordering-admin/products",
|
||||
headers=admin,
|
||||
json={"name": "Hidden Blend", "sku": "HIDDEN-1", "category": "custom_blends", "base_price": 50.0},
|
||||
)
|
||||
hidden_product_id = r.json()["id"]
|
||||
|
||||
# 4. Admin assigns a customer-specific price (contract @ 92.5).
|
||||
r = client.put(
|
||||
f"/api/ordering-admin/customers/{customer_id}/product-prices",
|
||||
headers=admin,
|
||||
json={"product_id": product_id, "unit_price": 92.5, "rule_type": "contract"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# 4b. Admin hides the second product from this customer.
|
||||
r = client.put(
|
||||
f"/api/ordering-admin/customers/{customer_id}/visibility",
|
||||
headers=admin,
|
||||
json={"product_id": hidden_product_id, "visible": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# 5. Customer logs in (token) and sees only their available product + price.
|
||||
cust = _customer_headers(client._session_factory, account_id=customer_id)
|
||||
r = client.get("/api/ordering/catalogue", headers=cust)
|
||||
assert r.status_code == 200, r.text
|
||||
catalogue = r.json()
|
||||
skus = {p["sku"] for p in catalogue}
|
||||
assert skus == {"MAIZE-1T"} # hidden product is not visible
|
||||
maize = catalogue[0]
|
||||
assert maize["price"]["unit_price"] == 92.5
|
||||
assert maize["price"]["price_source"] == "contract"
|
||||
|
||||
# 6. Customer creates a draft order.
|
||||
r = client.post(
|
||||
"/api/ordering/orders",
|
||||
headers=cust,
|
||||
json={"lines": [{"product_id": product_id, "quantity": 3}], "fulfilment_method": "delivery"},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
draft = r.json()
|
||||
assert draft["status"] == "draft"
|
||||
assert draft["subtotal_ex_gst"] == 277.5 # 3 * 92.5
|
||||
order_id = draft["id"]
|
||||
|
||||
# Minimum-order-quantity validation.
|
||||
r = client.post(
|
||||
"/api/ordering/orders",
|
||||
headers=cust,
|
||||
json={"lines": [{"product_id": product_id, "quantity": 0.5}]},
|
||||
)
|
||||
# quantity below MOQ of 1 → caught by schema (gt=0 ok) then MOQ check 422.
|
||||
assert r.status_code == 422
|
||||
|
||||
# 7. Customer submits the order — price is frozen on the line.
|
||||
r = client.post(f"/api/ordering/orders/{order_id}/submit", headers=cust, json={"purchase_order_number": "PO-77"})
|
||||
assert r.status_code == 200, r.text
|
||||
submitted = r.json()
|
||||
assert submitted["status"] == "submitted"
|
||||
assert submitted["order_number"].startswith("ORD-")
|
||||
assert submitted["lines"][0]["unit_price"] == 92.5
|
||||
assert submitted["purchase_order_number"] == "PO-77"
|
||||
|
||||
# Customer can no longer edit a submitted order.
|
||||
r = client.patch(f"/api/ordering/orders/{order_id}", headers=cust, json={"delivery_notes": "late edit"})
|
||||
assert r.status_code == 409
|
||||
|
||||
# 8. Admin sees the submitted order and manages it.
|
||||
r = client.get("/api/ordering-admin/orders", headers=admin)
|
||||
assert r.status_code == 200, r.text
|
||||
admin_orders = r.json()
|
||||
assert any(o["id"] == order_id for o in admin_orders)
|
||||
assert admin_orders[0]["customer_name"] == "Acme Farms"
|
||||
|
||||
# Status progression with lifecycle enforcement.
|
||||
r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "confirmed"})
|
||||
assert r.status_code == 200, r.text
|
||||
# Illegal jump is rejected.
|
||||
r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "completed"})
|
||||
assert r.status_code == 409
|
||||
|
||||
# 9. Xero submission (stub) succeeds and records an invoice id.
|
||||
r = client.post(f"/api/ordering-admin/orders/{order_id}/send-to-xero", headers=admin)
|
||||
assert r.status_code == 200, r.text
|
||||
xero = r.json()
|
||||
assert xero["xero_result"]["status"] == "success"
|
||||
assert xero["xero_result"]["stubbed"] is True
|
||||
assert xero["xero_invoice_id"].startswith("STUB-INV-")
|
||||
assert xero["raw_status"] == "sent_to_xero"
|
||||
|
||||
|
||||
def test_customer_isolation_between_companies(client):
|
||||
admin = _admin_headers()
|
||||
# Two customers, each with a user.
|
||||
a = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Alpha", "client_code": "ALPHA"}).json()
|
||||
b = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Beta", "client_code": "BETA"}).json()
|
||||
client.post(f"/api/ordering-admin/customers/{a['id']}/users", headers=admin, json={"full_name": "A1", "email": "a1@alpha.test", "role": "owner"})
|
||||
client.post(f"/api/ordering-admin/customers/{b['id']}/users", headers=admin, json={"full_name": "B1", "email": "b1@beta.test", "role": "owner"})
|
||||
product = client.post("/api/ordering-admin/products", headers=admin, json={"name": "Barley 1T", "sku": "BAR-1T", "category": "grains", "base_price": 80.0}).json()
|
||||
|
||||
headers_a = _customer_headers(client._session_factory, account_id=a["id"])
|
||||
headers_b = _customer_headers(client._session_factory, account_id=b["id"])
|
||||
|
||||
# Alpha creates and submits an order.
|
||||
order = client.post("/api/ordering/orders", headers=headers_a, json={"lines": [{"product_id": product["id"], "quantity": 2}]}).json()
|
||||
# Beta must not be able to read Alpha's order.
|
||||
r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_b)
|
||||
assert r.status_code == 404
|
||||
# Alpha can.
|
||||
r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_a)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_internal_staff_can_manage_orders(client):
|
||||
# Internal Hunter Stock Feeds staff (not the legacy /admin login) manage the
|
||||
# ordering portal via the client/internal session + manage_ordering perm.
|
||||
db = client._session_factory()
|
||||
seed_access(db)
|
||||
db.commit()
|
||||
admin_user = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||
user_id = admin_user.id
|
||||
db.close()
|
||||
|
||||
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user_id})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Can list customers and create catalogue products through the admin API.
|
||||
assert client.get("/api/ordering-admin/customers", headers=headers).status_code == 200
|
||||
r = client.post(
|
||||
"/api/ordering-admin/products",
|
||||
headers=headers,
|
||||
json={"name": "Staff Wheat", "sku": "WHEAT-STAFF", "category": "grains", "base_price": 30.0},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
|
||||
def test_disabled_customer_cannot_order(client):
|
||||
admin = _admin_headers()
|
||||
cust = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Gamma", "client_code": "GAMMA"}).json()
|
||||
client.post(f"/api/ordering-admin/customers/{cust['id']}/users", headers=admin, json={"full_name": "G1", "email": "g1@gamma.test", "role": "buyer"})
|
||||
client.post("/api/ordering-admin/products", headers=admin, json={"name": "Oats 1T", "sku": "OATS-1T", "category": "grains", "base_price": 70.0})
|
||||
|
||||
# Disable the customer account.
|
||||
r = client.patch(f"/api/ordering-admin/customers/{cust['id']}", headers=admin, json={"status": "disabled"})
|
||||
assert r.status_code == 200
|
||||
|
||||
headers = _customer_headers(client._session_factory, account_id=cust["id"])
|
||||
r = client.get("/api/ordering/catalogue", headers=headers)
|
||||
assert r.status_code == 403
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Unit tests for the backend-owned ordering pricing engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.session import Base
|
||||
from app.models.ordering import (
|
||||
CatalogueProduct,
|
||||
CustomerPriceAssignment,
|
||||
CustomerProductPrice,
|
||||
PriceList,
|
||||
PriceListItem,
|
||||
PriceTier,
|
||||
)
|
||||
from app.services.ordering_pricing import resolve_price
|
||||
|
||||
CUSTOMER_ID = 1
|
||||
TENANT = "test-tenant"
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||
|
||||
|
||||
def _product(db: Session, **overrides) -> CatalogueProduct:
|
||||
values = {
|
||||
"tenant_id": TENANT,
|
||||
"name": "Maize 1T",
|
||||
"sku": "MAIZE-1T",
|
||||
"category": "grains",
|
||||
"base_price": 100.0,
|
||||
"min_order_quantity": 1.0,
|
||||
"requires_quote": False,
|
||||
}
|
||||
values.update(overrides)
|
||||
product = CatalogueProduct(**values)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
return product
|
||||
|
||||
|
||||
def test_base_price_with_no_rules():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=2)
|
||||
assert res.unit_price == 100.0
|
||||
assert res.price_source == "base"
|
||||
assert res.requires_quote is False
|
||||
assert res.line_total(2) == 200.0
|
||||
|
||||
|
||||
def test_customer_discount_applies_to_base():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, discount_percent=10.0))
|
||||
db.flush()
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.unit_price == 90.0
|
||||
assert res.price_source == "base"
|
||||
assert res.discount_percent == 10.0
|
||||
|
||||
|
||||
def test_price_list_overrides_base():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
|
||||
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id, discount_percent=10.0))
|
||||
db.flush()
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
# Price list wins over base+discount.
|
||||
assert res.unit_price == 80.0
|
||||
assert res.price_source == "price_list"
|
||||
|
||||
|
||||
def test_customer_fixed_price_wins_over_price_list():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
|
||||
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id))
|
||||
db.add(
|
||||
CustomerProductPrice(
|
||||
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=72.5, rule_type="contract"
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.unit_price == 72.5
|
||||
assert res.price_source == "contract"
|
||||
|
||||
|
||||
def test_quantity_tier_wins_when_threshold_met():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
cpp = CustomerProductPrice(
|
||||
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=90.0, rule_type="fixed"
|
||||
)
|
||||
db.add(cpp)
|
||||
db.flush()
|
||||
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=10, unit_price=85.0))
|
||||
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=50, unit_price=80.0))
|
||||
db.flush()
|
||||
|
||||
# Below first tier → flat fixed price.
|
||||
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=5).unit_price == 90.0
|
||||
# Meets first tier.
|
||||
mid = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=10)
|
||||
assert mid.unit_price == 85.0
|
||||
assert mid.price_source == "tiered"
|
||||
# Meets highest tier.
|
||||
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=60).unit_price == 80.0
|
||||
|
||||
|
||||
def test_product_requires_quote():
|
||||
db = _session()
|
||||
product = _product(db, requires_quote=True)
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.requires_quote is True
|
||||
assert res.unit_price is None
|
||||
assert res.price_source == "quote"
|
||||
assert res.line_total(5) is None
|
||||
|
||||
|
||||
def test_customer_quote_rule_forces_quote():
|
||||
db = _session()
|
||||
product = _product(db)
|
||||
db.add(
|
||||
CustomerProductPrice(
|
||||
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=None, rule_type="quote"
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.requires_quote is True
|
||||
assert res.price_source == "quote"
|
||||
|
||||
|
||||
def test_no_price_falls_back_to_quote():
|
||||
db = _session()
|
||||
product = _product(db, base_price=None)
|
||||
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
|
||||
assert res.requires_quote is True
|
||||
assert res.price_source == "quote"
|
||||
Reference in New Issue
Block a user