Skip to main content

domain/grooming/
mod.rs

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