1use nutype::nutype;
10use serde::{Deserialize, Serialize};
11
12use crate::entities::{ServiceKind, Species};
13
14#[nutype(
16 sanitize(trim),
17 validate(not_empty, len_char_max = 120),
18 derive(
19 Debug,
20 Clone,
21 PartialEq,
22 Eq,
23 PartialOrd,
24 Ord,
25 Hash,
26 Serialize,
27 Deserialize
28 )
29)]
30pub struct Id(String);
31
32#[nutype(
34 sanitize(trim),
35 validate(not_empty, len_char_max = 80),
36 derive(
37 Debug,
38 Clone,
39 PartialEq,
40 Eq,
41 PartialOrd,
42 Ord,
43 Hash,
44 Serialize,
45 Deserialize
46 )
47)]
48pub struct VaccineName(String);
49
50#[nutype(
52 sanitize(trim),
53 validate(not_empty, len_char_max = 120),
54 derive(
55 Debug,
56 Clone,
57 PartialEq,
58 Eq,
59 PartialOrd,
60 Ord,
61 Hash,
62 Serialize,
63 Deserialize
64 )
65)]
66pub struct WorkflowName(String);
67
68pub mod automation {
70 use serde::{Deserialize, Serialize};
71
72 use super::WorkflowName;
73
74 pub mod rationale {
76 use nutype::nutype;
77
78 #[nutype(
79 sanitize(trim),
80 validate(not_empty, len_char_max = 400),
81 derive(
82 Debug,
83 Clone,
84 PartialEq,
85 Eq,
86 PartialOrd,
87 Ord,
88 Hash,
89 Serialize,
90 Deserialize
91 )
92 )]
93 pub struct Rationale(String);
94 }
95
96 pub use rationale::Rationale;
97
98 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99 pub enum Level {
101 SafeToAutomate,
103 DraftOnly,
105 InternalTaskOnly,
107 ManagerApprovalRequired,
109 NeverAutomate,
111 }
112
113 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114 pub struct Rule {
116 pub workflow: WorkflowName,
118 pub level: Level,
120 pub rationale: Rationale,
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct VaccineRequirement {
128 pub species: Species,
130 pub service: ServiceKind,
132 pub vaccines: Vec<VaccineName>,
134 pub source_must_be_licensed_vet: bool,
136}
137
138pub mod play {
140 pub use eligibility::{
141 ConservativePolicy, Decision, Eligibility, IneligibilityReason, Policy, Reason,
142 };
143
144 pub mod eligibility {
146 use serde::{Deserialize, Serialize};
147
148 use crate::entities::{Pet, ServiceKind, SpayNeuterStatus, Species};
149
150 use super::super::ReviewGate;
151
152 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153 pub struct Decision {
155 pub eligibility: Eligibility,
157 pub required_review: Option<ReviewGate>,
159 }
160
161 impl Decision {
162 pub fn eligible_for_group_play(&self) -> bool {
164 matches!(self.eligibility, Eligibility::Eligible(_))
165 }
166 }
167
168 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169 pub enum Eligibility {
171 Eligible(Reason),
173 Ineligible(IneligibilityReason),
175 }
176
177 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178 pub enum Reason {
180 NoConservativeHardStop,
182 }
183
184 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185 pub enum IneligibilityReason {
187 ServiceDoesNotRequireGroupPlay,
189 SpeciesReceivesIndividualPlay,
191 SpayNeuterStatusRequiresReview,
193 BehaviorFlagsRequireReview,
195 }
196
197 impl std::fmt::Display for IneligibilityReason {
198 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 let label = match self {
200 Self::ServiceDoesNotRequireGroupPlay => "service does not require group play",
201 Self::SpeciesReceivesIndividualPlay => "species receives individual play",
202 Self::SpayNeuterStatusRequiresReview => "spay/neuter status requires review",
203 Self::BehaviorFlagsRequireReview => "behavior flags require review",
204 };
205 formatter.write_str(label)
206 }
207 }
208
209 pub trait Policy {
211 fn decide(&self, pet: &Pet, service: &ServiceKind) -> Decision;
213 }
214
215 #[derive(Debug, Clone, Default)]
220 pub struct ConservativePolicy;
221
222 impl Policy for ConservativePolicy {
223 fn decide(&self, pet: &Pet, service: &ServiceKind) -> Decision {
224 if !matches!(service, ServiceKind::DayPlay | ServiceKind::Boarding) {
225 return Decision {
226 eligibility: Eligibility::Ineligible(
227 IneligibilityReason::ServiceDoesNotRequireGroupPlay,
228 ),
229 required_review: None,
230 };
231 }
232
233 if pet.species != Species::Dog {
234 return Decision {
235 eligibility: Eligibility::Ineligible(
236 IneligibilityReason::SpeciesReceivesIndividualPlay,
237 ),
238 required_review: None,
239 };
240 }
241
242 if matches!(
243 pet.spay_neuter_status,
244 SpayNeuterStatus::Intact | SpayNeuterStatus::Unknown
245 ) {
246 return Decision {
247 eligibility: Eligibility::Ineligible(
248 IneligibilityReason::SpayNeuterStatusRequiresReview,
249 ),
250 required_review: Some(ReviewGate::BehaviorReview),
251 };
252 }
253
254 if matches!(
255 pet.temperament.group_play_observation,
256 crate::temperament::GroupPlayObservation::StressedInGroupSetting
257 | crate::temperament::GroupPlayObservation::NeedsIntroAssessment
258 ) || matches!(
259 pet.temperament.rating,
260 crate::temperament::Rating::ReviewRequired
261 ) || pet.temperament.behavior_observations.iter().any(
262 crate::temperament::BehaviorObservation::indicates_behavior_review_evidence,
263 ) {
264 return Decision {
265 eligibility: Eligibility::Ineligible(
266 IneligibilityReason::BehaviorFlagsRequireReview,
267 ),
268 required_review: Some(ReviewGate::BehaviorReview),
269 };
270 }
271
272 Decision {
273 eligibility: Eligibility::Eligible(Reason::NoConservativeHardStop),
274 required_review: None,
275 }
276 }
277 }
278 }
279}
280
281pub mod denial {
283 use serde::{Deserialize, Serialize};
284
285 use super::play;
286
287 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288 pub enum Reason {
290 ManagerApprovalRequired,
292 MedicalDocumentReviewRequired,
294 BehaviorReviewRequired,
296 CustomerMessageApprovalRequired,
298 RefundOrDepositException,
300 PlayEligibility(play::IneligibilityReason),
302 }
303
304 impl std::fmt::Display for Reason {
305 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306 let label = match self {
307 Self::ManagerApprovalRequired => "manager approval required",
308 Self::MedicalDocumentReviewRequired => "medical document review required",
309 Self::BehaviorReviewRequired => "behavior review required",
310 Self::CustomerMessageApprovalRequired => "customer message approval required",
311 Self::RefundOrDepositException => "refund or deposit exception",
312 Self::PlayEligibility(reason) => {
313 return write!(formatter, "play eligibility denied: {reason}");
314 }
315 };
316 formatter.write_str(label)
317 }
318 }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub enum ReviewGate {
324 ManagerApproval,
326 MedicalDocumentReview,
328 BehaviorReview,
330 CustomerMessageApproval,
332 RefundOrDepositException,
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::entities::{self, CustomerId, Pet, PetId, SpayNeuterStatus, TemperamentProfile};
340 use crate::policy::play::Policy;
341 use crate::temperament::{BehaviorObservation, GroupPlayObservation, Rating};
342 use uuid::Uuid;
343
344 fn dog(spay_neuter_status: SpayNeuterStatus) -> Pet {
345 Pet {
346 id: PetId(Uuid::new_v4()),
347 customer_id: CustomerId(Uuid::new_v4()),
348 name: crate::pet::Name::try_new("Moose").expect("test pet name is valid"),
349 species: Species::Dog,
350 birth_date: None,
351 sex: None,
352 spay_neuter_status,
353 temperament: TemperamentProfile::default(),
354 care_profile: entities::CareProfile::default(),
355 }
356 }
357
358 #[test]
359 fn intact_dog_routes_away_from_group_play() {
360 let decision =
361 play::ConservativePolicy.decide(&dog(SpayNeuterStatus::Intact), &ServiceKind::DayPlay);
362 assert!(!decision.eligible_for_group_play());
363 assert_eq!(
364 decision.eligibility,
365 play::Eligibility::Ineligible(
366 play::IneligibilityReason::SpayNeuterStatusRequiresReview
367 )
368 );
369 assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
370 }
371
372 #[test]
373 fn neutered_dog_can_be_group_play_candidate() {
374 let decision = play::ConservativePolicy
375 .decide(&dog(SpayNeuterStatus::Neutered), &ServiceKind::DayPlay);
376 assert!(decision.eligible_for_group_play());
377 assert_eq!(
378 decision.eligibility,
379 play::Eligibility::Eligible(play::Reason::NoConservativeHardStop)
380 );
381 }
382
383 #[test]
384 fn bite_history_requires_behavior_review() {
385 let mut pet = dog(SpayNeuterStatus::Neutered);
386 pet.temperament
387 .behavior_observations
388 .push(BehaviorObservation::BiteHistory);
389
390 let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
391
392 assert!(!decision.eligible_for_group_play());
393 assert_eq!(
394 decision.eligibility,
395 play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
396 );
397 assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
398 }
399
400 #[test]
401 fn explicit_manager_review_flag_requires_behavior_review() {
402 let mut pet = dog(SpayNeuterStatus::Neutered);
403 pet.temperament
404 .behavior_observations
405 .push(BehaviorObservation::RequiresManagerReview);
406
407 let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
408
409 assert!(!decision.eligible_for_group_play());
410 assert_eq!(
411 decision.eligibility,
412 play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
413 );
414 assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
415 }
416
417 #[test]
418 fn staff_evaluation_observation_requires_behavior_review() {
419 let mut pet = dog(SpayNeuterStatus::Neutered);
420 pet.temperament.group_play_observation = GroupPlayObservation::NeedsIntroAssessment;
421
422 let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
423
424 assert!(!decision.eligible_for_group_play());
425 assert_eq!(
426 decision.eligibility,
427 play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
428 );
429 assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
430 }
431
432 #[test]
433 fn observed_group_stress_requires_behavior_review() {
434 let mut pet = dog(SpayNeuterStatus::Neutered);
435 pet.temperament.group_play_observation = GroupPlayObservation::StressedInGroupSetting;
436
437 let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
438
439 assert!(!decision.eligible_for_group_play());
440 assert_eq!(
441 decision.eligibility,
442 play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
443 );
444 assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
445 }
446
447 #[test]
448 fn comfortable_group_observation_has_no_conservative_hard_stop() {
449 let mut pet = dog(SpayNeuterStatus::Neutered);
450 pet.temperament.group_play_observation = GroupPlayObservation::ComfortableInObservedGroup;
451
452 let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
453
454 assert!(decision.eligible_for_group_play());
455 assert_eq!(
456 decision.eligibility,
457 play::Eligibility::Eligible(play::Reason::NoConservativeHardStop)
458 );
459 }
460
461 #[test]
462 fn play_eligibility_denial_reason_displays_group_play_context() {
463 let reason =
464 denial::Reason::PlayEligibility(play::IneligibilityReason::BehaviorFlagsRequireReview);
465
466 assert_eq!(
467 reason.to_string(),
468 "play eligibility denied: behavior flags require review"
469 );
470 }
471
472 #[test]
473 fn review_required_temperament_rating_requires_behavior_review() {
474 let mut pet = dog(SpayNeuterStatus::Neutered);
475 pet.temperament.rating = Rating::ReviewRequired;
476
477 let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
478
479 assert!(!decision.eligible_for_group_play());
480 assert_eq!(
481 decision.eligibility,
482 play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
483 );
484 assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
485 }
486}