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}