"""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}"'}, )