Skip to main content

commerce_theory/
inventory.rs

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});