diff --git a/core/shipments/README.md b/core/shipments/README.md index b224fe5..4666acc 100644 --- a/core/shipments/README.md +++ b/core/shipments/README.md @@ -15,4 +15,58 @@ This module handles shipments that are processed by the lost&found team. - **item validation**: the lostee recieves a email with publicly accessible link, on this page the user see the images and confirm the items - automatic state change after confirmation - **tracking**: the lostee recieves a email with publicly accessible link, on this page the user can enter his address -- Returning parcels could be managed to (e.g. if a shipment is marked as returned, the items will be *unfound* again) \ No newline at end of file +- Returning parcels could be managed to (e.g. if a shipment is marked as returned, the items will be *unfound* again) + +## State Model +The shipment process follows a defined state model to ensure proper workflow and validation: + +### States +1. **CREATED** (Initial State) + - Shipment is created without address, items, or tickets + - No validation requirements + +2. **PENDING_REVIEW** + - Items have been added to the shipment + - Waiting for requester to verify items and/or address + - Requires specification of what needs to be reviewed: + - Items only + - Address only + - Both items and address + - Validation requirements depend on review type + +3. **READY_FOR_SHIPPING** + - All required reviews completed + - All required fields validated + - Ready for shipping process + +4. **REJECTED** + - Shipment was reviewed and rejected + - Terminal state (no further transitions possible) + +5. **SHIPPED** + - Package has been shipped + - Requires complete address information + - Timestamp of shipping is recorded + +6. **COMPLETED** + - Package has been delivered + - Terminal state (no further transitions possible) + - Timestamp of completion is recorded + +### State Transitions +- CREATED → PENDING_REVIEW (when items are added, requires specification of review type) +- PENDING_REVIEW → READY_FOR_SHIPPING (when approved and all required reviews completed) +- PENDING_REVIEW → REJECTED (when rejected) +- READY_FOR_SHIPPING → SHIPPED (when marked as shipped) +- SHIPPED → COMPLETED (when marked as delivered) + +### Validation Rules +- Review requirement must be specified when entering PENDING_REVIEW state +- Address fields are required for shipping-related states (READY_FOR_SHIPPING, SHIPPED, COMPLETED) +- At least one item is required for any state beyond CREATED +- Invalid state transitions will raise validation errors +- Terminal states (REJECTED, COMPLETED) cannot transition to other states +- Approval requires completion of all specified review requirements: + - For ITEMS review: items must be added + - For ADDRESS review: all address fields must be complete + - For BOTH review: both items and complete address are required \ No newline at end of file diff --git a/core/shipments/models.py b/core/shipments/models.py index 9af5edf..b98f88d 100644 --- a/core/shipments/models.py +++ b/core/shipments/models.py @@ -1,13 +1,53 @@ import uuid - from django.db import models +from django.core.exceptions import ValidationError +from django.utils import timezone from django_softdelete.models import SoftDeleteModel, SoftDeleteManager from inventory.models import Item from tickets.models import IssueThread class Shipment(SoftDeleteModel): + # State choices + CREATED = 'CREATED' + PENDING_REVIEW = 'PENDING_REVIEW' + READY_FOR_SHIPPING = 'READY_FOR_SHIPPING' + REJECTED = 'REJECTED' + SHIPPED = 'SHIPPED' + COMPLETED = 'COMPLETED' + + SHIPMENT_STATES = [ + (CREATED, 'Created'), + (PENDING_REVIEW, 'Pending Review'), + (READY_FOR_SHIPPING, 'Ready for Shipping'), + (REJECTED, 'Rejected'), + (SHIPPED, 'Shipped'), + (COMPLETED, 'Completed'), + ] + + # Review requirement choices + REVIEW_ITEMS = 'ITEMS' + REVIEW_ADDRESS = 'ADDRESS' + REVIEW_BOTH = 'BOTH' + + REVIEW_REQUIREMENTS = [ + (REVIEW_ITEMS, 'Items Review Required'), + (REVIEW_ADDRESS, 'Address Review Required'), + (REVIEW_BOTH, 'Both Items and Address Review Required'), + ] + id = models.AutoField(primary_key=True) - public_secret = models.UUIDField(default = uuid.uuid4) + public_secret = models.UUIDField(default=uuid.uuid4) + state = models.CharField( + max_length=20, + choices=SHIPMENT_STATES, + default=CREATED + ) + review_requirement = models.CharField( + max_length=10, + choices=REVIEW_REQUIREMENTS, + null=True, + blank=True + ) # Shipping address fields recipient_name = models.CharField(max_length=255, blank=True, default='') @@ -23,8 +63,100 @@ class Shipment(SoftDeleteModel): created_at = models.DateTimeField(null=True, auto_now_add=True) updated_at = models.DateTimeField(blank=True, null=True) + shipped_at = models.DateTimeField(blank=True, null=True) + completed_at = models.DateTimeField(blank=True, null=True) all_objects = models.Manager() def __str__(self): - return '[' + str(self.id) + ']' + self.description \ No newline at end of file + return f'[{self.id}] {self.state}' + + def clean(self): + """Validate state transitions and required fields""" + if not self.pk: # New instance + return + + old_instance = Shipment.objects.get(pk=self.pk) + + # Validate state transitions + valid_transitions = { + self.CREATED: [self.PENDING_REVIEW], + self.PENDING_REVIEW: [self.READY_FOR_SHIPPING, self.REJECTED], + self.READY_FOR_SHIPPING: [self.SHIPPED], + self.SHIPPED: [self.COMPLETED], + self.REJECTED: [], # No transitions from rejected + self.COMPLETED: [], # No transitions from completed + } + + if (self.state != old_instance.state and + self.state not in valid_transitions[old_instance.state]): + raise ValidationError(f'Invalid state transition from {old_instance.state} to {self.state}') + + # Validate review requirement when entering PENDING_REVIEW + if self.state == self.PENDING_REVIEW and old_instance.state == self.CREATED: + if not self.review_requirement: + raise ValidationError('Review requirement must be specified when entering PENDING_REVIEW state') + + # Validate required fields based on state and review requirement + if self.state in [self.READY_FOR_SHIPPING, self.SHIPPED, self.COMPLETED]: + if not all([self.recipient_name, self.street_address, self.city, + self.state_province, self.postal_code, self.country]): + raise ValidationError('All address fields are required for shipping') + + if self.state != self.CREATED and not self.related_items.exists(): + raise ValidationError('Shipment must have at least one item') + + def add_items(self, review_requirement): + """Move shipment to PENDING_REVIEW after items are added""" + if self.state == self.CREATED and self.related_items.exists(): + if not review_requirement: + raise ValidationError('Review requirement must be specified when adding items') + if review_requirement not in [self.REVIEW_ITEMS, self.REVIEW_ADDRESS, self.REVIEW_BOTH]: + raise ValidationError(f'Invalid review requirement: {review_requirement}') + + self.review_requirement = review_requirement + self.state = self.PENDING_REVIEW + self.save() + + def approve(self): + """Approve shipment for shipping""" + if self.state == self.PENDING_REVIEW: + # Validate that all required reviews are completed + if self.review_requirement == self.REVIEW_ITEMS and not self.related_items.exists(): + raise ValidationError('Items must be added before approval') + elif self.review_requirement == self.REVIEW_ADDRESS and not all([self.recipient_name, self.street_address, self.city, + self.state_province, self.postal_code, self.country]): + raise ValidationError('Address must be complete before approval') + elif self.review_requirement == self.REVIEW_BOTH: + if not self.related_items.exists(): + raise ValidationError('Items must be added before approval') + if not all([self.recipient_name, self.street_address, self.city, + self.state_province, self.postal_code, self.country]): + raise ValidationError('Address must be complete before approval') + + self.state = self.READY_FOR_SHIPPING + self.save() + + def reject(self): + """Reject shipment""" + if self.state == self.PENDING_REVIEW: + self.state = self.REJECTED + self.save() + + def mark_shipped(self): + """Mark shipment as shipped""" + if self.state == self.READY_FOR_SHIPPING: + self.state = self.SHIPPED + self.shipped_at = timezone.now() + self.save() + + def mark_completed(self): + """Mark shipment as completed""" + if self.state == self.SHIPPED: + self.state = self.COMPLETED + self.completed_at = timezone.now() + self.save() + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) \ No newline at end of file