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}