1use 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#[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 #[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 #[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#[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#[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 #[default]
86 Pending,
87 Processing,
89 #[strum(serialize = "ready_to_ship", serialize = "readytoship")]
91 ReadyToShip,
92 Shipped,
94 #[strum(serialize = "in_transit", serialize = "intransit")]
96 InTransit,
97 #[strum(serialize = "out_for_delivery", serialize = "outfordelivery")]
99 OutForDelivery,
100 Delivered,
102 Failed,
104 Returned,
106 #[strum(serialize = "cancelled", serialize = "canceled")]
108 Cancelled,
109 #[strum(serialize = "on_hold", serialize = "onhold")]
111 OnHold,
112}
113
114impl ShipmentStatus {
115 #[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 (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 #[must_use]
139 pub const fn is_terminal(&self) -> bool {
140 matches!(self, Self::Delivered | Self::Cancelled | Self::Returned)
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Shipment {
147 pub id: ShipmentId,
149 pub shipment_number: String,
151 pub order_id: OrderId,
153 pub status: ShipmentStatus,
155 pub carrier: ShippingCarrier,
157 pub shipping_method: ShippingMethod,
159 pub tracking_number: Option<String>,
161 pub tracking_url: Option<String>,
163
164 pub recipient_name: String,
167 pub recipient_email: Option<String>,
169 pub recipient_phone: Option<String>,
171 pub shipping_address: String,
173
174 pub weight_kg: Option<Decimal>,
177 pub dimensions: Option<String>,
179 pub shipping_cost: Option<Decimal>,
181 pub insurance_amount: Option<Decimal>,
183 pub signature_required: bool,
185
186 pub shipped_at: Option<DateTime<Utc>>,
189 pub estimated_delivery: Option<DateTime<Utc>>,
191 pub delivered_at: Option<DateTime<Utc>>,
193
194 pub notes: Option<String>,
196 pub items: Vec<ShipmentItem>,
198 pub events: Vec<ShipmentEvent>,
200 pub version: i32,
202
203 pub created_at: DateTime<Utc>,
204 pub updated_at: DateTime<Utc>,
205}
206
207impl Shipment {
208 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ShipmentItem {
243 pub id: Uuid,
244 pub shipment_id: ShipmentId,
245 pub order_item_id: Option<Uuid>,
247 pub product_id: Option<ProductId>,
249 pub sku: String,
251 pub name: String,
253 pub quantity: i32,
255 pub created_at: DateTime<Utc>,
256 pub updated_at: DateTime<Utc>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ShipmentEvent {
262 pub id: Uuid,
263 pub shipment_id: ShipmentId,
264 pub event_type: String,
266 pub location: Option<String>,
268 pub description: Option<String>,
270 pub event_time: DateTime<Utc>,
272 pub created_at: DateTime<Utc>,
273}
274
275#[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#[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#[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#[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#[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}