fix error in gitignore

This commit is contained in:
2026-09-24 12:32:59 +03:30
commit 86c117ee2a
165 changed files with 5963 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# orders app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class OrdersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.orders"
+43
View File
@@ -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'],
},
),
]
View File
+62
View File
@@ -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)
+31
View File
@@ -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)
+81
View File
@@ -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
+72
View File
@@ -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")
+8
View File
@@ -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
+101
View File
@@ -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)