Skip to main content

stateset_core/models/
fulfillment.rs

1//! Fulfillment domain models
2//!
3//! Models for pick, pack, and ship operations in warehouse fulfillment.
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use stateset_primitives::{FulfillmentId, OrderId, OrderItemId, ShipmentId};
9use std::str::FromStr;
10use strum::{Display, EnumString};
11use uuid::Uuid;
12
13// ============================================================================
14// Core Fulfillment Types
15// ============================================================================
16
17/// A wave groups multiple orders for efficient picking.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Wave {
20    pub id: FulfillmentId,
21    pub wave_number: String,
22    pub warehouse_id: i32,
23    pub status: WaveStatus,
24    pub order_count: i32,
25    pub pick_count: i32,
26    pub completed_pick_count: i32,
27    pub priority: i32,
28    pub started_at: Option<DateTime<Utc>>,
29    pub completed_at: Option<DateTime<Utc>>,
30    pub notes: Option<String>,
31    pub created_by: Option<String>,
32    pub created_at: DateTime<Utc>,
33    pub updated_at: DateTime<Utc>,
34}
35
36/// A pick task for retrieving items from warehouse locations.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct PickTask {
39    pub id: Uuid,
40    pub wave_id: Option<FulfillmentId>,
41    pub order_id: OrderId,
42    pub order_item_id: OrderItemId,
43    pub warehouse_id: i32,
44    pub status: PickStatus,
45    pub sku: String,
46    pub product_name: Option<String>,
47    pub source_location_id: i32,
48    pub source_location_code: Option<String>,
49    pub quantity_requested: Decimal,
50    pub quantity_picked: Decimal,
51    pub quantity_short: Decimal,
52    pub lot_id: Option<Uuid>,
53    pub serial_number: Option<String>,
54    pub assigned_to: Option<String>,
55    pub priority: i32,
56    pub pick_sequence: i32,
57    pub started_at: Option<DateTime<Utc>>,
58    pub completed_at: Option<DateTime<Utc>>,
59    pub notes: Option<String>,
60    pub created_at: DateTime<Utc>,
61    pub updated_at: DateTime<Utc>,
62}
63
64/// A pack task for packaging picked items.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct PackTask {
67    pub id: Uuid,
68    pub order_id: OrderId,
69    pub shipment_id: Option<ShipmentId>,
70    pub status: PackStatus,
71    pub carton_count: i32,
72    pub total_weight_kg: Option<Decimal>,
73    pub assigned_to: Option<String>,
74    pub packing_station: Option<String>,
75    pub started_at: Option<DateTime<Utc>>,
76    pub completed_at: Option<DateTime<Utc>>,
77    pub notes: Option<String>,
78    pub created_at: DateTime<Utc>,
79    pub updated_at: DateTime<Utc>,
80}
81
82/// A carton/package within a pack task.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct Carton {
85    pub id: Uuid,
86    pub pack_task_id: Uuid,
87    pub carton_number: String,
88    pub package_type: PackageType,
89    pub weight_kg: Option<Decimal>,
90    pub length_cm: Option<Decimal>,
91    pub width_cm: Option<Decimal>,
92    pub height_cm: Option<Decimal>,
93    pub tracking_number: Option<String>,
94    pub label_printed: bool,
95    pub created_at: DateTime<Utc>,
96}
97
98/// Item in a carton.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct CartonItem {
101    pub id: Uuid,
102    pub carton_id: Uuid,
103    pub sku: String,
104    pub quantity: Decimal,
105    pub lot_id: Option<Uuid>,
106    pub serial_number: Option<String>,
107}
108
109/// A ship task for final shipping handoff.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct ShipTask {
112    pub id: Uuid,
113    pub order_id: OrderId,
114    pub shipment_id: ShipmentId,
115    pub pack_task_id: Uuid,
116    pub status: ShipStatus,
117    pub carrier: Option<String>,
118    pub service_level: Option<String>,
119    pub tracking_number: Option<String>,
120    pub label_url: Option<String>,
121    pub shipping_cost: Option<Decimal>,
122    pub assigned_to: Option<String>,
123    pub shipped_at: Option<DateTime<Utc>>,
124    pub notes: Option<String>,
125    pub created_at: DateTime<Utc>,
126    pub updated_at: DateTime<Utc>,
127}
128
129// ============================================================================
130// Enums
131// ============================================================================
132
133/// Status of a wave.
134#[derive(
135    Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, Serialize, Deserialize, Default,
136)]
137#[strum(serialize_all = "snake_case")]
138#[serde(rename_all = "snake_case")]
139#[non_exhaustive]
140pub enum WaveStatus {
141    /// Wave is being configured; orders have not been released to the floor.
142    #[default]
143    Draft,
144    /// Wave has been released and pick tasks are available to workers.
145    Released,
146    /// Picking is underway; at least one pick task has been started.
147    InProgress,
148    /// All pick tasks in the wave have been completed.
149    Completed,
150    /// Wave was cancelled before all picks were completed.
151    Cancelled,
152}
153
154impl FromStr for WaveStatus {
155    type Err = String;
156    fn from_str(s: &str) -> Result<Self, Self::Err> {
157        match s.trim().to_ascii_lowercase().as_str() {
158            "draft" => Ok(Self::Draft),
159            "released" => Ok(Self::Released),
160            "in_progress" | "inprogress" => Ok(Self::InProgress),
161            "completed" => Ok(Self::Completed),
162            "cancelled" | "canceled" => Ok(Self::Cancelled),
163            _ => Err(format!("Unknown wave status: {s}")),
164        }
165    }
166}
167
168/// Status of a pick task.
169#[derive(
170    Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, Serialize, Deserialize, Default,
171)]
172#[strum(serialize_all = "snake_case")]
173#[serde(rename_all = "snake_case")]
174#[non_exhaustive]
175pub enum PickStatus {
176    /// Pick task has been created but not yet assigned to a worker.
177    #[default]
178    Pending,
179    /// Pick task has been assigned to a worker but not yet started.
180    Assigned,
181    /// Worker is actively picking the item.
182    InProgress,
183    /// All requested quantity has been picked.
184    Completed,
185    /// Requested quantity was not available in the source location.
186    Short,
187    /// Pick task was cancelled; item will not be picked.
188    Cancelled,
189}
190
191impl FromStr for PickStatus {
192    type Err = String;
193    fn from_str(s: &str) -> Result<Self, Self::Err> {
194        match s.trim().to_ascii_lowercase().as_str() {
195            "pending" => Ok(Self::Pending),
196            "assigned" => Ok(Self::Assigned),
197            "in_progress" | "inprogress" => Ok(Self::InProgress),
198            "completed" => Ok(Self::Completed),
199            "short" => Ok(Self::Short),
200            "cancelled" | "canceled" => Ok(Self::Cancelled),
201            _ => Err(format!("Unknown pick status: {s}")),
202        }
203    }
204}
205
206/// Status of a pack task.
207#[derive(
208    Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, Serialize, Deserialize, Default,
209)]
210#[strum(serialize_all = "snake_case")]
211#[serde(rename_all = "snake_case")]
212#[non_exhaustive]
213pub enum PackStatus {
214    /// Pack task created; picking for this order is not yet complete.
215    #[default]
216    Pending,
217    /// Pack task has been assigned to a packing station or worker.
218    Assigned,
219    /// All picks for the order are complete; packing can begin.
220    ReadyToPack,
221    /// Worker is actively packing items into cartons.
222    InProgress,
223    /// All items have been packed and cartons are sealed.
224    Completed,
225    /// Pack task was cancelled; cartons were not produced.
226    Cancelled,
227}
228
229impl FromStr for PackStatus {
230    type Err = String;
231    fn from_str(s: &str) -> Result<Self, Self::Err> {
232        match s.trim().to_ascii_lowercase().as_str() {
233            "pending" => Ok(Self::Pending),
234            "assigned" => Ok(Self::Assigned),
235            "ready_to_pack" | "readytopack" => Ok(Self::ReadyToPack),
236            "in_progress" | "inprogress" => Ok(Self::InProgress),
237            "completed" => Ok(Self::Completed),
238            "cancelled" | "canceled" => Ok(Self::Cancelled),
239            _ => Err(format!("Unknown pack status: {s}")),
240        }
241    }
242}
243
244/// Status of a ship task.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
246#[serde(rename_all = "snake_case")]
247#[non_exhaustive]
248pub enum ShipStatus {
249    /// Ship task created; packing is not yet complete.
250    #[default]
251    Pending,
252    /// Packing is complete; package is ready for carrier handoff.
253    ReadyToShip,
254    /// Shipping label has been generated and printed.
255    LabelPrinted,
256    /// Package has been handed off to the carrier and is in transit.
257    Shipped,
258    /// Ship task was cancelled; package was not tendered to the carrier.
259    Cancelled,
260}
261
262impl std::fmt::Display for ShipStatus {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        match self {
265            Self::Pending => write!(f, "pending"),
266            Self::ReadyToShip => write!(f, "ready_to_ship"),
267            Self::LabelPrinted => write!(f, "label_printed"),
268            Self::Shipped => write!(f, "shipped"),
269            Self::Cancelled => write!(f, "cancelled"),
270        }
271    }
272}
273
274impl FromStr for ShipStatus {
275    type Err = String;
276    fn from_str(s: &str) -> Result<Self, Self::Err> {
277        match s.trim().to_ascii_lowercase().as_str() {
278            "pending" => Ok(Self::Pending),
279            "ready_to_ship" | "readytoship" => Ok(Self::ReadyToShip),
280            "label_printed" | "labelprinted" => Ok(Self::LabelPrinted),
281            "shipped" => Ok(Self::Shipped),
282            "cancelled" | "canceled" => Ok(Self::Cancelled),
283            _ => Err(format!("Unknown ship status: {s}")),
284        }
285    }
286}
287
288/// Package type for cartons.
289#[derive(
290    Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, Default,
291)]
292#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
293#[serde(rename_all = "snake_case")]
294#[non_exhaustive]
295pub enum PackageType {
296    /// Standard corrugated or rigid box.
297    #[default]
298    Box,
299    /// Flat mailer envelope for small or thin items.
300    Envelope,
301    /// Cylindrical tube for posters, prints, or long items.
302    Tube,
303    /// Pallet used for bulk or freight shipments.
304    Pallet,
305    /// Non-standard packaging defined by dimensions alone.
306    Custom,
307}
308
309/// Type of wave for order grouping
310#[derive(
311    Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, Default,
312)]
313#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
314#[serde(rename_all = "snake_case")]
315#[non_exhaustive]
316pub enum WaveType {
317    /// Process orders in batches
318    #[default]
319    Batch,
320    /// Priority orders processed first
321    Priority,
322    /// Zone-based wave planning
323    Zone,
324    /// Single order waves
325    #[strum(serialize = "single", serialize = "single_order", serialize = "singleorder")]
326    Single,
327}
328
329// ============================================================================
330// Input Types
331// ============================================================================
332
333/// Input for creating a wave.
334#[derive(Debug, Clone, Serialize, Deserialize, Default)]
335pub struct CreateWave {
336    pub warehouse_id: i32,
337    pub order_ids: Vec<OrderId>,
338    pub priority: Option<i32>,
339    pub notes: Option<String>,
340    pub created_by: Option<String>,
341}
342
343/// Input for creating a pick task.
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct CreatePickTask {
346    pub wave_id: Option<FulfillmentId>,
347    pub order_id: OrderId,
348    pub order_item_id: OrderItemId,
349    pub warehouse_id: i32,
350    pub sku: String,
351    pub product_name: Option<String>,
352    pub source_location_id: i32,
353    pub quantity_requested: Decimal,
354    pub lot_id: Option<Uuid>,
355    pub serial_number: Option<String>,
356    pub priority: Option<i32>,
357    pub notes: Option<String>,
358}
359
360/// Input for completing a pick.
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct CompletePick {
363    pub pick_id: Uuid,
364    pub quantity_picked: Decimal,
365    pub quantity_short: Option<Decimal>,
366    pub short_reason: Option<String>,
367    pub lot_id: Option<Uuid>,
368    pub serial_number: Option<String>,
369    pub completed_by: Option<String>,
370}
371
372/// Input for creating a pack task.
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct CreatePackTask {
375    pub order_id: OrderId,
376    pub notes: Option<String>,
377}
378
379/// Input for adding a carton.
380#[derive(Debug, Clone, Serialize, Deserialize, Default)]
381pub struct AddCarton {
382    pub pack_task_id: Uuid,
383    pub package_type: PackageType,
384    pub weight_kg: Option<Decimal>,
385    pub length_cm: Option<Decimal>,
386    pub width_cm: Option<Decimal>,
387    pub height_cm: Option<Decimal>,
388}
389
390/// Input for adding item to carton.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct AddCartonItem {
393    pub carton_id: Uuid,
394    pub sku: String,
395    pub quantity: Decimal,
396    pub lot_id: Option<Uuid>,
397    pub serial_number: Option<String>,
398}
399
400/// Input for creating a ship task.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct CreateShipTask {
403    pub order_id: OrderId,
404    pub shipment_id: ShipmentId,
405    pub pack_task_id: Uuid,
406    pub carrier: Option<String>,
407    pub service_level: Option<String>,
408    pub notes: Option<String>,
409}
410
411/// Input for completing shipping.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct CompleteShip {
414    pub ship_task_id: Uuid,
415    pub tracking_number: String,
416    pub shipping_cost: Option<Decimal>,
417    pub shipped_by: Option<String>,
418}
419
420// ============================================================================
421// Filter Types
422// ============================================================================
423
424/// Filter for listing waves.
425#[derive(Debug, Clone, Serialize, Deserialize, Default)]
426pub struct WaveFilter {
427    pub warehouse_id: Option<i32>,
428    pub status: Option<WaveStatus>,
429    pub from_date: Option<DateTime<Utc>>,
430    pub to_date: Option<DateTime<Utc>>,
431    pub limit: Option<u32>,
432    pub offset: Option<u32>,
433}
434
435/// Filter for listing pick tasks.
436#[derive(Debug, Clone, Serialize, Deserialize, Default)]
437pub struct PickTaskFilter {
438    pub warehouse_id: Option<i32>,
439    pub wave_id: Option<FulfillmentId>,
440    pub order_id: Option<OrderId>,
441    pub status: Option<PickStatus>,
442    pub assigned_to: Option<String>,
443    pub limit: Option<u32>,
444    pub offset: Option<u32>,
445}
446
447/// Filter for listing pack tasks.
448#[derive(Debug, Clone, Serialize, Deserialize, Default)]
449pub struct PackTaskFilter {
450    pub order_id: Option<OrderId>,
451    pub status: Option<PackStatus>,
452    pub assigned_to: Option<String>,
453    pub limit: Option<u32>,
454    pub offset: Option<u32>,
455}
456
457/// Filter for listing ship tasks.
458#[derive(Debug, Clone, Serialize, Deserialize, Default)]
459pub struct ShipTaskFilter {
460    pub order_id: Option<OrderId>,
461    pub status: Option<ShipStatus>,
462    pub carrier: Option<String>,
463    pub limit: Option<u32>,
464    pub offset: Option<u32>,
465}
466
467// ============================================================================
468// Helper Functions
469// ============================================================================
470
471/// Generate a unique wave number.
472///
473/// Format: `WV-YYYYMMDDHHmmSS-XXXXXXXX` (8 hex chars = 4 billion possible
474/// values per second, eliminating test-parallelism collisions).
475#[must_use]
476pub fn generate_wave_number() -> String {
477    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
478    let random = &uuid::Uuid::new_v4().to_string().replace('-', "")[..8].to_uppercase();
479    format!("WV-{timestamp}-{random}")
480}
481
482/// Generate a unique carton number.
483///
484/// Format: `CTN-HHmmSS-XXXXXXXX`
485#[must_use]
486pub fn generate_carton_number() -> String {
487    let timestamp = chrono::Utc::now().format("%H%M%S").to_string();
488    let random = &uuid::Uuid::new_v4().to_string().replace('-', "")[..8].to_uppercase();
489    format!("CTN-{timestamp}-{random}")
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn test_wave_status_from_str() {
498        assert_eq!(WaveStatus::from_str("released").unwrap(), WaveStatus::Released);
499        assert_eq!(WaveStatus::from_str("InProgress").unwrap(), WaveStatus::InProgress);
500        assert!(WaveStatus::from_str("nope").is_err());
501    }
502
503    #[test]
504    fn test_pick_status_from_str() {
505        assert_eq!(PickStatus::from_str("assigned").unwrap(), PickStatus::Assigned);
506        assert_eq!(PickStatus::from_str("canceled").unwrap(), PickStatus::Cancelled);
507        assert!(PickStatus::from_str("nope").is_err());
508    }
509
510    #[test]
511    fn test_pack_status_from_str() {
512        assert_eq!(PackStatus::from_str("assigned").unwrap(), PackStatus::Assigned);
513        assert_eq!(PackStatus::from_str("readytopack").unwrap(), PackStatus::ReadyToPack);
514        assert!(PackStatus::from_str("nope").is_err());
515    }
516
517    #[test]
518    fn test_ship_status_from_str() {
519        assert_eq!(ShipStatus::from_str("labelprinted").unwrap(), ShipStatus::LabelPrinted);
520        assert_eq!(ShipStatus::from_str("ready_to_ship").unwrap(), ShipStatus::ReadyToShip);
521        assert!(ShipStatus::from_str("nope").is_err());
522    }
523
524    #[test]
525    fn test_package_type_from_str() {
526        assert_eq!(PackageType::from_str("box").unwrap(), PackageType::Box);
527        assert!(PackageType::from_str("nope").is_err());
528    }
529
530    #[test]
531    fn test_wave_type_from_str() {
532        assert_eq!(WaveType::from_str("single_order").unwrap(), WaveType::Single);
533        assert!(WaveType::from_str("nope").is_err());
534    }
535}