1use std::collections::HashSet;
2
3use crate::foundation::*;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
7pub struct StockState {
8 pub(crate) sku: Sku,
9 pub(crate) total: Quantity,
10 pub(crate) reserved: Quantity,
11}
12
13impl StockState {
14 pub const fn try_new(sku: Sku, total: Quantity, reserved: Quantity) -> DomainResult<Self> {
15 if reserved > total {
16 return Err(ValidationError::Invariant("reserved stock exceeds total"));
17 }
18 Ok(Self {
19 sku,
20 total,
21 reserved,
22 })
23 }
24
25 #[must_use]
26 pub const fn sku(&self) -> Sku {
27 self.sku
28 }
29
30 #[must_use]
31 pub const fn total(&self) -> Quantity {
32 self.total
33 }
34
35 #[must_use]
36 pub const fn reserved(&self) -> Quantity {
37 self.reserved
38 }
39}
40
41#[must_use]
42pub const fn available_stock(s: &StockState) -> Quantity {
43 nat_sub(s.total, s.reserved)
44}
45
46#[must_use]
47pub const fn can_reserve(s: &StockState, q: Quantity) -> bool {
48 q <= available_stock(s)
49}
50
51pub fn reserve_stock(s: &StockState, q: Quantity) -> DomainResult<StockState> {
52 if !can_reserve(s, q) {
53 return Err(ValidationError::Invariant(
54 "reservation exceeds available stock",
55 ));
56 }
57 StockState::try_new(s.sku, s.total, checked_add(s.reserved, q, "reserve_stock")?)
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub struct VersionedStock {
63 pub(crate) stock: StockState,
64 pub(crate) version: Nat,
65}
66
67impl VersionedStock {
68 pub fn try_new(
69 sku: Sku,
70 total: Quantity,
71 reserved: Quantity,
72 version: Nat,
73 ) -> DomainResult<Self> {
74 Ok(Self {
75 stock: StockState::try_new(sku, total, reserved)?,
76 version,
77 })
78 }
79
80 #[must_use]
81 pub const fn from_stock(stock: StockState, version: Nat) -> Self {
82 Self { stock, version }
83 }
84
85 #[must_use]
86 pub const fn stock(&self) -> StockState {
87 self.stock
88 }
89
90 #[must_use]
91 pub const fn version(&self) -> Nat {
92 self.version
93 }
94}
95
96pub fn reserve_versioned_stock(
97 s: &VersionedStock,
98 q: Quantity,
99 expected_version: Nat,
100) -> DomainResult<VersionedStock> {
101 if expected_version != s.version {
102 return Err(ValidationError::Invariant("stock version mismatch"));
103 }
104 Ok(VersionedStock {
105 stock: reserve_stock(&s.stock, q)?,
106 version: checked_add(s.version, 1, "reserve_versioned_stock")?,
107 })
108}
109
110domain_struct! {
111 pub struct Warehouse {
112 id: Id,
113 name: String,
114 }
115}
116
117domain_struct! {
118 pub struct BinLocation {
119 warehouse: Warehouse,
120 bin_id: Id,
121 }
122}
123
124domain_struct! {
125 pub struct BinStock {
126 sku: Sku,
127 location: BinLocation,
128 quantity: Quantity,
129 }
130}
131
132#[derive(Clone, Debug, PartialEq, Eq)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct PickTask {
135 pub(crate) sku: Sku,
136 pub(crate) requested: Quantity,
137 pub(crate) bin: BinStock,
138}
139
140impl PickTask {
141 pub fn try_new(sku: Sku, requested: Quantity, bin: BinStock) -> DomainResult<Self> {
142 if requested > bin.quantity {
143 return Err(ValidationError::Invariant("pick exceeds bin quantity"));
144 }
145 Ok(Self {
146 sku,
147 requested,
148 bin,
149 })
150 }
151}
152
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
155pub struct PackTask {
156 pub(crate) picked: Quantity,
157 pub(crate) packed: Quantity,
158}
159
160impl PackTask {
161 pub const fn try_new(
162 source_quantity: Quantity,
163 packed_quantity: Quantity,
164 ) -> DomainResult<Self> {
165 if packed_quantity > source_quantity {
166 return Err(ValidationError::Invariant("packed exceeds picked"));
167 }
168 Ok(Self {
169 picked: source_quantity,
170 packed: packed_quantity,
171 })
172 }
173}
174
175#[derive(Clone, Debug, PartialEq, Eq)]
176#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
177pub struct WarehouseShipment {
178 pub(crate) packed: Quantity,
179 pub(crate) shipped: Quantity,
180}
181
182impl WarehouseShipment {
183 pub const fn try_new(packed: Quantity, shipped: Quantity) -> DomainResult<Self> {
184 if shipped > packed {
185 return Err(ValidationError::Invariant("shipped exceeds packed"));
186 }
187 Ok(Self { packed, shipped })
188 }
189}
190
191domain_struct! {
192 pub struct InventoryNode {
193 warehouse: Warehouse,
194 stock: StockState,
195 }
196}
197
198#[derive(Clone, Debug, PartialEq, Eq)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200pub struct Allocation {
201 pub(crate) node: InventoryNode,
202 pub(crate) quantity: Quantity,
203}
204
205impl Allocation {
206 pub fn try_new(node: InventoryNode, quantity: Quantity) -> DomainResult<Self> {
207 if quantity > available_stock(&node.stock) {
208 return Err(ValidationError::Invariant(
209 "allocation exceeds available stock",
210 ));
211 }
212 Ok(Self { node, quantity })
213 }
214
215 #[must_use]
216 pub const fn node(&self) -> &InventoryNode {
217 &self.node
218 }
219
220 #[must_use]
221 pub const fn quantity(&self) -> Quantity {
222 self.quantity
223 }
224}
225
226pub fn allocations_total(allocations: &[Allocation]) -> DomainResult<Quantity> {
227 checked_sum(allocations.iter().map(|a| a.quantity), "allocations_total")
228}
229
230pub fn allocations_available_total(allocations: &[Allocation]) -> DomainResult<Quantity> {
231 checked_sum(
232 allocations.iter().map(|a| available_stock(&a.node.stock)),
233 "allocations_available_total",
234 )
235}
236
237#[must_use]
238pub const fn allocation_key(a: &Allocation) -> (Nat, Nat) {
239 (a.node.warehouse.id, a.node.stock.sku.value())
240}
241
242#[must_use]
243pub fn allocation_keys_distinct(allocations: &[Allocation]) -> bool {
244 let mut seen = HashSet::new();
245 allocations.iter().all(|a| seen.insert(allocation_key(a)))
246}
247
248#[derive(Clone, Debug, PartialEq, Eq)]
249#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
250pub struct FulfillmentPlan {
251 pub(crate) requested: Quantity,
252 pub(crate) allocations: Vec<Allocation>,
253}
254
255impl FulfillmentPlan {
256 pub fn try_new(requested: Quantity, allocations: Vec<Allocation>) -> DomainResult<Self> {
257 if allocations_total(&allocations)? != requested {
258 return Err(ValidationError::Invariant(
259 "allocations must exactly cover request",
260 ));
261 }
262 Ok(Self {
263 requested,
264 allocations,
265 })
266 }
267
268 #[must_use]
269 pub const fn requested(&self) -> Quantity {
270 self.requested
271 }
272
273 #[must_use]
274 pub fn allocations(&self) -> &[Allocation] {
275 &self.allocations
276 }
277}
278
279#[derive(Clone, Debug, PartialEq, Eq)]
280#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
281pub struct DistinctFulfillmentPlan {
282 pub(crate) requested: Quantity,
283 pub(crate) allocations: Vec<Allocation>,
284}
285
286impl DistinctFulfillmentPlan {
287 pub fn try_new(requested: Quantity, allocations: Vec<Allocation>) -> DomainResult<Self> {
288 if allocations_total(&allocations)? != requested {
289 return Err(ValidationError::Invariant(
290 "allocations must exactly cover request",
291 ));
292 }
293 if !allocation_keys_distinct(&allocations) {
294 return Err(ValidationError::Invariant(
295 "allocation keys must be distinct",
296 ));
297 }
298 Ok(Self {
299 requested,
300 allocations,
301 })
302 }
303}
304
305pub const fn release_reserved_stock(s: &StockState, q: Quantity) -> DomainResult<StockState> {
306 if q > s.reserved {
307 return Err(ValidationError::InventoryInvariantFailed);
308 }
309 StockState::try_new(s.sku, s.total, nat_sub(s.reserved, q))
310}
311
312pub const fn confirm_reserved_shipment(s: &StockState, q: Quantity) -> DomainResult<StockState> {
313 if q > s.reserved {
314 return Err(ValidationError::InventoryInvariantFailed);
315 }
316 StockState::try_new(s.sku, nat_sub(s.total, q), nat_sub(s.reserved, q))
317}
318
319#[must_use]
320pub fn compare_and_swap_reserve(
321 s: &VersionedStock,
322 q: Quantity,
323 expected_version: Nat,
324) -> Option<VersionedStock> {
325 reserve_versioned_stock(s, q, expected_version).ok()
326}
327
328#[derive(Clone, Debug, PartialEq, Eq)]
329#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
330pub struct ReservationAttempt {
331 pub(crate) stock: VersionedStock,
332 pub(crate) quantity: Quantity,
333 pub(crate) expected_version: Nat,
334}
335
336impl ReservationAttempt {
337 #[must_use]
338 pub const fn new(stock: VersionedStock, quantity: Quantity, expected_version: Nat) -> Self {
339 Self {
340 stock,
341 quantity,
342 expected_version,
343 }
344 }
345}
346
347#[must_use]
348pub fn commit_reservation_attempt(attempt: &ReservationAttempt) -> Option<VersionedStock> {
349 compare_and_swap_reserve(&attempt.stock, attempt.quantity, attempt.expected_version)
350}
351
352#[derive(Clone, Debug, PartialEq, Eq)]
353#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
354pub struct ConcurrentReservationConflict {
355 pub(crate) first: ReservationAttempt,
356 pub(crate) second: ReservationAttempt,
357}
358
359impl ConcurrentReservationConflict {
360 pub fn try_new(first: ReservationAttempt, second: ReservationAttempt) -> DomainResult<Self> {
361 if first.stock.stock.sku != second.stock.stock.sku
362 || first.expected_version != second.expected_version
363 {
364 return Err(ValidationError::InventoryInvariantFailed);
365 }
366 Ok(Self { first, second })
367 }
368}
369
370#[derive(Clone, Copy, Debug, PartialEq, Eq)]
371#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
372pub enum ReservationStatus {
373 Active,
374 Expired,
375 Confirmed,
376 Released,
377}
378
379#[derive(Clone, Copy, Debug, PartialEq, Eq)]
380#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
381pub struct TimedReservation {
382 pub(crate) stock: StockState,
383 pub(crate) quantity: Quantity,
384 pub(crate) reserved_at: Timestamp,
385 pub(crate) expires_at: Timestamp,
386 pub(crate) status: ReservationStatus,
387}
388
389impl TimedReservation {
390 pub fn try_new(
391 stock: StockState,
392 quantity: Quantity,
393 reserved_at: Timestamp,
394 expires_at: Timestamp,
395 status: ReservationStatus,
396 ) -> DomainResult<Self> {
397 if expires_at < reserved_at || quantity > stock.reserved {
398 return Err(ValidationError::InventoryInvariantFailed);
399 }
400 Ok(Self {
401 stock,
402 quantity,
403 reserved_at,
404 expires_at,
405 status,
406 })
407 }
408}
409
410#[must_use]
411pub fn reservation_expired_at(now: Timestamp, reservation: &TimedReservation) -> bool {
412 reservation.expires_at < now
413}
414
415#[must_use]
416pub fn reservation_active_at(now: Timestamp, reservation: &TimedReservation) -> bool {
417 reservation.status == ReservationStatus::Active && now <= reservation.expires_at
418}
419
420pub fn release_expired_reservation(
421 reservation: &TimedReservation,
422 now: Timestamp,
423) -> DomainResult<StockState> {
424 if !reservation_expired_at(now, reservation) {
425 return Err(ValidationError::InventoryInvariantFailed);
426 }
427 release_reserved_stock(&reservation.stock, reservation.quantity)
428}
429
430#[derive(Clone, Debug, PartialEq, Eq)]
431#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
432pub struct BackorderRequest {
433 pub(crate) sku: Sku,
434 pub(crate) requested: Quantity,
435 pub(crate) available_now: Quantity,
436 pub(crate) backordered: Quantity,
437}
438
439impl BackorderRequest {
440 pub fn try_new(
441 sku: Sku,
442 requested: Quantity,
443 available_now: Quantity,
444 backordered: Quantity,
445 ) -> DomainResult<Self> {
446 if requested != checked_add(available_now, backordered, "backorder quantity")? {
447 return Err(ValidationError::InventoryInvariantFailed);
448 }
449 Ok(Self {
450 sku,
451 requested,
452 available_now,
453 backordered,
454 })
455 }
456}
457
458#[derive(Clone, Debug, PartialEq, Eq)]
459#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
460pub struct PreorderWindow {
461 pub(crate) sku: Sku,
462 pub(crate) opens_at: Timestamp,
463 pub(crate) closes_at: Timestamp,
464 pub(crate) capacity: Quantity,
465}
466
467impl PreorderWindow {
468 pub fn try_new(
469 sku: Sku,
470 opens_at: Timestamp,
471 closes_at: Timestamp,
472 capacity: Quantity,
473 ) -> DomainResult<Self> {
474 if closes_at < opens_at {
475 return Err(ValidationError::InventoryInvariantFailed);
476 }
477 Ok(Self {
478 sku,
479 opens_at,
480 closes_at,
481 capacity,
482 })
483 }
484}
485
486#[derive(Clone, Debug, PartialEq, Eq)]
487#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
488pub struct PreorderReservation {
489 pub(crate) window: PreorderWindow,
490 pub(crate) quantity: Quantity,
491 pub(crate) reserved_at: Timestamp,
492}
493
494impl PreorderReservation {
495 pub fn try_new(
496 window: PreorderWindow,
497 quantity: Quantity,
498 reserved_at: Timestamp,
499 ) -> DomainResult<Self> {
500 if quantity > window.capacity
501 || reserved_at < window.opens_at
502 || reserved_at > window.closes_at
503 {
504 return Err(ValidationError::InventoryInvariantFailed);
505 }
506 Ok(Self {
507 window,
508 quantity,
509 reserved_at,
510 })
511 }
512}
513
514#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
515#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
516pub struct SerialNumber {
517 value: Nat,
518}
519
520impl SerialNumber {
521 #[must_use]
522 pub const fn new(value: Nat) -> Self {
523 Self { value }
524 }
525
526 #[must_use]
527 pub const fn value(self) -> Nat {
528 self.value
529 }
530}
531
532domain_struct! {
533 pub struct SerializedInventoryUnit {
534 sku: Sku,
535 serial: SerialNumber,
536 warehouse: Warehouse,
537 reserved: bool,
538 }
539}
540
541#[must_use]
542pub fn serial_numbers_distinct(units: &[SerializedInventoryUnit]) -> bool {
543 let mut seen = HashSet::new();
544 units.iter().all(|unit| seen.insert(unit.serial.value()))
545}
546
547#[derive(Clone, Debug, PartialEq, Eq)]
548#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
549pub struct SerializedInventorySet {
550 pub(crate) units: Vec<SerializedInventoryUnit>,
551}
552
553impl SerializedInventorySet {
554 pub fn try_new(units: Vec<SerializedInventoryUnit>) -> DomainResult<Self> {
555 if !serial_numbers_distinct(&units) {
556 return Err(ValidationError::InventoryInvariantFailed);
557 }
558 Ok(Self { units })
559 }
560}
561
562domain_struct! {
563 pub struct InventoryLot {
564 sku: Sku,
565 lot_id: Id,
566 warehouse: Warehouse,
567 expires_at: Timestamp,
568 quantity: Quantity,
569 }
570}
571
572#[must_use]
573pub fn lot_usable_at(now: Timestamp, lot: &InventoryLot) -> bool {
574 now <= lot.expires_at && lot.quantity > 0
575}
576
577#[derive(Clone, Debug, PartialEq, Eq)]
578#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
579pub struct SkuSubstitution {
580 pub(crate) requested_sku: Sku,
581 pub(crate) substitute_sku: Sku,
582 pub(crate) substitute_stock: StockState,
583 pub(crate) max_substitute_qty: Quantity,
584}
585
586impl SkuSubstitution {
587 pub fn try_new(
588 requested_sku: Sku,
589 substitute_sku: Sku,
590 substitute_stock: StockState,
591 max_substitute_qty: Quantity,
592 ) -> DomainResult<Self> {
593 if substitute_stock.sku != substitute_sku
594 || max_substitute_qty > available_stock(&substitute_stock)
595 {
596 return Err(ValidationError::InventoryInvariantFailed);
597 }
598 Ok(Self {
599 requested_sku,
600 substitute_sku,
601 substitute_stock,
602 max_substitute_qty,
603 })
604 }
605}
606
607#[must_use]
608pub fn allocation_warehouse_ids(allocations: &[Allocation]) -> Vec<Id> {
609 allocations
610 .iter()
611 .map(|allocation| allocation.node.warehouse.id)
612 .collect()
613}
614
615#[derive(Clone, Debug, PartialEq, Eq)]
616#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
617pub struct SplitFulfillmentPlan {
618 pub(crate) plan: DistinctFulfillmentPlan,
619 pub(crate) first_warehouse: Warehouse,
620 pub(crate) second_warehouse: Warehouse,
621}
622
623impl SplitFulfillmentPlan {
624 pub fn try_new(
625 plan: DistinctFulfillmentPlan,
626 first_warehouse: Warehouse,
627 second_warehouse: Warehouse,
628 ) -> DomainResult<Self> {
629 let ids = allocation_warehouse_ids(plan.allocations());
630 if !ids.contains(&first_warehouse.id())
631 || !ids.contains(&second_warehouse.id())
632 || first_warehouse.id() == second_warehouse.id()
633 {
634 return Err(ValidationError::InventoryInvariantFailed);
635 }
636 Ok(Self {
637 plan,
638 first_warehouse,
639 second_warehouse,
640 })
641 }
642}
643
644impl_getters!(PickTask {
645 sku: Sku,
646 requested: Quantity,
647 bin: BinStock,
648});
649
650impl_getters!(PackTask {
651 picked: Quantity,
652 packed: Quantity,
653});
654
655impl_getters!(WarehouseShipment {
656 packed: Quantity,
657 shipped: Quantity,
658});
659
660impl_getters!(DistinctFulfillmentPlan {
661 requested: Quantity,
662 allocations: Vec<Allocation>,
663});
664
665impl_getters!(ReservationAttempt {
666 stock: VersionedStock,
667 quantity: Quantity,
668 expected_version: Nat,
669});
670
671impl_getters!(ConcurrentReservationConflict {
672 first: ReservationAttempt,
673 second: ReservationAttempt,
674});
675
676impl_getters!(TimedReservation {
677 stock: StockState,
678 quantity: Quantity,
679 reserved_at: Timestamp,
680 expires_at: Timestamp,
681 status: ReservationStatus,
682});
683
684impl_getters!(BackorderRequest {
685 sku: Sku,
686 requested: Quantity,
687 available_now: Quantity,
688 backordered: Quantity,
689});
690
691impl_getters!(PreorderWindow {
692 sku: Sku,
693 opens_at: Timestamp,
694 closes_at: Timestamp,
695 capacity: Quantity,
696});
697
698impl_getters!(PreorderReservation {
699 window: PreorderWindow,
700 quantity: Quantity,
701 reserved_at: Timestamp,
702});
703
704impl_getters!(SerializedInventorySet { units: Vec<SerializedInventoryUnit> });
705
706impl_getters!(SkuSubstitution {
707 requested_sku: Sku,
708 substitute_sku: Sku,
709 substitute_stock: StockState,
710 max_substitute_qty: Quantity,
711});
712
713impl_getters!(SplitFulfillmentPlan {
714 plan: DistinctFulfillmentPlan,
715 first_warehouse: Warehouse,
716 second_warehouse: Warehouse,
717});