v0.1.14 - b2b portal

This commit is contained in:
2026-06-11 23:56:02 +12:00
parent 349e4a4b5b
commit 4ff372d307
48 changed files with 5845 additions and 925 deletions
+23
View File
@@ -184,6 +184,29 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
return dependency
def require_ordering_admin_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
"""Internal-side authorization for managing the ordering portal.
Accepts the single Lean admin (``role == "admin"``) or an internal Hunter
user holding ``manage`` on the ordering module. Client/customer users are
rejected — they use the customer-facing ``require_client_module_access``
dependency instead.
"""
if session.role == "admin":
return session
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("ordering"), "manage"):
log_security_event("authz.denied", role=session.role, module="ordering", access_level="manage")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires manage access")
return session
log_security_event("authz.denied", role=session.role, module="ordering", required="admin")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires internal admin access")
def require_client_access_manager_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
+390
View File
@@ -0,0 +1,390 @@
"""Customer-facing B2B ordering API.
Every endpoint is gated by the ``ordering`` module and scoped to the caller's
own company (``tenant_id`` + ``client_account_id``). Customers can never see
another company's catalogue visibility, prices, or orders.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.ordering import CatalogueProduct, Order, OrderLine
from app.schemas.ordering import (
DraftOrderCreate,
DraftOrderUpdate,
OrderLineInput,
OrderSubmitRequest,
)
from app.services import ordering_service as svc
from app.services.order_notifications import send_order_submitted_notifications
from app.services.order_pdf import OrderPdfUnavailableError, build_order_confirmation_pdf
from app.services.ordering_pricing import resolve_price
router = APIRouter(prefix="/api/ordering", tags=["ordering"])
# --- Helpers -----------------------------------------------------------------
def _require_active_customer(db: Session, session: AuthSession):
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
svc.ensure_customer_active(account)
return account
def _get_visible_product(db: Session, session: AuthSession, product_id: int) -> CatalogueProduct:
hidden = svc.visible_product_ids_for_customer(
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
)
product = db.scalar(
select(CatalogueProduct).where(
CatalogueProduct.id == product_id,
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
CatalogueProduct.active.is_(True),
)
)
if product is None or product.id in hidden:
raise HTTPException(status_code=404, detail="Product not available")
return product
def _load_own_order(db: Session, session: AuthSession, order_id: int) -> Order:
order = db.scalar(
select(Order)
.where(
Order.id == order_id,
Order.tenant_id == svc.ORDERING_TENANT,
Order.client_account_id == session.client_account_id,
)
.options(selectinload(Order.lines))
)
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
return order
def _rebuild_lines(db: Session, session: AuthSession, order: Order, lines: list[OrderLineInput]) -> None:
"""Replace an order's lines with freshly priced lines (server-side pricing).
Validates product availability and minimum order quantity. Raises 422 on
invalid input.
"""
order.lines.clear()
db.flush()
for index, line_input in enumerate(lines):
product = _get_visible_product(db, session, line_input.product_id)
if line_input.quantity < product.min_order_quantity:
raise HTTPException(
status_code=422,
detail=(
f"{product.name}: minimum order quantity is "
f"{product.min_order_quantity:g} {product.unit_of_measure}"
),
)
resolution = resolve_price(
db,
client_account_id=session.client_account_id,
product=product,
quantity=line_input.quantity,
)
order.lines.append(
OrderLine(
tenant_id=svc.ORDERING_TENANT,
product_id=product.id,
product_name=product.name,
product_sku=product.sku,
quantity=line_input.quantity,
unit_price=resolution.unit_price,
requires_quote=resolution.requires_quote,
price_source=resolution.price_source,
price_rule_id=resolution.price_rule_id,
discount_percent=resolution.discount_percent,
sort_order=index,
notes=line_input.notes,
)
)
db.flush()
svc.recompute_order_totals(order)
# --- Catalogue ---------------------------------------------------------------
@router.get("/catalogue")
def list_catalogue(
category: str | None = Query(default=None),
q: str | None = Query(default=None),
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
products = svc.list_visible_products(
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
)
if category:
products = [p for p in products if p.category == category]
if q:
needle = q.lower()
products = [
p
for p in products
if needle in p.name.lower()
or needle in p.sku.lower()
or (p.description and needle in p.description.lower())
]
return [
svc.serialize_product(p, db=db, client_account_id=session.client_account_id)
for p in products
]
@router.get("/catalogue/{product_id}")
def get_catalogue_product(
product_id: int,
quantity: float = Query(default=1.0, gt=0),
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
product = _get_visible_product(db, session, product_id)
return svc.serialize_product(
product, db=db, client_account_id=session.client_account_id, quantity=quantity
)
# --- Orders ------------------------------------------------------------------
@router.get("/orders")
def list_orders(
status_filter: str | None = Query(default=None, alias="status"),
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
stmt = (
select(Order)
.where(
Order.tenant_id == svc.ORDERING_TENANT,
Order.client_account_id == session.client_account_id,
)
.options(selectinload(Order.lines))
.order_by(Order.created_at.desc())
)
orders = db.scalars(stmt).all()
if status_filter == "draft":
orders = [o for o in orders if o.status == "draft"]
elif status_filter == "submitted":
orders = [o for o in orders if o.status != "draft"]
return [svc.serialize_order(o, for_admin=False) for o in orders]
@router.get("/orders/{order_id}")
def get_order(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
return svc.serialize_order(order, for_admin=False)
@router.post("/orders", status_code=status.HTTP_201_CREATED)
def create_draft_order(
payload: DraftOrderCreate,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
order = Order(
tenant_id=svc.ORDERING_TENANT,
client_account_id=session.client_account_id,
status="draft",
created_by_user_id=session.user_id,
created_by_name=session.name,
purchase_order_number=payload.purchase_order_number,
delivery_notes=payload.delivery_notes,
requested_delivery_date=payload.requested_delivery_date,
fulfilment_method=payload.fulfilment_method,
)
db.add(order)
db.flush()
_rebuild_lines(db, session, order, payload.lines)
svc.record_status_change(
db, order, to_status="draft", actor_type="customer", actor_name=session.name, note="Draft created"
)
svc.audit_order_event(
db, session=session, order=order, action="order.created", summary="Draft order created."
)
db.commit()
db.refresh(order)
return svc.serialize_order(order, for_admin=False)
@router.patch("/orders/{order_id}")
def update_draft_order(
order_id: int,
payload: DraftOrderUpdate,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
if order.status not in svc.CUSTOMER_EDITABLE_STATUSES:
raise HTTPException(
status_code=409,
detail="This order has been submitted and can no longer be edited. Ask an admin to reopen it.",
)
changes = payload.model_dump(exclude_unset=True)
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
if field_name in changes:
setattr(order, field_name, changes[field_name])
if payload.lines is not None:
_rebuild_lines(db, session, order, payload.lines)
svc.audit_order_event(
db, session=session, order=order, action="order.updated", summary="Draft order updated."
)
db.commit()
db.refresh(order)
return svc.serialize_order(order, for_admin=False)
@router.delete("/orders/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_draft_order(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
if order.status != "draft":
raise HTTPException(status_code=409, detail="Only draft orders can be deleted")
db.delete(order)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/orders/{order_id}/submit")
def submit_order(
order_id: int,
payload: OrderSubmitRequest,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
account = _require_active_customer(db, session)
order = _load_own_order(db, session, order_id)
if order.status != "draft":
raise HTTPException(status_code=409, detail="Only draft orders can be submitted")
if not order.lines:
raise HTTPException(status_code=422, detail="Cannot submit an empty order")
# Apply any last-minute header changes supplied on submit.
changes = payload.model_dump(exclude_unset=True)
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
if field_name in changes and changes[field_name] is not None:
setattr(order, field_name, changes[field_name])
# Required PO number, if the tenant configured it.
from app.services.order_notifications import get_or_create_settings
settings = get_or_create_settings(db, order.tenant_id)
if settings.require_po_number and not (order.purchase_order_number or "").strip():
raise HTTPException(status_code=422, detail="A purchase order number is required to submit this order")
# Re-resolve and freeze the exact price used at submission time.
line_inputs = [
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
for line in order.lines
]
_rebuild_lines(db, session, order, line_inputs)
order.order_number = svc.next_order_number(db, order.tenant_id)
order.submitted_at = datetime.utcnow()
svc.record_status_change(
db, order, to_status="submitted", actor_type="customer", actor_name=session.name, note="Submitted by customer"
)
svc.audit_order_event(
db, session=session, order=order, action="order.submitted",
summary=f"Order {order.order_number} submitted (subtotal ex GST {order.subtotal_ex_gst:.2f}).",
)
notifications = send_order_submitted_notifications(db, order)
db.commit()
db.refresh(order)
result = svc.serialize_order(order, for_admin=False)
result["notifications"] = [
{"channel": n.channel, "recipients": n.recipients, "delivered": n.delivered, "detail": n.detail}
for n in notifications
]
return result
@router.post("/orders/{order_id}/reorder", status_code=status.HTTP_201_CREATED)
def reorder(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
source = _load_own_order(db, session, order_id)
new_order = Order(
tenant_id=svc.ORDERING_TENANT,
client_account_id=session.client_account_id,
status="draft",
created_by_user_id=session.user_id,
created_by_name=session.name,
fulfilment_method=source.fulfilment_method,
)
db.add(new_order)
db.flush()
line_inputs = [
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
for line in source.lines
]
# Skip lines whose product is no longer available rather than failing.
available: list[OrderLineInput] = []
for line_input in line_inputs:
product = db.scalar(
select(CatalogueProduct).where(
CatalogueProduct.id == line_input.product_id,
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
CatalogueProduct.active.is_(True),
)
)
if product is not None and line_input.quantity >= product.min_order_quantity:
available.append(line_input)
_rebuild_lines(db, session, new_order, available)
svc.record_status_change(
db, new_order, to_status="draft", actor_type="customer", actor_name=session.name,
note=f"Reordered from {source.order_number or source.id}",
)
svc.audit_order_event(
db, session=session, order=new_order, action="order.reordered",
summary=f"Draft created by reordering from {source.order_number or source.id}.",
)
db.commit()
db.refresh(new_order)
return svc.serialize_order(new_order, for_admin=False)
@router.get("/orders/{order_id}/confirmation.pdf")
def order_confirmation_pdf(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
try:
pdf_bytes = build_order_confirmation_pdf(order, account)
except OrderPdfUnavailableError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
filename = f"order-{order.order_number or order.id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
File diff suppressed because it is too large Load Diff