"""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 ], }