Files
cetana-backend/apps/orders/services.py
T
2026-10-19 23:03:58 +03:30

123 lines
5.3 KiB
Python

"""Order lifecycle service — ties reservations, weighing, dynamic step
selection, pricing and confirmation together. All financial values are
computed by the backend and stored as immutable snapshots."""
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from apps.core.errors import ApiError
from apps.orders.models import ProcessingOrder
from apps.pricing.engine import PricingEngine
from apps.workflows.models import Workflow
class OrderService:
def _active_workflow(self) -> Workflow:
workflow = Workflow.objects.filter(status="active", active_version__isnull=False).first()
if workflow is None:
raise ApiError("NO_ACTIVE_WORKFLOW", "No active workflow is configured.")
return workflow
@transaction.atomic
def create_from_reservation(self, reservation, workflow=None) -> ProcessingOrder:
if ProcessingOrder.objects.filter(reservation=reservation).exists():
raise ApiError("ORDER_EXISTS", "An order already exists for this reservation.")
if workflow is None:
workflow = self._active_workflow()
return ProcessingOrder.objects.create(
customer=reservation.customer,
reservation=reservation,
workflow_version=workflow.active_version,
)
@transaction.atomic
def record_weighing(self, order, gross, tare) -> ProcessingOrder:
order = ProcessingOrder.objects.select_for_update().get(pk=order.pk)
if order.net_weight_kg is not None:
raise ApiError("WEIGHING_IMMUTABLE", "Net weight is already recorded and immutable.")
gross = Decimal(str(gross))
tare = Decimal(str(tare))
if gross < tare:
raise ApiError("INVALID_WEIGHT", "Gross weight cannot be less than tare weight.")
order.gross_weight_kg = gross
order.tare_weight_kg = tare
order.net_weight_kg = gross - tare
order.save(update_fields=["gross_weight_kg", "tare_weight_kg", "net_weight_kg"])
return order
@transaction.atomic
def select_steps(self, order, step_keys) -> ProcessingOrder:
order = ProcessingOrder.objects.select_for_update().get(pk=order.pk)
if order.net_weight_kg is None:
raise ApiError("WEIGHING_REQUIRED", "Record weighing before selecting processing.")
version = order.workflow_version
steps = list(version.steps.filter(key__in=step_keys, enabled=True))
keys = {s.key for s in steps}
for required in version.steps.filter(required=True):
if required.key not in keys:
raise ApiError(
"REQUIRED_STEP_MISSING",
f"Required step '{required.key}' must be selected.",
{"step": required.key},
)
order.selected_step_keys = sorted(keys)
order.price_snapshot = PricingEngine().calculate(order.net_weight_kg, steps, order.currency)
order.status = ProcessingOrder.Status.AWAITING_CONFIRMATION
order.save(update_fields=["selected_step_keys", "price_snapshot", "status"])
return order
@transaction.atomic
def confirm(self, order, terms_accepted=True) -> ProcessingOrder:
order = ProcessingOrder.objects.select_for_update().get(pk=order.pk)
if order.status != ProcessingOrder.Status.AWAITING_CONFIRMATION:
raise ApiError("ORDER_NOT_CONFIRMABLE", "Order is not awaiting confirmation.")
if not terms_accepted:
raise ApiError("TERMS_REQUIRED", "Terms must be accepted.")
order.status = ProcessingOrder.Status.CONFIRMED
order.confirmed_at = timezone.now()
order.terms_accepted = True
order.save(update_fields=["status", "confirmed_at", "terms_accepted"])
# ── Spawn one StepExecution per selected step ──────────────────────
# This is what makes records appear in the /processing/ page.
# We use get_or_create so re-confirming an order never duplicates rows.
self._spawn_step_executions(order)
return order
def _spawn_step_executions(self, order) -> None:
"""Create a StepExecution for every step the customer selected.
The first step (lowest `order`) is set to READY so operators know
exactly where to begin. All subsequent steps stay PENDING until the
workflow engine or an operator explicitly advances them.
"""
from apps.processing.models import StepExecution
if not order.selected_step_keys:
return
steps = list(
order.workflow_version.steps
.filter(key__in=order.selected_step_keys, enabled=True)
.order_by("order")
)
for index, step in enumerate(steps):
initial_state = (
StepExecution.State.READY if index == 0
else StepExecution.State.PENDING
)
StepExecution.objects.get_or_create(
order=order,
step=step,
attempt=1,
defaults={"state": initial_state},
)
# Move the order itself into PROCESSING so the orders list
# reflects the current reality.
ProcessingOrder.objects.filter(pk=order.pk).update(
status=ProcessingOrder.Status.PROCESSING
)