Skip to main content

stateset_core/models/
shipment.rs

1//! Shipment models for tracking order fulfillment and delivery
2
3use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use stateset_primitives::{OrderId, ProductId, ShipmentId};
7use strum::{Display, EnumString};
8use uuid::Uuid;
9
10/// Shipping carrier for deliveries
11#[derive(
12    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
13)]
14#[serde(rename_all = "snake_case")]
15#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
16#[non_exhaustive]
17pub enum ShippingCarrier {
18    #[default]
19    Other,
20    Ups,
21    #[strum(serialize = "fedex", serialize = "fed_ex")]
22    FedEx,
23    Usps,
24    Dhl,
25    #[strum(serialize = "ontrac", serialize = "on_trac")]
26    OnTrac,
27    #[strum(serialize = "lasership", serialize = "laser_ship")]
28    LaserShip,
29}
30
31impl ShippingCarrier {
32    /// Get the tracking URL base for this carrier
33    #[must_use]
34    pub const fn tracking_url_base(&self) -> Option<&'static str> {
35        match self {
36            Self::Ups => Some("https://www.ups.com/track?tracknum="),
37            Self::FedEx => Some("https://www.fedex.com/apps/fedextrack/?tracknumbers="),
38            Self::Usps => Some("https://tools.usps.com/go/TrackConfirmAction?tLabels="),
39            Self::Dhl => Some(
40                "https://www.dhl.com/us-en/home/tracking/tracking-express.html?submit=1&tracking-id=",
41            ),
42            Self::OnTrac => Some("https://www.ontrac.com/tracking/?number="),
43            Self::LaserShip => Some("https://www.lasership.com/track/"),
44            Self::Other => None,
45        }
46    }
47
48    /// Generate a full tracking URL for a tracking number
49    #[must_use]
50    pub fn tracking_url(&self, tracking_number: &str) -> Option<String> {
51        self.tracking_url_base().map(|base| format!("{base}{tracking_number}"))
52    }
53}
54
55/// Shipping method/speed
56#[derive(
57    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
58)]
59#[serde(rename_all = "snake_case")]
60#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
61#[non_exhaustive]
62pub enum ShippingMethod {
63    #[default]
64    Standard,
65    Express,
66    Overnight,
67    #[strum(serialize = "two_day", serialize = "twoday")]
68    TwoDay,
69    Ground,
70    International,
71    #[strum(serialize = "same_day", serialize = "sameday")]
72    SameDay,
73    Freight,
74}
75
76/// Status of a shipment through its lifecycle
77#[derive(
78    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
79)]
80#[serde(rename_all = "snake_case")]
81#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
82#[non_exhaustive]
83pub enum ShipmentStatus {
84    /// Initial state - shipment created but not yet processed
85    #[default]
86    Pending,
87    /// Being prepared for shipping
88    Processing,
89    /// Packed and ready to ship
90    #[strum(serialize = "ready_to_ship", serialize = "readytoship")]
91    ReadyToShip,
92    /// Handed off to carrier
93    Shipped,
94    /// In carrier's network
95    #[strum(serialize = "in_transit", serialize = "intransit")]
96    InTransit,
97    /// On delivery vehicle
98    #[strum(serialize = "out_for_delivery", serialize = "outfordelivery")]
99    OutForDelivery,
100    /// Successfully delivered
101    Delivered,
102    /// Delivery attempt failed
103    Failed,
104    /// Returned to sender
105    Returned,
106    /// Shipment cancelled
107    #[strum(serialize = "cancelled", serialize = "canceled")]
108    Cancelled,
109    /// On hold for investigation
110    #[strum(serialize = "on_hold", serialize = "onhold")]
111    OnHold,
112}
113
114impl ShipmentStatus {
115    /// Check if this status can transition to the target status
116    #[must_use]
117    pub const fn can_transition_to(&self, target: Self) -> bool {
118        use ShipmentStatus::{
119            Cancelled, Delivered, Failed, InTransit, OnHold, OutForDelivery, Pending, Processing,
120            ReadyToShip, Returned, Shipped,
121        };
122        matches!(
123            (self, target),
124            // Forward flow
125            (Pending | OnHold, Processing)
126                | (Pending | Processing | ReadyToShip | OnHold, Cancelled)
127                | (Processing, ReadyToShip | OnHold)
128                | (ReadyToShip, Shipped)
129                | (Shipped | Failed, InTransit)
130                | (InTransit, OutForDelivery | Failed)
131                | (OutForDelivery, Delivered | Failed)
132                | (Failed | Delivered, Returned)
133                | (Pending, OnHold)
134        )
135    }
136
137    /// Check if this is a terminal status
138    #[must_use]
139    pub const fn is_terminal(&self) -> bool {
140        matches!(self, Self::Delivered | Self::Cancelled | Self::Returned)
141    }
142}
143
144/// A shipment tracks the physical delivery of items from an order
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Shipment {
147    /// Unique identifier
148    pub id: ShipmentId,
149    /// Human-readable shipment number (e.g., "SHP-ABC123")
150    pub shipment_number: String,
151    /// Order this shipment belongs to
152    pub order_id: OrderId,
153    /// Current status
154    pub status: ShipmentStatus,
155    /// Shipping carrier
156    pub carrier: ShippingCarrier,
157    /// Shipping method/speed
158    pub shipping_method: ShippingMethod,
159    /// Carrier tracking number
160    pub tracking_number: Option<String>,
161    /// Auto-generated tracking URL
162    pub tracking_url: Option<String>,
163
164    // Recipient information
165    /// Recipient name
166    pub recipient_name: String,
167    /// Recipient email for notifications
168    pub recipient_email: Option<String>,
169    /// Recipient phone
170    pub recipient_phone: Option<String>,
171    /// Full shipping address
172    pub shipping_address: String,
173
174    // Package details
175    /// Package weight in kg
176    pub weight_kg: Option<Decimal>,
177    /// Package dimensions (e.g., "10x8x6 cm")
178    pub dimensions: Option<String>,
179    /// Shipping cost
180    pub shipping_cost: Option<Decimal>,
181    /// Insurance amount
182    pub insurance_amount: Option<Decimal>,
183    /// Whether signature is required on delivery
184    pub signature_required: bool,
185
186    // Timestamps
187    /// When the shipment was handed to carrier
188    pub shipped_at: Option<DateTime<Utc>>,
189    /// Expected delivery date
190    pub estimated_delivery: Option<DateTime<Utc>>,
191    /// Actual delivery timestamp
192    pub delivered_at: Option<DateTime<Utc>>,
193
194    /// Notes about the shipment
195    pub notes: Option<String>,
196    /// Items in this shipment
197    pub items: Vec<ShipmentItem>,
198    /// Tracking events/history
199    pub events: Vec<ShipmentEvent>,
200    /// Version for optimistic locking
201    pub version: i32,
202
203    pub created_at: DateTime<Utc>,
204    pub updated_at: DateTime<Utc>,
205}
206
207impl Shipment {
208    /// Generate a unique shipment number based on timestamp
209    #[must_use]
210    pub fn generate_shipment_number() -> String {
211        let now = chrono::Utc::now();
212        let suffix = Uuid::new_v4().simple().to_string();
213        let short_suffix = suffix[..8].to_uppercase();
214        format!("SHP-{}-{short_suffix}", now.format("%Y%m%d%H%M%S"))
215    }
216
217    /// Calculate transit time in days (if delivered)
218    #[must_use]
219    pub fn transit_days(&self) -> Option<f64> {
220        match (self.shipped_at, self.delivered_at) {
221            (Some(shipped), Some(delivered)) => {
222                let duration = delivered - shipped;
223                Some(duration.num_hours() as f64 / 24.0)
224            }
225            _ => None,
226        }
227    }
228
229    /// Check if delivery is late
230    #[must_use]
231    pub fn is_late(&self) -> bool {
232        match (self.estimated_delivery, self.delivered_at) {
233            (Some(estimated), Some(delivered)) => delivered > estimated,
234            (Some(estimated), None) if !self.status.is_terminal() => Utc::now() > estimated,
235            _ => false,
236        }
237    }
238}
239
240/// An item within a shipment
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ShipmentItem {
243    pub id: Uuid,
244    pub shipment_id: ShipmentId,
245    /// Reference to order item
246    pub order_item_id: Option<Uuid>,
247    /// Product being shipped
248    pub product_id: Option<ProductId>,
249    /// SKU of the item
250    pub sku: String,
251    /// Item name/description
252    pub name: String,
253    /// Quantity in this shipment
254    pub quantity: i32,
255    pub created_at: DateTime<Utc>,
256    pub updated_at: DateTime<Utc>,
257}
258
259/// A tracking event in a shipment's history
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ShipmentEvent {
262    pub id: Uuid,
263    pub shipment_id: ShipmentId,
264    /// Type of event (e.g., "`picked_up`", "`departed_facility`", "`arrived_at_hub`")
265    pub event_type: String,
266    /// Location where event occurred
267    pub location: Option<String>,
268    /// Description of the event
269    pub description: Option<String>,
270    /// When the event occurred (may differ from `created_at` for imported events)
271    pub event_time: DateTime<Utc>,
272    pub created_at: DateTime<Utc>,
273}
274
275/// Input for creating a new shipment
276#[derive(Debug, Clone, Default)]
277pub struct CreateShipment {
278    pub order_id: OrderId,
279    pub carrier: Option<ShippingCarrier>,
280    pub shipping_method: Option<ShippingMethod>,
281    pub tracking_number: Option<String>,
282    pub recipient_name: String,
283    pub recipient_email: Option<String>,
284    pub recipient_phone: Option<String>,
285    pub shipping_address: String,
286    pub weight_kg: Option<Decimal>,
287    pub dimensions: Option<String>,
288    pub shipping_cost: Option<Decimal>,
289    pub insurance_amount: Option<Decimal>,
290    pub signature_required: Option<bool>,
291    pub estimated_delivery: Option<DateTime<Utc>>,
292    pub notes: Option<String>,
293    pub items: Option<Vec<CreateShipmentItem>>,
294}
295
296/// Input for creating a shipment item
297#[derive(Debug, Clone, Default)]
298pub struct CreateShipmentItem {
299    pub order_item_id: Option<Uuid>,
300    pub product_id: Option<ProductId>,
301    pub sku: String,
302    pub name: String,
303    pub quantity: i32,
304}
305
306/// Input for updating a shipment
307#[derive(Debug, Clone, Default)]
308pub struct UpdateShipment {
309    pub status: Option<ShipmentStatus>,
310    pub carrier: Option<ShippingCarrier>,
311    pub tracking_number: Option<String>,
312    pub recipient_name: Option<String>,
313    pub recipient_email: Option<String>,
314    pub recipient_phone: Option<String>,
315    pub shipping_address: Option<String>,
316    pub weight_kg: Option<Decimal>,
317    pub dimensions: Option<String>,
318    pub shipping_cost: Option<Decimal>,
319    pub estimated_delivery: Option<DateTime<Utc>>,
320    pub notes: Option<String>,
321}
322
323/// Input for adding a tracking event
324#[derive(Debug, Clone)]
325pub struct AddShipmentEvent {
326    pub event_type: String,
327    pub location: Option<String>,
328    pub description: Option<String>,
329    pub event_time: Option<DateTime<Utc>>,
330}
331
332/// Filter for querying shipments
333#[derive(Debug, Clone, Default)]
334pub struct ShipmentFilter {
335    pub order_id: Option<OrderId>,
336    pub status: Option<ShipmentStatus>,
337    pub carrier: Option<ShippingCarrier>,
338    pub tracking_number: Option<String>,
339    pub limit: Option<u32>,
340    pub offset: Option<u32>,
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::str::FromStr;
347
348    #[test]
349    fn test_shipment_status_from_str() {
350        assert_eq!(ShipmentStatus::from_str("pending").unwrap(), ShipmentStatus::Pending);
351        assert_eq!(ShipmentStatus::from_str("ready_to_ship").unwrap(), ShipmentStatus::ReadyToShip);
352        assert_eq!(
353            ShipmentStatus::from_str("outfordelivery").unwrap(),
354            ShipmentStatus::OutForDelivery
355        );
356        assert_eq!(ShipmentStatus::from_str("canceled").unwrap(), ShipmentStatus::Cancelled);
357    }
358
359    #[test]
360    fn test_shipping_carrier_from_str() {
361        assert_eq!(ShippingCarrier::from_str("ups").unwrap(), ShippingCarrier::Ups);
362        assert_eq!(ShippingCarrier::from_str("fed_ex").unwrap(), ShippingCarrier::FedEx);
363        assert_eq!(ShippingCarrier::from_str("on_trac").unwrap(), ShippingCarrier::OnTrac);
364        assert_eq!(ShippingCarrier::from_str("other").unwrap(), ShippingCarrier::Other);
365    }
366
367    #[test]
368    fn test_shipping_method_from_str() {
369        assert_eq!(ShippingMethod::from_str("standard").unwrap(), ShippingMethod::Standard);
370        assert_eq!(ShippingMethod::from_str("two_day").unwrap(), ShippingMethod::TwoDay);
371        assert_eq!(ShippingMethod::from_str("sameday").unwrap(), ShippingMethod::SameDay);
372    }
373
374    #[test]
375    fn shipment_numbers_are_unique_within_the_same_second() {
376        let first = Shipment::generate_shipment_number();
377        let second = Shipment::generate_shipment_number();
378
379        assert!(first.starts_with("SHP-"));
380        assert!(second.starts_with("SHP-"));
381        assert_ne!(first, second);
382    }
383}