stateset_embedded/warehouse.rs
1//! Warehouse and Location management operations
2//!
3//! Comprehensive warehouse management system supporting:
4//! - Warehouse definitions and configuration
5//! - Location hierarchy (zones, aisles, racks, bins)
6//! - Location inventory tracking
7//! - Inventory movements and transfers
8//!
9//! # Example
10//!
11//! ```ignore
12//! use stateset_embedded::{Commerce, CreateWarehouse, CreateLocation, WarehouseType, LocationType};
13//!
14//! let commerce = Commerce::new("./store.db")?;
15//!
16//! // Create a warehouse
17//! let warehouse = commerce.warehouse().create_warehouse(CreateWarehouse {
18//! code: "WH-001".into(),
19//! name: "Main Distribution Center".into(),
20//! warehouse_type: WarehouseType::Distribution,
21//! ..Default::default()
22//! })?;
23//!
24//! // Create a location within the warehouse
25//! let location = commerce.warehouse().create_location(CreateLocation {
26//! warehouse_id: warehouse.id,
27//! location_type: LocationType::Pick,
28//! zone: Some("A".into()),
29//! aisle: Some("01".into()),
30//! rack: Some("02".into()),
31//! bin: Some("03".into()),
32//! ..Default::default()
33//! })?;
34//!
35//! println!("Created location {} in warehouse {}", location.code, warehouse.name);
36//! # Ok::<(), stateset_embedded::CommerceError>(())
37//! ```
38
39use rust_decimal::Decimal;
40use stateset_core::{
41 AdjustLocationInventory, BatchResult, CreateCycleCount, CreateLocation, CreateWarehouse,
42 CreateZone, CycleCount, CycleCountFilter, Location, LocationFilter, LocationInventory,
43 LocationInventoryFilter, LocationMovement, MoveInventory, MovementFilter, RecordCycleCountLine,
44 Result, UpdateLocation, UpdateWarehouse, UpdateZone, Warehouse, WarehouseFilter, Zone,
45};
46use stateset_db::Database;
47use std::sync::Arc;
48use uuid::Uuid;
49
50#[cfg(feature = "events")]
51use crate::events::EventSystem;
52#[cfg(feature = "events")]
53use chrono::Utc;
54#[cfg(feature = "events")]
55use stateset_core::CommerceEvent;
56
57/// Warehouse and Location management interface.
58pub struct WarehouseOps {
59 db: Arc<dyn Database>,
60 #[cfg(feature = "events")]
61 event_system: Arc<EventSystem>,
62}
63
64impl std::fmt::Debug for WarehouseOps {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("WarehouseOps").finish_non_exhaustive()
67 }
68}
69
70impl WarehouseOps {
71 #[cfg(feature = "events")]
72 pub(crate) fn new(db: Arc<dyn Database>, event_system: Arc<EventSystem>) -> Self {
73 Self { db, event_system }
74 }
75
76 #[cfg(not(feature = "events"))]
77 pub(crate) fn new(db: Arc<dyn Database>) -> Self {
78 Self { db }
79 }
80
81 #[cfg(feature = "events")]
82 fn emit(&self, event: CommerceEvent) {
83 self.event_system.emit(event);
84 }
85
86 // ========================================================================
87 // Warehouse Operations
88 // ========================================================================
89
90 /// Create a new warehouse.
91 ///
92 /// # Example
93 ///
94 /// ```rust,no_run
95 /// use stateset_embedded::{Commerce, CreateWarehouse, WarehouseType, WarehouseAddress};
96 ///
97 /// let commerce = Commerce::new(":memory:")?;
98 ///
99 /// let warehouse = commerce.warehouse().create_warehouse(CreateWarehouse {
100 /// code: "DC-EAST".into(),
101 /// name: "East Coast Distribution Center".into(),
102 /// warehouse_type: WarehouseType::Distribution,
103 /// address: WarehouseAddress {
104 /// street1: "123 Logistics Way".into(),
105 /// city: "Newark".into(),
106 /// state: "NJ".into(),
107 /// postal_code: "07102".into(),
108 /// country: "US".into(),
109 /// ..Default::default()
110 /// },
111 /// timezone: Some("America/New_York".into()),
112 /// })?;
113 /// # Ok::<(), stateset_embedded::CommerceError>(())
114 /// ```
115 pub fn create_warehouse(&self, input: CreateWarehouse) -> Result<Warehouse> {
116 self.db.warehouse().create_warehouse(input)
117 }
118
119 /// Get a warehouse by ID.
120 pub fn get_warehouse(&self, id: i32) -> Result<Option<Warehouse>> {
121 self.db.warehouse().get_warehouse(id)
122 }
123
124 /// Get a warehouse by code.
125 pub fn get_warehouse_by_code(&self, code: &str) -> Result<Option<Warehouse>> {
126 self.db.warehouse().get_warehouse_by_code(code)
127 }
128
129 /// Update a warehouse.
130 pub fn update_warehouse(&self, id: i32, input: UpdateWarehouse) -> Result<Warehouse> {
131 self.db.warehouse().update_warehouse(id, input)
132 }
133
134 /// List warehouses with optional filtering.
135 ///
136 /// # Example
137 ///
138 /// ```rust,no_run
139 /// use stateset_embedded::{Commerce, WarehouseFilter, WarehouseType};
140 ///
141 /// let commerce = Commerce::new(":memory:")?;
142 ///
143 /// // Get all active distribution centers
144 /// let warehouses = commerce.warehouse().list_warehouses(WarehouseFilter {
145 /// warehouse_type: Some(WarehouseType::Distribution),
146 /// is_active: Some(true),
147 /// ..Default::default()
148 /// })?;
149 /// # Ok::<(), stateset_embedded::CommerceError>(())
150 /// ```
151 pub fn list_warehouses(&self, filter: WarehouseFilter) -> Result<Vec<Warehouse>> {
152 self.db.warehouse().list_warehouses(filter)
153 }
154
155 /// Delete a warehouse. Only warehouses without locations can be deleted.
156 pub fn delete_warehouse(&self, id: i32) -> Result<()> {
157 self.db.warehouse().delete_warehouse(id)
158 }
159
160 /// Count warehouses matching the filter.
161 pub fn count_warehouses(&self, filter: WarehouseFilter) -> Result<u64> {
162 self.db.warehouse().count_warehouses(filter)
163 }
164
165 // ========================================================================
166 // Zone Operations
167 // ========================================================================
168
169 /// Create a new zone within a warehouse.
170 ///
171 /// Zones are logical groupings of locations (e.g., "Bulk Storage", "Pick Area", "Returns").
172 pub fn create_zone(&self, input: CreateZone) -> Result<Zone> {
173 self.db.warehouse().create_zone(input)
174 }
175
176 /// Get a zone by ID.
177 pub fn get_zone(&self, id: i32) -> Result<Option<Zone>> {
178 self.db.warehouse().get_zone(id)
179 }
180
181 /// Get all zones in a warehouse.
182 pub fn get_zones(&self, warehouse_id: i32) -> Result<Vec<Zone>> {
183 self.db.warehouse().get_zones(warehouse_id)
184 }
185
186 /// Update a zone.
187 pub fn update_zone(&self, id: i32, input: UpdateZone) -> Result<Zone> {
188 self.db.warehouse().update_zone(id, input)
189 }
190
191 /// Delete a zone.
192 pub fn delete_zone(&self, id: i32) -> Result<()> {
193 self.db.warehouse().delete_zone(id)
194 }
195
196 // ========================================================================
197 // Location Operations
198 // ========================================================================
199
200 /// Create a new location within a warehouse.
201 ///
202 /// # Example
203 ///
204 /// ```rust,no_run
205 /// use stateset_embedded::{Commerce, CreateLocation, LocationType};
206 /// use rust_decimal_macros::dec;
207 ///
208 /// let commerce = Commerce::new(":memory:")?;
209 ///
210 /// // Create a pick location with capacity constraints
211 /// let location = commerce.warehouse().create_location(CreateLocation {
212 /// warehouse_id: 1,
213 /// location_type: LocationType::Pick,
214 /// zone: Some("A".into()),
215 /// aisle: Some("01".into()),
216 /// rack: Some("05".into()),
217 /// level: Some("2".into()),
218 /// bin: Some("03".into()),
219 /// max_weight_kg: Some(dec!(100)),
220 /// max_volume_m3: Some(dec!(0.5)),
221 /// is_pickable: Some(true),
222 /// is_receivable: Some(false),
223 /// ..Default::default()
224 /// })?;
225 ///
226 /// println!("Created location: {}", location.code); // e.g., "A-01-05-2-03"
227 /// # Ok::<(), stateset_embedded::CommerceError>(())
228 /// ```
229 pub fn create_location(&self, input: CreateLocation) -> Result<Location> {
230 self.db.warehouse().create_location(input)
231 }
232
233 /// Get a location by ID.
234 pub fn get_location(&self, id: i32) -> Result<Option<Location>> {
235 self.db.warehouse().get_location(id)
236 }
237
238 /// Get a location by code within a warehouse.
239 pub fn get_location_by_code(&self, warehouse_id: i32, code: &str) -> Result<Option<Location>> {
240 self.db.warehouse().get_location_by_code(warehouse_id, code)
241 }
242
243 /// Update a location.
244 pub fn update_location(&self, id: i32, input: UpdateLocation) -> Result<Location> {
245 self.db.warehouse().update_location(id, input)
246 }
247
248 /// List locations with optional filtering.
249 ///
250 /// # Example
251 ///
252 /// ```rust,no_run
253 /// use stateset_embedded::{Commerce, LocationFilter, LocationType};
254 ///
255 /// let commerce = Commerce::new(":memory:")?;
256 ///
257 /// // Get all pickable locations in zone A
258 /// let locations = commerce.warehouse().list_locations(LocationFilter {
259 /// warehouse_id: Some(1),
260 /// zone: Some("A".into()),
261 /// is_pickable: Some(true),
262 /// is_active: Some(true),
263 /// ..Default::default()
264 /// })?;
265 /// # Ok::<(), stateset_embedded::CommerceError>(())
266 /// ```
267 pub fn list_locations(&self, filter: LocationFilter) -> Result<Vec<Location>> {
268 self.db.warehouse().list_locations(filter)
269 }
270
271 /// Delete a location. Only locations without inventory can be deleted.
272 pub fn delete_location(&self, id: i32) -> Result<()> {
273 self.db.warehouse().delete_location(id)
274 }
275
276 /// Count locations matching the filter.
277 pub fn count_locations(&self, filter: LocationFilter) -> Result<u64> {
278 self.db.warehouse().count_locations(filter)
279 }
280
281 /// Get all active locations for a warehouse.
282 pub fn get_locations_for_warehouse(&self, warehouse_id: i32) -> Result<Vec<Location>> {
283 self.db.warehouse().get_locations_for_warehouse(warehouse_id)
284 }
285
286 /// Get pickable locations with available inventory for a SKU.
287 ///
288 /// Returns locations that are marked as pickable and have available
289 /// (non-reserved) inventory for the specified SKU.
290 pub fn get_pickable_locations(&self, warehouse_id: i32, sku: &str) -> Result<Vec<Location>> {
291 self.db.warehouse().get_pickable_locations(warehouse_id, sku)
292 }
293
294 /// Get receivable locations for a warehouse.
295 ///
296 /// Returns locations where inventory can be received (e.g., receiving docks, staging areas).
297 pub fn get_receivable_locations(&self, warehouse_id: i32) -> Result<Vec<Location>> {
298 self.db.warehouse().get_receivable_locations(warehouse_id)
299 }
300
301 /// Create multiple locations in a batch.
302 ///
303 /// Returns a `BatchResult` with succeeded and failed operations.
304 pub fn create_locations_batch(
305 &self,
306 inputs: Vec<CreateLocation>,
307 ) -> Result<BatchResult<Location>> {
308 self.db.warehouse().create_locations_batch(inputs)
309 }
310
311 /// Get multiple locations by ID.
312 pub fn get_locations_batch(&self, ids: Vec<i32>) -> Result<Vec<Location>> {
313 self.db.warehouse().get_locations_batch(ids)
314 }
315
316 // ========================================================================
317 // Location Inventory Operations
318 // ========================================================================
319
320 /// Get all inventory at a specific location.
321 pub fn get_location_inventory(&self, location_id: i32) -> Result<Vec<LocationInventory>> {
322 self.db.warehouse().get_location_inventory(location_id)
323 }
324
325 /// Get all locations with inventory for a SKU within a warehouse.
326 pub fn get_inventory_for_sku(
327 &self,
328 warehouse_id: i32,
329 sku: &str,
330 ) -> Result<Vec<LocationInventory>> {
331 self.db.warehouse().get_inventory_for_sku(warehouse_id, sku)
332 }
333
334 /// Adjust inventory at a location.
335 ///
336 /// Use positive quantities to add inventory, negative to remove.
337 /// Creates a movement record for audit trail.
338 ///
339 /// # Example
340 ///
341 /// ```rust,no_run
342 /// use stateset_embedded::{Commerce, AdjustLocationInventory};
343 /// use rust_decimal_macros::dec;
344 ///
345 /// let commerce = Commerce::new(":memory:")?;
346 ///
347 /// // Add inventory from receiving
348 /// let inventory = commerce.warehouse().adjust_inventory(AdjustLocationInventory {
349 /// location_id: 1,
350 /// sku: "PROD-001".into(),
351 /// lot_id: None,
352 /// quantity: dec!(100),
353 /// reason: "Receipt from PO-12345".into(),
354 /// reference_type: Some("purchase_order".into()),
355 /// reference_id: None,
356 /// performed_by: Some("warehouse_user".into()),
357 /// })?;
358 ///
359 /// println!("New quantity on hand: {}", inventory.quantity_on_hand);
360 /// # Ok::<(), stateset_embedded::CommerceError>(())
361 /// ```
362 pub fn adjust_inventory(&self, input: AdjustLocationInventory) -> Result<LocationInventory> {
363 self.db.warehouse().adjust_inventory(input)
364 }
365
366 /// Move inventory between locations.
367 ///
368 /// Validates that sufficient available (non-reserved) quantity exists
369 /// at the source location. Creates movement records for both locations.
370 ///
371 /// # Example
372 ///
373 /// ```rust,no_run
374 /// use stateset_embedded::{Commerce, MoveInventory};
375 /// use rust_decimal_macros::dec;
376 ///
377 /// let commerce = Commerce::new(":memory:")?;
378 ///
379 /// // Move inventory from bulk to pick location
380 /// let movement = commerce.warehouse().move_inventory(MoveInventory {
381 /// from_location_id: 1, // Bulk storage
382 /// to_location_id: 2, // Pick location
383 /// sku: "PROD-001".into(),
384 /// lot_id: None,
385 /// quantity: dec!(50),
386 /// reason: Some("Replenish pick location".into()),
387 /// performed_by: Some("forklift_operator".into()),
388 /// })?;
389 /// # Ok::<(), stateset_embedded::CommerceError>(())
390 /// ```
391 pub fn move_inventory(&self, input: MoveInventory) -> Result<LocationMovement> {
392 self.db.warehouse().move_inventory(input)
393 }
394
395 /// List location inventory with optional filtering.
396 pub fn list_location_inventory(
397 &self,
398 filter: LocationInventoryFilter,
399 ) -> Result<Vec<LocationInventory>> {
400 self.db.warehouse().list_location_inventory(filter)
401 }
402
403 /// Get total available quantity for a SKU across a warehouse.
404 ///
405 /// Sums available quantity (`on_hand` - reserved) across all locations.
406 pub fn get_total_available(&self, warehouse_id: i32, sku: &str) -> Result<Decimal> {
407 let inventory = self.db.warehouse().get_inventory_for_sku(warehouse_id, sku)?;
408 Ok(inventory.iter().map(|i| i.quantity_available).sum())
409 }
410
411 /// Get total on-hand quantity for a SKU across a warehouse.
412 pub fn get_total_on_hand(&self, warehouse_id: i32, sku: &str) -> Result<Decimal> {
413 let inventory = self.db.warehouse().get_inventory_for_sku(warehouse_id, sku)?;
414 Ok(inventory.iter().map(|i| i.quantity_on_hand).sum())
415 }
416
417 // ========================================================================
418 // Movement Operations
419 // ========================================================================
420
421 /// Get inventory movements with optional filtering.
422 ///
423 /// # Example
424 ///
425 /// ```rust,no_run
426 /// use stateset_embedded::{Commerce, MovementFilter, MovementType};
427 /// use chrono::{Utc, Duration};
428 ///
429 /// let commerce = Commerce::new(":memory:")?;
430 ///
431 /// // Get all transfers in the last 7 days
432 /// let movements = commerce.warehouse().get_movements(MovementFilter {
433 /// warehouse_id: Some(1),
434 /// movement_type: Some(MovementType::Transfer),
435 /// from_date: Some(Utc::now() - Duration::days(7)),
436 /// limit: Some(100),
437 /// ..Default::default()
438 /// })?;
439 /// # Ok::<(), stateset_embedded::CommerceError>(())
440 /// ```
441 pub fn get_movements(&self, filter: MovementFilter) -> Result<Vec<LocationMovement>> {
442 self.db.warehouse().get_movements(filter)
443 }
444
445 /// Count movements matching the filter.
446 pub fn count_movements(&self, filter: MovementFilter) -> Result<u64> {
447 self.db.warehouse().count_movements(filter)
448 }
449
450 // ========================================================================
451 // Cycle Count Operations
452 // ========================================================================
453
454 /// Create a cycle count (draft) with its expected lines.
455 ///
456 /// # Example
457 ///
458 /// ```rust,no_run
459 /// use stateset_embedded::{Commerce, CreateCycleCount, CreateCycleCountLine};
460 /// use rust_decimal_macros::dec;
461 ///
462 /// let commerce = Commerce::new(":memory:")?;
463 ///
464 /// let count = commerce.warehouse().create_cycle_count(CreateCycleCount {
465 /// warehouse_id: 1,
466 /// location_id: Some(1),
467 /// counted_by: Some("counter@example.com".into()),
468 /// lines: vec![CreateCycleCountLine {
469 /// sku: "PROD-001".into(),
470 /// lot_id: None,
471 /// expected_quantity: dec!(100),
472 /// }],
473 /// ..Default::default()
474 /// })?;
475 /// # Ok::<(), stateset_embedded::CommerceError>(())
476 /// ```
477 pub fn create_cycle_count(&self, input: CreateCycleCount) -> Result<CycleCount> {
478 self.db.warehouse().create_cycle_count(input)
479 }
480
481 /// Get a cycle count (with lines) by ID.
482 pub fn get_cycle_count(&self, id: Uuid) -> Result<Option<CycleCount>> {
483 self.db.warehouse().get_cycle_count(id)
484 }
485
486 /// List cycle counts matching the filter.
487 pub fn list_cycle_counts(&self, filter: CycleCountFilter) -> Result<Vec<CycleCount>> {
488 self.db.warehouse().list_cycle_counts(filter)
489 }
490
491 /// Start a draft cycle count (`draft` → `in_progress`).
492 pub fn start_cycle_count(&self, id: Uuid) -> Result<CycleCount> {
493 self.db.warehouse().start_cycle_count(id)
494 }
495
496 /// Record physical counts against an in-progress cycle count.
497 pub fn record_cycle_counts(
498 &self,
499 id: Uuid,
500 counts: Vec<RecordCycleCountLine>,
501 ) -> Result<CycleCount> {
502 self.db.warehouse().record_cycle_counts(id, counts)
503 }
504
505 /// Complete an in-progress cycle count.
506 ///
507 /// Computes each line's variance (counted - expected) and applies matching
508 /// inventory adjustments to `location_inventory`, recording `cycle_count`
509 /// movements for the audit trail.
510 pub fn complete_cycle_count(&self, id: Uuid) -> Result<CycleCount> {
511 let count = self.db.warehouse().complete_cycle_count(id)?;
512 #[cfg(feature = "events")]
513 self.emit(CommerceEvent::CycleCountCompleted {
514 cycle_count_id: count.id,
515 warehouse_id: count.warehouse_id,
516 line_count: count.lines.len(),
517 variance_line_count: count
518 .lines
519 .iter()
520 .filter(|l| l.variance.is_some_and(|v| !v.is_zero()))
521 .count(),
522 total_variance: count.lines.iter().filter_map(|l| l.variance).sum(),
523 timestamp: count.completed_at.unwrap_or_else(Utc::now),
524 });
525 Ok(count)
526 }
527
528 /// Cancel a draft or in-progress cycle count. No adjustments are applied.
529 pub fn cancel_cycle_count(&self, id: Uuid) -> Result<CycleCount> {
530 self.db.warehouse().cancel_cycle_count(id)
531 }
532}