1use bon::Builder;
64use serde::{Deserialize, Deserializer, Serialize};
65
66use crate::service_line::{boarding, daycare, grooming, retail, training};
67use domain::operations::{pet_resort, service_core};
68
69pub use crate::service_line::{
70 grooming::StoredCadenceWeeksError,
71 training::{
72 StoredProgramDurationWeeks as StoredTrainingProgramDurationWeeks,
73 StoredProgramDurationWeeksError as StoredTrainingProgramDurationWeeksError,
74 },
75};
76
77pub type Result<T> = std::result::Result<T, Error>;
79
80#[derive(Debug, thiserror::Error)]
81pub enum Error {
83 #[error("storage codec error")]
84 Codec(#[from] CodecError),
86 #[error("{record:?} storage shape mismatch: {reason:?}")]
87 StorageShapeMismatch {
89 record: RecordKind,
91 reason: ShapeMismatchReason,
93 },
94 #[error("domain value rejected storage field {field:?}: {reason}")]
95 InvalidDomainValue {
97 field: StorageField,
99 reason: String,
101 },
102}
103
104#[derive(Debug, thiserror::Error)]
105pub enum CodecError {
107 #[error("failed to decode json: {source}")]
108 JsonDecode {
110 source: serde_json::Error,
112 },
113 #[error("failed to encode json: {source}")]
114 JsonEncode {
116 source: serde_json::Error,
118 },
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum RecordKind {
124 PetResortPortfolio,
126 ServiceOffering,
128 CoreServiceContracts,
130 DataQualityHygieneOutcome,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum ShapeMismatchReason {
137 RequiredFieldMissing,
139 FieldBelongsToDifferentVariant,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum StorageField {
146 ResortCount,
148 BrandName,
150 GroomingCadenceWeeks,
152 TrainingProgramDurationWeeks,
154 ManagerDailyBriefLaborMinutes,
156 DataQualityHygieneLaborMinutes,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
161pub struct StoredSourceRecordRef {
163 pub system: String,
165 pub record_type: String,
167 pub record_id: String,
169 pub observed_at: String,
171 pub adapter_version: String,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum ManagerDailyBriefOutcomeCode {
179 Completed,
181 Deferred,
183 SuppressedByManager,
185 SourceFactWasWrong,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum ManagerDailyBriefPersonaCode {
193 GeneralManager,
195 AssistantGeneralManager,
197 FrontDeskLead,
199 FrontDeskAgent,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum ManagerDailyBriefActionKindCode {
207 ReviewDemandAgainstStaffingPlan,
209 ResolveCheckoutException,
211 ApproveRetentionFollowUpDraft,
213 InvestigateSourceDataQualityIssue,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct ManagerDailyBriefReportingGroup {
220 pub location_id: String,
222 pub operating_day: String,
224 pub action_kind: ManagerDailyBriefActionKindCode,
226 pub owner_persona: ManagerDailyBriefPersonaCode,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
231#[serde(transparent)]
232pub struct StoredManagerDailyBriefLaborMinutes(u16);
234
235impl StoredManagerDailyBriefLaborMinutes {
236 pub fn try_new(value: u16) -> Result<Self> {
238 if value == 0 {
239 return Err(Error::InvalidDomainValue {
240 field: StorageField::ManagerDailyBriefLaborMinutes,
241 reason: "must be greater than zero".to_owned(),
242 });
243 }
244
245 Ok(Self(value))
246 }
247
248 pub const fn get(self) -> u16 {
250 self.0
251 }
252}
253
254impl<'de> Deserialize<'de> for StoredManagerDailyBriefLaborMinutes {
255 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
256 where
257 D: Deserializer<'de>,
258 {
259 let value = u16::deserialize(deserializer)?;
260 Self::try_new(value).map_err(serde::de::Error::custom)
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
265pub struct ManagerDailyBriefOutcomeRecord {
267 pub action_id: String,
269 pub outcome: ManagerDailyBriefOutcomeCode,
271 pub before_minutes: StoredManagerDailyBriefLaborMinutes,
273 pub actual_minutes: StoredManagerDailyBriefLaborMinutes,
275 pub actor_id: String,
277 pub actor_persona: ManagerDailyBriefPersonaCode,
279 pub feedback: String,
281 #[builder(default)]
282 pub source_refs: Vec<StoredSourceRecordRef>,
284 pub recorded_at: String,
286 pub correlation_id: String,
288 pub location_id: String,
290 pub operating_day: String,
292 pub action_kind: ManagerDailyBriefActionKindCode,
294 pub owner_persona: ManagerDailyBriefPersonaCode,
296 pub estimated_minutes_saved: u16,
298}
299
300impl ManagerDailyBriefOutcomeRecord {
301 pub fn decode_json(raw: &str) -> Result<Self> {
303 serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
304 }
305
306 pub fn encode_json(&self) -> Result<String> {
308 serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
309 }
310
311 pub const fn actual_minutes_saved(&self) -> u16 {
313 self.before_minutes
314 .get()
315 .saturating_sub(self.actual_minutes.get())
316 }
317
318 pub fn reporting_group(&self) -> ManagerDailyBriefReportingGroup {
320 ManagerDailyBriefReportingGroup {
321 location_id: self.location_id.clone(),
322 operating_day: self.operating_day.clone(),
323 action_kind: self.action_kind,
324 owner_persona: self.owner_persona,
325 }
326 }
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "snake_case")]
331pub enum DataQualityHygieneOutcomeCode {
333 Completed,
335 Deferred,
337 SuppressedByManager,
339 SourceFactWasWrong,
341 NotActionable,
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "snake_case")]
347pub enum DataQualityHygienePersonaCode {
349 GeneralManager,
351 AssistantGeneralManager,
353 FrontDeskLead,
355 FrontDeskAgent,
357 RegionalOperator,
359 OperationsAnalyst,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
364#[serde(rename_all = "snake_case")]
365pub enum DataQualityHygieneActionKindCode {
367 InvestigateMissingSourceEvidence,
369 ReconcileDuplicateCustomerOrPetCandidate,
371 CompleteMissingPetOrCustomerProfileFields,
373 ReviewStaleVaccinationSourceFreshness,
375 NormalizeAmbiguousServiceLineNaming,
377 ReviewCheckoutOrUnclosedReservationEvidence,
379 EscalateSensitiveOrQuarantinedPayload,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "snake_case")]
385pub enum DataQualityResolutionStatusCode {
387 Open,
389 Acknowledged,
391 Ignored,
393 Repaired,
395 Superseded,
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct DataQualityHygieneReportingGroup {
402 pub location_id: String,
404 pub operating_day: String,
406 pub action_kind: DataQualityHygieneActionKindCode,
408 pub owner_persona: DataQualityHygienePersonaCode,
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
413#[serde(transparent)]
414pub struct StoredDataQualityHygieneLaborMinutes(u16);
416
417impl StoredDataQualityHygieneLaborMinutes {
418 pub fn try_new(value: u16) -> Result<Self> {
420 if value == 0 {
421 return Err(Error::InvalidDomainValue {
422 field: StorageField::DataQualityHygieneLaborMinutes,
423 reason: "must be greater than zero".to_owned(),
424 });
425 }
426
427 Ok(Self(value))
428 }
429
430 pub const fn get(self) -> u16 {
432 self.0
433 }
434}
435
436impl<'de> Deserialize<'de> for StoredDataQualityHygieneLaborMinutes {
437 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
438 where
439 D: Deserializer<'de>,
440 {
441 let value = u16::deserialize(deserializer)?;
442 Self::try_new(value).map_err(serde::de::Error::custom)
443 }
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
447pub struct DataQualityHygieneOutcomeRecord {
449 pub action_id: String,
451 pub outcome: DataQualityHygieneOutcomeCode,
453 pub before_minutes: StoredDataQualityHygieneLaborMinutes,
455 pub actual_minutes: StoredDataQualityHygieneLaborMinutes,
457 pub actor_id: String,
459 pub actor_persona: DataQualityHygienePersonaCode,
461 pub feedback: String,
463 #[builder(default)]
464 pub source_refs: Vec<StoredSourceRecordRef>,
466 #[builder(default)]
467 pub issue_refs: Vec<String>,
469 pub resolution_status_after_review: DataQualityResolutionStatusCode,
471 pub recorded_at: String,
473 pub correlation_id: String,
475 pub location_id: String,
477 pub operating_day: String,
479 pub action_kind: DataQualityHygieneActionKindCode,
481 pub owner_persona: DataQualityHygienePersonaCode,
483 pub estimated_minutes_saved: u16,
485}
486
487impl DataQualityHygieneOutcomeRecord {
488 pub fn decode_json(raw: &str) -> Result<Self> {
490 serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
491 }
492
493 pub fn encode_json(&self) -> Result<String> {
495 serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
496 }
497
498 pub const fn actual_minutes_saved(&self) -> u16 {
500 self.before_minutes
501 .get()
502 .saturating_sub(self.actual_minutes.get())
503 }
504
505 pub fn reporting_group(&self) -> DataQualityHygieneReportingGroup {
507 DataQualityHygieneReportingGroup {
508 location_id: self.location_id.clone(),
509 operating_day: self.operating_day.clone(),
510 action_kind: self.action_kind,
511 owner_persona: self.owner_persona,
512 }
513 }
514}
515
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
517pub struct PetResortPortfolioRecord {
519 pub operator: OperatorCode,
521 pub resort_count: StoredResortCount,
523 pub structure: PortfolioStructureCode,
525 pub business_lines: Vec<BusinessLineCode>,
527 pub brands: Vec<PetResortBrandRecord>,
529}
530
531impl PetResortPortfolioRecord {
532 pub fn decode_json(raw: &str) -> Result<Self> {
534 serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
535 }
536
537 pub fn encode_json(&self) -> Result<String> {
539 serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
540 }
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(rename_all = "snake_case")]
545pub enum OperatorCode {
547 #[serde(rename = "nva")]
548 NationalVeterinaryAssociates,
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
553#[serde(rename_all = "snake_case")]
554pub enum PortfolioStructureCode {
556 FederatedMultiBrand,
558 SingleBrand,
560 Unknown,
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
565#[serde(rename_all = "snake_case")]
566pub enum BusinessLineCode {
568 GeneralPracticeVeterinaryHospitals,
570 PetResorts,
572 Equine,
574 SpecialtyEmergencyHospitals,
576}
577
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(tag = "kind", rename_all = "snake_case")]
580pub enum PetResortBrandRecord {
582 Known {
584 code: PetResortBrandCode,
586 },
587 Other {
589 name: StoredBrandName,
591 },
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
595#[serde(rename_all = "snake_case")]
596pub enum PetResortBrandCode {
598 NvaPetResorts,
600 PetSuites,
602 PoochHotel,
604 EliteSuites,
606 TheBarkSide,
608 WoofdorfAstoria,
610 DoggieDistrict,
612}
613
614#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
615pub struct StoredResortCount(u16);
617
618impl StoredResortCount {
619 pub const fn try_new(value: u16) -> std::result::Result<Self, StoredResortCountError> {
621 if value == 0 {
622 return Err(StoredResortCountError::ZeroResorts);
623 }
624 Ok(Self(value))
625 }
626
627 pub const fn get(self) -> u16 {
629 self.0
630 }
631}
632
633impl<'de> Deserialize<'de> for StoredResortCount {
634 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
635 where
636 D: Deserializer<'de>,
637 {
638 Self::try_new(u16::deserialize(deserializer)?).map_err(serde::de::Error::custom)
639 }
640}
641
642#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
643pub enum StoredResortCountError {
645 #[error("stored pet resort portfolios require at least one resort")]
646 ZeroResorts,
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
651pub struct StoredBrandName(String);
653
654impl StoredBrandName {
655 pub fn try_new(value: impl Into<String>) -> Result<Self> {
657 let value = value.into().trim().to_owned();
658 if value.is_empty() {
659 return Err(Error::InvalidDomainValue {
660 field: StorageField::BrandName,
661 reason: "brand name cannot be empty".to_owned(),
662 });
663 }
664 Ok(Self(value))
665 }
666
667 pub fn as_str(&self) -> &str {
669 &self.0
670 }
671}
672
673impl TryFrom<PetResortPortfolioRecord> for pet_resort::Portfolio {
674 type Error = Error;
675
676 fn try_from(record: PetResortPortfolioRecord) -> Result<Self> {
677 Ok(Self::builder()
678 .operator(record.operator.into())
679 .resort_count(record.resort_count.try_into()?)
680 .structure(record.structure.into())
681 .business_lines(record.business_lines.into_iter().map(Into::into).collect())
682 .brands(
683 record
684 .brands
685 .into_iter()
686 .map(TryInto::try_into)
687 .collect::<Result<Vec<_>>>()?,
688 )
689 .build())
690 }
691}
692
693impl TryFrom<pet_resort::Portfolio> for PetResortPortfolioRecord {
694 type Error = Error;
695
696 fn try_from(domain_portfolio: pet_resort::Portfolio) -> Result<Self> {
697 Ok(Self::builder()
698 .operator(domain_portfolio.operator.into())
699 .resort_count(domain_portfolio.resort_count.try_into()?)
700 .structure(domain_portfolio.structure.into())
701 .business_lines(
702 domain_portfolio
703 .business_lines
704 .into_iter()
705 .map(Into::into)
706 .collect(),
707 )
708 .brands(
709 domain_portfolio
710 .brands
711 .into_iter()
712 .map(TryInto::try_into)
713 .collect::<Result<Vec<_>>>()?,
714 )
715 .build())
716 }
717}
718
719impl From<OperatorCode> for pet_resort::Operator {
720 fn from(value: OperatorCode) -> Self {
721 match value {
722 OperatorCode::NationalVeterinaryAssociates => Self::NationalVeterinaryAssociates,
723 }
724 }
725}
726
727impl From<pet_resort::Operator> for OperatorCode {
728 fn from(value: pet_resort::Operator) -> Self {
729 match value {
730 pet_resort::Operator::NationalVeterinaryAssociates => {
731 Self::NationalVeterinaryAssociates
732 }
733 }
734 }
735}
736
737impl From<PortfolioStructureCode> for pet_resort::PortfolioStructure {
738 fn from(value: PortfolioStructureCode) -> Self {
739 match value {
740 PortfolioStructureCode::FederatedMultiBrand => Self::FederatedMultiBrand,
741 PortfolioStructureCode::SingleBrand => Self::SingleBrand,
742 PortfolioStructureCode::Unknown => Self::Unknown,
743 }
744 }
745}
746
747impl From<pet_resort::PortfolioStructure> for PortfolioStructureCode {
748 fn from(value: pet_resort::PortfolioStructure) -> Self {
749 match value {
750 pet_resort::PortfolioStructure::FederatedMultiBrand => Self::FederatedMultiBrand,
751 pet_resort::PortfolioStructure::SingleBrand => Self::SingleBrand,
752 pet_resort::PortfolioStructure::Unknown => Self::Unknown,
753 }
754 }
755}
756
757impl From<BusinessLineCode> for pet_resort::BusinessLine {
758 fn from(value: BusinessLineCode) -> Self {
759 match value {
760 BusinessLineCode::GeneralPracticeVeterinaryHospitals => {
761 Self::GeneralPracticeVeterinaryHospitals
762 }
763 BusinessLineCode::PetResorts => Self::PetResorts,
764 BusinessLineCode::Equine => Self::Equine,
765 BusinessLineCode::SpecialtyEmergencyHospitals => Self::SpecialtyEmergencyHospitals,
766 }
767 }
768}
769
770impl From<pet_resort::BusinessLine> for BusinessLineCode {
771 fn from(value: pet_resort::BusinessLine) -> Self {
772 match value {
773 pet_resort::BusinessLine::GeneralPracticeVeterinaryHospitals => {
774 Self::GeneralPracticeVeterinaryHospitals
775 }
776 pet_resort::BusinessLine::PetResorts => Self::PetResorts,
777 pet_resort::BusinessLine::Equine => Self::Equine,
778 pet_resort::BusinessLine::SpecialtyEmergencyHospitals => {
779 Self::SpecialtyEmergencyHospitals
780 }
781 }
782 }
783}
784
785impl TryFrom<StoredResortCount> for domain::operations::ResortCount {
786 type Error = Error;
787
788 fn try_from(value: StoredResortCount) -> Result<Self> {
789 domain::operations::ResortCount::try_new(value.get()).map_err(|err| {
790 Error::InvalidDomainValue {
791 field: StorageField::ResortCount,
792 reason: err.to_string(),
793 }
794 })
795 }
796}
797
798impl TryFrom<domain::operations::ResortCount> for StoredResortCount {
799 type Error = Error;
800
801 fn try_from(value: domain::operations::ResortCount) -> Result<Self> {
802 Self::try_new(value.get()).map_err(|err| Error::InvalidDomainValue {
803 field: StorageField::ResortCount,
804 reason: err.to_string(),
805 })
806 }
807}
808
809impl TryFrom<PetResortBrandRecord> for pet_resort::Brand {
810 type Error = Error;
811
812 fn try_from(value: PetResortBrandRecord) -> Result<Self> {
813 Ok(match value {
814 PetResortBrandRecord::Known { code } => code.into(),
815 PetResortBrandRecord::Other { name } => Self::Other {
816 name: ::domain::location::Name::try_new(name.as_str()).map_err(|err| {
817 Error::InvalidDomainValue {
818 field: StorageField::BrandName,
819 reason: err.to_string(),
820 }
821 })?,
822 },
823 })
824 }
825}
826
827impl TryFrom<pet_resort::Brand> for PetResortBrandRecord {
828 type Error = Error;
829
830 fn try_from(value: pet_resort::Brand) -> Result<Self> {
831 Ok(match value {
832 pet_resort::Brand::NvaPetResorts => Self::Known {
833 code: PetResortBrandCode::NvaPetResorts,
834 },
835 pet_resort::Brand::PetSuites => Self::Known {
836 code: PetResortBrandCode::PetSuites,
837 },
838 pet_resort::Brand::PoochHotel => Self::Known {
839 code: PetResortBrandCode::PoochHotel,
840 },
841 pet_resort::Brand::EliteSuites => Self::Known {
842 code: PetResortBrandCode::EliteSuites,
843 },
844 pet_resort::Brand::TheBarkSide => Self::Known {
845 code: PetResortBrandCode::TheBarkSide,
846 },
847 pet_resort::Brand::WoofdorfAstoria => Self::Known {
848 code: PetResortBrandCode::WoofdorfAstoria,
849 },
850 pet_resort::Brand::DoggieDistrict => Self::Known {
851 code: PetResortBrandCode::DoggieDistrict,
852 },
853 pet_resort::Brand::Other { name } => Self::Other {
854 name: StoredBrandName::try_new(name.into_inner())?,
855 },
856 })
857 }
858}
859
860impl From<PetResortBrandCode> for pet_resort::Brand {
861 fn from(value: PetResortBrandCode) -> Self {
862 match value {
863 PetResortBrandCode::NvaPetResorts => Self::NvaPetResorts,
864 PetResortBrandCode::PetSuites => Self::PetSuites,
865 PetResortBrandCode::PoochHotel => Self::PoochHotel,
866 PetResortBrandCode::EliteSuites => Self::EliteSuites,
867 PetResortBrandCode::TheBarkSide => Self::TheBarkSide,
868 PetResortBrandCode::WoofdorfAstoria => Self::WoofdorfAstoria,
869 PetResortBrandCode::DoggieDistrict => Self::DoggieDistrict,
870 }
871 }
872}
873
874#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
875pub struct ServiceOfferingRecord {
877 pub service_kind: ServiceOfferingKindCode,
879 pub boarding_accommodation: Option<boarding::AccommodationCode>,
881 #[builder(default)]
882 pub boarding_included_care: Vec<boarding::CareFeatureCode>,
884 #[builder(default)]
885 pub boarding_add_ons: Vec<boarding::AddOnCode>,
887 pub daycare_format: Option<daycare::FormatCode>,
889 #[builder(default)]
890 pub daycare_eligibility_rules: Vec<daycare::EligibilityRuleCode>,
892 pub grooming_service: Option<grooming::ServiceCode>,
894 pub grooming_cadence_weeks: Option<grooming::StoredCadenceWeeks>,
896 pub training_program: Option<training::ProgramRecord>,
898 pub retail_partner: Option<retail::PartnerCode>,
900 pub retail_product_category: Option<retail::ProductCategoryCode>,
902}
903
904impl ServiceOfferingRecord {
905 pub fn decode_json(raw: &str) -> Result<Self> {
907 serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
908 }
909
910 pub fn encode_json(&self) -> Result<String> {
912 serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
913 }
914
915 fn mismatch(reason: ShapeMismatchReason) -> Error {
916 Error::StorageShapeMismatch {
917 record: RecordKind::ServiceOffering,
918 reason,
919 }
920 }
921
922 fn ensure_empty_cross_variant_fields(&self, allowed: ServiceOfferingKindCode) -> Result<()> {
923 let invalid = match allowed {
924 ServiceOfferingKindCode::Boarding => {
925 self.daycare_format.is_some()
926 || !self.daycare_eligibility_rules.is_empty()
927 || self.grooming_service.is_some()
928 || self.grooming_cadence_weeks.is_some()
929 || self.training_program.is_some()
930 || self.retail_partner.is_some()
931 || self.retail_product_category.is_some()
932 }
933 ServiceOfferingKindCode::Daycare => {
934 self.boarding_accommodation.is_some()
935 || !self.boarding_included_care.is_empty()
936 || !self.boarding_add_ons.is_empty()
937 || self.grooming_service.is_some()
938 || self.grooming_cadence_weeks.is_some()
939 || self.training_program.is_some()
940 || self.retail_partner.is_some()
941 || self.retail_product_category.is_some()
942 }
943 ServiceOfferingKindCode::Grooming => {
944 self.boarding_accommodation.is_some()
945 || !self.boarding_included_care.is_empty()
946 || !self.boarding_add_ons.is_empty()
947 || self.daycare_format.is_some()
948 || !self.daycare_eligibility_rules.is_empty()
949 || self.training_program.is_some()
950 || self.retail_partner.is_some()
951 || self.retail_product_category.is_some()
952 }
953 ServiceOfferingKindCode::Training => {
954 self.boarding_accommodation.is_some()
955 || !self.boarding_included_care.is_empty()
956 || !self.boarding_add_ons.is_empty()
957 || self.daycare_format.is_some()
958 || !self.daycare_eligibility_rules.is_empty()
959 || self.grooming_service.is_some()
960 || self.grooming_cadence_weeks.is_some()
961 || self.retail_partner.is_some()
962 || self.retail_product_category.is_some()
963 }
964 ServiceOfferingKindCode::RetailPartnerProduct => {
965 self.boarding_accommodation.is_some()
966 || !self.boarding_included_care.is_empty()
967 || !self.boarding_add_ons.is_empty()
968 || self.daycare_format.is_some()
969 || !self.daycare_eligibility_rules.is_empty()
970 || self.grooming_service.is_some()
971 || self.grooming_cadence_weeks.is_some()
972 || self.training_program.is_some()
973 }
974 };
975
976 if invalid {
977 Err(Self::mismatch(
978 ShapeMismatchReason::FieldBelongsToDifferentVariant,
979 ))
980 } else {
981 Ok(())
982 }
983 }
984}
985
986#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
987#[serde(rename_all = "snake_case")]
988pub enum ServiceOfferingKindCode {
990 Boarding,
992 Daycare,
994 Grooming,
996 Training,
998 RetailPartnerProduct,
1000}
1001
1002impl TryFrom<domain::operations::ServiceOffering> for ServiceOfferingRecord {
1003 type Error = Error;
1004
1005 fn try_from(value: domain::operations::ServiceOffering) -> Result<Self> {
1006 Ok(match value {
1007 domain::operations::ServiceOffering::Boarding {
1008 accommodation,
1009 included_care,
1010 add_ons,
1011 } => Self::builder()
1012 .service_kind(ServiceOfferingKindCode::Boarding)
1013 .boarding_accommodation(accommodation.into())
1014 .boarding_included_care(included_care.into_iter().map(Into::into).collect())
1015 .boarding_add_ons(add_ons.into_iter().map(Into::into).collect())
1016 .build(),
1017 domain::operations::ServiceOffering::Daycare {
1018 format,
1019 eligibility_rules,
1020 } => Self::builder()
1021 .service_kind(ServiceOfferingKindCode::Daycare)
1022 .daycare_format(format.into())
1023 .daycare_eligibility_rules(eligibility_rules.into_iter().map(Into::into).collect())
1024 .build(),
1025 domain::operations::ServiceOffering::Grooming { service, cadence } => {
1026 let cadence_weeks = match cadence {
1027 domain::grooming::rebooking::Cadence::EveryWeeks(weeks) => {
1028 Some(weeks.try_into()?)
1029 }
1030 domain::grooming::rebooking::Cadence::AsNeeded
1031 | domain::grooming::rebooking::Cadence::GroomerRecommended
1032 | domain::grooming::rebooking::Cadence::Unknown => None,
1033 };
1034 let builder = Self::builder()
1035 .service_kind(ServiceOfferingKindCode::Grooming)
1036 .grooming_service(service.into());
1037 match cadence_weeks {
1038 Some(weeks) => builder.grooming_cadence_weeks(weeks).build(),
1039 None => builder.build(),
1040 }
1041 }
1042 domain::operations::ServiceOffering::Training { program } => Self::builder()
1043 .service_kind(ServiceOfferingKindCode::Training)
1044 .training_program(program.try_into()?)
1045 .build(),
1046 domain::operations::ServiceOffering::RetailPartnerProduct { partner, category } => {
1047 Self::builder()
1048 .service_kind(ServiceOfferingKindCode::RetailPartnerProduct)
1049 .retail_partner(partner.into())
1050 .retail_product_category(category.into())
1051 .build()
1052 }
1053 })
1054 }
1055}
1056
1057impl TryFrom<ServiceOfferingRecord> for domain::operations::ServiceOffering {
1058 type Error = Error;
1059
1060 fn try_from(record: ServiceOfferingRecord) -> Result<Self> {
1061 match record.service_kind {
1062 ServiceOfferingKindCode::Boarding => {
1063 record.ensure_empty_cross_variant_fields(ServiceOfferingKindCode::Boarding)?;
1064 Ok(Self::Boarding {
1065 accommodation: record
1066 .boarding_accommodation
1067 .ok_or_else(|| {
1068 ServiceOfferingRecord::mismatch(
1069 ShapeMismatchReason::RequiredFieldMissing,
1070 )
1071 })?
1072 .into(),
1073 included_care: record
1074 .boarding_included_care
1075 .into_iter()
1076 .map(Into::into)
1077 .collect(),
1078 add_ons: record
1079 .boarding_add_ons
1080 .into_iter()
1081 .map(Into::into)
1082 .collect(),
1083 })
1084 }
1085 ServiceOfferingKindCode::Daycare => {
1086 record.ensure_empty_cross_variant_fields(ServiceOfferingKindCode::Daycare)?;
1087 Ok(Self::Daycare {
1088 format: record
1089 .daycare_format
1090 .ok_or_else(|| {
1091 ServiceOfferingRecord::mismatch(
1092 ShapeMismatchReason::RequiredFieldMissing,
1093 )
1094 })?
1095 .into(),
1096 eligibility_rules: record
1097 .daycare_eligibility_rules
1098 .into_iter()
1099 .map(Into::into)
1100 .collect(),
1101 })
1102 }
1103 ServiceOfferingKindCode::Grooming => {
1104 record.ensure_empty_cross_variant_fields(ServiceOfferingKindCode::Grooming)?;
1105 let service = record
1106 .grooming_service
1107 .ok_or_else(|| {
1108 ServiceOfferingRecord::mismatch(ShapeMismatchReason::RequiredFieldMissing)
1109 })?
1110 .into();
1111 let cadence = match record.grooming_cadence_weeks {
1112 Some(weeks) => {
1113 domain::grooming::rebooking::Cadence::EveryWeeks(weeks.try_into()?)
1114 }
1115 None => domain::grooming::rebooking::Cadence::Unknown,
1116 };
1117 Ok(Self::Grooming { service, cadence })
1118 }
1119 ServiceOfferingKindCode::Training => {
1120 record.ensure_empty_cross_variant_fields(ServiceOfferingKindCode::Training)?;
1121 Ok(Self::Training {
1122 program: record
1123 .training_program
1124 .ok_or_else(|| {
1125 ServiceOfferingRecord::mismatch(
1126 ShapeMismatchReason::RequiredFieldMissing,
1127 )
1128 })?
1129 .try_into()?,
1130 })
1131 }
1132 ServiceOfferingKindCode::RetailPartnerProduct => {
1133 record.ensure_empty_cross_variant_fields(
1134 ServiceOfferingKindCode::RetailPartnerProduct,
1135 )?;
1136 Ok(Self::RetailPartnerProduct {
1137 partner: record
1138 .retail_partner
1139 .ok_or_else(|| {
1140 ServiceOfferingRecord::mismatch(
1141 ShapeMismatchReason::RequiredFieldMissing,
1142 )
1143 })?
1144 .into(),
1145 category: record
1146 .retail_product_category
1147 .ok_or_else(|| {
1148 ServiceOfferingRecord::mismatch(
1149 ShapeMismatchReason::RequiredFieldMissing,
1150 )
1151 })?
1152 .into(),
1153 })
1154 }
1155 }
1156 }
1157}
1158
1159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1160pub struct CoreServiceContractsRecord {
1162 pub location_id: domain::entities::LocationId,
1164 pub boarding: boarding::ContractRecord,
1166 pub daycare: daycare::ContractRecord,
1168 pub grooming: grooming::ContractRecord,
1170 pub training: training::ContractRecord,
1172 pub retail: retail::ContractRecord,
1174}
1175
1176impl CoreServiceContractsRecord {
1177 pub const fn record_kind(&self) -> RecordKind {
1179 RecordKind::CoreServiceContracts
1180 }
1181
1182 pub fn encode_json(&self) -> Result<String> {
1184 serde_json::to_string(self)
1185 .map_err(|source| Error::Codec(CodecError::JsonEncode { source }))
1186 }
1187
1188 pub fn decode_json(raw: &str) -> Result<Self> {
1190 serde_json::from_str(raw).map_err(|source| Error::Codec(CodecError::JsonDecode { source }))
1191 }
1192}
1193
1194impl From<service_core::ServiceContracts> for CoreServiceContractsRecord {
1195 fn from(contracts: service_core::ServiceContracts) -> Self {
1196 Self {
1197 location_id: contracts.location_id,
1198 boarding: contracts.boarding.into(),
1199 daycare: contracts.daycare.into(),
1200 grooming: contracts.grooming.into(),
1201 training: contracts.training.into(),
1202 retail: contracts.retail.into(),
1203 }
1204 }
1205}
1206
1207impl From<CoreServiceContractsRecord> for service_core::ServiceContracts {
1208 fn from(record: CoreServiceContractsRecord) -> Self {
1209 Self::builder()
1210 .location_id(record.location_id)
1211 .boarding(record.boarding.into())
1212 .daycare(record.daycare.into())
1213 .grooming(record.grooming.into())
1214 .training(record.training.into())
1215 .retail(record.retail.into())
1216 .build()
1217 }
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
1221pub struct TechnologyEcosystemRecord {
1223 pub core_portal: CoreOperatingSystemCode,
1225 pub data_access: Vec<DataAccessPatternCode>,
1227 pub adjacent_systems: Vec<AdjacentSystemCode>,
1229}
1230
1231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1232#[serde(rename_all = "snake_case")]
1233pub enum CoreOperatingSystemCode {
1235 Gingr,
1237 MixedSystems,
1239 Unknown,
1241}
1242
1243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1244#[serde(rename_all = "snake_case")]
1245pub enum DataAccessPatternCode {
1247 Api,
1249 Webhook,
1251 DataExport,
1253 Warehouse,
1255 BusinessIntelligenceDashboard,
1257 Unknown,
1259}
1260
1261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1262#[serde(rename_all = "snake_case")]
1263pub enum AdjacentSystemCode {
1265 AvatureRecruiting,
1267 Ga4,
1269 Amplitude,
1271 GoogleTagManager,
1273 Hris,
1275 LaborScheduling,
1277 Payroll,
1279 MarketingAutomation,
1281 Ticketing,
1283 CallCenterTelephony,
1285 Reviews,
1287 EmailSmsMarketing,
1289 BusinessIntelligence,
1291 DataLake,
1293}
1294
1295impl From<domain::operations::TechnologyEcosystem> for TechnologyEcosystemRecord {
1296 fn from(value: domain::operations::TechnologyEcosystem) -> Self {
1297 Self::builder()
1298 .core_portal(value.core_portal.into())
1299 .data_access(value.data_access.into_iter().map(Into::into).collect())
1300 .adjacent_systems(value.adjacent_systems.into_iter().map(Into::into).collect())
1301 .build()
1302 }
1303}
1304
1305impl From<TechnologyEcosystemRecord> for domain::operations::TechnologyEcosystem {
1306 fn from(value: TechnologyEcosystemRecord) -> Self {
1307 Self::builder()
1308 .core_portal(value.core_portal.into())
1309 .data_access(value.data_access.into_iter().map(Into::into).collect())
1310 .adjacent_systems(value.adjacent_systems.into_iter().map(Into::into).collect())
1311 .build()
1312 }
1313}
1314
1315macro_rules! bidirectional_code_map {
1316 ($storage:ty, $domain:ty, { $($storage_variant:ident => $domain_variant:ident),+ $(,)? }) => {
1317 impl From<$storage> for $domain {
1318 fn from(value: $storage) -> Self {
1319 match value {
1320 $(<$storage>::$storage_variant => Self::$domain_variant,)+
1321 }
1322 }
1323 }
1324
1325 impl From<$domain> for $storage {
1326 fn from(value: $domain) -> Self {
1327 match value {
1328 $(<$domain>::$domain_variant => Self::$storage_variant,)+
1329 }
1330 }
1331 }
1332 };
1333}
1334
1335bidirectional_code_map!(CoreOperatingSystemCode, service_core::OperatingSystem, {
1336 Gingr => Gingr,
1337 MixedSystems => MixedSystems,
1338 Unknown => Unknown,
1339});
1340
1341bidirectional_code_map!(DataAccessPatternCode, domain::operations::DataAccessPattern, {
1342 Api => Api,
1343 Webhook => Webhook,
1344 DataExport => DataExport,
1345 Warehouse => Warehouse,
1346 BusinessIntelligenceDashboard => BusinessIntelligenceDashboard,
1347 Unknown => Unknown,
1348});
1349
1350bidirectional_code_map!(AdjacentSystemCode, domain::operations::AdjacentSystem, {
1351 AvatureRecruiting => AvatureRecruiting,
1352 Ga4 => Ga4,
1353 Amplitude => Amplitude,
1354 GoogleTagManager => GoogleTagManager,
1355 Hris => Hris,
1356 LaborScheduling => LaborScheduling,
1357 Payroll => Payroll,
1358 MarketingAutomation => MarketingAutomation,
1359 Ticketing => Ticketing,
1360 CallCenterTelephony => CallCenterTelephony,
1361 Reviews => Reviews,
1362 EmailSmsMarketing => EmailSmsMarketing,
1363 BusinessIntelligence => BusinessIntelligence,
1364 DataLake => DataLake,
1365});