Skip to main content

commerce_theory/
merchandising.rs

1use crate::competitor_pricing::*;
2use crate::foundation::*;
3
4#[derive(Clone, Debug, PartialEq, Eq)]
5#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6pub struct BrandPricingPolicy {
7    pub(crate) map_price: Money,
8    pub(crate) msrp: Money,
9}
10
11impl BrandPricingPolicy {
12    pub const fn try_new(map_price: Money, msrp: Money) -> DomainResult<Self> {
13        if map_price > msrp {
14            return Err(ValidationError::Invariant("MAP exceeds MSRP"));
15        }
16        Ok(Self { map_price, msrp })
17    }
18}
19
20#[must_use]
21pub const fn advertised_price_allowed(
22    policy: &BrandPricingPolicy,
23    advertised_price: Money,
24) -> bool {
25    policy.map_price <= advertised_price
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30pub struct BundleComponent {
31    pub(crate) sku: Sku,
32    pub(crate) units_per_bundle: Quantity,
33    pub(crate) stock_available: Quantity,
34}
35
36impl BundleComponent {
37    pub const fn try_new(
38        sku: Sku,
39        units_per_bundle: Quantity,
40        stock_available: Quantity,
41    ) -> DomainResult<Self> {
42        if units_per_bundle == 0 {
43            return Err(ValidationError::Invariant(
44                "bundle units per component must be positive",
45            ));
46        }
47        Ok(Self {
48            sku,
49            units_per_bundle,
50            stock_available,
51        })
52    }
53}
54
55pub fn component_required_for_bundles(
56    bundle_qty: Quantity,
57    component: &BundleComponent,
58) -> DomainResult<Quantity> {
59    checked_mul(
60        bundle_qty,
61        component.units_per_bundle,
62        "component_required_for_bundles",
63    )
64}
65
66pub fn component_can_fulfill_bundles(
67    bundle_qty: Quantity,
68    component: &BundleComponent,
69) -> DomainResult<bool> {
70    Ok(component_required_for_bundles(bundle_qty, component)? <= component.stock_available)
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct BundleReservation {
76    pub(crate) bundle_qty: Quantity,
77    pub(crate) components: Vec<BundleComponent>,
78}
79
80impl BundleReservation {
81    pub fn try_new(bundle_qty: Quantity, components: Vec<BundleComponent>) -> DomainResult<Self> {
82        for component in &components {
83            if !component_can_fulfill_bundles(bundle_qty, component)? {
84                return Err(ValidationError::Invariant(
85                    "bundle component cannot fulfill reservation",
86                ));
87            }
88        }
89        Ok(Self {
90            bundle_qty,
91            components,
92        })
93    }
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98pub enum PromotionStackingPolicy {
99    Exclusive,
100    Stackable,
101    StackableWithCap,
102}
103
104#[derive(Clone, Debug, PartialEq, Eq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106pub struct AcceptedPromotionSet {
107    pub(crate) resulting_price: Money,
108    pub(crate) total_discount: Money,
109    pub(crate) discount_cap: Money,
110    pub(crate) profit_floor: Money,
111}
112
113impl AcceptedPromotionSet {
114    pub const fn try_new(
115        resulting_price: Money,
116        total_discount: Money,
117        discount_cap: Money,
118        profit_floor: Money,
119    ) -> DomainResult<Self> {
120        if total_discount > discount_cap {
121            return Err(ValidationError::Invariant("promotion discount exceeds cap"));
122        }
123        if profit_floor > resulting_price {
124            return Err(ValidationError::Invariant(
125                "promotion price below profit floor",
126            ));
127        }
128        Ok(Self {
129            resulting_price,
130            total_discount,
131            discount_cap,
132            profit_floor,
133        })
134    }
135}
136
137#[must_use]
138pub const fn promotion_set_allowed_by_policy(
139    policy: PromotionStackingPolicy,
140    promotion_count: Nat,
141    set: &AcceptedPromotionSet,
142) -> bool {
143    match policy {
144        PromotionStackingPolicy::Exclusive => promotion_count <= 1,
145        PromotionStackingPolicy::Stackable => true,
146        PromotionStackingPolicy::StackableWithCap => set.total_discount <= set.discount_cap,
147    }
148}
149
150domain_struct! {
151    pub struct SearchResultItem {
152        sku: Sku,
153        archived: bool,
154        in_stock: bool,
155        margin_safe: bool,
156    }
157}
158
159#[derive(Clone, Debug, PartialEq, Eq)]
160#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
161pub struct ValidSearchResultItem {
162    pub(crate) item: SearchResultItem,
163}
164
165impl ValidSearchResultItem {
166    pub const fn try_new(item: SearchResultItem) -> DomainResult<Self> {
167        if item.archived || !item.in_stock || !item.margin_safe {
168            return Err(ValidationError::Invariant("search result is not safe"));
169        }
170        Ok(Self { item })
171    }
172}
173
174pub(crate) const fn _competitor_anchor(_: Option<TrustLevel>) {}
175
176impl_getters!(BrandPricingPolicy {
177    map_price: Money,
178    msrp: Money,
179});
180
181impl_getters!(BundleComponent {
182    sku: Sku,
183    units_per_bundle: Quantity,
184    stock_available: Quantity,
185});
186
187impl_getters!(BundleReservation {
188    bundle_qty: Quantity,
189    components: Vec<BundleComponent>,
190});
191
192impl_getters!(AcceptedPromotionSet {
193    resulting_price: Money,
194    total_discount: Money,
195    discount_cap: Money,
196    profit_floor: Money,
197});
198
199impl_getters!(ValidSearchResultItem {
200    item: SearchResultItem,
201});