Skip to main content

storage/
operations.rs

1//! Persistence records for app/domain operational contracts.
2//!
3//! This module documents the storage/public projection boundary for the
4//! pet-resort AI program: portfolio seed facts, service-line offerings, core
5//! service contracts, manager daily-brief labor outcomes, data-quality hygiene
6//! outcomes, and source-system ecosystem records. Storage code is allowed to
7//! speak in stable record codes, flattened optional fields, and JSON payloads,
8//! but promotion back into `domain` values is explicit and source-grounded.
9//!
10//! The boundary is deliberately narrow:
11//!
12//! - `domain` owns business meaning and invariants such as daycare eligibility,
13//!   grooming cadence, training duration, source evidence, and review gates.
14//! - `storage` owns durable representations, discriminator checks, codec errors,
15//!   and idempotent evidence records suitable for Postgres or fixtures.
16//! - `app` and runtime crates decide when a workflow may read or write records;
17//!   storage records never authorize live provider writes or customer messaging.
18//! - `integration` adapters attach `StoredSourceRecordRef` values so a derived
19//!   record can be audited back to Gingr, a warehouse export, or another source
20//!   instead of becoming an invented operational fact.
21//!
22//! ```rust
23//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
24//! use storage::operations::{
25//!     ServiceOfferingKindCode, ServiceOfferingRecord, StoredSourceRecordRef,
26//! };
27//!
28//! let source_ref = StoredSourceRecordRef {
29//!     system: "gingr".to_owned(),
30//!     record_type: "reservation_type".to_owned(),
31//!     record_id: "reservation-type-42".to_owned(),
32//!     observed_at: "2026-06-18T14:00:00Z".to_owned(),
33//!     adapter_version: "gingr-fixture-v1".to_owned(),
34//! };
35//!
36//! let promoted_service = domain::operations::ServiceOffering::Daycare {
37//!     format: domain::operations::DaycareFormat::AllDayPlay,
38//!     eligibility_rules: vec![
39//!         domain::operations::DaycareEligibilityRule::TemperamentReviewRequired,
40//!         domain::operations::DaycareEligibilityRule::StaffToPetRatioRequired,
41//!     ],
42//! };
43//!
44//! let stored = ServiceOfferingRecord::try_from(promoted_service.clone())?;
45//! assert_eq!(stored.service_kind, ServiceOfferingKindCode::Daycare);
46//! assert_eq!(source_ref.record_id, "reservation-type-42");
47//!
48//! let encoded = stored.encode_json()?;
49//! let decoded = ServiceOfferingRecord::decode_json(&encoded)?;
50//! let demoted: domain::operations::ServiceOffering = decoded.try_into()?;
51//! assert_eq!(demoted, promoted_service);
52//! # Ok(())
53//! # }
54//! ```
55
56use 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
70/// Result type returned by fallible storage projection and codec operations.
71pub type Result<T> = std::result::Result<T, Error>;
72
73#[derive(Debug, thiserror::Error)]
74/// Errors raised while validating storage records, codecs, or domain-to-storage projection.
75pub enum Error {
76    #[error("storage codec error")]
77    /// Wraps a storage JSON codec failure without losing the underlying source error.
78    Codec(#[from] CodecError),
79    #[error("{record:?} storage shape mismatch: {reason:?}")]
80    /// Signals that a flattened record populated fields inconsistent with its discriminator.
81    StorageShapeMismatch {
82        /// Record family whose flattened storage shape failed validation.
83        record: RecordKind,
84        /// Human-readable or typed reason explaining why storage conversion failed.
85        reason: ShapeMismatchReason,
86    },
87    #[error("domain value rejected storage field {field:?}: {reason}")]
88    /// Signals that a domain value cannot be represented safely in storage.
89    InvalidDomainValue {
90        /// Storage field whose value failed projection or validation.
91        field: StorageField,
92        /// Human-readable reason explaining why the storage projection was unsafe.
93        reason: String,
94    },
95}
96
97#[derive(Debug, thiserror::Error)]
98/// JSON codec failures at the storage boundary.
99pub enum CodecError {
100    #[error("failed to decode json: {source}")]
101    /// JSON could not be decoded into the expected storage record.
102    JsonDecode {
103        /// Underlying serde error raised while decoding the stored payload.
104        source: serde_json::Error,
105    },
106    #[error("failed to encode json: {source}")]
107    /// Storage record could not be serialized as JSON.
108    JsonEncode {
109        /// Underlying serde error raised while encoding the stored payload.
110        source: serde_json::Error,
111    },
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115/// Storage record families used in shape-validation diagnostics.
116pub enum RecordKind {
117    /// Portfolio seed facts used to orient multi-brand NVA pet-resort assumptions.
118    PetResortPortfolio,
119    /// Flattened record for one boarding, daycare, grooming, training, or retail offering.
120    ServiceOffering,
121    /// Location-level snapshot of enabled service-line contracts.
122    CoreServiceContracts,
123    /// Labor-evidence record for a data-quality hygiene workflow outcome.
124    DataQualityHygieneOutcome,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128/// Reasons a flattened record cannot represent the requested domain variant.
129pub enum ShapeMismatchReason {
130    /// A field required by the selected discriminator was absent.
131    RequiredFieldMissing,
132    /// A field from another flattened variant was populated.
133    FieldBelongsToDifferentVariant,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137/// Persisted fields that can reject invalid domain values during storage conversion.
138pub enum StorageField {
139    /// Resort-count field promoted into a positive domain count.
140    ResortCount,
141    /// Freeform brand-name field preserved for non-enumerated pet-resort banners.
142    BrandName,
143    /// Grooming cadence quantity persisted in weeks when cadence is known.
144    GroomingCadenceWeeks,
145    /// Training program duration quantity persisted in weeks.
146    TrainingProgramDurationWeeks,
147    /// Manager daily-brief labor-minute field used for before/after evidence.
148    ManagerDailyBriefLaborMinutes,
149    /// Data-quality hygiene labor-minute field used for before/after evidence.
150    DataQualityHygieneLaborMinutes,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
154/// Provider provenance attached to stored evidence so facts can be audited back to Gingr or another source system.
155pub struct StoredSourceRecordRef {
156    /// Source system name, for example `gingr`, used to keep provider facts quarantined by origin.
157    pub system: String,
158    /// Provider record collection or endpoint that produced the evidence.
159    pub record_type: String,
160    /// Provider-native identifier for the source record.
161    pub record_id: String,
162    /// Timestamp when the adapter observed this provider fact.
163    pub observed_at: String,
164    /// Adapter or fixture version that interpreted the source record.
165    pub adapter_version: String,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170/// Persisted outcome states for manager daily-brief actions.
171pub enum ManagerDailyBriefOutcomeCode {
172    /// Workflow completed and can contribute final labor evidence.
173    Completed,
174    /// Workflow was postponed and should not be counted as completed savings.
175    Deferred,
176    /// Manager intentionally hid or skipped the suggested workflow action.
177    SuppressedByManager,
178    /// Provider evidence was incorrect, so the action is excluded or corrected.
179    SourceFactWasWrong,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184/// Persisted staff personas accountable for manager daily-brief work.
185pub enum ManagerDailyBriefPersonaCode {
186    /// Stable storage code for general manager.
187    GeneralManager,
188    /// Stable storage code for assistant general manager.
189    AssistantGeneralManager,
190    /// Stable storage code for front desk lead.
191    FrontDeskLead,
192    /// Stable storage code for front desk agent.
193    FrontDeskAgent,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198/// Persisted manager daily-brief actions that can produce labor-minute evidence.
199pub enum ManagerDailyBriefActionKindCode {
200    /// Stable storage code for review demand against staffing plan.
201    ReviewDemandAgainstStaffingPlan,
202    /// Stable storage code for resolve checkout exception.
203    ResolveCheckoutException,
204    /// Stable storage code for approve retention follow up draft.
205    ApproveRetentionFollowUpDraft,
206    /// Stable storage code for investigate source data quality issue.
207    InvestigateSourceDataQualityIssue,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211/// Dimensions used to aggregate manager daily-brief labor outcomes by location, day, action, and owner role.
212pub struct ManagerDailyBriefReportingGroup {
213    /// Location whose operating day or service contract is described.
214    pub location_id: String,
215    /// Business date used for labor and reporting aggregation.
216    pub operating_day: String,
217    /// Workflow action that generated the labor evidence.
218    pub action_kind: ManagerDailyBriefActionKindCode,
219    /// Role expected to own or review the workflow item.
220    pub owner_persona: ManagerDailyBriefPersonaCode,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
224#[serde(transparent)]
225/// Non-zero minute quantity persisted for manager daily-brief labor evidence.
226pub struct StoredManagerDailyBriefLaborMinutes(u16);
227
228impl StoredManagerDailyBriefLaborMinutes {
229    /// Validates and wraps a non-empty brand name before persistence.
230    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    /// Returns the validated numeric quantity carried by this storage wrapper.
242    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)]
258/// Stored evidence for a manager daily-brief action, including before/after labor minutes and source references.
259pub struct ManagerDailyBriefOutcomeRecord {
260    /// Stable workflow action identifier used for idempotent labor evidence.
261    pub action_id: String,
262    /// Final disposition recorded for the workflow action.
263    pub outcome: ManagerDailyBriefOutcomeCode,
264    /// Estimated manual minutes before automation or assisted workflow execution.
265    pub before_minutes: StoredManagerDailyBriefLaborMinutes,
266    /// Observed minutes spent after the workflow was completed or reviewed.
267    pub actual_minutes: StoredManagerDailyBriefLaborMinutes,
268    /// User, worker, or system actor that recorded the outcome.
269    pub actor_id: String,
270    /// Role of the actor that completed or reviewed the action.
271    pub actor_persona: ManagerDailyBriefPersonaCode,
272    /// Optional operator feedback explaining the decision or correction.
273    pub feedback: String,
274    #[builder(default)]
275    /// Provider evidence records used to justify the workflow action.
276    pub source_refs: Vec<StoredSourceRecordRef>,
277    /// Timestamp when the labor evidence was written.
278    pub recorded_at: String,
279    /// Cross-system identifier tying the record to a workflow run or request.
280    pub correlation_id: String,
281    /// Location whose operating day or service contract is described.
282    pub location_id: String,
283    /// Business date used for labor and reporting aggregation.
284    pub operating_day: String,
285    /// Workflow action that generated the labor evidence.
286    pub action_kind: ManagerDailyBriefActionKindCode,
287    /// Role expected to own or review the workflow item.
288    pub owner_persona: ManagerDailyBriefPersonaCode,
289    /// Derived labor savings based on before and actual minute evidence.
290    pub estimated_minutes_saved: u16,
291}
292
293impl ManagerDailyBriefOutcomeRecord {
294    /// Decodes a JSON storage payload into its typed record shape.
295    pub fn decode_json(raw: &str) -> Result<Self> {
296        serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
297    }
298
299    /// Encodes the storage record as JSON for persistence or fixture comparison.
300    pub fn encode_json(&self) -> Result<String> {
301        serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
302    }
303
304    /// Returns the derived minutes saved from before/after labor evidence.
305    pub const fn actual_minutes_saved(&self) -> u16 {
306        self.before_minutes
307            .get()
308            .saturating_sub(self.actual_minutes.get())
309    }
310
311    /// Returns the aggregation dimensions used for labor reporting.
312    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")]
324/// Persisted outcome states for data-quality hygiene actions.
325pub enum DataQualityHygieneOutcomeCode {
326    /// Workflow completed and can contribute final labor evidence.
327    Completed,
328    /// Workflow was postponed and should not be counted as completed savings.
329    Deferred,
330    /// Manager intentionally hid or skipped the suggested workflow action.
331    SuppressedByManager,
332    /// Provider evidence was incorrect, so the action is excluded or corrected.
333    SourceFactWasWrong,
334    /// Issue was reviewed but did not require an operational repair.
335    NotActionable,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340/// Persisted personas accountable for data-quality hygiene work.
341pub enum DataQualityHygienePersonaCode {
342    /// Stable storage code for general manager.
343    GeneralManager,
344    /// Stable storage code for assistant general manager.
345    AssistantGeneralManager,
346    /// Stable storage code for front desk lead.
347    FrontDeskLead,
348    /// Stable storage code for front desk agent.
349    FrontDeskAgent,
350    /// Stable storage code for regional operator.
351    RegionalOperator,
352    /// Stable storage code for operations analyst.
353    OperationsAnalyst,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "snake_case")]
358/// Persisted data-quality actions used to quarantine, repair, or reconcile source evidence.
359pub enum DataQualityHygieneActionKindCode {
360    /// Stable storage code for investigate missing source evidence.
361    InvestigateMissingSourceEvidence,
362    /// Stable storage code for reconcile duplicate customer or pet candidate.
363    ReconcileDuplicateCustomerOrPetCandidate,
364    /// Stable storage code for complete missing pet or customer profile fields.
365    CompleteMissingPetOrCustomerProfileFields,
366    /// Stable storage code for review stale vaccination source freshness.
367    ReviewStaleVaccinationSourceFreshness,
368    /// Stable storage code for normalize ambiguous service line naming.
369    NormalizeAmbiguousServiceLineNaming,
370    /// Stable storage code for review checkout or unclosed reservation evidence.
371    ReviewCheckoutOrUnclosedReservationEvidence,
372    /// Stable storage code for escalate sensitive or quarantined payload.
373    EscalateSensitiveOrQuarantinedPayload,
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378/// Persisted lifecycle status for a data-quality issue after review.
379pub enum DataQualityResolutionStatusCode {
380    /// Issue remains open after review.
381    Open,
382    /// Issue was accepted for later repair or monitoring.
383    Acknowledged,
384    /// Issue was intentionally ignored after review.
385    Ignored,
386    /// Issue was corrected during or after review.
387    Repaired,
388    /// Issue was replaced by fresher evidence or another issue record.
389    Superseded,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393/// Dimensions used to group data-quality hygiene outcomes by location, day, issue type, and owner role.
394pub struct DataQualityHygieneReportingGroup {
395    /// Location whose operating day or service contract is described.
396    pub location_id: String,
397    /// Business date used for labor and reporting aggregation.
398    pub operating_day: String,
399    /// Workflow action that generated the labor evidence.
400    pub action_kind: DataQualityHygieneActionKindCode,
401    /// Role expected to own or review the workflow item.
402    pub owner_persona: DataQualityHygienePersonaCode,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
406#[serde(transparent)]
407/// Non-zero minute quantity persisted for data-quality hygiene labor evidence.
408pub struct StoredDataQualityHygieneLaborMinutes(u16);
409
410impl StoredDataQualityHygieneLaborMinutes {
411    /// Validates and wraps a positive storage quantity before persistence.
412    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    /// Returns the validated resort count carried by this storage wrapper.
424    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)]
440/// Stored evidence for a data-quality hygiene action, including labor deltas, issue references, and resolution state.
441pub struct DataQualityHygieneOutcomeRecord {
442    /// Stable workflow action identifier used for idempotent labor evidence.
443    pub action_id: String,
444    /// Final disposition recorded for the workflow action.
445    pub outcome: DataQualityHygieneOutcomeCode,
446    /// Estimated manual minutes before automation or assisted workflow execution.
447    pub before_minutes: StoredDataQualityHygieneLaborMinutes,
448    /// Observed minutes spent after the workflow was completed or reviewed.
449    pub actual_minutes: StoredDataQualityHygieneLaborMinutes,
450    /// User, worker, or system actor that recorded the outcome.
451    pub actor_id: String,
452    /// Role of the actor that completed or reviewed the action.
453    pub actor_persona: DataQualityHygienePersonaCode,
454    /// Optional operator feedback explaining the decision or correction.
455    pub feedback: String,
456    #[builder(default)]
457    /// Provider evidence records used to justify the workflow action.
458    pub source_refs: Vec<StoredSourceRecordRef>,
459    #[builder(default)]
460    /// Data-quality issue identifiers reviewed by the hygiene workflow.
461    pub issue_refs: Vec<String>,
462    /// Issue lifecycle state after the hygiene review completed.
463    pub resolution_status_after_review: DataQualityResolutionStatusCode,
464    /// Timestamp when the labor evidence was written.
465    pub recorded_at: String,
466    /// Cross-system identifier tying the record to a workflow run or request.
467    pub correlation_id: String,
468    /// Location whose operating day or service contract is described.
469    pub location_id: String,
470    /// Business date used for labor and reporting aggregation.
471    pub operating_day: String,
472    /// Workflow action that generated the labor evidence.
473    pub action_kind: DataQualityHygieneActionKindCode,
474    /// Role expected to own or review the workflow item.
475    pub owner_persona: DataQualityHygienePersonaCode,
476    /// Derived labor savings based on before and actual minute evidence.
477    pub estimated_minutes_saved: u16,
478}
479
480impl DataQualityHygieneOutcomeRecord {
481    /// Decodes a JSON storage payload into its typed record shape.
482    pub fn decode_json(raw: &str) -> Result<Self> {
483        serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
484    }
485
486    /// Encodes the storage record as JSON for persistence or fixture comparison.
487    pub fn encode_json(&self) -> Result<String> {
488        serde_json::to_string(self).map_err(|source| CodecError::JsonEncode { source }.into())
489    }
490
491    /// Returns or constructs the Gingr actual minutes saved value.
492    pub const fn actual_minutes_saved(&self) -> u16 {
493        self.before_minutes
494            .get()
495            .saturating_sub(self.actual_minutes.get())
496    }
497
498    /// Returns the aggregation dimensions used for labor reporting.
499    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)]
510/// Storage shape for the pet-resort portfolio facts used to seed operating assumptions.
511pub struct PetResortPortfolioRecord {
512    /// Portfolio operator represented by the seed record.
513    pub operator: OperatorCode,
514    /// Number of resorts represented by the portfolio fact.
515    pub resort_count: StoredResortCount,
516    /// Portfolio organization model used in operating assumptions.
517    pub structure: PortfolioStructureCode,
518    /// Business lines included in the portfolio fact.
519    pub business_lines: Vec<BusinessLineCode>,
520    /// Pet-resort brands included in the portfolio fact.
521    pub brands: Vec<PetResortBrandRecord>,
522}
523
524impl PetResortPortfolioRecord {
525    /// Decodes a JSON storage payload into its typed record shape.
526    pub fn decode_json(raw: &str) -> Result<Self> {
527        serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
528    }
529
530    /// Encodes the storage record as JSON for persistence or fixture comparison.
531    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")]
538/// Stable operator code used in portfolio seed records.
539pub enum OperatorCode {
540    #[serde(rename = "nva")]
541    /// Stable storage code for national veterinary associates.
542    NationalVeterinaryAssociates,
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "snake_case")]
547/// Stable portfolio-structure codes for pet-resort operating assumptions.
548pub enum PortfolioStructureCode {
549    /// Stable storage code for federated multi brand.
550    FederatedMultiBrand,
551    /// Stable storage code for single brand.
552    SingleBrand,
553    /// Provider supplied an unrecognized value; preserve it for audit instead of failing closed.
554    Unknown,
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
558#[serde(rename_all = "snake_case")]
559/// Stable business-line codes for NVA portfolio membership.
560pub enum BusinessLineCode {
561    /// Stable storage code for general practice veterinary hospitals.
562    GeneralPracticeVeterinaryHospitals,
563    /// Stable storage code for pet resorts.
564    PetResorts,
565    /// Stable storage code for equine.
566    Equine,
567    /// Stable storage code for specialty emergency hospitals.
568    SpecialtyEmergencyHospitals,
569}
570
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(tag = "kind", rename_all = "snake_case")]
573/// Stored pet-resort brand descriptor with code plus display name.
574pub enum PetResortBrandRecord {
575    /// Enumerated brand known to the pet-resort context pack.
576    Known {
577        /// Stable brand code promoted into a domain brand.
578        code: PetResortBrandCode,
579    },
580    /// Non-enumerated brand preserved with a validated display name.
581    Other {
582        /// Validated display name for a brand not yet represented by a stable code.
583        name: StoredBrandName,
584    },
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
588#[serde(rename_all = "snake_case")]
589/// Stable brand codes for NVA pet-resort banners.
590pub enum PetResortBrandCode {
591    /// Stable storage code for nva pet resorts.
592    NvaPetResorts,
593    /// Stable storage code for pet suites.
594    PetSuites,
595    /// Stable storage code for pooch hotel.
596    PoochHotel,
597    /// Stable storage code for elite suites.
598    EliteSuites,
599    /// Stable storage code for the bark side.
600    TheBarkSide,
601    /// Stable storage code for woofdorf astoria.
602    WoofdorfAstoria,
603    /// Stable storage code for doggie district.
604    DoggieDistrict,
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
608/// Positive resort count persisted for portfolio seed facts.
609pub struct StoredResortCount(u16);
610
611impl StoredResortCount {
612    /// Validates and wraps a positive quantity before it is persisted.
613    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    /// Returns the provider numeric identifier carried by this wrapper.
621    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)]
636/// Validation failures for persisted resort-count quantities.
637pub enum StoredResortCountError {
638    #[error("stored pet resort portfolios require at least one resort")]
639    /// Stable storage code for zero resorts.
640    ZeroResorts,
641}
642
643#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
644/// Non-empty pet-resort brand display name persisted beside the stable brand code.
645pub struct StoredBrandName(String);
646
647impl StoredBrandName {
648    /// Validates and wraps a positive storage quantity before persistence.
649    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    /// Returns the normalized provider or storage string slice.
661    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)]
868/// Flattened storage shape for one service-line offering; only fields valid for its `service_kind` may be populated.
869pub struct ServiceOfferingRecord {
870    /// Discriminator indicating which service-line fields are meaningful.
871    pub service_kind: ServiceOfferingKindCode,
872    /// Boarding room or suite type for a boarding offering.
873    pub boarding_accommodation: Option<boarding::AccommodationCode>,
874    #[builder(default)]
875    /// Included care features bundled with a boarding offering.
876    pub boarding_included_care: Vec<boarding::CareFeatureCode>,
877    #[builder(default)]
878    /// Optional boarding add-ons available for the offering.
879    pub boarding_add_ons: Vec<boarding::AddOnCode>,
880    /// Daycare play or day-boarding format represented by the offering.
881    pub daycare_format: Option<daycare::FormatCode>,
882    #[builder(default)]
883    /// Eligibility requirements that must be satisfied before daycare use.
884    pub daycare_eligibility_rules: Vec<daycare::EligibilityRuleCode>,
885    /// Grooming service represented by the offering.
886    pub grooming_service: Option<grooming::ServiceCode>,
887    /// Recommended grooming repeat cadence in weeks.
888    pub grooming_cadence_weeks: Option<grooming::StoredCadenceWeeks>,
889    /// Training program represented by the offering.
890    pub training_program: Option<training::ProgramRecord>,
891    /// Retail partner product represented by the offering.
892    pub retail_partner: Option<retail::PartnerCode>,
893    /// Retail category used for merchandising and upsell logic.
894    pub retail_product_category: Option<retail::ProductCategoryCode>,
895}
896
897impl ServiceOfferingRecord {
898    /// Decodes a JSON storage payload into its typed record shape.
899    pub fn decode_json(raw: &str) -> Result<Self> {
900        serde_json::from_str(raw).map_err(|source| CodecError::JsonDecode { source }.into())
901    }
902
903    /// Encodes the storage record as JSON for persistence or fixture comparison.
904    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")]
981/// Discriminator for the service-line variant represented by a flattened offering record.
982pub enum ServiceOfferingKindCode {
983    /// Stable storage code for boarding.
984    Boarding,
985    /// Stable storage code for daycare.
986    Daycare,
987    /// Stable storage code for grooming.
988    Grooming,
989    /// Stable storage code for training.
990    Training,
991    /// Stable storage code for retail partner product.
992    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)]
1153/// Storage snapshot of the service-line contracts enabled for a location.
1154pub struct CoreServiceContractsRecord {
1155    /// Location whose operating day or service contract is described.
1156    pub location_id: domain::entities::LocationId,
1157    /// Boarding contract capabilities for the location.
1158    pub boarding: boarding::ContractRecord,
1159    /// Daycare contract capabilities for the location.
1160    pub daycare: daycare::ContractRecord,
1161    /// Grooming contract capabilities for the location.
1162    pub grooming: grooming::ContractRecord,
1163    /// Training contract capabilities for the location.
1164    pub training: training::ContractRecord,
1165    /// Retail contract capabilities for the location.
1166    pub retail: retail::ContractRecord,
1167}
1168
1169impl CoreServiceContractsRecord {
1170    /// Returns the stable record family represented by this storage snapshot.
1171    pub const fn record_kind(&self) -> RecordKind {
1172        RecordKind::CoreServiceContracts
1173    }
1174
1175    /// Encodes the storage record as JSON for persistence or fixture comparison.
1176    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    /// Decodes a JSON storage payload into its typed record shape.
1182    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)]
1214/// Stored view of the systems that produce operational data and adjacent labor signals.
1215pub struct TechnologyEcosystemRecord {
1216    /// Primary operating portal expected to originate pet-resort facts.
1217    pub core_portal: CoreOperatingSystemCode,
1218    /// Access paths available for extracting source evidence.
1219    pub data_access: Vec<DataAccessPatternCode>,
1220    /// Nearby systems that may corroborate or enrich operational evidence.
1221    pub adjacent_systems: Vec<AdjacentSystemCode>,
1222}
1223
1224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1225#[serde(rename_all = "snake_case")]
1226/// Stable codes for operational source systems that may feed NVA workflows.
1227pub enum CoreOperatingSystemCode {
1228    /// Stable storage code for gingr.
1229    Gingr,
1230    /// Stable storage code for mixed systems.
1231    MixedSystems,
1232    /// Provider supplied an unrecognized value; preserve it for audit instead of failing closed.
1233    Unknown,
1234}
1235
1236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1237#[serde(rename_all = "snake_case")]
1238/// Stable codes for how operational facts are accessed from source systems.
1239pub enum DataAccessPatternCode {
1240    /// Stable storage code for api.
1241    Api,
1242    /// Stable storage code for webhook.
1243    Webhook,
1244    /// Stable storage code for data export.
1245    DataExport,
1246    /// Stable storage code for warehouse.
1247    Warehouse,
1248    /// Stable storage code for business intelligence dashboard.
1249    BusinessIntelligenceDashboard,
1250    /// Provider supplied an unrecognized value; preserve it for audit instead of failing closed.
1251    Unknown,
1252}
1253
1254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1255#[serde(rename_all = "snake_case")]
1256/// Stable codes for adjacent systems that provide labor, recruiting, marketing, or analytics evidence.
1257pub enum AdjacentSystemCode {
1258    /// Stable storage code for avature recruiting.
1259    AvatureRecruiting,
1260    /// Stable storage code for ga4.
1261    Ga4,
1262    /// Stable storage code for amplitude.
1263    Amplitude,
1264    /// Stable storage code for google tag manager.
1265    GoogleTagManager,
1266    /// Stable storage code for hris.
1267    Hris,
1268    /// Stable storage code for labor scheduling.
1269    LaborScheduling,
1270    /// Stable storage code for payroll.
1271    Payroll,
1272    /// Stable storage code for marketing automation.
1273    MarketingAutomation,
1274    /// Stable storage code for ticketing.
1275    Ticketing,
1276    /// Stable storage code for call center telephony.
1277    CallCenterTelephony,
1278    /// Stable storage code for reviews.
1279    Reviews,
1280    /// Stable storage code for email sms marketing.
1281    EmailSmsMarketing,
1282    /// Stable storage code for business intelligence.
1283    BusinessIntelligence,
1284    /// Stable storage code for data lake.
1285    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});