Files
data-entry-app/backend/app/services/ordering_service.py
T

314 lines
11 KiB
Python
Raw Normal View History

2026-06-11 23:56:02 +12:00
"""Shared ordering-portal business logic: lifecycle, visibility, totals,
serialization, and audit.
Customer isolation is enforced by callers passing ``tenant_id`` /
``client_account_id`` into every query; this module never widens that scope.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession
from app.core.config import settings
from app.models.client_access import ClientAccount
from app.models.ordering import (
CatalogueProduct,
CustomerProductVisibility,
Order,
OrderLine,
OrderStatusHistory,
)
from app.services.client_access_service import record_audit_event
from app.services.ordering_pricing import resolve_price
# The ordering catalogue is "standard globally" — it lives under a single
# seller/ordering tenant, while individual customers (ClientAccounts) may have
# their own tenant_id. Customer isolation therefore keys on the globally-unique
# ``client_account_id``, NOT on the customer's session tenant. Admin and
# customer code both store/read ordering data under ORDERING_TENANT.
ORDERING_TENANT = settings.client_tenant_id
# --- Lifecycle ---------------------------------------------------------------
# Full internal lifecycle → the simplified status a customer sees.
CUSTOMER_VISIBLE_STATUS = {
"draft": "draft",
"submitted": "submitted",
"under_review": "processing",
"confirmed": "confirmed",
"sent_to_xero": "confirmed",
"in_production": "processing",
"ready_for_pickup": "ready",
"dispatched": "dispatched",
"completed": "completed",
"cancelled": "cancelled",
}
# Statuses an admin may move an order into from a given status. Cancellation is
# allowed from any non-terminal status (handled separately).
ALLOWED_ADMIN_TRANSITIONS: dict[str, set[str]] = {
"draft": {"submitted", "cancelled"},
"submitted": {"under_review", "confirmed", "cancelled"},
"under_review": {"confirmed", "submitted", "cancelled"},
"confirmed": {"sent_to_xero", "in_production", "cancelled"},
"sent_to_xero": {"in_production", "cancelled"},
"in_production": {"ready_for_pickup", "dispatched", "cancelled"},
"ready_for_pickup": {"dispatched", "completed", "cancelled"},
"dispatched": {"completed", "cancelled"},
"completed": set(),
"cancelled": set(),
}
# Customers may only edit an order while it is a draft.
CUSTOMER_EDITABLE_STATUSES = {"draft"}
def can_admin_transition(from_status: str, to_status: str) -> bool:
if to_status == "cancelled" and from_status not in {"completed", "cancelled"}:
return True
return to_status in ALLOWED_ADMIN_TRANSITIONS.get(from_status, set())
def record_status_change(
db: Session,
order: Order,
*,
to_status: str,
actor_type: str,
actor_name: str | None,
note: str | None = None,
) -> None:
db.add(
OrderStatusHistory(
tenant_id=order.tenant_id,
order_id=order.id,
from_status=order.status,
to_status=to_status,
actor_type=actor_type,
actor_name=actor_name,
note=note,
)
)
order.status = to_status
def next_order_number(db: Session, tenant_id: str) -> str:
# Cheap monotonic-ish numbering based on max id; good enough for v1.
last_id = db.scalar(select(Order.id).order_by(Order.id.desc()).limit(1)) or 0
return f"ORD-{last_id + 1:06d}"
# --- Product visibility ------------------------------------------------------
def visible_product_ids_for_customer(db: Session, *, tenant_id: str, client_account_id: int) -> set[int]:
"""Ids the customer is explicitly *hidden* from (opt-out model)."""
rows = db.scalars(
select(CustomerProductVisibility.product_id).where(
CustomerProductVisibility.client_account_id == client_account_id,
CustomerProductVisibility.tenant_id == tenant_id,
CustomerProductVisibility.visible.is_(False),
)
).all()
return set(rows)
def list_visible_products(
db: Session, *, tenant_id: str, client_account_id: int
) -> list[CatalogueProduct]:
hidden = visible_product_ids_for_customer(
db, tenant_id=tenant_id, client_account_id=client_account_id
)
products = db.scalars(
select(CatalogueProduct)
.where(CatalogueProduct.tenant_id == tenant_id, CatalogueProduct.active.is_(True))
.order_by(CatalogueProduct.category, CatalogueProduct.name)
).all()
return [p for p in products if p.id not in hidden]
# --- Totals ------------------------------------------------------------------
def effective_unit_price(line: OrderLine) -> float | None:
if line.admin_override_price is not None:
return line.admin_override_price
return line.unit_price
def recompute_order_totals(order: Order) -> None:
subtotal = 0.0
requires_quote = False
for line in order.lines:
unit = effective_unit_price(line)
if line.requires_quote or unit is None:
requires_quote = True
line.line_total = None
continue
line.line_total = round(unit * line.quantity, 4)
subtotal += line.line_total
order.subtotal_ex_gst = round(subtotal, 4)
order.requires_quote = requires_quote
# --- Serialization -----------------------------------------------------------
def serialize_product(
product: CatalogueProduct,
*,
db: Session | None = None,
client_account_id: int | None = None,
quantity: float = 1.0,
) -> dict:
"""Serialize a catalogue product. When ``db`` + ``client_account_id`` are
given, attach the resolved customer-specific price + provenance."""
data = {
"id": product.id,
"name": product.name,
"sku": product.sku,
"description": product.description,
"category": product.category,
"image_url": product.image_url,
"unit_size": product.unit_size,
"unit_of_measure": product.unit_of_measure,
"min_order_quantity": product.min_order_quantity,
"stock_status": product.stock_status,
"active": product.active,
"requires_quote": product.requires_quote,
"base_price": product.base_price,
"created_at": product.created_at,
}
if db is not None and client_account_id is not None:
resolution = resolve_price(
db, client_account_id=client_account_id, product=product, quantity=quantity
)
data["price"] = {
"unit_price": resolution.unit_price,
"price_source": resolution.price_source,
"price_rule_id": resolution.price_rule_id,
"discount_percent": resolution.discount_percent,
"requires_quote": resolution.requires_quote,
"label": resolution.label,
}
return data
def serialize_order_line(line: OrderLine, *, for_admin: bool) -> dict:
data = {
"id": line.id,
"product_id": line.product_id,
"product_name": line.product_name,
"product_sku": line.product_sku,
"quantity": line.quantity,
"unit_price": effective_unit_price(line),
"line_total": line.line_total,
"requires_quote": line.requires_quote,
"price_source": line.price_source,
"discount_percent": line.discount_percent,
"notes": line.notes,
}
if for_admin:
data.update(
{
"resolved_unit_price": line.unit_price,
"admin_override_price": line.admin_override_price,
"admin_override_reason": line.admin_override_reason,
"price_rule_id": line.price_rule_id,
}
)
return data
def serialize_order(order: Order, *, for_admin: bool) -> dict:
status = order.status if for_admin else CUSTOMER_VISIBLE_STATUS.get(order.status, order.status)
data = {
"id": order.id,
"order_number": order.order_number,
"status": status,
"client_account_id": order.client_account_id,
"created_by_name": order.created_by_name,
"purchase_order_number": order.purchase_order_number,
"delivery_notes": order.delivery_notes,
"requested_delivery_date": order.requested_delivery_date,
"fulfilment_method": order.fulfilment_method,
"subtotal_ex_gst": order.subtotal_ex_gst,
"requires_quote": order.requires_quote,
"submitted_at": order.submitted_at,
"created_at": order.created_at,
"updated_at": order.updated_at,
"editable": order.status in CUSTOMER_EDITABLE_STATUSES,
"lines": [serialize_order_line(line, for_admin=for_admin) for line in order.lines],
}
if for_admin:
data.update(
{
"raw_status": order.status,
"admin_notes": order.admin_notes,
"reopened": order.reopened,
"xero_status": order.xero_status,
"xero_invoice_id": order.xero_invoice_id,
"status_history": [
{
"id": h.id,
"from_status": h.from_status,
"to_status": h.to_status,
"actor_type": h.actor_type,
"actor_name": h.actor_name,
"note": h.note,
"created_at": h.created_at,
}
for h in order.status_history
],
}
)
return data
# --- Audit -------------------------------------------------------------------
def audit_order_event(
db: Session,
*,
session: AuthSession,
order: Order,
action: str,
summary: str,
target_id: int | None = None,
) -> None:
"""Record an ordering audit event onto the shared audit trail."""
actor_type = "lean_admin" if session.role in {"admin", "internal"} else "customer"
record_audit_event(
db,
tenant_id=order.tenant_id,
client_account_id=order.client_account_id,
actor_type=actor_type,
actor_name=session.name or "",
actor_email=session.email or "",
actor_role=session.client_role or session.role,
action=action,
target_type="order",
target_id=target_id if target_id is not None else order.id,
module_key="ordering",
summary=summary,
)
def get_customer_account(db: Session, *, client_account_id: int) -> ClientAccount | None:
# client_account_id is a global primary key; a customer's own tenant_id may
# differ from ORDERING_TENANT, so we look up by id alone.
return db.scalar(select(ClientAccount).where(ClientAccount.id == client_account_id))
def ensure_customer_active(account: ClientAccount | None) -> None:
from fastapi import HTTPException, status
if account is None:
raise HTTPException(status_code=404, detail="Customer account not found")
if account.status != "active":
raise HTTPException(status_code=403, detail="This customer account is disabled")