Skip to main content

domain/retail/
product.rs

1//! Product catalog contracts for SKUs, categories, location offerings, and sellability rules.
2
3use bon::Builder;
4use nutype::nutype;
5use serde::{Deserialize, Serialize};
6
7use crate::entities::LocationId;
8
9use super::{inventory, pos, reorder};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12/// Product category used to distinguish supplements, boarding diets, and personalized upsell items.
13pub enum Category {
14    /// Supplement retail operational signal for inventory, POS, reorder, recommendation, or review handling.
15    Supplement,
16    /// In house diet retail operational signal for inventory, POS, reorder, recommendation, or review handling.
17    InHouseDiet,
18    /// Personalized upsell retail operational signal for inventory, POS, reorder, recommendation, or review handling.
19    PersonalizedUpsell,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
23/// Non-empty SKU identifier promoted from POS/catalog data for inventory and reorder workflows.
24pub struct Sku(String);
25
26impl Sku {
27    /// Validates and creates the retail value.
28    pub fn try_new(value: impl Into<String>) -> std::result::Result<Self, SkuError> {
29        let value = value.into().trim().to_owned();
30        if value.is_empty() {
31            return Err(SkuError::Empty);
32        }
33        Ok(Self(value))
34    }
35
36    /// Returns the owned inner string for storage or outbound mapping.
37    pub fn into_inner(self) -> String {
38        self.0
39    }
40
41    /// Returns the provider or domain identifier as a string slice.
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47impl<'de> Deserialize<'de> for Sku {
48    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
49    where
50        D: serde::Deserializer<'de>,
51    {
52        Self::try_new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
57/// Decision vocabulary for sku error in retail workflows.
58pub enum SkuError {
59    #[error("retail SKU cannot be empty")]
60    /// Empty retail operational signal for inventory, POS, reorder, recommendation, or review handling.
61    Empty,
62}
63
64#[nutype(
65    sanitize(trim),
66    validate(not_empty, len_char_max = 160),
67    derive(
68        Debug,
69        Clone,
70        PartialEq,
71        Eq,
72        PartialOrd,
73        Ord,
74        Hash,
75        Serialize,
76        Deserialize
77    )
78)]
79pub struct Name(String);
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82/// Retail product with a SKU and category used across POS, inventory, and recommendation contracts.
83pub struct Product {
84    sku: Sku,
85    /// Source-derived category carried by this retail contract.
86    pub category: Category,
87}
88
89impl Product {
90    /// Assembles this retail value from already-validated domain parts.
91    pub fn new(sku: Sku, category: Category) -> Self {
92        Self { sku, category }
93    }
94
95    /// Returns the sku evidence recorded on this retail contract.
96    pub fn sku(&self) -> &Sku {
97        &self.sku
98    }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102/// Location-level offering status used to prevent inactive or discontinued products from being sold.
103pub enum OfferingStatus {
104    /// Active retail operational signal for inventory, POS, reorder, recommendation, or review handling.
105    Active,
106    /// Inactive retail operational signal for inventory, POS, reorder, recommendation, or review handling.
107    Inactive,
108    /// Discontinued retail operational signal for inventory, POS, reorder, recommendation, or review handling.
109    Discontinued,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113/// Usage policy distinguishing customer-sellable items from in-house consumables such as boarding diets.
114pub enum Usage {
115    /// Customer sellable retail operational signal for inventory, POS, reorder, recommendation, or review handling.
116    CustomerSellable,
117    /// In house consumable retail operational signal for inventory, POS, reorder, recommendation, or review handling.
118    InHouseConsumable,
119    /// Sellable and in house consumable retail operational signal for inventory, POS, reorder, recommendation, or review handling.
120    SellableAndInHouseConsumable,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
124/// Location-specific product offering with POS, inventory, and reorder policies attached.
125pub struct LocationOffering {
126    /// Source-derived location id carried by this retail contract.
127    pub location_id: LocationId,
128    /// Source-derived product carried by this retail contract.
129    pub product: Product,
130    /// Source-derived status carried by this retail contract.
131    pub status: OfferingStatus,
132    /// Source-derived usage carried by this retail contract.
133    pub usage: Usage,
134    /// Source-derived pos carried by this retail contract.
135    pub pos: pos::Policy,
136    /// Source-derived inventory carried by this retail contract.
137    pub inventory: inventory::Policy,
138    /// Source-derived reorder carried by this retail contract.
139    pub reorder: reorder::Policy,
140}
141
142impl LocationOffering {
143    /// Reports whether the product is active and customer-sellable at this location.
144    pub fn can_be_sold_to_customer(&self) -> bool {
145        matches!(self.status, OfferingStatus::Active)
146            && matches!(
147                self.usage,
148                Usage::CustomerSellable | Usage::SellableAndInHouseConsumable
149            )
150    }
151
152    /// Checks tracked inventory before allowing a POS sale draft for the requested quantity.
153    pub fn has_available_sale_units(&self, quantity: pos::Quantity) -> bool {
154        match self.inventory {
155            inventory::Policy::NotTracked => true,
156            inventory::Policy::Tracked { on_hand, .. } => on_hand.get() >= quantity.get(),
157        }
158    }
159}