Fulfilment: mark packed orders as shipped + customer address edits
Python · Django · advanced · modification
Adds the ship step to fulfilment plus customer address editing. mark_shipped() validates the order is packed, books the parcel with the carrier, then stamps the order shipped with its tracking code and timestamp; PATCH /orders/<pk>/address/ lets customers correct the delivery address until the order ships (409 after that, 404 for signed-in non-owners). The carrier call is slow but the flow stays linear and easy to follow. Walked packed→shipped end-to-end on staging, tracking code lands on the order and the address endpoint rejects edits after shipment.
An e-commerce warehouse on Django 5 + DRF, Postgres, USE_TZ=True. Orders are packed at a station whose queue feeds the fulfilment worker; meanwhile customers can still correct their delivery address from the storefront.
Requirements
- This PR adds the fulfilment step that marks a packed order as shipped: `fulfilment/services.py::mark_shipped(order_id)` is invoked by the warehouse worker process consuming the packing queue. The caller passes the id of a persisted order (the queue only ever emits ids the packing station wrote to the DB), so a missing row does not need to be handled here.
- mark_shipped accepts only orders in the `packed` status and raises `FulfilmentError` for anything else. The fulfilment worker is the sole writer of status transitions for a packed order — no other process may move a packed order to another status while the worker runs — so the status does not need to be re-checked after the carrier call.
- Before flipping the status, the worker books the parcel with the carrier (`POST {CARRIER_API_URL}/shipments` with the order's reference). The call typically takes 3–8 seconds. On success the carrier responds with the shipment's tracking code; the endpoint is idempotent on `reference` (re-booking the same reference returns the existing tracking code), so a retry by the worker platform after a failed run cannot double-book a parcel.
- On a successful booking the order gets `status = shipped`, the returned `tracking_code`, and `shipped_at = timezone.now()`.
- The carrier API contract guarantees that every `tracking_code` returned by the shipment endpoint is a non-empty alphanumeric string of at most 32 characters, so `max_length=64` on the model field is a generous safety margin.
- Customers may edit `shipping_address` through the new `PATCH orders/<int:pk>/address/` endpoint until the order is shipped or cancelled; for those two statuses the endpoint responds 409. An edit is valid if the order is not yet shipped/cancelled at the moment the request validates it. The endpoint accepts a non-empty address of at most 500 characters and only ever acts on the requesting customer's own orders.
- The fulfilment worker and the customer-facing API run concurrently against the same database: an address edit can be accepted while mark_shipped is waiting on the carrier. A shipped order must carry the latest saved delivery address — the courier's handheld reads the address from the order row at pickup, and the shipping label itself carries only the order reference.
Files touched
- orders/models.py
- orders/migrations/0006_order_tracking_code_and_more.py
- fulfilment/services.py
- orders/api.py
- orders/urls.py
- config/urls.py
--- orders/models.py
)
shipping_address = models.TextField()
+ tracking_code = models.CharField(max_length=64, blank=True, default="")
+ shipped_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
--- orders/migrations/0006_order_tracking_code_and_more.py
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("orders", "0005_order_shipping_address"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="order",
+ name="tracking_code",
+ field=models.CharField(blank=True, default="", max_length=64),
+ ),
+ migrations.AddField(
+ model_name="order",
+ name="shipped_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
+