Skip to main content

storage/
operations.rs

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