Skip to main content

domain/grooming/
mod.rs

1//! Grooming service-line rules for pet-resort scheduling, no-show, rebooking, reminder, and review queues.
2//!
3//! Operators use this module to answer grooming queue questions without rereading notes by hand: how much groomer time a mini/full groom, bath, nail service, or coat/skin add-on should reserve; whether repeat no-show history requires a deposit or manager review; when a completed service should become a rebooking prompt; and whether a reminder draft is safe to prepare. The labor reduction is triage and evidence assembly, not unattended execution.
4//!
5//! Use it when the business question is "what grooming work can be prepared for staff or customer review, and what calendar, deposit, handling, or message approval still blocks live execution?" Next step: start with the location rules and `Service` for policy and request type, then follow `duration_estimate`, `no_show`, `rebooking`, `reminder`, or `calendar` depending on the queue you are trying to explain.
6//!
7//! The authoritative facts are the location rules, the requested `Service`, breed/coat facts on `EstimationRequest`, prior approved `history::ServiceHistoryEntry` records, pet/customer/location/staff identity from `domain::entities`, and shared `domain::policy::ReviewGate` approvals. Provider catalog names, adapter defaults, and AI suggestions must be promoted into these values or remain pending review evidence.
8//!
9//! This module must not book or move appointments, assign a live provider-calendar slot, send a customer message, charge or waive a deposit, or decide medical/handling safety on its own. `ReviewRequirement::calendar_execution_gate`, `no_show::Decision`, and `reminder::Plan::customer_message_gate` preserve the human review gates that protect pets, customers, groomers, and managers before app/storage/integration layers perform live work.
10
11use bon::Builder;
12use chrono::NaiveDate;
13use serde::{Deserialize, Deserializer, Serialize};
14
15use crate::entities::{CustomerId, LocationId, PetId, StaffId};
16
17macro_rules! positive_scalar {
18    ($name:ident, $primitive:ty, $error:ident, $message:literal) => {
19        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
20        /// Positive grooming quantity used where a zero-minute appointment or zero-length operational value would create impossible schedule math.
21        pub struct $name($primitive);
22
23        impl $name {
24            /// Rejects zero or unsupported grooming values before they affect groomer calendars, duration estimates, deposits, reminders, or rebooking prompts.
25            pub const fn try_new(value: $primitive) -> std::result::Result<Self, $error> {
26                if value == 0 {
27                    return Err($error::Zero);
28                }
29                Ok(Self(value))
30            }
31
32            /// Returns the grooming number used by scheduling, estimate, reminder, or rebooking calculations.
33            pub const fn get(self) -> $primitive {
34                self.0
35            }
36        }
37
38        impl<'de> Deserialize<'de> for $name {
39            fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
40            where
41                D: Deserializer<'de>,
42            {
43                Self::try_new(<$primitive>::deserialize(deserializer)?)
44                    .map_err(serde::de::Error::custom)
45            }
46        }
47
48        #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
49        /// Validation failures returned by grooming domain constructors.
50        pub enum $error {
51            #[error($message)]
52            /// Rejects zero where the pet-resort workflow requires a positive quantity.
53            Zero,
54        }
55    };
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59/// Grooming services and add-ons that drive groomer calendar load, checkout upsells, duration estimates, and follow-up reminders.
60pub enum Service {
61    /// Mini groom request that typically consumes less groomer time but still needs coat/history context.
62    MiniGroom,
63    /// Full groom request that drives the heaviest groomer labor estimate and style-history review.
64    FullGroom,
65    /// Bath offered before departure from boarding.
66    ExitBath,
67    /// Full bath appointment that may stand alone or attach to daycare/boarding checkout.
68    FullBath,
69    /// Premium bath that can justify product/style-note capture and higher checkout value.
70    PremiumBath,
71    /// Nail trim add-on that affects short-slot grooming capacity.
72    NailTrim,
73    /// Nail Dremel add-on that should respect pet handling notes and appointment timing.
74    NailDremel,
75    /// Ear-cleaning add-on whose care sensitivity may require staff review before customer claims.
76    EarCleaning,
77    /// Coat/skin product add-on that should remain a product recommendation unless care review approves stronger claims.
78    CoatSkinSpecificProduct,
79    /// First-time grooming offer used to convert new/lapsed guests without bypassing scheduling constraints.
80    FirstTimeGroomingOffer,
81}
82
83positive_scalar!(
84    AppointmentMinutes,
85    u16,
86    AppointmentMinutesError,
87    "grooming appointment estimate requires at least one minute"
88);
89
90/// Groomer-calendar policy for assigning grooming work without inventing availability.
91pub mod calendar {
92    use super::*;
93
94    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95    /// Groomer-assignment policy used to decide whether a request can draft directly or needs manager/groomer review.
96    pub enum Policy {
97        /// Any qualified groomer may take the appointment if the schedule system shows capacity.
98        AnyQualifiedGroomer,
99        /// A specific groomer is required because of guest history, owner request, or service complexity.
100        GroomerSpecific,
101        /// First-available assignment is allowed only with a manager override when ordinary matching cannot satisfy demand.
102        FirstAvailableWithManagerOverride,
103    }
104}
105/// Breed/coat inputs for converting pet profile facts into labor-time estimates.
106pub mod breed_coat {
107    use super::*;
108
109    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110    /// Breed and coat groupings used to estimate grooming labor time.
111    pub enum BreedCategory {
112        /// Short-coat category with lower expected grooming labor when no history indicates otherwise.
113        ShortCoat,
114        /// Double-coat category that may require extra drying/deshedding time.
115        DoubleCoat,
116        /// Doodle or similar coat category where matting/style history often changes the estimate.
117        Doodle,
118        /// Cat guest, using cat-specific policy and accommodation rules.
119        Cat,
120    }
121
122    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123    /// Coat condition signals that affect grooming time and review needs.
124    pub enum CoatCondition {
125        /// Maintained coat condition suitable for standard estimates.
126        Maintained,
127        /// Thick undercoat condition that increases labor estimate and may alter product recommendations.
128        ThickUndercoat,
129        /// Matted coat condition that requires groomer review before accepting a duration estimate.
130        Matted,
131    }
132
133    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134    /// Duration estimate input derived from breed and coat facts for groomer calendar planning.
135    pub struct TimeEstimate {
136        /// Breed/coat class used to translate pet profile data into groomer labor demand.
137        pub breed: BreedCategory,
138        /// Coat condition that can raise confidence risk or trigger groomer review.
139        pub coat: CoatCondition,
140        minutes: AppointmentMinutes,
141    }
142
143    impl TimeEstimate {
144        /// Creates this grooming value from already-checked resort workflow inputs.
145        pub const fn new(
146            breed: BreedCategory,
147            coat: CoatCondition,
148            minutes: AppointmentMinutes,
149        ) -> Self {
150            Self {
151                breed,
152                coat,
153                minutes,
154            }
155        }
156
157        /// Returns the minutes value used by grooming schedule/rebooking review.
158        pub const fn minutes(&self) -> AppointmentMinutes {
159            self.minutes
160        }
161    }
162}
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164/// Service-history retention requirement that protects rebooking quality and safe handling across visits.
165pub enum HistoryRequirement {
166    /// Preserve service notes so future estimates can cite source history rather than invent timing.
167    KeepServiceNotes,
168    /// Preserve style notes/photos so groomers can reproduce customer preferences at the next cadence.
169    KeepStyleNotesAndPhotos,
170    /// Preserve medical or handling notes and route sensitive interpretation through care review.
171    KeepMedicalHandlingNotes,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
175/// Grooming estimate request assembled from pet profile facts before staff propose any calendar change.
176pub struct EstimationRequest {
177    /// Pet receiving the grooming or care service.
178    pub pet_id: PetId,
179    /// Requested service that drives scheduling and labor estimates.
180    pub service: Service,
181    /// Breed/coat class used to translate pet profile data into groomer labor demand.
182    pub breed: breed_coat::BreedCategory,
183    /// Coat condition that can raise confidence risk or trigger groomer review.
184    pub coat: breed_coat::CoatCondition,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188/// Evidence basis that explains why a grooming duration was chosen for scheduling review.
189pub enum EstimateBasis {
190    /// Estimate came from the location breed/coat policy.
191    BreedCoatPolicy,
192    /// Estimate came from prior groomer history for this pet.
193    GroomerHistory,
194    /// Estimate fell back to a location default when stronger source facts were unavailable.
195    LocationDefault,
196    /// Estimate came from provider defaults and should not override local policy silently.
197    ProviderDefault,
198    /// Estimate was overridden by staff and should be auditable as a human-entered fact.
199    ManualStaffOverride,
200    /// Estimate was suggested by automation and must remain pending review before schedule use.
201    AiSuggestedPendingReview,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205/// Confidence level assigned to a grooming duration estimate.
206pub enum EstimateConfidence {
207    /// Estimate is reliable enough for normal scheduling.
208    High,
209    /// Estimate is usable but should be treated with moderate uncertainty.
210    Medium,
211    /// Estimate is uncertain and may require staff confirmation.
212    Low,
213    /// Estimate confidence is unknown and must be reviewed.
214    UnknownRequiresReview,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218/// Review lane that determines whether a grooming estimate may be used for calendar execution.
219pub enum ReviewRequirement {
220    /// No additional workflow gate is required.
221    None,
222    /// General staff review is required before this estimate becomes actionable.
223    StaffReview,
224    /// Groomer review is required because coat/history/service complexity affects labor time.
225    GroomerReview,
226    /// Manager review is required before accepting an exceptional estimate or schedule choice.
227    ManagerReview,
228    /// Care/medical-document review is required before acting on sensitive handling information.
229    CareReview,
230}
231
232impl ReviewRequirement {
233    /// Maps the grooming review lane to the workflow gate that must approve scheduling.
234    pub const fn calendar_execution_gate(self) -> Option<crate::policy::ReviewGate> {
235        match self {
236            Self::None => None,
237            Self::StaffReview | Self::GroomerReview | Self::ManagerReview => {
238                Some(crate::policy::ReviewGate::ManagerApproval)
239            }
240            Self::CareReview => Some(crate::policy::ReviewGate::MedicalDocumentReview),
241        }
242    }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246/// Grooming duration decision with evidence, confidence, and the review gate needed before calendar use.
247pub struct DurationEstimate {
248    minutes: AppointmentMinutes,
249    basis: EstimateBasis,
250    confidence: EstimateConfidence,
251    review: ReviewRequirement,
252}
253
254impl DurationEstimate {
255    const fn new(
256        minutes: AppointmentMinutes,
257        basis: EstimateBasis,
258        confidence: EstimateConfidence,
259        review: ReviewRequirement,
260    ) -> Self {
261        Self {
262            minutes,
263            basis,
264            confidence,
265            review,
266        }
267    }
268
269    /// Returns the minutes value used by grooming schedule/rebooking review.
270    pub const fn minutes(&self) -> AppointmentMinutes {
271        self.minutes
272    }
273
274    /// Returns the basis value used by grooming schedule/rebooking review.
275    pub const fn basis(&self) -> EstimateBasis {
276        self.basis
277    }
278
279    /// Returns the confidence value used by grooming schedule/rebooking review.
280    pub const fn confidence(&self) -> EstimateConfidence {
281        self.confidence
282    }
283
284    /// Returns the review value used by grooming schedule/rebooking review.
285    pub const fn review(&self) -> ReviewRequirement {
286        self.review
287    }
288
289    /// Maps the grooming review lane to the workflow gate that must approve scheduling.
290    pub const fn calendar_execution_gate(&self) -> Option<crate::policy::ReviewGate> {
291        self.review.calendar_execution_gate()
292    }
293}
294
295#[derive(Debug, Clone, Default)]
296/// Policy object that chooses a grooming duration from pet history first, then location breed/coat defaults.
297pub struct EstimationPolicy;
298
299impl EstimationPolicy {
300    /// Estimates appointment minutes from source history or local policy defaults and records any required review gate.
301    pub fn estimate(
302        &self,
303        request: EstimationRequest,
304        history: &[history::ServiceHistoryEntry],
305        contract: &Contract,
306    ) -> DurationEstimate {
307        if let Some(entry) = history
308            .iter()
309            .rev()
310            .find(|entry| entry.pet_id == request.pet_id && entry.duration().is_some())
311        {
312            return DurationEstimate::new(
313                entry.duration().expect("checked above"),
314                EstimateBasis::GroomerHistory,
315                EstimateConfidence::Medium,
316                if entry.requires_review() {
317                    ReviewRequirement::GroomerReview
318                } else {
319                    ReviewRequirement::None
320                },
321            );
322        }
323
324        let minutes = contract
325            .time_estimates
326            .iter()
327            .find(|estimate| estimate.breed == request.breed && estimate.coat == request.coat)
328            .or_else(|| {
329                contract
330                    .time_estimates
331                    .iter()
332                    .find(|estimate| estimate.breed == request.breed)
333            })
334            .map(breed_coat::TimeEstimate::minutes)
335            .unwrap_or_else(|| {
336                AppointmentMinutes::try_new(60).expect("default estimate is positive")
337            });
338
339        let review = match request.coat {
340            breed_coat::CoatCondition::Matted => ReviewRequirement::GroomerReview,
341            breed_coat::CoatCondition::Maintained | breed_coat::CoatCondition::ThickUndercoat => {
342                ReviewRequirement::None
343            }
344        };
345        let confidence = if matches!(review, ReviewRequirement::None) {
346            EstimateConfidence::High
347        } else {
348            EstimateConfidence::Medium
349        };
350
351        DurationEstimate::new(minutes, EstimateBasis::BreedCoatPolicy, confidence, review)
352    }
353}
354
355/// No-show and late-cancel policy for protecting groomer capacity and rebooking decisions.
356pub mod no_show {
357    use super::*;
358
359    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
360    /// Rebooking rule that tells staff whether history only, deposit review, or manager review applies.
361    pub enum Rule {
362        /// Staff can see the note history only grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
363        NoteHistoryOnly,
364        /// Staff can see the require deposit for rebooking grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
365        RequireDepositForRebooking,
366        /// Staff can see the manager review before rebooking grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
367        ManagerReviewBeforeRebooking,
368    }
369
370    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
371    /// No-show count considered during grooming deposit and rebooking review.
372    pub struct Count(u16);
373
374    impl Count {
375        /// Rejects zero or unsupported grooming values before they affect groomer calendars, duration estimates, deposits, reminders, or rebooking prompts.
376        pub const fn try_new(value: u16) -> std::result::Result<Self, std::convert::Infallible> {
377            Ok(Self(value))
378        }
379
380        /// Returns the grooming number used by scheduling, estimate, reminder, or rebooking calculations.
381        pub const fn get(self) -> u16 {
382            self.0
383        }
384    }
385
386    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
387    /// Late-cancel count considered with no-shows during grooming rebooking review.
388    pub struct LateCancelCount(u16);
389
390    impl LateCancelCount {
391        /// Rejects zero or unsupported grooming values before they affect groomer calendars, duration estimates, deposits, reminders, or rebooking prompts.
392        pub const fn try_new(value: u16) -> std::result::Result<Self, std::convert::Infallible> {
393            Ok(Self(value))
394        }
395
396        /// Returns the grooming number used by scheduling, estimate, reminder, or rebooking calculations.
397        pub const fn get(self) -> u16 {
398            self.0
399        }
400    }
401
402    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
403    /// Repeat grooming history staff review before clearing a rebooking path.
404    pub struct History {
405        /// No shows from source or staff evidence used during grooming schedule/rebooking review; it does not authorize live changes by itself.
406        pub no_shows: Count,
407        /// Late cancels from source or staff evidence used during grooming schedule/rebooking review; it does not authorize live changes by itself.
408        pub late_cancels: LateCancelCount,
409    }
410
411    impl History {
412        /// Creates this grooming value from already-checked resort workflow inputs.
413        pub const fn new(no_shows: Count, late_cancels: LateCancelCount) -> Self {
414            Self {
415                no_shows,
416                late_cancels,
417            }
418        }
419
420        /// Returns the repeat behavior count value used by grooming schedule/rebooking review.
421        pub const fn repeat_behavior_count(&self) -> u16 {
422            self.no_shows.get().saturating_add(self.late_cancels.get())
423        }
424    }
425
426    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427    /// Grooming rebooking outcome that tells staff whether to clear, collect a deposit, or seek manager review.
428    pub enum Decision {
429        /// Staff can see the clear to rebook grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
430        ClearToRebook,
431        /// Review gate that must clear before this grooming decision can trigger a live schedule, deposit, or message action.
432        DepositRequired {
433            /// Approval gate staff must clear before acting on this variant.
434            gate: crate::policy::ReviewGate,
435        },
436        /// Review gate that must clear before this grooming decision can trigger a live schedule, deposit, or message action.
437        ManagerReviewRequired {
438            /// Approval gate staff must clear before acting on this variant.
439            gate: crate::policy::ReviewGate,
440        },
441    }
442
443    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444    /// Grooming rebooking evaluation packet tying customer, pet, and repeat-history facts together.
445    pub struct Evaluation {
446        /// Customer whose grooming reminder, deposit review, or rebooking packet is being prepared.
447        pub customer_id: CustomerId,
448        /// Pet receiving the grooming or care service.
449        pub pet_id: PetId,
450        /// No-show and late-cancel history staff review before choosing a rebooking path.
451        pub history: History,
452    }
453
454    #[derive(Debug, Clone)]
455    /// Grooming policy object that turns local rules into staff review decisions.
456    pub struct Policy {
457        rule: Rule,
458    }
459
460    impl Policy {
461        /// Creates this grooming value from already-checked resort workflow inputs.
462        pub const fn new(rule: Rule) -> Self {
463            Self { rule }
464        }
465
466        /// Evaluates grooming source facts into a rebooking or review decision.
467        pub fn evaluate(
468            &self,
469            customer_id: CustomerId,
470            pet_id: PetId,
471            history: History,
472        ) -> Decision {
473            let _evaluation = Evaluation {
474                customer_id,
475                pet_id,
476                history,
477            };
478            match self.rule {
479                Rule::NoteHistoryOnly => Decision::ClearToRebook,
480                Rule::RequireDepositForRebooking if history.repeat_behavior_count() > 0 => {
481                    Decision::DepositRequired {
482                        gate: crate::policy::ReviewGate::RefundOrDepositException,
483                    }
484                }
485                Rule::RequireDepositForRebooking => Decision::ClearToRebook,
486                Rule::ManagerReviewBeforeRebooking => Decision::ManagerReviewRequired {
487                    gate: crate::policy::ReviewGate::ManagerApproval,
488                },
489            }
490        }
491    }
492}
493
494/// History workflow gate for the grooming schedule, estimate, history, rebooking, reminder, or review workflow.
495pub mod history {
496    use super::*;
497
498    /// Style note workflow gate for the grooming schedule, estimate, history, rebooking, reminder, or review workflow.
499    pub mod style_note {
500        use nutype::nutype;
501
502        #[nutype(
503            sanitize(trim),
504            validate(not_empty, len_char_max = 500),
505            derive(
506                Debug,
507                Clone,
508                PartialEq,
509                Eq,
510                PartialOrd,
511                Ord,
512                Hash,
513                Serialize,
514                Deserialize
515            )
516        )]
517        pub struct StyleNote(String);
518    }
519
520    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521    /// Care references that keep product, medical, or handling notes visible to groomer review.
522    pub enum CareReference {
523        /// Staff can see the sensitive skin product grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
524        SensitiveSkinProduct,
525        /// Staff can see the medicated product requires review grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
526        MedicatedProductRequiresReview,
527        /// Staff can see the handling or medical concern grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
528        HandlingOrMedicalConcern,
529    }
530
531    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
532    /// Service outcome recorded for grooming history, estimates, and rebooking prompts.
533    pub enum ServiceOutcome {
534        /// Staff can see the completed grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
535        Completed,
536        /// Staff can see the no show grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
537        NoShow,
538        /// Staff can see the late cancelled grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
539        LateCancelled,
540        /// Staff can see the needs follow up grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
541        NeedsFollowUp,
542    }
543
544    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
545    /// Approval state controlling whether grooming history can support future estimates or rebooking.
546    pub enum ApprovalState {
547        /// Staff can see the draft grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
548        Draft,
549        /// Review gate that must clear before this grooming decision can trigger a live schedule, deposit, or message action.
550        ReviewRequired {
551            /// Approval gate staff must clear before acting on this variant.
552            gate: crate::policy::ReviewGate,
553        },
554        /// Groomer who approved the history entry for later estimate, care, or rebooking review.
555        ApprovedByGroomer {
556            /// Groomer who approved this grooming history or review state.
557            groomer_id: StaffId,
558        },
559        /// Review gate that must clear before this grooming decision can trigger a live schedule, deposit, or message action.
560        Rejected {
561            /// Approval gate staff must clear before acting on this variant.
562            gate: crate::policy::ReviewGate,
563        },
564    }
565
566    impl ApprovalState {
567        /// Reports whether care-team review is needed before proceeding.
568        pub const fn requires_review(&self) -> bool {
569            matches!(self, Self::Draft | Self::ReviewRequired { .. })
570        }
571    }
572
573    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
574    /// Grooming history entry used for future duration estimates, style continuity, care review, and rebooking.
575    pub struct ServiceHistoryEntry {
576        /// Pet receiving the grooming or care service.
577        pub pet_id: PetId,
578        /// Resort location whose grooming history should be considered for this pet.
579        pub location_id: LocationId,
580        /// Requested service that drives scheduling and labor estimates.
581        pub service: super::Service,
582        /// Date the grooming outcome was completed or recorded for cadence and history review.
583        pub completed_on: NaiveDate,
584        /// Service outcome used to decide whether future estimates, rebooking prompts, or follow-up are appropriate.
585        pub outcome: ServiceOutcome,
586        /// Approval state that keeps sensitive grooming history out of automation until review clears.
587        pub approval: ApprovalState,
588        #[builder(default)]
589        style_notes: Vec<style_note::StyleNote>,
590        #[builder(default)]
591        care_refs: Vec<CareReference>,
592        duration: Option<AppointmentMinutes>,
593    }
594
595    impl ServiceHistoryEntry {
596        /// Returns the style notes value used by grooming schedule/rebooking review.
597        pub fn style_notes(&self) -> &[style_note::StyleNote] {
598            &self.style_notes
599        }
600
601        /// Returns the care refs value used by grooming schedule/rebooking review.
602        pub fn care_refs(&self) -> &[CareReference] {
603            &self.care_refs
604        }
605
606        /// Returns the duration value used by grooming schedule/rebooking review.
607        pub const fn duration(&self) -> Option<AppointmentMinutes> {
608            self.duration
609        }
610
611        /// Reports whether care-team review is needed before proceeding.
612        pub const fn requires_review(&self) -> bool {
613            self.approval.requires_review() || !self.care_refs.is_empty()
614        }
615    }
616}
617
618/// Rebooking cadence policy for identifying due, overdue, or history-insufficient grooming follow-up.
619pub mod rebooking {
620    use super::*;
621
622    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
623    /// Grooming cadence in weeks for due/overdue rebooking prompts.
624    pub struct CadenceWeeks(u8);
625
626    impl CadenceWeeks {
627        /// Rejects zero or unsupported grooming values before they affect groomer calendars, duration estimates, deposits, reminders, or rebooking prompts.
628        pub const fn try_new(value: u8) -> std::result::Result<Self, CadenceWeeksError> {
629            if value == 0 {
630                return Err(CadenceWeeksError::ZeroWeeks);
631            }
632            Ok(Self(value))
633        }
634
635        /// Returns the grooming number used by scheduling, estimate, reminder, or rebooking calculations.
636        pub const fn get(self) -> u8 {
637            self.0
638        }
639    }
640
641    impl<'de> Deserialize<'de> for CadenceWeeks {
642        fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
643        where
644            D: Deserializer<'de>,
645        {
646            Self::try_new(u8::deserialize(deserializer)?).map_err(serde::de::Error::custom)
647        }
648    }
649
650    #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
651    /// Cadence validation error for rebooking prompts that cannot use zero weeks.
652    pub enum CadenceWeeksError {
653        #[error("grooming cadence requires at least one week")]
654        /// Staff can see the zero weeks grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
655        ZeroWeeks,
656    }
657
658    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
659    /// Ordinary grooming cadence band used when staff expect repeat appointments every few weeks.
660    pub struct OrdinaryCadenceWeeks(u8);
661
662    impl OrdinaryCadenceWeeks {
663        /// Rejects zero or unsupported grooming values before they affect groomer calendars, duration estimates, deposits, reminders, or rebooking prompts.
664        pub const fn try_new(value: u8) -> std::result::Result<Self, OrdinaryCadenceWeeksError> {
665            if value < 2 || value > 8 {
666                return Err(OrdinaryCadenceWeeksError::OutsideOrdinaryGroomingBand);
667            }
668            Ok(Self(value))
669        }
670
671        /// Returns the grooming number used by scheduling, estimate, reminder, or rebooking calculations.
672        pub const fn get(self) -> u8 {
673            self.0
674        }
675    }
676
677    impl<'de> Deserialize<'de> for OrdinaryCadenceWeeks {
678        fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
679        where
680            D: Deserializer<'de>,
681        {
682            Self::try_new(u8::deserialize(deserializer)?).map_err(serde::de::Error::custom)
683        }
684    }
685
686    #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
687    /// Cadence validation error for values outside the ordinary grooming rebooking band.
688    pub enum OrdinaryCadenceWeeksError {
689        #[error("ordinary grooming rebooking cadence must be between 2 and 8 weeks")]
690        /// Staff can see the outside ordinary grooming band grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
691        OutsideOrdinaryGroomingBand,
692    }
693
694    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
695    /// Rebooking cadence source used for due-date prompts and groomer-recommended follow-up.
696    pub enum Cadence {
697        /// Staff can see the every weeks grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
698        EveryWeeks(CadenceWeeks),
699        /// Staff can see the as needed grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
700        AsNeeded,
701        /// Staff can see the groomer recommended grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
702        GroomerRecommended,
703        /// Provider role or status could not be mapped confidently.
704        Unknown,
705    }
706
707    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
708    /// Normalized reservation states observed during source-data ingestion.
709    pub enum Status {
710        /// Staff can see the due later grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
711        DueLater,
712        /// Staff can see the due now grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
713        DueNow,
714        /// Staff can see the overdue grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
715        Overdue,
716        /// Staff can see the needs groomer recommendation grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
717        NeedsGroomerRecommendation,
718    }
719
720    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
721    /// Reason explaining why a grooming rebooking prompt is due or needs groomer input.
722    pub enum Rationale {
723        /// Staff can see the last completed service cadence grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
724        LastCompletedServiceCadence,
725        /// Staff can see the no completed history grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
726        NoCompletedHistory,
727        /// Staff can see the groomer recommended cadence required grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
728        GroomerRecommendedCadenceRequired,
729    }
730
731    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
732    /// Grooming rebooking recommendation staff can review before drafting customer follow-up.
733    pub struct Recommendation {
734        /// Pet receiving the grooming or care service.
735        pub pet_id: PetId,
736        /// Date when the next grooming reminder or rebooking prompt becomes due.
737        pub due_on: Option<NaiveDate>,
738        /// Rebooking status staff use to decide whether to prompt, wait, or request groomer input.
739        pub status: Status,
740        /// Reason explaining why the rebooking recommendation is due, overdue, or blocked for groomer input.
741        pub rationale: Rationale,
742    }
743
744    #[derive(Debug, Clone, Default)]
745    /// Grooming policy object that turns local rules into staff review decisions.
746    pub struct Policy;
747
748    impl Policy {
749        /// Returns the recommend from history value used by grooming schedule/rebooking review.
750        pub fn recommend_from_history(
751            &self,
752            pet_id: PetId,
753            history: &[history::ServiceHistoryEntry],
754            cadence: Cadence,
755            today: NaiveDate,
756        ) -> Recommendation {
757            let Some(last_completed) = history
758                .iter()
759                .filter(|entry| entry.pet_id == pet_id)
760                .filter(|entry| matches!(entry.outcome, history::ServiceOutcome::Completed))
761                .max_by_key(|entry| entry.completed_on)
762            else {
763                return Recommendation {
764                    pet_id,
765                    due_on: None,
766                    status: Status::NeedsGroomerRecommendation,
767                    rationale: Rationale::NoCompletedHistory,
768                };
769            };
770
771            let Cadence::EveryWeeks(weeks) = cadence else {
772                return Recommendation {
773                    pet_id,
774                    due_on: None,
775                    status: Status::NeedsGroomerRecommendation,
776                    rationale: Rationale::GroomerRecommendedCadenceRequired,
777                };
778            };
779
780            let due_on = last_completed
781                .completed_on
782                .checked_add_days(chrono::Days::new(u64::from(weeks.get()) * 7))
783                .expect("bounded grooming cadence should fit chrono date range");
784            let status = if today > due_on {
785                Status::Overdue
786            } else if today == due_on {
787                Status::DueNow
788            } else {
789                Status::DueLater
790            };
791
792            Recommendation {
793                pet_id,
794                due_on: Some(due_on),
795                status,
796                rationale: Rationale::LastCompletedServiceCadence,
797            }
798        }
799    }
800}
801
802/// Reminder policy for drafting appointment confirmations, prep instructions, and cadence winback messages.
803pub mod reminder {
804    use super::*;
805
806    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
807    /// Rebooking rule that tells staff whether history only, deposit review, or manager review applies.
808    pub enum Rule {
809        /// Staff can see the one week before grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
810        OneWeekBefore,
811        /// Staff can see the forty eight hours before grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
812        FortyEightHoursBefore,
813        /// Staff can see the morning of grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
814        MorningOf,
815    }
816
817    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
818    /// Reminder purpose for grooming confirmations, prep instructions, and cadence follow-up drafts.
819    pub enum Kind {
820        /// Staff can see the appointment confirmation grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
821        AppointmentConfirmation,
822        /// Staff can see the prep instructions grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
823        PrepInstructions,
824        /// Staff can see the morning of grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
825        MorningOf,
826        /// Staff can see the rebooking due grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
827        RebookingDue,
828        /// Staff can see the lapsed cadence winback grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
829        LapsedCadenceWinback,
830    }
831
832    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
833    /// Customer-message consent state used before grooming reminder drafts proceed.
834    pub enum Consent {
835        /// Staff can see the granted grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
836        Granted,
837        /// Staff can see the not granted grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
838        NotGranted,
839    }
840
841    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
842    /// Customer-message send status for grooming reminder plans.
843    pub enum SendBoundary {
844        /// Staff can see the draft requires approval grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
845        DraftRequiresApproval,
846        /// Staff can see the ready for approved send grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
847        ReadyForApprovedSend,
848        /// Staff can see the suppressed until consent grooming state during grooming scheduling, estimate, history, rebooking, reminder, or review work.
849        SuppressedUntilConsent,
850    }
851
852    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853    /// Grooming reminder plan that separates message purpose from send approval.
854    pub struct Plan {
855        /// Customer whose grooming reminder, deposit review, or rebooking packet is being prepared.
856        pub customer_id: CustomerId,
857        /// Reminder purpose that controls whether the draft is confirmation, prep, same-day, or cadence follow-up copy.
858        pub kind: Kind,
859        boundary: SendBoundary,
860    }
861
862    impl Plan {
863        /// Returns the customer-message send gate value used by grooming schedule/rebooking review.
864        pub const fn send_boundary(&self) -> SendBoundary {
865            self.boundary
866        }
867
868        /// Returns the customer-message approval gate required before this grooming reminder is sent.
869        pub const fn customer_message_gate(&self) -> Option<crate::policy::ReviewGate> {
870            match self.boundary {
871                SendBoundary::DraftRequiresApproval => {
872                    Some(crate::policy::ReviewGate::CustomerMessageApproval)
873                }
874                SendBoundary::ReadyForApprovedSend | SendBoundary::SuppressedUntilConsent => None,
875            }
876        }
877    }
878
879    #[derive(Debug, Clone, Default)]
880    /// Grooming policy object that turns local rules into staff review decisions.
881    pub struct Policy;
882
883    impl Policy {
884        /// Builds a grooming reminder plan from customer consent and reminder purpose.
885        pub const fn plan(&self, customer_id: CustomerId, kind: Kind, consent: Consent) -> Plan {
886            let boundary = match consent {
887                Consent::Granted => SendBoundary::DraftRequiresApproval,
888                Consent::NotGranted => SendBoundary::SuppressedUntilConsent,
889            };
890            Plan {
891                customer_id,
892                kind,
893                boundary,
894            }
895        }
896    }
897}
898
899#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
900/// Location grooming ruleset tying calendar assignment, estimate policy, no-show rules, rebooking cadence, reminders, and history retention together.
901pub struct Contract {
902    /// Calendar-assignment rule staff honor before drafting or reviewing grooming work.
903    pub calendar: calendar::Policy,
904    #[builder(default)]
905    /// Breed/coat duration estimates staff use for groomer-calendar planning.
906    pub time_estimates: Vec<breed_coat::TimeEstimate>,
907    /// No-show rule that controls whether repeat history creates deposit or manager review.
908    pub no_show: no_show::Rule,
909    /// Rebooking cadence staff use when preparing due or overdue grooming prompts.
910    pub rebooking: rebooking::Cadence,
911    #[builder(default)]
912    /// Reminder timing options that can be drafted only through customer-message review.
913    pub reminders: Vec<reminder::Rule>,
914    /// No-show and late-cancel history staff review before choosing a rebooking path.
915    pub history: HistoryRequirement,
916}
917
918impl Contract {
919    /// Reports whether prior no-shows should trigger a deposit or manager review before rebooking.
920    pub fn requires_deposit_after_no_show(&self) -> bool {
921        matches!(
922            self.no_show,
923            no_show::Rule::RequireDepositForRebooking | no_show::Rule::ManagerReviewBeforeRebooking
924        )
925    }
926    /// Builds representative PetSuites-style grooming rules for docs/tests without claiming they are live policy.
927    pub fn standard_petsuites() -> Self {
928        Self::builder()
929            .calendar(calendar::Policy::GroomerSpecific)
930            .time_estimates(vec![breed_coat::TimeEstimate::new(
931                breed_coat::BreedCategory::Doodle,
932                breed_coat::CoatCondition::Matted,
933                AppointmentMinutes::try_new(180).unwrap(),
934            )])
935            .no_show(no_show::Rule::RequireDepositForRebooking)
936            .rebooking(rebooking::Cadence::EveryWeeks(
937                rebooking::CadenceWeeks::try_new(6).unwrap(),
938            ))
939            .reminders(vec![
940                reminder::Rule::FortyEightHoursBefore,
941                reminder::Rule::MorningOf,
942            ])
943            .history(HistoryRequirement::KeepStyleNotesAndPhotos)
944            .build()
945    }
946}
947
948/// Appointment-owned public vocabulary for grooming service requests.
949pub mod appointment {
950    pub use super::{EstimationRequest as Request, Service};
951}
952
953/// Duration-estimate decision vocabulary.
954pub mod duration_estimate {
955    pub use super::{
956        AppointmentMinutes, AppointmentMinutesError, DurationEstimate, EstimateBasis,
957        EstimateConfidence, EstimationPolicy as Policy, ReviewRequirement,
958    };
959}