fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# orders app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OrdersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.orders"
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
('reservations', '0001_initial'),
|
||||
('workflows', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProcessingOrder',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('order_number', models.CharField(blank=True, max_length=32, unique=True)),
|
||||
('gross_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('tare_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('net_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('selected_step_keys', models.JSONField(blank=True, default=list)),
|
||||
('price_snapshot', models.JSONField(blank=True, default=dict)),
|
||||
('currency', models.CharField(default='IRR', max_length=8)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('awaiting_confirmation', 'Awaiting confirmation'), ('confirmed', 'Confirmed'), ('processing', 'Processing'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='draft', max_length=24)),
|
||||
('confirmed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('terms_accepted', models.BooleanField(default=False)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='accounts.customer')),
|
||||
('reservation', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order', to='reservations.reservation')),
|
||||
('workflow_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='workflows.workflowversion')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
def generate_order_number() -> str:
|
||||
year = timezone.now().year
|
||||
prefix = f"STN-{year}-"
|
||||
last = (
|
||||
ProcessingOrder.objects.filter(order_number__startswith=prefix)
|
||||
.order_by("-order_number")
|
||||
.first()
|
||||
)
|
||||
if last:
|
||||
try:
|
||||
seq = int(last.order_number.rsplit("-", 1)[1]) + 1
|
||||
except (IndexError, ValueError):
|
||||
seq = 1
|
||||
else:
|
||||
seq = 1
|
||||
return f"{prefix}{seq:06d}"
|
||||
|
||||
|
||||
class ProcessingOrder(UUIDModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
AWAITING_CONFIRMATION = "awaiting_confirmation", "Awaiting confirmation"
|
||||
CONFIRMED = "confirmed", "Confirmed"
|
||||
PROCESSING = "processing", "Processing"
|
||||
COMPLETED = "completed", "Completed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
order_number = models.CharField(max_length=32, unique=True, blank=True)
|
||||
customer = models.ForeignKey("accounts.Customer", on_delete=models.PROTECT, related_name="orders")
|
||||
reservation = models.OneToOneField(
|
||||
"reservations.Reservation", null=True, blank=True, on_delete=models.SET_NULL, related_name="order"
|
||||
)
|
||||
# Workflow version is immutable per order — historical orders never change.
|
||||
workflow_version = models.ForeignKey(
|
||||
"workflows.WorkflowVersion", on_delete=models.PROTECT, related_name="orders"
|
||||
)
|
||||
gross_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
tare_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
net_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
selected_step_keys = models.JSONField(default=list, blank=True)
|
||||
price_snapshot = models.JSONField(default=dict, blank=True)
|
||||
currency = models.CharField(max_length=8, default="IRR")
|
||||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.DRAFT)
|
||||
confirmed_at = models.DateTimeField(null=True, blank=True)
|
||||
terms_accepted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.order_number:
|
||||
self.order_number = generate_order_number()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.order_number or str(self.id)
|
||||
@@ -0,0 +1,31 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.orders.models import ProcessingOrder
|
||||
|
||||
|
||||
class ProcessingOrderSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProcessingOrder
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id", "order_number", "workflow_version", "price_snapshot",
|
||||
"status", "confirmed_at", "net_weight_kg", "gross_weight_kg", "tare_weight_kg",
|
||||
]
|
||||
|
||||
|
||||
class OrderCreateSerializer(serializers.Serializer):
|
||||
reservation = serializers.UUIDField()
|
||||
workflow = serializers.UUIDField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class WeighingSerializer(serializers.Serializer):
|
||||
gross = serializers.DecimalField(max_digits=12, decimal_places=2)
|
||||
tare = serializers.DecimalField(max_digits=12, decimal_places=2)
|
||||
|
||||
|
||||
class StepSelectionSerializer(serializers.Serializer):
|
||||
step_keys = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
|
||||
class ConfirmSerializer(serializers.Serializer):
|
||||
terms_accepted = serializers.BooleanField(default=True)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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
|
||||
@@ -0,0 +1,72 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import Customer
|
||||
from apps.core.errors import ApiError
|
||||
from apps.orders.services import OrderService
|
||||
from apps.pricing.models import PricingRule
|
||||
from apps.reservations.models import Facility, Reservation, Slot
|
||||
from apps.workflows.engine import WorkflowEngine
|
||||
from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def make_active_workflow():
|
||||
wf = Workflow.objects.create(name="W", slug="w-order")
|
||||
v = WorkflowVersion.objects.create(workflow=wf, version=1)
|
||||
s1 = WorkflowStep.objects.create(version=v, key="start", name="Start", order=1, is_start=True)
|
||||
wash = WorkflowStep.objects.create(version=v, key="washing", name="Washing", order=2, required=True)
|
||||
end = WorkflowStep.objects.create(version=v, key="end", name="End", order=3, is_terminal=True)
|
||||
WorkflowTransition.objects.create(version=v, from_step=s1, to_step=wash)
|
||||
WorkflowTransition.objects.create(version=v, from_step=wash, to_step=end)
|
||||
WorkflowEngine().publish(wf, v)
|
||||
PricingRule.objects.create(step=wash, name="Wash", rule_type="per_kg", amount=Decimal("5000"))
|
||||
return wf, v
|
||||
|
||||
|
||||
class OrderServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="c", password="***")
|
||||
self.customer = Customer.objects.create(user=self.user)
|
||||
self.facility = Facility.objects.create(name="F")
|
||||
self.slot = Slot.objects.create(
|
||||
facility=self.facility, date=timezone.localdate(), start_time="08:00", end_time="08:30", capacity=5
|
||||
)
|
||||
self.reservation = Reservation.objects.create(customer=self.customer, slot=self.slot, status="confirmed")
|
||||
make_active_workflow()
|
||||
self.service = OrderService()
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
order = self.service.create_from_reservation(self.reservation)
|
||||
order = self.service.record_weighing(order, "1000", "80")
|
||||
self.assertEqual(order.net_weight_kg, Decimal("920"))
|
||||
order = self.service.select_steps(order, ["washing"])
|
||||
self.assertEqual(order.status, "awaiting_confirmation")
|
||||
self.assertEqual(order.price_snapshot["total"], "4600000.00")
|
||||
order = self.service.confirm(order)
|
||||
self.assertEqual(order.status, "confirmed")
|
||||
self.assertIsNotNone(order.confirmed_at)
|
||||
|
||||
def test_required_step_missing(self):
|
||||
order = self.service.create_from_reservation(self.reservation)
|
||||
order = self.service.record_weighing(order, "1000", "0")
|
||||
with self.assertRaises(ApiError) as ctx:
|
||||
self.service.select_steps(order, ["end"])
|
||||
self.assertEqual(ctx.exception.code, "REQUIRED_STEP_MISSING")
|
||||
|
||||
def test_weighing_immutable(self):
|
||||
order = self.service.create_from_reservation(self.reservation)
|
||||
order = self.service.record_weighing(order, "1000", "80")
|
||||
with self.assertRaises(ApiError) as ctx:
|
||||
self.service.record_weighing(order, "900", "0")
|
||||
self.assertEqual(ctx.exception.code, "WEIGHING_IMMUTABLE")
|
||||
|
||||
def test_duplicate_order_for_reservation(self):
|
||||
self.service.create_from_reservation(self.reservation)
|
||||
with self.assertRaises(ApiError) as ctx:
|
||||
self.service.create_from_reservation(self.reservation)
|
||||
self.assertEqual(ctx.exception.code, "ORDER_EXISTS")
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.orders.views import ProcessingOrderViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", ProcessingOrderViewSet, basename="order")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,101 @@
|
||||
from rest_framework import decorators, mixins, response, status, viewsets
|
||||
|
||||
from apps.core.errors import ApiError, error_response
|
||||
from apps.orders.models import ProcessingOrder
|
||||
from apps.orders.serializers import (
|
||||
ConfirmSerializer,
|
||||
OrderCreateSerializer,
|
||||
ProcessingOrderSerializer,
|
||||
StepSelectionSerializer,
|
||||
WeighingSerializer,
|
||||
)
|
||||
from apps.orders.services import OrderService
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
from apps.reservations.models import Reservation
|
||||
from apps.workflows.models import Workflow
|
||||
|
||||
|
||||
class ProcessingOrderViewSet(
|
||||
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
serializer_class = ProcessingOrderSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {
|
||||
"list": "processing.view",
|
||||
"retrieve": "processing.view",
|
||||
"create": "reservation.create",
|
||||
"weigh": "weighing.create",
|
||||
"select_steps": "reservation.update",
|
||||
"confirm": "reservation.update",
|
||||
}
|
||||
service = OrderService()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = ProcessingOrder.objects.select_related("customer", "workflow_version").all()
|
||||
if user.is_superuser:
|
||||
return qs
|
||||
return qs.filter(customer__user=user)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
input_serializer = OrderCreateSerializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
reservation = Reservation.objects.get(pk=input_serializer.validated_data["reservation"])
|
||||
except Reservation.DoesNotExist:
|
||||
return response.Response(
|
||||
error_response("RESERVATION_NOT_FOUND", "Reservation not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
workflow_id = input_serializer.validated_data.get("workflow")
|
||||
workflow = None
|
||||
if workflow_id:
|
||||
try:
|
||||
workflow = Workflow.objects.get(pk=workflow_id)
|
||||
except Workflow.DoesNotExist:
|
||||
return response.Response(
|
||||
error_response("WORKFLOW_NOT_FOUND", "Workflow not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
try:
|
||||
order = self.service.create_from_reservation(reservation, workflow)
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ProcessingOrderSerializer(order).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def weigh(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
input_serializer = WeighingSerializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
order = self.service.record_weighing(
|
||||
order,
|
||||
input_serializer.validated_data["gross"],
|
||||
input_serializer.validated_data["tare"],
|
||||
)
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ProcessingOrderSerializer(order).data)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def select_steps(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
input_serializer = StepSelectionSerializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
order = self.service.select_steps(order, input_serializer.validated_data["step_keys"])
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ProcessingOrderSerializer(order).data)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def confirm(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
input_serializer = ConfirmSerializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
order = self.service.confirm(order, input_serializer.validated_data["terms_accepted"])
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ProcessingOrderSerializer(order).data)
|
||||
Reference in New Issue
Block a user