Skip to main content

domain/retail/
recommendation.rs

1//! Retail recommendation models for personalized upsell candidates, review gates, and safe customer copy.
2
3use bon::Builder;
4use serde::{Deserialize, Serialize};
5
6use crate::entities::{CustomerId, LocationId, PetId};
7use crate::policy;
8
9use super::inventory::Availability;
10use super::product::Product;
11
12/// Rationale text keeps the staff-readable evidence for why a retail recommendation exists.
13pub mod rationale {
14    use nutype::nutype;
15    #[allow(unused_imports)]
16    use serde::{Deserialize, Serialize};
17
18    #[nutype(
19        sanitize(trim),
20        validate(not_empty, len_char_max = 500),
21        derive(
22            Debug,
23            Clone,
24            PartialEq,
25            Eq,
26            PartialOrd,
27            Ord,
28            Hash,
29            Serialize,
30            Deserialize
31        )
32    )]
33    pub struct Text(String);
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37/// Recommendation rule that names the operational event that can produce an upsell candidate.
38pub enum Rule {
39    /// No recommendation rule is active, so no upsell candidate should be produced from this rule alone.
40    None,
41    /// Boarding stay may justify an internal anxiety-support upsell candidate after inventory and care checks.
42    AnxietySupportAfterBoarding,
43    /// Boarding diet history may justify a continuity recommendation when stock and care policy allow it.
44    DietSupportAfterBoarding,
45    /// Grooming outcome may justify a coat-care upsell candidate after staff review gates are satisfied.
46    CoatCareAfterGrooming,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
50/// Retail recommendation candidate containing customer/pet context, product, rationale, inventory, preference, and care-safety signals.
51pub struct Candidate {
52    /// Customer whose preferences and opt-out status control whether recommendation work may proceed.
53    pub customer_id: CustomerId,
54    /// Pet receiving the grooming or care service.
55    pub pet_id: PetId,
56    /// Location where inventory and staff review capacity are evaluated for this candidate.
57    pub location_id: LocationId,
58    /// Product being considered for an internal upsell candidate, not an automatic customer send.
59    pub product: Product,
60    /// Internal reason staff see when deciding whether the recommendation is useful and safe.
61    pub reason: Reason,
62    /// Staff-readable rationale explaining the source event or care context behind the candidate.
63    pub rationale: rationale::Text,
64    /// Care-safety state that can require medical-document or manager review before use.
65    pub care_sensitivity: CareSensitivity,
66    /// Availability state that suppresses candidates when stock cannot support the recommendation.
67    pub inventory: Availability,
68    /// Preference or opt-out state that prevents unwanted recommendation drafts.
69    pub customer_preference: Preference,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73/// Business reason for recommending a product, kept separate from customer-facing copy.
74pub enum Reason {
75    /// Reason tied to boarding stress notes or staff-observed anxiety support needs.
76    AnxietyOrStressSupport,
77    /// Reason tied to preserving diet continuity after a boarding stay.
78    BoardingDietContinuity,
79    /// Reason tied to groomer-observed coat or skin care follow-up.
80    CoatOrSkinCareAfterGrooming,
81    /// Reason tied to replenishing a product the customer previously bought.
82    PriorPurchaseReplenishment,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86/// Care-sensitivity status that decides whether supplement/diet/product recommendations need care or manager review.
87pub enum CareSensitivity {
88    /// No care conflict is known, so the candidate can remain an internal draft if other gates pass.
89    NoKnownCareConflict,
90    /// Supplement or diet item must pause for medical-document review before staff use or customer copy.
91    SupplementOrDietReviewRequired,
92    /// Pet care plan conflicts with the suggested product and requires manager review.
93    CarePlanConflict,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97/// Customer preference state used to suppress opted-out recommendations and review unknown consent.
98pub enum Preference {
99    /// Customer preference allows retail recommendation drafts when inventory and care gates pass.
100    AllowsRetailRecommendations,
101    /// Customer opted out, so the candidate is suppressed before staff or customer use.
102    OptedOut,
103    /// Customer recommendation preference is unknown, so staff must confirm consent before use.
104    UnknownRequiresReview,
105}
106
107#[derive(Debug, Clone, Default)]
108/// Evaluates retail recommendation safety so staff see useful candidates without bypassing care, inventory, or preference gates.
109pub struct Policy;
110
111impl Policy {
112    /// Evaluates recommendation or customer copy safety without bypassing preference, inventory, or care-review gates.
113    pub fn evaluate(&self, candidate: &Candidate) -> Decision {
114        if matches!(candidate.customer_preference, Preference::OptedOut) {
115            return Decision::Suppressed {
116                reason: SuppressionReason::CustomerOptedOut,
117            };
118        }
119        if !matches!(candidate.inventory, Availability::Available) {
120            return Decision::Suppressed {
121                reason: SuppressionReason::InventoryUnavailable,
122            };
123        }
124        match candidate.care_sensitivity {
125            CareSensitivity::NoKnownCareConflict => Decision::DraftInternalCandidate,
126            CareSensitivity::SupplementOrDietReviewRequired => Decision::StaffReviewRequired {
127                reason: ReviewReason::CareSensitiveProduct,
128                gate: policy::ReviewGate::MedicalDocumentReview,
129            },
130            CareSensitivity::CarePlanConflict => Decision::ManagerReviewRequired {
131                reason: ReviewReason::CarePlanConflict,
132                gate: policy::ReviewGate::ManagerApproval,
133            },
134        }
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139/// Recommendation decision that either drafts an internal candidate, requires review, or suppresses the upsell.
140pub enum Decision {
141    /// Candidate may be shown internally to staff but is not approved customer copy or a sale action.
142    DraftInternalCandidate,
143    /// Candidate must pause for staff/medical-document review before use.
144    StaffReviewRequired {
145        /// Internal reason staff see when deciding whether the recommendation is useful and safe.
146        reason: ReviewReason,
147        /// Approval gate that must be satisfied before recommendation or customer-copy workflow proceeds.
148        gate: policy::ReviewGate,
149    },
150    /// Candidate must pause for manager approval because care-plan conflict or policy risk is present.
151    ManagerReviewRequired {
152        /// Internal reason staff see when deciding whether the recommendation is useful and safe.
153        reason: ReviewReason,
154        /// Approval gate that must be satisfied before recommendation or customer-copy workflow proceeds.
155        gate: policy::ReviewGate,
156    },
157    /// Candidate is hidden from staff/customer workflows because preference, inventory, or safety policy failed.
158    Suppressed {
159        /// Internal reason staff see when deciding whether the recommendation is useful and safe.
160        reason: SuppressionReason,
161    },
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165/// Review reasons that explain whether care facts, care-plan conflicts, or consent uncertainty paused the candidate.
166pub enum ReviewReason {
167    /// Product category or care facts require medical-document review before staff recommend it.
168    CareSensitiveProduct,
169    /// Pet care plan conflicts with the suggested product and requires manager review.
170    CarePlanConflict,
171    /// Customer preference is unknown, so staff must verify consent before recommendation use.
172    UnknownCustomerPreference,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176/// Reason a recommendation is suppressed before staff/customer use.
177pub enum SuppressionReason {
178    /// Customer opted out, so the recommendation must be suppressed.
179    CustomerOptedOut,
180    /// Product is unavailable, so the recommendation must not promise or draft a sale.
181    InventoryUnavailable,
182}
183
184/// Customer-copy policy keeps retail recommendation text in draft/review state until approval and rejects unsafe claims.
185pub mod customer_copy {
186    use nutype::nutype;
187    use serde::{Deserialize, Serialize};
188
189    use crate::policy;
190
191    #[nutype(
192        sanitize(trim),
193        validate(not_empty, len_char_max = 500),
194        derive(
195            Debug,
196            Clone,
197            PartialEq,
198            Eq,
199            PartialOrd,
200            Ord,
201            Hash,
202            Serialize,
203            Deserialize
204        )
205    )]
206    pub struct SafeCopy(String);
207
208    #[derive(Debug, Clone, Default)]
209    /// Evaluates customer-facing retail copy so recommendations stay draft-only until customer-message approval.
210    pub struct Policy;
211
212    impl Policy {
213        /// Rejects unsafe claim language and otherwise keeps copy behind the customer-message approval gate.
214        pub fn evaluate(&self, copy: &SafeCopy) -> Decision {
215            let normalized = copy.clone().into_inner().to_lowercase();
216            if ["treat", "diagnos", "cure", "prescrib", "medical"]
217                .iter()
218                .any(|term| normalized.contains(term))
219            {
220                Decision::Rejected {
221                    reason: RejectionReason::MedicalClaim,
222                    gate: policy::ReviewGate::CustomerMessageApproval,
223                }
224            } else {
225                Decision::DraftRequiresApproval {
226                    gate: policy::ReviewGate::CustomerMessageApproval,
227                }
228            }
229        }
230    }
231
232    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233    /// Customer-copy decision that either remains approval-gated or is rejected for rewrite.
234    pub enum Decision {
235        /// Customer-facing copy is only a draft until the customer-message approval gate is satisfied.
236        DraftRequiresApproval {
237            /// Approval gate that must be satisfied before recommendation or customer-copy workflow proceeds.
238            gate: policy::ReviewGate,
239        },
240        /// Customer-facing copy is rejected because it contains an unsafe claim or unsupported promise.
241        Rejected {
242            /// Rejection reason staff must address before this copy can be approved.
243            reason: RejectionReason,
244            /// Approval gate that must be satisfied before recommendation or customer-copy workflow proceeds.
245            gate: policy::ReviewGate,
246        },
247    }
248
249    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250    /// Customer-copy rejection reasons that explain what staff must rewrite before approval.
251    pub enum RejectionReason {
252        /// Copy suggests treatment, diagnosis, cure, prescribing, or medical benefit and cannot be sent as-is.
253        MedicalClaim,
254        /// Copy promises an outcome staff cannot verify, so it must be rewritten and approved before use.
255        UnsupportedPromise,
256    }
257}