82 lines
3.7 KiB
Python
82 lines
3.7 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"])
|
|
return order
|