feat(shipments): implement state model and validation for shipment workflow
This commit is contained in:
parent
dfdea0158f
commit
6515ce8efa
2 changed files with 190 additions and 4 deletions
|
@ -16,3 +16,57 @@ This module handles shipments that are processed by the lost&found team.
|
||||||
- automatic state change after confirmation
|
- automatic state change after confirmation
|
||||||
- **tracking**: the lostee recieves a email with publicly accessible link, on this page the user can enter his address
|
- **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)
|
- 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
|
|
@ -1,13 +1,53 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils import timezone
|
||||||
from django_softdelete.models import SoftDeleteModel, SoftDeleteManager
|
from django_softdelete.models import SoftDeleteModel, SoftDeleteManager
|
||||||
from inventory.models import Item
|
from inventory.models import Item
|
||||||
from tickets.models import IssueThread
|
from tickets.models import IssueThread
|
||||||
|
|
||||||
class Shipment(SoftDeleteModel):
|
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)
|
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
|
# Shipping address fields
|
||||||
recipient_name = models.CharField(max_length=255, blank=True, default='')
|
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)
|
created_at = models.DateTimeField(null=True, auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(blank=True, null=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()
|
all_objects = models.Manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '[' + str(self.id) + ']' + self.description
|
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)
|
Loading…
Add table
Reference in a new issue