391 lines
14 KiB
Python
391 lines
14 KiB
Python
|
|
"""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}"'},
|
||
|
|
)
|