domain/daycare/package_opportunity.rs
1//! Daycare package-opportunity policy for review-gated membership/pass recommendations.
2//!
3//! ```
4//! use domain::{daycare, entities, policy};
5//! use uuid::Uuid;
6//!
7//! let evidence = daycare::package_opportunity::Evidence::builder()
8//! .customer_id(entities::CustomerId(Uuid::nil()))
9//! .pet_id(entities::PetId(Uuid::nil()))
10//! .attendance_visits(daycare::package_opportunity::AttendanceVisitCount::new(8))
11//! .eligibility(daycare::package_opportunity::CareEligibility::Cleared)
12//! .package_state(daycare::package_opportunity::PackageState::PayPerVisit)
13//! .payment_state(daycare::package_opportunity::PaymentState::Current)
14//! .build();
15//!
16//! assert_eq!(
17//! daycare::package_opportunity::Policy.classify(&evidence),
18//! daycare::package_opportunity::Decision::RecommendStaffReview {
19//! score: daycare::package_opportunity::OpportunityScore::Strong,
20//! gate: policy::ReviewGate::CustomerMessageApproval,
21//! },
22//! );
23//! ```
24
25use super::*;
26use crate::policy;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29/// Count of recent daycare visits used to score pass or membership opportunities.
30pub struct AttendanceVisitCount(u16);
31
32impl AttendanceVisitCount {
33 /// Creates an attendance visit count from prior daycare check-in history.
34 pub const fn new(value: u16) -> Self {
35 Self(value)
36 }
37
38 /// Returns the raw visit count for reporting, scoring, and serialization.
39 pub const fn get(self) -> u16 {
40 self.0
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45/// Care eligibility state used before recommending daycare packages.
46pub enum CareEligibility {
47 /// Care and safety gates are clear enough to consider package recommendations.
48 Cleared,
49 /// Safety or care review blocks sales recommendations until staff clear it.
50 BlockedBySafetyReview,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54/// Existing package coverage state for a customer and pet.
55pub enum PackageState {
56 /// Customer currently pays per visit and may benefit from a package recommendation.
57 PayPerVisit,
58 /// Existing package or membership already covers the daycare need.
59 AlreadyCovered,
60 /// Package coverage could not be mapped confidently, so recommendations should not assume need.
61 Unknown,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65/// Billing state used to suppress recommendations when collection or review is needed.
66pub enum PaymentState {
67 /// Payment status is current enough to allow staff-reviewed recommendations.
68 Current,
69 /// Billing state requires review before staff suggest another package or membership.
70 NeedsBillingReview,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Builder)]
74/// Source evidence used to classify daycare package or membership opportunities.
75pub struct Evidence {
76 /// Customer account that would receive the package recommendation.
77 pub customer_id: CustomerId,
78 /// Pet whose attendance history and care eligibility drive the recommendation.
79 pub pet_id: PetId,
80 /// Recent visit count used as the demand signal for package scoring.
81 pub attendance_visits: AttendanceVisitCount,
82 /// Care/safety eligibility that can suppress recommendations.
83 pub eligibility: CareEligibility,
84 /// Existing package coverage used to avoid duplicate sales prompts.
85 pub package_state: PackageState,
86 /// Billing readiness used to suppress recommendations needing collection review.
87 pub payment_state: PaymentState,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91/// Package-opportunity decision staff may review before contacting a customer.
92pub enum Decision {
93 /// Recommendation is promising enough to show staff, but customer messaging remains approval-gated.
94 RecommendStaffReview {
95 /// Opportunity strength derived from attendance history.
96 score: OpportunityScore,
97 /// Review gate required before staff use or send a recommendation.
98 gate: policy::ReviewGate,
99 },
100 /// Recommendation is intentionally hidden because safety, care, or billing review comes first.
101 Suppressed {
102 /// Reason evidence does not allow a direct package recommendation.
103 reason: SuppressionReason,
104 /// Review gate required before staff use or send a recommendation.
105 gate: policy::ReviewGate,
106 },
107 /// Evidence does not justify a package recommendation.
108 NoOpportunity {
109 /// Reason evidence does not allow a direct package recommendation.
110 reason: NoOpportunityReason,
111 },
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115/// Strength of a daycare package opportunity from recent attendance evidence.
116pub enum OpportunityScore {
117 /// Attendance history suggests a possible package fit, but not enough for the strongest score.
118 Moderate,
119 /// Attendance history strongly suggests staff should review a package or membership offer.
120 Strong,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124/// Reasons daycare package recommendations are suppressed before staff review.
125pub enum SuppressionReason {
126 /// Care or behavior review must be handled before sales recommendations.
127 SafetyOrCareReviewRequired,
128 /// Billing issue must be handled before sales recommendations.
129 PaymentOrBillingReviewRequired,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133/// Reasons evidence does not indicate a new daycare package opportunity.
134pub enum NoOpportunityReason {
135 /// Existing package or membership already covers the daycare need.
136 AlreadyCovered,
137 /// Recent visits are too low to justify a package recommendation.
138 NotEnoughAttendanceHistory,
139}
140
141#[derive(Debug, Clone, Default)]
142/// Deterministic policy that scores daycare package opportunities from evidence.
143pub struct Policy;
144
145impl Policy {
146 /// Classifies package opportunity evidence into recommend, suppress, or no-opportunity outcomes.
147 pub fn classify(&self, evidence: &Evidence) -> Decision {
148 if matches!(evidence.eligibility, CareEligibility::BlockedBySafetyReview) {
149 return Decision::Suppressed {
150 reason: SuppressionReason::SafetyOrCareReviewRequired,
151 gate: policy::ReviewGate::BehaviorReview,
152 };
153 }
154 if matches!(evidence.payment_state, PaymentState::NeedsBillingReview) {
155 return Decision::Suppressed {
156 reason: SuppressionReason::PaymentOrBillingReviewRequired,
157 gate: policy::ReviewGate::RefundOrDepositException,
158 };
159 }
160 if matches!(evidence.package_state, PackageState::AlreadyCovered) {
161 return Decision::NoOpportunity {
162 reason: NoOpportunityReason::AlreadyCovered,
163 };
164 }
165 if evidence.attendance_visits.get() >= 8 {
166 Decision::RecommendStaffReview {
167 score: OpportunityScore::Strong,
168 gate: policy::ReviewGate::CustomerMessageApproval,
169 }
170 } else {
171 Decision::NoOpportunity {
172 reason: NoOpportunityReason::NotEnoughAttendanceHistory,
173 }
174 }
175 }
176}