Files
adminandClaude Opus 4.8 2de82776cb v0.1.19 - Throughput overview & responsive header
- Throughput: new "Throughput Overview" header with Gauge icon and brand-green
  badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix)
- Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks)
  driving the customer-mix cards; stats window widened to 12 weeks so switching
  range is a pure client-side re-filter
- Throughput: cards collapse to a single even 5-across row on laptop and up,
  with container-query value text that scales to each card's width
- Throughput: date logic pinned to Australian Eastern time (fixes the day-early
  date); This week subtitle shows the Mon-Sun date range
- Throughput: subtler tinted add-form; removed the inline-entry kicker and the
  "Open full form" link
- Topbar: fix cramped laptop header - action toggles no longer wrap above the
  user button; search drops to its own row earlier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:01:10 +12:00

1161 lines
40 KiB
Python

"""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 datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, 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,
XeroContactLink,
XeroSyncLog,
)
from app.schemas.ordering import (
CatalogueProductCreate,
CatalogueProductUpdate,
CategoryCreate,
CategoryUpdate,
CustomerCreate,
CustomerPriceAssignmentUpsert,
CustomerProductPriceUpsert,
CustomerUpdate,
CustomerUserCreate,
CustomerUserUpdate,
NotificationSettingsUpdate,
OrderLineOverride,
OrderStatusUpdate,
PriceListCreate,
PriceListItemUpsert,
ReopenOrderRequest,
VisibilityUpdate,
XeroContactLinkUpsert,
)
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 (
list_xero_contacts,
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 _xero_link_for(db: Session, customer_id: int) -> XeroContactLink | None:
return db.scalar(
select(XeroContactLink).where(
XeroContactLink.tenant_id == TENANT,
XeroContactLink.client_account_id == customer_id,
)
)
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)
)
link = _xero_link_for(db, 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,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"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))
link = _xero_link_for(db, order.client_account_id)
result = submit_order_to_xero(order, account, link)
if link is not None and result.status == "success":
link.last_synced_at = datetime.utcnow()
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()
total_customers = db.scalar(
select(func.count()).select_from(ClientAccount)
) or 0
linked_customers = db.scalar(
select(func.count()).select_from(XeroContactLink).where(XeroContactLink.tenant_id == TENANT)
) or 0
return {
"connection": xero_status_snapshot(),
"contact_links": {
"linked": linked_customers,
"total": total_customers,
"unlinked": max(total_customers - linked_customers, 0),
},
"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
],
}
# --- Xero contact mapping ----------------------------------------------------
@router.get("/xero/contacts")
def list_available_xero_contacts(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""The Xero contacts available to link a customer against (stub or live)."""
contacts, stubbed = list_xero_contacts()
return {"contacts": [c.as_dict() for c in contacts], "stubbed": stubbed}
@router.get("/xero/contact-links")
def list_xero_contact_links(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""Every customer with its current Xero link and a suggested match.
The suggestion is a best-effort name match against the available contacts so
the operator can confirm rather than hunt through a dropdown.
"""
contacts, _ = list_xero_contacts()
by_name = {c.name.strip().lower(): c for c in contacts}
accounts = db.scalars(select(ClientAccount).order_by(ClientAccount.name)).all()
rows = []
for account in accounts:
link = _xero_link_for(db, account.id)
suggestion = None if link else by_name.get(account.name.strip().lower())
rows.append(
{
"customer_id": account.id,
"customer_name": account.name,
"client_code": account.client_code,
"linked": link is not None,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"xero_contact_email": link.xero_contact_email if link else None,
"last_synced_at": link.last_synced_at if link else None,
"suggested_contact_id": suggestion.contact_id if suggestion else None,
}
)
return rows
@router.put("/customers/{customer_id}/xero-link")
def link_customer_to_xero(
customer_id: int,
payload: XeroContactLinkUpsert,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
# Default the cached name/email from the known contact list when the caller
# only sends an id, so the mapping reads nicely without a live round-trip.
name = payload.xero_contact_name
email = payload.xero_contact_email
if name is None or email is None:
contacts, _ = list_xero_contacts()
match = next((c for c in contacts if c.contact_id == payload.xero_contact_id), None)
if match is not None:
name = name or match.name
email = email or match.email
link = _xero_link_for(db, customer_id)
if link is None:
link = XeroContactLink(
tenant_id=TENANT,
client_account_id=customer_id,
xero_contact_id=payload.xero_contact_id,
xero_contact_name=name,
xero_contact_email=email,
)
db.add(link)
else:
link.xero_contact_id = payload.xero_contact_id
link.xero_contact_name = name
link.xero_contact_email = email
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_linked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} linked to Xero contact {name or payload.xero_contact_id}.",
**_actor(session),
)
db.commit()
return {
"customer_id": customer_id,
"linked": True,
"xero_contact_id": link.xero_contact_id,
"xero_contact_name": link.xero_contact_name,
"xero_contact_email": link.xero_contact_email,
}
@router.delete("/customers/{customer_id}/xero-link", status_code=status.HTTP_204_NO_CONTENT)
def unlink_customer_from_xero(
customer_id: int,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
link = _xero_link_for(db, customer_id)
if link is not None:
db.delete(link)
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_unlinked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} unlinked from Xero contact.",
**_actor(session),
)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)