Skip to content

Commit 54cb748

Browse files
cosmithclaude
andcommitted
feat: update frontend to add complete orders instead of individual stops
- Replace individual stop selection with complete order addition in trip drawer - Add pickup and delivery time inputs with improved UX - Use new /trips/{id}/add-order/ API endpoint for atomic order addition - Remove unsafe POST /trip-stops/ endpoint to prevent incomplete orders - Update trip drawer UI to show both pickup and delivery locations - Add validation service for ensuring order completeness - Update API documentation and remove obsolete test cases 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ea6ae28 commit 54cb748

8 files changed

Lines changed: 725 additions & 332 deletions

File tree

backend/API_DOC.md

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ All list endpoints return data in this format:
284284

285285
### Trip Actions
286286
- **POST** `/api/trips/{id}/notify-driver/` - Send email to driver
287+
- **POST** `/api/trips/{id}/add-order/` - Add complete order (pickup + delivery) to trip
287288

288289
**Trip Object (List View):**
289290
```json
@@ -376,10 +377,9 @@ Includes all above fields plus:
376377

377378
## Trip Stops
378379

379-
### List/Create Trip Stops
380+
### List Trip Stops
380381
- **GET** `/api/trip-stops/` - List all trip stops
381382
- **GET** `/api/trip-stops/?trip={id}` - Filter by trip
382-
- **POST** `/api/trip-stops/` - Create new trip stop
383383

384384
### Trip Stop Details
385385
- **GET** `/api/trip-stops/{id}/` - Get trip stop details
@@ -433,6 +433,70 @@ Example: If a trip has stops with orders [1, 2, 3] and you create a new stop wit
433433
- New stop: order=2
434434
- Existing stops: orders [1, 3, 4] (original order=2 and order=3 are shifted up)
435435

436+
#### Order Completeness Validation
437+
438+
**Business Rule: Complete Order Journeys**
439+
Trips must contain complete order journeys (both pickup and delivery stops). This ensures you can always deliver what you picked up.
440+
441+
**Validation Rules:**
442+
- When adding a stop to a trip that belongs to an order, the corresponding pickup/delivery stop must also be in the trip
443+
- Adding a pickup stop without its delivery stop will result in a 400 error
444+
- Adding a delivery stop without its pickup stop will result in a 400 error
445+
- Stops without orders (standalone stops) are allowed without restrictions
446+
- Orders must have both pickup and delivery stops defined to be usable in trips
447+
448+
**Error Response Example:**
449+
```json
450+
{
451+
"error": "Cannot add delivery stop for order ORD-2024-0001 without also including its pickup stop. Trips must contain complete order journeys (both pickup and delivery)."
452+
}
453+
```
454+
455+
**Recommended Workflow:**
456+
1. Ensure orders have both pickup and delivery stops before trip assignment
457+
2. Use **POST** `/api/trips/{id}/add-order/` to add complete orders to trips
458+
3. Individual trip stops can only be viewed/updated via GET/PUT operations
459+
460+
**Adding Complete Orders to Trips:**
461+
**POST** `/api/trips/{id}/add-order/`
462+
463+
Request:
464+
```json
465+
{
466+
"order": 1,
467+
"pickup_time": "09:00:00",
468+
"delivery_time": "14:00:00",
469+
"notes": "Handle with care"
470+
}
471+
```
472+
473+
Response:
474+
```json
475+
{
476+
"message": "Successfully added order ORD-2024-0001 to trip",
477+
"pickup_trip_stop": {
478+
"id": 15,
479+
"order": 3,
480+
"planned_arrival_time": "09:00:00",
481+
"stop": {
482+
"id": 10,
483+
"name": "Warehouse A",
484+
"stop_type": "pickup"
485+
}
486+
},
487+
"delivery_trip_stop": {
488+
"id": 16,
489+
"order": 4,
490+
"planned_arrival_time": "14:00:00",
491+
"stop": {
492+
"id": 11,
493+
"name": "Customer Site B",
494+
"stop_type": "delivery"
495+
}
496+
}
497+
}
498+
```
499+
436500
**Deleting Trip Stops:**
437501
When a trip stop is deleted, remaining stops with higher order numbers are automatically shifted down to close gaps and maintain consecutive ordering.
438502

backend/dashmap/management/commands/reset_db.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from companies.models import Company
88
from vehicles.models import Vehicle
99
from trips.models import Trip, TripStop
10+
from trips.services import get_orders_requiring_both_stops, add_order_to_trip
1011
from orders.models import Stop, Order
1112
from positions.models import Position
1213
from accounts.models import AuthToken, UserProfile
@@ -167,13 +168,15 @@ def handle(self, *args, **options):
167168
self.stdout.write(f'Created {len(orders)} test orders with {len(all_stops)} stops')
168169

169170

170-
# Create test trips
171+
# Create test trips with complete pickup/delivery pairs
171172
self.stdout.write('Creating test trips...')
172-
today = timezone.now().date()
173173

174174
trips = []
175175
statuses = ['draft', 'planned', 'in_progress', 'completed']
176176

177+
# Get orders that have both pickup and delivery stops
178+
complete_orders = get_orders_requiring_both_stops()
179+
177180
for i in range(10):
178181
trip = Trip.objects.create(
179182
vehicle=fake.random_element(vehicles),
@@ -185,23 +188,28 @@ def handle(self, *args, **options):
185188
notes=fake.sentence()
186189
)
187190

188-
# Add random stops to trip
189-
num_stops = fake.random_int(min=2, max=4)
190-
trip_stops = fake.random_elements(elements=all_stops, length=num_stops, unique=True)
191+
# Add complete orders (pickup + delivery pairs) to trip
192+
num_orders = fake.random_int(min=1, max=2) # 1-2 complete orders per trip
193+
selected_orders = fake.random_elements(elements=complete_orders, length=num_orders, unique=True)
194+
195+
for order in selected_orders:
196+
# Generate times - pickup first, delivery after
197+
pickup_time = fake.time()
198+
delivery_time = fake.time()
199+
while delivery_time <= pickup_time:
200+
delivery_time = fake.time()
191201

192-
for j, stop in enumerate(trip_stops):
193-
arrival_time = fake.time()
194-
TripStop.objects.create(
202+
# Add the complete order to the trip
203+
add_order_to_trip(
195204
trip=trip,
196-
stop=stop,
197-
order=j + 1,
198-
planned_arrival_time=arrival_time,
199-
notes=f'Stop {j+1} of {num_stops}'
205+
order=order,
206+
pickup_time=pickup_time,
207+
delivery_time=delivery_time
200208
)
201209

202210
trips.append(trip)
203211

204-
self.stdout.write(f'Created {len(trips)} test trips')
212+
self.stdout.write(f'Created {len(trips)} test trips with complete pickup/delivery pairs')
205213

206214
# Create test positions for active vehicles
207215
self.stdout.write('Creating test position data...')
@@ -243,6 +251,6 @@ def handle(self, *args, **options):
243251
self.stdout.write('\nTest data includes:')
244252
self.stdout.write('• Dashmove company with 20 realistic vehicles')
245253
self.stdout.write('• 15 orders with pickup and delivery stops (every stop belongs to an order)')
246-
self.stdout.write('• 10 trips with random stops and statuses')
254+
self.stdout.write('• 10 trips with complete pickup/delivery pairs (ensuring order integrity)')
247255
self.stdout.write('• 24 hours of position data for 10 vehicles')
248256
self.stdout.write('• All data generated using Faker with French localization')

backend/trips/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,12 @@ class Meta:
4242
ordering = ['order']
4343
unique_together = ['trip', 'order']
4444

45+
def save(self, *args, skip_validation=False, **kwargs):
46+
# Only validate for new TripStop instances (not updates) and when not explicitly skipped
47+
if not self.pk and not skip_validation:
48+
from .services import validate_new_trip_stop
49+
validate_new_trip_stop(self.trip, self.stop)
50+
super().save(*args, **kwargs)
51+
4552
def __str__(self):
4653
return f"{self.trip.name} - Stop {self.order}: {self.stop.name}"

backend/trips/services.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
from django.core.exceptions import ValidationError
2+
from django.db import models
3+
from typing import List, Dict, Any
4+
from .models import Trip, TripStop
5+
from orders.models import Stop, Order
6+
7+
8+
class TripValidationError(ValidationError):
9+
"""Custom validation error for trip-related violations"""
10+
pass
11+
12+
13+
def validate_trip_stops_completeness(trip: Trip) -> None:
14+
"""
15+
Validate that all orders in a trip have both pickup and delivery stops.
16+
17+
Args:
18+
trip: The Trip instance to validate
19+
20+
Raises:
21+
TripValidationError: If any order is missing its pickup or delivery stop
22+
"""
23+
incomplete_orders = get_incomplete_orders(trip)
24+
if incomplete_orders:
25+
order_numbers = [order.order_number for order in incomplete_orders]
26+
raise TripValidationError(
27+
f"Trip '{trip.name}' contains incomplete orders. "
28+
f"Orders {order_numbers} are missing either pickup or delivery stops. "
29+
f"All orders must have both pickup and delivery stops in the trip."
30+
)
31+
32+
33+
def validate_new_trip_stop(trip: Trip, stop: Stop) -> None:
34+
"""
35+
Validate that adding a new stop to a trip maintains order completeness.
36+
37+
Args:
38+
trip: The Trip instance to add the stop to
39+
stop: The Stop instance to be added
40+
41+
Raises:
42+
TripValidationError: If adding this stop would violate the completeness rule
43+
"""
44+
if not stop.order:
45+
# Stops without orders are allowed (legacy or special stops)
46+
return
47+
48+
order = stop.order
49+
trip_stops = trip.trip_stops.all()
50+
existing_stop_ids = set(ts.stop.id for ts in trip_stops)
51+
52+
# Get the paired stop (pickup if this is delivery, delivery if this is pickup)
53+
paired_stop_type = 'pickup' if stop.stop_type == 'delivery' else 'delivery'
54+
paired_stop = order.stops.filter(stop_type=paired_stop_type).first()
55+
56+
if not paired_stop:
57+
raise TripValidationError(
58+
f"Order {order.order_number} does not have a {paired_stop_type} stop. "
59+
f"Cannot add {stop.stop_type} stop without its pair."
60+
)
61+
62+
# If the paired stop is not in the trip, this would create an incomplete order
63+
if paired_stop.id not in existing_stop_ids:
64+
raise TripValidationError(
65+
f"Cannot add {stop.stop_type} stop for order {order.order_number} "
66+
f"without also including its {paired_stop_type} stop. "
67+
f"Trips must contain complete order journeys (both pickup and delivery)."
68+
)
69+
70+
71+
def get_incomplete_orders(trip: Trip) -> List[Order]:
72+
"""
73+
Get a list of orders that have incomplete stop pairs in the trip.
74+
75+
Args:
76+
trip: The Trip instance to check
77+
78+
Returns:
79+
List of Order instances that are missing either pickup or delivery stops
80+
"""
81+
trip_stops = trip.trip_stops.select_related('stop', 'stop__order').all()
82+
orders_in_trip = {}
83+
84+
# Group stops by order
85+
for trip_stop in trip_stops:
86+
if trip_stop.stop.order:
87+
order = trip_stop.stop.order
88+
if order.id not in orders_in_trip:
89+
orders_in_trip[order.id] = {'order': order, 'stops': []}
90+
orders_in_trip[order.id]['stops'].append(trip_stop.stop)
91+
92+
incomplete_orders = []
93+
for order_data in orders_in_trip.values():
94+
order = order_data['order']
95+
stops = order_data['stops']
96+
stop_types = {stop.stop_type for stop in stops}
97+
98+
# Check if both pickup and delivery are present
99+
if 'pickup' not in stop_types or 'delivery' not in stop_types:
100+
incomplete_orders.append(order)
101+
102+
return incomplete_orders
103+
104+
105+
def ensure_order_pair_in_trip(trip: Trip, order: Order) -> Dict[str, Any]:
106+
"""
107+
Ensure both pickup and delivery stops for an order are in the trip.
108+
109+
Args:
110+
trip: The Trip instance
111+
order: The Order instance to ensure completeness for
112+
113+
Returns:
114+
Dict with 'pickup_stop' and 'delivery_stop' that should be in the trip
115+
116+
Raises:
117+
TripValidationError: If the order doesn't have both pickup and delivery stops
118+
"""
119+
pickup_stop = order.stops.filter(stop_type='pickup').first()
120+
delivery_stop = order.stops.filter(stop_type='delivery').first()
121+
122+
if not pickup_stop:
123+
raise TripValidationError(
124+
f"Order {order.order_number} does not have a pickup stop."
125+
)
126+
127+
if not delivery_stop:
128+
raise TripValidationError(
129+
f"Order {order.order_number} does not have a delivery stop."
130+
)
131+
132+
return {
133+
'pickup_stop': pickup_stop,
134+
'delivery_stop': delivery_stop
135+
}
136+
137+
138+
def add_order_to_trip(trip: Trip, order: Order, pickup_time, delivery_time, notes: str = "") -> Dict[str, Any]:
139+
"""
140+
Add both pickup and delivery stops for an order to a trip atomically.
141+
142+
Args:
143+
trip: The Trip instance to add stops to
144+
order: The Order instance to add
145+
pickup_time: Time for pickup stop
146+
delivery_time: Time for delivery stop
147+
notes: Optional notes for both stops
148+
149+
Returns:
150+
Dict with 'pickup_trip_stop' and 'delivery_trip_stop' instances
151+
152+
Raises:
153+
TripValidationError: If the order doesn't have both pickup and delivery stops
154+
"""
155+
from django.db import transaction
156+
from .models import TripStop
157+
158+
# Validate the order has both stops
159+
order_stops = ensure_order_pair_in_trip(trip, order)
160+
pickup_stop = order_stops['pickup_stop']
161+
delivery_stop = order_stops['delivery_stop']
162+
163+
with transaction.atomic():
164+
# Get the next available order numbers for the trip
165+
last_order = trip.trip_stops.aggregate(
166+
max_order=models.Max('order')
167+
)['max_order'] or 0
168+
169+
pickup_order = last_order + 1
170+
delivery_order = last_order + 2
171+
172+
# Create both trip stops with validation disabled
173+
pickup_trip_stop = TripStop(
174+
trip=trip,
175+
stop=pickup_stop,
176+
order=pickup_order,
177+
planned_arrival_time=pickup_time,
178+
notes=notes or f'Pickup for {order.order_number}'
179+
)
180+
pickup_trip_stop.save(skip_validation=True)
181+
182+
delivery_trip_stop = TripStop(
183+
trip=trip,
184+
stop=delivery_stop,
185+
order=delivery_order,
186+
planned_arrival_time=delivery_time,
187+
notes=notes or f'Delivery for {order.order_number}'
188+
)
189+
delivery_trip_stop.save(skip_validation=True)
190+
191+
return {
192+
'pickup_trip_stop': pickup_trip_stop,
193+
'delivery_trip_stop': delivery_trip_stop
194+
}
195+
196+
197+
def get_orders_requiring_both_stops() -> List[Order]:
198+
"""
199+
Get all orders that have both pickup and delivery stops defined.
200+
This is useful for trip planning to ensure we only work with complete orders.
201+
202+
Returns:
203+
List of Order instances that have both pickup and delivery stops
204+
"""
205+
orders_with_both_stops = []
206+
207+
for order in Order.objects.prefetch_related('stops').all():
208+
stops = order.stops.all()
209+
stop_types = {stop.stop_type for stop in stops}
210+
211+
if 'pickup' in stop_types and 'delivery' in stop_types:
212+
orders_with_both_stops.append(order)
213+
214+
return orders_with_both_stops

0 commit comments

Comments
 (0)