1use bon::Builder;
57use serde::{Deserialize, Deserializer, Serialize};
58
59use crate::service_line::{boarding, daycare, grooming, retail, training};
60use domain::operations::{pet_resort, service_core};
61
62pub use crate::service_line::{
63 grooming::StoredCadenceWeeksError,
64 training::{
65 StoredProgramDurationWeeks as StoredTrainingProgramDurationWeeks,
66 StoredProgramDurationWeeksError as StoredTrainingProgramDurationWeeksError,
67 },
68};
69
70pub type Result<T> = std::result::Result<T, Error>;
72
73#[derive(Debug, thiserror::Error)]
74pub enum Error {
76 #[error("storage codec error")]
77 Codec(#[from] CodecError),
79 #[error("{record:?} storage shape mismatch: {reason:?}")]
80 StorageShapeMismatch {
82 record: RecordKind,
84 reason: ShapeMismatchReason,
86 },
87 #[error("domain value rejected storage field {field:?}: {reason}")]
88 InvalidDomainValue {
90 field: StorageField,
92 reason: String,
94 },
95}
96
97#[derive(Debug, thiserror::Error)]
98pub enum CodecError {
100 #[error("failed to decode json: {source}")]
101 JsonDecode {
103 source: serde_json::Error,
105 },
106 #[error("failed to encode json: {source}")]
107 JsonEncode {
109 source: serde_json::Error,
111 },
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum RecordKind {
117 PetResortPortfolio,
119 ServiceOffering,
121 CoreServiceContracts,
123 DataQualityHygieneOutcome,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum ShapeMismatchReason {
130 RequiredFieldMissing,
132 FieldBelongsToDifferentVariant,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum StorageField {
139 ResortCount,
141 BrandName,
143 GroomingCadenceWeeks,
145 TrainingProgramDurationWeeks,
147 ManagerDailyBriefLaborMinutes,
149 DataQualityHygieneLaborMinutes,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
154pub struct StoredSourceRecordRef {
156 pub system: String,
158 pub record_type: String,
160 pub record_id: String,
162 pub observed_at: String,
164 pub adapter_version: String,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum ManagerDailyBriefOutcomeCode {
172 Completed,
174 Deferred,
176 SuppressedByManager,
178 SourceFactWasWrong,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum ManagerDailyBriefPersonaCode {
186 GeneralManager,
188 AssistantGeneralManager,
190 FrontDeskLead,
192 FrontDeskAgent,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum ManagerDailyBriefActionKindCode {
200 ReviewDemandAgainstStaffingPlan,
202 ResolveCheckoutException,
204 ApproveRetentionFollowUpDraft,
206 InvestigateSourceDataQualityIssue,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct ManagerDailyBriefReportingGroup {
213 pub location_id: String,
215 pub operating_day: String,
217 pub action_kind: ManagerDailyBriefActionKindCode,
219 pub owner_persona: ManagerDailyBriefPersonaCode,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
224#[serde(transparent)]
225pub struct StoredManagerDailyBriefLaborMinutes(u16);
227
228impl StoredManagerDailyBriefLaborMinutes {
229 pub fn try_new(value: u16) -> Result<Self> {
231 if value == 0 {
232 return Err(Error::InvalidDomainValue {
233 field: StorageField::ManagerDailyBriefLaborMinutes,
234 reason: "must be greater than zero".to_owned(),
235 });
236 }
237
238 Ok(Self(value))
239 }
240
241 pub const fn get(self) -> u16 {
243 self.0
244 }
245}
246
247impl<'de> Deserialize<'de> for StoredManagerDailyBriefLaborMinutes {
248 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
249 where
250 D: Deserializer<'de>,
251 {
252 let value = u16::deserialize(deserializer)?;
253 Self::try_new(value).map_err(serde::de::Error::custom)
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
258pub struct ManagerDailyBriefOutcomeRecord {
260 pub action_id: String,
262 pub outcome: ManagerDailyBriefOutcomeCode,
264 pub before_minutes: StoredManagerDailyBriefLaborMinutes,
266 pub actual_minutes: StoredManagerDailyBriefLaborMinutes,
268 pub actor_id: String,
270 pub actor_persona: ManagerDailyBriefPersonaCode,
272 pub feedback: String,
274 #[builder(default)]
275 pub source_refs: Vec<StoredSourceRecordRef>,
277 pub recorded_at: String,
279 pub correlation_id: String,
281 pub location_id: String,
283 pub operating_day: String,
285 pub action_kind: ManagerDailyBriefActionKindCode,
287 pub owner_persona: ManagerDailyBriefPersonaCode,
289 pub estimated_minutes_saved: u16,
291}
292
293impl ManagerDailyBriefOutcomeRecord {
294 pub fn decode_json(raw: &str) -> Result<Self> {
296 serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
297 }
298
299 pub fn encode_json(&self) -> Result<String> {
301 serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
302 }
303
304 pub const fn actual_minutes_saved(&self) -> u16 {
306 self.before_minutes
307 .get()
308 .saturating_sub(self.actual_minutes.get())
309 }
310
311 pub fn reporting_group(&self) -> ManagerDailyBriefReportingGroup {
313 ManagerDailyBriefReportingGroup {
314 location_id: self.location_id.clone(),
315 operating_day: self.operating_day.clone(),
316 action_kind: self.action_kind,
317 owner_persona: self.owner_persona,
318 }
319 }
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum DataQualityHygieneOutcomeCode {
326 Completed,
328 Deferred,
330 SuppressedByManager,
332 SourceFactWasWrong,
334 NotActionable,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum DataQualityHygienePersonaCode {
342 GeneralManager,
344 AssistantGeneralManager,
346 FrontDeskLead,
348 FrontDeskAgent,
350 RegionalOperator,
352 OperationsAnalyst,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "snake_case")]
358pub enum DataQualityHygieneActionKindCode {
360 InvestigateMissingSourceEvidence,
362 ReconcileDuplicateCustomerOrPetCandidate,
364 CompleteMissingPetOrCustomerProfileFields,
366 ReviewStaleVaccinationSourceFreshness,
368 NormalizeAmbiguousServiceLineNaming,
370 ReviewCheckoutOrUnclosedReservationEvidence,
372 EscalateSensitiveOrQuarantinedPayload,
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub enum DataQualityResolutionStatusCode {
380 Open,
382 Acknowledged,
384 Ignored,
386 Repaired,
388 Superseded,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393pub struct DataQualityHygieneReportingGroup {
395 pub location_id: String,
397 pub operating_day: String,
399 pub action_kind: DataQualityHygieneActionKindCode,
401 pub owner_persona: DataQualityHygienePersonaCode,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
406#[serde(transparent)]
407pub struct StoredDataQualityHygieneLaborMinutes(u16);
409
410impl StoredDataQualityHygieneLaborMinutes {
411 pub fn try_new(value: u16) -> Result<Self> {
413 if value == 0 {
414 return Err(Error::InvalidDomainValue {
415 field: StorageField::DataQualityHygieneLaborMinutes,
416 reason: "must be greater than zero".to_owned(),
417 });
418 }
419
420 Ok(Self(value))
421 }
422
423 pub const fn get(self) -> u16 {
425 self.0
426 }
427}
428
429impl<'de> Deserialize<'de> for StoredDataQualityHygieneLaborMinutes {
430 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
431 where
432 D: Deserializer<'de>,
433 {
434 let value = u16::deserialize(deserializer)?;
435 Self::try_new(value).map_err(serde::de::Error::custom)
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
440pub struct DataQualityHygieneOutcomeRecord {
442 pub action_id: String,
444 pub outcome: DataQualityHygieneOutcomeCode,
446 pub before_minutes: StoredDataQualityHygieneLaborMinutes,
448 pub actual_minutes: StoredDataQualityHygieneLaborMinutes,
450 pub actor_id: String,
452 pub actor_persona: DataQualityHygienePersonaCode,
454 pub feedback: String,
456 #[builder(default)]
457 pub source_refs: Vec<StoredSourceRecordRef>,
459 #[builder(default)]
460 pub issue_refs: Vec<String>,
462 pub resolution_status_after_review: DataQualityResolutionStatusCode,
464 pub recorded_at: String,
466 pub correlation_id: String,
468 pub location_id: String,
470 pub operating_day: String,
472 pub action_kind: DataQualityHygieneActionKindCode,
474 pub owner_persona: DataQualityHygienePersonaCode,
476 pub estimated_minutes_saved: u16,
478}
479
480impl DataQualityHygieneOutcomeRecord {
481 pub fn decode_json(raw: &str) -> Result<Self> {
483 serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
484 }
485
486 pub fn encode_json(&self) -> Result<String> {
488 serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
489 }
490
491 pub const fn actual_minutes_saved(&self) -> u16 {
493 self.before_minutes
494 .get()
495 .saturating_sub(self.actual_minutes.get())
496 }
497
498 pub fn reporting_group(&self) -> DataQualityHygieneReportingGroup {
500 DataQualityHygieneReportingGroup {
501 location_id: self.location_id.clone(),
502 operating_day: self.operating_day.clone(),
503 action_kind: self.action_kind,
504 owner_persona: self.owner_persona,
505 }
506 }
507}
508
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
510pub struct PetResortPortfolioRecord {
512 pub operator: OperatorCode,
514 pub resort_count: StoredResortCount,
516 pub structure: PortfolioStructureCode,
518 pub business_lines: Vec<BusinessLineCode>,
520 pub brands: Vec<PetResortBrandRecord>,
522}
523
524impl PetResortPortfolioRecord {
525 pub fn decode_json(raw: &str) -> Result<Self> {
527 serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
528 }
529
530 pub fn encode_json(&self) -> Result<String> {
532 serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
533 }
534}
535
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
537#[serde(rename_all = "snake_case")]
538pub enum OperatorCode {
540 #[serde(rename = "nva")]
541 NationalVeterinaryAssociates,
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "snake_case")]
547pub enum PortfolioStructureCode {
549 FederatedMultiBrand,
551 SingleBrand,
553 Unknown,
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
558#[serde(rename_all = "snake_case")]
559pub enum BusinessLineCode {
561 GeneralPracticeVeterinaryHospitals,
563 PetResorts,
565 Equine,
567 SpecialtyEmergencyHospitals,
569}
570
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(tag = "kind", rename_all = "snake_case")]
573pub enum PetResortBrandRecord {
575 Known {
577 code: PetResortBrandCode,
579 },
580 Other {
582 name: StoredBrandName,
584 },
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
588#[serde(rename_all = "snake_case")]
589pub enum PetResortBrandCode {
591 NvaPetResorts,
593 PetSuites,
595 PoochHotel,
597 EliteSuites,
599 TheBarkSide,
601 WoofdorfAstoria,
603 DoggieDistrict,
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
608pub struct StoredResortCount(u16);
610
611impl StoredResortCount {
612 pub const fn try_new(value: u16) -> std::result::Result<Self, StoredResortCountError> {
614 if value == 0 {
615 return Err(StoredResortCountError::ZeroResorts);
616 }
617 Ok(Self(value))
618 }
619
620 pub const fn get(self) -> u16 {
622 self.0
623 }
624}
625
626impl<'de> Deserialize<'de> for StoredResortCount {
627 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
628 where
629 D: Deserializer<'de>,
630 {
631 Self::try_new(u16::deserialize(deserializer)?).map_err(serde::de::Error::custom)
632 }
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
636pub enum StoredResortCountError {
638 #[error("stored pet resort portfolios require at least one resort")]
639 ZeroResorts,
641}
642
643#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
644pub struct StoredBrandName(String);
646
647impl StoredBrandName {
648 pub fn try_new(value: impl Into<String>) -> Result<Self> {
650 let value = value.into().trim().to_owned();
651 if value.is_empty() {
652 return Err(Error::InvalidDomainValue {
653 field: StorageField::BrandName,
654 reason: "brand name cannot be empty".to_owned(),
655 });
656 }
657 Ok(Self(value))
658 }
659
660 pub fn as_str(&self) -> &str {
662 &self.0
663 }
664}
665
666impl TryFrom<PetResortPortfolioRecord> for pet_resort::Portfolio {
667 type Error = Error;
668
669 fn try_from(record: PetResortPortfolioRecord) -> Result<Self> {
670 Ok(Self::builder()
671 .operator(record.operator.into())
672 .resort_count(record.resort_count.try_into()?)
673 .structure(record.structure.into())
674 .business_lines(record.business_lines.into_iter().map(Into::into).collect())
675 .brands(
676 record
677 .brands
678 .into_iter()
679 .map(TryInto::try_into)
680 .collect::<Result<Vec<_>>>()?,
681 )
682 .build())
683 }
684}
685
686impl TryFrom<pet_resort::Portfolio> for PetResortPortfolioRecord {
687 type Error = Error;
688
689 fn try_from(domain_portfolio: pet_resort::Portfolio) -> Result<Self> {
690 Ok(Self::builder()
691 .operator(domain_portfolio.operator.into())
692 .resort_count(domain_portfolio.resort_count.try_into()?)
693 .structure(domain_portfolio.structure.into())
694 .business_lines(
695 domain_portfolio
696 .business_lines
697 .into_iter()
698 .map(Into::into)
699 .collect(),
700 )
701 .brands(
702 domain_portfolio
703 .brands
704 .into_iter()
705 .map(TryInto::try_into)
706 .collect::<Result<Vec<_>>>()?,
707 )
708 .build())
709 }
710}
711
712impl From<OperatorCode> for pet_resort::Operator {
713 fn from(value: OperatorCode) -> Self {
714 match value {
715 OperatorCode::NationalVeterinaryAssociates => Self::NationalVeterinaryAssociates,
716 }
717 }
718}
719
720impl From<pet_resort::Operator> for OperatorCode {
721 fn from(value: pet_resort::Operator) -> Self {
722 match value {
723 pet_resort::Operator::NationalVeterinaryAssociates => {
724 Self::NationalVeterinaryAssociates
725 }
726 }
727 }
728}
729
730impl From<PortfolioStructureCode> for pet_resort::PortfolioStructure {
731 fn from(value: PortfolioStructureCode) -> Self {
732 match value {
733 PortfolioStructureCode::FederatedMultiBrand => Self::FederatedMultiBrand,
734 PortfolioStructureCode::SingleBrand => Self::SingleBrand,
735 PortfolioStructureCode::Unknown => Self::Unknown,
736 }
737 }
738}
739
740impl From<pet_resort::PortfolioStructure> for PortfolioStructureCode {
741 fn from(value: pet_resort::PortfolioStructure) -> Self {
742 match value {
743 pet_resort::PortfolioStructure::FederatedMultiBrand => Self::FederatedMultiBrand,
744 pet_resort::PortfolioStructure::SingleBrand => Self::SingleBrand,
745 pet_resort::PortfolioStructure::Unknown => Self::Unknown,
746 }
747 }
748}
749
750impl From<BusinessLineCode> for pet_resort::BusinessLine {
751 fn from(value: BusinessLineCode) -> Self {
752 match value {
753 BusinessLineCode::GeneralPracticeVeterinaryHospitals => {
754 Self::GeneralPracticeVeterinaryHospitals
755 }
756 BusinessLineCode::PetResorts => Self::PetResorts,
757 BusinessLineCode::Equine => Self::Equine,
758 BusinessLineCode::SpecialtyEmergencyHospitals => Self::SpecialtyEmergencyHospitals,
759 }
760 }
761}
762
763impl From<pet_resort::BusinessLine> for BusinessLineCode {
764 fn from(value: pet_resort::BusinessLine) -> Self {
765 match value {
766 pet_resort::BusinessLine::GeneralPracticeVeterinaryHospitals => {
767 Self::GeneralPracticeVeterinaryHospitals
768 }
769 pet_resort::BusinessLine::PetResorts => Self::PetResorts,
770 pet_resort::BusinessLine::Equine => Self::Equine,
771 pet_resort::BusinessLine::SpecialtyEmergencyHospitals => {
772 Self::SpecialtyEmergencyHospitals
773 }
774 }
775 }
776}
777
778impl TryFrom<StoredResortCount> for domain::operations::ResortCount {
779 type Error = Error;
780
781 fn try_from(value: StoredResortCount) -> Result<Self> {
782 domain::operations::ResortCount::try_new(value.get()).map_err(|err| {
783 Error::InvalidDomainValue {
784 field: StorageField::ResortCount,
785 reason: err.to_string(),
786 }
787 })
788 }
789}
790
791impl TryFrom<domain::operations::ResortCount> for StoredResortCount {
792 type Error = Error;
793
794 fn try_from(value: domain::operations::ResortCount) -> Result<Self> {
795 Self::try_new(value.get()).map_err(|err| Error::InvalidDomainValue {
796 field: StorageField::ResortCount,
797 reason: err.to_string(),
798 })
799 }
800}
801
802impl TryFrom<PetResortBrandRecord> for pet_resort::Brand {
803 type Error = Error;
804
805 fn try_from(value: PetResortBrandRecord) -> Result<Self> {
806 Ok(match value {
807 PetResortBrandRecord::Known { code } => code.into(),
808 PetResortBrandRecord::Other { name } => Self::Other {
809 name: ::domain::location::Name::try_new(name.as_str()).map_err(|err| {
810 Error::InvalidDomainValue {
811 field: StorageField::BrandName,
812 reason: err.to_string(),
813 }
814 })?,
815 },
816 })
817 }
818}
819
820impl TryFrom<pet_resort::Brand> for PetResortBrandRecord {
821 type Error = Error;
822
823 fn try_from(value: pet_resort::Brand) -> Result<Self> {
824 Ok(match value {
825 pet_resort::Brand::NvaPetResorts => Self::Known {
826 code: PetResortBrandCode::NvaPetResorts,
827 },
828 pet_resort::Brand::PetSuites => Self::Known {
829 code: PetResortBrandCode::PetSuites,
830 },
831 pet_resort::Brand::PoochHotel => Self::Known {
832 code: PetResortBrandCode::PoochHotel,
833 },
834 pet_resort::Brand::EliteSuites => Self::Known {
835 code: PetResortBrandCode::EliteSuites,
836 },
837 pet_resort::Brand::TheBarkSide => Self::Known {
838 code: PetResortBrandCode::TheBarkSide,
839 },
840 pet_resort::Brand::WoofdorfAstoria => Self::Known {
841 code: PetResortBrandCode::WoofdorfAstoria,
842 },
843 pet_resort::Brand::DoggieDistrict => Self::Known {
844 code: PetResortBrandCode::DoggieDistrict,
845 },
846 pet_resort::Brand::Other { name } => Self::Other {
847 name: StoredBrandName::try_new(name.into_inner())?,
848 },
849 })
850 }
851}
852
853impl From<PetResortBrandCode> for pet_resort::Brand {
854 fn from(value: PetResortBrandCode) -> Self {
855 match value {
856 PetResortBrandCode::NvaPetResorts => Self::NvaPetResorts,
857 PetResortBrandCode::PetSuites => Self::PetSuites,
858 PetResortBrandCode::PoochHotel => Self::PoochHotel,
859 PetResortBrandCode::EliteSuites => Self::EliteSuites,
860 PetResortBrandCode::TheBarkSide => Self::TheBarkSide,
861 PetResortBrandCode::WoofdorfAstoria => Self::WoofdorfAstoria,
862 PetResortBrandCode::DoggieDistrict => Self::DoggieDistrict,
863 }
864 }
865}
866
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
868pub struct ServiceOfferingRecord {
870 pub service_kind: ServiceOfferingKindCode,
872 pub boarding_accommodation: Option<boarding::AccommodationCode>,
874 #[builder(default)]
875 pub boarding_included_care: Vec<boarding::CareFeatureCode>,
877 #[builder(default)]
878 pub boarding_add_ons: Vec<boarding::AddOnCode>,
880 pub daycare_format: Option<daycare::FormatCode>,
882 #[builder(default)]
883 pub daycare_eligibility_rules: Vec<daycare::EligibilityRuleCode>,
885 pub grooming_service: Option<grooming::ServiceCode>,
887 pub grooming_cadence_weeks: Option<grooming::StoredCadenceWeeks>,
889 pub training_program: Option<training::ProgramRecord>,
891 pub retail_partner: Option<retail::PartnerCode>,
893 pub retail_product_category: Option<retail::ProductCategoryCode>,
895}
896
897impl ServiceOfferingRecord {
898 pub fn decode_json(raw: &str) -> Result<Self> {
900 serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
901 }
902
903 pub fn encode_json(&self) -> Result<String> {
905 serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
906 }
907
908 fn mismatch(reason: ShapeMismatchReason) -> Error {
909 Error::StorageShapeMismatch {
910 record: RecordKind::ServiceOffering,
911 reason,
912 }
913 }
914
915 fn ensure_empty_cross_variant_fields(&self, allowed: ServiceOfferingKindCode) -> Result<()> {
916 let invalid = match allowed {
917 ServiceOfferingKindCode::Boarding => {
918 self.daycare_format.is_some()
919 || !self.daycare_eligibility_rules.is_empty()
920 || self.grooming_service.is_some()
921 || self.grooming_cadence_weeks.is_some()
922 || self.training_program.is_some()
923 || self.retail_partner.is_some()
924 || self.retail_product_category.is_some()
925 }
926 ServiceOfferingKindCode::Daycare => {
927 self.boarding_accommodation.is_some()
928 || !self.boarding_included_care.is_empty()
929 || !self.boarding_add_ons.is_empty()
930 || self.grooming_service.is_some()
931 || self.grooming_cadence_weeks.is_some()
932 || self.training_program.is_some()
933 || self.retail_partner.is_some()
934 || self.retail_product_category.is_some()
935 }
936 ServiceOfferingKindCode::Grooming => {
937 self.boarding_accommodation.is_some()
938 || !self.boarding_included_care.is_empty()
939 || !self.boarding_add_ons.is_empty()
940 || self.daycare_format.is_some()
941 || !self.daycare_eligibility_rules.is_empty()
942 || self.training_program.is_some()
943 || self.retail_partner.is_some()
944 || self.retail_product_category.is_some()
945 }
946 ServiceOfferingKindCode::Training => {
947 self.boarding_accommodation.is_some()
948 || !self.boarding_included_care.is_empty()
949 || !self.boarding_add_ons.is_empty()
950 || self.daycare_format.is_some()
951 || !self.daycare_eligibility_rules.is_empty()
952 || self.grooming_service.is_some()
953 || self.grooming_cadence_weeks.is_some()
954 || self.retail_partner.is_some()
955 || self.retail_product_category.is_some()
956 }
957 ServiceOfferingKindCode::RetailPartnerProduct => {
958 self.boarding_accommodation.is_some()
959 || !self.boarding_included_care.is_empty()
960 || !self.boarding_add_ons.is_empty()
961 || self.daycare_format.is_some()
962 || !self.daycare_eligibility_rules.is_empty()
963 || self.grooming_service.is_some()
964 || self.grooming_cadence_weeks.is_some()
965 || self.training_program.is_some()
966 }
967 };
968
969 if invalid {
970 Err(Self::mismatch(
971 ShapeMismatchReason::FieldBelongsToDifferentVariant,
972 ))
973 } else {
974 Ok(())
975 }
976 }
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
980#[serde(rename_all = "snake_case")]
981pub enum ServiceOfferingKindCode {
983 Boarding,
985 Daycare,
987 Grooming,
989 Training,
991 RetailPartnerProduct,
993}
994
995impl TryFrom<domain::operations::ServiceOffering> for ServiceOfferingRecord {
996 type Error = Error;
997
998 fn try_from(value: domain::operations::ServiceOffering) -> Result<Self> {
999 Ok(match value {
1000 domain::operations::ServiceOffering::Boarding {
1001 accommodation,
1002 included_care,
1003 add_ons,
1004 } => Self::builder()
1005 .service_kind(ServiceOfferingKindCode::Boarding)
1006 .boarding_accommodation(accommodation.into())
1007 .boarding_included_care(included_care.into_iter().map(Into::into).collect())
1008 .boarding_add_ons(add_ons.into_iter().map(Into::into).collect())
1009 .build(),
1010 domain::operations::ServiceOffering::Daycare {
1011 format,
1012 eligibility_rules,
1013 } => Self::builder()
1014 .service_kind(ServiceOfferingKindCode::Daycare)
1015 .daycare_format(format.into())
1016 .daycare_eligibility_rules(eligibility_rules.into_iter().map(Into::into).collect())
1017 .build(),
1018 domain::operations::ServiceOffering::Grooming { service, cadence } => {
1019 let cadence_weeks = match cadence {
1020 domain::grooming::rebooking::Cadence::EveryWeeks(weeks) => {
1021 Some(weeks.try_into()?)
1022 }
1023 domain::grooming::rebooking::Cadence::AsNeeded
1024 | domain::grooming::rebooking::Cadence::GroomerRecommended
1025 | domain::grooming::rebooking::Cadence::Unknown => None,
1026 };
1027 let builder = Self::builder()
1028 .service_kind(ServiceOfferingKindCode::Grooming)
1029 .grooming_service(service.into());
1030 match cadence_weeks {
1031 Some(weeks) => builder.grooming_cadence_weeks(weeks).build(),
1032 None => builder.build(),
1033 }
1034 }
1035 domain::operations::ServiceOffering::Training { program } => Self::builder()
1036 .service_kind(ServiceOfferingKindCode::Training)
1037 .training_program(program.try_into()?)
1038 .build(),
1039 domain::operations::ServiceOffering::RetailPartnerProduct { partner, category } => {
1040 Self::builder()
1041 .service_kind(ServiceOfferingKindCode::RetailPartnerProduct)
1042 .retail_partner(partner.into())
1043 .retail_product_category(category.into())
1044 .build()
1045 }
1046 })
1047 }
1048}
1049
1050impl TryFrom<ServiceOfferingRecord> for domain::operations::ServiceOffering {
1051 type Error = Error;
1052
1053 fn try_from(record: ServiceOfferingRecord) -> Result<Self> {
1054 match record.service_kind {
1055 ServiceOfferingKindCode::Boarding => {
1056 record.ensure_empty_cross_variant_fields(ServiceOfferingKindCode::Boarding)?;
1057 Ok(Self::Boarding {
1058 accommodation: record
1059 .boarding_accommodation
1060 .ok_or_else(|| {
1061 ServiceOfferingRecord::mismatch(
1062 ShapeMismatchReason::RequiredFieldMissing,
1063 )
1064 })?
1065 .into(),
1066 included_care: record
1067 .boarding_included_care
1068 .into_iter()
1069 .map(Into::into)
1070 .collect(),
1071 add_ons: record
1072 .boarding_add_ons
1073 .into_iter()
1074 .map(Into::into)
1075 .collect(),
1076 })
1077 }
1078 ServiceOfferingKindCode::Daycare => {
1079 record.ensure_empty_cross_variant_fields(ServiceOfferingKindCode::Daycare)?;
1080 Ok(Self::Daycare {
1081 format: record
1082 .daycare_format
1083 .ok_or_else(|| {
1084 ServiceOfferingRecord::mismatch(
1085 ShapeMismatchReason::RequiredFieldMissing,
1086 )
1087 })?
1088 .into(),
1089 eligibility_rules: record
1090 .daycare_eligibility_rules
1091 .into_iter()
1092 .map(Into::into)
1093 .collect(),
1094 })
1095 }
1096 ServiceOfferingKindCode::Grooming => {
1097 record.ensure_empty_cross_variant_fields(ServiceOfferingKindCode::Grooming)?;
1098 let service = record
1099 .grooming_service
1100 .ok_or_else(|| {
1101 ServiceOfferingRecord::mismatch(ShapeMismatchReason::RequiredFieldMissing)
1102 })?
1103 .into();
1104 let cadence = match record.grooming_cadence_weeks {
1105 Some(weeks) => {
1106 domain::grooming::rebooking::Cadence::EveryWeeks(weeks.try_into()?)
1107 }
1108 None => domain::grooming::rebooking::Cadence::Unknown,
1109 };
1110 Ok(Self::Grooming { service, cadence })
1111 }
1112 ServiceOfferingKindCode::Training => {
1113 record.ensure_empty_cross_variant_fields(ServiceOfferingKindCode::Training)?;
1114 Ok(Self::Training {
1115 program: record
1116 .training_program
1117 .ok_or_else(|| {
1118 ServiceOfferingRecord::mismatch(
1119 ShapeMismatchReason::RequiredFieldMissing,
1120 )
1121 })?
1122 .try_into()?,
1123 })
1124 }
1125 ServiceOfferingKindCode::RetailPartnerProduct => {
1126 record.ensure_empty_cross_variant_fields(
1127 ServiceOfferingKindCode::RetailPartnerProduct,
1128 )?;
1129 Ok(Self::RetailPartnerProduct {
1130 partner: record
1131 .retail_partner
1132 .ok_or_else(|| {
1133 ServiceOfferingRecord::mismatch(
1134 ShapeMismatchReason::RequiredFieldMissing,
1135 )
1136 })?
1137 .into(),
1138 category: record
1139 .retail_product_category
1140 .ok_or_else(|| {
1141 ServiceOfferingRecord::mismatch(
1142 ShapeMismatchReason::RequiredFieldMissing,
1143 )
1144 })?
1145 .into(),
1146 })
1147 }
1148 }
1149 }
1150}
1151
1152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1153pub struct CoreServiceContractsRecord {
1155 pub location_id: domain::entities::LocationId,
1157 pub boarding: boarding::ContractRecord,
1159 pub daycare: daycare::ContractRecord,
1161 pub grooming: grooming::ContractRecord,
1163 pub training: training::ContractRecord,
1165 pub retail: retail::ContractRecord,
1167}
1168
1169impl CoreServiceContractsRecord {
1170 pub const fn record_kind(&self) -> RecordKind {
1172 RecordKind::CoreServiceContracts
1173 }
1174
1175 pub fn encode_json(&self) -> Result<String> {
1177 serde_json::to_string(self)
1178 .map_err(|source| Error::Codec(CodecError::JsonEncode { source }))
1179 }
1180
1181 pub fn decode_json(raw: &str) -> Result<Self> {
1183 serde_json::from_str(raw).map_err(|source| Error::Codec(CodecError::JsonDecode { source }))
1184 }
1185}
1186
1187impl From<service_core::ServiceContracts> for CoreServiceContractsRecord {
1188 fn from(contracts: service_core::ServiceContracts) -> Self {
1189 Self {
1190 location_id: contracts.location_id,
1191 boarding: contracts.boarding.into(),
1192 daycare: contracts.daycare.into(),
1193 grooming: contracts.grooming.into(),
1194 training: contracts.training.into(),
1195 retail: contracts.retail.into(),
1196 }
1197 }
1198}
1199
1200impl From<CoreServiceContractsRecord> for service_core::ServiceContracts {
1201 fn from(record: CoreServiceContractsRecord) -> Self {
1202 Self::builder()
1203 .location_id(record.location_id)
1204 .boarding(record.boarding.into())
1205 .daycare(record.daycare.into())
1206 .grooming(record.grooming.into())
1207 .training(record.training.into())
1208 .retail(record.retail.into())
1209 .build()
1210 }
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
1214pub struct TechnologyEcosystemRecord {
1216 pub core_portal: CoreOperatingSystemCode,
1218 pub data_access: Vec<DataAccessPatternCode>,
1220 pub adjacent_systems: Vec<AdjacentSystemCode>,
1222}
1223
1224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1225#[serde(rename_all = "snake_case")]
1226pub enum CoreOperatingSystemCode {
1228 Gingr,
1230 MixedSystems,
1232 Unknown,
1234}
1235
1236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1237#[serde(rename_all = "snake_case")]
1238pub enum DataAccessPatternCode {
1240 Api,
1242 Webhook,
1244 DataExport,
1246 Warehouse,
1248 BusinessIntelligenceDashboard,
1250 Unknown,
1252}
1253
1254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1255#[serde(rename_all = "snake_case")]
1256pub enum AdjacentSystemCode {
1258 AvatureRecruiting,
1260 Ga4,
1262 Amplitude,
1264 GoogleTagManager,
1266 Hris,
1268 LaborScheduling,
1270 Payroll,
1272 MarketingAutomation,
1274 Ticketing,
1276 CallCenterTelephony,
1278 Reviews,
1280 EmailSmsMarketing,
1282 BusinessIntelligence,
1284 DataLake,
1286}
1287
1288impl From<domain::operations::TechnologyEcosystem> for TechnologyEcosystemRecord {
1289 fn from(value: domain::operations::TechnologyEcosystem) -> Self {
1290 Self::builder()
1291 .core_portal(value.core_portal.into())
1292 .data_access(value.data_access.into_iter().map(Into::into).collect())
1293 .adjacent_systems(value.adjacent_systems.into_iter().map(Into::into).collect())
1294 .build()
1295 }
1296}
1297
1298impl From<TechnologyEcosystemRecord> for domain::operations::TechnologyEcosystem {
1299 fn from(value: TechnologyEcosystemRecord) -> Self {
1300 Self::builder()
1301 .core_portal(value.core_portal.into())
1302 .data_access(value.data_access.into_iter().map(Into::into).collect())
1303 .adjacent_systems(value.adjacent_systems.into_iter().map(Into::into).collect())
1304 .build()
1305 }
1306}
1307
1308macro_rules! bidirectional_code_map {
1309 ($storage:ty, $domain:ty, { $($storage_variant:ident => $domain_variant:ident),+ $(,)? }) => {
1310 impl From<$storage> for $domain {
1311 fn from(value: $storage) -> Self {
1312 match value {
1313 $(<$storage>::$storage_variant => Self::$domain_variant,)+
1314 }
1315 }
1316 }
1317
1318 impl From<$domain> for $storage {
1319 fn from(value: $domain) -> Self {
1320 match value {
1321 $(<$domain>::$domain_variant => Self::$storage_variant,)+
1322 }
1323 }
1324 }
1325 };
1326}
1327
1328bidirectional_code_map!(CoreOperatingSystemCode, service_core::OperatingSystem, {
1329 Gingr => Gingr,
1330 MixedSystems => MixedSystems,
1331 Unknown => Unknown,
1332});
1333
1334bidirectional_code_map!(DataAccessPatternCode, domain::operations::DataAccessPattern, {
1335 Api => Api,
1336 Webhook => Webhook,
1337 DataExport => DataExport,
1338 Warehouse => Warehouse,
1339 BusinessIntelligenceDashboard => BusinessIntelligenceDashboard,
1340 Unknown => Unknown,
1341});
1342
1343bidirectional_code_map!(AdjacentSystemCode, domain::operations::AdjacentSystem, {
1344 AvatureRecruiting => AvatureRecruiting,
1345 Ga4 => Ga4,
1346 Amplitude => Amplitude,
1347 GoogleTagManager => GoogleTagManager,
1348 Hris => Hris,
1349 LaborScheduling => LaborScheduling,
1350 Payroll => Payroll,
1351 MarketingAutomation => MarketingAutomation,
1352 Ticketing => Ticketing,
1353 CallCenterTelephony => CallCenterTelephony,
1354 Reviews => Reviews,
1355 EmailSmsMarketing => EmailSmsMarketing,
1356 BusinessIntelligence => BusinessIntelligence,
1357 DataLake => DataLake,
1358});