v0.1.14 - b2b portal

This commit is contained in:
2026-06-11 23:56:02 +12:00
parent 349e4a4b5b
commit 4ff372d307
48 changed files with 5845 additions and 925 deletions
+23
View File
@@ -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),
+390
View File
@@ -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
+3
View File
@@ -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"),
+15
View File
@@ -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,
}
+4
View File
@@ -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)
+30
View File
@@ -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",
+390
View File
@@ -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)
+302
View File
@@ -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
+17
View File
@@ -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)
+12
View File
@@ -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",
+172
View File
@@ -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
+15 -4
View File
@@ -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"
+119
View File
@@ -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
+112
View File
@@ -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()
+201
View File
@@ -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")
+313
View File
@@ -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")
+181
View File
@@ -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
],
}