Skip to main content

domain/retail/
product.rs

1//! Product catalog models 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 item that may need care or medical-document review before recommendation copy is shown.
15    Supplement,
16    /// Boarding or care diet stocked for in-house use and monitored for depletion.
17    InHouseDiet,
18    /// Customer-facing upsell candidate whose sale and copy still depend on inventory, preference, and review gates.
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/// SKU validation errors that keep catalog, stock, and reorder records traceable.
58pub enum SkuError {
59    #[error("retail SKU cannot be empty")]
60    /// Rejects blank catalog/POS SKU values so inventory and reorder facts remain traceable.
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 decisions.
83pub struct Product {
84    sku: Sku,
85    /// Product category used to route supplement, diet, and upsell handling.
86    pub category: Category,
87}
88
89impl Product {
90    /// Pairs a validated SKU with its retail category for catalog, inventory, and recommendation work.
91    pub fn new(sku: Sku, category: Category) -> Self {
92        Self { sku, category }
93    }
94
95    /// Returns the SKU that ties this product to catalog, inventory, POS, and vendor records.
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    /// Offering can be considered for sale or recommendation if usage and inventory also allow it.
105    Active,
106    /// Offering is disabled at the location and must not be sold or recommended.
107    Inactive,
108    /// Offering has been retired and should remain out of staff sale and recommendation drafts.
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    /// Product may be sold to customers when status and inventory permit.
116    CustomerSellable,
117    /// Product is reserved for resort use, such as boarding diets, and should not become customer-sale copy.
118    InHouseConsumable,
119    /// Product can support both staff operations and customer sale drafts when other gates pass.
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    /// Location whose catalog, shelf policy, and inventory counts own this offering.
127    pub location_id: LocationId,
128    /// SKU and category being evaluated for sale, recommendation, inventory, and reorder work.
129    pub product: Product,
130    /// Location status that blocks inactive or discontinued items from customer-sale drafts.
131    pub status: OfferingStatus,
132    /// Usage policy deciding whether the product is customer-sellable, in-house only, or both.
133    pub usage: Usage,
134    /// POS policy controlling checkout sources and price-exception approval.
135    pub pos: pos::Policy,
136    /// Inventory policy controlling stock checks and low-stock attention.
137    pub inventory: inventory::Policy,
138    /// Reorder policy controlling manager tasks, vendor notices, or no-action outcomes.
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}