Skip to main content

stateset_core/models/
warehouse.rs

1//! Warehouse and Location domain models
2//!
3//! This module provides models for warehouse management including:
4//! - Warehouse definitions
5//! - Location hierarchy (zones, aisles, racks, bins)
6//! - Location inventory tracking
7
8use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use strum::{Display, EnumString};
12use uuid::Uuid;
13
14// ============================================================================
15// Enums
16// ============================================================================
17
18/// Type of warehouse
19#[derive(
20    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
21)]
22#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
23#[serde(rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum WarehouseType {
26    /// Main distribution center
27    #[default]
28    Distribution,
29    /// Manufacturing facility
30    Manufacturing,
31    /// Retail store with inventory
32    Retail,
33    /// Third-party logistics provider
34    #[strum(serialize = "third_party", serialize = "thirdparty")]
35    ThirdParty,
36    /// Consignment warehouse
37    Consignment,
38    /// Returns processing center
39    Returns,
40}
41
42/// Type of location within a warehouse
43#[derive(
44    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
45)]
46#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
47#[serde(rename_all = "snake_case")]
48#[non_exhaustive]
49pub enum LocationType {
50    /// Bulk storage location
51    #[default]
52    Bulk,
53    /// Picking location for order fulfillment
54    Pick,
55    /// Staging area for orders
56    Staging,
57    /// Receiving dock/area
58    Receiving,
59    /// Shipping dock/area
60    Shipping,
61    /// Quarantine area for quality holds
62    Quarantine,
63    /// Returns processing area
64    Returns,
65    /// Production/manufacturing area
66    Production,
67    /// Packing station
68    Packing,
69    /// Cross-docking area
70    #[strum(serialize = "cross_dock", serialize = "crossdock")]
71    CrossDock,
72}
73
74// ============================================================================
75// Address (embedded)
76// ============================================================================
77
78/// Physical address for a warehouse
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
80#[serde(rename_all = "snake_case")]
81pub struct WarehouseAddress {
82    pub street1: String,
83    pub street2: Option<String>,
84    pub city: String,
85    pub state: String,
86    pub postal_code: String,
87    pub country: String,
88    pub phone: Option<String>,
89}
90
91// ============================================================================
92// Warehouse
93// ============================================================================
94
95/// A warehouse or distribution center
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub struct Warehouse {
99    pub id: i32,
100    pub code: String,
101    pub name: String,
102    pub warehouse_type: WarehouseType,
103    pub address: WarehouseAddress,
104    pub timezone: Option<String>,
105    pub is_active: bool,
106    pub created_at: DateTime<Utc>,
107    pub updated_at: DateTime<Utc>,
108}
109
110/// Input for creating a warehouse
111#[derive(Debug, Clone, Serialize, Deserialize, Default)]
112#[serde(rename_all = "snake_case")]
113pub struct CreateWarehouse {
114    pub code: String,
115    pub name: String,
116    pub warehouse_type: WarehouseType,
117    pub address: WarehouseAddress,
118    pub timezone: Option<String>,
119}
120
121/// Input for updating a warehouse
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
123#[serde(rename_all = "snake_case")]
124pub struct UpdateWarehouse {
125    pub name: Option<String>,
126    pub warehouse_type: Option<WarehouseType>,
127    pub address: Option<WarehouseAddress>,
128    pub timezone: Option<String>,
129    pub is_active: Option<bool>,
130}
131
132/// Filter for listing warehouses
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub struct WarehouseFilter {
136    pub warehouse_type: Option<WarehouseType>,
137    pub is_active: Option<bool>,
138    pub limit: Option<u32>,
139    pub offset: Option<u32>,
140}
141
142// ============================================================================
143// Location
144// ============================================================================
145
146/// A location within a warehouse (zone/aisle/rack/bin)
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub struct Location {
150    pub id: i32,
151    pub warehouse_id: i32,
152    pub code: String,
153    pub location_type: LocationType,
154    pub zone: Option<String>,
155    pub aisle: Option<String>,
156    pub rack: Option<String>,
157    pub level: Option<String>,
158    pub bin: Option<String>,
159    pub max_weight_kg: Option<Decimal>,
160    pub max_volume_m3: Option<Decimal>,
161    pub current_weight_kg: Option<Decimal>,
162    pub current_volume_m3: Option<Decimal>,
163    pub is_pickable: bool,
164    pub is_receivable: bool,
165    pub is_active: bool,
166    pub created_at: DateTime<Utc>,
167    pub updated_at: DateTime<Utc>,
168}
169
170/// Input for creating a location
171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
172#[serde(rename_all = "snake_case")]
173pub struct CreateLocation {
174    pub warehouse_id: i32,
175    pub code: Option<String>,
176    pub location_type: LocationType,
177    pub zone: Option<String>,
178    pub aisle: Option<String>,
179    pub rack: Option<String>,
180    pub level: Option<String>,
181    pub bin: Option<String>,
182    pub max_weight_kg: Option<Decimal>,
183    pub max_volume_m3: Option<Decimal>,
184    pub is_pickable: Option<bool>,
185    pub is_receivable: Option<bool>,
186}
187
188/// Input for updating a location
189#[derive(Debug, Clone, Serialize, Deserialize, Default)]
190#[serde(rename_all = "snake_case")]
191pub struct UpdateLocation {
192    pub location_type: Option<LocationType>,
193    pub zone: Option<String>,
194    pub aisle: Option<String>,
195    pub rack: Option<String>,
196    pub level: Option<String>,
197    pub bin: Option<String>,
198    pub max_weight_kg: Option<Decimal>,
199    pub max_volume_m3: Option<Decimal>,
200    pub is_pickable: Option<bool>,
201    pub is_receivable: Option<bool>,
202    pub is_active: Option<bool>,
203}
204
205/// Filter for listing locations
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub struct LocationFilter {
209    pub warehouse_id: Option<i32>,
210    pub location_type: Option<LocationType>,
211    pub zone: Option<String>,
212    pub aisle: Option<String>,
213    pub is_pickable: Option<bool>,
214    pub is_receivable: Option<bool>,
215    pub is_active: Option<bool>,
216    pub has_capacity: Option<bool>,
217    pub limit: Option<u32>,
218    pub offset: Option<u32>,
219}
220
221// ============================================================================
222// Location Inventory
223// ============================================================================
224
225/// Inventory at a specific location
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub struct LocationInventory {
229    pub location_id: i32,
230    pub sku: String,
231    pub lot_id: Option<Uuid>,
232    pub quantity_on_hand: Decimal,
233    pub quantity_reserved: Decimal,
234    pub quantity_available: Decimal,
235    pub updated_at: DateTime<Utc>,
236}
237
238/// Input for adjusting location inventory
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub struct AdjustLocationInventory {
242    pub location_id: i32,
243    pub sku: String,
244    pub lot_id: Option<Uuid>,
245    pub quantity: Decimal,
246    pub reason: String,
247    pub reference_type: Option<String>,
248    pub reference_id: Option<Uuid>,
249    pub performed_by: Option<String>,
250}
251
252/// Input for moving inventory between locations
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub struct MoveInventory {
256    pub from_location_id: i32,
257    pub to_location_id: i32,
258    pub sku: String,
259    pub lot_id: Option<Uuid>,
260    pub quantity: Decimal,
261    pub reason: Option<String>,
262    pub performed_by: Option<String>,
263}
264
265/// Filter for location inventory
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub struct LocationInventoryFilter {
269    pub location_id: Option<i32>,
270    pub warehouse_id: Option<i32>,
271    pub sku: Option<String>,
272    pub lot_id: Option<Uuid>,
273    pub has_quantity: Option<bool>,
274    pub limit: Option<u32>,
275    pub offset: Option<u32>,
276}
277
278// ============================================================================
279// Location Movement
280// ============================================================================
281
282/// Record of inventory movement between locations
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "snake_case")]
285pub struct LocationMovement {
286    pub id: Uuid,
287    pub movement_type: MovementType,
288    pub from_location_id: Option<i32>,
289    pub to_location_id: Option<i32>,
290    pub sku: String,
291    pub lot_id: Option<Uuid>,
292    pub quantity: Decimal,
293    pub reference_type: Option<String>,
294    pub reference_id: Option<Uuid>,
295    pub reason: Option<String>,
296    pub performed_by: Option<String>,
297    pub created_at: DateTime<Utc>,
298}
299
300/// Type of inventory movement
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303#[non_exhaustive]
304pub enum MovementType {
305    /// Received into warehouse
306    Receipt,
307    /// Moved between locations
308    Transfer,
309    /// Picked for order
310    Pick,
311    /// Adjustment (count, damage, etc.)
312    Adjustment,
313    /// Shipped out
314    Shipment,
315    /// Returned to stock
316    Return,
317    /// Cycle count variance adjustment
318    CycleCount,
319}
320
321impl std::fmt::Display for MovementType {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        match self {
324            Self::Receipt => write!(f, "receipt"),
325            Self::Transfer => write!(f, "transfer"),
326            Self::Pick => write!(f, "pick"),
327            Self::Adjustment => write!(f, "adjustment"),
328            Self::Shipment => write!(f, "shipment"),
329            Self::Return => write!(f, "return"),
330            Self::CycleCount => write!(f, "cycle_count"),
331        }
332    }
333}
334
335impl std::str::FromStr for MovementType {
336    type Err = crate::CommerceError;
337
338    fn from_str(s: &str) -> Result<Self, Self::Err> {
339        match s.to_lowercase().as_str() {
340            "receipt" => Ok(Self::Receipt),
341            "transfer" => Ok(Self::Transfer),
342            "pick" => Ok(Self::Pick),
343            "adjustment" => Ok(Self::Adjustment),
344            "shipment" => Ok(Self::Shipment),
345            "return" => Ok(Self::Return),
346            "cycle_count" => Ok(Self::CycleCount),
347            _ => Err(crate::CommerceError::ValidationError(format!("Invalid movement type: {s}"))),
348        }
349    }
350}
351
352/// Filter for inventory movements
353#[derive(Debug, Clone, Default, Serialize, Deserialize)]
354#[serde(rename_all = "snake_case")]
355pub struct MovementFilter {
356    pub warehouse_id: Option<i32>,
357    pub location_id: Option<i32>,
358    pub sku: Option<String>,
359    pub lot_id: Option<Uuid>,
360    pub movement_type: Option<MovementType>,
361    pub from_date: Option<DateTime<Utc>>,
362    pub to_date: Option<DateTime<Utc>>,
363    pub limit: Option<u32>,
364    pub offset: Option<u32>,
365}
366
367// ============================================================================
368// Zone
369// ============================================================================
370
371/// A zone within a warehouse (grouping of locations)
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub struct Zone {
375    pub id: i32,
376    pub warehouse_id: i32,
377    pub code: String,
378    pub name: String,
379    pub description: Option<String>,
380    pub is_active: bool,
381    pub created_at: DateTime<Utc>,
382}
383
384/// Input for creating a zone
385#[derive(Debug, Clone, Serialize, Deserialize, Default)]
386#[serde(rename_all = "snake_case")]
387pub struct CreateZone {
388    pub warehouse_id: i32,
389    pub code: String,
390    pub name: String,
391    pub description: Option<String>,
392}
393
394/// Input for updating a zone
395#[derive(Debug, Clone, Serialize, Deserialize, Default)]
396#[serde(rename_all = "snake_case")]
397pub struct UpdateZone {
398    pub name: Option<String>,
399    pub description: Option<String>,
400    pub is_active: Option<bool>,
401}
402
403// ============================================================================
404// Type Aliases for API compatibility
405// ============================================================================
406
407/// Alias for `CreateLocation` for API convenience
408pub type CreateWarehouseLocation = CreateLocation;
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::str::FromStr;
414
415    // ============================================================================
416    // WarehouseType
417    // ============================================================================
418
419    #[test]
420    fn warehouse_type_display_from_str_round_trip() {
421        for t in [
422            WarehouseType::Distribution,
423            WarehouseType::Manufacturing,
424            WarehouseType::Retail,
425            WarehouseType::ThirdParty,
426            WarehouseType::Consignment,
427            WarehouseType::Returns,
428        ] {
429            assert_eq!(WarehouseType::from_str(&t.to_string()), Ok(t));
430        }
431    }
432
433    #[test]
434    fn warehouse_type_third_party_aliases() {
435        assert_eq!(WarehouseType::from_str("third_party"), Ok(WarehouseType::ThirdParty));
436        assert_eq!(WarehouseType::from_str("thirdparty"), Ok(WarehouseType::ThirdParty));
437        assert_eq!(WarehouseType::ThirdParty.to_string(), "third_party");
438    }
439
440    #[test]
441    fn warehouse_type_case_insensitive_and_unknown() {
442        assert_eq!(WarehouseType::from_str("RETAIL"), Ok(WarehouseType::Retail));
443        assert!(WarehouseType::from_str("spaceport").is_err());
444    }
445
446    #[test]
447    fn warehouse_type_default_is_distribution() {
448        assert_eq!(WarehouseType::default(), WarehouseType::Distribution);
449    }
450
451    // ============================================================================
452    // LocationType
453    // ============================================================================
454
455    #[test]
456    fn location_type_display_from_str_round_trip() {
457        for t in [
458            LocationType::Bulk,
459            LocationType::Pick,
460            LocationType::Staging,
461            LocationType::Receiving,
462            LocationType::Shipping,
463            LocationType::Quarantine,
464            LocationType::Returns,
465            LocationType::Production,
466            LocationType::Packing,
467            LocationType::CrossDock,
468        ] {
469            assert_eq!(LocationType::from_str(&t.to_string()), Ok(t));
470        }
471    }
472
473    #[test]
474    fn location_type_cross_dock_aliases() {
475        assert_eq!(LocationType::from_str("cross_dock"), Ok(LocationType::CrossDock));
476        assert_eq!(LocationType::from_str("crossdock"), Ok(LocationType::CrossDock));
477        assert_eq!(LocationType::CrossDock.to_string(), "cross_dock");
478    }
479
480    #[test]
481    fn location_type_default_is_bulk_and_unknown_errs() {
482        assert_eq!(LocationType::default(), LocationType::Bulk);
483        assert!(LocationType::from_str("void").is_err());
484    }
485
486    // ============================================================================
487    // MovementType
488    // ============================================================================
489
490    #[test]
491    fn movement_type_display_from_str_round_trip() {
492        for t in [
493            MovementType::Receipt,
494            MovementType::Transfer,
495            MovementType::Pick,
496            MovementType::Adjustment,
497            MovementType::Shipment,
498            MovementType::Return,
499            MovementType::CycleCount,
500        ] {
501            assert_eq!(MovementType::from_str(&t.to_string()).expect("round trip"), t);
502        }
503    }
504
505    #[test]
506    fn movement_type_from_str_case_insensitive() {
507        assert_eq!(MovementType::from_str("RETURN").expect("parses"), MovementType::Return);
508        assert_eq!(MovementType::from_str("Receipt").expect("parses"), MovementType::Receipt);
509    }
510
511    #[test]
512    fn movement_type_from_str_invalid_is_validation_error() {
513        let err = MovementType::from_str("teleport").expect_err("should fail");
514        match err {
515            crate::CommerceError::ValidationError(msg) => {
516                assert!(msg.contains("teleport"), "message should include input: {msg}");
517            }
518            other => panic!("expected ValidationError, got {other:?}"),
519        }
520    }
521
522    // ============================================================================
523    // Serde representations
524    // ============================================================================
525
526    #[test]
527    fn warehouse_type_serde_snake_case() {
528        let json = serde_json::to_string(&WarehouseType::ThirdParty).expect("serialize");
529        assert_eq!(json, "\"third_party\"");
530        let back: WarehouseType = serde_json::from_str("\"third_party\"").expect("deserialize");
531        assert_eq!(back, WarehouseType::ThirdParty);
532    }
533
534    #[test]
535    fn movement_type_serde_snake_case() {
536        let json = serde_json::to_string(&MovementType::Receipt).expect("serialize");
537        assert_eq!(json, "\"receipt\"");
538        let back: MovementType = serde_json::from_str("\"adjustment\"").expect("deserialize");
539        assert_eq!(back, MovementType::Adjustment);
540    }
541
542    #[test]
543    fn location_inventory_serde_round_trip() {
544        let inv = LocationInventory {
545            location_id: 7,
546            sku: "SKU-1".to_string(),
547            lot_id: None,
548            quantity_on_hand: Decimal::from(10),
549            quantity_reserved: Decimal::from(3),
550            quantity_available: Decimal::from(7),
551            updated_at: Utc::now(),
552        };
553        let json = serde_json::to_string(&inv).expect("serialize");
554        let back: LocationInventory = serde_json::from_str(&json).expect("deserialize");
555        assert_eq!(back, inv);
556    }
557
558    // ============================================================================
559    // Defaults
560    // ============================================================================
561
562    #[test]
563    fn warehouse_address_default_is_empty() {
564        let addr = WarehouseAddress::default();
565        assert!(addr.street1.is_empty());
566        assert!(addr.city.is_empty());
567        assert!(addr.country.is_empty());
568        assert_eq!(addr.street2, None);
569        assert_eq!(addr.phone, None);
570    }
571
572    #[test]
573    fn create_warehouse_default_uses_distribution() {
574        let create = CreateWarehouse::default();
575        assert_eq!(create.warehouse_type, WarehouseType::Distribution);
576        assert!(create.code.is_empty());
577        assert_eq!(create.timezone, None);
578    }
579
580    #[test]
581    fn create_location_default_uses_bulk() {
582        let create = CreateLocation::default();
583        assert_eq!(create.location_type, LocationType::Bulk);
584        assert_eq!(create.warehouse_id, 0);
585        assert_eq!(create.is_pickable, None);
586        assert_eq!(create.max_weight_kg, None);
587    }
588
589    #[test]
590    fn filters_default_to_unset() {
591        let f = LocationFilter::default();
592        assert!(f.warehouse_id.is_none() && f.location_type.is_none() && f.limit.is_none());
593        let mf = MovementFilter::default();
594        assert!(mf.sku.is_none() && mf.movement_type.is_none() && mf.offset.is_none());
595    }
596}