1002 lines
34 KiB
Python
1002 lines
34 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 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
|
|
],
|
|
}
|