From 4ff372d3071d55ef0e387d854bceb8cfb687f7eb Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Thu, 11 Jun 2026 23:56:02 +1200 Subject: [PATCH] v0.1.14 - b2b portal --- backend/app/api/deps.py | 23 + backend/app/api/ordering.py | 390 +++++++ backend/app/api/ordering_admin.py | 1001 +++++++++++++++++ backend/app/core/access.py | 3 + backend/app/db/migrations.py | 15 + backend/app/main.py | 4 + backend/app/models/__init__.py | 30 + backend/app/models/ordering.py | 390 +++++++ backend/app/schemas/ordering.py | 302 +++++ backend/app/seed.py | 17 + backend/app/seed_access.py | 12 + backend/app/seed_ordering.py | 172 +++ backend/app/services/client_access_service.py | 19 +- backend/app/services/order_notifications.py | 119 ++ backend/app/services/order_pdf.py | 112 ++ backend/app/services/ordering_pricing.py | 201 ++++ backend/app/services/ordering_service.py | 313 ++++++ backend/app/services/xero_service.py | 181 +++ backend/pyproject.toml | 2 +- backend/tests/test_ordering_flow.py | 249 ++++ backend/tests/test_ordering_pricing.py | 156 +++ frontend/package.json | 2 +- frontend/src/app.html | 11 +- frontend/src/lib/api.ts | 96 +- frontend/src/lib/changelog.ts | 48 + frontend/src/lib/components/AdminShell.svelte | 391 ------- .../src/lib/components/ClientShell.svelte | 53 + .../lib/components/CustomerPortalShell.svelte | 383 +++++++ .../src/lib/components/WhatsNewDialog.svelte | 173 +++ .../mix-calculator/MixCalculatorEditor.svelte | 8 +- .../navigation/ClientPrimaryRail.svelte | 5 +- .../src/lib/navigation/client-navigation.ts | 14 + frontend/src/lib/styles/theme.css | 23 +- frontend/src/lib/theme.ts | 26 +- frontend/src/lib/types.ts | 165 +++ frontend/src/lib/whats-new.ts | 37 + frontend/src/lib/workspace-access.ts | 57 + frontend/src/routes/+layout.svelte | 14 +- frontend/src/routes/+page.svelte | 13 +- frontend/src/routes/admin/+page.svelte | 394 ------- frontend/src/routes/admin/+page.ts | 41 - .../routes/admin/client-access/+page.svelte | 7 - .../src/routes/admin/client-access/+page.ts | 41 - frontend/src/routes/load-fetch.test.ts | 8 - frontend/src/routes/ordering/+page.svelte | 401 +++++++ frontend/src/routes/ordering/+page.ts | 27 + .../src/routes/ordering/manage/+page.svelte | 591 ++++++++++ frontend/src/routes/ordering/manage/+page.ts | 30 + 48 files changed, 5845 insertions(+), 925 deletions(-) create mode 100644 backend/app/api/ordering.py create mode 100644 backend/app/api/ordering_admin.py create mode 100644 backend/app/models/ordering.py create mode 100644 backend/app/schemas/ordering.py create mode 100644 backend/app/seed_ordering.py create mode 100644 backend/app/services/order_notifications.py create mode 100644 backend/app/services/order_pdf.py create mode 100644 backend/app/services/ordering_pricing.py create mode 100644 backend/app/services/ordering_service.py create mode 100644 backend/app/services/xero_service.py create mode 100644 backend/tests/test_ordering_flow.py create mode 100644 backend/tests/test_ordering_pricing.py create mode 100644 frontend/src/lib/changelog.ts delete mode 100644 frontend/src/lib/components/AdminShell.svelte create mode 100644 frontend/src/lib/components/CustomerPortalShell.svelte create mode 100644 frontend/src/lib/components/WhatsNewDialog.svelte create mode 100644 frontend/src/lib/whats-new.ts delete mode 100644 frontend/src/routes/admin/+page.svelte delete mode 100644 frontend/src/routes/admin/+page.ts delete mode 100644 frontend/src/routes/admin/client-access/+page.svelte delete mode 100644 frontend/src/routes/admin/client-access/+page.ts create mode 100644 frontend/src/routes/ordering/+page.svelte create mode 100644 frontend/src/routes/ordering/+page.ts create mode 100644 frontend/src/routes/ordering/manage/+page.svelte create mode 100644 frontend/src/routes/ordering/manage/+page.ts diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index d813856..544341a 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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), diff --git a/backend/app/api/ordering.py b/backend/app/api/ordering.py new file mode 100644 index 0000000..3f91cab --- /dev/null +++ b/backend/app/api/ordering.py @@ -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}"'}, + ) diff --git a/backend/app/api/ordering_admin.py b/backend/app/api/ordering_admin.py new file mode 100644 index 0000000..16c3124 --- /dev/null +++ b/backend/app/api/ordering_admin.py @@ -0,0 +1,1001 @@ +"""Admin / internal management API for the B2B ordering portal. + +Gated by :func:`require_ordering_admin_session` (the Lean admin, or an internal +user with ``manage`` on the ordering module). Operates across all customers +within the seller's ordering tenant. +""" +from __future__ import annotations + +import re + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, selectinload + +from app.api.deps import AuthSession, require_ordering_admin_session +from app.db.session import get_db +from app.models.client_access import ClientAccount, ClientUser +from app.models.ordering import ( + CatalogueProduct, + CustomerPriceAssignment, + CustomerProductPrice, + CustomerProductVisibility, + Order, + OrderLine, + PriceList, + PriceListItem, + PriceTier, + ProductCategory, + XeroSyncLog, +) +from app.schemas.ordering import ( + CatalogueProductCreate, + CatalogueProductUpdate, + CategoryCreate, + CategoryUpdate, + CustomerCreate, + CustomerPriceAssignmentUpsert, + CustomerProductPriceUpsert, + CustomerUpdate, + CustomerUserCreate, + CustomerUserUpdate, + NotificationSettingsUpdate, + OrderLineOverride, + OrderStatusUpdate, + PriceListCreate, + PriceListItemUpsert, + ReopenOrderRequest, + VisibilityUpdate, +) +from app.services import ordering_service as svc +from app.services.client_access_service import ( + ensure_user_module_permissions, + record_audit_event, +) +from app.services.order_notifications import get_or_create_settings +from app.services.xero_service import submit_order_to_xero, xero_status_snapshot + +router = APIRouter(prefix="/api/ordering-admin", tags=["ordering-admin"]) + +TENANT = svc.ORDERING_TENANT + + +# --- Helpers ----------------------------------------------------------------- + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-") + return slug or "item" + + +def _actor(session: AuthSession) -> dict[str, str]: + return { + "actor_type": "lean_admin", + "actor_name": session.name or "Admin", + "actor_email": session.email or "", + "actor_role": session.client_role or session.role, + } + + +def _customer_or_404(db: Session, customer_id: int) -> ClientAccount: + account = db.scalar(select(ClientAccount).where(ClientAccount.id == customer_id)) + if account is None: + raise HTTPException(status_code=404, detail="Customer not found") + return account + + +def _product_or_404(db: Session, product_id: int) -> CatalogueProduct: + product = db.scalar( + select(CatalogueProduct).where( + CatalogueProduct.id == product_id, CatalogueProduct.tenant_id == TENANT + ) + ) + if product is None: + raise HTTPException(status_code=404, detail="Product not found") + return product + + +def _load_order(db: Session, order_id: int) -> Order: + order = db.scalar( + select(Order) + .where(Order.id == order_id, Order.tenant_id == TENANT) + .options(selectinload(Order.lines), selectinload(Order.status_history)) + ) + if order is None: + raise HTTPException(status_code=404, detail="Order not found") + return order + + +def _serialize_tiers(db: Session, *, customer_product_price_id=None, price_list_item_id=None) -> list[dict]: + stmt = select(PriceTier) + if customer_product_price_id is not None: + stmt = stmt.where(PriceTier.customer_product_price_id == customer_product_price_id) + else: + stmt = stmt.where(PriceTier.price_list_item_id == price_list_item_id) + return [ + {"id": t.id, "min_quantity": t.min_quantity, "unit_price": t.unit_price} + for t in db.scalars(stmt.order_by(PriceTier.min_quantity)).all() + ] + + +# --- Customers --------------------------------------------------------------- + + +def _serialize_customer(db: Session, account: ClientAccount) -> dict: + users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all() + assignment = db.scalar( + select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id) + ) + return { + "id": account.id, + "name": account.name, + "client_code": account.client_code, + "tenant_id": account.tenant_id, + "status": account.status, + "notes": account.notes, + "user_count": len(users), + "price_list_id": assignment.price_list_id if assignment else None, + "discount_percent": assignment.discount_percent if assignment else 0.0, + "created_at": account.created_at, + } + + +@router.get("/customers") +def list_customers( + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + accounts = db.scalars(select(ClientAccount).order_by(ClientAccount.name)).all() + return [_serialize_customer(db, a) for a in accounts] + + +@router.post("/customers", status_code=status.HTTP_201_CREATED) +def create_customer( + payload: CustomerCreate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + tenant_id = (payload.tenant_id or _slugify(payload.client_code)).strip() or _slugify(payload.name) + account = ClientAccount( + tenant_id=tenant_id, + name=payload.name, + client_code=payload.client_code, + status="active", + notes=payload.notes, + ) + db.add(account) + try: + db.flush() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="A customer with that name or code already exists") from exc + + # Enable the ordering feature for the new customer so its users can access + # the portal once granted module permissions. + from app.models.client_access import ClientFeatureAccess + from app.services.client_access_service import MODULE_INDEX + + info = MODULE_INDEX["ordering"] + db.add( + ClientFeatureAccess( + tenant_id=account.tenant_id, + client_account_id=account.id, + feature_key="ordering", + feature_name=info["module_name"], + feature_group=info["module_group"], + description=info["description"], + enabled=True, + ) + ) + record_audit_event( + db, + tenant_id=account.tenant_id, + client_account_id=account.id, + action="customer.created", + target_type="client_account", + target_id=account.id, + module_key="ordering", + summary=f"Customer {account.name} created for ordering.", + **_actor(session), + ) + db.commit() + db.refresh(account) + return _serialize_customer(db, account) + + +@router.patch("/customers/{customer_id}") +def update_customer( + customer_id: int, + payload: CustomerUpdate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + account = _customer_or_404(db, customer_id) + for field_name, value in payload.model_dump(exclude_unset=True).items(): + setattr(account, field_name, value) + record_audit_event( + db, + tenant_id=account.tenant_id, + client_account_id=account.id, + action="customer.updated", + target_type="client_account", + target_id=account.id, + module_key="ordering", + summary=f"Customer {account.name} updated (status {account.status}).", + **_actor(session), + ) + db.commit() + db.refresh(account) + return _serialize_customer(db, account) + + +# --- Customer users ---------------------------------------------------------- + + +def _serialize_user(user: ClientUser) -> dict: + return { + "id": user.id, + "client_account_id": user.client_account_id, + "full_name": user.full_name, + "email": user.email, + "role": user.role, + "status": user.status, + "created_at": user.created_at, + } + + +@router.get("/customers/{customer_id}/users") +def list_customer_users( + customer_id: int, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + _customer_or_404(db, customer_id) + users = db.scalars( + select(ClientUser).where(ClientUser.client_account_id == customer_id).order_by(ClientUser.full_name) + ).all() + return [_serialize_user(u) for u in users] + + +@router.post("/customers/{customer_id}/users", status_code=status.HTTP_201_CREATED) +def create_customer_user( + customer_id: int, + payload: CustomerUserCreate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + account = _customer_or_404(db, customer_id) + user = ClientUser( + tenant_id=account.tenant_id, + client_account_id=account.id, + full_name=payload.full_name, + email=payload.email, + role=payload.role, + status="invited", + ) + db.add(user) + try: + db.flush() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="A user with that email already exists for this customer") from exc + ensure_user_module_permissions(db, user) + record_audit_event( + db, + tenant_id=account.tenant_id, + client_account_id=account.id, + action="user.created", + target_type="client_user", + target_id=user.id, + module_key="ordering", + summary=f"{user.full_name} invited as {user.role}.", + **_actor(session), + ) + db.commit() + db.refresh(user) + return _serialize_user(user) + + +@router.patch("/customers/{customer_id}/users/{user_id}") +def update_customer_user( + customer_id: int, + user_id: int, + payload: CustomerUserUpdate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + account = _customer_or_404(db, customer_id) + user = db.scalar( + select(ClientUser).where(ClientUser.id == user_id, ClientUser.client_account_id == customer_id) + ) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + changes = payload.model_dump(exclude_unset=True) + original_role = user.role + for field_name, value in changes.items(): + setattr(user, field_name, value) + if "role" in changes and changes["role"] != original_role: + from app.services.client_access_service import default_access_level_for_role + + for permission in user.module_permissions: + permission.access_level = default_access_level_for_role(user.role, permission.module_key) + record_audit_event( + db, + tenant_id=account.tenant_id, + client_account_id=account.id, + action="user.updated", + target_type="client_user", + target_id=user.id, + module_key="ordering", + summary=f"{user.full_name} updated (role {user.role}, status {user.status}).", + **_actor(session), + ) + db.commit() + db.refresh(user) + return _serialize_user(user) + + +# --- Categories -------------------------------------------------------------- + + +def _serialize_category(c: ProductCategory) -> dict: + return { + "id": c.id, + "slug": c.slug, + "name": c.name, + "description": c.description, + "sort_order": c.sort_order, + "active": c.active, + } + + +@router.get("/categories") +def list_categories( + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + rows = db.scalars( + select(ProductCategory).where(ProductCategory.tenant_id == TENANT).order_by(ProductCategory.sort_order, ProductCategory.name) + ).all() + return [_serialize_category(c) for c in rows] + + +@router.post("/categories", status_code=status.HTTP_201_CREATED) +def create_category( + payload: CategoryCreate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + category = ProductCategory(tenant_id=TENANT, **payload.model_dump()) + db.add(category) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="A category with that slug already exists") from exc + db.refresh(category) + return _serialize_category(category) + + +@router.patch("/categories/{category_id}") +def update_category( + category_id: int, + payload: CategoryUpdate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + category = db.scalar( + select(ProductCategory).where(ProductCategory.id == category_id, ProductCategory.tenant_id == TENANT) + ) + if category is None: + raise HTTPException(status_code=404, detail="Category not found") + for field_name, value in payload.model_dump(exclude_unset=True).items(): + setattr(category, field_name, value) + db.commit() + db.refresh(category) + return _serialize_category(category) + + +# --- Products (catalogue) ---------------------------------------------------- + + +@router.get("/products") +def list_products( + include_inactive: bool = Query(default=True), + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + stmt = select(CatalogueProduct).where(CatalogueProduct.tenant_id == TENANT) + if not include_inactive: + stmt = stmt.where(CatalogueProduct.active.is_(True)) + products = db.scalars(stmt.order_by(CatalogueProduct.category, CatalogueProduct.name)).all() + return [svc.serialize_product(p) for p in products] + + +@router.post("/products", status_code=status.HTTP_201_CREATED) +def create_product( + payload: CatalogueProductCreate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + product = CatalogueProduct(tenant_id=TENANT, **payload.model_dump()) + db.add(product) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="A product with that SKU already exists") from exc + db.refresh(product) + return svc.serialize_product(product) + + +@router.patch("/products/{product_id}") +def update_product( + product_id: int, + payload: CatalogueProductUpdate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + product = _product_or_404(db, product_id) + for field_name, value in payload.model_dump(exclude_unset=True).items(): + setattr(product, field_name, value) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="A product with that SKU already exists") from exc + db.refresh(product) + return svc.serialize_product(product) + + +# --- Per-customer visibility ------------------------------------------------- + + +@router.get("/customers/{customer_id}/visibility") +def get_customer_visibility( + customer_id: int, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + _customer_or_404(db, customer_id) + hidden = svc.visible_product_ids_for_customer(db, tenant_id=TENANT, client_account_id=customer_id) + products = db.scalars( + select(CatalogueProduct).where(CatalogueProduct.tenant_id == TENANT).order_by(CatalogueProduct.name) + ).all() + return [ + {"product_id": p.id, "name": p.name, "sku": p.sku, "category": p.category, "visible": p.id not in hidden} + for p in products + ] + + +@router.put("/customers/{customer_id}/visibility") +def set_customer_visibility( + customer_id: int, + payload: VisibilityUpdate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + account = _customer_or_404(db, customer_id) + _product_or_404(db, payload.product_id) + row = db.scalar( + select(CustomerProductVisibility).where( + CustomerProductVisibility.client_account_id == customer_id, + CustomerProductVisibility.product_id == payload.product_id, + ) + ) + if row is None: + row = CustomerProductVisibility( + tenant_id=TENANT, + client_account_id=customer_id, + product_id=payload.product_id, + visible=payload.visible, + ) + db.add(row) + else: + row.visible = payload.visible + record_audit_event( + db, + tenant_id=account.tenant_id, + client_account_id=account.id, + action="visibility.updated", + target_type="catalogue_product", + target_id=payload.product_id, + module_key="ordering", + summary=f"Product {payload.product_id} {'shown to' if payload.visible else 'hidden from'} {account.name}.", + **_actor(session), + ) + db.commit() + return {"product_id": payload.product_id, "visible": payload.visible} + + +# --- Price lists ------------------------------------------------------------- + + +def _serialize_price_list(db: Session, pl: PriceList) -> dict: + items = db.scalars(select(PriceListItem).where(PriceListItem.price_list_id == pl.id)).all() + return { + "id": pl.id, + "code": pl.code, + "name": pl.name, + "description": pl.description, + "active": pl.active, + "items": [ + { + "id": it.id, + "product_id": it.product_id, + "unit_price": it.unit_price, + "tiers": _serialize_tiers(db, price_list_item_id=it.id), + } + for it in items + ], + } + + +@router.get("/price-lists") +def list_price_lists( + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + rows = db.scalars(select(PriceList).where(PriceList.tenant_id == TENANT).order_by(PriceList.name)).all() + return [_serialize_price_list(db, pl) for pl in rows] + + +@router.post("/price-lists", status_code=status.HTTP_201_CREATED) +def create_price_list( + payload: PriceListCreate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + price_list = PriceList(tenant_id=TENANT, **payload.model_dump()) + db.add(price_list) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="A price list with that code already exists") from exc + db.refresh(price_list) + return _serialize_price_list(db, price_list) + + +@router.put("/price-lists/{price_list_id}/items") +def upsert_price_list_item( + price_list_id: int, + payload: PriceListItemUpsert, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + price_list = db.scalar( + select(PriceList).where(PriceList.id == price_list_id, PriceList.tenant_id == TENANT) + ) + if price_list is None: + raise HTTPException(status_code=404, detail="Price list not found") + _product_or_404(db, payload.product_id) + item = db.scalar( + select(PriceListItem).where( + PriceListItem.price_list_id == price_list_id, PriceListItem.product_id == payload.product_id + ) + ) + if item is None: + item = PriceListItem( + tenant_id=TENANT, price_list_id=price_list_id, product_id=payload.product_id, unit_price=payload.unit_price + ) + db.add(item) + db.flush() + else: + item.unit_price = payload.unit_price + if payload.tiers is not None: + db.query(PriceTier).filter(PriceTier.price_list_item_id == item.id).delete() + for tier in payload.tiers: + db.add( + PriceTier( + tenant_id=TENANT, + price_list_item_id=item.id, + min_quantity=tier.min_quantity, + unit_price=tier.unit_price, + ) + ) + db.commit() + db.refresh(price_list) + return _serialize_price_list(db, price_list) + + +# --- Customer pricing -------------------------------------------------------- + + +def _serialize_customer_pricing(db: Session, customer_id: int) -> dict: + assignment = db.scalar( + select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == customer_id) + ) + product_prices = db.scalars( + select(CustomerProductPrice).where(CustomerProductPrice.client_account_id == customer_id) + ).all() + return { + "customer_id": customer_id, + "price_list_id": assignment.price_list_id if assignment else None, + "discount_percent": assignment.discount_percent if assignment else 0.0, + "product_prices": [ + { + "id": pp.id, + "product_id": pp.product_id, + "unit_price": pp.unit_price, + "rule_type": pp.rule_type, + "contract_reference": pp.contract_reference, + "notes": pp.notes, + "active": pp.active, + "tiers": _serialize_tiers(db, customer_product_price_id=pp.id), + } + for pp in product_prices + ], + } + + +@router.get("/customers/{customer_id}/pricing") +def get_customer_pricing( + customer_id: int, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + _customer_or_404(db, customer_id) + return _serialize_customer_pricing(db, customer_id) + + +@router.put("/customers/{customer_id}/assignment") +def upsert_customer_assignment( + customer_id: int, + payload: CustomerPriceAssignmentUpsert, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + account = _customer_or_404(db, customer_id) + if payload.price_list_id is not None: + if db.scalar(select(PriceList.id).where(PriceList.id == payload.price_list_id, PriceList.tenant_id == TENANT)) is None: + raise HTTPException(status_code=404, detail="Price list not found") + assignment = db.scalar( + select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == customer_id) + ) + if assignment is None: + assignment = CustomerPriceAssignment( + tenant_id=TENANT, + client_account_id=customer_id, + price_list_id=payload.price_list_id, + discount_percent=payload.discount_percent, + ) + db.add(assignment) + else: + assignment.price_list_id = payload.price_list_id + assignment.discount_percent = payload.discount_percent + record_audit_event( + db, + tenant_id=account.tenant_id, + client_account_id=account.id, + action="pricing.assignment_updated", + target_type="customer_price_assignment", + target_id=None, + module_key="ordering", + summary=f"{account.name} assigned price list {payload.price_list_id} at {payload.discount_percent:g}% discount.", + **_actor(session), + ) + db.commit() + return _serialize_customer_pricing(db, customer_id) + + +@router.put("/customers/{customer_id}/product-prices") +def upsert_customer_product_price( + customer_id: int, + payload: CustomerProductPriceUpsert, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + account = _customer_or_404(db, customer_id) + _product_or_404(db, payload.product_id) + cpp = db.scalar( + select(CustomerProductPrice).where( + CustomerProductPrice.client_account_id == customer_id, + CustomerProductPrice.product_id == payload.product_id, + ) + ) + if cpp is None: + cpp = CustomerProductPrice( + tenant_id=TENANT, + client_account_id=customer_id, + product_id=payload.product_id, + unit_price=payload.unit_price, + rule_type=payload.rule_type, + contract_reference=payload.contract_reference, + notes=payload.notes, + active=payload.active, + ) + db.add(cpp) + db.flush() + else: + cpp.unit_price = payload.unit_price + cpp.rule_type = payload.rule_type + cpp.contract_reference = payload.contract_reference + cpp.notes = payload.notes + cpp.active = payload.active + if payload.tiers is not None: + db.query(PriceTier).filter(PriceTier.customer_product_price_id == cpp.id).delete() + for tier in payload.tiers: + db.add( + PriceTier( + tenant_id=TENANT, + customer_product_price_id=cpp.id, + min_quantity=tier.min_quantity, + unit_price=tier.unit_price, + ) + ) + record_audit_event( + db, + tenant_id=account.tenant_id, + client_account_id=account.id, + action="pricing.product_price_updated", + target_type="customer_product_price", + target_id=cpp.id, + module_key="ordering", + summary=f"{account.name} {payload.rule_type} price set for product {payload.product_id}.", + **_actor(session), + ) + db.commit() + return _serialize_customer_pricing(db, customer_id) + + +@router.delete("/customers/{customer_id}/product-prices/{product_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_customer_product_price( + customer_id: int, + product_id: int, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + _customer_or_404(db, customer_id) + cpp = db.scalar( + select(CustomerProductPrice).where( + CustomerProductPrice.client_account_id == customer_id, + CustomerProductPrice.product_id == product_id, + ) + ) + if cpp is not None: + db.query(PriceTier).filter(PriceTier.customer_product_price_id == cpp.id).delete() + db.delete(cpp) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +# --- Orders ------------------------------------------------------------------ + + +@router.get("/orders") +def list_all_orders( + status_filter: str | None = Query(default=None, alias="status"), + customer_id: int | None = Query(default=None), + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + stmt = ( + select(Order) + .where(Order.tenant_id == TENANT) + .options(selectinload(Order.lines)) + .order_by(Order.created_at.desc()) + ) + if status_filter: + stmt = stmt.where(Order.status == status_filter) + if customer_id is not None: + stmt = stmt.where(Order.client_account_id == customer_id) + orders = db.scalars(stmt).all() + # Hide drafts from the admin queue by default unless explicitly requested. + if not status_filter: + orders = [o for o in orders if o.status != "draft"] + result = [] + for o in orders: + data = svc.serialize_order(o, for_admin=True) + account = db.scalar(select(ClientAccount.name).where(ClientAccount.id == o.client_account_id)) + data["customer_name"] = account + result.append(data) + return result + + +@router.get("/orders/{order_id}") +def get_order_admin( + order_id: int, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + order = _load_order(db, order_id) + data = svc.serialize_order(order, for_admin=True) + account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id)) + data["customer_name"] = account.name if account else None + return data + + +@router.patch("/orders/{order_id}/status") +def update_order_status( + order_id: int, + payload: OrderStatusUpdate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + order = _load_order(db, order_id) + if order.status == payload.to_status: + raise HTTPException(status_code=409, detail="Order is already in that status") + if not svc.can_admin_transition(order.status, payload.to_status): + raise HTTPException( + status_code=409, + detail=f"Cannot move an order from {order.status} to {payload.to_status}", + ) + from_status = order.status + svc.record_status_change( + db, order, to_status=payload.to_status, actor_type="lean_admin", actor_name=session.name, note=payload.note + ) + svc.audit_order_event( + db, session=session, order=order, action="order.status_changed", + summary=f"Order {order.order_number or order.id} moved {from_status} → {payload.to_status}.", + ) + db.commit() + db.refresh(order) + return svc.serialize_order(order, for_admin=True) + + +@router.patch("/orders/{order_id}/lines/{line_id}") +def override_order_line( + order_id: int, + line_id: int, + payload: OrderLineOverride, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + order = _load_order(db, order_id) + if order.status in {"completed", "cancelled", "sent_to_xero"}: + raise HTTPException(status_code=409, detail="This order can no longer be adjusted") + line = next((l for l in order.lines if l.id == line_id), None) + if line is None: + raise HTTPException(status_code=404, detail="Order line not found") + changes = payload.model_dump(exclude_unset=True) + if "quantity" in changes and changes["quantity"] is not None: + line.quantity = changes["quantity"] + if "unit_price" in changes and changes["unit_price"] is not None: + line.admin_override_price = changes["unit_price"] + line.requires_quote = False + line.price_source = "fixed" + if "reason" in changes: + line.admin_override_reason = changes["reason"] + svc.recompute_order_totals(order) + svc.audit_order_event( + db, session=session, order=order, action="order.price_override", + summary=f"Line {line.product_name} overridden on order {order.order_number or order.id}.", + target_id=line.id, + ) + db.commit() + db.refresh(order) + return svc.serialize_order(order, for_admin=True) + + +@router.post("/orders/{order_id}/reopen") +def reopen_order( + order_id: int, + payload: ReopenOrderRequest, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + order = _load_order(db, order_id) + if order.status in {"completed", "cancelled"}: + raise HTTPException(status_code=409, detail="Completed or cancelled orders cannot be reopened") + if order.status == "draft": + raise HTTPException(status_code=409, detail="Order is already a draft") + svc.record_status_change( + db, order, to_status="draft", actor_type="lean_admin", actor_name=session.name, + note=payload.note or "Reopened by admin for customer edits", + ) + order.reopened = True + svc.audit_order_event( + db, session=session, order=order, action="order.reopened", + summary=f"Order {order.order_number or order.id} reopened to draft.", + ) + db.commit() + db.refresh(order) + return svc.serialize_order(order, for_admin=True) + + +@router.post("/orders/{order_id}/send-to-xero") +def send_order_to_xero( + order_id: int, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + order = _load_order(db, order_id) + if order.status not in {"confirmed", "in_production", "sent_to_xero"}: + raise HTTPException(status_code=409, detail="Only confirmed orders can be sent to Xero") + account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id)) + result = submit_order_to_xero(order, account) + + db.add( + XeroSyncLog( + tenant_id=TENANT, + order_id=order.id, + status=result.status, + request_summary=result.request_summary, + xero_invoice_id=result.xero_invoice_id, + response_message=result.message, + ) + ) + order.xero_status = result.status + if result.status == "success": + order.xero_invoice_id = result.xero_invoice_id + if order.status != "sent_to_xero" and svc.can_admin_transition(order.status, "sent_to_xero"): + svc.record_status_change( + db, order, to_status="sent_to_xero", actor_type="lean_admin", actor_name=session.name, + note=f"Xero invoice {result.xero_invoice_id}", + ) + svc.audit_order_event( + db, session=session, order=order, action="order.xero_submit", + summary=f"Xero submission {result.status} for order {order.order_number or order.id} ({result.message}).", + ) + db.commit() + db.refresh(order) + data = svc.serialize_order(order, for_admin=True) + data["xero_result"] = { + "status": result.status, + "stubbed": result.stubbed, + "message": result.message, + "xero_invoice_id": result.xero_invoice_id, + } + return data + + +# --- Settings / status ------------------------------------------------------- + + +@router.get("/notification-settings") +def get_notification_settings( + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + settings_row = get_or_create_settings(db, TENANT) + db.commit() + return { + "internal_recipients": settings_row.internal_recipients, + "send_customer_confirmation": settings_row.send_customer_confirmation, + "require_po_number": settings_row.require_po_number, + "from_email": settings_row.from_email, + } + + +@router.patch("/notification-settings") +def update_notification_settings( + payload: NotificationSettingsUpdate, + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + settings_row = get_or_create_settings(db, TENANT) + for field_name, value in payload.model_dump(exclude_unset=True).items(): + setattr(settings_row, field_name, value) + db.commit() + return { + "internal_recipients": settings_row.internal_recipients, + "send_customer_confirmation": settings_row.send_customer_confirmation, + "require_po_number": settings_row.require_po_number, + "from_email": settings_row.from_email, + } + + +@router.get("/xero/status") +def get_xero_status( + session: AuthSession = Depends(require_ordering_admin_session), + db: Session = Depends(get_db), +): + recent = db.scalars( + select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20) + ).all() + return { + "connection": xero_status_snapshot(), + "recent_syncs": [ + { + "id": log.id, + "order_id": log.order_id, + "status": log.status, + "xero_invoice_id": log.xero_invoice_id, + "response_message": log.response_message, + "created_at": log.created_at, + } + for log in recent + ], + } diff --git a/backend/app/core/access.py b/backend/app/core/access.py index 6d277f1..41a4ddf 100644 --- a/backend/app/core/access.py +++ b/backend/app/core/access.py @@ -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"), diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index acb805b..60a5ed5 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -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, } diff --git a/backend/app/main.py b/backend/app/main.py index 87f36a2..830b16d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e25b49e..0223d0b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/ordering.py b/backend/app/models/ordering.py new file mode 100644 index 0000000..60ecb7a --- /dev/null +++ b/backend/app/models/ordering.py @@ -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) diff --git a/backend/app/schemas/ordering.py b/backend/app/schemas/ordering.py new file mode 100644 index 0000000..4c74818 --- /dev/null +++ b/backend/app/schemas/ordering.py @@ -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 diff --git a/backend/app/seed.py b/backend/app/seed.py index 6222c2a..af06f68 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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) diff --git a/backend/app/seed_access.py b/backend/app/seed_access.py index fc63b2e..4bbc363 100644 --- a/backend/app/seed_access.py +++ b/backend/app/seed_access.py @@ -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", diff --git a/backend/app/seed_ordering.py b/backend/app/seed_ordering.py new file mode 100644 index 0000000..daeea58 --- /dev/null +++ b/backend/app/seed_ordering.py @@ -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 diff --git a/backend/app/services/client_access_service.py b/backend/app/services/client_access_service.py index 9afcaf6..15e2b1d 100644 --- a/backend/app/services/client_access_service.py +++ b/backend/app/services/client_access_service.py @@ -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" diff --git a/backend/app/services/order_notifications.py b/backend/app/services/order_notifications.py new file mode 100644 index 0000000..d4ee240 --- /dev/null +++ b/backend/app/services/order_notifications.py @@ -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 diff --git a/backend/app/services/order_pdf.py b/backend/app/services/order_pdf.py new file mode 100644 index 0000000..2235623 --- /dev/null +++ b/backend/app/services/order_pdf.py @@ -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() diff --git a/backend/app/services/ordering_pricing.py b/backend/app/services/ordering_pricing.py new file mode 100644 index 0000000..225a56b --- /dev/null +++ b/backend/app/services/ordering_pricing.py @@ -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") diff --git a/backend/app/services/ordering_service.py b/backend/app/services/ordering_service.py new file mode 100644 index 0000000..306f171 --- /dev/null +++ b/backend/app/services/ordering_service.py @@ -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") diff --git a/backend/app/services/xero_service.py b/backend/app/services/xero_service.py new file mode 100644 index 0000000..3e3aa66 --- /dev/null +++ b/backend/app/services/xero_service.py @@ -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 + ], + } diff --git a/backend/pyproject.toml b/backend/pyproject.toml index cc45669..af1d27a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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 = [ diff --git a/backend/tests/test_ordering_flow.py b/backend/tests/test_ordering_flow.py new file mode 100644 index 0000000..01e2f60 --- /dev/null +++ b/backend/tests/test_ordering_flow.py @@ -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 diff --git a/backend/tests/test_ordering_pricing.py b/backend/tests/test_ordering_pricing.py new file mode 100644 index 0000000..268eda1 --- /dev/null +++ b/backend/tests/test_ordering_pricing.py @@ -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" diff --git a/frontend/package.json b/frontend/package.json index 79778d4..20be484 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.12", + "version": "0.1.14", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/app.html b/frontend/src/app.html index 12a3f94..b51dc5f 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -5,15 +5,14 @@ - - - {pageTitle(page.url.pathname)} | Lean 101 Admin Panel - - -
- - -
-
-
-

Admin Area

-

{pageTitle(page.url.pathname)}

-
- - {#if !$sessionHydrated} -
- A -
- Checking saved session - Restoring admin access -
-
- {:else if $adminSession} -
- {initials($adminSession.name)} -
- {$adminSession.name} - {$adminSession.email} -
-
- {:else} -
- A -
- Admin sign-in required - Use `/admin` to authenticate -
-
- {/if} -
- -
- {#if isProtectedRoute && (!$sessionHydrated || isRestoringSession)} -
-

Checking Session

-

Restoring the Lean 101 admin workspace.

-

Refreshing the current route with the saved operator session before prompting for sign-in.

-
- {:else if isProtectedRoute && !$adminSession} -
-

Restricted

-

Sign in through the Lean 101 Admin Panel to continue.

-

Client access controls are only available inside the separate admin workspace.

- Go to admin sign-in -
- {:else} - {@render children()} - {/if} -
-
-
- - diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index f2a0526..788d94f 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -4,6 +4,9 @@ import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte'; import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte'; import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte'; + import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte'; + import { currentChangelog } from '$lib/changelog'; + import { hasSeenVersion, markVersionSeen } from '$lib/whats-new'; import { invalidateAll } from '$app/navigation'; import { goto } from '$app/navigation'; import { page } from '$app/state'; @@ -17,6 +20,8 @@ canOpenEditor as sessionCanOpenEditor, canOpenMixCalculator as sessionCanOpenMixCalculator, canOpenMixMaster as sessionCanOpenMixMaster, + canOpenCustomerOrdering as sessionCanOpenCustomerOrdering, + canManageOrdering as sessionCanManageOrdering, canOpenProductCosting as sessionCanOpenProductCosting, canOpenReporting as sessionCanOpenReporting, canOpenSettings as sessionCanOpenSettings, @@ -36,6 +41,7 @@ footerLinks, matchesRoute, mixCalculatorItem, + orderingItem, pageTitle, productCostingItem, reportingItem, @@ -65,6 +71,10 @@ let userMenuOpen = $state(false); let navOpen = $state(false); let showBottomNav = $state(false); + let whatsNewOpen = $state(false); + // The user identity we've already run the "what's new" check for this mount, + // so the dialog is evaluated once per login rather than on every navigation. + let whatsNewCheckedFor = $state(null); let isRestoringSession = $state(false); let restoredSessionKey = $state(null); let seededSearchItems = $state([]); @@ -102,6 +112,17 @@ const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null); const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession)); const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null); + // Ordering serves two audiences: internal staff get the management console + // (/ordering/manage), customers get the catalogue (/ordering). + const canManageOrdering = $derived(sessionCanManageOrdering($clientSession)); + const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession)); + const visibleOrderingItem = $derived( + canManageOrdering + ? { ...orderingItem, href: '/ordering/manage', label: 'Order Management', shortLabel: 'OM' } + : canOpenCustomerOrdering + ? orderingItem + : null + ); const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null); const visibleEditorItem = $derived(canOpenEditor ? editorItem : null); // Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the @@ -117,6 +138,7 @@ ...visibleWorkingDocumentItems ], throughput: visibleThroughputItem, + ordering: visibleOrderingItem, reporting: visibleReportingItem }) ); @@ -331,6 +353,33 @@ goto(workspaceHomeHref, { replaceState: true }); }); + // Surface the release notes once per version per user, right after login. + // hasSeenVersion keeps this to a single appearance: once dismissed (which + // records the version), it won't return until the next version ships. + $effect(() => { + if (!$sessionHydrated || !$clientSession || !currentChangelog) { + return; + } + + const userKey = `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}`; + if (whatsNewCheckedFor === userKey) { + return; + } + + whatsNewCheckedFor = userKey; + if (!hasSeenVersion(userKey, currentChangelog.version)) { + whatsNewOpen = true; + } + }); + + function dismissWhatsNew() { + if ($clientSession && currentChangelog) { + const userKey = `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}`; + markVersionSeen(userKey, currentChangelog.version); + } + whatsNewOpen = false; + } + onMount(() => { syncViewport(); @@ -639,6 +688,10 @@ {/if} +{#if $clientSession && whatsNewOpen && currentChangelog} + +{/if} + {#if $clientSession && paletteOpen}