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}