1use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use strum::{Display, EnumString};
12use uuid::Uuid;
13
14#[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 #[default]
28 Distribution,
29 Manufacturing,
31 Retail,
33 #[strum(serialize = "third_party", serialize = "thirdparty")]
35 ThirdParty,
36 Consignment,
38 Returns,
40}
41
42#[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 #[default]
52 Bulk,
53 Pick,
55 Staging,
57 Receiving,
59 Shipping,
61 Quarantine,
63 Returns,
65 Production,
67 Packing,
69 #[strum(serialize = "cross_dock", serialize = "crossdock")]
71 CrossDock,
72}
73
74#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303#[non_exhaustive]
304pub enum MovementType {
305 Receipt,
307 Transfer,
309 Pick,
311 Adjustment,
313 Shipment,
315 Return,
317 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#[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#[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#[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#[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
403pub type CreateWarehouseLocation = CreateLocation;
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use std::str::FromStr;
414
415 #[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 #[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 #[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 #[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 #[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}